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
100 changes: 51 additions & 49 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,85 +150,87 @@ Non-goals:

# Proposed Solution

`AsyncLocal` 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 AsyncLocal<T> {
constructor(options: AsyncLocalOptions<T>);
namespace AsyncContext {
class Variable<T> {
constructor(options: AsyncVariableOptions<T>);

get name(): string;
get name(): string;

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

get(): T | undefined;
}
get(): T | undefined;
}

interface AsyncLocalOptions<T> {
name?: string;
defaultValue?: T;
}
interface AsyncVariableOptions<T> {
name?: string;
defaultValue?: T;
}

class AsyncSnapshot {
constructor();
class Snapshot {
constructor();

restore<R>(fn: (...args: any[]) => R, ...args: any[]): R;
restore<R>(fn: (...args: any[]) => R, ...args: any[]): R;
}
}
```

`AsyncLocal.prototype.run()` and `AsyncLocal.prototype.get()` sets and gets
the current value of an async execution flow. `AsyncSnapshot` allows you
to opaquely capture the current value of all `AsyncLocal`s and execute a
`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 `AsyncSnapshot`, you can
only access the value associated with an `AsyncLocal` instance if you have
(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 local = new AsyncLocal();
const asyncVar = new AsyncContext.Variable();

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

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

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

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

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

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

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

// Captures the state of all AsyncLocal's at this moment.
const snapshotDuringTop = new AsyncSnapshot();
// Captures the state of all AsyncContext.Variable's at this moment.
const snapshotDuringTop = new AsyncContext.Snapshot();

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

// The snapshotDuringTop will restore all AsyncLocal to their snapshot
// 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.restore(() => {
// Despite being lexically nested inside 'C', the snapshot restored us to
// to the 'top' state.
console.log(local.get()); // => 'top'
console.log(asyncVar.get()); // => 'top'
});
});
}
Expand All @@ -238,7 +240,7 @@ function randomTimeout() {
}
```

`AsyncSnapshot` 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,7 @@ let queue = [];

export function enqueueCallback(cb: () => void) {
// Each callback is stored with the context at which it was enqueued.
const snapshot = new AsyncSnapshot();
const snapshot = new AsyncContext.Snapshot();
queue.push(() => {
snapshot.restore(cb);
});
Expand All @@ -264,7 +266,7 @@ runWhenIdle(() => {
```

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

## Use cases

Expand Down Expand Up @@ -303,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
`AsyncLocal` 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 @@ -312,20 +314,20 @@ tracing span doesn't need to be manually passing around by usercodes.
```typescript
// tracer.js

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

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

## Transitive task attribution

User tasks can be scheduled with attributions. With `AsyncLocal`, 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 = {
local: new AsyncLocal(),
asyncVar: new AsyncContext.Variable(),
postTask(task, options) {
// In practice, the task execution may be deferred.
// Here we simply run the task immediately.
return this.local.run({ priority: options.priority }, task);
return this.asyncVar.run({ priority: options.priority }, task);
},
currentTask() {
return this.local.get() ?? { priority: "default" };
return this.asyncVar.get() ?? { priority: "default" };
},
};

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

The major concerns of `AsyncLocal` 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
`AsyncLocal`. This document is about defining the scoping of `AsyncLocal`.
`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 `AsyncLocal` 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
`AsyncLocal` instances are lexically scoped too. It is not possible to access a
value inside an `AsyncLocal` without explicit access to the `AsyncLocal` instance
`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 local = new AsyncLocal();
const asyncVar = new AsyncContext.Variable();

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

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

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

Hence, knowing the name of an `AsyncLocal` variable does not give you the
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 local = new AsyncLocal();
const asyncVar = new AsyncContext.Variable();

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

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

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

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

### Dynamic Scoping: dependency on caller

One argument on the dynamic scoping is that the values in `AsyncLocal` 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 `AsyncLocal` 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 SyncLocal {
class SyncVariable {
#current;

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

const syncLocal = new SyncLocal();
const syncVar = new SyncVariable();

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

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

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

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

If this userland `SyncLocal` is acceptable, than adding an `AsyncLocal`
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 `AsyncLocal` compared to
regular JavaScript variables. Only code with direct access to `AsyncLocal`
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
`local.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