Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add AsyncContext namespace and Snapshot and Variable #55

Merged
merged 11 commits into from
Jun 14, 2023
115 changes: 60 additions & 55 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,85 +150,87 @@ Non-goals:

# Proposed Solution

`AsyncContext` are designed as a value store for context propagation across
`AsyncContext.Variable` are designed as a value store for context propagation across
legendecas marked this conversation as resolved.
Show resolved Hide resolved
logically-connected sync/async code execution.

```typescript
class AsyncContext<T> {
static wrap<R>(callback: (...args: any[]) => R): (...args: any[]) => R;
namespace AsyncContext {
class Variable<T> {
constructor(options: AsyncVariableOptions<T>);

constructor(options: AsyncContextOptions<T>);
get name(): string;

get name(): string;
run<R>(value: T, fn: () => R): R;
jridgewell marked this conversation as resolved.
Show resolved Hide resolved

run<R>(value: T, callback: () => R): R;
get(): T | undefined;
}

get(): T | undefined;
}
interface AsyncVariableOptions<T> {
name?: string;
defaultValue?: T;
}

class Snapshot {
constructor();

interface AsyncContextOptions<T> {
name?: string;
defaultValue?: T;
restore<R>(fn: (...args: any[]) => R, ...args: any[]): R;
}
}
```

`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 `AsyncContext`s and execute the
callback at a later time with as if those values were still the current values
(a snapshot and restore). Note that even with `AsyncContext.wrap()`, you can
only access the value associated with an `AsyncContext` instance if you have
`AsyncContext.Variable.prototype.run()` and `AsyncContext.Variable.prototype.get()` sets and gets
the current value of an async execution flow. `AsyncContext.Snapshot` allows you
to opaquely capture the current value of all `AsyncContext.Variable`s and execute a
function at a later time with as if those values were still the current values
(a snapshot and restore). Note that even with `AsyncContext.Snapshot`, you can
only access the value associated with an `AsyncContext.Variable` instance if you have
access to that instance.

```typescript
const context = new AsyncContext();
const asyncVar = new AsyncContext.Variable();

// Sets the current value to 'top', and executes the `main` function.
context.run("top", main);
asyncVar.run("top", main);

