Skip to content

Executive summary: Introducing a CLS-like API to Node.js core #807

Closed
@vdeturckheim

Description

@vdeturckheim

After a discussion with @Qard and @puzpuzpuz, it seems that the two current PRs are taking very different approaches and we did not find a common ground to merge them into one single PR.

The following document has been authored by me and reviewed by @Qard, @puzpuzpuz and @Flarna. It aims at giving full context to the TSC regarding both PRs.

Please let us know if there is any point we should clarify to help here.

Context: 3 PRs and 1 consensus

Context Document: Making async_hooks fast (enough)

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 @mcollina & @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.

Naming

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.

Interfaces

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
    });
});

AsyncContext also provide synchronous entrypoints but documentation highlights the risks of using them.

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

Synchronous vs. Asynchronous API

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.

A synchronous API has been added to AsyncContext too:

  • enterSync/exitSync which do not enforce scoping
  • runAndReturn(cb)/exitAndReturn(cb) which run the callback synchronously. The store is only available within the callback,

Eventually, an asynchronous API could be added to AsyncLocal if there is a need for it.

Stopping propagation

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).

Disabling

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.

Store type

AsyncContext
AsyncContext.prototype.getStore will return:

  • undefined
    • if called outside the callback of run or
    • inside the callback of exit
  • an instance of Map

AsyncLocal
AsyncLocal.prototype.get will return:

  • undefined if AsyncLocal.prototype.set has not been called first
  • any value the user would have given to AsyncLocal.prototype.set

Store mutability

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.

Overall philosophy

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.

Next steps

After an API (AsyncContext, AsyncLocal or another potential API) is merged, this roadmap might be followed:

  1. Releasing the API in the current version of Node.js (as experimental)
  2. Backporting the API to currently supported versions of Node.js (as experimental)
  3. Defining conditions for the API to get out of experimental
  4. Moving the API to its own core module and alias it from Async Hooks (tentatively for Node.js 14)
  5. 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.

EDITS: Status afyer the original document

Current status on Feb 4th 2020

executionAsyncResource
PR is still blocked as some resource mismatch in init hooks and executionAsyncResource().
This seems to be on a path to resolve.
Also, reused resources are still exposed which can introduce a memory leak if a destroy hook is not used. One of the main point of executionAsyncResource is to get rid of the need for a destroy hook originally.

AsyncLocal
PR is blocked by the executionAsyncResource issue.

AsyncContext
The PR can be merged and rebased over executionAsyncResource later.

There has been a few iterations regarding synchronous entrypoint.
After advise from @Qard comment and @Flarna comment only methods taking callbacks have been kept:

  • asyncContext.runAndReturn(cb): runs the callback synchronously. The context is entered before running the callback and exited when the callback has run.
  • asyncContext.run(cb): works the same as runAndReturn but asynchronously (within a process.nextTick).

The difference between the two entrypoints concerns error management as the asynchronous method will not throw errrors. Also, exit and exitAndReturn can be used to stop propagation.

Exposing unscoped methods (without callback) would introduce the following behavior:

emitter.on('x', () => { enterSync() });
emitter.on('x', => { /* this code is within context */ });
emitter.on('x', () => { exitSync() });
emitter.on('x', => { /* this code is outside context */ });

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions