promises-aplus / cancellation-spec

Discussion and drafts of a possible promise cancellation spec.
24 stars 5 forks source link

Draft D: Cancellation Token+ #11

Open bergus opened 10 years ago

bergus commented 10 years ago

Update: A reworded and more detailed version can be found in this gist

I really love the idea of cancellation tokens from #8. However, I will go even further and amend/modify the specification for then, so that handlers themselves can be prevented from execution.

Terminology

  1. A CancellationToken is an object with methods for determining whether an operation can be cancelled.
  2. One CancellationToken might be associated with a promise.
  3. Many CancellationTokens can be registered with a promise.
  4. A CancellationError is an error used to reject cancelled promises.
  5. A cancelled promise is a promise that has been rejected with a CancellationError.
  6. A cancelled token is a CancellationToken that is in the cancelled state, denoting that the result of the operation is no longer of interest. It might be considered an unregistered token or a revoked token.
  7. A cancelled callback is an onFulfilled or onRejected argument to a .then() call whose cancellationToken has been revoked.

    Requirements

    The then method

Extensions are made to the following sections: 2.2.1. If onFulfilled is a function: 2.2.1.1. it must be called _unless it is cancelled_ after promise is fulfilled, with promise’s value as its first argument. 2.2.2.1. If onRejected is a function, 2.2.2.2. it must be called _unless it is cancelled_ after promise is rejected, with promise’s reason as its first argument.

Note: 2.2.1.3. and 2.2.2.3. ("must not be called more than once") stay in place, and still at most one of the two is called.

2.2.6.1. If/when promise is fulfilled, all respective _uncancelled_ onFulfilled callbacks must execute in the order of their originating calls to then. 2.2.6.2. If/when promise is rejected, all respective _uncancelled_ onRejected callbacks must execute in the order of their originating calls to then.

2.2.7.3. If onFulfilled is not a function and promise1 is fulfilled _and promise2 was not cancelled_, promise2 must be fulfilled with the same value as promise1. 2.2.7.4. If onRejected is not a function and promise1 is rejected _and promise2 was not cancelled_, promise2 must be rejected with the same reason as promise1.

(we probably need these last two in every cancellation spec anyway)

The CancellationToken

A CancellationToken is an object with a unique identity. It can get revoked, moving it into the cancelled state, which is an irreversible change.

The object has an isCancelled property, whose value must be a boolean[, or a function that returns a boolean]. It must yield true if the token is in the cancelled state, and false otherwise.

Retrieving the state of a cancellation token must not change the state, i.e. an isCancelled function must have no side effects.

The CancellationError

  1. It must be an instance of Error (cancellationError instanceof Error === true).
  2. It should have a name property with value "CancellationError".
  3. It must have a cancelled property with value true.

    The cancellationToken parameter

The fourth parameter of the then method is an optional cancellationToken; a call does look like

promise = parentPromise.then(onFulfilled, onRejected, onProgress, cancellationToken)

If cancellationToken is not a CancellationToken object, create an implicit CancellationToken for the new promise. In both cases (explicit and implicit) associate it with the new promise. The state of an explicit token must not be changed by the then method.

Register this cancellation token with the parentPromise.

Also register this cancellation token with any child promises that are returned from onFulfilled or onRejected (2.2.7.1). This includes passing the cancellation token to the then call in step 2.3.3.3. of the Promise Resolution Procedure.

If the promise is attempted to be cancelled with an error, run the following steps:

  1. If its associated token is an implicit token, test whether all the registered tokens on it are cancelled. If so, revoke the implicit token.
  2. If its associated token is not cancelled, return.
  3. If parentPromise is pending, attempt to cancel it with error.
  4. [If onRejected is a function and neither it nor onFulfilled have been called, execute it with the error as its argument. (with 2.2.4. "async execution", and 2.2.7.1. "assimilation" in mind)]
  5. If onFulfilled or onRejected have been called and returned a child promise, attempt to cancel that with error.
  6. [Only if none of the cancellation attempts was successfull [and onRejected will not be executed]], reject promise with error.
  7. Signal success.

    The cancel method

The cancel method of a promise accepts two optional parameters:

