DISCLAIMER: This was a working document now availabe in nodejs/TSC#807. No comments on the current gist will be accepted
On the TSC meeting of 2020-JAN-22, the TSC reached consensus regarding the need to have an Asynchronous Storage API in core.
Three PRs related to this topic are currently open, out of simplicity, we will refer to them by a name as of:
PR | Author | Name |
---|---|---|
#30959 | @Qard | executionAsyncResource |
#31016 | @puzpuzpuz | AsyncLocal |
#26540 | @vdeturckheim | AsyncContext |
The AsyncLocal proposal relies on the executionAsyncResource API. The AsyncContext proposal aims at working without executionAsyncResource, but should be rebased over executionAsyncResource when it is merged. A userland version of this API is available for testing purpose.
The rest of this document aims at comparing the AsyncLocal and the AsyncContext proposals. Both of these proposal introduce a CLS-like API to Node.js core.
Both proposals introduce a new class in the Async Hooks module. One is named AsyncContext and the other is named AsyncLocal.
Also, the name AsyncStorage has been discussed earlier.
This topic can easily be covered as a consensus on any name can be ported to any proposal.
.NET exposes an AsyncLocal
class.
AsyncLocals and AsyncContexts expose different interfaces:
AsyncContexts
const asyncContext = new AsyncContext();
// here context.getStore() will return undefined
asyncContext.run((store) => {
// store is a new instance of Map for each call to `run`
// from here asyncContext.getStore() will return the same Map as store
const item = {};
store.set('a', item);
asyncContext.getStore().get('a'); // returns item
asyncContext.exit(() => {
// from here asyncContext.getStore() will return undefined
asyncContext.getStore(); // returns undefined
});
});
AsyncLocal
const asyncLocal = new AsyncLocal();
const item = {};
asyncLocal.get(); // will return undefined
asyncLocal.set(item); // will populate the store
asyncLocal.get(); // returns item
asyncLocal.remove(); // disable the AsyncLocal
asyncLocal.get(); // will return undefined
asyncLocal.set(item); // will throw an exception
As the examples show, AsyncLocal exposes a synchronous API and AsyncContext exposes an asynchronous one.
The synchronous API is unopinionated and is very async/await
friendly.
The asynchronous API defines a clear scope regarding which pieces of code will have
access to the store and which ones will not be able to see it. Calling run
is an asynchronous operation that executes the callback in a process.netxTick
call.
This is intended in order to have no implicit behavior that were a major issue according to the domain post mortem. It is expected that the API will be used to provide domain-like capabilities.
Eventually, a synchronous API could be added to AsyncContext when the executionAsyncResource rebase is done. In this case, documentation will clearly state that using run
is the prefered method and that synchronous methods have less explicit behaviors.
Eventually, an asynchronous API could be added to AsyncLocal if there is a need for it.
AsyncContext exposes a method named exit(callback)
that stops propagation of the context through the following asynchronous calls.
Asynchronous operations following the callback cannot access the store.
With AsyncLocal, propagation is stopped by calling set(undefined)
.
An instance of AsyncLocal can be disabled by calling remove. It can't be used anymore after this call. Underlying resources are freed when the call is made, i.e. no strong references for the value remain in AsyncLocal and the internal global async hook is disabled (unless there are more active AsyncLocal exist).
AsyncContext does not provide such method.
AsyncContext
AsyncContext.prototype.getStore
will return:
undefined
- if called outside the callback of
run
or - inside the callback of
exit
- if called outside the callback of
- an instance of
Map
AsyncLocal
AsyncLocal.prototype.get
will return:
undefined
ifAsyncLocal.prototype.set
has not been called first- any value the user would have given to
AsyncLocal.prototype.set
AsyncContext propagates it's built in mutable store which is accessible in whole async tree created.
AsyncLocal uses copy on write semantics resulting in branch of parts of the tree by setting a new value. Only mutation of the value (e.g. changing/setting a Map entry) will not branch off.
AsyncLocal is a low-level unopinionated API that aims at being used as a foundation by ecosystem packages. It will be a standard brick upon which other modules are built.
AsyncContext is a high-level user-friendly API that cans be used out of the box by Node.js users. It will be an API used directly by most users who have needs for context tracing.
After an API (AsyncContext, AsyncLocal or another potential API) is merged, this roadmap might be followed:
- Releasing the API in the current version of Node.js (as experimental)
- Backporting the API to currently supported versions of Node.js (as experimental)
- Defining conditions for the API to get out of experimental
- Moving the API to its own core module and alias it from Async Hooks (tentatively for Node.js 14)
- Move the API out of experimental (tentatively when Node.js 14 becomes LTS)
This will enable us to iterate over Async Hook and maybe bring breaking changes to it while still providing an API filling most of Node.js users need in term of tracing through a stable API.
Regarding name: Maybe add a link to .NET
AsyncLocal
to let TSC know that this name is already used for something similar but with significant differences.Regarding Async/Sync API:
I think it should be noted that the async API has also it's pitfalls and I think they should be mentioned in some doc (either here or in the node docs).
In general I like APIs like
domain.run(fn)
orasyncResource.runInAsyncScope()
which avoid the need to exception safe matchenter
/exit
calls. Ifenter
/exit
are exposed additionally it's should be documented thatrun
is preferred.But there is an important difference between how this works for
domain
/asyncResource
andAsyncContext
:domain
/asyncResource
call the callback synchron. Consider following code:results in:
As you can see sequence has changed for AsyncContext but not for Domains.
Similar if an exception is thrown in
doSomething()
atry/catch
aroundwithContext()
or in the closure past toasyncContext.run()
will not help - it will end up in and uncaught exception.Regarding AsyncLocal does not provide a method to stop propagation.: This can be done by calling
asyncLocal.set(undefined)
.What I miss are the differences in propagation but maybe that's going too deep into the details:
AsyncLocal uses copy on write semantics resulting in branch of parts of the tree by setting a new value. Mutation of the value (e.g. if it is a Map/Object) is not triggering cow.
Regarding next steps: I would mention also that adding this to core could improve the situation with user land modules not using
AsyncResource
.Maybe it's out of scope for this discussion but I think we should also aim to get
AsyncResource
out of experimental - just keep the low level hooks experimental.