Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

3.0 cancellation overhaul #415

Closed
petkaantonov opened this issue Dec 28, 2014 · 101 comments
Closed

3.0 cancellation overhaul #415

petkaantonov opened this issue Dec 28, 2014 · 101 comments
Labels

Comments

@petkaantonov
Copy link
Owner

The current cancellation seems to be the most problematic feature due to some annoying limits in the design:

  • Awkward API for attaching the callback that cancels the work (through typed catch handler)
  • Cannot compose because it is impossible to tell the difference between rejection and cancellation (e.g. Promise.all(...).cancel() cannot do the obvious thing and also call cancel() on the promises in the array)
  • .cancel() is asynchronous

Since all consumers and producers must be trusted/"your code", there is no reason to enforce that cancellable promises are single-consumer or create new primitives.

Edit: The below design has been scratched, see #415 (comment)

In the new cancellation you would register the callback while marking the promise cancellable:
promise.cancellable(function onCancel(reason) {

});

This returns a new cancellable promise (unlike right now which just mutates existing promise which causes a lot of headache internally) and the flag will automatically propagate to all derived promises. However, the reference to the onCancel callback will only be held by the new promise created by .cancellable().
Calling .cancel() on a cancellable promise will keep propagating to cancellable parents and calling their onCancel callbacks.

The onCancel callback will receive the cancellation reason as its only argument (the default is a CancellationError instance).

From the callback it's possible to decide the fate of the promise:

  • Throwing an error will reject the promise with the error as the rejection reason
  • Returning a value will fulfill the promise with the value as the fulfillment value

(However I guess 99.99% of the time you want throw reason, so this flexibility adds a lot of inconvenience?)

Bad (should use .timeout() for this) example of usage:

function delay(value, time) {
    var timerId;
    return new Promise(function(resolve, reject) {
        timerId = setTimeout(function() {
            resolve(value);
        }, time || value);
    }).cancellable(function(reason) {
        clearTimeout(timerId);
        throw reason;
    });
}


var after500ms = delay(500).catch(CancellationError, function(e) {
    console.log("Your request took too long");
});
delay(250).then(function() {
    after500ms.cancel();
});
@benjamingr
Copy link
Collaborator

Summoning some relevant people - @kriskowal @domenic @spion

@benjamingr
Copy link
Collaborator

I agree that current cancellation is bulky and strange so a big +1 on overhauling it - it's a constant source of confusion for people in SO and it's not very intuitive IMO.

@kriskowal raised the argument that cancellation is inherently impossible. Here is the original discussion

I tend to agree that the major source of confusion is the dependency graph.

The most basic confusing case is:

                   A(cancellable promise)
      /-----------|------------\
      |                        | // children
      B                        C 

The way propagation works in this scenario is not obvious when you cancel B - in the current design suggested it will propagate to C (it has to) which might be unexpected to users


I'm asking why (like progression) not do this at the user level? Why do we need a special construct?

function delay(value, time, token) {
    var timerId;
    return new Promise(function(resolve, reject) {
        timerId = setTimeout(function() {
            resolve(value);
        }, time || value);
            token.cancel = function(){
                 clearTimeout(timerId);
                 reject(new CancellationError());
            };
    });
}
var token = {cancel: function(){}};
var after500ms = delay(500, token).catch(CancellationError, function(e) {
    console.log("Your request took too long");
});
delay(250).then(function() {
    token.cancel();
});

This way anyone holding the token can cancel so who has the ability to cancel is explicit.

It seems like most times promises require cancellation of the direct problematic promise (the HTTP request, the timeout etc) - if we're not creating new primitives with single-consumer can't we give up all of propagation?

This is what languages like C# or Scala do (for example: http://msdn.microsoft.com/en-us/library/dd997396%28v=vs.110%29.aspx ) although I have to admit I'm not a fan of what C# does to be fair.

@MadaraUchiha
Copy link

I tend to agree with Benjamin (when do I not?), this seems like an awfully lot of complexity to add to something that can be relatively simply implemented in userland.

@petkaantonov
Copy link
Owner Author

There has been many issues posted about cancellation, literally none about multiple consumer afaik. Anyone can call _reject on any promise so there is no security issue which I understood the whole impossibility thing was based on.

Progress is simply a callback that doesn't even need error handling (and if it does there was no good way to do it even when integrated in promises, what bluebird does when progress handler throws is absolutely horrible).

However there are multiple issues with the token:

  • Doesn't compose (or at least I don't see how it could) with the collection methods which I believe is the most common issue
  • Makes user create their own CancellationError which has been standard since version 0.x
  • The inconvenience of having to create a weird pass-by-reference emulation caller side, not to mention how are you going to pass the token around when you already need to pass the promise around

@UndeadBaneGitHub
Copy link

Excuse me for bumping in, but I would support Petka's implementation with the onCancel callback. As he has rightly observed, the absolute majority of complains (tbh, mine as well) lies in the field of using cancellable with collections. Say, with Promise.map we have to do use something like this:

var collectionItemsWrappingPromises = [];
var someCollection = [1,2,3];
return Promise.map(someCollection, function(collectionElement) {
    collectionItemsWrappingPromises.push(new Promise(function(resolve, reject) {
         console.log(collectionElement);
         //now do something lengthy
    })
    .cancellable());
    return collectionItemsWrappingPromises[collectionItemsWrappingPromises.length - 1];
})
.catch(Promise.CancellationError, function(err) {
           collectionItemsWrappingPromises.forEach(function(collectionItemWrappingPromise) {
                collectionItemWrappingPromise.cancel();
            });
            throw err;
         });

This is, essentially, the very same token idea, implementing at the user level and it is hellishly inconvenient.

It is ok to do it once, maybe twice. But if the application is complex enough, one has to do it all the time, constantly minding the hierarchy of collections stored. Let alone creating some heavy-loaded apps, which require stuff like Mozilla's node-compute-cluster or similar.
It becomes really messy really fast, so making it a bit more obscure syntax-wise (I mean that return/throw thing) but way more convenient, I'd say, would be a great plus.

@petkaantonov
Copy link
Owner Author

By issues I was mostly referring to github but I also checked stackoverflow now.

https://stackoverflow.com/questions/27479419/bluebird-promise-cancellation - No idea what they were trying to do here but seems to be related to the first issue of having to use .catch to attach cancellation hooks.. although they aren't even using that hook to cancel anything (Awkward API for attaching cancellation hook +1)

https://stackoverflow.com/questions/27132662/cancel-a-promise-from-the-then - Needed to use throw instead of cancel (Also trying to compose with .props which wouldn't work, collection composability +1)

https://stackoverflow.com/questions/24152596/bluebird-cancel-on-promise-join-doesnt-cancel-children - Join doesn't compose (collection composability +1)

https://stackoverflow.com/questions/23538239/catch-a-timeouterror-of-a-promise-beforehand - (Awkward API for attaching cancellation hook +1)

So that's only 4 out of 203 related to cancellation, and all are at least related to the top 3 issues posted in the OP. I only checked the bluebird tag.

@benjamingr
Copy link
Collaborator

@petkaantonov :

There has been many issues posted about cancellation, literally none about multiple consumer afaik. Anyone can call _reject on any promise so there is no security issue which I understood the whole impossibility thing was based on.

I've seen it on StackOverflow several times - it's not about encapsulation at all it's about propagation not being simple to reason about. What happens when you Promise.all from a cancellable? What happens when it has multiple consumers and cancel one that also consumed (through a return in then) another cancellable? And so on.

As for tokens:

Doesn't compose (or at least I don't see how it could) with the collection methods which I believe is the most common issue

Yes! This is the point. I'm saying I think I don't want them to compose since if you want to cancel something you just need a reference to the original promise's token. This circumvents the hierarchy problem.

Makes user create their own CancellationError which has been standard since version 0.x

Why can't they throw Bluebird's Promise.CancellationError?

The inconvenience of having to create a weird pass-by-reference emulation caller side

Yes, this is pretty weird but then again it's also how other languages do it. The reason it is passed to the function (rather than let's say - monkey patched) is that it is conceptually not the concern of the returned promise but the concern of the action itself (a promise just represents an eventual value - the function producing it is the one concerned with cancellation and progression). I think putting cancellation on the promise itself is definitely complecting it.

Also here are more questions: https://stackoverflow.com/search?q=%5Bbluebird%5D+cancel+is%3Aquestion

@UndeadBaneGitHub :

Excuse me for bumping in

This thread is here for feedback and discussion - if people weren't bumping here there would be no point :)

How would you expect aggregation to work with cancellation in general? How would all work? How would any?

@petkaantonov
Copy link
Owner Author

I've seen it on StackOverflow several times - it's not about encapsulation at all it's about propagation not being simple to reason about. What happens when you Promise.all from a cancellable? What happens when it has multiple consumers and cancel one that also consumed (through a return in then) another cancellable? And so on.

You don't typically actually have multiple consumers for any promise when using .all (or any other collection method afaik). The .all PromiseArray is consuming the input promises while the user is consuming the output promise of .all. Cancelling the output promise of any collection method will call cancel on all the promises related to the collection.

@UndeadBaneGitHub
Copy link

@benjamingr
Let's take the example you've posted.
Right now the user has to hold on to a manually managed collection of C promises and just write B in a way when he is ok with it finishing and its result would be uncontrolled.
And then, C might have D (cancellable), D might have E (also cancellable) and so on. Lots of collections, lots of catch(Promise.CancellationError), function(err)) and a ton of dirty code.
I see what @petkaantonov has offered to be a nifty syntax sugar, saving time of the users.
It does not solve problems with composition (I doubt that this can be solved in a convenient way at all), but looks far better, than the present implementation.
any is an interesting quesiton, though.