promise.cancel(reason, token);
  1. Assert: promise is still pending. Return false otherwise.
  2. If reason is a CancellationError, let error be that error object, else let error be a new CancellationError with the reason as the value of its message property.
  3. If token is a CancellationToken, revoke it.
  4. Attempt to cancel the promise with error.

    The Promise constructor

Promises not created by a call to then may handle attempts to cancel them in implementation-dependent ways.

Constructors are however encouraged to signal these to the promise creators, and optionally provide them access to the list of registered tokens. This might be done through a callback that is passed as an additional argument to the Promise constructor, or returned from the resolver call.


Pluses:

Minus:

The basic idea of this draft is that handlers that were passed to then will not be executed when the cancellation token that accompanied them is cancelled:

var ajax = http.get(…);
ajax.then(doSomething);
var json = ajax.then(JSON.parse);
// later:
json.cancel(); // `ajax` can't be cancelled (because `doSomething` is still in-
               // terested in it), but the `JSON.parse` won't need to be executed

I'm not so sure about the steps 4 and 6 of cancellation attempts.

yahuio commented 9 years ago

It's still a bit difficult to undersand how it works. Can you see if this the code below demonstrate the full scenario?

var defer = promise.defer(),
            p1 = defer.promise

        var p4 = p1.then(function(value) {
            var p2 = new Promise();

            return p2;
        }, function(error) {
            var p3 = new Promise();
            return p3;
        }, onProgress, cToken);

        //cToken associated with p4
        //cToken registered with p1
        //cToken registered with p2
        //cToken registered with p3

        var p5 = p4.then(function(){});
        //cToken registered with p5

        //--- case 1: try cancel p1
        p1.cancel(new CancellationError);
        // only p1 is cancelled as there's no associated cancellation token

        //--- case 2: try cancel p1, and cToken
        p1.cancel(new CancellationError, cToken);

        // p1, p2, p3, p4 and p5 is cancelled, as cToken is revoked

        //--- case 3: try cancel p4 without token
        p4.cancel(new CancellationError);

        //only p4 is cancelled, as cToken is not revoked

        //--- case 4: try cancel p4 with token
        p4.cancel(new CancellationError, cToken);

        // p1, p2, p3, p4 and p5 is cancelled, as cToken is revoked
        // NOTE that p1 is also cancelled. as "If parentPromise is pending, attempt to cancel it with error." 
bergus commented 9 years ago

Hi, thanks for your interest (after such a long time :-) - I had to re-read the draft myself). Unfortunately, most of your code does not behave as intended by me. The core idea behind this design with the cancellation tokens was that promises cannot get cancelled while there are still (uncancelled) handlers registered on it. Only by revoking all handlers (and their respective tokens) you can cancel a promise.

So let's step through your code:

var defer = promise.defer(),
    p1 = defer.promise;

var p4 = p1.then(function(value) {
    var p2 = new Promise();
    return p2;
}, function(error) {
    var p3 = new Promise();
    return p3;
}, onProgress, cToken);

So far so good. Notice that p1 is pending, the success/error handlers are not executed. I'm missing a declaration like var cToken = {cancelled: false};

//cToken associated with p4
//cToken registered with p1

Yes. You've passed an explicit token, which is associated with p4 and registered - together with the 3 handlers - on p1.

//cToken registered with p2
//cToken registered with p3

No. Neither handler is executed yet, p2 and p3 don't even exist at this point. Once p1 would be fulfilled or rejected, one of them would - only then cToken is registered with the child promise.

var p5 = p4.then(function(){});
//cToken registered with p5

No. As you haven't passed any token, a new implicit token is created that is associated with p5 and registered - together with that handler - on p4.

p1.cancel(new CancellationError); //--- case 1: try cancel p1
// only p1 is cancelled as there's no associated cancellation token

Not necessarily. There is no associated token, yes, but that doesn't mean it's automatically cancelled. p1 is created by some deferred, and that will need to handle the cancellation attempt in some implementation-defined way. It would however know that the still uncancelled cToken is registered on it, and probably not do anything.

