Code Monkey home page Code Monkey logo

proposal-async-context's Introduction

Async Context for JavaScript

Stage: 1

Champions:

  • Chengzhong Wu (@legendecas)
  • Justin Ridgewell (@jridgewell)

Motivation

The goal of the proposal is to provide a mechanism to ergonomically track async contexts in JavaScript. Put another way, it allows propagating a value through a callstack regardless of any async execution.

It's easiest to explain this in terms of setting and reading a global variable in sync execution. Imagine we're a library which provides a simple log and run function. Users may pass their callbacks into our run function and an arbitrary "id". The run will then invoke their callback and while running, the developer may call our log function to annotate the logs with the id they passed to the run.

let currentId = undefined;

export function log() {
  if (currentId === undefined) throw new Error('must be inside a run call stack');
  console.log(`[${currentId}]`, ...arguments);
}

export function run<T>(id: string, cb: () => T) {
  let prevId = currentId;
  try {
    currentId = id;
    return cb();
  } finally {
    currentId = prevId;
  }
}

The developer may then use our library like this:

import { run, log } from 'library';
import { helper } from 'some-random-npm-library';

document.body.addEventListener('click', () => {
  const id = new Uuid();

  run(id, () => {
    log('starting');

    // Assume helper will invoke doSomething.
    helper(doSomething);

    log('done');
  });
});

function doSomething() {
  log("did something");
}

In this example, no matter how many times a user may click, we'll also see a perfect "[123] starting", "[123] did something" "[123] done" log. We've essentially implemented a synchronous context stack, able to propagate the id down through the developers call stack without them needing to manually pass or store the id themselves. This pattern is extremely useful. It is not always ergonomic (or even always possible) to pass a value through every function call (think of passing React props through several intermediate components vs passing through a React Context).

However, this scenario breaks as soon as we introduce any async operation into our call stack.

document.body.addEventListener('click', () => {
  const id = new Uuid();

  run(id, async () => {
    log('starting');

    await helper(doSomething);

    // This will error! We've lost our id!
    log('done');
  });
});

function doSomething() {
  // Will this error? Depends on if `helper` awaited before calling.
  log("did something");
}

AsyncContext solves this issue, allowing you to propagate the id through both sync and async execution by keeping track of the context in which we started the execution.

const context = new AsyncContext();

export function log() {
  const currentId = context.get();
  if (currentId === undefined) throw new Error('must be inside a run call stack');
  console.log(`[${currentId}]`, ...arguments);
}

export function run<T>(id: string, cb: () => T) {
  context.run(id, cb);
}

Summary

This proposal introduces APIs to propagate a value through asynchronous hop or continuation, such as a promise continuation or async callbacks.

Non-goals:

  1. Async tasks scheduling and interception.
  2. Error handling & bubbling through async stacks.

Proposed Solution

AsyncContext are designed as a value store for context propagation across multiple logically-connected sync/async operations.

class AsyncContext<T> {
  static wrap<R>(callback: (...args: any[]) => R): (...args: any[]) => R;

  run<R>(value: T, callback: () => R): R;

  get(): T;
}

AsyncContext.prototype.run() and AsyncContext.prototype.get() sets and gets the current value of an async execution flow. AsyncContext.wrap() allows you to opaquely capture the current value of all AsyncContexts and execute the callback at a later time with as if those values were still the current values (a snapshot and restore).

const context = new AsyncContext();

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

function main() {
  // Context is maintained through other platform queueing.
  setTimeout(() => {
    console.log(context.get()); // => 'top'

    context.run('A', () => {
      console.log(context.get()); // => 'A'

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

  // Context runs can be nested.
  context.run('B', () => {
    console.log(context.get()); // => 'B'

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

  // Context was restored after the previous run.
  console.log(context.get()); // => 'top'

  // Captures the state of all AsyncContext's at this moment.
  const snapshotDuringTop = AsyncContext.wrap((cb) => {
      console.log(context.get()); // => 'top'
      cb();
  });


  // Context runs can be nested.
  context.run('C', () => {
    console.log(context.get()); // => 'C'

    // The snapshotDuringTop will restore all AsyncContext to their snapshot
    // state and invoke the wrapped function. We pass a callback which it will
    // invoke.
    snapshotDuringTop(() => {
      // Despite being lexically nested inside 'C', the snapshot restored us to
      // to the 'top' state.
      console.log(context.get()); // => 'top'
    });
  });
}

function randomTimeout() {
  return Math.random() * 1000;
}

Note: There are controversial thought on the dynamic scoping and AsyncContext, checkout SCOPING.md for more details.

Examples

Determine the initiator of a task

Application monitoring tools like OpenTelemetry save their tracing spans in the AsyncContext 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 tracing span doesn't need to be manually passing around by usercodes.

// tracer.js

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

export function end() {
  // (b)
  const span = context.get();
  span?.endTime = Date.now();
}
// my-app.js
import * as tracer from './tracer.js'

button.onclick = e => {
  // (1)
  tracer.run(() => {
    fetch("https://example.com").then(res => {
      // (2)

      return processBody(res.body).then(data => {
        // (3)

        const dialog = html`<dialog>Here's some cool data: ${data}
                            <button>OK, cool</button></dialog>`;
        dialog.show();

        tracer.end();
      });
    });
  });
};

In the example above, run and end don't share same lexical scope with actual code functions, and they are capable of async reentrance thus capable of concurrent multi-tracking.

Transitive task attribution

User tasks can be scheduled with attributions. With AsyncContext, task attributions are propagated in the async task flow and sub-tasks can be scheduled with the same priority.

const scheduler = {
  context: new AsyncContext(),
  postTask(task, options) {
    // In practice, the task execution may be deferred.
    // Here we simply run the task immediately with the context.
    this.context.run({ priority: options.priority }, task);
  },
  currentTask() {
    return this.context.get() ?? { priority: 'default' };
  },
};

const res = await scheduler.postTask(task, { priority: 'background' });
console.log(res);

async function task() {
  // Fetch remains background priority by referring to scheduler.currentPriority().
  const resp = await fetch('/hello');
  const text = await resp.text();

  scheduler.currentTask(); // => { priority: 'background' }
  return doStuffs(text);
}

async function doStuffs(text) {
  // Some async calculation...
  return text;
}

Prior Arts

zones.js

Zones proposed a Zone object, which has the following API:

class Zone {
  constructor({ name, parent });

  name;
  get parent();

  fork({ name });
  run(callback);
  wrap(callback);

  static get current();
}

The concept of the current zone, reified as Zone.current, is crucial. Both run and wrap are designed to manage running the current zone:

  • z.run(callback) will set the current zone to z for the duration of callback, resetting it to its previous value afterward. This is how you "enter" a zone.
  • z.wrap(callback) produces a new function that essentially performs z.run(callback) (passing along arguments and this, of course).

The current zone is the async context that propagates with all our operations. In our above example, sites (1) through (6) would all have the same value of Zone.current. If a developer had done something like:

const loadZone = Zone.current.fork({ name: "loading zone" });
window.onload = loadZone.wrap(e => { ... });

then at all those sites, Zone.current would be equal to loadZone.

Node.js domain module

Domain's global central active domain can be consumed by multiple endpoints and be exchanged in any time with synchronous operation (domain.enter()). Since it is possible that some third party module changed active domain on the fly and application owner may unaware of such change, this can introduce unexpected implicit behavior and made domain diagnosis hard.

Check out Domain Module Postmortem for more details.

Node.js async_hooks

This is what the proposal evolved from. async_hooks in Node.js enabled async resources tracking for APM vendors. On which Node.js also implemented AsyncLocalStorage.

Chrome Async Stack Tagging API

Frameworks can schedule tasks with their own userland queues. In such case, the stack trace originated from the framework scheduling logic tells only part of the story.

Error: Call stack
  at someTask (example.js)
  at loop (framework.js)

The Chrome Async Stack Tagging API introduces a new console method named console.createTask(). The API signature is as follows:

interface Console {
  createTask(name: string): Task;
}

interface Task {
  run<T>(f: () => T): T;
}

console.createTask() snapshots the call stack into a Task record. And each Task.run() restores the saved call stack and append it to newly generated call stacks.

Error: Call stack
  at someTask (example.js)
  at loop (framework.js)          // <- Task.run
  at async someTask               // <- Async stack appended
  at schedule (framework.js)      // <- console.createTask
  at businessLogic (example.js)

proposal-async-context's People

Contributors

legendecas avatar domenic avatar jridgewell avatar littledan avatar

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    ๐Ÿ–– Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. ๐Ÿ“Š๐Ÿ“ˆ๐ŸŽ‰

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google โค๏ธ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.