function main() {
// Context is maintained through other platform queueing.
// AsyncContext.Variable is maintained through other platform queueing.
setTimeout(() => {
console.log(context.get()); // => 'top'
console.log(asyncVar.get()); // => 'top'

context.run("A", () => {
console.log(context.get()); // => 'A'
asyncVar.run("A", () => {
console.log(asyncVar.get()); // => 'A'

setTimeout(() => {
console.log(context.get()); // => 'A'
console.log(asyncVar.get()); // => 'A'
}, randomTimeout());
});
}, randomTimeout());

// Context runs can be nested.
context.run("B", () => {
console.log(context.get()); // => 'B'
// AsyncContext.Variable runs can be nested.
asyncVar.run("B", () => {
console.log(asyncVar.get()); // => 'B'

setTimeout(() => {
console.log(context.get()); // => 'B'
console.log(asyncVar.get()); // => 'B'
}, randomTimeout());
});

// Context was restored after the previous run.
console.log(context.get()); // => 'top'
// AsyncContext.Variable was restored after the previous run.
console.log(asyncVar.get()); // => 'top'

// Captures the state of all AsyncContext's at this moment.
const snapshotDuringTop = AsyncContext.wrap((cb) => {
console.log(context.get()); // => 'top'
cb();
});
// Captures the state of all AsyncContext.Variable's at this moment.
const snapshotDuringTop = new AsyncContext.Snapshot();

// Context runs can be nested.
context.run("C", () => {
console.log(context.get()); // => 'C'
asyncVar.run("C", () => {
console.log(asyncVar.get()); // => 'C'

// The snapshotDuringTop will restore all AsyncContext to their snapshot
// state and invoke the wrapped function. We pass a callback which it will
// The snapshotDuringTop will restore all AsyncContext.Variable to their snapshot
// state and invoke the wrapped function. We pass a function which it will
// invoke.
snapshotDuringTop(() => {
snapshotDuringTop.restore(() => {
// Despite being lexically nested inside 'C', the snapshot restored us to
// to the 'top' state.
console.log(context.get()); // => 'top'
console.log(asyncVar.get()); // => 'top'
});
});
}
Expand All @@ -238,7 +240,7 @@ function randomTimeout() {
}
```

`AsyncContext.wrap` is useful for implementing APIs that logically "schedule" a
`AsyncContext.Snapshot` is useful for implementing APIs that logically "schedule" a
callback, so the callback will be called with the context that it logically
belongs to, regardless of the context under which it actually runs:

Expand All @@ -247,7 +249,10 @@ let queue = [];

export function enqueueCallback(cb: () => void) {
// Each callback is stored with the context at which it was enqueued.
queue.push(AsyncContext.wrap(cb));
const snapshot = new AsyncContext.Snapshot();
queue.push(() => {
snapshot.restore(cb);
});
legendecas marked this conversation as resolved.
Show resolved Hide resolved
}

runWhenIdle(() => {
Expand All @@ -261,11 +266,11 @@ runWhenIdle(() => {
```

> Note: There are controversial thought on the dynamic scoping and
> `AsyncContext`, checkout [SCOPING.md][] for more details.
> `AsyncContext.Variable`, checkout [SCOPING.md][] for more details.

## Use cases

Use cases for `AsyncContext` include:
Use cases for async context include:

- Annotating logs with information related to an asynchronous callstack.

Expand Down Expand Up @@ -300,7 +305,7 @@ A detailed example usecase can be found [here](./USE-CASES.md)
## Determine the initiator of a task

Application monitoring tools like OpenTelemetry save their tracing spans in the
`AsyncContext` and retrieve the span when they need to determine what started
`AsyncContext.Variable` 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
Expand All @@ -309,20 +314,20 @@ tracing span doesn't need to be manually passing around by usercodes.
```typescript
// tracer.js

const context = new AsyncContext();
const asyncVar = new AsyncContext.Variable();
export function run(cb) {
// (a)
const span = {
startTime: Date.now(),
traceId: randomUUID(),
spanId: randomUUID(),
};
context.run(span, cb);
asyncVar.run(span, cb);
}

export function end() {
// (b)
const span = context.get();
const span = asyncVar.get();
span?.endTime = Date.now();
}
```
Expand Down Expand Up @@ -358,20 +363,20 @@ concurrent multi-tracking.

## Transitive task attribution

User tasks can be scheduled with attributions. With `AsyncContext`, task
User tasks can be scheduled with attributions. With `AsyncContext.Variable`, task
attributions are propagated in the async task flow and sub-tasks can be
scheduled with the same priority.

```typescript
const scheduler = {
context: new AsyncContext(),
asyncVar: new AsyncContext.Variable(),
postTask(task, options) {
// In practice, the task execution may be deferred.
// Here we simply run the task immediately with the context.
return this.context.run({ priority: options.priority }, task);
// Here we simply run the task immediately.
return this.asyncVar.run({ priority: options.priority }, task);
},
currentTask() {
return this.context.get() ?? { priority: "default" };
return this.asyncVar.get() ?? { priority: "default" };
},
};

Expand Down
73 changes: 37 additions & 36 deletions SCOPING.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
# Scoping of AsyncContext
# Scoping of AsyncContext.Variable

The major concerns of `AsyncContext` advancing to Stage 1 of TC39 proposal
The major concerns of `AsyncContext.Variable` advancing to Stage 1 of TC39 proposal
process is that there are potential dynamic scoping of the semantics of
`AsyncContext`. This document is about defining the scoping of `AsyncContext`.
`AsyncContext.Variable`. This document is about defining the scoping of
`AsyncContext.Variable`.

### Dynamic Scoping

Expand All @@ -22,61 +23,61 @@ $ echo $x # does this print 1, or 2?
1
```

However, the naming scope of an async context is identical to a regular variable
However, the naming scope of an `AsyncContext.Variable` is identical to a regular variable
in JavaScript. Since JavaScript variables are lexically scoped, the naming of
async context instances are lexically scoped too. It is not possible to access a
value inside an async context without explicit access to the async context
`AsyncContext.Variable` instances are lexically scoped too. It is not possible to access a
value inside an `AsyncContext.Variable` without explicit access to the `AsyncContext.Variable` instance
itself.

```typescript
const context = new AsyncContext();
const asyncVar = new AsyncContext.Variable();

context.run(1, f);
console.log(context.get()); // => undefined
asyncVar.run(1, f);
console.log(asyncVar.get()); // => undefined

function g() {
console.log(context.get()); // => 1
console.log(asyncVar.get()); // => 1
}

function f() {
// Intentionally named the same "context"
const context = new AsyncContext();
context.run(2, g);
// Intentionally named the same "asyncVar"
const asyncVar = new AsyncContext.Variable();
asyncVar.run(2, g);
}
```

Hence, knowing the name of an async context variable does not give you the
ability to change that context. You must have direct access to it in order to
affect it.
Hence, knowing the name of an `AsyncContext.Variable` variable does not give you the
ability to change the value of that variable. You must have direct access to it
in order to affect it.

```typescript
const context = new AsyncContext();
const asyncVar = new AsyncContext.Variable();

context.run(1, f);
asyncVar.run(1, f);

console.log(context.get()); // => undefined;
console.log(asyncVar.get()); // => undefined;

function f() {
const context = new AsyncContext();
context.run(2, g);
const asyncVar = new AsyncContext.Variable();
asyncVar.run(2, g);

function g() {
console.log(context.get(); // => 2;
console.log(asyncVar.get()); // => 2;
}
}
```

### Dynamic Scoping: dependency on caller

One argument on the dynamic scoping is that the values in `AsyncContext` can be
One argument on the dynamic scoping is that the values in `AsyncContext.Variable` can be
changed depending on which the caller is.

However, the definition of whether the value of an async context can be changed
However, the definition of whether the value of an `AsyncContext.Variable` can be changed
has the same meaning with a regular JavaScript variable: anyone with direct
access to a variable has the ability to change the variable.

```typescript
class SyncContext {
class SyncVariable {
#current;

get() {
Expand All @@ -94,30 +95,30 @@ class SyncContext {
}
}

const context = new SyncContext();
const syncVar = new SyncVariable();

context.run(1, f);
syncVar.run(1, f);

console.log(context.get()); // => undefined;
console.log(syncVar.get()); // => undefined;

function g() {
console.log(context.get()); // => 1
console.log(syncVar.get()); // => 1
}

function f() {
// Intentionally named the same "context"
const context = new AsyncContext();
context.run(2, g);
// Intentionally named the same "syncVar"
const syncVar = new AsyncContext.Variable();
syncVar.run(2, g);
}
```

If this userland `SyncContext` is acceptable, than adding an `AsyncContext`
If this userland `SyncVariable` is acceptable, than adding an `AsyncContext.Variable`
that can operate across sync/async execution should be no different.

### Summary

There are no differences regarding naming scope of async contexts compared to
regular JavaScript variables. Only code with direct access to `AsyncContex`
There are no differences regarding naming scope of `AsyncContext.Variable` compared to
regular JavaScript variables. Only code with direct access to `AsyncContext.Variable`
instances can modify the value, and only for code execution nested inside a new
`context.run()`. Further, the capability to modify a local variable which you
`asyncVar.run()`. Further, the capability to modify an AsyncVariable which you
have direct access to is already possible in sync code execution.
Loading