generated from legendecas/tc39-proposal
-
Notifications
You must be signed in to change notification settings - Fork 15
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
eadad3d
commit 2490323
Showing
1 changed file
with
392 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,392 @@ | ||
# 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`][] | ||
### Alternative: `@@dispose` | ||
|
||
An alternative to exposing the `set` semantics directly is allowing mutation | ||
with well-known symbol interface [`@@dispose`][] (and potentially | ||
enforcing the `using` declaration with [`@@enter`][]). | ||
|
||
```js | ||
const asyncVar = new AsyncContext.Variable({ defaultValue: "default" }); | ||
|
||
{ | ||
using _ = asyncVar.value("main"); | ||
new AsyncContext.Snapshot() // snapshot 0 | ||
console.log(asyncVar.get()); // => "main" | ||
} | ||
|
||
{ | ||
using _ = asyncVar.value("value-1"); | ||
new AsyncContext.Snapshot() // snapshot 1 | ||
Promise.resolve() | ||
.then(() => { // continuation 1 | ||
console.log(asyncVar.get()); // => 'value-1' | ||
}) | ||
} | ||
|
||
{ | ||
using _ = asyncVar.value("value-2"); | ||
new AsyncContext.Snapshot() // snapshot 2 | ||
Promise.resolve() | ||
.then(() => { // continuation 2 | ||
console.log(asyncVar.get()); // => 'value-2' | ||
}) | ||
} | ||
``` | ||
|
||
The value mapping is still equivalent to: | ||
|
||
``` | ||
⌌-----------⌍ snapshot 0 | ||
| 'main' | | ||
⌎-----------⌏ | ||
⌌-----------⌍ snapshot 1 | ||
| 'value-1' | <---- the continuation 1 | ||
⌎-----------⌏ | ||
⌌-----------⌍ snapshot 2 | ||
| 'value-2' | <---- the continuation 2 | ||
⌎-----------⌏ | ||
``` | ||
|
||
However, the well-known symbol `@@dispose` and `@@enter` is not bound to the | ||
`using` declaration syntax, and they can be invoked manually. This can be a | ||
by-design feature allowing advanced userland extension. | ||
|
||
This can be an extension to the proposed `run` semantics. | ||
|
||
### Alternative: 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 | ||
⌎---------------⌏ | ||
``` | ||
|
||
|
||
[`@@dispose`]: https://github.com/tc39/proposal-explicit-resource-management?tab=readme-ov-file#using-declarations | ||
[`@@enter`]: https://github.com/tc39/proposal-using-enforcement?tab=readme-ov-file#proposed-solution | ||
[`ContinuationVariable`]: ./CONTINUATION.md |