Hold my context!



Miroslav Bajtoš

About me

🇨🇿

Senior Software Engineer

@ Protocol Labs

History


StrongLoop & IBM

Node Inspector

LoopBack

Node.js core

What is context propagation?

Example API server




      const app = require('express')();
      const {queryProducts} = require('./db');
      const {getRatings} = require('./ratings');

      app.get('/products', listProducts);

      function listProducts(req, res, next) {
        const filter = /* build filter from req.query */;
        queryProducts(filter, (err, products) => {
          if (err) return next(err);

          const prodIds = products.map(p => p.id);
          getRatings(prodIds, (err, ratings) => {
            if (err) return next(err);
            res.json(/* build the response */);
          });
        });
      }
    

Application Context


Correlation ID

User credentials

User permissions

Implicit Context


queryProducts called from listProducts

getRatings called from queryProducts callback

/products handler triggers SQL query
SELECT * FROM products

/products handler triggers HTTP call to
GET /ratings

Thread-per-request model

Event-loop model

Early solutions

Domains (2012-2015)



    const domain = require('domain');

    const server = require('http').createServer((req, res) => {
      const d = domain.create();
      d.on('error', (err) => { /* handle uncaught errors */ });

      d.add(req);
      d.add(res);

      d.run(() => {
        handleRequest(req, res);
      });
    });
    


continuation-local-storage (2013-2017)


    // request-context.js
    const {createNamespace} =
      require('continuation-local-storage');
    module.exports = createNamespace('my local storage');

    // server.js
    const requestCtx = require('./request-context');
    const server = require('http').createServer((req, res) => {
      requestCtx.run(() => {
        requestCtx.bindEmitter(req);
        requestCtx.bindEmitter(res);
        handleRequest(req, res);
      });
    });

    // inside your middleware and routes
    const ctx = require('./request-context');
    ctx.set('request-id', req.headers['X-Correlation-ID']);
    const reqId = ctx.get('request-id');
    

Issues

Context is lost

Request context is undefined

Incorrect context

Request 2 gets context of request 1

Connection pools

Task queues


user-land Promise implementations


Explicit context passing


      function listProducts(req, res, next) {
        const context = /* build from req */;
        const filter = /* build filter from req.query */;
        queryProducts(context, filter, (err, products) => {
          if (err) return next(err);

          const prodIds = products.map(p => p.id);
          getRatings(context, prodIds, (err, ratings) => {
            if (err) return next(err);
            res.json(/* build the response */);
          });
        });
      }
    

Pros


No magic

Always reliable


Cons


Major code changes

No implicit context propagation


The holy grail


Built-in API

Supported by all core modules

(including native Promises)

API to restore context

Little to no performance overhead


The quest

async listener (2013-2014)


Design based on domains

Abandoned before release


async wrap (2015-2018)


Based on async listener internals

Undocumented low-level API


async hooks (2017-now)


  • Built-in API (experimental status)
  • Supported by all core modules
  • (including native Promises)
  • API to restore context
  • Little to no performance overhead


cls-hooked (2016-2019)


continuation-local-storage + async hooks


async local storage (2020-now)

🎯

Node.js core feature

Easy-to-use API

Stable since version 16.4.0 (Feb 2021)

Async Hooks


    const async_hooks = require('async_hooks');

    async_hooks.createHook({
      init(asyncId, type, triggerAsyncId) {
        fs.writeSync(
          process.stdout.fd,
          `${type} ${asyncId} triggered by ${triggerAsyncId}\n`,
        );
      }
    }).enable();

    

    require('net').createServer((conn) => {}).listen(8080);
    

server started


      TCPSERVERWRAP 5 triggered by 1
      

connection accepted


      TCPWRAP 7 triggered by 5
      

Restore context manually



    const { AsyncResource } = require('async_hooks');

    module.exports = function queue(taskFn, callback) {
      callback = AsyncResource.bind(callback);

      myTaskQueueImpl.schedule(taskFn, callback);
    }
    

Promises preserve context



    module.exports = function queue(taskFn) {
      return new Promise((resolve, reject) => {

        myTaskQueueImpl.schedule(taskFn, (err, result) => {

          if (err) return reject(err);
          resolve(result);
        });
      });
    }
    

Async Local Storage


    // request-context.js
    const { AsyncLocalStorage } = require('async_hooks');
    module.exports = new AsyncLocalStorage();

    // server.js
    const localStorage = require('./request-context');
    const server = require('http').createServer((req, res) => {
      localStorage.run(
        // initial context,
        new Map(),
        // function to run
        handleRequest,
        // `this` + arguments
        undefined, req, res);
      });
    });

    // inside your middleware and routes
    const localStorage = require('./request-context');
    const ctx = localStorage.getStore();
    ctx.set('request-id', req.headers['X-Correlation-ID']);
    const reqId = ctx.get('request-id');
    

fastify


    // server.js
    const Fastify = require('fastify');
    const {
      fastifyRequestContextPlugin
    } = require('@fastify/request-context');

    const app = Fastify();
    app.register(fastifyRequestContextPlugin);

    // inside your middleware and routes
    const {requestContext} = require('@fastify/request-context');
    requestContext
      .set('request-id', req.headers['X-Correlation-ID']);
    const reqId = requestContext.get('request-id');
    

Known issues

Tricky edge cases



    const storage = new AsyncLocalStorage();
    http.createServer((req, res) => {
      storage.run({ /* context */ }, function () {
        req.resume();
        req.on('end', () => {
          // context is lost
        });
      });
    });
    


See node#41285 and node#41978

User-land modules still catching up

pg-pool


Callbacks breaks async context

(Promises works)


abandonware


Example: q

Last release: Oct 2017

Weekly downloads: 14.7m


async-break-finder


Detects async context propagation problems


Takeaways

AsyncLocalStorage is finally here

(experimental in Node.js 14.x, stable from 16.x)

Native Promises & async/await
restore async context

Choose modern & actively-maintained dependencies



Thank you!


Miroslav Bajtoš
twitter.com/bajtos


slides: bajtos.net/C