Pardon Tech - Promise Causality
Node’s the 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.
Ordering Causality
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”, but the behavior is independent of the execution order: since only awaited assignments to the global object count.
The base of this is a tracking system that understands what happened to reach a certain point, by way of a series of tracked values.
In a single threaded usage, track(value)
looks like it pushes a value
onto a list and awaited()
returns that list.
However if we have some asynchronous processes that track values,
we won’t see them get tracked until we we await
those promises.
Notice that awaited()
above can return any combination of 'f'
and/or 'g'
in different orders: depending purely on the order they were awaited,
rather than the order the track
calls were actually run!
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 offers but tells us not to use.
The nodes of the execution graph 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 this a node looks something like this:
Note that trigger
is in general a still-pending operation,
(as is promise
, of course), while init
is the context when
the promise
was created, so awaiting the promise will include
tracked values from inside init
(and wherever the function was called),
and then the values that were tracked in the course of whatever trigger
is waiting for.
Calling awaited()
collects tracked values by searching this graph.
Garbage
Like all non-trivial abstractions, this one leaks: Memory.
The awaited values are garbage-collected along with promises, as much as possible, but we have two helpers to cut the graph for correctness and better garbage collection, respectively.
shared(() => { ... })
drops awaited values going into the execution of...
.disconnected(() => { ... })
drops awaited values leaving the execution of...
.
Both of these methods asynchronosuly start executing their function and return a promise for its result.
Shared Execution
shared
should be used to wrap the creation of promises that are
reused.
Consider the following,…
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.
Inside fetchAuthToken()
the awaited()
list will be clear.
Any values tracked inside fetchAuthToken()
can still be awaited from the authTokenPromise
.
Disconnected Execution
The disconnected
helper does the opposite: it prevents awaited values
from escaping the disconnected(() => { ... })
boundary. This is meant
to be more of a garbage-collector hint than a behavioral mechanism.
For instance, when running hundreds of test cases we don’t want the
async graphs to pile up in memory, and disconnected
applied to
the right places does the trick here.