Skip to content
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

Revisit the AsyncLocalStorage onPropagate option #46374

Closed
jasnell opened this issue Jan 26, 2023 · 15 comments
Closed

Revisit the AsyncLocalStorage onPropagate option #46374

jasnell opened this issue Jan 26, 2023 · 15 comments
Labels
async_hooks Issues and PRs related to the async hooks subsystem.

Comments

@jasnell
Copy link
Member

jasnell commented Jan 26, 2023

The onPropagate option was landed as experimental in v19.2... I'd like to propose that we revisit that and take a different approach.

https://nodejs.org/dist/latest-v19.x/docs/api/async_context.html#new-asynclocalstorageoptions

As background, onPropagate attaches a callback function to the AsyncLocalStorage instance that is invoked whenever a new AsyncResource is created. This is problematic primarily because it incurs a significant additional performance overhead and assumes a model that is based on the underlying async_hooks API model (which I think there's consensus we want to move away from #46265).

Alternatively, I'd like to propose a "tag' model like in the following example:

// Defines the ALS instance such that a matching tag must be
// passed in to `getStore()` to successfully retrieve the store.
const als = new AsyncLocalStorage({ tag: 'foo' });

console.log(als.run(123, () => als.getStore('foo')); // 123
console.log(als.run(321, () => als.getStore()); // undefined, doesn't match
console.log(als.run(321, () => als.getStore('bar')); // undefined, doesn't match

The tag here effectively becomes a component of the storage key. This is much more efficient design that will not incur a significant additional performance penalty.

/cc @legendecas @littledan @nodejs/async_hooks

@jasnell jasnell added the async_hooks Issues and PRs related to the async hooks subsystem. label Jan 26, 2023
@Flarna
Copy link
Member

Flarna commented Jan 26, 2023

onPropagate doesn't rely on async hooks. It requires a notification that propagation happens which can be anything else.

Here you wrote Whenever an AsyncResource is created, it simply grabs a reference to the current storage context.
I assume fetching a reference and store it internally is also some sort of function call. onPropagate is not intended for any sort of heavy calculations but well, noone stops people from doing such stuff.

anyhow, the main usecase I had in mind for onPropagate is to avoid that a store is kept alive forever in e.g. a setInterval because it's propagate forever. While technically this is correct it's not always helpful.

In the onPropagate callback each AsyncLocalStore user can implement it's algorithm. It could be a hop count in the store, based on the name of the AsyncResource or some store age,....

To my understanding the tag variant doesn't solve this because it's more like an userId. The same functionality is currently available by just creating more instances of AsyncLocalStore.

@jasnell
Copy link
Member Author

jasnell commented Jan 26, 2023

The key point is that onPropagate, as defined, is "called during creation of an async resource". It assumes that the individual ALS is propagated to the individual resource, which means for every resource that is created we end up calling N onPropagate callbacks depending on the number AsyncLocalStorage instances that being used. That can get very expensive and can be error prone.

For the setInterval case, something like als.exit(() => setInterval(...)) should work to prevent propagation of that specific AsyncLocalStorage instance if that is what you want.

@Flarna
Copy link
Member

Flarna commented Jan 26, 2023

For the setInterval case, something like als.exit(() => setInterval(...)) should work to prevent propagation of that specific AsyncLocalStorage instance if that is what you want.

You assume here that an ALS user has full control over the complete code which is usually not true. If one has full control over the code ALS would be not needed because one can manually pass the context around (like done in go).

If e.g. some database driver internally does setInterval this database driver has no access to all ALS instances to call exit first. And some ALS instances may want propagation across setInterval (but may have other barriers).

@jasnell
Copy link
Member Author

jasnell commented Jan 26, 2023

If e.g. some database driver internally does setInterval this database driver has no access to all ALS instances to call exit first.

Yep, I understand that. I think this is a more general problem that the AsyncContext proposal is going to need to address. Right now, I'm very much concerned that onPropagate might not be compatible with that model however. @littledan @jridgewell @legendecas ... I'd appreciate your thoughts on this.

@jridgewell
Copy link
Contributor

jridgewell commented Jan 26, 2023

Right now, I'm very much concerned that onPropagate might not be compatible with that model however.

It definitely not compatible with the current AsyncContext model. With absolute certainty, an API like onPropagate will never pass the committee for 2 reasons:

  1. Performance concerns highlighted in Revisit the AsyncLocalStorage onPropagate option #46374 (comment)
    • The creation of a async continuation must be an O(1) algorithm. Any additional expense hurts all code, not just code that uses AsyncContext.
    • It prevents engine optimizations for Promises, including internal promises used for async/await.
  2. Exposes lifecycle events to user code
    • This being absolutely antithetical to the SES community's security goals

Personally, I think onPropagate also breaks an important mental model:

async function test() { 
  const before = als.getStore();
  await 1;
  const after = als.getStore();

  assert.strictEqual(before, after);
}

@Flarna
Copy link
Member

Flarna commented Jan 26, 2023

Are there any ideas how to cover the use cases mentioned above without onPropagate?

@jridgewell
Copy link
Contributor

jridgewell commented Jan 26, 2023

With the ability to single out setInterval specifically? No, I can't think of a good solution.

If you're ok with a general timeout, you could implement something using an intermediate holder:

type Intermediate<T> = {
  value: T | undefined;
};

export class TimedContext<T> extends AsyncContext<Intermediate<T>> {
  get(): T | undefined {
    return super.get()?.value;
  }

  run(value: T, cb: (...args: any[]) => any, ...args: any[]): any {
    const intermediate = { value: value };
    setTimeout(() => {
      intermediate.value = undefined;
    }, 10 * 60 * 60 * 1000);

    return super.run(intermediate, cb, ...args);
  }
}

@Flarna
Copy link
Member

Flarna commented Jan 26, 2023

It's not concrete about setInterval, this is just an example where a store gets captured forever.

It's about the possibility to break/stop propagation based on "some" condition. And the point is to do this for one AsyncLocalStore instance without impacting the others.

Please note that AsyncLocalStore instances are in most cases created independent of the remaining application (e.g. like in domain module in node.js). The actual code executed later is out of reach to tune it.

I think the best workaround is likely to wrap the actual store in a proxy object and clear it in a getStore. The proxy object is still kept alive forever, the (likely bigger) real store only if getStore is never called in this context (which could be forced by using a timer).
Big benefit is that getStore() happens by far less frequent then propagation.

@jasnell
Copy link
Member Author

jasnell commented Jan 27, 2023

Given the conversation here, and given that onPropagate is not likely to be forward compatible with AsyncContext when it comes, I'd like to propose that we remove it and revisit other possible approaches to addressing those cases. We don't have to replace it with anything specific yet but can keep exploring other options.

@legendecas
Copy link
Member

@jasnell Alternatively, I'd like to propose a "tag' model like in the following example.

I didn't see how this "tag" model would solve the problem. Aside from passing the AsyncLocalStorage instances around to retrieve the store value, a "tag" for validation doesn't seem to add additional invariance here. AsyncLocalStorage already meets the OCAP model, i.e. without explicit grant of the object no user code can access the store value of it (well, one can access the store through the storage's symbol but that's implementation details).

@Flarna It's not concrete about setInterval, this is just an example where a store gets captured forever.

It's about the possibility to break/stop propagation based on "some" condition. And the point is to do this for one AsyncLocalStore instance without impacting the others.

Binding to the root context avoids capturing the store values. I didn't find a real-world example of filtered propagation, would you mind sharing one if possible?

@jasnell
Copy link
Member Author

jasnell commented Jan 27, 2023

I didn't see how this "tag" model would solve the problem.

Let's forget about tag for now. The key issue is that onPropagate is problematic and not forwards compatible. Let's start with just backing that out and we can investigate other options separately.

jasnell added a commit to jasnell/node that referenced this issue Jan 27, 2023
Refs: nodejs#46374

The `onPropagate` option for `AsyncLocalStorage` is problematic for a
couple of reasons:

1. It is not expected to be forwards compatible in any way with the
   upcoming TC-39 `AsyncContext` proposal.
2. It introduces a non-trivial O(n) cost invoking a JavaScript callback
   for *every* AsyncResource that is created, including every Promise.

While it is still experimental, I recommend removing it while we can
revisit the fundamental use cases in light of the coming `AsyncContext`
proposal.
@Flarna
Copy link
Member

Flarna commented Jan 30, 2023

Binding to the root context avoids capturing the store values. I didn't find a real-world example of filtered propagation, would you mind sharing one if possible?

Binding to root requires access/modifications at the actual code location where the new AsyncResource is crated. For monitoring tools like OTel instrumentations the actual application code and the owner of ALS instance are not the same therefore this is hard to archive. e.g. HTTP server instrumentation wraps the HTTP request handler and calls the original with a store set but the HTTP instrumentation can't know/modify the actual user code in the original request handler.

The discussion here touches also this topic but very specific to timers wheres my idea with onPropagate was to allow users to provide a predicate to stop propagation based on a (hopefully simple) decision.

Anyhow, adding onPropagate as experimental was exactly done to allow modifications. Removal is just a special case of modification :o) and there will be another way to archive the target. It's not yet used anywhere in production code from our side.

jasnell added a commit to jasnell/node that referenced this issue Jan 30, 2023
Refs: nodejs#46374

The `onPropagate` option for `AsyncLocalStorage` is problematic for a
couple of reasons:

1. It is not expected to be forwards compatible in any way with the
   upcoming TC-39 `AsyncContext` proposal.
2. It introduces a non-trivial O(n) cost invoking a JavaScript callback
   for *every* AsyncResource that is created, including every Promise.

While it is still experimental, I recommend removing it while we can
revisit the fundamental use cases in light of the coming `AsyncContext`
proposal.
@jasnell
Copy link
Member Author

jasnell commented Jan 30, 2023

Thinking more about the binding to root case, if we do adopt the AsyncLocalStorage.snapshot()... it's conceivable that we could add an option there like AsyncLocalStorage.snapshot({ root: true }) or have a AsyncLocalStorage.snapshotRoot() that always binds to the root empty context. One could then always establish a new async context branch from there forward:

const als = new AsyncLocalStorage();
const runInRootContext = AsyncLocalStorage.snapshotRoot();
runInRootContext (() => {
  als.run(123, () => {
    // ...
  });
});

@Flarna
Copy link
Member

Flarna commented Jan 30, 2023

als.run replaces the current store, there is no need to switch to an empty store. Besides that there is already als.exit for this purpose.

But as said usually code where propagation is triggered and owners of the als instances are not the same therefore explicit calls on all these als instances are not an option.

jasnell added a commit that referenced this issue Feb 1, 2023
The `onPropagate` option for `AsyncLocalStorage` is problematic for a
couple of reasons:

1. It is not expected to be forwards compatible in any way with the
   upcoming TC-39 `AsyncContext` proposal.
2. It introduces a non-trivial O(n) cost invoking a JavaScript callback
   for *every* AsyncResource that is created, including every Promise.

While it is still experimental, I recommend removing it while we can
revisit the fundamental use cases in light of the coming `AsyncContext`
proposal.

Refs: #46374
PR-URL: #46386
Reviewed-By: Stephen Belanger <[email protected]>
Reviewed-By: Vladimir de Turckheim <[email protected]>
Reviewed-By: Geoffrey Booth <[email protected]>
Reviewed-By: Benjamin Gruenbaum <[email protected]>
Reviewed-By: Gerhard Stöbich <[email protected]>
Reviewed-By: Chengzhong Wu <[email protected]>
@Flarna
Copy link
Member

Flarna commented Feb 1, 2023

onPropagate open has been removed via #46386

@Flarna Flarna closed this as completed Feb 1, 2023
MylesBorins pushed a commit that referenced this issue Feb 18, 2023
The `onPropagate` option for `AsyncLocalStorage` is problematic for a
couple of reasons:

1. It is not expected to be forwards compatible in any way with the
   upcoming TC-39 `AsyncContext` proposal.
2. It introduces a non-trivial O(n) cost invoking a JavaScript callback
   for *every* AsyncResource that is created, including every Promise.

While it is still experimental, I recommend removing it while we can
revisit the fundamental use cases in light of the coming `AsyncContext`
proposal.

Refs: #46374
PR-URL: #46386
Reviewed-By: Stephen Belanger <[email protected]>
Reviewed-By: Vladimir de Turckheim <[email protected]>
Reviewed-By: Geoffrey Booth <[email protected]>
Reviewed-By: Benjamin Gruenbaum <[email protected]>
Reviewed-By: Gerhard Stöbich <[email protected]>
Reviewed-By: Chengzhong Wu <[email protected]>
danielleadams pushed a commit that referenced this issue Apr 11, 2023
The `onPropagate` option for `AsyncLocalStorage` is problematic for a
couple of reasons:

1. It is not expected to be forwards compatible in any way with the
   upcoming TC-39 `AsyncContext` proposal.
2. It introduces a non-trivial O(n) cost invoking a JavaScript callback
   for *every* AsyncResource that is created, including every Promise.

While it is still experimental, I recommend removing it while we can
revisit the fundamental use cases in light of the coming `AsyncContext`
proposal.

Refs: #46374
PR-URL: #46386
Reviewed-By: Stephen Belanger <[email protected]>
Reviewed-By: Vladimir de Turckheim <[email protected]>
Reviewed-By: Geoffrey Booth <[email protected]>
Reviewed-By: Benjamin Gruenbaum <[email protected]>
Reviewed-By: Gerhard Stöbich <[email protected]>
Reviewed-By: Chengzhong Wu <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
async_hooks Issues and PRs related to the async hooks subsystem.
Projects
None yet
Development

No branches or pull requests

4 participants