Skip to content

async_hooks: add use() method to AsyncLocalStorage #58104

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions doc/api/async_context.md
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,43 @@ try {
}
```

### `asyncLocalStorage.use(store)`

<!-- YAML
added: REPLACEME
-->

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it intended to be added as stable ?
Otherwise add > Stability: 1 - Experimental here.

* `store` {any}
* Returns: {Disposable} A disposable object.

Transitions into the given context, and transitions back into the previous
Copy link
Member

@Flarna Flarna May 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's not the previous context. It's that one seen at the time use() was called.
If use and the matching dispose are called in right order this is the same but if they are called in different order it's not.

e.g. a cast like this (condensed, in real world this would be distributed at independent locations):

const { AsyncLocalStorage } = require("node:async_hooks");

const als1 = new AsyncLocalStorage();
const als2 = new AsyncLocalStorage();

const d1 = als1.use(store1);
const d2 = als2.use(store2);
const d3 = als1.use(store3);
als1.getStore(); // Returns store3
als2.getStore(); // Returns store2

d1[Symbol.dispose]();
als1.getStore(); // Returns undefined
als2.getStore(); // Returns undefined

d2[Symbol.dispose]();
als1.getStore(); // Returns store1
als2.getStore(); // Returns undefined

d3[Symbol.dispose]();
als1.getStore(); // Returns store1
als2.getStore(); // Returns store2

Likely a user problem and once using is a thing harder to do.
But I think it should be clear documented what happens or avoid such cases.

In special that one ALS user might effect others is critical in my opinion.

Note that only the AsyncContextFrame variant shows side effects to all ALS users.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think if folks are manually calling Symbol.dispose they're rather on their own at that point. It's a bit unfortunate that the ERM model gives them that ability but there's only do much we can do.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That said, we'll want to be sure that this works as expected when using DisposableStack when that is available.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I fully agree once using keyword is a thing. But until this is true users have to do it manually.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I notices that the side effect to other ALS instances is not bound to wrong usage of dispose:

const d1 = als1.use("store1");
als2.enterWith("store2");
console.log(als1.getStore()); // store1
console.log(als2.getStore()); // store2
d1[Symbol.dispose]();
console.log(als1.getStore()); // undefined
console.log(als2.getStore()); // undefined, but should be store2

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I noticed that this global side effect is not new in the AsyncContextFrame variant. See #58149.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

use() is intended to roughly mimic run(), and since that's been changed in #58149 to isolate the storage instances, I'll do that here as well in the next rev.

context when the returned disposable object is disposed. The store is
accessible to any asynchronous operations created before disposal.

Example:

```js
const store1 = { id: 1 };
const store2 = { id: 2 };

function inner() {
// Once `using` syntax is supported, you can use that here, and omit the
// dispose call at the end of this function.
const disposable = asyncLocalStorage.use(store);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

using works meanwhile on main (see #58154) therefore this should be updated accordingly.

asyncLocalStorage.getStore(); // Returns store2
setTimeout(() => {
asyncLocalStorage.getStore(); // Returns store2
}, 200);
disposable[Symbol.dispose]();
}

asyncLocalStorage.run(store1, () => {
asyncLocalStorage.getStore(); // Returns store1
inner();
asyncLocalStorage.getStore(); // Returns store1
});
```

### `asyncLocalStorage.exit(callback[, ...args])`

<!-- YAML
Expand Down
18 changes: 18 additions & 0 deletions lib/internal/async_local_storage/async_context_frame.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

const {
ReflectApply,
SymbolDispose,
} = primordials;

const {
Expand All @@ -11,6 +12,19 @@ const {
const AsyncContextFrame = require('internal/async_context_frame');
const { AsyncResource } = require('async_hooks');

class DisposableStore {
#oldFrame = undefined;

constructor(store, storage) {
this.#oldFrame = AsyncContextFrame.current();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess by capturing/restoring only the owned store instead the complete frame the side effects to other ALS users could be avoided.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, an operation on an AsyncLocalStorage should not affect other instances. For reference: tc39/proposal-async-context-disposable#2

storage.enterWith(store);
}

[SymbolDispose]() {
AsyncContextFrame.set(this.#oldFrame);
}
}

class AsyncLocalStorage {
#defaultValue = undefined;
#name = undefined;
Expand Down Expand Up @@ -62,6 +76,10 @@ class AsyncLocalStorage {
}
}

use(data) {
return new DisposableStore(data, this);
}

exit(fn, ...args) {
return this.run(undefined, fn, ...args);
}
Expand Down
31 changes: 31 additions & 0 deletions lib/internal/async_local_storage/async_hooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const {
ObjectIs,
ReflectApply,
Symbol,
SymbolDispose,
} = primordials;

const {
Expand All @@ -30,6 +31,31 @@ const storageHook = createHook({
},
});

class DisposableStore {
#oldStore = undefined;
#resource = undefined;
#kResourceStore = undefined;

constructor(store, storage) {
this.#oldStore = storage.getStore();

if (ObjectIs(store, this.#oldStore)) {
return;
}

this.#kResourceStore = storage.kResourceStore;
this.#resource = executionAsyncResource();
this.#resource[this.#kResourceStore] = store;
}

[SymbolDispose]() {
if (this.#resource === undefined) {
return;
}
this.#resource[this.#kResourceStore] = this.#oldStore;
}
}

class AsyncLocalStorage {
#defaultValue = undefined;
#name = undefined;
Expand Down Expand Up @@ -120,6 +146,11 @@ class AsyncLocalStorage {
}
}

use(store) {
this._enable();
return new DisposableStore(store, this);
}

exit(callback, ...args) {
if (!this.enabled) {
return ReflectApply(callback, null, args);
Expand Down
25 changes: 25 additions & 0 deletions test/parallel/test-async-local-storage-use.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
'use strict';

require('../common');
const assert = require('assert');
const { AsyncLocalStorage } = require('async_hooks');

const storage = new AsyncLocalStorage();

const store1 = {};
const store2 = {};
const store3 = {};

function inner() {
// TODO(bengl): Once `using` is supported use that here and don't call
// dispose manually later.
const disposable = storage.use(store2);
assert.strictEqual(storage.getStore(), store2);
storage.enterWith(store3);
disposable[Symbol.dispose]();
}

storage.enterWith(store1);
assert.strictEqual(storage.getStore(), store1);
inner();
assert.strictEqual(storage.getStore(), store1);
Loading