Quantcast
Channel: Node.jsタグが付けられた新着記事 - Qiita
Viewing all articles
Browse latest Browse all 8889

Everything about Cancellation of Promises

$
0
0

以下は英語版です。日本語版はまだ準備中です。申し訳ありません。今日中に公開するつもりです。

In this thorough post about JavaScript and Node.js you can learn about the history of cancellation of Promises; why it is relevant to Node.js and what you should be aware of when you try to use it with the async/await API.

This post is for you if you have a good insight into the JavaScript Promise API and some experience with Node.JS.

History

Ever since the Promise API was introduced to browsers in 2014, people were looking into what else can be done with Promises. The first relevant API that made it to browsers was fetch for HTTP requests.

The thing with HTTP requests is that they cost server resources and if you have a lot of requests that are sent to a server it costs money. This made particular Cancellation(which sounds better than Aborting) an important detail. With Cancellation, it would be possible to prevent further execution of a request that is not relevant anymore: resulting in faster clients and servers. :rocket:

The bluebird.js library offered even before Promise was shipped with browsers a very fast Promise implementation. And even though it changed in v3 and needs to be enabled, it does offer to this day a .cancel() method.

But it took another 4 years until browsers made the DOM class AbortController widely available as an effort to cancel fetch() requests. Pretty much at the same time as async/await became available! (Sidenote: The DOM is not part of the JavaScript specification)

For a complete picture: Promises appeared in 2015 in Node with v0.12. async/await became stable with v8 in 2017 but AbortController only became available with v15 in 2020, which means it is not stable at the time of writing! :scream:

If you want to use AbortController or AbortSignal in node < v15, the npm package abort-controller is available since 2018.

There have been other efforts surrounding this topic as well. Most prominently there is a new proposed tc39 spec (the group of people who develop JavaScript) that focuses on cancellation: proposal-cancellation.

Another interesting approach has been presented with CAF which uses generator functions instead of async/await to handle async workflows.

Problem overview

As the introduction to proposal-cancellation states there many processes that can profit from cancellation:

  • Asynchronous functions or iterators
  • Fetching remote resources (HTTP, I/O, etc.)
  • Interacting with background tasks (Web Workers, forked processes, etc.)
  • Long-running operations (animations, etc.)
  • Synchronous observation (e.g. in a game loop)
  • Asynchronous observation (e.g. aborting an XMLHttpRequest, stopping an animation)

But lets look at a concrete, JavaScript example code to see where the problem is:

asyncfunctiongetPage(list,page){constresponse=awaitfetch(`/data?list=${list}&page=${page}`)returnawaitresponse.json()}asyncfunctiongetList(list){letpage=0,hasMore=false,data=[]do{constloaded=awaitgetPage(list,page)data=data.concat(loaded.data)hasMore=page!==loaded.lastPagepage++}while(hasMore)returndata}constpromiseA=getList('users')promiseA.then(entries=>{/* do something */},error=>{console.error(error)})

This example loads a user-list on the server. But what happens if the user decideds to switch the UI from users to messages?

constpromiseB=getList('messages')promiseB.then(entries=>{/*...*/})

Now promiseA is started even though promiseB may not have finished yet.

In an effort to understand the challenge, lets try to stop promiseA without any new API.

letcurrentRequest=0asyncfunctiongetList(list){letpage=0,hasMore=false,data=[]constrequest=++currentRequestdo{console.log(`load ${list}/${page}`)if(request!==currentRequest){thrownewError('new request sent, stopping this one')}constloaded=awaitgetPage(list,page)data=data.concat(loaded.data)hasMore=page<loaded.lastPageconsole.log({hasMore,page,last:loaded.lastPage})page++}while(hasMore)returndata}

Now, any list that we fetch will automatically stop the previous request with an error.

This simple example is highlighting a few issues that kept the :

  • Who is in control? In this case, it's stopped internally. AbortSignal is externally but other concepts may share the control.
  • This is a "best-effort". Not everything can be stopped. HTTP requests go out and in our example, we don't even stop fetch. What we can do is prevent future action as best as possible.
  • It can resolve after abort(). In this example, we only stop before we send out the next request. This means that, even though we aborted the request, it could still resolve successfully!

Using the AbortSignal