p1.cancel(new CancellationError, cToken); //--- case 2: try cancel p1, and cToken
// p1, p2, p3, p4 and p5 is cancelled, as cToken is revoked

Yes, cToken is revoked and following the above logic the deferred will most likely decide to reject p1 with the error. However, there were no attempts at cancelling p4 and p5, they will stay mostly unaffected. As cToken is cancelled, the success and error callbacks are cancelled as well, and neither of them will get called (p2 and p3 never are created). p4 stays forever pending (until being cancelled).

p4.cancel(new CancellationError); //--- case 3: try cancel p4 without token
//only p4 is cancelled, as cToken is not revoked

No. With cToken not being revoked, p4 won't be cancelled.

p4.cancel(new CancellationError, cToken); //--- case 4: try cancel p4 with token
// p1, p2, p3, p4 and p5 is cancelled, as cToken is revoked
// NOTE that p1 is also cancelled. as "If parentPromise is pending, attempt to cancel it with error." 

Yes, p1 and p4 are successfully cancelled. p2 and p3 never were created (p1 is still pending). p5 is "cancelled" in the sense that p4 was rejected with the cancellation error, and there was no attempt to catch this so p5 is rejected as well.

If these results seem queer, notice that the explicit cancellation token is only a tool for explicit control over the cancellation process (which might be supplied by the user). Most usages of this proposal will rely on the implicitly created cancellation tokens that will get automatically revoked when there are no more handlers.

yahuio commented 9 years ago

Thanks so much for your time to walk me through. I'm a bit more understood there. With a few more questions :).

Register this cancellation token with the parentPromise.

Why does it need to register the cancellation token with the paren promise, p1 in the above scenario. what's a possible use case there? Won't it be more intuitive to only allow token associate with p1 during the creation of p1?

p4.cancel(new CancellationError);

Why do we need to supply a CancellationError during a cancel call? Who will handle it, the onRejection handler? As I can't see a onCancellation handler to assign with.

p4.cancel(new CancellationError, cToken);

I think it would be more intuitive if cancelling a promise actually revoke the cancellationToken associated with it? i.e. without the second parameter. How do you think?

bergus commented 9 years ago

Why does it need to register the cancellation token with the parent promise

Because that is the most important purpose of the tokens: registering a token together with one or two handlers signals to the promise that someone is interested in its result. Cancelling this token later signals the disinterest - the handlers shall be removed and never be executed. You never want to cancel a "promise" - a promise is nothing, it's a mere placeholder for a result. You want to cancel the task that is fetching the value, in the case of then this is "waiting for the parent and executing the callbacks". The use case are promises that are not linear chains, but have multiple handlers installed - see the ajax example.

Why do we need to supply a CancellationError during a cancel call?

To distinguish cancellations from other causes that prevented the promise from completing normally.

Who will handle it, the onRejection handler? As I can't see a onCancellation handler to assign with.

Possibly, if step 4 is un-bracketed - I'm not sure about this. The cancellation error becomes the rejection reason of the promise, so that if you cancelled a promise and later tried to add another handler on it, you'd see it rejected with this error. Also, any promises not created by then but by some other implementation-dependent mechanism are free to handle cancellation attempts in any way they like - e.g. calling an onCancellation handler with the error.

I think it would be more intuitive if cancelling a promise actually revoke the cancellationToken associated with it?

Yes, that's the default behaviour for implicit cancellation tokens - you will never see them. The cToken parameter is optional and would only be used with explicit tokens (though when you use explicit tokens and maintain reference to them anyway, you might cancel the tokens in some other ways as well).

yahuio commented 9 years ago

Thanks for explanation, that help me clarified a lot of things! @bergus

The part I like the most is the extension to the promise/A+ then specification. And the ability to cancel chained promises.

Initially, I feel that using the token would be easy to understand. However, after I tried the usage it seems that the down said is the code readability that's it's not intuitive to know which promise and it's pending task is being involved due to chained promise. Or how when to supply a token or not.

I have idea to simplify that other than token tho, I will share it in a separate thread as another proposal. Hope you don't mind.

bergus commented 9 years ago

I've reworded, clarified and detailed the draft now, you can find it in this gist.