withLatestFrom *only* takes immediate values it does not wait for deferred events from the `second` observable
geoffmacd opened this issue · comments
Short description of the issue:
If you use withLatestFrom()
and the second
observable is delayed, after getting 1 event from the first
observable, the result will silently fail and not emit another event until the next first
event. There is an early return that does nothing (no event of any kind) which, to me, was super unexpected and caused a timing-related bug.
Expected outcome:
I expect that withLatestFrom
should return some kind of event if the second
observable does not have immediate values upon subscription but has deferred events. It should not just be ignored and returned early as the code does in
What actually happens:
In my case, we have something like this:
func enableSomeFeatureDependingOnDiskSpace(enabled: Observable<Bool>, diskSpaceAcceptable: Observable<Bool>) {
enabled
.withLatestFrom(diskSpaceAcceptable) { ($0, $1) }
.subscribe(onNext: { ...do a bunch of stuff...})
.dispose(by: disposeBag)
}
When I changed the logic in diskSpaceAcceptable
to asynchronously generate the first event (takes a while), I had reasonably expected that the withLatestFrom
functionality would wait until there was a value before emitting the combined values. This does not happen due to this early return
Event
at all, not a next
/error
or completion
. This means that values from enabled
were being ignored and this code would be stuck here and never generated an event. Only if the diskSpaceAcceptable
had immediate values would this return anything.
I do not believe this should silently do nothing and cause race conditions as it did for us. I think withLatestFrom
should either a) wait until a value is generated from second
OR b) throw an error (I'd prefer A although it will change default behavior). I think
Self contained code example that reproduces the issue:
modified example of the testWithLatestFrom_Simple1
illustrating what I mean by the deferred second
observable event:
func testWithLatestFrom_Simple1() {
let scheduler = TestScheduler(initialClock: 0)
let xs = scheduler.createHotObservable([
.next(90, 1),
.next(180, 2),
.next(250, 3),
.completed(590)
])
let ys = scheduler.createHotObservable([
.next(255, "bar"),
.completed(400)
])
let res = scheduler.start {
xs.withLatestFrom(ys) { x, y in "\(x)\(y)" }
}
XCTAssertEqual(res.events, [
.next(255, "3bar"), // <-- should give me the "latest value" and not just ignored "bar". This fails
.completed(590)
])
XCTAssertEqual(xs.subscriptions, [
Subscription(200, 590)
])
XCTAssertEqual(ys.subscriptions, [
Subscription(200, 400)
])
}
RxSwift/RxCocoa/RxBlocking/RxTest version/commit
version or commit here
Platform/Environment
- all platforms
How easy is to reproduce? (chances of successful reproduce after running the self contained code)
- easy, 100% repro
- sometimes, 10%-100%
- hard, 2% - 10%
- extremely hard, %0 - 2%
Xcode version:
all
Level of RxSwift knowledge:
(this is so we can understand your level of knowledge
and formulate the response in an appropriate manner)
- just starting
- I have a small code base
- I have a significant code base
The withLatestFrom operator has basically the same behavior as combineLatest in this case. If there is no latest, there's nothing to return.
The solution is for you to start(with:)
on the second operator so your "sentinel value" emits if the second observable hasn't emitted a value yet.
It is not correct to 'startsWith' in this case. There is a need here to truly not have any value/event for highly asynchronous code as I suggested in my use case. Starting with some default value would be the wrong behavior and lead to other issues.
I suppose my disagreement is in the api contract with the word "latest". I would not expect that this would silently break/cause a race condition if there is no "latest" value. Throwing an error at least or allowing a different behavior is preferable to this situation.
Hmm... Well, not only would your suggestion be a breaking change, but it would also be contrary to the way withLatestFrom
is implemented in other Rx implementations (Rx.js for ex.)
So, as I understand it, you want the second
observable to emit immediately if the first
observable has emitted something... If the first has emitted two values before second emits its first value, would you want the first two values of second to emit immediately when they come in?
It sounds like, you would be better served with a buffer(boundary:)
implementation like the one here on line 143. To use it, you would do something more like second.buffer(boundary: first)
.
With it, every time first
emitted, the observable would emit all the values that second
emitted since the previous emission of first
.
withLatestFrom doesn't wait or fire on changes for "second", it pulls a "current view" of the "second" and if there is no value there it will not do anything (and the stream will be "stuck"). This isn't a bug but planned behavior.
What you want seems more like combineLatest indeed.
Lastly @geoffmacd ... If you want to describe the exact behavior you want, I'm happy to work with you and make a custom operator. It would be best to do this on the Slack channel rather than here though I think...
After looking at other Rx impls, this current implementation is correct it seems https://rxmarbles.com/#withLatestFrom. So I agree now that it shouldn't be changed.
For the record, I made a change in this diff anyway to keep backwards compatibility but add the behavior I expected with a new behavior
similar to other operators. See the tests for the withLatestFrom(..., behavior: .deferred)
functionality I had expected. I'm not going to PR this but wondering if this adds values for other devs. combineLatest
is also not quite what I want.
Here is your operator without having to maintain your own branch of RxSwift:
extension ObservableType {
func withDeferredLatestFrom<Source>(_ second: Source) -> Observable<Source.Element>
where Source: ObservableConvertibleType
{
Observable.create { observer in
var triggered = false
var latest = Source.Element?.none
let lock = NSRecursiveLock()
let source = second.asObservable().subscribe { event in
lock.lock(); defer { lock.unlock() }
switch event {
case let .next(element):
latest = element
if triggered {
observer.onNext(element)
triggered = false
}
case let .error(error):
observer.onError(error)
case .completed:
break
}
}
let trigger = self.subscribe { event in
lock.lock(); defer { lock.unlock() }
switch event {
case .next:
if let element = latest {
observer.onNext(element)
triggered = false
} else {
triggered = true
}
case let .error(error):
observer.onError(error)
case .completed:
observer.onCompleted()
}
}
return CompositeDisposable(source, trigger)
}
}
}
Let's think this through though. Are you sure you want the Observable to abruptly stop if the source completes, but the operator is still waiting for a value from second
or should the operator wait and emit a last value from second before completing?
This seems safe to close now...