diff --git a/benchmark/async_hooks/async-resource-vs-destroy.js b/benchmark/async_hooks/async-resource-vs-destroy.js index 4464dd5f93e7de..5fec6ccc28c348 100644 --- a/benchmark/async_hooks/async-resource-vs-destroy.js +++ b/benchmark/async_hooks/async-resource-vs-destroy.js @@ -8,7 +8,8 @@ const common = require('../common.js'); const { createHook, executionAsyncResource, - executionAsyncId + executionAsyncId, + AsyncLocal } = require('async_hooks'); const { createServer } = require('http'); @@ -18,7 +19,7 @@ const connections = 500; const path = '/'; const bench = common.createBenchmark(main, { - type: ['async-resource', 'destroy'], + type: ['async-resource', 'destroy', 'async-local'], asyncMethod: ['callbacks', 'async'], n: [1e6] }); @@ -102,6 +103,29 @@ function buildDestroy(getServe) { } } +function buildAsyncLocal(getServe) { + const server = createServer(getServe(getCLS, setCLS)); + const asyncLocal = new AsyncLocal(); + + return { + server, + close + }; + + function getCLS() { + return asyncLocal.get(); + } + + function setCLS(state) { + asyncLocal.set(state); + } + + function close() { + asyncLocal.remove(); + server.close(); + } +} + function getServeAwait(getCLS, setCLS) { return async function serve(req, res) { setCLS(Math.random()); @@ -126,7 +150,8 @@ function getServeCallbacks(getCLS, setCLS) { const types = { 'async-resource': buildCurrentResource, - 'destroy': buildDestroy + 'destroy': buildDestroy, + 'async-local': buildAsyncLocal, }; const asyncMethods = { diff --git a/doc/api/async_hooks.md b/doc/api/async_hooks.md index 51792e2dd1d5be..82b7bf586ff094 100644 --- a/doc/api/async_hooks.md +++ b/doc/api/async_hooks.md @@ -579,6 +579,121 @@ const server = net.createServer((conn) => { Promise contexts may not get valid `triggerAsyncId`s by default. See the section on [promise execution tracking][]. +### Class: `AsyncLocal` + + + +This class can be used to store a value which follows asynchronous execution +flow. Any value set on an `AsyncLocal` instance is propagated to any callback +or promise executed within the flow. Because of that, a continuation local +storage can be build with an `AsyncLocal` instance. This API is similar to +thread local storage in other runtimes and languages. + +The implementation relies on async hooks to follow the execution flow. +So, if an application or a library does not play nicely with async hooks, +the same problems will be seen with the `AsyncLocal` API. In order to fix +such issues the `AsyncResource` API should be used. + +The following example shows how to use `AsyncLocal` to build a simple logger +that assignes ids to HTTP requests and includes them into messages logged +within each request. + +```js +const http = require('http'); +const { AsyncLocal } = require('async_hooks'); + +const asyncLocal = new AsyncLocal(); + +function print(msg) { + const id = asyncLocal.get(); + console.log(`${id !== undefined ? id : '-'}:`, msg); +} + +let idSeq = 0; +http.createServer((req, res) => { + asyncLocal.set(idSeq++); + print('start'); + setImmediate(() => { + print('finish'); + res.end(); + }); +}).listen(8080); + +http.get('http://localhost:8080'); +http.get('http://localhost:8080'); +// Prints: +// 0: start +// 1: start +// 0: finish +// 1: finish +``` + +#### `new AsyncLocal()` + +Creates a new instance of `AsyncLocal`. + +### `asyncLocal.get()` + +* Returns: {any} + +Returns the value of the `AsyncLocal` in current execution context, +or `undefined` if the value is not set or the `AsyncLocal` was removed. + +### `asyncLocal.set(value)` + +* `value` {any} + +Sets the value for the `AsyncLocal` within current execution context. + +Once set, the value will be kept through the subsequent asynchronous calls, +unless overridden by calling `asyncLocal.set(value)`: + +```js +const asyncLocal = new AsyncLocal(); + +setImmediate(() => { + asyncLocal.set('A'); + + setImmediate(() => { + console.log(asyncLocal.get()); + // Prints: A + + asyncLocal.set('B'); + console.log(asyncLocal.get()); + // Prints: B + }); + + console.log(asyncLocal.get()); + // Prints: A + + // Stop further value propagation + asyncLocal.set(undefined); + + console.log(asyncLocal.get()); + // Prints: undefined + + setImmediate(() => { + console.log(asyncLocal.get()); + // Prints: undefined + }); +}); +``` + +If the `AsyncLocal` was removed before this call is made, +[`ERR_ASYNC_LOCAL_CANNOT_SET_VALUE`][] is thrown. + +### `asyncLocal.remove()` + +Disables value propagation for the `AsyncLocal` and releases all +values stored by it. Calling `asyncLocal.remove()` multiple times will +have no effect. + +Any subsequent `asyncLocal.get()` calls will return `undefined`. +Any subsequent `asyncLocal.set(value)` calls will throw +[`ERR_ASYNC_LOCAL_CANNOT_SET_VALUE`][]. + ## Promise execution tracking By default, promise executions are not assigned `asyncId`s due to the relatively @@ -868,3 +983,4 @@ for (let i = 0; i < 10; i++) { [PromiseHooks]: https://docs.google.com/document/d/1rda3yKGHimKIhg5YeoAmCOtyURgsbTH_qaYR79FELlk/edit [`Worker`]: worker_threads.html#worker_threads_class_worker [promise execution tracking]: #async_hooks_promise_execution_tracking +[`ERR_ASYNC_LOCAL_CANNOT_SET_VALUE`]: errors.html#ERR_ASYNC_LOCAL_CANNOT_SET_VALUE diff --git a/doc/api/errors.md b/doc/api/errors.md index b186275807aee7..e5fe87c996c56c 100644 --- a/doc/api/errors.md +++ b/doc/api/errors.md @@ -643,6 +643,11 @@ by the `assert` module. An attempt was made to register something that is not a function as an `AsyncHooks` callback. + +### `ERR_ASYNC_LOCAL_CANNOT_SET_VALUE` + +An attempt was made to set value for a `AsyncLocal` after it was removed. + ### `ERR_ASYNC_TYPE` diff --git a/lib/async_hooks.js b/lib/async_hooks.js index 923b9d675818a3..aa0eca14261bc1 100644 --- a/lib/async_hooks.js +++ b/lib/async_hooks.js @@ -4,12 +4,14 @@ const { NumberIsSafeInteger, ReflectApply, Symbol, + WeakMap, } = primordials; const { ERR_ASYNC_CALLBACK, ERR_ASYNC_TYPE, - ERR_INVALID_ASYNC_ID + ERR_INVALID_ASYNC_ID, + ERR_ASYNC_LOCAL_CANNOT_SET_VALUE, } = require('internal/errors').codes; const { validateString } = require('internal/validators'); const internal_async_hooks = require('internal/async_hooks'); @@ -132,6 +134,63 @@ function createHook(fns) { return new AsyncHook(fns); } +// AsyncLocal API // + +const locals = []; +const localsHook = createHook({ + init(asyncId, type, triggerAsyncId, resource) { + const execRes = executionAsyncResource(); + // Using var here instead of let because "for (var ...)" is faster than let. + // Refs: https://github.com/nodejs/node/pull/30380#issuecomment-552948364 + for (var i = 0; i < locals.length; i++) { + locals[i][kPropagateSymbol](execRes, resource); + } + } +}); + +const kResToValSymbol = Symbol('resToVal'); +const kPropagateSymbol = Symbol('propagate'); + +class AsyncLocal { + constructor() { + this[kResToValSymbol] = new WeakMap(); + locals.push(this); + localsHook.enable(); + } + + [kPropagateSymbol](execRes, initRes) { + const value = this[kResToValSymbol].get(execRes); + // Always overwrite value to prevent issues with reused resources. + this[kResToValSymbol].set(initRes, value); + } + + get() { + if (this[kResToValSymbol]) { + return this[kResToValSymbol].get(executionAsyncResource()); + } + return undefined; + } + + set(value) { + if (!this[kResToValSymbol]) { + throw new ERR_ASYNC_LOCAL_CANNOT_SET_VALUE(); + } + this[kResToValSymbol].set(executionAsyncResource(), value); + } + + remove() { + const index = locals.indexOf(this); + if (index === -1) + return; + + delete this[kResToValSymbol]; + locals.splice(index, 1); + if (locals.size === 0) { + localsHook.disable(); + } + } +} + // Embedder API // @@ -213,6 +272,7 @@ module.exports = { executionAsyncId, triggerAsyncId, executionAsyncResource, + AsyncLocal, // Embedder API AsyncResource, }; diff --git a/lib/internal/errors.js b/lib/internal/errors.js index 0d88a92eb41ecc..cc40e7ace3a1a9 100644 --- a/lib/internal/errors.js +++ b/lib/internal/errors.js @@ -730,6 +730,8 @@ E('ERR_AMBIGUOUS_ARGUMENT', 'The "%s" argument is ambiguous. %s', TypeError); E('ERR_ARG_NOT_ITERABLE', '%s must be iterable', TypeError); E('ERR_ASSERTION', '%s', Error); E('ERR_ASYNC_CALLBACK', '%s must be a function', TypeError); +E('ERR_ASYNC_LOCAL_CANNOT_SET_VALUE', 'Cannot set value for removed AsyncLocal', + Error); E('ERR_ASYNC_TYPE', 'Invalid name for async "type": %s', TypeError); E('ERR_BROTLI_INVALID_PARAM', '%s is not a valid Brotli parameter', RangeError); E('ERR_BUFFER_OUT_OF_BOUNDS', diff --git a/test/async-hooks/test-async-local-isolation.js b/test/async-hooks/test-async-local-isolation.js new file mode 100644 index 00000000000000..70ab17449a77af --- /dev/null +++ b/test/async-hooks/test-async-local-isolation.js @@ -0,0 +1,29 @@ +'use strict'; + +require('../common'); +const assert = require('assert'); +const async_hooks = require('async_hooks'); +const { AsyncLocal } = async_hooks; + +// This test ensures isolation of `AsyncLocal`s +// from each other in terms of stored values + +const asyncLocalOne = new AsyncLocal(); +const asyncLocalTwo = new AsyncLocal(); + +setTimeout(() => { + assert.strictEqual(asyncLocalOne.get(), undefined); + assert.strictEqual(asyncLocalTwo.get(), undefined); + + asyncLocalOne.set('foo'); + asyncLocalTwo.set('bar'); + assert.strictEqual(asyncLocalOne.get(), 'foo'); + assert.strictEqual(asyncLocalTwo.get(), 'bar'); + + asyncLocalOne.set('baz'); + asyncLocalTwo.set(42); + setTimeout(() => { + assert.strictEqual(asyncLocalOne.get(), 'baz'); + assert.strictEqual(asyncLocalTwo.get(), 42); + }, 0); +}, 0); diff --git a/test/async-hooks/test-async-local-propagation.js b/test/async-hooks/test-async-local-propagation.js new file mode 100644 index 00000000000000..7db79df1f5ef79 --- /dev/null +++ b/test/async-hooks/test-async-local-propagation.js @@ -0,0 +1,30 @@ +'use strict'; + +require('../common'); +const assert = require('assert'); +const async_hooks = require('async_hooks'); +const { AsyncLocal } = async_hooks; + +// This test ensures correct work of the global hook +// that serves for propagation of all `AsyncLocal`s +// in the context of `.get()`/`.set(value)` calls + +const asyncLocal = new AsyncLocal(); + +setTimeout(() => { + assert.strictEqual(asyncLocal.get(), undefined); + + asyncLocal.set('A'); + setTimeout(() => { + assert.strictEqual(asyncLocal.get(), 'A'); + + asyncLocal.set('B'); + setTimeout(() => { + assert.strictEqual(asyncLocal.get(), 'B'); + }, 0); + + assert.strictEqual(asyncLocal.get(), 'B'); + }, 0); + + assert.strictEqual(asyncLocal.get(), 'A'); +}, 0); diff --git a/test/async-hooks/test-async-local-removal.js b/test/async-hooks/test-async-local-removal.js new file mode 100644 index 00000000000000..0ea5b27fe09147 --- /dev/null +++ b/test/async-hooks/test-async-local-removal.js @@ -0,0 +1,30 @@ +'use strict'; + +require('../common'); +const assert = require('assert'); +const async_hooks = require('async_hooks'); +const { AsyncLocal } = async_hooks; + +// This test ensures correct work of the global hook +// that serves for propagation of all `AsyncLocal`s +// in the context of `.remove()` call + +const asyncLocalOne = new AsyncLocal(); +asyncLocalOne.set(1); +const asyncLocalTwo = new AsyncLocal(); +asyncLocalTwo.set(2); + +setImmediate(() => { + // Removal of one local should not affect others + asyncLocalTwo.remove(); + assert.strictEqual(asyncLocalOne.get(), 1); + + // Removal of the last active local should not + // prevent propagation of locals created later + asyncLocalOne.remove(); + const asyncLocalThree = new AsyncLocal(); + asyncLocalThree.set(3); + setImmediate(() => { + assert.strictEqual(asyncLocalThree.get(), 3); + }); +}); diff --git a/test/async-hooks/test-async-local.async-await.js b/test/async-hooks/test-async-local.async-await.js new file mode 100644 index 00000000000000..fe2daba3832d26 --- /dev/null +++ b/test/async-hooks/test-async-local.async-await.js @@ -0,0 +1,24 @@ +'use strict'; + +const common = require('../common'); +const assert = require('assert'); +const async_hooks = require('async_hooks'); +const { AsyncLocal } = async_hooks; + +const asyncLocal = new AsyncLocal(); + +async function asyncFunc() { + return new Promise((resolve) => { + setTimeout(resolve, 0); + }); +} + +async function testAwait() { + asyncLocal.set('foo'); + await asyncFunc(); + assert.strictEqual(asyncLocal.get(), 'foo'); +} + +testAwait().then(common.mustCall(() => + assert.strictEqual(asyncLocal.get(), 'foo') +)); diff --git a/test/async-hooks/test-async-local.js b/test/async-hooks/test-async-local.js new file mode 100644 index 00000000000000..74329f79174847 --- /dev/null +++ b/test/async-hooks/test-async-local.js @@ -0,0 +1,33 @@ +'use strict'; + +const common = require('../common'); +const assert = require('assert'); +const async_hooks = require('async_hooks'); +const { AsyncLocal } = async_hooks; + +assert.strictEqual(new AsyncLocal().get(), undefined); + +const asyncLocal = new AsyncLocal(); + +assert.strictEqual(asyncLocal.get(), undefined); + +asyncLocal.set(42); +assert.strictEqual(asyncLocal.get(), 42); +asyncLocal.set('foo'); +assert.strictEqual(asyncLocal.get(), 'foo'); +const obj = {}; +asyncLocal.set(obj); +assert.strictEqual(asyncLocal.get(), obj); + +asyncLocal.remove(); +assert.strictEqual(asyncLocal.get(), undefined); + +// Throws on modification after removal +const error = common.expectsError({ + code: 'ERR_ASYNC_LOCAL_CANNOT_SET_VALUE', + name: 'Error', +}); +assert.throws(() => asyncLocal.set('bar'), error); + +// Subsequent .remove() does not throw +asyncLocal.remove();