-
-
Notifications
You must be signed in to change notification settings - Fork 137
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Desynchronization downstream of combine #309
Comments
Here's something unexpected. I wanted to see if it had to do with the complete signals from const a = xs.never().startWith('a');
const b = xs.never().startWith('b');
const ab = ... // Same from here This alternative works as expected: xy settles on 'true' after every tick of either a or b (apart from the mildly annoying intermediate 'false' value it emits, but that's a separate issue and can be solved with debounce(0)) const a = xs.periodic(2000);
const b = xs.periodic(3000);
const ab = ... // Same from here |
I've found that adding |
Hey @CarlLeth! I didn't read everything, because I'm quite confident this is a "glitch". It looks like the typical "diamond shaped" case of two synchronous streams (or in general, two streams that emit at the "same" time) being combined. I wrote two blog posts about this (about RxJS but applicable to xstream too): Read those carefully, and if you still have comments, let me know. |
@staltz This is not a case of a transient, intermediate value being replaced by the correct value. The streams in the example never emit the correct value. |
@staltz those were good articles and I agree with the majority of what you said. However, I don't think they apply here. I think this is a bigger problem than you're thinking it is. I'm glad I was able to find the delay(0) workaround, because even though the preconditions are perhaps unusual, this was a show-stopping bug for my project. Some important points about this functionality: 1. It violates basic invariants in reactive programmingLet's say I had this function: function doubleStream(numbers: Stream<number>) {
return numbers.map(x => x * 2);
} I hope you agree it's reasonable to expect that (assuming subscriptions are running) whenever 2. There are legit reasons to create new information and objects within a map functionI have lots of types like this (over-simplified for the example): class Button {
clicks: Stream<MouseEvent>;
html: Stream<string>;
constructor() { /*... */ }
}
class TextBox {
updates: Stream<string>;
html: Stream<string>;
constructor() { /*... */ }
}
const addNewRecordButton = new Button();
const newRecords = addNewRecordButton.clicks.map(o => (records: Array<any>) => records.concat([{
id: createGuid(),
name: new TextBox()
}])); Later on I merge all the Again: this is not about glitches. I really don't care how many times |
I belive everthying works as expected. You can add this: const ab = xs
.combine(a, b)
.map(([a, b]) => Math.random())
.debug('ab')
.concat(xs.never())
.remember() With this you make |
The promise of reactive programming is that your program becomes much more predictable and less susceptible to incorrect behavior caused by unexpected sequences of events. The promise of functional programming is that you can think mathematically, writing nearly "self-proving" and "obviously correct" programs. In neither of those worlds does it seem reasonable to me to say that this "works as expected". I understand that if you pull off the covers and trace how the subscription actually happens, it's doing what you expect. But I wrote a simple statement that But it's obviously your library and so far it's been the best reactive library I've found so it's hard for me to be too critical. Thanks for considering it. |
@CarlLeth I read the examples, and this still looks to me like a glitch. I've seen many reactive programming libraries throughout the years ( Glitches as such are, as you said, not mathematically pleasing, and break our ability to reason about code. There are two ways of fixing them: (1) make the library free of glitches. This usually involves heavyweight techniques such as keeping a directed-acyclic graph-like structure that keeps track of all streams that "want" to emit, and then using a custom scheduler that does a breadth-first search through that graph and guarantees that glitches don't occur. I've seen this kind of implementation in (2) use the reactive programming library in a different way, often by modelling the variables in your application in a different way, making sure you Solution (1) is a huge undertaking, would require rethinking xstream entirely, and in the end would be not as performant as today. Solution (2) requires changing your code style to adhere to a reactive programming code style, but once learned you can forget about this category of problems, and keep the performance. Note that solution (2) is what all these libraries decided to commit to: RxJS, most.js, xstream. |
@staltz Thank you for the thoughtful response. I agree that a glitch-free library is based on a very different philosophy and I certainly don't expect a rethink of xstream. I still feel like this case is somehow different from a "normal" glitch, in that it's more about what happens during a subscribe ("backward") than how an emission is handled ("forward"). It seems like the values that propagate down the chain during the initial subscribe event are somehow cheating, or at least playing by different rules than values that propagate because they were emitted. Under the normal rules, I'll consider your advice in point (2). I'm not convinced I can achieve the level of abstraction I want if every module needs to know whether it's allowed to combine two of its input streams or not, but I'll keep trying. Thanks for the discussion. |
I believe there is no canonical definition of reactive programming but mostly it is referred to as programming with asynchronous data streams. Your example is synchronous (it happens all in one JS-engine tick) and you should consider the specifics of the platform:
So you just should understand how things actually work. |
I can rephrase that statement: "So every module of code you write should understand exactly how every stream that gets passed in is constructed, since that will affect its behavior". That breaks the entire point of abstraction. Say I wrote a function: function addSeven(x: number) {
return x + 7;
} I could reasonably complain if this function sometimes works and sometimes doesn't, depending on whether And I would say: okay, well, this function has no way to check the bit-parity of the mantissa of the pseudo-binary representation, and besides, that's way outside of the scope of what So when I write function addSeven(numbers: Stream<number>) {
return numbers.map(x => x +7);
} and ask: why doesn't this always work? Why can And I say: okay, well, this function has no way to check if the I understand the technical reasons why it's occurring in this case. But it is a flaw. It may be a theoretically necessary flaw, one that's not feasible to fix without causing other problems, and/or one you can work around if you completely change your coding style, but it's still a flaw. Don't act like this is some sort of stupid question. |
Actually this problem would go away if a MemoryStream simply gave the last value it emitted to any new subscribers on subscription, right? Why doesn't it? Why does |
If you know/understand how things work they always will be working that way, if you don't they sometimes maybe working in unknown/unpredictable fashion. -)
Because |
So the question boils down to: why does a MemoryStream forget its value when it receives a complete signal? That actually seems like the fundamental confusion point in a lot of these issues. |
When hot stream completes - that means it's dead, it's over, it ain't going any further, nothing can "resume" completed stream, just restart all over again. |
But isn't restarting a stream and replaying past events firmly in "cold" territory? If I subscribe to a MemoryStream, why is it "colder" to say "7, and don't expect any more values" instead of "oh hold on let me go run all the numbers again"? Besides, isn't MemoryStream forced to sit right on the boundary between hot and cold anyway? What's the actual logical reason why you wouldn't simply regurgitate the last value and complete? |
I have an issue where I combine two streams, then make a new object. I then branch from that stream into two others, and it's important that the values in the two streams must originate from the same object. This has to do with the other libraries I'm using, but I also think it's the expected and intended behavior. Instead, the two streams end up looking at different objects because they've received different signals.
A simple illustration of the problem is below. Note that
Math.random()
is a stand-in for creating a new object that the signals in streams x and y both must originate from. As an example of why this is important, consider if the object is a new data entity that comes with a randomly-generated UUID; clearly x and y must agree on what that UUID is.Expected behavior:
Actual behavior:
The text was updated successfully, but these errors were encountered: