diff --git a/SYNC-MUTATION.md b/MUTATION-SCOPE.md similarity index 58% rename from SYNC-MUTATION.md rename to MUTATION-SCOPE.md index 545a5a7..50d5cfc 100644 --- a/SYNC-MUTATION.md +++ b/MUTATION-SCOPE.md @@ -1,4 +1,4 @@ -# Synchronous Mutation +# Mutation Scope The enforced mutation function scope APIs with `run` (as in `AsyncContext.Snapshot.prototype.run` and `AsyncContext.Variable.prototype.run`) @@ -48,21 +48,79 @@ 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. +There are two types of mutation scopes in the above example: -Type | Mutation not visible to parent scope | Mutation visible to parent scope ---- | --- | --- -Sync | `.run(value, fn)` | `.set(value)` -Async | `AsyncContext.Variable` | `ContinuationVariable` +- "sync": mutations made in synchronous execution in `someLibrary.doSyncWork()` + (or `someLibrary.doAsyncWork()` without `await`), +- "async": mutations made in async flow in `await someLibrary.doAsyncWork()`. -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. +Type | Mutation not visible to parent scope | Mutation visible to parent scope +--- | --- | --- +Sync | `.run(value, fn)`, set semantic with scope enforcement | set semantic without scope enforcement +Async | `AsyncContext.Variable` | `ContinuationVariable` -## Limitation of run +## Usages of run + +The `run` pattern can already handles many existing usage pattern well that +involves function calls, like: + +- Event handlers, +- Middleware. + +For example, an event handler can be easily refactored to use `.run(value, fn)` +by wrapping: + +```js +function handler(event) { + ... +} + +button.addEventListener("click", handler); +// ... replace it with ... +button.addEventListener("click", event => { + asyncVar.run(createSpan(), handler, event); +}); +``` + +Or, on Node.js server applications, where middlewares are common to use: + +```js +const middlewares = []; +function use(fn) { + middlewares.push(fn) +} + +async function runMiddlewares(req, res) { + function next(i) { + if (i === middlewares.length) { + return; + } + return middlewares[i](req, res, next.bind(i++)); + } + + return next(0); +} +``` + +A tracing library like OpenTelemetry can instrument it with a simple +middleware like: + +```js +async function otelMiddleware(req, res, next) { + const w3cTraceHeaders = extractW3CHeaders(req); + const span = createSpan(w3cTraceHeaders); + req.setHeader('x-trace-id', span.traceId); + try { + await asyncVar.run(span, next); + } catch (e) { + span.setError(e); + } finally { + span.end(); + } +} +``` + +### 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 @@ -103,83 +161,38 @@ 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(); +```diff + const asyncVar = new AsyncContext.Variable(); -function *gen() { - asyncVar.set(createSpan(i)); - yield computeResult(i); - yield computeResult2(i); -} + function *gen() { ++ using _ = asyncVar.withValue(createSpan(i)); + yield computeResult(i); + yield computeResult2(i); + } ``` -## The set semantics +## The set semantic with scope enforcement 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`][]). +with well-known symbol interface [`@@dispose`][] by using declaration (and +potentially enforcing the `using` declaration with [`@@enter`][]). ```js const asyncVar = new AsyncContext.Variable({ defaultValue: "default" }); { - using _ = asyncVar.value("main"); + using _ = asyncVar.withValue("main"); new AsyncContext.Snapshot() // snapshot 0 console.log(asyncVar.get()); // => "main" } { - using _ = asyncVar.value("value-1"); + using _ = asyncVar.withValue("value-1"); new AsyncContext.Snapshot() // snapshot 1 Promise.resolve() .then(() => { // continuation 1 @@ -188,7 +201,7 @@ const asyncVar = new AsyncContext.Variable({ defaultValue: "default" }); } { - using _ = asyncVar.value("value-2"); + using _ = asyncVar.withValue("value-2"); new AsyncContext.Snapshot() // snapshot 2 Promise.resolve() .then(() => { // continuation 2 @@ -197,28 +210,115 @@ const asyncVar = new AsyncContext.Variable({ defaultValue: "default" }); } ``` -The value mapping is still equivalent to: +The value mapping is equivalent to: ``` ⌌-----------⌍ snapshot 0 | 'main' | ⌎-----------⌏ - + | ⌌-----------⌍ snapshot 1 | 'value-1' | <---- the continuation 1 ⌎-----------⌏ - + | ⌌-----------⌍ snapshot 2 | 'value-2' | <---- the continuation 2 ⌎-----------⌏ ``` +Each `@@enter` operation create a new value slot preventing any mutation to +existing snapshots where the current `AsyncContext.Variable`'s value was +captured. + +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`][] + 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. +by-design feature allowing advanced userland extension, like OpenTelemetry's +example in the next section. This can be an extension to the proposed `run` semantics. +### Use cases + +The set semantic allows instrumenting existing codes without nesting them in a +new function scope and reducing the refactoring work: + +```js +async function doAnotherWork() { + // defer work to next promise tick. + await 0; + using span = tracer.startAsCurrentSpan("anotherWork"); + console.log("doing another work"); + // the span is closed when it's out of scope +} + +async function doWork() { + using parent = tracer.startAsCurrentSpan("parent"); + // do some work that 'parent' tracks + console.log("doing some work..."); + const anotherWorkPromise = doAnotherWork(); + // Create a nested span to track nested work + { + using child = tracer.startAsCurrentSpan("child"); + // do some work that 'child' tracks + console.log("doing some nested work...") + // the nested span is closed when it's out of scope + } + await anotherWorkPromise; + // This parent span is also closed when it goes out of scope +} +``` + +> This example is adapted from the OpenTelemetry Python example. +> https://opentelemetry.io/docs/languages/python/instrumentation/#creating-spans + +Each `tracer.startAsCurrentSpan` invocation retrieves the parent span from its +own `AsyncContext.Variable` instance and create span as a child, and set the +child span as the current value of the `AsyncContext.Variable` instance: + +```js +class Tracer { + #var = new AsyncContext.Variable(); + + startAsCurrentSpan(name) { + let scope; + const span = { + name, + parent: this.#var.get(), + [Symbol.enter]: () => { + scope[Symbol.enter](); + }, + [Symbol.dispose]: () => { + scope[Symbol.dispose](); + }, + }; + scope = this.#var.withValue(span); + return span; + } +} +``` + +The set semantic that doesn't mutate existing snapshots is crucial to the +`startAsCurrentSpan` example here, as it allows deferred span created in +`doAnotherWork` to be a child span of the `"parent"` instead of `"child"`, +shown as graph below: + +``` +⌌----------⌍ +| 'parent' | +⌎----------⌏ + | ⌌---------⌍ + |---| 'child' | + | ⌎---------⌏ + | ⌌-----------------⌍ + |---| 'doAnotherWork' | + | ⌎-----------------⌏ +``` + ### Alternative: Decouple mutation with scopes To preserve the strong scope guarantees provided by `run`, an additional @@ -262,11 +362,10 @@ AsyncContext.Snapshot.wrap(() => { console.log(asyncVar.get()); // => "default" ``` -### Use cases +#### 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). +integration, or similar frameworks that have prose style declarations. ```js describe("asynct context", () => { @@ -294,8 +393,7 @@ function testDriver() { 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. +This can be addressed with a callback nesting API to continue the prose: ```js describe("asynct context", () => { @@ -327,65 +425,14 @@ function testDriver() { } ``` -### 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. +> ❓: A real world use case that facilitate the same component that uses +> `AsyncContext.Variable` in both production and test environment. -```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 -⌎---------------⌏ -``` +## Summary +The set semantic can be an extension to the existing proposal with `@@enter` +and `@@dispose` well-known symbols allowing using declaration scope +enforcement. [`@@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