Skip to content

Commit

Permalink
Add mutation explainer
Browse files Browse the repository at this point in the history
  • Loading branch information
legendecas committed Jul 24, 2024
1 parent eadad3d commit 9af8820
Showing 1 changed file with 335 additions and 0 deletions.
335 changes: 335 additions & 0 deletions SYNC-MUTATION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,335 @@
# Synchronous Mutation

The enforced mutation function scope APIs with `run` (as in
`AsyncContext.Snapshot.prototype.run` and `AsyncContext.Variable.prototype.run`)
requires any `Variable` value mutations or `Snapshot` restorations to be
performed within a new function scope.

Modifications to `Variable` values are propagated to its subtasks. This `.run`
scope enforcement prevents any modifications to be visible to its caller
function scope, consequently been propagated to tasks created in sibling
function calls.

For instance, given a global scheduler state and a piece of user code:

```js
globalThis.scheduler = {
#asyncVar: new AsyncContext.Variable(),
postTask(task, { priority }) {
asyncVar.run(priority, task);
},
yield() {
const priority = asyncVar.get();
return new Promise(resolve => {
// resolve at a timing depending on the priority
resolve();
});
},
};

async function f() {
await scheduler.yield();

await someLibrary.doAsyncWork();
someLibrary.doSyncWork();

// this can not be affected by either `doAsyncWork` or `doSyncWork` call.
await scheduler.yield();
}
```

In this case, the `scheduler.yield` calls in function `f` will never be affected by
sibling library function calls.

Notably, AsyncContext by itself is designed to be scoped by instance of
`AsyncContext.Variable`s, and without sharing a reference to the instance, its
value will not be affected in library calls. This example shows a design that
modifications in `AsyncContext.Variable` are only visible to logical subtasks.

## Overview

The `.run` and `.set` comparison has the similar traits when comparing
`AsyncContext.Variable` and [`ContinuationVariable`][]. The difference is that
whether the mutations made with `.run`/`.set` is visible to its parent scope.

Type | Mutation not visible to parent scope | Mutation visible to parent scope
--- | --- | ---
Sync | `.run(value, fn)` | `.set(value)`
Async | `AsyncContext.Variable` | `ContinuationVariable`

In the above table, the "sync" is referring to
`someLibrary.doSyncWork()` (or `someLibrary.doAsyncWork()` without `await`),
and the "async" is referring to `await someLibrary.doAsyncWork()` in the
example snippet above respectively.

## Limitation of run

The enforcement of mutation scopes can reduce the chance that the mutation is
exposed to the parent scope in unexpected way, but it also increases the bar to
use the feature or migrate existing code to adopt the feature.

For example, given a snippet of code:

```js
function *gen() {
yield computeResult();
yield computeResult2();
}
```

If we want to scope the `computeResult` and `computeResult2` calls with a new
AsyncContext value, it needs non-trivial refactor:

```js
const asyncVar = new AsyncContext.Context();

function *gen() {
const span = createSpan();
yield asyncVar.run(span, () => computeResult());
yield asyncVar.run(span, () => computeResult2());
// ...or
yield* asyncVar.run(span, function *() {
yield computeResult();
yield computeResult2();
});
}
```

`.run(val, fn)` creates a new function body. The new function environment
is not equivalent to the outer environment and can not trivially share code
fragments between them. Additionally, `break`/`continue`/`return` can not be
refactored naively.

It will be more intuitive to be able to insert a new line and without refactor
existing code snippet.

```js
const asyncVar = new AsyncContext.Context();

function *gen() {
asyncVar.set(createSpan(i));
yield computeResult(i);
yield computeResult2(i);
}
```

## The set semantics

With the name of `set`, this method actually doesn't modify existing async
context snapshots, similar to consecutive `run` operations. For example, in
the following case, `set` doesn't change the context variables in async tasks
created just prior to the mutation:

```js
const asyncVar = new AsyncContext.Variable({ defaultValue: "default" });

asyncVar.set("main");
new AsyncContext.Snapshot() // snapshot 0
console.log(asyncVar.get()); // => "main"

asyncVar.set("value-1");
new AsyncContext.Snapshot() // snapshot 1
Promise.resolve()
.then(() => { // continuation 1
console.log(asyncVar.get()); // => 'value-1'
})

asyncVar.set("value-2");
new AsyncContext.Snapshot() // snapshot 2
Promise.resolve()
.then(() => { // continuation 2
console.log(asyncVar.get()); // => 'value-2'
})
```

The value mapping is equivalent to:

