raquo / Airstream

State propagation and event streams with mandatory ownership and no glitches

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Investigate Observable Completion

raquo opened this issue · comments

Currently Airstream has no concept of "completing" observables.

Completion is a special message emitted by an observable that notifies all observers – both internal and external – that the observable is done and will not produce any more events. Imagine onComplete() in addition to onNext(event) and onError(err).

This concept is a natural fit for event streams, but it needs to be adapted to signals. Unlike streams, Signals carry a current value, so for Signals completion also means that their current value will no longer change.

For example, Val-s would be completed on initialization. stream.take(1) would complete after one event. If Airstream had a take operator, that is. It doesn't, and I think we must at least have that much before we go all the way to implement completion.

The benefits of completion as a feature are not quite clear to me at the moment. I'm yet to see a real life pattern that requires completion. Which is not to say that such patterns don't exist, I just haven't run into them myself yet, or maybe I just tend to structure my code differently because I have Signals in Airstream. Not quite sure.

As for performance...

Completion will allow all child observables to remove themselves as internal observers from the completed observable. The completed observable can actually do this by itself without the completion feature, but what the completion feature allows is propagating this completion down the chain of observables – since an observable that only depends on completed observables is (generally) considered completed.

This chain reaction can result in early disposal of completed observables and subscriptions, which could potentially reduce memory usage (a subscription that looks at a completed observable can be killed without waiting for the owner to kill it).

One practical application where this could be useful is a pattern where an a parent component renders a dynamic list of children, and has an event bus that is sourced from streams generated by those children. With completion feature, we would complete the streams exposed by a child component when said child gets unmounted, the parent's event bus would be notified about that and would stop listening to this completed stream.

However, we already have a solution for this: eventBus.writer.addSource requires an Owner which will remove the source observable from event bus (e.g. when the component it belongs to is unmounted). Granted, this is a rather ad-hoc solution whereas completion would be a generalized solution to this problem.

But I'm not sure how useful completion is outside of this pattern. All the cases I ran into where I wished for completion for a performance gain involved firing one event before completing. That does not feel like much of an Observable to me, and I'm not sure if Observables should be optimized for what essentially is a singular callback pattern.

Is component.$mountEvents.collect { case ev: NodeDidMount => ev }.foreach(doStuff) all that better than a simple component.onMount(doStuff) callback? Is it such a big a problem that this stream does not stop after a NodeDidMount event was published (let's assume there will only ever be one such event)? Should this be solved differently, perhaps with a .once(doStuff), a new method that will unsubscibe the observer after one event?

Ultimately, observable completion is a big feature, and has complex interactions with other features. Without a clear need, it will remain unimplemented for now. I just wanted to get this out of my head.

More thoughts on observable completion:

  • Observable completion notifies downstream observers that there will be no future updates (until the observable is stopped and started again). What can we do with this information?

    • Direct external observers can be unsubscribed, freeing up a bit of memory
    • Direct internal observers can be ALSO completed IF all of their parent observables have completed
      • This is how completion can be auto-propagated
      • e.g. if AjaxEventStream() has completed, everything else that depends on it and (on nothing else) can be completed as well, and every external observer that listens to any of that can be unsubscribed.
  • Stopping an observable

    • Stopping an observable means removing it from the parent's list of internal observers
    • Can we stop the completed observable?
      • Probably, if we make it part of the contract
      • Could you stop AjaxEventStream() after propagating its completion? Yes. It doesn't depend on any observable.
      • Could you stop parent.take(5) once it takes five elements? Yes. We don't need any more updates from the parent.
    • But what about restarting, e.g. in Laminar, after the element has unmounted and then mounted again?
      • If parent.take(5) is bound to the element being re-mounted, that subscription will be killed on complete (or on unmount if the stream hasn't completed), and then a new equivalent subscription will be created on subsequent mount, which will re-add parent.take(5) to parent as internal observer (until the next time it emits 5 elements).
  • Emitting after completion

    • This should not be possible. Therefore, we must have some kind of isComplete flag to prevent that.
    • Not sure if this is different from isStarted flag
  • Signals

    • Unlike streams, signals remember their current value, including after completion. However that value shouldn't change, just like it shouldn't change while the signal is stopped.
    • A completed signal's value is considered to be merely finalized, not necessarily stale.
  • Errors

    • Unlike other libraries, errors do not result in automatic completion of an observable
  • Adding subscribers after completion

    • Normally, adding observers to an observable starts it. If a completed observable is considered stopped, then... this is indeed what would need to happen.
    • And well otherwise, there would be no way to re-start a stream once it's completed.
    • Remember that Laminar uses dynamic subscriptions, so new subscribers will be created on every mount for any subscription that is bound to an element
  • Timing

    • If an observable is created and is immediately completed (e.g. Val(1) or EventStream.fromValue(1)), will its observers be notified in the correct order? First onNext, then onComplete?
    • What about transaction propagation stuff? Like all those complications we had with Var-s. Is onComplete propagation fully the same as onNext in terms of timing?
      • This could be an issue if we could complete a Var I guess
      • Also if we could complete... well, anything. Even just a stream created with withCallback, I think.
  • Special cases to hash out

    • subscriptions owned by unsafeWindowOwner
      • Unlike Laminar element dynamic subs, these won't be re-subscribed. Is that ok?
      • Prooobably?
    • subscriptions owned by manual owners
      • Unlike Laminar element dynamic subs, these won't be re-subscribed. Is that ok?
    • subscriptions owned by elements with different lifecycles (e.g. parent looking at child's completed stream, or vice versa, or even siblings)
    • merge
      • complete when all of the parents are complete
    • combine
      • complete when all of the parents are complete
    • sample / withCurrentValueOf
      • even though this has two parents, they're not the same as combine
      • combine is not complete until both parents are complete
      • but sample / withCurrentValueOf can be complete if only one of the parents is complete (I think?)
    • flatten
      • complete when the switching parent is complete
    • SignalViewer
    • EventBus
      • never complete... I think? Unless we want to make it manually complete-able? Why though?
    • Var
      • should Var-s be complete-able?
    • Val
      • immediately complete
    • from* observable constructors
      • the future ones should be complete... synchronously or asynchronously?
    • withCallback constructors
      • add API to allow completion? But how? It's EventBus-based
    • custom sources
      • add API to allow completion?
  • Plan the required functionality

    • What kind of new concepts, methods, classes, etc. we will need to make good use of this feature
    • Would that be an acceptable expansion of the API? Will it add a lot of bundle size if not used?
    • Do we need some kind of neverComplete operator? If yes, there needs to be a dam good reason because it's ugly af.

This is not going to make it into 0.15.0. I'm still struggling to find a good solution for the problem. The problem is not as much technical, but mostly conceptual – if the observable is allowed to emit after it was completed then is stopped and started again, then completion does not mean much, and does not allow for any optimizations – all we'd get from that is a concat method. On the other hand, if observables can only complete once, that would allow some optimizations, but would be of very limited use, as you wouldn't be able to restart such an observable when remounting the component. Maybe that would be ok, but I find it hard to judge that due to apparent lack of real world use cases requiring this feature.

It seems that most people don't care about this feature, and of those who mentioned who mentioned wanting this, most solved the problem by using EventStream.merge, so in addition to implementation / design challenges, I'm just lacking the motivation to work on this. This issue will remain open for discussion. Feel free to comment with your use cases and patterns that you'd like to use observable completion for.