Stage: 1
Champions:
- Chengzhong Wu (@legendecas)
- Justin Ridgewell (@jridgewell)
The goal of the proposal is to provide a mechanism to ergonomically track async contexts in JavaScript. Put another way, it allows propagating a value through a callstack regardless of any async execution.
It's easiest to explain this in terms of setting and reading a global variable
in sync execution. Imagine we're a library which provides a simple log
and
run
function. Users may pass their callbacks into our run
function and an
arbitrary "id". The run
will then invoke their callback and while running, the
developer may call our log
function to annotate the logs with the id they
passed to the run.
let currentId = undefined;
export function log() {
if (currentId === undefined) throw new Error('must be inside a run call stack');
console.log(`[${currentId}]`, ...arguments);
}
export function run<T>(id: string, cb: () => T) {
let prevId = currentId;
try {
currentId = id;
return cb();
} finally {
currentId = prevId;
}
}
The developer may then use our library like this:
import { run, log } from 'library';
import { helper } from 'some-random-npm-library';
document.body.addEventListener('click', () => {
const id = new Uuid();
run(id, () => {
log('starting');
// Assume helper will invoke doSomething.
helper(doSomething);
log('done');
});
});
function doSomething() {
log("did something");
}
In this example, no matter how many times a user may click, we'll also see a
perfect "[123] starting", "[123] did something" "[123] done" log. We've
essentially implemented a synchronous context stack, able to propagate the id
down through the developers call stack without them needing to manually pass or
store the id themselves. This pattern is extremely useful. It is not always
ergonomic (or even always possible) to pass a value through every function call
(think of passing React props through several intermediate components vs passing
through a React Context).
However, this scenario breaks as soon as we introduce any async operation into our call stack.
document.body.addEventListener('click', () => {
const id = new Uuid();
run(id, async () => {
log('starting');
await helper(doSomething);
// This will error! We've lost our id!
log('done');
});
});
function doSomething() {
// Will this error? Depends on if `helper` awaited before calling.
log("did something");
}
AsyncContext
solves this issue, allowing you to propagate the id through both
sync and async execution by keeping track of the context in which we started the
execution.
const context = new AsyncContext();
export function log() {
const currentId = context.get();
if (currentId === undefined) throw new Error('must be inside a run call stack');
console.log(`[${currentId}]`, ...arguments);
}
export function run<T>(id: string, cb: () => T) {
context.run(id, cb);
}
This proposal introduces APIs to propagate a value through asynchronous hop or continuation, such as a promise continuation or async callbacks.
Non-goals:
- Async tasks scheduling and interception.
- Error handling & bubbling through async stacks.
AsyncContext
are designed as a value store for context propagation across
multiple logically-connected sync/async operations.
class AsyncContext<T> {
static wrap<R>(callback: (...args: any[]) => R): (...args: any[]) => R;
run<R>(value: T, callback: () => R): R;
get(): T;
}
AsyncContext.prototype.run()
and AsyncContext.prototype.get()
sets and gets the current
value of an async execution flow. AsyncContext.wrap()
allows you to opaquely
capture the current value of all AsyncContexts
and execute the callback at a
later time with as if those values were still the current values (a snapshot and
restore).
const context = new AsyncContext();
// Sets the current value to 'top', and executes the `main` function.
context.run('top', main);
function main() {
// Context is maintained through other platform queueing.
setTimeout(() => {
console.log(context.get()); // => 'top'
context.run('A', () => {
console.log(context.get()); // => 'A'
setTimeout(() => {
console.log(context.get()); // => 'A'
}, randomTimeout());
});
}, randomTimeout());
// Context runs can be nested.
context.run('B', () => {
console.log(context.get()); // => 'B'
setTimeout(() => {
console.log(context.get()); // => 'B'
}, randomTimeout());
});
// Context was restored after the previous run.
console.log(context.get()); // => 'top'
// Captures the state of all AsyncContext's at this moment.
const snapshotDuringTop = AsyncContext.wrap((cb) => {
console.log(context.get()); // => 'top'
cb();
});
// Context runs can be nested.
context.run('C', () => {
console.log(context.get()); // => 'C'
// The snapshotDuringTop will restore all AsyncContext to their snapshot
// state and invoke the wrapped function. We pass a callback which it will
// invoke.
snapshotDuringTop(() => {
// Despite being lexically nested inside 'C', the snapshot restored us to
// to the 'top' state.
console.log(context.get()); // => 'top'
});
});
}
function randomTimeout() {
return Math.random() * 1000;
}
Note: There are controversial thought on the dynamic scoping and
AsyncContext
, checkout SCOPING.md for more details.
Application monitoring tools like OpenTelemetry save their tracing spans in the
AsyncContext
and retrieve the span when they need to determine what started
this chain of interaction.
These libraries can not intrude the developer APIs for seamless monitoring. The tracing span doesn't need to be manually passing around by usercodes.
// tracer.js
const context = new AsyncContext();
export function run(cb) {
// (a)
const span = {
startTime: Date.now(),
traceId: randomUUID(),
spanId: randomUUID(),
};
context.run(span, cb);
}
export function end() {
// (b)
const span = context.get();
span?.endTime = Date.now();
}
// my-app.js
import * as tracer from './tracer.js'
button.onclick = e => {
// (1)
tracer.run(() => {
fetch("https://example.com").then(res => {
// (2)
return processBody(res.body).then(data => {
// (3)
const dialog = html`<dialog>Here's some cool data: ${data}
<button>OK, cool</button></dialog>`;
dialog.show();
tracer.end();
});
});
});
};
In the example above, run
and end
don't share same lexical scope with
actual code functions, and they are capable of async reentrance thus capable of
concurrent multi-tracking.
User tasks can be scheduled with attributions. With AsyncContext
, task
attributions are propagated in the async task flow and sub-tasks can be
scheduled with the same priority.
const scheduler = {
context: new AsyncContext(),
postTask(task, options) {
// In practice, the task execution may be deferred.
// Here we simply run the task immediately with the context.
this.context.run({ priority: options.priority }, task);
},
currentTask() {
return this.context.get() ?? { priority: 'default' };
},
};
const res = await scheduler.postTask(task, { priority: 'background' });
console.log(res);
async function task() {
// Fetch remains background priority by referring to scheduler.currentPriority().
const resp = await fetch('/hello');
const text = await resp.text();
scheduler.currentTask(); // => { priority: 'background' }
return doStuffs(text);
}
async function doStuffs(text) {
// Some async calculation...
return text;
}
Zones proposed a Zone
object, which has the following API:
class Zone {
constructor({ name, parent });
name;
get parent();
fork({ name });
run(callback);
wrap(callback);
static get current();
}
The concept of the current zone, reified as Zone.current
, is crucial.
Both run
and wrap
are designed to manage running the current zone:
z.run(callback)
will set the current zone toz
for the duration ofcallback
, resetting it to its previous value afterward. This is how you "enter" a zone.z.wrap(callback)
produces a new function that essentially performsz.run(callback)
(passing along arguments and this, of course).
The current zone is the async context that propagates with all our
operations. In our above example, sites (1)
through (6)
would all have
the same value of Zone.current
. If a developer had done something like:
const loadZone = Zone.current.fork({ name: "loading zone" });
window.onload = loadZone.wrap(e => { ... });
then at all those sites, Zone.current
would be equal to loadZone
.
Domain's global central active domain can be consumed by multiple endpoints
and be exchanged in any time with synchronous operation (domain.enter()
).
Since it is possible that some third party module changed active domain on
the fly and application owner may unaware of such change, this can introduce
unexpected implicit behavior and made domain diagnosis hard.
Check out Domain Module Postmortem for more details.
This is what the proposal evolved from. async_hooks
in Node.js enabled async
resources tracking for APM vendors. On which Node.js also implemented
AsyncLocalStorage
.
Frameworks can schedule tasks with their own userland queues. In such case, the stack trace originated from the framework scheduling logic tells only part of the story.
Error: Call stack
at someTask (example.js)
at loop (framework.js)
The Chrome Async Stack Tagging API introduces a new console method named
console.createTask()
. The API signature is as follows:
interface Console {
createTask(name: string): Task;
}
interface Task {
run<T>(f: () => T): T;
}
console.createTask()
snapshots the call stack into a Task
record. And each
Task.run()
restores the saved call stack and append it to newly generated
call stacks.
Error: Call stack
at someTask (example.js)
at loop (framework.js) // <- Task.run
at async someTask // <- Async stack appended
at schedule (framework.js) // <- console.createTask
at businessLogic (example.js)