Skip to content

Pardon Tech - Promise Causality

Node’s async_hooks API has been “experimental” since it was introducted in Node 8:

Stability: 1 - Experimental. Please migrate away from this API, if you can. We do not recommend using the createHook, AsyncHook, and executionAsyncResource APIs as they have usability issues, safety risks, and performance implications. Async context tracking use cases are better served by the stable AsyncLocalStorage API. If you have a use case for createHook, AsyncHook, or executionAsyncResource beyond the context tracking need solved by AsyncLocalStorage or diagnostics data currently provided by Diagnostics Channel, please open an issue at https://github.com/nodejs/node/issues describing your use case so we can create a more purpose-focused API.

We’re using it anyway!

Concurrent Serial Tests

Other http frameworks provide a single thread of execution, and a global environment object which is used to configure each request and response in turn.

With Pardon, we offer the same ergonomics of a “global object” (environment), but the values assigned to this object are independent of the execution order: only awaited updates are visible in the “global” object.

Underlying this is a tracking system that understands what happened to reach a certain point by way of a series of tracked values. (One might think of these as magic breadcrumbs).

With single-threaded (non-concurrent) usage, track(value) looks like it pushes a value onto a list and awaited() returns that list:

const {
track,
awaited
} = tracker();
track('a');
awaited(); // ['a']
track('b');
track('c');
awaited(); // ['a', 'b', 'c']

However in an async context, a tracked value won’t become visible until the execution that tracked it is awaited.

const { track, awaited } = tracker();
async function f() { await randomDelay(); track('f'); };
async function g() { await randomDelay(); track('g'); };
const pf = f();
const pg = g();
... // we could await a delay here and it wouldn't change the next value of awaited
awaited() // []
await pf;
awaited(); // ['f']
await pg;
awaited(); // ['f', 'g']
// further awaits of pf/pg here will not change the result.

Notice that awaited() above will always return the tracked values in awaited order, despite the fact that the tracking might have been in the reverse order due to the randomDelay()s.

This might feel a bit weird at first, but it is stable, as the composition/order of the awaited() values will be based only on the (stable) “lexical shape” of the code, rather than the unstable order of execution.

In tests, we use this to track assignments to a global environment value. We also track dependent requests that might be executed via script helpers to surface dependent requests in favor.

How this tracks

The tracked values are stored in nodes of an execution graph maintained with the async_hooks API NodeJS discourages using.

The nodes of the execution graph essentially have two links:

  • the init link is for the context in which the promise is created, and
  • the trigger link is to the promise that preceeds the execution.

The creation of an execution node looks something like this, with init being the node where a promise is created and trigger is the promise that must complete to start the execution.

function init(trigger: Promise<unknown>) {
return promise = trigger.then(() => { ... });
}

Calling awaited() effectively collects tracked values by searching this graph, with priority given to values tracked in the init execution before the trigger execution.

The implementation has diverged from a graph search, for both efficiency and to facilitate garbage collection of the tracked values as the promise objects become unreachable.

Garbage Collection

Like all non-trivial abstractions, this one leaks: in particular it leaks memory.

The awaited values are garbage-collected along with promises as much as possible, but sometimes we need to control this behavior.

  • shared(() => { ... }) executes without any initial tracked values.
  • disconnected(() => { ... }) does not expose any values tracked in its execution.

Both of these methods asynchronosuly start executing their function and return a promise for its result. (These mechanisms are currently global across all track/awaited pairs.)

Shared Execution

shared() is used for executing asynchronous operations where the promise may be cached/reused. It prevents the tracked values from the initial execution being part of the result, and Awaiting a shared promise still integrates any values that were tracked during its execution.

Consider the following,…

function getAuthToken() {
return authTokenPromise ??= fetchAuthToken();
}

in this case the authTokenPromise will inherit the tracked values leading to the first call of fetchAuthToken() only. This introduces a race condition as the tracked results of getAuthToken() would depend on who called it first. Additionally the fetchAuthToken() function would have access to the awaited() values of its first caller.

To fix this, we use shared to disconnect the execution graph leading into the call.

function getAuthToken() {
return authTokenPromise ??= shared(() => fetchAuthToken());
}

Inside fetchAuthToken() the awaited() list will be clear. Any values tracked inside fetchAuthToken() can still be awaited from the authTokenPromise.

Disconnected Execution

disconnected() is used to drop tracked values. Awaiting a disconnected result does not add any more tracked values to the next awaited() call.

We use this to reduce the maximum impact of running a large number of test cases, for instance.