Different results from the publisher in Combine - bug?

Hello,
Let's imagine we would like to have a pipeline which will print something in case there is a negative value after previously encountered zero by using following code:

let nums = PassthroughSubject<Int, Never>()

let hasZero = nums
    .scan(false) { $0 || $1 == 0 }
    .print("1:")

let negatives = nums
    .print("2:")
    .filter { $0 < 0 }
    .print("3:")

let combine = negatives
    .combineLatest(hasZero)
    .print("4:")
    .sink { negative, afterZero in
        if afterZero {
            print("negative \(negative) after 0")
        }
    }
...
nums.send(0)
nums.send(-10)

I cannot explain it myself but most of the time negative -10 after 0 is printed twice instead of once. Is it a bug? I think so because it's weird to have different result from the same pipeline.

Example of valid (or what I'm considered to be valid) output:

2:: receive subscription: (PassthroughSubject)
3:: receive subscription: (Print)
1:: receive subscription: (PassthroughSubject)
4:: receive subscription: (CombineLatest)
4:: request unlimited
3:: request unlimited
2:: request unlimited
1:: request unlimited
1:: receive value: (true)
2:: receive value: (0)
2:: request max: (1) (synchronous)
1:: receive value: (true)
2:: receive value: (-10)
3:: receive value: (-10)
4:: receive value: ((-10, true))
negative -10 after 0

Example of invalid output:

2:: receive subscription: (PassthroughSubject)
3:: receive subscription: (Print)
1:: receive subscription: (PassthroughSubject)
4:: receive subscription: (CombineLatest)
4:: request unlimited
3:: request unlimited
2:: request unlimited
1:: request unlimited
2:: receive value: (0)
2:: request max: (1) (synchronous)
1:: receive value: (true)
2:: receive value: (-10)
3:: receive value: (-10)
4:: receive value: ((-10, true))
negative -10 after 0
1:: receive value: (true)
4:: receive value: ((-10, true))
negative -10 after 0

It's not a bug, this is expected behaviour. The combineLatest keeps a buffer with the latest value from each of the publishers it has combined and publishes whenever any of the two sends a new value, effectively combining it with the current or "old" value of the other one. This can be seen from the docs here Apple Developer Documentation and in particular if you take a look at their example.

In your construction, it's just the case that in the first situation the 0 gets to hasZero first but then the -10 gets to negatives first. At that point combine publishes the valid tuple (-10, 0) and prints. Later, the -10 gets to hasZero itself and as per the specification of combineLates discussed in the last paragraph it again publishes (-10, true) because this is just the new value which in this construction just happens to equal the old one.

The final note is that in this particular situation Combine doesn't give you any guarantees as to what the ordering of publishing of hasZero and negatives. Therefore both kinds of output are possible output and very much valid.

For a more clear example of what I'm saying consider the slight modification:

let nums = PassthroughSubject<Int, Never>()

let howManyTimesSinceZeroInclusive = nums
    .scan(0) { initialResult, nextPartial in
        if initialResult > 0 || nextPartial == 0 {
            return initialResult + 1
        } else {
            return initialResult
        }
    }
    .print("1:")

let negatives = nums
    .print("2:")
    .filter { $0 < 0 }
    .print("3:")

let combine = negatives
    .combineLatest(howManyTimesSinceZeroInclusive)
    .print("4:")
    .sink { negative, afterZero in
        if afterZero >= 1 {
            print("negative \(negative) after 0")
        }
    }

nums.send(0)
nums.send(-10)

which can print

2:: receive subscription: (PassthroughSubject)
3:: receive subscription: (Print)
1:: receive subscription: (PassthroughSubject)
4:: receive subscription: (CombineLatest)
4:: request unlimited
3:: request unlimited
2:: request unlimited
1:: request unlimited
2:: receive value: (0)
2:: request max: (1) (synchronous)
1:: receive value: (1)
2:: receive value: (-10)
3:: receive value: (-10)
4:: receive value: ((-10, 1))
negative -10 after 0
1:: receive value: (2)
4:: receive value: ((-10, 2))
negative -10 after 0

You're saying that typle (-10, 0) (I think you meant (-10, false) here) is received and prints, but it's not true for two reasons:

  1. sink does/should not print in case afterZero is false
  2. from the logs you can see that combineLatest is actually receiving (-10, true) two times (but not always)

Combine doesn't give you any guarantees as to what the ordering of publishing of hasZero and negatives

I'm not expecting particular order when I'm sending one value (e.g. only for nums.send(0)) but I'm expecting that both hasZero and negatives will handle nums.send(0) first, and nums.send(-10) afterwards. Am I'm missing something here?

afterZero is always true in your construction because:

  1. on the first 0 $0 = false and $1 = 1 so indeed $0 || $1 == 0 evaluates to true and scan retains initialResult = true

  2. on publishing any subsequent values, scan has $0 = true (again because it retains the results of previous events) and so $0 || $1 == 0 trivially evaluates to true

With regards to the order, the ordering scenario which I described combined with the consideration that afterZero=true which we established gives you the required output

Now I get it that -10 is handled two times by hasZero and negatives so combineLatest should publish (-10, true) two times. Am I right? So expected result is two prints. In this case my question would be why sometimes it's only printed once?

P.S. looks like in case negatives/filter receives value first, combineLatest will receive two values, but in case hasZero/scan receives value first, combineLatest will receive (-10, true) tuple only once. Feels weird, is it actually expected? The only reason I've created this topic is because I didn't expect different results on different runs.

Now I've got it :smiley: Thank you for helping me out with a reasoning, it's really hard to follow reactive stuff
In the end in my simplified example logic is not the same as it is in a real code, see my next comment

I actually checked my code where I had an issue and looks like I simplified it wrong.
What I have is an event bus and it creates sort of a cycle. Similar to:

enum Events {
	case one
	case two
	case three
}

let bus = PassthroughSubject<Events, Never>()

let first = bus
	.filter { $0 == .one }
	.sink { event in
		bus.send(.two)
	}

let second = bus
	.filter { $0 == .two }
	.sink { event in
		bus.send(.three)
	}

let all = bus
	.sink { event in
		print("\(event)")
	}

bus.send(.one)

which can print events in any possible order like one, two, three or three, one, two.

Out of curiosity I tried to recreate similar functionality in RxSwift and ReactiveSwift.
ReactiveSwift crashes in this case (or I did something wrong...).
RxSwift gave me same order three, two, one multiple times but I doubt one should rely on that because what RxSwift is also doing is displaying a diagnostic message which is really cool in my opinion:

⚠️ Reentrancy anomaly was detected.
  > Debugging: To debug this issue you can set a breakpoint in /Users/dratkevich/Downloads/RxSwift-main/RxSwift/Rx.swift:96 and observe the call stack.
  > Problem: This behavior is breaking the observable sequence grammar. `next (error | completed)?`
    This behavior breaks the grammar because there is overlapping between sequence events.
    Observable sequence is trying to send an event before sending of previous event has finished.
  > Interpretation: This could mean that there is some kind of unexpected cyclic dependency in your code,
    or that the system is not behaving in the expected way.
  > Remedy: If this is the expected behavior this message can be suppressed by adding `.observe(on:MainScheduler.asyncInstance)`
    or by enqueuing sequence events in some other way.

So personally I like that both RxSwift and ReactiveSwift are communicating somehow that something is off. In my opinion Combine could also benefit from a diagnostic message similar to one that RxSwift has.

Terms of Service

Privacy Policy

Cookie Policy