Show HN: Cancelable async primitives for JavaScript
github.com
github.com
Short answer: observables do too much, and Rx is WAY, WAAAAY too large. We need simple async primitives before layering bigger abstractions on them. They shouldn't take encyclopedic amount of reading to learn. They need to come in a library that doesn't weigh over 100 KB.
A well-designed tool should minimise the amount of states the program can be in. Asynchronous programming is already horrifically bad, it generates combinatorial explosions of intermediary states. Adding imperative, mutative programming and an event-driven API only exacerbates the problem, increasing the amount of possible state sequences even further. I don't think xtream provides a good API.
Also, implementing an observable from scratch is pretty much trivial. The operators are where all of the logic are, and they are arguably simpler to understand than alternatives because of how well documented they are. And if you limit yourself to the operators that are equivalent to what this library provides, there really wouldn't be much complexity to it.
There are also different types of observables to consider. Rx, xtream, Most.js, they're basically stream libraries. When doing GUI programming, it's more useful to have reactive units with a _synchronous_ data access, because it matches how you want to access the data when drawing your view (e.g. in React). I ended using observables that look like Clojure's atoms (see Espo: [2]), with a special adapter for React (see Prax: [3]). Observable libraries like Rx were useless for that use case.
[1] Example of decent future/stream design: https://tokio.rs
[2] Observables focused on synchronous access: https://mitranim.com/espo/#-atom-value-
[3] React adapter for implicit reactivity, based on synchronous observables (impossible with async streams): https://mitranim.com/prax/
Level 0: one-value operations (promises = futures).
Level 1: multi-value operations (streams = observables), implemented in terms of one-value operations. Tokio did it well for Rust (https://tokio.rs).
Starting at level 1 doesn't feel right to me.
Promise, Future, Observable = data structures
Bluebird, Fluture, RxJS Observable = implementation of the data structures
Operators (map/race/all) = helper implementations for operating the data structure implementations
Now, different implementations may make different choices, like there are dozens of Promise implementations tuned for either feature richness, speed, or small file size. Same holds true for observables, there are feature-rich libs like RxJS, but also fast and/or small implementations (Bacon, most.js, xstream).
My point: A cancelable promise is basically an observable that emits a single item (and caches that item). So an observable is kind of like a superset of a cancelable promise (and of course a non-cancelable promise). All operator implementations manipulating observables can directly be reused for cancelable and non cancelable promises-like observables; the other way around does NOT hold true.
My main point: Just use observables, they can do everything that promises and cancelable promises can do and, if needed, a lot more. So instead of layering, learn to use this one data structure and you will be able to cope with a lot of async troubles.
But observables are very different from promises at the core. Not only are they cached, but they're also eagers, while observables are not. The very minimum implementation of an observable is just a few lines of code. Promises...are much more complex. So I'm not sure how reusing operators between the two efficiently would work.
So yeah, just use observables. Most people think observables are a more complex, higher level abstraction with promises being the primitive, simple one. It isn't true though. Observables are much, MUCH simpler, and are perfectly suited to doing everything promises do, but better (lazy, optionally cached async primitive is way better).
I repeat my claim: Observables (at least as implemented by RxJS) are a superset of (cancelable-)promises. Superset means, they can do everything the exact same way as promises, and a lot more.
But why start with the superset? I don't think it's good design. Consider the perspective of a language designer. You want to start with primitives that are as simple as possible while satisfying a vast number of real use cases. One-short async primitives are an important intermediary step towards observables, it shouldn't be skipped. Observables should be implemented in terms of these.
This layered design gets you a smaller cognitive cost of entry (promises are hard enough as it is!) and higher efficiency for the vast number of cases that don't need stream-like functionality.
On top of that, one-shot primitives are conceptually compatible with blocking expressions in coroutines such as async/await, whereas streams are not.
I don't get it when people shoot for fat primitives that do it all.
Observables can do everything promises can do, but the construct itself, while more flexible, is significantly simpler. Like, -way- simpler. And a lot of what they do and how they do it comes from how they're implemented at the very base (essentially a generic observer pattern). Building observables on top of something else would add tons of overhead/complexity for no reason.
The observable needs to be the lowest level piece and we build the rest on top, not the other way around. That is counter intuitive since they can do so much more: the abstractions on top are more like specializations of the low level construct.
And note that with RxJS 5 you can only pick operators and data structures that you need; I have written a Redux-clone using RxJS [1] which uses Observable, Subject and a handful of operators and is less than 16kb as minified & gzipped in total as self-contained umd bundle (of which half the size is attributed to lodash helper functions).
Bundle size: a typical SPA imports multiple libraries, they import more libraries, and so on. It's not just Rx. I shouldn't have to explain how small things add up. Not caring about size is how it balloons up. We have to care about it in every library.
import "rxjs/add/operator/map"; import "rxjs/add/operator/reduce"; import "rxjs/add/operator/take"; import "rxjs/add/observable/of";
// etc. - this way you only get what you need into your bundle
If can make a suggestion--a cookbook of promise interop might be worth adding. There's a fundamental incompatibility, given the cancellation concerns you mention, so I'm sure it couldn't be 100% caveat-free, but it would still ease adoption given the ecosystem's current standardization on promises.
What do you think is worth adding or writing about?
On cursory inspection, looks like Fluture has multiple features that I rejected when designing Posterus: laziness/templating, separation of map/flatmap, and possibly others. They come at the cost of cognitive load and API surface.
In contrast, Posterus aims to be the simplest Promise replacement you could possibly come up with, filling the main missing features: cancelation and scheduling control.
Additional features come at a cost. Laziness & templating trips up unfamiliar developers. Separating map and flatmap doesn't make sense in a dynamic language; since you can't statically enforce it, it's just a tripmine with no benefit. Also, a large amount of utilities bloats the code size, which I care about very much. Posterus is designed to be as small as a typical promise polyfill, fitting into a size-constrained browser application.
Needless to say, Fluture has features you might want that don't exist in Posterus. I'm not familiar with it, so it's difficult to recomment any.
There must have been man-years of discussion on the Bluebird project around cancellation, but for me it all comes back to tokens as every promise baked-in solution feels a bit off. Golang effectively has them as well via the context package.
*As you say it is pretty easy to do, but it's nice to standardize on an implementation
We've had 4 cancellation proposals rejected so far (cancellation as rejection, third state, cancellable-promise and cancel-tokens).
They work in bluebird pretty well - but people have concerns.
Personally I use our cancellation if I already have bluebird, and tokens if I don't.
Basically, you can make it work pretty easily with promises and multicast (by reference counting).
The reason they're not added to promises is because some people feel they break a guarantee a promise makes. Not only is it possible - it works well.
Domenic is worn out from having to put up with all the involved shit (if you're reading this, sorry :D). He put a lot of work but I think he's tired of fighting for this rather than push other areas where he meets less resistance in the DOM.
I just wanted to point out that while cancellation is a whole world of complexity and is very interesting - it's not avoided in the language because we're stuck on the technical side.
Multicast cancelation based on refcounting doesn't look right to me. I explicitly avoided this in Posterus, sticking with exclusive ownership, what GTOR calls unicast (thanks for linking). The reason is that subscribers may come and go over time. I've seen, and written, code that caches a promise and reuses it many times. It may start with one consumer, lose it, be canceled, then another consumer comes and gets rejected. This just feels like a big footgun. In Posterus, multicast is explicit and opt-in (`.weak()` futures).
If we eventually standardise a solution, I'd like something with as few interfaces as possible. In a dynamic language, you don't have the luxury of creating a future that initialises into a task, returning a cancelation token. Too many types without static checking is just a footgun. This is part of why Posterus only has futures, why cancelation is provided in the future constructor and tied to the future itself, and why they're eager rather than lazy. Deviating from these constraints would introduce new types, increasing the footgun potential.
Here's an example: in Bluebird, cancelation doesn't
propagate upstream. After registering onCancel in a
promise constructor, you have to call .cancel() on
that exact promise object. Calling .cancel() in any
child promise created with .then() or .catch() will
not abort the work, rendering the feature useless for
the most common use case!
True cancelation must propagate upstream, prevent
all pending work, and immediately free resources and memory.
From the README As an optimization, the cancellation signal propagates
upwards the promise chain so that an ongoing operation e.g.
network request can be aborted.Unfortunately it's still not viable in the browser due to its size, promises too easily get converted into non-cancelables, and worst of all, async/await forces native promises. If you want cancelable coroutines, you're forced to roll a generator-based implementation such as what Posterus provides.
Kind of answered in another subthread: https://news.ycombinator.com/item?id=14963524
* Server. Request handler starts expensive work. Let's say 4-5 database requests, some FS operations, rendering and sending response. Client disconnects. Running those operations will waste resources, we should stop. [1]
* Web. You can make at most 6 concurrent HTTP requests. They're precious resources. Dropping a request you no longer need will let others complete. It's nice to be able to abstract a painful ajax API behind something like a promise that doesn't lose the important ability to abort.
* React. View instance starts async fetch that eventually updates its state. User navigates to another view before it finishes. Updating the state after the component is unmounted is an error, and React will rightly complain. We should stop it.
* Server: abstracting an operation that's already cancelable behind a future. Let's say using Electron to render a website into a PDF. It's a really expensive operation, and the API is a pain to program against. You want to provide something as simple as a promise, but that doesn't lose the ability to stop it. [2]
[1] I write servers using Posterus coroutines, so all async code is automatically owned and canceled where appropriate: https://github.com/Mitranim/koa-ring
[2] This relies on futures to abstract away tricky timing management: https://github.com/Mitranim/epdf/blob/1c54481d4760a7eb730eb3...
I actually had to write my way around non-cancellable promises quite a few times and my situation mirrors yours though mostly on the client:
I have a search page that updates results every time a new filter is updated (imagine you select a new tag to filter by, or narrow down the search somehow). This can be done faster than responses come back and can create a huge mess because some responses can come back faster than others.
This creates uncertainty in terms of what should be on the page. A few lines of code and it's fixed but the cool thing is that I get to throw away results that I don't care about and not process them (expensive-ish action).
This kind of situation happens somewhat often because a user can quickly navigate around, fire off a ton of requests and only really cares about the last one in the queue.
I haven't worked my way around it on a global scale which means that there could be a ton of requests being processed and thrown away right after for no good reason.
And in most cases this doesn't matter at all.
1) allow the user to cancel it explicitly
2) cancel it if the user selects something else (instead of firing a second query, and then having a race condition on which returns first)
Useful, yes. Absolutely. But I’ll wait on a standard spec first.
I’ve yet to see a clean implementation of this (bluebird comes closest though).
People gave a few examples in this subtopic: https://news.ycombinator.com/item?id=14962684
On the other side, JS indeed is ugly language with trash ecosystem (btw, how long it will take for community to get rid of fsevents warning on non-Mac systems?), but there are patterns forming and best practices being documented. It can be learned, but juniors should not expect learning a huge engineering discipline in a week.
They weren't that hard in Visual Basic 20 years ago, nor in any modern environment like Cocoa -- and they didn't need all the craziness and frantic over-engineering that goes on in JS frameworks.
The problem is everybody in the web rebuilds the whole UI from low level primitives. Need a wizard? Make one yourself. Need a form? Build it. No upfront structure to anything -- and the frameworks don't add enough either.
Never said they were. Just used them as two basic examples, that web UIs still overcomplicate and have problems with.
>You cannot build everything from a high level template. That's why the whole UX discipline exists.
The UX discipline exists for a totally orthogonal reason: to study, understand, and suggest improvements to the design of interfaces (and the resulting "user experience" with them), whether they are made with a "high level template" or not. You still need UX if you everything with the basic Cocoa controls or Windows standard widgets or whatever.
The kind of UIs you can do in native, beyond forms and wizards, the web can't even dream of. The inverse is not true (except if you do your whole thing in Canvas or WebGL which defeats the purpose).
var req = get("https://news.ycombinator.com")
req.onData = callbackFunction
if(condition) req.abort()
"Callback hell" can be avoided by using named functions and sub-functions.It seems to me that first time I really avoided cb hell is when I started using async/await.
var timeCreated = new Date();
button.onclick = function buttonClicked(clickEvent) {
var timeClicked = new Date();
console.log("Button was created " + (timeClicked-timeCreated) + "ms ago");
}
Lets say you have two functions: Jon from support, and Jane from billing. Both of them have access to the customer database.
Now a customer calls, the customer hits 1 for support and Jon gets the call, while Jon is on the phone, another customer calls, hits 2 for billing and Jane gets the call.You can have a very complex setup using just functions, and some variables to keep track of things, maybe an array for implementing a call queue. You can choose when to run in serial, and when to run in parallel, and make a limit on how many calls that can run at any given time. And every possible error handled and managed. Promises, async/await and futures are only leaky abstractions that will just complicate things. They look nice for simple examples without any error handling, but when you want to do real world stuff you have to think about how they work behind the scenes.
Not sure what you mean by "simple examples without error handling". Just like normal synchronous code, promises and coroutines use exceptions, which have the nice property of composability: one catch for several statements/promises. So error handling is the same as in synchronous code.
Now granted, there's a LOT of async/reactive problems where sequential abstractions like promises are irrelevant. GUI programming comes to mind, where you want the view to reflect some reactive state that changes in arbitrary order.
I'm curious about the naming choices though, since user friendliness seems to be one of the goals: why 'deinit' over 'cancel' and 'arrive' over 'resolve'?
`arrive` — no particular reason. It's one "errback" method rather than two methods like `resolve/reject`, so it needs to have a neutral tone. Not too happy with it, better suggestions are welcome.
[1] Basic implementation of automatic resource management in JS: https://mitranim.com/espo/#-agent-value-
As a corollary, are custom promises faster than native promises?
Even better, they come with generator-based coroutines that work with futures, and are automatically cancelable: https://github.com/Mitranim/posterus#routine
You can even run it with Koa 2 instead of async/await: https://github.com/Mitranim/koa-ring
Inhibitory neurons make it possible for you to walk down stairs without falling the whole way, so whatever trickster is voting down neurons, cut it out
People gave a few examples in this subtopic: https://news.ycombinator.com/item?id=14962684
Posterus coroutines are similar to async/await, but cancelable and free of `await`'s race condition problem (promise can get rejected before `await` attaches a handler).
Example with Posterus:
async function main() {
try {
await Future.fromError('fail')
}
catch (err) {
console.error('caught:', err)
}
}
In Node, this actually produces an unhandled rejection because Posterus's scheduler uses `process.nextTick` and squeezes into this unnecessary delay. Doesn't happen if you `.catch()` manually or just use Posterus coroutines instead of interoping with async/await, but it highlights the incorrect implementation of async/await in the first place. (Or is the spec at fault?)