Skip to content
This repository has been archived by the owner on Oct 7, 2020. It is now read-only.

No standard way to cancel promises / async operations #10

Closed
itaysabato opened this issue May 20, 2018 · 30 comments
Closed

No standard way to cancel promises / async operations #10

itaysabato opened this issue May 20, 2018 · 30 comments

Comments

@itaysabato
Copy link

And also no reasonable way to implement it in userland.

For instance, one can override native promises with Bluebird promises which expose an optional cancellation API, but this API does not inter-operate with modern async constructs such as async functions, generators and iterators.

IMO, this (not to mention the forgoing of native promises altogether for cancellation's sake) is a price too high to pay in the long run.

Suggestion

Without forcing the community to agree on a "right" way to implement cancellations, hooks can be provided to allow developers a way to standardize cancellations in their respective applications and in the way they see fit.

This reference implementation demonstrates a possible implementation of a "Bluebird-style" cancellation API on top of native promises via a promise interception hooks API that I drafted therein.

Both the implementation and the suggested interception API should be considered as a proof of concept that may be incomplete and possibly buggy.

Additional use cases

These kinds of interception hooks could prove useful for implementing additional use cases, e.g. for debugging tools etc.

I will add these additional use cases as separate issues that will reference this one.

@getify
Copy link
Contributor

getify commented May 21, 2018

Just figured I would point out that I vehemently dislike the idea of promises being cancelable. Async operations should be cancelable, of course, but promises should only represent the observation of an event being completed, not some reverse hook to control it before it has finished. This topic has been debated at length, and any mechanism which allows for a promise to be used to signal a cancelation "back up the chain" will be opposed by many of us for all the same reasons that have been brought up.

As for async functions being cancelable, I think cancelation tokens is how that should work. I built CAF to model an approach to that.

@benjamingr
Copy link
Member

@getify

I think that the focus here isn't whether or not ECMAScript or Node.js should provide cancellation or promise cancellation but rather whether or not we should allow for hooks that enable that.

I'm very hesitant to even debate promise cancellation or cancel tokens - but I'm definitely in favor of adding hooks that enables tools to establish user-land solutions.

@benjamingr
Copy link
Member

benjamingr commented May 21, 2018

For example, if I understand the request of @itaysabato correctly (not sure I do) - it would enable CAF to work with async functions.

@getify
Copy link
Contributor

getify commented May 21, 2018

Adding hooks on async functions is fine. I was objecting to adding hooks to promises. :) Related: #11

@itaysabato
Copy link
Author

Hi @getify,

If I understand correctly, we both agree that:

  1. Async operations (such as async functions) should be optionally cancellable.
  2. The best way to expose a "cancellation controller" (let's call it a token from now on) is for it to be returned rather than be passed forward.

What you disapprove of is:

  1. Making promises into something they are not - a controller rather than a mere observational tool.
  2. Taking away control from the initiator of the async operation - he and he alone should decide whether and how the operation could be cancelled and by whom.

I tend to agree with both points, but:

  1. This is mostly a semantic problem. Instead of calling the promises cancellable, let's just say they (optionally) piggyback a cancellation token for the async operation that they observe.
  2. Bluebird makes every promise cancellable but this is not set in stone in my view. The initiator of an async operation (be it an async function, generator, or new Promise call) should be able to deny cancellation from its consumers. It simply means it will not propagate the cancellation upward and will not suppress a future resolution (nor will it explicitly abort anything).

In any case, as @benjamingr said, these hooks may allow for "more things being cancellable" than you like, but by subsumption you can implement your flavor of cancellability without explicitly passing around tokens. There could be a few libraries, each implementing a different API for cancelling async operations and the market will decide for itself what is best.

I want to stress this point again: just because we say we are "canceling a promise" doesn't mean that the promise itself is cancelled - which is semantically impossible - what we are really doing is "cancelling the async operation this promise observes (if possible) and ignoring its resolution anyway".

@getify
Copy link
Contributor

getify commented May 22, 2018

I'm not sure if the critical point is being missed here, regarding "promise cancelation". What is vital to NOT allow is for there to be two observers of a promise, in ostensibly different parts of the application, and one of them gets to decide to "cancel" the original operation, thereby affecting the other observer without them knowing or agreeing. This is called action-at-a-distance and is almost universally considered poor design.

If I have a promise, no one else should be able to affect my ability to observe it, except the originator of the operation. Observers are just observers, not manipulators.

Moreover, the most critical part of a promise's design is that you be able to trust that it represents a reliable and immutable (once resolved) result of an operation. If you can't trust that because the promise itself exposes state mutators (like cancelation), then the promise is almost entirely useless. The majority reason for promises being useful is their trustability.

@benjamingr
Copy link
Member

I'd really rather not focus on whether or not we should do promise cancellation, what the right solution is or how to do it to be honest.

I'd like to see what hooks Node.js or V8 can expose in order to allow users to better prototype solutions - ideally a hook would allow both CAF and bluebird like cancellation of async functions so these solutions can be prototyped in Node.js

@getify
Copy link
Contributor

getify commented May 22, 2018

CAF is able to "cancel" an asynchronous operation because it actually uses generators, and thus is able to simply abort immediately with return(..) even if a promise is pending. I don't think JS would provide any way, unless your hook was REALLY deep in the machinery, for an async function to be aborted in the middle of it, in the same way.

Of course, I'd love for regular async functions to be cancelable in that same way, but I can't envision any path where that happens, if for no other reason than that it would be backwards-incompatible for JS to ever start allowing that.

@getify
Copy link
Contributor

getify commented May 22, 2018

I'm sorry to be such a pest on this topic -- I will leave it alone after I post this message -- but the reason I'm being so persistent about objecting to any hook that makes a promise cancelable is, I consider that such bad practice that I think it's irresponsible for any platform to even consider it.

IMO, it's in the same boat as extending/overriding native prototypes. We've all known for more than a decade that this is terrible practice. No amount of, "but hey I really would like to do ____" overcomes that it's a terrible practice.

The overall spirit of what you're exploring here -- what hooks can we provide to let users explore potential fixes/extensions -- is important and I'm thankful and supportive of it... but not all things that you CAN expose, SHOULD be exposed.

Imagine someone asked, "Hey, could you provide a hook so I could modify the + operator so that it truncates addition operations to integers?" COULD that be provided for developers to play around with? Sure. Is there any use-case which might enjoy that capability? I imagine so. But should the platform expose it? Absolutely not.

@benjamingr
Copy link
Member

benjamingr commented May 22, 2018

unless your hook was REALLY deep in the machinery

I am not shying away from that.

Of course, I'd love for regular async functions to be cancelable in that same way, but I can't envision any path where that happens, if for no other reason than that it would be backwards-incompatible for JS to ever start allowing that.

So basically - my goal in the summit at the end of the month isn't to provide solutions. I intend to:

  • Present use cases regarding capabilities developers ask for
    • am planning to focus on debugging and tooling by the way
  • Present various things that userland solutions can do and native can't
    • Like the fact it's impossible to get a full async stack trace in mocha because async stack traces are hidden from JS at the moment.
    • Like the fact libraries like lolex can't work with promise returning or async functions because it is impossible to flush the microtick queue.
    • Like the fact libraries like express can't detect that a request finished but there are "live" promises created in it and warn.
    • Like the fact it's impossible to create bluebird-like warnings (for creating a promise but not returning or awaiting it) in userland at the moment.

"Cancellation" is one thing I can present, I wasn't planning to focus on it or ask for it but it looks like people care deeply - so I intend to try and explore what hooks we can expose. We do not intend to break ECMAScript semantics in either case or provide promise cancellation in Node.js.

I want to bring hooks that will enable userland solutions to prototype things.

I will leave it alone after I post this message

Your participation here is welcome, you are welcome to comment and discuss this for as long as you're willing and I promise to do my best to read everything carefully and bring it into consideration in the meeting.

In fact, I would be very interested in what pains you have when users debug apps written with CAF and what we can do to give them a better user debugging story.

benjamingr pushed a commit that referenced this issue May 25, 2018
@benjamingr
Copy link
Member

Codefied in a way that does not imply a solution is preferable to another in

https://github.com/nodejs/promise-use-cases/blob/master/use-cases/extras/2/extras-2.md and https://github.com/nodejs/promise-use-cases/blob/master/use-cases/extras/1/extras-1.md

@itaysabato
Copy link
Author

itaysabato commented May 25, 2018

Cool!

BTW as I was reading https://github.com/nodejs/promise-use-cases/blob/master/use-cases/extras/1/extras-1.md I thought a much much simpler API could also be very helpful in this respect:

What if you could simply pass a promise returned by an async function to an API and get back a "controller" for that function?

Then we can do something like:

const p = someAsyncFunction()
const controller = magicApi(p)
return {cancel: () => controller.return(), p: Promise.resolve(p)}

@benjamingr
Copy link
Member

@itaysabato if you could write or add a document looking at different approaches hooks can provide in order to deal with the use case that would be appreciated.

I am not looking at solutions yet but rather exposing capabilities.

@itaysabato
Copy link
Author

@benjamingr sure, do you mean discussing several options for hooks/APIs that could be used to solve the use case?

@benjamingr
Copy link
Member

@itaysabato yes - just to present them to V8 or Node

@overlookmotel
Copy link

@getify You said:

This topic has been debated at length, and any mechanism which allows for a promise to be used to signal a cancelation "back up the chain" will be opposed by many of us for all the same reasons that have been brought up.

I'd be very interested to read this debate. I have used bluebird's ability to do exactly what you are opposing and found it a much clearer and more composable interface for cancellation than tokens. But I know many people more experienced that me don't like it.

Could you point me in the right direction to read more on this please?

@getify
Copy link
Contributor

getify commented Jun 7, 2018

Here's two very long threads where many discussions were had about cancelable promises (there were others but can't find them ATM):

Also, the proposal for cancelable promises repo had several issues in it that may be of interest. Finally, I'd suggest checking TC39 meeting notes for several meetings they had about it, where eventually there was enough push back that the cancelable-promise issue was dropped.


TL;DR (those threads are really long)-

My main objection to cancelable promises is that it creates action at a distance, which is generally held to be bad system design. If an async operation produces a promise, and five different parts of the application get a copy/reference to that promise, now there are five independent observers of the resolution of that operation.

Let's say one of them decides it wants to "cancel" the original operation. Should they be able to unilaterally decide from that vantage point that none of the other 4 observers should get to observe the resolution? If so, that's action-at-a-distance. Put a different way, if I hand you a promise so you can observe my operation, am I really ok that I also handed you the ability to cancel my operation out from underneath me?

A promise, at its core concept, is supposed to be an immutable and trustable time-independent representation of a future resolution/completion. Holding a reference to a promise and being able to unilaterally alter its state (by canceling it) is violating this principle, and fundamentally making promises less trustable.

Two last points:

  1. I think what most people want from "promise cancelation" is actually "promise un-registration of a then() handler". IOW, I don't really care to actually cancel the original operation, it's just that this part of my app no longer wants to observe it. I proposed here a unthen(..) / uncatch(..) / unfinally(..) handler for unregistering such handlers, to stop observing a promise. Doesn't cancel the original operation, though.

  2. If you want to cancel an async operation, that's a separate capability from wanting to observe its resolution. Those two capabilities should not be conflated. We should have either had a "controller" (with both promise and cancel as separate pieces), or we should have cancelation tokens. But in either case, we need to keep cancelation semantics separate from observation semantics.

@bmeurer
Copy link
Member

bmeurer commented Jun 7, 2018

This is something that came up quite often also during the Node Collaborator Summit. Here's my (brief) take on this: It doesn't make sense to think of cancellation in terms of promises. Promises are placeholders for values, and you don't cancel values. Instead cancellation is something that makes sense for operations.

@itaysabato
Copy link
Author

@getify I would like to address the issue of multiple consumers, and how it is solved in Bluebird (and therefore not really an issue).

First of all, the case of:

If an async operation produces a promise, and five different parts of the application get a copy/reference to that promise, now there are five independent observers of the resolution of that operation.

is very rare IMO. In the vast majority of cases, a promise has a single consumer - usually the initiator of the async operation which passes its own caller the same promise or a child thereof.

Let's say one of them decides it wants to "cancel" the original operation. Should they be able to unilaterally decide from that vantage point that none of the other 4 observers should get to observe the resolution?

No. In Bluebird cancellation semantics, if multiple consumers depend on the same promise, a single consumer cannot cancel it - if only a subset of consumers tries to cancel (via a child promises) it will only be "unregistered", as you say.

I agree that, semantically, "unregistration" and cancellation are two distinct abilities and that promises were not designed to act as controllers. Nevertheless, the way I see it, functionality trumps semantics and since in ~99% of practical use cases the initiator of the async operation, the consumer of the promise, the canceller of the operation and the "unregisterer" of the promise are all the same logical entity, the simplest most elegant solution that provides the best functionality is using promises as handles for cancellation.

Since mechanisms as mentioned above can be put in place to protect against the edge cases of multiple consumers, etc. it is really only that feeling that "we're doing something wrong" that is holding us back. Semantics can, and should, be extended and changed over time, and that's fine - to me it doesn't feel "wrong".

@benjamingr
Copy link
Member

In short and summing up the discussion from there: you're all right and there is no incompatibility.

  • Kyle is right that cancelling async flows makes more sense than cancelling promises in general.
  • Benedikt is right that promises are placeholders for values and cancelling values is meaningless.
  • Itai is right that "cancellation" of promises can be made to work and be sound.

The problem is with the term "cancellation". Bluebird doesn't do promise "cancellation" it does promise "disinterest".

In bluebird, you don't cancel promises, you express disinterest in them. When you "cancel" a promise all it does it communicate upstream that we are not interested in the value being passed. Whether or not any action was actually done about it somewhere (like cancelling an HTTP request) is transparent to the users.

We are talking about two different things - cancellation of functions (which are actions) vs. expressing disinterest in promises (unsubscribing from them).

(By the way - Kyle, promise "disinterest" is a little like your unthen issue with the added "if a promise reaches 0 listeners, it can in a way that has no impact on the call site unsubscribe from it").


Anyway, I don't think this is the right place to discuss promise disinterest or async function cancellation - especially given how contentious this topic has been historically.

What I'd love though is some discussion about how we can provide hooks for people to experiment with. I'd love it if there were 5 libraries out there that prototyped cancellation with async functions (of functions, promises or cats for that matter) so we can gather more data to present to the TC and to our own TSC when discussing cancellation.

@not-an-aardvark
Copy link

not-an-aardvark commented Jun 7, 2018

In Bluebird cancellation semantics, if multiple consumers depend on the same promise, a single consumer cannot cancel it - if only a subset of consumers tries to cancel (via a child promises) it will only be "unregistered", as you say.

@itaysabato If multiple consumers share the same Promise, bluebird has no way to tell which consumer cancelled it (or that there are multiple consumers at all). As a result, one consumer can cancel the Promise for the other consumer. Bluebird's refcounting semantics are only sound if each individual Promise is used by a single consumer, and each consumer calls .then() on its Promise in order to share it.

@itaysabato
Copy link
Author

@not-an-aardvark you are correct. Like I said it is very rare to have the same promise ref spread in different parts of the code.

It is very easy though to spread children of that promise instead of the original ref in order to avoid this.

The point is that this is a mere technicality that can be solved in various ways. Like @benjamingr said it would be best if we had tools to experiment with cancellation over native async operations (i.e. without replacing native promises).

@MayaLekova
Copy link

What I'd love though is some discussion about how we can provide hooks for people to experiment with.

That sounds like a good follow-up action item.

I saw the Solutions Exploration from the comments above and I'm wondering what other proposals do we have about the hooks API that will allow for further user-land experiments. For instance are the moments when hooks are executed in the promise lifetime sufficient?

@overlookmotel
Copy link

@getify Thanks for the links. I will read up on it before weighing in further in the debate.

For what it's worth, in my particular use case, I'm after "real" cancellation, not "unthen-ing". I want to abort the operation and get told when/if the abort is complete. i.e. cancel but maintain observation.

@benjamingr I have some thoughts about the hooks required. I got most of the way through implementing abort-style cancellation for bluebird last year but gave up as I realised that the inexorable rise of async/await was going to render it obsolete pretty soon. But that work gave me a good idea of the mechanics required.

Where would be best to put these thoughts? In this issue or elsewhere?

@benjamingr
Copy link
Member

I saw the Solutions Exploration from the comments above and I'm wondering what other proposals do we have about the hooks API that will allow for further user-land experiments. For instance are the moments when hooks are executed in the promise lifetime sufficient?

We have three people here who have experimented with this (well, four if you include me) - @getify (with caf) @overlookmotel (with abortable) and @itaysabato (with the exploration proposal).

If would be great if we could get you in a call/room/discussion to specify what hooks would be sufficient for exploration of these solutions.

@MayaLekova
Copy link

If would be great if we could get you in a call/room/discussion to specify what hooks would be sufficient for exploration of these solutions.

That would be very helpful definitely. I guess @bmeurer would like to join as well.
Do you prefer a call or keeping it as written discussion?

@overlookmotel
Copy link

I'd be happy to do a call/discussion, but would want to write it all up first anyway to make sure I have my thoughts all in order.

One thing that's apparent is that we also all may have different opinions on what "cancellation" means - so that could cause crossed wires unless we've each specified that first.

@benjamingr
Copy link
Member

One thing that's apparent is that we also all may have different opinions on what "cancellation" means - so that could cause crossed wires unless we've each specified that first.

I think the purpose is explicitly not to solve the language-level problem but to instead enable hooks that would allow more userland experimentation with async/await.

In fact, I think the fact you Itai and Kyle have a very different opinion on how cancellation should work is pretty great.

@getify
Copy link
Contributor

getify commented Jun 7, 2018

instead enable hooks that would allow more userland experimentation with async/await.

In that spirit, just some sketches of possibility:

  • if there was a hook that could be fired whenever anasync..await function is returning its value (ie, creating its promise), and that hook was able to override the return value, then you could take cancelation of an async..await function in very different ways.

    You could still return a promise, but return a cancelable promise. Or you could instead return a controller object that included both a promise and a cancelation token or other abort() method.

  • even better, if there was also a hook at the start of an async..await function call, it could create and inject a cancelation token into the call from the start, allowing the async function to respond to it all along the way.

  • if there was a hook fired whenever an await happens on a promise inside a specific async..await function, you could override that await to be on a Promise.race([..]) with a cancelation token, so that each await point in the function is sensitive to cancelation.

  • even more powerful would be a hook (probably the one fired at the beginning of the async..await function's call) that had access to an internal return() like method (similar to the one on iterators) that could forcibly stop an async..await function right in its tracks. You could then optionally wire this up to the cancelation token, etc.

@itaysabato
Copy link
Author

@getify These are good ideas and we can actually start implementing and experimenting with this kind of functionality right now by leveraging TypeScript's tslib module.

For instance, as a workaround for making bluebird cancellations work with async functions, I've created this module that replaces the implementation of TypeScript's default __awaiter function (from tslib). In a similar fashion, we can simulate the hooks that we would like to see from Node/v8, e.g. returning a controller instead of a promise, etc. and create modules that depend on these hooks that actually work (using transpilation) even before the real hooks are exposed.

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

No branches or pull requests

7 participants