If you look closely at the Node.js v15 API, you may notice that fs.readFile() has a new option: signal, and there is the experimental require('timers/promises') API that also allows setTimeout(200, { signal })(keep your eyes open for other examples!).

Let's see how to use them:

// Assumes Node.js v15const{setTimeout}=require('timers/promises')constcontrol=newAbortController()constpromise=setTimeout(()=>console.log('hello world'),500,{signal:control.signal})control.abort()

This will cause the following error:

Uncaught:
DOMException [AbortError]: The operation was aborted

Cool. :wink: But how about using the same API with the initial example?

asyncfunctiongetPage(...,signal){constresponse=awaitfetch(...,{signal})returnawaitresponse.json()}asyncfunctiongetList(...,signal){// ...do{constloaded=awaitgetPage(...,signal)// ...}while(hasMore)returndata}constcontrol=newAbortController()constpromiseA=getList('users',control.signal)control.abort()

Now getList(..., signal) will pass the signal to getPage(..., signal) passing it on to fetch, but what should we do with await response.json()? It doesn't have signal support but we can do this using .aborted:

asyncfunctiongetPage(...,signal){// ...if(signal?.aborted){thrownewError('aborted')}returnawaitresponse.json()}

Now we can be sure that no unnecessary .json call is triggered, neat.

But Wait!:thinking:.abortedcan be true. So, what happens here:

constcontrol=newAbortController()control.abort()getList('users',control.signal)

:scream:

The signal can be aborted at the start of a function. This means that a function that accepts a signal needs to check it at the beginning:

asyncfunctiongetList(...,signal){if(signal?.aborted){thrownewError('aborted')}// ..}

I had to learn this the hard way when I tried to use the abort event.

functionwaitForAbort(signal){returnnewPromise(resolve=>{signal.addEventListener('abort',resolve)})}

This never resolved when the signal was passed in aborted: This was hard to debug and I had to change it to:

functionwaitForAbort(signal){returnnewPromise(resolve=>{if(signal.aborted)resolve()elsesignal.addEventListener('abort',resolve)})}

To summarize what we have learned here:

  • We know who is in control: The piece of code that holds the control instance!
  • This is a "best-effort": we don't stop code that is finished.
  • A signal may be aborted: we need to abort early.

Good API

The fetch()-API wisely made signal optional. It will still work if you don't pass in a signal. You also may want to make signal optional. The API recommendation that I have currently:

asyncfunctionmyApi({signal}={}){}

This makes signal part of an optional options property that may very well be null.

If you use TypeScript - even just the .d.ts files - you might want to give signal the signature provided by abort-controller:

import{AbortSignal}from'abort-controller'interfaceMyApiOptions{signal?:AbortSignal}declarefunctionmyApi(opts?:MyApiOptions):void

But what about the error case? How can we differentiate a promise from being aborted to a promise with an error? The safest way is to
add a .code property to the Error instance:

asyncfunctionmyApi(opts?:MyApiOptions):void{if(opts?.signal?.aborted){throwObject.assign(newError('aborted'),{code:'ABORT_ERR'})}thrownewError('other error')}

This way we can check in an catch-block easily what error occured:

// Assuming top-level awaittry{awaitmyApi()}catch(error){if(error.code==='ABORT_ERR'){// do nothing}Handleerror}

We use the code ABORT_ERR because it is the same .code also used by Node.js. This way we will be future-proof! :dark_sunglasses:

Less noise, fewer mistakes

Adding if-causes and complex error statements to code is a sure way to make mistakes.

Let's refactor the code together to prevent those mistakes!

First step: Extend Error instead of Object.assign():

classAbortErrorextendsError{constructor(){super('The operation was aborted.')this.code="ABORT_ERR"this.name='AbortError'}}if(signal?.aborted){thrownewAbortError()}

This reduces the amount to type and makes sure we have the correct error. Our next step: extract the whole if block into a function:

functionbubbleAbort(signal){if(signal?.aborted)thrownewAbortError()}asyncfunctiongetPage(list,page,{signal}={}){bubbleAbort(signal)constresponse=awaitfetch(...,{signal})bubbleAbort(signal)returnawaitresponse.json()}

Side-note: I like the name bubbleAbort - it sounds like a bubble bath. :bath:

If we have to write bubbleAbort often, we do get quite a bit of duplication. Let's use bind() to make that simpler:

asyncfunctiongetPage(list,page,{signal}={}){constcheckpoint=bubbleAbort.bind(null,signal)checkpoint()constresponse=awaitfetch(...,{signal})checkpoint()returnawaitresponse.json()}

This looks better. In a further step, we extract this block into a function:

functioncheckpoint(signal){bubbleAbort(signal)return()=>bubbleAbort(signal)}asyncfunctiongetPage(list,page,{signal}={}){constcp=checkpoint(signal)constresponse=awaitfetch(...,{signal})cp()returnawaitresponse.json()}

Still a bit noisy. We can do better! :muscle:

functioncheckpoint(signal){bubbleAbort(signal)returnnext=>{bubbleAbort(signal)returnnext?()}}asyncfunctiongetPage(list,page,{signal}={}){constcp=checkpoint(signal)constresponse=awaitfetch(...,{signal})returnawaitcp(()=>response.json())}

That seems to be as good as it gets. Well done! :thumbsup:

In case you need an AbortSignal

Let's look a bit at another use case and say we use Promise.race() to abort the slower of two processes:

const{setTimeout}=require('timers/promises')functionresolveRandom(data,opts){returnsetTimeout(Math.random()*400,data,opts)}functionstartTwoThings(){constcontroller=newAbortController()constopts={signal:controller.signal}returnPromise.race([resolveRandom('A',opts),resolveRandom('B',opts),]).finally(()=>controller.abort())}

This will abort the slower one. So far so good! But, what happens if we still want to give control to the caller?

functionstartTwoThings({signal}={}){constcontroller=newAbortController()// ...}

Now, we have two signals?! This needs some creative problem-solving...

functioncomposeAbort(signal){bubbleAbort(signal)// signal could be aboertedconstcontroller=newAbortController()letaborted=falseconstabort=()=>{if(aborted)returnaborted=truesignal?.removeEventListener('abort',abort)controller.abort()}signal?.addEventListener('abort',abort)return{abort,signal:controller.signal}}

This is powerful, now we get a controller-like structure that can be aborted by itself and a parent process.

Using this new composeAbort helper we can go back to our initial task:

functionstartTwoThings({signal}={}){const{abort,signal}=composeAbort(signal)constopts={signal}returnPromise.race([resolveRandom('A',opts),resolveRandom('B',opts),]).finally(abort)}

Great, we have a working race function! this will surely come in handy!

Published on NPM

Wow! We got quite far, let's put AbortError, bubbleAbort, checkpoint and composeAbort together into an npm package and publish it...

Ooops, that's already done! :wink:

$ npm install @consento/promise --save
const{AbortError,bubbleAbort,checkpoint}=require('@consento/promise')

Recently, we extracted these functions from our code into a library published it with docs :tada:... and, it has a bit more to offer!

One thing is the raceWithSignal function: It makes that previous example even easier!

const{raceWithSignal}=require('@consento/promise')functionstartTwoThings(opts){returnraceWithSignal(signal=>[resolveRandom('A',{signal}),resolveRandom('B',{signal}),],opts?.signal)}

It also comes with wrapTimeout that takes care of a common problem: to cancel a promise when a timeout is reached:

const{wrapTimeout}=require('@consento/promise')awaitwrapTimeout(signal=>startTwoThings({signal}),{timeout:100})

And to finish things for today we have cleanupPromise. This is an alternative to new Promise. It supports cancellation, timeouts, and a cleanup method:

const{cleanupPromise}=require('@consento/promise')constcontroller=newAbortController()constp=cleanupPromise((resolve,reject,signal)=>{// Like regular promise but with added `signal` propertyreturnfunctioncleanup(){// Cleanup operation is called in place of finally}},{timeout:500,signal:controller.signal})

Final Words

That is all I can tell you about Cancellation at this time. I hope you enjoyed it! :smile_cat:

Are you as excited as I am about the advances that Node.js made?
With these few tricks, I feel comfortable creating cancelable APIs. Maybe you do too?

If you have any questions, don't hesitate to let me know in our issue tracker! :heart:

:wave: See you next year!


Viewing all articles
Browse latest Browse all 8889