2

Can someone explain why the below test fails?

public class ObservableTest {
    @Test
    public void badObservableUsedTwiceDoesNotEmitToSecondConsumer() {
        // Any simpler observable makes the test pass
        Observable<Integer> badObservable = Observable.just(1)
                .zipWith(Observable.just(2), (one, two) -> Observable.just(3))
                .flatMap(observable -> observable);

        ObservableCalculator calc1 = new ObservableCalculator(badObservable);
        ObservableCalculator calc2 = new ObservableCalculator(badObservable);

        // zipping causes the failure
        // Calling calculate().toBlocking().subscribe() on each calc passes
        // Observable.from(listOfCalcs).flatMap(calc -> calc.calculate()) passes
        Observable.zip(ImmutableList.of(calc1.calculate(), calc2.calculate()), results -> results)
                .toBlocking()
                .subscribe();

        assertThat(calc1.hasCalculated).isTrue();
        assertThat(calc2.hasCalculated).isTrue(); // this fails
    }

    private static class ObservableCalculator {
        private final Observable<?> observable;

        public boolean hasCalculated = false;

        public ObservableCalculator(Observable<?> observable) {
            this.observable = observable;
        }

        public Observable<Void> calculate() {
            return observable.concatMap(o -> {
                hasCalculated = true;
                // returning Observable.just(null) makes the test pass
                return Observable.empty();
            });
        }
    }
}

I've tried to simplify the "bad" observable further, but can't find anything I can remove to make it simpler.

My current understanding, though, is that it's an Observable which (regardless of how it's constructed), should emit a single value and then complete. We then make two similar instances of an object based on that Observable, and call a method on those objects which consumes the Observable, makes a note of having done so, and then returns Observable.empty().

Can anyone explain why using this observable causes the test the fail (when using a simpler observable causes the test to pass)?

It's also possible to make the test pass by either serially calling calculate().toBlocking().subscribe() rather than using zip, or making calculate return Observable.just(null) instead. That makes some sense to me (zip won't subscribe to calc2 if calc1 is empty, since it in that case zip could never yield anything), but not complete sense (I don't understand why zip doesn't behave like that for a simpler version of badObservable - the calculate() methods still return empty, regardless of that input).

Rowan
  • 2,585
  • 4
  • 24
  • 34

1 Answers1

1

If you zip an empty source with something, the operator detects it can't produce any value anymore and unsubscribes from all of its sources. There is a mix of zip and merge involved and merge takes unsubscription seriously: it doesn't emit the value 3 at all thus concatMap doesn't call the mapping function for the second source either.

akarnokd
  • 69,132
  • 14
  • 157
  • 192
  • Thanks. Still not sure I understand. So you're saying zip subscribes to the first calc observable (which means concatMap's lambda is executed), realises it's empty, and so unsubscribes from calc2 before it's ever subscribed? If so, why does calc2's lambda get executed if the source observable is a simple Observable.just(1)? When you say "there is a mix of zip and merge," can you explain where the merge is? – Rowan Dec 16 '15 at 08:46
  • `merge` is in `flatMap(v -> v)`. Yes, you can unsubscribe before even subscribing because `Subscriber` is also a `Subscription`. Calling `unsubscribe()` on a `Subscriber` before calling `Observable.subscribe()` with it is effectively unsubscribing immediately. Your second `zip` boils down to the following code: `zip(Observable.empty(), Observable.empty(), v -> v).toBlocking().subscribe()` which is empty or `zip(just(null), just(null), v -> v)` which has one single value. – akarnokd Dec 16 '15 at 09:15
  • Okay, so if it's `merge` that's sensitive to `unsubscribe()`, and it is in `flatMap`, then why does the test pass if `badObservable = Observable.just(1).flatMap(Observable::just)`? It still has a `flatMap`, so still has a `merge`, which will still be unsubscribed from the remaining `zip` (which sees an `Observable.empty()`), so what's different? – Rowan Dec 17 '15 at 22:12
  • That emits a single value while your original doesn't. If the first source to zip emits a value, zip will not unsubscribe the others and thus the second source has chance to emit. – akarnokd Dec 18 '15 at 08:18
  • Is that true? As far as I can see, both forms of `badObservable` (one with both `zip` and `flatMap`, and one with only `flatMap`) emit a single value. Also, from what I understand, the `ObservableCalculator.calculate()` method (which is fed in to the second `zip`) will _always_ return `Object.empty()`, regardless of what `badObservable` is. So I'd expect the second `zip` to always unsubscribe the second source, but some forms of `badObservable` don't seem to care. – Rowan Dec 18 '15 at 09:39