diff --git a/SYNC-MUTATION.md b/SYNC-MUTATION.md new file mode 100644 index 0000000..df02a2f --- /dev/null +++ b/SYNC-MUTATION.md @@ -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 { + 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