diff --git a/benchmark/async_hooks/async-resource-vs-destroy.js b/benchmark/async_hooks/async-resource-vs-destroy.js index 32212df0574bf3..9b85a4c20a493c 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'], method: ['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 asyncMethod = { diff --git a/doc/api/async_hooks.md b/doc/api/async_hooks.md index 303c3b58f76832..052191093883f7 100644 --- a/doc/api/async_hooks.md +++ b/doc/api/async_hooks.md @@ -580,6 +580,110 @@ 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 +}); +``` + +If the `AsyncLocal` was removed before this call is made, +[`ERR_ASYNC_LOCAL_CANNOT_SET_VALUE`][] is thrown. + +### asyncLocal.remove() + +When called, removes all values stored in the `AsyncLocal` and disables +callbacks for the internal `AsyncHook` instance. 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 @@ -747,3 +851,4 @@ never be called. [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 cf21c142d6dd97..99d3f348b288de 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 bc30b555e0f246..6a3e1247687e27 100644 --- a/lib/async_hooks.js +++ b/lib/async_hooks.js @@ -8,7 +8,8 @@ const { const { ERR_ASYNC_CALLBACK, - 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'); @@ -130,6 +131,47 @@ function createHook(fns) { return new AsyncHook(fns); } +// AsyncLocal API // + +const kResToValSymbol = Symbol('resToVal'); +const kHookSymbol = Symbol('hook'); + +class AsyncLocal { + constructor() { + const resToVals = new WeakMap(); + const init = (asyncId, type, triggerAsyncId, resource) => { + const value = resToVals.get(executionAsyncResource()); + if (value !== undefined) { + resToVals.set(resource, value); + } + }; + this[kHookSymbol] = createHook({ init }).enable(); + this[kResToValSymbol] = resToVals; + } + + 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() { + if (this[kResToValSymbol]) { + delete this[kResToValSymbol]; + this[kHookSymbol].disable(); + delete this[kHookSymbol]; + } + } +} + // Embedder API // @@ -207,6 +249,7 @@ module.exports = { executionAsyncId, triggerAsyncId, executionAsyncResource, + AsyncLocal, // Embedder API AsyncResource, }; diff --git a/lib/internal/errors.js b/lib/internal/errors.js index ad12d99c7cc49c..464bf211cff00c 100644 --- a/lib/internal/errors.js +++ b/lib/internal/errors.js @@ -722,6 +722,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..7799f5b2f20769 --- /dev/null +++ b/test/async-hooks/test-async-local-isolation.js @@ -0,0 +1,26 @@ +'use strict'; + +require('../common'); +const assert = require('assert'); +const async_hooks = require('async_hooks'); +const { AsyncLocal } = async_hooks; + +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..d8aae1f530e245 --- /dev/null +++ b/test/async-hooks/test-async-local-propagation.js @@ -0,0 +1,26 @@ +'use strict'; + +require('../common'); +const assert = require('assert'); +const async_hooks = require('async_hooks'); +const { AsyncLocal } = async_hooks; + +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.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..1ae85c868cda10 --- /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 +common.expectsError( + () => asyncLocal.set('bar'), { + code: 'ERR_ASYNC_LOCAL_CANNOT_SET_VALUE', + type: Error, + }); + +// Subsequent .remove() does not throw +asyncLocal.remove();