Alternative Promise cancellation proposal
Bergi
a.d.bergi at web.de
Mon Jul 25 21:32:43 UTC 2016
I am proposing a new approach for promise cancellation:
<github.com/bergus/promise-cancellation>
The major points are
* cancellation capability is separated from result promise through
`CancelToken`s
* targets of cancellation requests are made explicit by passing tokens
* no downward propagation of cancellation as a promise result
* promises are made cancellable by associating a token to them at their
creation
* promises get cancelled directly when `cancel()` for their associated
token is called
* promises can still be cancelled after being resolved to another
pending promise
* promises propagate their associated token to assimilated thenables
* callbacks are made cancellable ("removable") by passing a token to `then`
* callbacks get cancelled ("ignored") immediately when the respective
cancellation is requested
This has a few merits:
* simple, powerful and backwards-compatible semantics
* the subscriber is in charge of cancellation of callbacks, not the promise
* good integration with `async`/`await`
* support for potential (userland) `Task` implementations
An important functionality, the [race between cancellation and normal
promise
resolution](https://github.com/bergus/promise-cancellation/blob/master/trifurcation.md)
and the distinction of the outcomes, is available as a separate helper
method.
(I'm still looking for a good name
<https://github.com/bergus/promise-cancellation/issues/4>).
It could be implemented by user code in terms of `promise.then` and
`token.subscribe`, but that's cumbersome, error-prone and inconvenient.
Now let code speak for itself:
```js
function example(token) {
return new Promise(resolve => {
const resolveUnlessCancelled = token.subscribeOrcall(() => {
clearTimeout(timer);
}, resolve);
const timer = setTimeout(resolveUnlessCancelled, 500)
}, token)
.then(() => cancellableAction(token), token)
.then(uncancellableAction, token);
}
const {token, cancel} = CancelToken.source();
setTimeout(cancel, 1000);
example(token)
.trifurcate(result => console.log(result),
error => console.error(error),
reason => console.log("cancelled because", reason));
```
Of course any new cancellation proposal has to contrasted with Domenic's
current one <https://github.com/domenic/cancelable-promise>.
It's core semantical primitive is the race between fulfillment,
rejection and cancellation, for which a new promise state is introduced.
**TL;DR: My approach is fundamentally different, and - obviously - I
believe it's better.**
(You may stop reading here if you didn't know Domenics approach, the
rest of this is only discussion)
For quick comparison, the same thing as above would look like this:
```js
function example(token) {
return new Promise((resolve, reject, cancel) => {
let timer = setTimeout(() => {
timer = undefined;
resolve();
}, 500);
token.promise.then(reason => {
if (timer !== undefined) {
cancel(reason);
clearTimeout(timer);
}
});
})
.then(() => {
token.cancelIfRequested();
return cancellableAction(token);
})
.then(res => {
token.cancelIfRequested();
return uncancellableAction(res)
.then(res => {
token.cancelIfRequested();
return res;
});
});
}
const {token, cancel} = CancelToken.source();
setTimeout(cancel, 1000);
example(token)
.then(result => console.log(result),
error => console.error(error),
reason => console.log("cancelled because", reason));
```
Here's what I'm doing different from Domenic:
* Cancellation is no promise result that propagates downwards.
Signalling cancellation through the token is enough to drop work.
* There is no third `then` callback to avoid any incompatibilities
with popular legacy implementation that already use one.
* There is no third promise state affecting how `then` behaves
to preserve compatibility with Promises/A+ semantics and
prevent issues with assimilation.
* There is no new synchronous "cancel" abrupt completion type
to make it easy to polyfill without requiring a transpiler.
* There is no need to call `token.cancelIfRequested()` or wrap the
callback body in `if (!token.requested)` to prevent it from running
when cancellation is requested, instead you pass the `token` as the
last argument to `then`.
* There is no `.cancelIfRequested` method on tokens
which is unnecessary without a new completion type.
Some new ideas which I have incorporated in my proposal but don't feel
strongly about:
* `cancel()` result allows the canceller to handle errors from
the cancellation phase
<https://github.com/bergus/promise-cancellation/issues/9>
* `CancelToken`s have no `.promise` which never rejects
but just a `.subscribe` method instead of `.promise.then`
<https://github.com/domenic/cancelable-promise/issues/30>
There are three fundamental differences between Domenics approach and
mine (covered in detail below):
* a promise can be cancelled after being resolved to another promise
* cancellation is not a result value - a promise is cancelled (only
and directly) by the token, not by the operation
* cancelled promises are rejected
The first major difference is how tokens affect promises. Instead of
having an additional resolution type, when a promise is created a token
can be associated to it. If there is no token, the promise is not
cancellable.
If there is one, it will cancel the promise at any point of time until
it is settled, even after it is resolved.
With my proposal, when doing
```js
const promise = uncancellableActionA().then(uncancellableActionB, token)
```
the `promise` is cancelled exactly at the same time as the `token` is
cancelled.
If that happens during action A, then the action B is not started.
In contrast, when doing a similar thing with Domenics proposal
```js
const promise = uncancellableActionA().then(res =>
token.cancelIfRequested();
return uncancellableActionB(res);
});
```
then the `promise` is either cancelled at exactly the time when action A
fulfills,
or if cancellation happens after that (during action B), then the
`promise` is not cancelled at all!
The second major difference in functionality is how handlers can affect
the promise return value in case of a cancellation request.
Compare the following:
```js
p = promise()
a = p.then(A, token)
b = a.finally(B)
c = b.then(C, token)
d = c.finally(D)
e = d.then(E, token)
f = e.trifurcate(null, null, F)
g = f.then(G, token)
```
If `p` is not yet resolved and the `token` is cancelled, then a-e and g
are immediately cancelled.
A, C, E and G are dropped and never executed as the token that
accompanied them is cancelled.
B, D and F are all immediately scheduled to be called asynchronously.
When F is called, f is resolved with the result.
With Domenics proposal
```js
p = promise(token)
a = p.then(A)
b = a.finally(B)
c = b.then(C)
d = c.finally(D)
e = d.then(E)
f = e.then(null, null, F)
g = f.then(G)
```
things do turn out differently (disregarding here that cancellation is
ignored if it happens after `p` settles).
If `token` is cancelled and `p` was not yet settled but does get
cancelled now, then `a` is cancelled as well and B does get scheduled.
After B is called and its result is awaited, b and c get cancelled as
well, and D does get scheduled.
After D is called and its result is awaited, d and e get cancelled as
well, and F does get scheduled.
After F is called, f is resolved with the result. Unless it re-cancels,
G is scheduled, and when finally called then g is resolved with the
result.
If any of B, D or F did throw then a rejection would have propagated
down the chain.
I do believe that my approach is more comfortable and the generally
expected behaviour: When I cancel an action, I am ignoring its result. I
don't want it to reject or fulfill (at an arbitrary later time)
regardless of my cancellation request. It also means that cancellable
callbacks can be attached to all promises, it doesn't matter whether the
action does support cancellation itself or not.
Admittedly, `finally` handlers waiting for another could be quite nice,
though I guess being executed sequentially is enough.
But if you really *need* that, you can still do it with my proposal by
treating cancellation as a rejection that explicitly propagates
downwards (and doing `if (token.requested) throw token.reason` in every
uncancellable handler).
The third major difference is that cancellation causes rejection.
We don't need a third state in promises, as the race between the
cancellation request (`cancel()`) on the token and the fulfillment or
rejection (`resolve`, `reject`) on the promise is enough to describe the
semantics of cancelled promises. If cancellation wins, the resolution of
the promise doesn't really matter any more, usually all (current)
subscriptions are already ignoring the result.
There are several arguments favouring rejection however:
* The result that was promised will not become available, which is
naturally a reason for rejection.
* If code you are depending on makes a breaking change and switches to
use cancellation, your code does not expect cancellation and that's what
will happen: an unexpected rejection, triggering error handlers. That's
a much saner default than suddenly doing nothing at all.
* Future subscribers that didn't expect the cancellation (and call
`.then` nonetheless) will get a rejection
* If there are multiple subscribers with multiple tokens (different
cancellations), such as in caches or queues, they don't expect any
cancellation that wasn't theirs, and need to handle it like an error
A separate observable state would have the following issues:
* not compatible with Promises/A+ semantics (including ES6 and ES7)
* A+ assimilation would translate cancellation into forever-pending
promises:
`ThirdStatePromise.resolve(AplusPromise.resolve(cancelledThirdStatePromise))`
* it's confusing if it does not behave the least like the other states
So after all, I believe that my approach requires no changes to
completion semantics, has better backward compatibility, offers nicer,
simpler and more composable syntax to developers, and gives more
predicability with cancellation semantics that are easier to reason about.
If you want a particular behaviour from Domenics proposal, you still can
model it fairly easy with explicit rejections; In contrast, you can't
get the behaviour I desire with Domenics approach.
Feedback here on the mailing list and at the repo is warmly welcome.
Bergi
More information about the es-discuss
mailing list