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.