```
⌌-----------⌍ snapshot 0
| 'main' |
⌎-----------⌏
|
⌌-----------⌍ snapshot 1
| 'value-1' | <---- the continuation 1
⌎-----------⌏
|
⌌-----------⌍ snapshot 2
| 'value-2' | <---- the continuation 2
⌎-----------⌏
```

This trait is important with both `run` and `set` because mutations to
`AsyncContext.Variable`s must not mutate prior `AsyncContext.Snapshot`s.

> Note: this also applies to [`ContinuationVariable`][]
### Decouple mutation with scopes

To preserve the strong scope guarantees provided by `run`, an additional
constraint can also be put to `set` to declare explicit scopes of mutation.

A dedicated `AsyncContext.contextScope` can be decoupled with `run` to open a
mutable scope with a series of `set` operations.

```js
const asyncVar = new AsyncContext.Variable({ defaultValue: "default" });

asyncVar.set("A"); // Throws ReferenceError: Not in a mutable context scope.

// Executes the `main` function in a new mutable context scope.
AsyncContext.contextScope(() => {
asyncVar.set("main");

console.log(asyncVar.get()); // => "main"
});
// Goes out of scope and all variables are restored in the current context.

console.log(asyncVar.get()); // => "default"
```

`AsyncContext.contextScope` is basically a shortcut of
`AsyncContext.Snapshot.run`:

```js
const asyncVar = new AsyncContext.Variable({ defaultValue: "default" });

asyncVar.set("A"); // Throws ReferenceError: Not in a mutable context scope.

// Executes the `main` function in a new mutable context scope.
AsyncContext.Snapshot.wrap(() => {
asyncVar.set("main");

console.log(asyncVar.get()); // => "main"
})();
// Goes out of scope and all variables are restored in the current context.

console.log(asyncVar.get()); // => "default"
```

### Use cases

One use case of `set` is that it allows more intuitive test framework
integration (or similar frameworks that have prose style declarations,
like middlewares).

```js
describe("asynct context", () => {
const ctx = new AsyncContext.Variable();

beforeEach((test) => {
ctx.set(1);
});

it('run in snapshot', () => {
// This function is run as a second paragraph of the test sequence.
assert.strictEqual(ctx.get(),1);
});
});

function testDriver() {
await AsyncContext.contextScope(async () => {
runBeforeEach();
await runTest();
runAfterEach();
});
}
```

However, without proper test framework support, mutations in async `beforeEach`
are still unintuitive, e.g. https://github.com/xunit/xunit/issues/1880.

This will need a return-value API to feedback the final context snapshot to the
next function paragraph.

```js
describe("asynct context", () => {
const ctx = new AsyncContext.Variable();

beforeEach(async (test) => {
await undefined;
ctx.set(1);
test.setSnapshot(new AsyncContext.Snapshot());
});

it('run in snapshot', () => {
// This function is run in the snapshot saved in `test.setSnapshot`.
assert.strictEqual(ctx.get(),1);
});
});

function testDriver() {
let snapshot = new AsyncContext.Snapshot();
await AsyncContext.contextScope(async () => {
await runBeforeEach({
setSnapshot(it) {
snapshot = it;
}
});
await snapshot.run(() => runTest());
await runAfterEach();
});
}
```

### Polyfill Viability

> Can `set` be implementation in user land with `run`?
The most important trait of `set` is that it will not mutate existing
`AsyncContext.Snapshot`.

A userland polyfill like the following one can not preserve this trait.

```typescript
class SettableVar<T> {
private readonly internal: AsyncContext.Variable<[T]>;
constructor(opts = {}) {
this.internal = new AsyncContext.Variable({...opts, defaultValue: [opts.defaultValue]});
}

get() {
return this.internal.get()[0];
}

set(val) {
this.internal.get()[0] = val;
}
}
```

In the following snippet, mutations to a `SettableVar` will also apply to prior
snapshots.

```js
const asyncVar = new SettableVar({ defaultValue: "default" });

asyncVar.set("main");
new AsyncContext.Snapshot() // snapshot 0
console.log(asyncVar.get()); // => "main"

asyncVar.set("value-1");
new AsyncContext.Snapshot() // snapshot 1
Promise.resolve()
.then(() => { // continuation 1
console.log(asyncVar.get()); // => 'value-2'
})

asyncVar.set("value-2");
new AsyncContext.Snapshot() // snapshot 2
Promise.resolve()
.then(() => { // continuation 2
console.log(asyncVar.get()); // => 'value-2'
})
```

The value mapping is equivalent to:

```
⌌---------------⌍ snapshot 0 & 1 & 2
| [ 'value-2' ] | <---- the continuation 1 & 2
⌎---------------⌏
```



[`ContinuationVariable`]: ./CONTINUATION.md

0 comments on commit 9af8820

Please sign in to comment.