Cancelable promises proposal
I have thought about something similar before, and used it in one of my libs. Recently, I have come up another idea, it was already implemented on Yaku:
let Promise = require('yaku')
// The `self` is the instance of the newly created promise.let p = new
Promise((resolve, reject, self) => {
let tmr = setTimeout(resolve, 3000)
let self.abort = () => {
clearTimeout(tmr)
reject(new Error('abort promise'))
}
})
p.abort()
The origin post is here: promises-aplus/cancellation-spec#16.
what do you think about it?
2015年8月3日(月) 8:43 Glen Huang <curvedmark at gmail.com>:
On 8/2/15 8:43 PM, Glen Huang wrote:
You can think it as that each promise keeps a list of its child promises, when the same callback is passed to .ignore() it sets a flag on the corresponding child promise so that when itself resolves/rejects, it won't pass that state to that child promise
There seems to be a potential footgun here. Say I call then() twice with the same function. Now I have two child promises. If I now call ignore() with that function, which of the child promises is ignored? Both? Just one of them? Something else?
This situation doesn't quite arise with add/removeEventListener, because you can't addEventListener a given listener twice to the same event target. So a removeEventListener can see at most one matching listener.
not so different from what I've proposed already, except it looks ugly with
a self
at the end, and it's based on runtime method attachment that could
happen at any time or mislead, or forget to invoke reject etc etc ... I
will try to stay away from this thread 'cause I've already talked as much
as I could about this topic but my proposal was this one:
var p = new Proomise((resolve, reject, fnIfAborted) => {
let timer = setTimeout(resolve, 3000, 'All Good');
// **only** if fnIfAborted is invoked here
// the promise will be cancelable, otherwise it cannot
// be cancelable later on, and it's not possible
// to even invoke fnIfAborted later on or asynchronously
// this is the best I could think to make the contract as simple as
possible
fnIfAborted(function () {
clearTimeout(timer);
});
});
Once established that the user that created the Promise is the only one capable of deciding if that could be cancelable or not, there's no way anything else could interfeer with that cancelability, and the promise can be passed around as cancelable, or simply wrapped through another non cancelable promise.
This gives the promise creator the ability to cancel it without exposing such ability to the outer world.
The eventual p.abort()
if executed after the promise has been
resolved/rejected will not invoke the fnIfAborted
internally passed
callback.
Reasons such callback must be defined in the Promise scope is because only there there would be eventually the ability to resolve, reject, or abort the operation.
It's simple, it's probably a bit ugly, but it's there to solve edge cases ( basically all network related cases or every case that might take long time and the user, or even the developer, might get border and would like to do something else instead of wasting resources )
Best
This is a good point. I didn't think about passing the same callback more than once.
Ideally, if the same resolve & reject callback combination is passed more than once, the same promise is always returned (I can't think of a use case where promises could stop to work, if promises were specced with this additional rule, but maybe I overlooked). So if we pretend that's the case, it seems ignore() should deregister all callbacks.
Do you have a use case where the same callback will be passed to the same promise more than once? Does deregistering all callbacks at once seem counter-intuitive in that case if you want to show disinterest?
Another way out is to make ignore() cancel then() in a LIFO manner, probably also provide a ignoreAll(), which also gives a hint that ignore() only ignore one callback combination a time. But this just seems ugly.
@Andrea @Yad
The only thing I'm not sure about is this "some promises have the abort ability, some don't" design. It might seem clear when you first create that promise, but it gets messy when you start to pass that promise around. It's very easy for some function to accidentally have the ability to abort() where it shouldn't.
my proposal doesn't make abortability accidental, only promises that has
passed through their initialization the callback would be cancelable,and
this could be reflected through a .cancelable
property. We could have
.abort()
throwing if used when cancelable is true ( or not
undefined for backward compatiblity sake )
The only problem I see with my proposal is thay purists would never accept cancel-ability in first place, and pragmatists would never find out what should be the behavior once aborted 'cause Promise can only reject and never be ignored.
So the TL;DR is that I don't see cancel-able promises becoming reality at any point in these days, I rather would think from scratch something better ( Task ? ) and leave Promises the way these are, hoping standard bodies will just stop putting/using them for everything asynchronous that might take more than 10ms and should be cancelable.
I'm the first one that want them cancelable, and at this point, after months of discussions, the one that also doesn't see that as a solution.
Best
if used when cancelable is true
if used when cancelable is NOT true, so throw if non cancelable
On Aug 4, 2015, at 1:32 PM, Andrea Giammarchi <andrea.giammarchi at gmail.com> wrote:
only promises that has passed through their initialization the callback would be cancelable,and this could be reflected through a
.cancelable
property
That's precisely the problem. When you made a mistake and thought a function should have the ability to abort, you wouldn't reflect that .cancelable
property, you would simply call abort(). Passing a different object makes this less likely to happen since it's not thenable.
and pragmatists would never find out what should be the behavior once aborted 'cause Promise can only reject and never be ignored.
What do you think of the ignore() method I proposed?
Glen, sorry if this has been covered in other discussions, but it's not
clear to me so I wanted to ask. Are there some example use-cases where
being able to .ignore
is preferable to having the promise reject? Promise
chains are about defining flows of data and I can't really see a case where
you'd want to disconnect a promise handler without informing promises
farther down the chain, like your .then(log)
in your example. To me
anyway, the proposed .ignore
seems like it adds boat-loads of complexity
with unclear goals.
Also to your question about adding multiple handlers, if that handler has
side-effects, then it would definitely cause bugs if the same promise were
returned for multiple calls to .then
with the same callback.
Are there some example use-cases where being able to
.ignore
is preferable to having the promise reject?
The purpose of .ignore() is to let promises show disinterest, by disinterest, i mean the situation that whatever happens to that promise, it shouldn't be observable to the callbacks. Making that promise pending forever is the correct way to do that IMO. Rejecting the promise means the callback is still interested in the rejecting case.
I can't really see a case where you'd want to disconnect a promise handler without informing promises farther down the chain
If you want to inform promises farther down the chain, you shouldn't disconnect the handler, you should let that aborted reason flow down the chain.
Also to your question about adding multiple handlers, if that handler has side-effects, then it would definitely cause bugs if the same promise were returned for multiple calls to
.then
with the same callback.
Are you talking about passing the same callback combination to .then() returns the same promise? Would you provide an example where it can cause bugs? I guessed it's dangerous, but not sure how it could fail. But let's say it causes bug, do you think .ignore() disconnecting all same callbacks in the current promise could cause bug too? Or it could behave counter-intuitively? I'm only using returning the same promise as a guidance on designing how ignore() works, .then() doesn't really have to behave that way.
Finally, here is an example to demonstrate the use case.
Given
let query = queryDB(sql);
let updateView = data => render(data);
let log = data => console.log(data);
let logAbort = err => {
if (err instanceof AbortError) console.error(reason);
throw err;
};
Let's say you want to abort the query, but log the abortion in a child promise:
query.done.then(updateView).then(log, logAbort);
query.abort();
Or if you want to show disinterest that the query is irrelevant now:
let done = query.done;
done.then(updateView).then(log, logAbort);
done.ignore(updateView);
Does this answer you question?
Given your example of
let done = query.done;
done.then(updateView).then(log, logAbort);
done.ignore(updateView);
The core of my complaint comes down to control flow. To demonstrate, let's convert this to a co generator so it's readable like sync code.
co(function*(){
try {
let result = yield query.done;
let update = yield updateView(result);
log(update);
} catch(err){
logAbort(err);
}
});
What you are asking for seems like a way to halt execution of the function,
without ever returning from the function, or executing either branch of the
try/catch. In synchronous code the only way to achieve this would be
while(true){}
, which is terrible. There isn't really a world where I'd
expect that code to execute neither side, because promises are
fundamentally about chaining data together, not about listening for events.
Instead you might write your code like
let ignore = false;
let done = query.done;
done.then(result => ignore ? undefined : updateView(result).then(log,
logAbort));
ignore = true;
Your code is skipped, like you wanted, but the outer promise still resolves eventually.
As for the duplicate handler question, imagine this:
let counter = 0;
function fn(){
return ++counter;
}
let original = Promise.resolve();
let result;
for (let i = 0; i < 10; i++){
result = original.then(fn);
}
result.then(output => console.log(output));
the result is 10
because result
was the 10th promise and then the 10th
execution of fn
. If the same promise were returned constantly, then this
would instead print 1
.
like I've said ...
On Wed, Aug 5, 2015 at 1:32 AM, Glen Huang <curvedmark at gmail.com> wrote:
What do you think of the ignore() method I proposed?
... I don't want to express myself further.
I think this story should be split in few little stories. The first one would be asking around how many here thinks Promises should be cancelable in first place.
There's no equivalent like a silent failure in a synchronous like await/generator world and since promises are apparently used as de-facto pattern too deal with that, you'll find hard time to resolve this problem.
Once this abstraction/idea covers what developers think Promises should cover, you might think about the how.
So, far in months of discussions, I still haven't seen any agreement about "Promises should definitively be cancelable"
So good luck with that, I actually think these shouldn't but since these have been used for things that should be cancelable, these should as side effect :-(
Best
What you are asking for seems like a way to halt execution of the function, without ever returning from the function, or executing either branch of the try/catch
This also happens when query.done never resolves / rejects. You don't need to introduce ignore() to trigger this behavior.
I think .ignore() is more similar to
let timer = setTimeout(...);
clearTimeout(timer);
Trying to imagine setTimeout as a promise that never rejects.
promises are fundamentally about chaining data together, not about listening for events.
IMHO, promises are more about reacting to state change and pass down that reaction. And the reaction I'm proposing to add is "no reaction will be passed down".
Your code is skipped, like you wanted, but the outer promise still resolves eventually.
Yes, you can do that. But how does that relate to ignore()? The point of ignore() is to keep pending all the descendant promises. I don't think it's doable with flags when you have no idea want kind of descendant promises might be attached.
If the same promise were returned constantly, then this would instead print
1
Thanks for the example. This makes me realize that ignoring based on callbacks to selectively not passing down reaction to child promises is bad idea. What about making ignore() ignore parent's state change (maybe also a method to undo that)?
let query = queryDB(sql);
let updateView = data => render(data);
let log = data => console.log(data);
let update = query.done.then(updateView);
update.then(log);
update.ignore(); // ignore parent's state change, updateView and log will never be called
setTimeout(() => {
update.observe() // unless keep observing parent's state change
}, timeEnoughForQueryToFinish);
This should work if you pass the same callbacks multiple times.
Le 6 août 2015 à 04:20, Glen Huang <curvedmark at gmail.com> a écrit :
promises are fundamentally about chaining data together, not about listening for events.
IMHO, promises are more about reacting to state change and pass down that reaction. And the reaction I'm proposing to add is "no reaction will be passed down".
Note that when you attach a reaction (through the .then()
method) to a promise that was resolved several minutes ago, the reaction will trigger: you have to somewhat stretch you mind in order to call that "reacting to state change".
As I understand, promises are ideal for requesting and transmitting data without having to care much when the data is available (be it several minutes ago or several minutes later) — which would be managing an event. On the contrary, a "state change" sounds more like an event.
The way you think of (and use) promises will influence the way you think what "cancelling a promise" and similar actions should do. If you use promises for asking for data, "cancelling" is naturally akin to "reject with an abort signal as reason".
Now concerning your original case: "let child promises show disinterest on the result of that action". The child promise that want to show disinterest should just settle itself (for example reject itself with an abort signal, but the precise outcome may depend on the reason why you want to stop listening). That will give an appropriate signal to downstream clients, and that will prevent itself from receiving messages from upstream (because a promise cannot be settled more than once). — (True, the .then()
method doesn’t produce a promise that could be settled prematurely: you have to construct it manually. I leave that as an exercise to the reader.)
Note that when you attach a reaction (through the
.then()
method) to a promise that was resolved several minutes ago, the reaction will trigger: you have to somewhat stretch you mind in order to call that "reacting to state change".
What attaching callbacks through .then()
really does is to create a new promise and attach it to the parent promise. The reaction lives in the new promise, and every new child promise without a parent promise is equivalent to having a pending parent promise. So when you attach that new promise to a resolved parent promise, I think it's reasonable to say that, from the reaction's point of view, the parent promise's state has changed.
As I understand, promises are ideal for requesting and transmitting data without having to care much when the data is available
IMHO, that's promises's goal. How does it archive that? By reacting to parent's promise state change, which is really just a event. The only thing special about promises is that when you add event listeners, a new promise containing those listeners are returned, and the events are only filed once for each promise. Getting the data is like reading event.data.
If you use promises for asking for data, "cancelling" is naturally akin to "reject with an abort signal as reason".
Yes, looks like akin, but is actually totally different. cancelling sends signals back to parent, rejecting sends signals down to children. The symmetry can be misleading. Able to sending signals in both directions greatly complicates the design of promises with no obvious benefits. What I'm saying is that to archive the goals that you want from cancelling, you actually don't need the ability to send signal upwards. You don't need to open that can of worms.
The child promise that want to show disinterest should just settle itself
Are you talking about this pattern:
let parentPromise = asyncAction();
let cancel;
let tmpPromise = new Promise((res, rej) => {
cancel = () => rej(new AbortError());
});
let childPromise = Promise.race([parentPromise.then(...), tmpPromise]);
childPromise.cancel = cancel;
This is still one way communication. So does it mean that you agree promises should be kept that way?
And basically you are giving promises the ability to resolve / reject themselves after they are created. Let me rewrite previous example with more familiar APIs:
let parentPromise = asyncAction();
let childPromise = parentPromise.then(...);
childPromise.reject(new AbortError()); // don't care about parent promise, let's reject the childPromise
This also means a grand child might have to differentiate from an abort rejection from a root promise or an ancestor promise, once you encourage this pattern.
Also, with the ignoring parent state change design, its possible to make a promise be interested its parent's state change again after it has shown disinterest. I don't think it's possible if you expose resolve / reject on created promises.
Am I the only one that sees this concept best left as a user Promise subclass? Especially since I'm still seeing some disagreement on even what the problem is that's needing solved.
On 8/3/15 8:15 PM, Glen Huang wrote:
Ideally, if the same resolve & reject callback combination is passed more than once, the same promise is always returned
That's a backwards-incompatible change from what browsers are shipping, right?
(I can't think of a use case where promises could stop to work, if promises were specced with this additional rule, but maybe I overlooked)
As long as no one uses the promise as a key in a Map or anything else that relies on its object identity...
Do you have a use case where the same callback will be passed to the same promise more than once?
Not offhand, but I would be shocked if no one is doing it.
IMO promises should reject if they are "cancelled". The same method that gave you the Promise (for example, an AJAX API) can give you a capability to "cancel" the Promise, ie cancel the pending network operation and reject the Promise (rejecting is a no-op if the operation races with cancellation and manages to resolve first, naturally).
I'm not opposed to setting up a defacto API for the cancellation
capability, but I don't think this needs to be part of EcmaScript. If you
want to define a CancelablePromise
subclass which has a
CancelablePromise#cancel()
method, feel free. But I don't think it
should be tightly tied to the Promise spec at all. The capability to
cancel an operation should be first-class, and able to be passed around
independent of the Promise which gives the future result of the operation.
I don't think you need to define an "unlisten" API, either. Just discard the resolve/reject capabilities for the Promise in question. The last time I audited the spec, I convinced myself that this would ensure that all chained promises were also garbage and would be safely collected. (The only pointers to chained promises are held by the resolve/reject capabilities.)
I was discussing with Jake Archibald about cancelable promises the other day. I was trying to convince him promise.cancel() might not be a good idea. But that conversation unfortunately leans more towards on how the abort API should be exposed on fetch. I did provide some thoughts on that topic, and think the abort API represents a strong use case for cancelable promises. But I feel what's more important is the underlying control flow design.
So I would like to offer my proposal of cancelable promises here, and would like to ask if you think it's a good idea, or if promise.cancel() is actually a good idea and my understanding of promises is flawed.
I think that promises should be an observation API. In other words, it should be kept as one-way communication. This keeps promises simple and easy to reason about.
When we talk about cancelable promises, what we really want is the ability to:
With the premise that promises should be one-way communication, it's clear that #1 should be achieved with a separate API. For example:
This means you need to have access to that separate API in order to abort the action, instead of just the promise.
And abort() rejects the root promise with a special error object, if it's pending.
This also means the abort API doesn't have to be universal. Each action initiator can design their own APIs to abort that action.
And correspondingly, to create a "cancelable promise" with the Promise constructor, it can be as simple as:
For #2, I propose we add a method to Promise.prototype that undos .then() (probably also a sugar to undo .catch()) like removeEventListener undos addEventListener. For example.
You can think it as that each promise keeps a list of its child promises, when the same callback is passed to .ignore() it sets a flag on the corresponding child promise so that when itself resolves/rejects, it won't pass that state to that child promise, unless that the same callback is later registered again.
What do you think of this design? Do you think it covers all of your use cases for cancelable promises?
I have to give credit for Kyle Simpson and Jake Archibald for this idea. Kyle's opposition of sending signal back to the promise vendor, and Jake's argument that we need a way to let an observer signal disinterest greatly clarifies my understanding of promises.
I guess someone has probably expressed this idea somewhere in some way (or in some libraries). Sorry if I missed that.