I have a JavaScript (EmberJS + Electron) application that needs to execute sequences of asynchronous tasks. Here is a simplified example:
t1
seconds latert2
seconds laterFor simple cases this seems reasonably easy to implement with Promises: 1 then 2 then 3 ... It gets a little trickier when timeouts are incorporated, but Promise.race
and Promise.all
seem like reasonable solutions for that.
However, I need to allow users to be able to cancel a sequence gracefully, and I am struggling to think of sensible ways to do this. The first thing that came to mind was to do some kind of polling during each step to see if a variable someplace has been set to indicate that the sequence should be canceled. Of course, that has some serious problems with it:
cancel
event is completely unrelated to time so shouldn't require using a timer. The isCanceled
variable may need to be outside of the promise' scope. etc.Another thought I had was to perhaps race everything so far against another promise that only resolves when the user sends a cancel signal. A major problem here is that the individual tasks running (that the user wants to cancel) don't know that they need to stop, roll-back, etc. so even though the code that gets the promise resolution from the race works fine, the code in the other promises does not get notified.
Once upon a time there was talk about cancel-able promises, but it looks to me like the proposal was withdrawn so won't be incorporated into ECMAScript any time soon though I think the BlueBird promise library supports this idea. The application I'm making already includes the RSVP promise library, so I didn't really want to bring in another one but I guess that's a potential option.
How else can this problem be solved? Should I be using promises at all? Would this be better served by a pub/sub event system or some such thing?
Ideally, I'd like to separate the concern of being canceled from each task (just like how the Promise
object is taking care of the concern of asynchronicity). It'd also be nice if the cancellation signal could be something passed-in/injected.
Despite not being graphically skilled, I've attempted to illustrate what I'm trying to do by making the two drawings below. If you find them confusing then feel free to ignore them.
If I understand your problem correctly, the following may be a solution.
Assume your mainline code looks like this:
send(msg1)
.then(() => receive(t1))
.then(() => send(msg2))
.then(() => receive(t2))
.catch(() => console.log("Didn't complete sequence"));
receive
would be something like:
function receive(t) {
return new Promise((resolve, reject) => {
setTimeout(() => reject("timed out"), t);
receiveMessage(resolve, reject);
});
}
This assumes the existence of an underlying API receiveMessage
, which takes two callbacks as parameters, one for success and one for failure. receive
simply wraps receiveMessage
with the addition of the timeout which rejects the promise if time t
passes before receiveMessage
resolves.
But how to structure this so that an external user can cancel the sequence? You have the right idea to use a promise instead of polling. Let's write our own cancelablePromise
:
function cancelablePromise(executor, canceler) {
return new Promise((resolve, reject) => {
canceler.then(e => reject(`cancelled for reason ${e}`));
executor(resolve, reject);
});
}
We pass an "executor" and a "canceler". "Executor" is the technical term for the parameter passed to the Promise constructor, a function with the signature (resolve, reject)
. The canceler we pass in is a promise, which when fulfilled, cancels (rejects) the promise we are creating. So cancelablePromise
works exactly like new Promise
, with the addition of a second parameter, a promise for use in canceling.
Now you can write your code as something like the following, depending on when you want to be able to cancel:
var canceler1 = new Promise(resolve =>
document.getElementById("cancel1", "click", resolve);
);
send(msg1)
.then(() => cancelablePromise(receiveMessage, canceler1))
.then(() => send(msg2))
.then(() => cancelablePromise(receiveMessage, canceler2))
.catch(() => console.log("Didn't complete sequence"));
If you are programming in ES6 and like using classes, you could write
class CancelablePromise extends Promise {
constructor(executor, canceler) {
super((resolve, reject) => {
canceler.then(reject);
executor(resolve, reject);
}
}
This would then obviously be used as in
send(msg1)
.then(() => new CancelablePromise(receiveMessage, canceler1))
.then(() => send(msg2))
.then(() => new CancelablePromise(receiveMessage, canceler2))
.catch(() => console.log("Didn't complete sequence"));
If programming in TypeScript, with the above code you will likely need to target ES6 and run the resulting code in an ES6-friendly environment which can handle the subclassing of built-ins like Promise
correctly. If you target ES5, the code TypeScript emits might not work.
The above approach has a minor (?) defect. Even if canceler
has fulfilled before we start the sequence, or invoke cancelablePromise(receiveMessage, canceler1)
, although the promise will still be canceled (rejected) as expected, the executor will nevertheless run, kicking off the receiving logic--which in the best case might consume network resources we would prefer not to. Solving this problem is left as an exercise.
But none of the above addresses what may be the real issue: to cancel an in-progress asynchronous computation. This kind of scenario was what motivated the proposals for cancelable promises, including the one which was recently withdrawn from the TC39 process. The assumption is that the computation provides some interface for cancelling it, such as xhr.abort()
.
Let's assume that we have a web worker to calculate the nth prime, which kicks off on receiving the go
message:
function findPrime(n) {
return new Promise(resolve => {
var worker = new Worker('./find-prime.js');
worker.addEventListener('message', evt => resolve(evt.data));
worker.postMessage({cmd: 'go', n});
}
}
> findPrime(1000000).then(console.log)
< 15485863
We can make this cancelable, assuming the worker responds to a "stop"
message to terminate its work, again using a canceler
promise, by doing:
function findPrime(n, canceler) {
return new Promise((resolve, reject) => {
// Initialize worker.
var worker = new Worker('./find-prime.js');
// Listen for worker result.
worker.addEventListener('message', evt => resolve(evt.data));
// Kick off worker.
worker.postMessage({cmd: 'go', n});
// Handle canceler--stop worker and reject promise.
canceler.then(e => {
worker.postMessage({cmd: 'stop')});
reject(`cancelled for reason ${e}`);
});
}
}
The same approach could be used for a network request, where the cancellation would involve calling xhr.abort()
, for example.
By the way, one rather elegant (?) proposal for handling this sort of situation, namely promises which know how to cancel themselves, is to have the executor, whose return value is normally ignored, instead return a function which can be used to cancel itself. Under this approach, we would write the findPrime
executor as follows:
const findPrimeExecutor = n => resolve => {
var worker = new Worker('./find-prime.js');
worker.addEventListener('message', evt => resolve(evt.data));
worker.postMessage({cmd: 'go', n});
return e => worker.postMessage({cmd: 'stop'}));
}
In other words, we need only to make a single change to the executor: a return
statement which provides a way to cancel the computation in progress.
Now we can write a generic version of cancelablePromise
, which we will call cancelablePromise2
, which knows how to work with these special executors that return a function to cancel the process:
function cancelablePromise2(executor, canceler) {
return new Promise((resolve, reject) => {
var cancelFunc = executor(resolve, reject);
canceler.then(e => {
if (typeof cancelFunc === 'function') cancelFunc(e);
reject(`cancelled for reason ${e}`));
});
});
}
Assuming a single canceler, your code can now be written as something like
var canceler = new Promise(resolve => document.getElementById("cancel", "click", resolve);
function chain(msg1, msg2, canceler) {
const send = n => () => cancelablePromise2(findPrimeExecutor(n), canceler);
const receive = () => cancelablePromise2(receiveMessage, canceler);
return send(msg1)()
.then(receive)
.then(send(msg2))
.then(receive)
.catch(e => console.log(`Didn't complete sequence for reason ${e}`));
}
chain(msg1, msg2, canceler);
At the moment that the user clicks on the "Cancel" button, and the canceler
promise is fulfilled, any pending sends will be canceled, with the worker stopping in midstream, and/or any pending receives will be canceled, and the promise will be rejected, that rejection cascading down the chain to the final catch
.
The various approaches that have been proposed for cancelable promise attempt to make the above more streamlined, more flexible, and more functional. To take just one example, some of them allow synchronous inspection of the cancellation state. To do this, some of them use the notion of "cancel tokens" which can be passed around, playing a role somewhat analogous to our canceler
promises. However, in most cases cancellation logic can be handled without too much complexity in pure userland code, as we have done here.