@benjamingr
Copy link
Collaborator

@petkaantonov :

Cancelling the output promise of any collection method will call cancel on all the promises related to the collection.

So Promise.race([p1, p2, p3]).cancel() will cancel all promises in the race?

What about:

 var p = Promise.race([p1, p2, p3]);
 p.then(function(){
      p.cancel();
 });

This would cancel every promise in the race except for the one that resolved?

What about:

 var p5 = Promise.map([p1, p2, p3, p4], function(elem){
     p5.cancel();
});

Would it cancel all promises except for p1 except for those that did not resolve first?

What about:

 p1 = getCancellablePromise();
 var p2 = p1.then(doSomethingElse);
 var p3 = p1.then(doSomething).then(function(){ p1.cancel(); });

Do you think users will understand this is a race condition between p2 and p3?

I'm just not convinced this is easy to reason about for the general collection method case - kind of like progression. Cancelling an operation itself seems orthogonal to the returned promise for the value.

Also - how would you cancel mid-chain? The whole .cancellable syntax seems kind of quirky to be fair. I haven't formed a strong opinion on the new syntax yet but I'm wondering if we need a bigger overhaul here.

@UndeadBaneGitHub :

Right now the user has to handle aggregation of cancellations manually - my argument here is that there is no generic way to always aggregate cancellation in a way the user would find predictable. With the token approach you'd have something like:

 var tokens = [];
 var myRequests = Promise.map(urls, function(url){
       var token = {cancel: function(){}};
       tokens.push(token);
       return makeRequest(url, token);
 });

