Is your feature request related to a problem? Please describe.
Please describe the problem you are trying to solve.
Right now, the AsyncLocalStorage
object needs to be externally available for the async functions to avail the store.
Describe the solution you'd like
Please describe the desired behavior.
Implement a getContextCLS
API that returns the AsyncLocalStorage
object under which this asynchronous call was initiated. Return undefined, if it was not run under AsyncLocalStorage
semantics.
Illustration:
const { AsyncLocalStorage } = require('async_hooks');
function main() {
const cls1 = new AsyncLocalStorage();
const cls2 = new AsyncLocalStorage();
cls1.run(() => {
const store = cls1.getStore()
store.set('x', 'y')
setTimeout(foo, 1000)
})
cls2.run(() => {
const store = cls2.getStore()
store.set('x', 'z')
setTimeout(foo, 1000)
})
}
function foo() {
// obtain the cls object here that this async call was part of
// for example:
// const cls = getContextCLS()
// const store = cls.getStore()
// console.log(store.get('x')
// prints 'y' and 'z' as and when those are called.
}
main()
Use case: in a concurrent workload scenario, it is not easy to maintain AsyncLocalStorage
objects globally, and across multiple async function families.
Describe alternatives you've considered
Please describe alternative solutions or features you have considered.
Alternative is to share these objects globally (which is not very attractive)
/cc @vdeturckheim
My understanding is that there is no particular AsyncLocalStorage
instance associated with any async call, so I’m not sure what getContextCLS()
would actually return?
Alternative is to share these objects globally (which is not very attractive)
Why not? I think that’s the thing that conceptually makes the most sense here.
We did not want to make the AsyncLocalStorage
s globally availabed by default to let users hide their instance and prevent anyone from tempering with it.
As there might be multiple AsyncLocalStorage
running on the same code, a method like getContextCLS()
would return an array:
const { AsyncLocalStorage } = require('async_hooks');
const asl1 = new AsyncLocalStorage();
const asl2 = new AsyncLocalStorage();
asl1.run(new Map(), () => {
asl2.run(new Map(), () => {
AsyncLocalStorage.getActive(); // returns [asl1, asl2];
});
});
I don't have strong opinions here appart that I'd like to leave the owner of an AsyncLocalStorage
the ability to keep it private.
@addaleax - the problem with the global approach is that we need to explicitly maintain the association of AsyncLocalStorage
(let us call it ALS) with an async function. For example, in the code in the first comment, foo
will be called from both the contexts, in arbitrary order. So even if we make cls1
and cls2
global, how do one decide which ALS
to use (to access the store etc.)?
@gireeshpunathil I think there might be a misunderstanding about how ALS works. In particular, AsyncLocalStorage
instances are not async contexts.
So even if we make
cls1
andcls2
global, how do one decide whichALS
to use (to access the store etc.)?
I don’t quite understand this question… can you maybe give a more concrete use case as an example?
sure:
ALS.run
context, so all the callbacks emanating from that context are grouped under one ALS object.Is it possible to achieve this using ALS
APIs?
[ edit: couple of typos]
@gireeshpunathil in that case you would use one single ASL:
const asl = new AsyncLocalStorage();
server.on('request', (req, res) => {
asl.run({ req, res}, () => {
// all calls to asl.getStore() wil return {req, res}
});
});
Would that make sense in your example?
@vdeturckheim - here is a complete example that exemplifies my use case, but using your suggestion of using a single ALS:
const { AsyncLocalStorage } = require('async_hooks')
const http = require('http')
const cls = new AsyncLocalStorage()
let store
let index = 0
const server = http.createServer((q, r) => {
r.end((index++).toString().repeat(1024 * 1024 * 10))
})
server.listen(12000, () => {
cls.run(new Map(), () => {
for(let i = 0; i < 10; i++) {
const req = http.get('http://localhost:12000', (res) => {
const data = ''
store = cls.getStore()
store.set('data', data)
res.on('data', ondata)
res.on('end', onend)
})
req.end()
}
})
})
function ondata(d) {
// keep store globally due to a bug, ref: https://github.com/nodejs/node/issues/32060
// const store = cls.getStore()
if (store && store.has('data')) {
let chunk = store.get('data')
chunk += d
store.set('data', chunk)
} else {
console.log('error...')
}
}
function onend() {
// let store = cls.getStore()
if (store && store.has('data')) {
let chunk = store.get('data')
var re = new RegExp(chunk[0], 'g')
console.log(`stream type: ${chunk[0]}`)
console.log(`stream length: ${chunk.replace(re, '').length}`)
} else {
console.log('ended, but in error...')
}
}
the test does these:
the output shows they are not, instead the data is all clobbered between various responses
stream type: 9
stream length: 73947963
stream type: 9
stream length: 73948064
stream type: 9
stream length: 73948165
stream type: 9
stream length: 73948266
stream type: 9
stream length: 73948367
stream type: 9
stream length: 73948468
stream type: 9
stream length: 73948569
stream type: 9
stream length: 73948670
stream type: 9
stream length: 75498381
stream type: 9
stream length: 75498381
expected output:
stream type: 0
stream length: 0
stream type: 1
stream length: 0
stream type: 2
stream length: 0
stream type: 3
stream length: 0
stream type: 4
stream length: 0
stream type: 5
stream length: 0
stream type: 6
stream length: 0
stream type: 7
stream length: 0
stream type: 8
stream length: 0
stream type: 9
stream length: 0
@gireeshpunathil
cls.run(new Map(), () => {
for(let i = 0; i < 10; i++) {
should be
for(let i = 0; i < 10; i++) {
cls.run(new Map(), () => {
otherwise all the requests share a single store.
ok, I can test this with https://github.com/nodejs/node/pull/32063 only, because with this change, we will have 10 stores, and with the getStore
bug, I will have to manage them separately. Will test and get back, thanks!
@addaleax - with the patch of your PR, and with your suggested patch, the code is working as expected, thanks!
so a single instance of AsyncLocalStorage
is capable of handling multiple callback chains (families, groups, etc. whatever we call those), through giving access to the appropriate store when invoked within the asynchronous routines! then why we would multiple instances of AsyncLocalStorage
in the first place (why the new operator) ?
that is what confused me.
However, with a single object able to anchor all the stores in an application, keeping it global makes sense to me, and that addresses my use case as well. Not sure if there are other scenarios where this is not the case. Keeping it open for a couple of days to see if any.
thanks once again!
then why we would multiple instances of
AsyncLocalStorage
in the first place (why the new operator) ?
Because different AsyncLocalStorage
instances can be used for different purposes or even from different npm modules, which should not conflict with each other. And in particular, the scopes that they use might not match; for example, in
cls1.run(new Map(), () => {
cls2.run(new Map(), () => {
x()
});
cls2.run(new Map(), () => {
y()
});
});
You’ll see x()
and y()
sharing a store as far as cls1
is concerned but having different stores as far as cls2
is concerned.
(But I mostly think it’s the fact that we don’t want ALS from different modules to conflict.)
Is your feature request related to a problem? Please describe.
Please describe the problem you are trying to solve.Right now, the
AsyncLocalStorage
object needs to be externally available for the async functions to avail the store.Describe the solution you'd like
Please describe the desired behavior.Implement a
getContextCLS
API that returns theAsyncLocalStorage
object under which this asynchronous call was initiated. Return undefined, if it was not run underAsyncLocalStorage
semantics.Illustration:
const { AsyncLocalStorage } = require('async_hooks'); function main() { const cls1 = new AsyncLocalStorage(); const cls2 = new AsyncLocalStorage(); cls1.run(() => { const store = cls1.getStore() store.set('x', 'y') setTimeout(foo, 1000) }) cls2.run(() => { const store = cls2.getStore() store.set('x', 'z') setTimeout(foo, 1000) }) } function foo() { // obtain the cls object here that this async call was part of // for example: // const cls = getContextCLS() // const store = cls.getStore() // console.log(store.get('x') // prints 'y' and 'z' as and when those are called. } main()
Use case: in a concurrent workload scenario, it is not easy to maintain
AsyncLocalStorage
objects globally, and across multiple async function families.Describe alternatives you've considered
Please describe alternative solutions or features you have considered.Alternative is to share these objects globally (which is not very attractive)
@lroal the goal here is more to clarify the use and the features of the new core API serving that goal I believe :)
Wouldn't it be convenient to use a static dictionary object instead of passing around the cls instance to all modules. It would just be helper methods. Something like this:
//to create a store on a key
let cls = AsyncLocalStorage.create('someKey');
//to get the current store on a key.
let cls = AsyncLocalStorage.get('someKey');
@lroal I think it mighe be room for an ecosystem module to do this. In general, I would want to ensure someone can prevent anyone else from accessing their instance within the process.
I understand your argument, but I still think it should be implemented directly in nodejs - not in an ecosystem module. If you have both alternatives you still have the option to go for the safer new AsyncLocalStorage. I think the pros outweigh the cons of such a helper function.
@lroal
I understand your argument, but I still think it should be implemented directly in nodejs - not in an ecosystem module.
Once such feature is implemented in the core, library authors won't have isolation enforcement for their ALS instances. And isolation is a really strong guarantee (I'd say, a must have) when you, for instance, implement an APM agent.
Although, it doesn't prevent user-land modules for implementing such feature, as well as a different overall API.
As for the performance, the main cost is related with the first active ALS instance. Subsequent instances (if there are not lots of them, of course) should be significantly "cheaper" than the first one. I have no benchmark results that would demonstrate this, but if you're really curious you could slightly modify async-resource-vs-destroy.js
and check it.
isolation guarantee - aren't the stores inherently isolated from out of sequence accesses? i.e, an ALS store is only visible to the asynchronous chain in which it is created?
even with the current isolation, cross-module access is possible? (a store created by an asynchronous sequence in module A, accessed by a consuming module B)
Is visibility / modularity / security an ALS theme at all?
isolation guarantee - aren't the stores inherently isolated from out of sequence accesses? i.e, an ALS store is only visible to the asynchronous chain in which it is created?
Yes, stores are isolated from outer async call chain accesses. But my point was about instance isolation. Let's consider a middleware library A that has an ALS instance and stores something in the context at the beginning of the request processing. If a library B has the access to library A instance, nothing prevents that library from calling .run*()
method on the same instance and overwriting the store. That means context loss for library A and it won't be able to work correctly anymore.
even with the current isolation, cross-module access is possible? (a store created by an asynchronous sequence in module A, accessed by a consuming module B)
Nothing prevents users from sharing ALS instance with other modules or any user code. But that has to be explicit, i.e. done by library authors/app developers themselves. ALS instances are self-contained and once you create one, it's up to you to decide whether you want to share it or not.
Is visibility / modularity / security an ALS theme at all?
I believe that visibility and modularity are necessary for ALS, but only in terms of instance isolation. Security is probably out of scope.
If you create a library, like a middleware, then new AsyncLocalStorage() is the safe way to go.
But majority of developers don't create libs, they create applications. For those the api would be clunky. And you could always use a guid as key to avoid name collisions.
And when thinking about it - there are lots other stuff in node (singletons, prototypes) than can be changed / are visible.
If you create a library, like a middleware, then new AsyncLocalStorage() is the safe way to go.
But majority of developers don't create libs, they create applications. For those the api would be clunky.
Why do you think it would be clunky? Sharing an ALS instance between modules is a matter of exporting the object in one module and importing it in others. Also, most applications shouldn't require more than one ALS instance and, probably, don't even need to share ALS across modules. In a simple request id logging case, the logging module can encapsulate ALS and only expose a middleware and logging functions.
If you can describe concrete examples when ALS seems clunky, it would be easier to suggest on approaches and best practices.
When wrapping the ALS instance in a module, you typically don't want to require it by relative path in multiple places down the hierarchy. So, you go on create a separate package.json for it and reference it as a file module. E.g. in "requestContext": "file:../requestContext" .
But npm install does not play nice with relative file modules. Especially when the requestContext module itself has dependencies. Also, I think this adds extra ceremony. If ALS is going to be a proper replacement for the deprecated Domains, I think it's api should more similar. And this is a good time to consider it as it is still experimental.
(Don't get me wrong. I am really excited about the new Async Local Storage. And I am looking forward to finally getting rid of Domain. )
To avoid name collisions you could always use a symbol as key. (But it would be great if it worked with string keys as well):
let key = Symbol();
let cls = AsyncLocalStorage.create(key);
let cls = AsyncLocalStorage.get(key);
@lroal this is a lot of great points!
I don't want to go too deep into API considerations at this time (my brain already shut down for the day ;) ) but I have a couple points here:
If ALS is going to be a proper replacement for the deprecated Domains, I think it's api should more similar. And this is a good time to consider it as it is still experimental.
Indeed, ASL should help us get rid of domain in 99% of the current usages of domain. When working on this feature, error management was one of my first-class design goals.
This is really the opportunity to build something that is here to stay and won't reproduce some of the issues of domain (see this doc, also (cc @bmeck as we discussed it not long ago :) )).
I mean, here I think we could go with something that "enables the users to get the same end feature as domain but in a different way".
If you have time and are a user of domain, it would be really interesting to get transition feedback. As you say, it is still time to change the API.
To avoid name collisions you could always use a symbol as key. (But it would be great if it worked with string keys as well):
You would need to share this symbol somehow right? Isn't that the same as placing the ASL instance in a package?
Yes, I have been using domain a lot - both in open source libs and in production code at work. I am interested in giving feedback.
About, the symbol, yes it suffers from the same problem as the ALS instance in a package. I just wanted to demonstrate that you could use the same syntax in a collision-safe way. (Makes it more consistent I think. And earlier in this thread, @puzpuzpuz talks about instance isolation).
Wouldn't it be convenient to use a static dictionary object instead of passing around the cls instance to all modules. It would just be helper methods.
@lroal I think something like https://www.npmjs.com/package/@northscaler/continuation-local-storage is what you're looking for. I'll see about enhancing it to also support AsyncLocalStorage
.
But I agree that it would be nice for class AsyncLocalStorage
to have static methods like create(key)
and get(key)
that create & get a named AsyncLocalStorage
instance, respectively. I'd say the best practice would be to pass a Symbol
for the key, but a string
would work, too, at the risk of multiple libraries using the same string key.
@matthewadams thanks. I wrote my own cls library some time ago. node-cls . Though, I was hoping AsyncLocalStorage would replace it entirely. But for me this is not the case yet.
(If you are creating a lib, you can just pass a symbol instead of a string. )
@lroal I like your "Await instead of run" feature -- that's a new one on me. I guess in the meantime, I guess we'll just have to Roll Our Own™️ until such time as AsyncLocalStorage
supports our features. 😊
@matthewadams @lroal
I am not sure I get this discussion here.
Getting the let cls = AsyncLocalStorage.create('someKey');
feature seems to me to be as straightforward as an ecosystem module used as a proxy for providing AsyncLocalStorage
instances. I still believe having this in core would break the point of allowing people to hide their own instances from the outside (and the Symbol
based solution does not really bring anything to the table IMO).
I like your "Await instead of run" feature -- that's a new one on me.
Is there a difference between this and enterWith
?
I guess we'll just have to Roll Our Own™️ until such time as AsyncLocalStorage supports our features. 😊
I would be happy to review any PR adding features on this API, also, my opinions can be wrong and an healthy discussion with more collaborators can probably bring ideas here.
@vdeturckheim
I like your "Await instead of run" feature -- that's a new one on me.
Is there a difference between this and enterWith?
It appears not. I wasn't aware of node-cls
and I hadn't noticed it on AsyncLocalStorage
until I started participating in this thread.
@vdeturckheim
I am not sure I get this discussion here.
The only thing to understand here, IMHO, is that two authors of similar packages (myself and @lroal) utilize a static map of context instances available from anywhere, as opposed to being required to have a reference to a particular instance of one, and that we were surprised not to see something similar in AsyncLocalStorage
.
The proposal, at its simplest, would be to add static methods to AsyncLocalStorage
that allow for easy creation & retrieval of instances using either namespaced string
keys, Symbol
keys, or both. I'd expect library authors that leverage ALS to use Symbol
s and application authors to use Symbol
s or string
s.
I'd be curious to your thoughts, @vdeturckheim, after reviewing node-cls and @northscaler/continuation-local-storage to get a feel of what we expected in AsyncLocalStorage
.
I'm totally open to an explanation of how our packages' behaviors are implementable using AsyncLocalStorage
natively, rather than leaving it to userland code maintain a static map of instances.
@matthewadams @vdeturckheim
There is a slight difference between enterWith and start. Start() returns a promise and sets the context on a child resource - not the current resource. EnterWith() will set the context on current resource / asyncId.
@matthewadams thanks for your response.
As mentionned earlier in the thread, I don't see any gain in such static map. Do you have examples of situations where such a map in core would solve specific problems?
@lroal
you were porbably meaning to ping me in your last message. That's an interesting point. I'd tend to think that doing await context.enterWith()
would do the same then. 🤔
@matthewadams @vdeturckheim
It would make developer life easier. You don't need to pass around/require the reference to the singleton instance everywhere. And if you have a deep file hierarcy, you need to require it by relative path. I suspect application developers will seek for a workaround anyway - either by using some user land module or implementing it themself. Perhaps as a local file package (which has it's problems in combo with npm). So the "workaround" might as well be in core rather than in user land. In my opinion.
@vdeturckheim , about the enterWith() vs start(). I am not sure myself. The case I had in mind was if current "thread" was busy doing something async and start() should not interfere with that context - but rather create it's own child "thread". But, maybe that's purely hypothetical. It is more like synctactical sugar perhaps. 😊
Do you think it is worth creating a PR ? Will it be considered ? It would need tests i guess. I have never create PRs in node before.
Thanks for taking your time.
It would make developer life easier. You don't need to pass around/require the reference to the singleton instance everywhere. And if you have a deep file hierarcy, you need to require it by relative path. I suspect application developers will seek for a workaround anyway - either by using some user land module or implementing it themself. Perhaps as a local file package (which has it's problems in combo with npm). So the "workaround" might as well be in core rather than in user land. In my opinion.
Once again, I think the ability to hide an instance from other and that is a must have feature for me (I don't want anyone external to tamper with my instance of AsyncLocalStorage). Be ready for UX discussions :) It will needs the opinion from other collaborators and my concerns can't be the only thing taken in account.
@lroal a PR would, for sure, be considered! There are also considerations about what the UX would look like (create
vs get
, what about multiple calls?). We are always very excited by any opportunity of welcoming a new contributor to the project!
Housekeeping part, I think this issue has derived a bit from its original point. I will close it, feel free to reopen if needed and let's follow-up either in a dedicated issue or PR.
Thanks @vdeturckheim .
I just want emphasize (as mentioned earlier in this thread), as a library developer you would use a Symbol as key. Then nobody can tamper with your instance of AsyncLocalStorage.
@vdeturckheim @lroal
I forgot to mention a use case that we leverage that uses a static map containing String
s as keys instead of Symbol
s. We sometimes use JavaScript decorators that dictate that certain contextual information (classically, user & role information) be placed into continuation local storage so that the decorator can access it later to execute logic (again, classically, access control decisions). In this case, String
s work great as keys to the static map of AsyncLocalStorage
instances because the decorator library provider _intends_ to use the shared context information to do its job.
FYI, we just released an update to @northscaler/continuation-local-storage
that includes support for AsyncLocalStorage
in addition to cls-hooked
& zone.js
! 😊
Most helpful comment
@lroal this is a lot of great points!
I don't want to go too deep into API considerations at this time (my brain already shut down for the day ;) ) but I have a couple points here:
Indeed, ASL should help us get rid of domain in 99% of the current usages of domain. When working on this feature, error management was one of my first-class design goals.
This is really the opportunity to build something that is here to stay and won't reproduce some of the issues of domain (see this doc, also (cc @bmeck as we discussed it not long ago :) )).
I mean, here I think we could go with something that "enables the users to get the same end feature as domain but in a different way".
If you have time and are a user of domain, it would be really interesting to get transition feedback. As you say, it is still time to change the API.
You would need to share this symbol somehow right? Isn't that the same as placing the ASL instance in a package?