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();