Now, we can cancel all the requests with tokens.map(function(el){ return el.cancel(); }, or cancel a specific request with tokens[4].cancel() or we can aggregate them by wrapping it in a function:

 function getRequests(urls, token){
   var tokens = [];
   var myRequests = Promise.map(urls, function(url){
       var token = {cancel: function(){}};
       tokens.push(token);
       return makeRequest(url, token);
   });
   token.cancel = function(){ tokens.forEach(function(t){ t.cancel(); };
   return p;
 }

However since we did this little wiring ourself we can do all this wiring to match what we actually want - cancel all of them, or just one, or maybe the last two - it's in our side and it's not as ugly as in your example at all :)

@petkaantonov
Copy link
Owner Author

@UndeadBaneGitHub What problem with composition is not solved?

Cancelling a promise which is a promise for the result of any collection method will cancel the input promise-for-array if it's still pending.

Otherwise cancelling a promise which is a promise for the result of .all, .some, .any, .props, .settle or .race will cancel all the input promises which are still pending.

Otherwise cancelling a promise which is a promise for the result of .each or .reduce will cancel the currently awaited-on promise.

Otherweise cancelling a promise which is a promise for the result of .map or .filter will cancel all the currently in-flight promises, original input or mapped ones.

@petkaantonov
Copy link
Owner Author

 var p = Promise.race([p1, p2, p3]);
 p.then(function(){
      p.cancel();
 });

That's a no-op, since p is not cancellable (cancellability means not pending and having a cancellation flag) at that point.


 var p5 = Promise.map([p1, p2, p3, p4], function(elem){
     p5.cancel();
});

Cancel will be no-op after the first call, but most likely the cancellation hook will reject an input promise which causes the whole map to reject and no other elements will be then iterated.


 p1 = getCancellablePromise();
 var p2 = p1.then(doSomethingElse);
 var p3 = p1.then(doSomething).then(function(){ p1.cancel(); });

No-op since p1 must be resolved at the point where you are calling p1.cancel()

@benjamingr
Copy link
Collaborator

That's a no-op, since p is not cancellable (cancellability means not pending and having a cancellation flag) at that point.

That makes sense although it's very common to want to cancel all other requests once one has resolved in races or somes.

causes the whole map to reject and no other elements will be then iterated.

That's kind of scary given that iteration might have side effects in peoples' code.

No-op since p1 must be resolved at the point where you are calling p1.cancel()

Still if you call p2.cancel there is a race condition - but that's inherent to what cancellations means though so that's irrelevant.

@petkaantonov
Copy link
Owner Author

That's kind of scary given that iteration might have side effects in peoples' code.

That's not related to cancellation though since any promise can also reject naturally

@petkaantonov
Copy link
Owner Author

it's very common to want to cancel all other requests once one has resolved in races

Which is ironic because it's very rare to need .race. I agree about .any and .some though - in that case there is the inconvenience of having to .cancel the input array manually after .any has succeeded. However, those methods can take option autoCancel if it's a real problem.

@UndeadBaneGitHub
Copy link

causes the whole map to reject and no other elements will be then iterated

Now this makes me kinda concerned, as it might look like this:

 var p5 = Promise.map([p1, p2, p3, p4], function(elem){
    // something really lengthy here
     p5.cancel();
});

This looks to me like a race condition with completely unpredictable behavior. What if p1, p2, p3 or p4 is a non-cancellable, but already iterated, as the first then took long enough?

@petkaantonov
Copy link
Owner Author

Replace p5.cancel() with anything that throws and you have exactly the same problem, no?

@UndeadBaneGitHub
Copy link

Fair enough, but I guess, this is a matter of what you want cancel to be and how it should be perceived.
If it is like "this is is just some sugar, but in the end the behavior is as unpredictable and (potentially) destructive to your app state as throw is" - then it is fine as it is. Personally, I think that is still a great improvement over the existing mechanism, and I am ok with that as a user - just always keep in mind, that cancel might kill your state. I mean, throw in a properly constructed code usually means "ok, this block is dead, let's clean it up and crash/start anew". At the moment, cancel resides at the very same place, and in the overhaul you've offered it stays there, but the is just "sugarized", which can lead to some confusion.
But if you want to go for the approach "cancel is a separate and predictable mechanism and not just a sugar for a specific exception type" I guess some greater overhaul is required, not sure which for the moment. Hell, I'm not even sure it is possible with async architecture.

@petkaantonov
Copy link
Owner Author

Well it doesn't have to be throw, just having the promise reject that you return from .map has same effect. Note that cancellation doesn't have to throw at all, you can for instance make the cancelled promise fulfill with null when it's cancelled.

@spion
Copy link
Collaborator

spion commented Dec 28, 2014

By its very nature .cancel() is a source of race conditions. If you think about it, the essence of the feature is to give you the power to interrupt one or more ongoing operations mid-flight from another chain of execution. The sync equivalent of cancellation is killing a thread (ok, its slightly better, but its pretty close once many promises are involved). There is just no way to make it safe and predictable.

We don't use the feature in our code, at all. In most of the cases we've encountered using it is just premature optimization. In other cases, the problem is better solved with streams. In the very few cases where its really necessary, we split up the operation into multiple atomic actions and race them with a rejectable (unresolved) promise which serves as a way to interrupt the execution.

@benjamingr
Copy link
Collaborator

I tend to agree with @spion - in general we use cancel in very few places in our code.

@UndeadBaneGitHub
Copy link

Well, I use cancel not as something like Thread.Abort or TerminateThread, rather as a "stop processing" message in the infinite message loop. Thus, all the promises are cancellable, and cancellation handlers properly handle the resources if called, but. It took quite a bit of effort to construct such a thing, so I would really prefer cancellation going the following way:
Say, we have that very example:

var p5 = Promise.map([p1, p2, p3, p4], function(elem){
    // something really lengthy here
     p5.cancel();
});

And p1, p3 and p4 are cancellable, but p2 is not. Thus, if all promises got iterated and then p5.cancel(); happens, p1, p3 and p4 should have their onCancel callback called, while p2, being non-cancellable, just run till the end of it.

This sounds to me like a more or less predictable behavior (yes, still a race condition and you don't know whether p2 was called at all), but resources-wise you can guarantee, that if p2 starts, nothing wonky would happen with the resources it uses, and p1, p3 and p4 must be implemented in a specific, cancellable way, or make the app unstable.
And the onCancel mechanics would separate this behavior from throw and reject ones.

@spion
Copy link
Collaborator

spion commented Dec 29, 2014

@UndeadBaneGitHub thats a rather interesting interaction between .map and cancellation. Do you have a slightly larger example that we could look at (with an infinite message loop)?

I know that .map is implemented differently (not just sugar) now, but if the code was written instead with Promise.all([p1,p2,p3,p4]).then(list => list.map(f)).all(), would you still expect f to execute on p2?

@UndeadBaneGitHub
Copy link

@spion I would really appreciate, if you wrote the code in a bit more detail.
If you mean this:

var p5 = Promise.all([p1, p2, p3, p4], function(list){
    list.map(f);
});
p5.cancel();

then list.map(f) would just not happen, if cancellation comes while all p1, p2, p3 and p4 have not yet been completed.
However, p2, if already iterated, must be allowed to finish in my variant of how cancel works.

@spion
Copy link
Collaborator

spion commented Dec 30, 2014

Its

var p5 = Promise.all([p1,p2,p3,p4]).then(function(list) { 
  return list.map(f); 
}).all();
lengthy.then(function() { p5.cancel(); });

@benjamingr
Copy link
Collaborator

Every C# person I talked to is against conflating cancellation with promises directly. I do think that the library should help users with that but perhaps it is best if it is not a part of the promise itself.

F# passes the token implicitly - I'll look into it.

@rogierschouten
Copy link

We've had a discussion about this internally, and we came up with the following:

  1. The suggestion by @petkaantonov to have the cancellation callback instead of the catch-thing is an improvement, especially since we tend to use catch for programming errors and not for run-time control flow (and we tell our linting tool to disallow Promise.catch())
  2. Propagation/composition is a different issue altogether and so far @petkaantonov's composition overview seems reasonable
  3. Our use cases fall into two categories: either very simple (propagation/composition not necessary) or too complicated to be able to use the built-in bluebird cancellation anyway - typically we need a 'nice' cancel (finish a piece of work and stop) and an abort-type cancel (e.g. destroy underlying socket so that the work fails).

So in short, we support both @benjamingr and @petkaantonov 's suggestions in that we like the proposal for the new cancellation, and simultaneously, that real-world problems often need something custom.

@benjamingr
Copy link
Collaborator

@rogierschouten thanks a lot for taking the time to do this and for the feedback!

So in your use cases propagation was not necessary? Just making sure we're on the same page:

 cancellableA().then(foo).then(bar).then(baz).cancel(); // this is propagation

@rogierschouten
Copy link

@benjamingr You're right I meant composition. Propagation is necessary.

@WebReflection
Copy link

@rbuckton answering about my POV on your benefits ...

  • you have to hook a token within Promise initialization because that's the only place you can actually meaningfully cancel the eventual resolve or reject ... I see overhead for no concrete advantage and a token inability to do a thing if not hooked inside anyway ...
  • due previous point, you don't actually separate a thing. You couple them instead, the moment you need the token upfront to define the cancelable Promise. You can separate the cancelation concern regardless with my proposal keeping the cancelable Promise (the token in your case) for yourself and passing a Promise that is not cancelable. No need to introduce anything new
  • if you can cancel you have same ability, token or not token. I've written 3 proposal and all of them implicitly or via monkey patching give you the ability "to graph" complex scenarios
  • this is scary ... multiple shared token holders capable of messing with a Promise passed around without the token ... How about you just have a cancelable Promise around anyone is aware and can react accordingly? How about holding it and passing a non cancelable Promise instead? Anyway, I don't even understand this use case
  • I strongly believe in scenarios where Promises may not be appropriate, you should not use Promises ... the token ain't gnna save anyone from such huge mistake. If it's encouraging instead because tokens are cooler to cancel Promises then we have even more broken Promises around: use them for whatever inappropriate case 'cause token gonna screw them all? Not sure I like this future for JS programming.

Last, if you do like the idea of having an additional method on the Promise prototype for observing the cancellation then all this decoupling you talked about would be even more messed up 'cause you might realize it is cancelable, and nobody gave you that power, holding who knows where, and in how many, that "big red button"

My 2 cents

@ProTip
Copy link

ProTip commented Jun 3, 2015

I spent quite a while looking into this the other day and as somebody new to coffeescript/nodejs but with a lot of C# experience I'm pretty dead-set on have a good async workflow.

Unfortunately when it came time to use cancelling to stop a coroutine I fell into a quagmire. I need to be able to treat async functions(coroutines) as promises so I can yield them or bundle them up as required but for the life of me couldn't figure out any cancellation support on them.

For what it's worth, I found somebodies cancellation(tokens) package and it has worked fine for me; I'm off to the races and dev moves forward.

@fresheneesz
Copy link
Contributor

I didn't see this issue, and wrote this issue: #663 (comment)

@WebReflection What happens if cancellation occurs after a couple thens in the chain have already been called? Like this:

var yourProcess = new Promise(function ($res, $rej, ifCanceled) {
  var internal = setTimeout($rej, 1000);
  ifCanceled(function () {
    clearTimeout(internal);
  });
})
.then(function () {
    console.log('on time');
    yourProcess.cancel({because:'reason'})
})

yourProcess.catch(function () { // does it stop here? I feel like it should
    console.log('did i catch it?');
    //yourProcess.cancel({because:'reason2'}) // what if cancellation happens here instead?? Is it too late?
})
.then(function (value) {
  console.log(value);
});

I think for cancellation to be appropriately powerful, you need to somehow define all the individual promises that should be cancelled - identifying just one promise and then propogating that to all ancestors or all descendants doesn't cut it. The easiest way to define a list of all the individual promises you're likely to mean is to define a range - say that all promises between A and B are cancelled.

If the way you do this is to define a new Promise chain and call cancellable on it, I think cancellation ranges could be pretty easy to define. Example:

var  A = new Promise(function(ra,ta,ifCanceledA) {
  ifCanceledA(function() {
    console.log("A cancelled")
  })

  var B = new Promise(function(rb,tb,ifCanceledB){
    // event 1
    ifCanceledB(function() {
      console.log("B cancelled")
    })
  }).then(function() {
     console.log("B done")     // event 2
  }).cancellable()

  return B.catch(function(e) {
    return "B's cancellation caught" // event 3
  })
}).then(function() {
   console.log("A done")     // event 4
}).cancellable()

There are a few different important scenarios:
I. If A is cancelled anytime before event 4 happens, "A cancelled" is printed, and A is rejected with a CancellationException
II. If A is cancelled after event 4, "A done" prints, and A is rejected with a CancellationException
III. If B is cancelled anytime before event 1 happens, "B cancelled" is printed, then "A done" is printed, and A resolves to "B's cancellation caught".
IV. If B is cancelled after event 1 and before event 2, "A done" is printed, and A resolves to "B's cancellation caught".
V. If B is cancelled after event 2, same thing happens as in step 4 except "B done" is printed first

So in steps III, IV, and V, A isn't cancelled because cancelling B strictly defines the callbacks that can be cut off as the ones that make events 1 and 2 happen. This way you can define and pass around arbitrarily specific chains that can be cancelled, even if they're nested inside other changes, or called in two different chains, without affecting promises outside that defined range.

I think this is simpler than a cancellation token and yet just as powerful (if not more so).

Thoughts?

@bergus
Copy link
Contributor

bergus commented Jun 18, 2015

@fresheneesz I think you'll want to read #565. Things like not propagating cancellation if there are other callbacks waiting for a promise are already implemented :-)

@fresheneesz
Copy link
Contributor

@bergus What part of my post are you addressing? My proposal there covers a lot more than just that.

@WebReflection
Copy link

@fresheneesz ...

I think for cancellation to be appropriately powerful, you need to somehow define all the individual promises that should be cancelled

cancellation should be possible at any time for chainability reason which is part of the strength of Promises ( .then().then().then() ) so if it wasn't canceled already, it should cancel the very first encountered cancelable promise in the chain.

If already canceled, nothing happens, you keep going as you would have done in any case. This is inevitable unless you want to expose canceled as property and behave accordingly but I think that's superfluous.

Since it's the author of the Promise to decide its cancelability, whoever want to return a cancelable promise in the chain can simply do it, canceling the external promise when its new one with its new cancelability is invoked.

var  A = new Promise(function(ra,ta,ifCanceled) {
  ifCanceled(function() {
    console.log("A cancelled")
  });

var B = new Promise(function(ra,ta,ifCanceled) {
  ifCanceled(function() {
    A.cancel();
    console.log("B cancelled")
  });

at that point you can simply pass B around ... I think this a very simplified solution but for all use cases I could think of it should just works.

@fresheneesz
Copy link
Contributor

@WebReflection

if it wasn't canceled already, it should cancel the very first encountered cancelable promise in the chain

Do you mean the very first cancelable promise in the chain's ancestry, or do you mean its descendancy? It would be much clearer for me personally if you could address the specific example I brought up.

whoever want to return a cancelable promise in the chain can simply do it, canceling the external promise when its new one with its new cancelability is invoked.

And how do you program something if you want to only cancel that internal promise, and not the external promise? This is what my proposal addresses.

@WebReflection
Copy link

sorry, ancestry of course, you can decide to pass around a cancelable Promise you should never be able to cancel Promises you don't own or didn't receive ... so ancestry or nothing.

And how do you program something if you want to only cancel that internal promise, and not the external promise? This is what my proposal addresses.

You wrap it through a cancelable Promise as my example does ... it passes around B that once canceled can cancel A too. Whoever receives a Promise, receives B, and will be unable to directly cancel A.

@fresheneesz
Copy link
Contributor

ancestry or nothing

Makes sense

it passes around B that once canceled can cancel A too

In the last example you posted, A and B aren't related in any way except that if B is cancelled A is cancelled. I'm much more concerned with promise chains, not individual promises.

I realize I'm joining this discussion late, and I don't want you to repeat everything you've already said just so I can understand, but you mentioned you wrote "3 proposals", which I assume are API proposals, and I can't find them either in the esdiscuss.org link that petka gave, or in this issue comment thread. Is there a current work-in-progress proposal we are discussing?

Also, do you understand the proposal I put forth? What are the shortcomings you see in it?

@fresheneesz
Copy link
Contributor

One thing I just thought about, if you have some conceptual processes X, A, and B, where A and B are parts of X, like this:

X {
  A {
    // ...
  }
  B {
    // ...
  }
}

Cancelling X should cancel all of process X, including processes A and B. But if what a cancellation does is create an exception that propogates, something inside A might catch and "handle" that cancellation, so that part of process A and all of process B actually continues. This isn't what you would want in a cancellation right? It looks like some people in this thread have balked at having a third state ("cancelled"), since it wouldn't match spec, but that seems like the cleanest way to handle it. You don't want some unknown inner process catching the CancellationException and overturning the cancellation. How else would you get around this without having a 3rd state - the cancellation state?

@WebReflection
Copy link

@fresheneesz you cancel what you want to cancel and what you have access to or what you create as cancelable. You should really think it at that simple logic, and no magic involved.

whatwg/fetch#27 (comment)
whatwg/fetch#27 (comment)
whatwg/fetch#27 (comment)
whatwg/fetch#27 (comment)
whatwg/fetch#27 (comment)

but you should really probably take your time and read the entire thing there and not just my opinion or code examples.

Anyway, I'm really done here because there's nothing more I need to add or to understand and mostly everything has been told already.

I'm every day more convinced Promises are just the wrong solution for anything asynchronous that might need to be dropped without needing to throw an error.

Promises are great for small/quick things, like pressButton.then(switchLightOn);, where you won't even have time to change your mind or it's just cheap to pres the button again and switch it off.

Promises are also great for server side tasks where you want that your full chain of asynchronous actions is executed from start to end without problems ... no interference there meant, wanted, or needed.

However, if you use Promises for chains that involves unpredictable amount of time to execute you'll realize that patterns like pressButton.then(closeDoors).then(reachFloor).then(openDoors) will chop people that will pass in the middle while the doors were closing; unable to simply re-open and close again after, without needing to throw and trash the entire chain with an error.

It's not an error, it's just a little change to the very same initial action of reaching the floor, user shouldn't need to start the action again or be notified with an alarm that an error occurred.

We should never forget the importance of the time variable and we also don't have crystal balls.

If you need to change an action that is taking time to execute you, as developer, should be able to do that. This is how pragmatic I believe this matter is, this is how .abort() worked so far and pretty well, so you could react through a listener or just ignore it: you were in control.

Internally, indeed, every Promise can be somehow "aborted", but I guess we just like playing philosophy on user-land and we are also often those very same that create problems to ourselves ^_^

Well, at least it's never boring here, but I'm a bit tired of this topic and I simply avoid Promises whenever it makes sense doing it.

I do hope this entire story taught us at least that using Promises for everything asynchronous is a bloody footgun and that events still have many valid uses cases and applications that should probably never be replaced with Promises .... or let developers wrap them when it's convenient, so that everybody wins, and everyone can control "the flow".

Best Regards, please contact me privately if you'd like to discuss more. I'm off this thread now.

@spion
Copy link
Collaborator

spion commented Jun 19, 2015

@WebReflection I think that BB 3.0 cancellation semantics may change your mind once they're released. We'll see...

@bergus
Copy link
Contributor

bergus commented Jun 19, 2015

@fresheneesz

It looks like some people in this thread have balked at having a third state ("cancelled"), since it wouldn't match spec, but that seems like the cleanest way to handle it. You don't want some unknown inner process catching the CancellationException and overturning the cancellation. How else would you get around this without having a 3rd state - the cancellation state?

There are two points:

  • the "cancelled state" is equivalent to the rejected state with a CancellationError for the reason
  • cancelling means cancelling the callbacks before the promise is rejected. With "forget semantics", there is nothing that would observe or even "catch" the cancellation error.

There is no such thing as "overturning" because the descendant chain is already cancelled.

@spion
Copy link
Collaborator

spion commented Jun 19, 2015

@bergus except finally callbacks to run the cleanups. I disliked it at first, but after reading all the arguments I think I'm sold! :)

@fresheneesz
Copy link
Contributor

@WebReflection Thanks for the list of examples, that helps a lot! I definitely agree that events have a completely different use case than promises, and any case where you have an event happening multiple times, promises don't help you there.

In any case, I think I understand a lot more about what you said about it being more simple.

@bergus Yeah, i was actually thinking of a case where you define different cancelable processes in the same chain like you wrote up on march 16th, like A.then(B).then(C) and you want to have the choice of only cancelling B and also cancelling A, B, and C. I see now this just isn't the right way to define separate processes, and you should instead do something like: A.then(function(){return B}).then(C) in which case its very clear what should happen if you cancel B - A is left intact if B catches its cancellation, but A gets a cancellation error otherwise. So I feel a little more ok about not having a third state.

But cancellation is complicated, and there are a couple cases I want to bring up:

  1. For var x = A.then(function(){return B}).then(C), if B is cancelable, and x is cancelled during process B, process B should also be cancelled
  2. UNLESS B is being used by some other process that hasn't been cancelled, in which case, it should only be cancelled if all processes its being used by are cancelled,
  3. EXCEPT when B is still needed by a future process, and we want it to keep running so as to keep latency down. In this case, if we followed the ideas in 1 & 2, we would need some explicit way to mark B as not cancellable by its users. Something like var B = processB.selfCancellable(), defining it as only cancellable if B.cancel() is called, and not when processes using it are called. Alternatively, we could go the other way around, and require a special marking for processes that can be cancelled by processes using it. Something like processB.parentCancellable().
  4. For var x = A.then(function(){return B}).then(C), if B is cancelled, there should be some way to define x as dependent on B, and cancel x when B is cancelled. This one should be easy, just B.catch(Cancellation, function() {x.cancel()})
  5. Cancellability should be easily definable for a whole promise chain, without having to explicitly define something for each part of the chain, so that if your chain has 10 steps, you can cancel on step 5, and only the cancellation callbacks (ifCancelled) for steps 6-10 will be called.
  6. If you do cancel a chain of 10 steps, the error should be caught by a step later than 10 regardless of there being any catches on steps 10 or less. I'm unsure whether or not catches on steps 10 or less should be called.
  7. If you have a branching continuation like: var x = A.then(B); var y = A.then(C), if x is cancelled, B should be cancelled, but A should not be cancelled, for the same reason as in case 2, with the same caveat as in case 3

Are these cases kind of in line with how bluebird's v3 cancellation is currently being designed?

@bergus
Copy link
Contributor

bergus commented Jun 20, 2015

@fresheneesz Thanks for your input, yes cancellation is complicated :)
I'll try to address all of your points:

For var x = A.then(function(){return B}).then(C), if B is cancelable, and x is cancelled during process B, process B should also be cancelled

It is - supposed that A is fulfilled and B has not already settled.

UNLESS B is being used by some other process that hasn't been cancelled, in which case, it should only be cancelled if all processes its being used by are cancelled

Yes, if B is used elsewhere (and has other uncancelled callbacks attached to it), it won't be cancelled. Only the callback that resolves the A.then(() => B) promise will be "forgotten" about.

EXCEPT when B is still needed by a future process, and we want it to keep running so as to keep latency down. In this case, if we followed the ideas in 1 & 2, we would need some explicit way to mark B as not cancellable by its users. Something like var B = processB.selfCancellable(), defining it as only cancellable if B.cancel() is called, and not when processes using it are called. Alternatively, we could go the other way around, and require a special marking for processes that can be cancelled by processes using it. Something like processB.parentCancellable().

Not as complicated as this. You just would do var bForFuture = B.then(); and B is not cancelled by our process until bForFuture is as well.

For var x = A.then(function(){return B}).then(C), if B is cancelled, there should be some way to define x as dependent on B, and cancel x when B is cancelled.

x is automatically dependent on B as soon as A fulfills and the callback returns B. At that point, B can't be cancelled until x is cancelled.
If B is already cancelled by the time A fulfills, then x is rejected with the cancellation error.

Cancellability should be easily definable for a whole promise chain, without having to explicitly define something for each part of the chain,

Indeed it is. then does create a new promise that is dependent on its ancestors and will propagate cancellation automatically, no explicit syntax required.

so that if your chain has 10 steps, you can cancel on step 5, and only the cancellation callbacks ( ifCancelled ) for steps 6-10 will be called.

Not sure what you mean. If there are still the steps 6-10 depending on the promise 5, you cannot simply cancel that. You have to cancel promise 10 first.

If you do cancel a chain of 10 steps, the error should be caught by a step later than 10 regardless of there being any catches on steps 10 or less. I'm unsure whether or not catches on steps 10 or less should be called.

Again, there is no error to be caught when you cancel anything. The only error you will receive is the one when you then on an already cancelled promise.

If you have a branching continuation like: var x = A.then(B); var y = A.then(C), if x is cancelled, B should be cancelled, but A should not be cancelled, for the same reason as in case 2, with the same caveat as in case 3

Yes, A isn't cancelled, it's still needed for y. Unless you "forcibly" cancel A (not sure whether or how that may be possible), but in that case y would be rejected.

@fresheneesz
Copy link
Contributor

@bergus Thanks for the responses!

If there are still the steps 6-10 depending on the promise 5, you cannot simply cancel that. You have to cancel promise 10 first.

Yes I meant "you can cancel [the promise for step 10] on step 5, and only the cancellation callbacks .. for steps 6-10 will be called."

Again, there is no error to be caught when you cancel anything. The only error you will receive is the one when you then on an already cancelled promise.

So lets say you have var x = A.then(B).then(C). If you cancel x inside B, what happens to B? Does it have some kind of ifCancelled that will be called (if its defined)?

In any case, sounds like 3.0 cancellation will be pretty great! Would love to see the docs for the proposed/WIP 3.0 cancellation API.

@bergus
Copy link
Contributor

bergus commented Jun 21, 2015

@fresheneesz

you can cancel [the promise for step 10] on step 5, and only the cancellation callbacks .. for steps 6-10 will be called.

Yes indeed, if the promises up to step 5 are already settled, then they are no more cancelled.

So lets say you have var x = A.then(B).then(C). If you cancel x inside B, what happens to B? Does it have some kind of ifCancelled that will be called (if its defined)?

If we have x = a.then(function B(){ x.cancel(); return b; }).then(C), then it means that C is never executed, x is rejected with a cancellation error, and when the B callback returns a promise then that b value is cancelled as well.

@fresheneesz
Copy link
Contributor

@bergus Thanks for the explanation. Sounds like its everything I'd want in cancellation!

@petkaantonov
Copy link
Owner Author

Fixed in 3.0 release

@bergus
Copy link
Contributor

bergus commented Oct 27, 2015

Nice! I see there still are many documentation holes to fill.
I guess I'll open another issue about the questions left open in the 3.0.0 docs

@Artazor
Copy link
Contributor

Artazor commented Oct 27, 2015

Congrats!

@petkaantonov
Copy link
Owner Author

I just left the docs partially undone because otherwise 3.0 would never be published :D

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests