-
Notifications
You must be signed in to change notification settings - Fork 30.1k
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
AsyncLocalStorage and deferred promises #46262
Comments
It seems easier for me to wrap my head around the context being the one at promise creation: this is the originative event that creates the async task 🤔 |
My intuition was |
I would argue that for promises it is more correct to capture the context at the moment class Foo {
constructor() {
this.deferred = createDeferredPromise();
}
void resolve() { this.deferred.resolve(); }
void reject() { this.deferred.resolve(); }
}
const foo = new Foo();
als.run(123, () => foo.resolve()); If I specifically wanted to capture the context when the |
It's really hard to think about this stuff abstractly. How about in terms of use cases? Has anyone come across a case where they need to look at AsyncLocalStorage from inside the (Bloomberg definitely has an analogous internal case where we currently depend on restoring the AsyncLocalStorage from kInit in |
Main use case for ALS till now was context propagation. e.g. consider following function myWebRequestHandler(req, res) {
const span = startSpan("myWebRequest");
als.run(span, () => {
await callSomeDb(query);
await callSomeDb(other query);
const activeSpan = als.getStore(); // getting anything else then active span here woudl be strange.
});
} The resolve of above promise happens in some unrelated code, maybe even some native callback. I think the correct place to capture the context would be when
|
good point, in my cases, I want to associate an unhandled rejection with the http request the server was working on. Or at least whatever network transaction the rejection is part of. |
@jasnell I see your point and now I am not certain of anything anymore 😅 |
Note that this isn't quite correct. Yes, the context is captured when let resolve;
const deferred = new Promise((r) => {
resolve = r;
});
const p = als.run(123, async () => {
await deferred;
console.log("123", als.getStore());
});
als.run(321, async () => {
await p;
console.log("321", als.getStore());
});
resolve("abc"); If there is no |
I think we all agree that, in the case where a |
In general it makes sense to propagate a context from promise subscription being added (then/await) to execution of reactions. In this case we don't have either. However I would argue than more often than not, the context that subscribes to the promise is the same as the context that creates the promise, but I'm not sure it justifies capturing and using that context. Propagation of context is also done with the explicit intent to disconnect the resolving context from the reaction execution context. I really don't see why the rejecting context should be the one used in the unhandled event. My question is, why is the context in which the event handler was added not the one used? What should the following example produce? const { AsyncLocalStorage } = require('async_hooks');
const als = new AsyncLocalStorage();
als.run(0, () => {
process.on('unhandledRejection', () => {
console.log(als.getStore());
});
});
const panic = als.run(123, () =>Promise.reject(Error('panic')));
panic.catch(() => {});
als.run(321, () => Promise.resolve(panic)); |
The more I go through this, the more I'm convinced that |
That is an option, but it means you must also remember to unregister your handler or you're going to get multiple logs: function handleRequest(req, res) {
als.run({}, () => {
const unhandled = () => {
console.log(als.getStore());
};
process.on('unhandledRejection', unhandled);
try {
// work work work…
} finally {
process.off('unhandledRejection', unhandled);
}
});
} But your example actually won't log anything (the promise is handled, and |
Oops, ugh promise adoption is tricky. Ok so this seem to be really limited to the deferred use case and unhandled rejections?
I don't think that'd work if the work is async since there is no way to know when that async flow is done, right? |
Yes, very much so. |
Yes, it's just this one case that is weird.
Oh yah, that's even worse. You never know when a unhandled promise would be created, and you can't do any to figure out which unhandled handler should be called. |
If I summarize above the proposed solution would be like this:
Are we sure that this is also possible in all sorts of promise chaining? |
Can you reproduce the behavior you don't like without using the promise constructor? |
The case is pretty specific to the deferred promise pattern using the promise constructor, calling |
For me, unhandled promise rejections are coding bugs. Someone somewhere forgot to add a rejection handler. The most useful information when trying to debug an unhandled promise rejection is where the promise passed through before being unhandled. This is why async stack traces you get with async/await are great. The place the rejection originated really is inconsequential. What do people actually do in their const http = require('http');
const db = require('my-db-library');
const { AsyncLocalStorage } = require('async_hooks');
const als = new AsyncLocalStorage();
process.on('unhandledRejection', () => {
const store = als.getStore();
store.res.writeHead(500);
store.res.end();
});
const dbConnectionP = db.connect();
// returns a promise, rejected if the connection can't be established
const server = http.createServer((req, res) => {
als.run({ res }, async () => {
const dbConnection = await dbConnectionP;
const response = dbConnection.process(req);
res.writeHead(200);
res.end(response);
});
});
server.listen(80); |
@dharesign that case works as is in either case because the rejection is still happening within the scope the context created by the one Even if we did switch the model to yield |
It's not is it? The rejection is from within the Can you give an example for
If I do Where would a rejection then originate? |
It will run correctly. Any rejection which is scoped inside any nested call from a const als = new AsyncLocalStorage();
process.on('unhandledRejection', () => {
assert.equal(als.getStore(), undefined);
});
const deferred = als.run(123, () => {
const deferred = {};
deferred.promise = new Promise((_, rej) => {
deferred.reject = rej;
});
return deferred;
});
// The reject escaped the run
deferred.reject();
// 1. deferred.promise has not been handled
// 2. reject escaped ctx
// 3. unhandledRejection will see undefined. Importantly, if you were to chain of that deferred inside a ALS context and have those promises be unhandled, those promises will have the correct context: const als = new AsyncLocalStorage();
process.on('unhandledRejection', () => {
assert.equal(als.getStore(), 123);
});
const deferred = als.run(123, () => {
const deferred = {};
deferred.promise = new Promise((_, rej) => {
deferred.reject = rej;
});
// Create the unhandled promise here.
// The .then() guarantees that the unhandled's internal reject will
// inherit the 123 context.
const unhandled = deferred.promise.then();
return deferred;
});
// The reject escaped the run
deferred.reject();
// 1. deferred.promise was been handled
// 2. unhandled called `deferred.promsie.then()`, capturing 123 context
// 3. reject escaped ctx
// 4. unhandled becomes rejected using its restored 123 context
// 5. unhandledRejection will see 123. |
@jridgewell Your first sample actually gets With the proposed change it would receive |
Off-topic, but this is really only a bug if the promise doesn't get handled before it gets dropped on the floor. IMO Node is way too aggressive in reporting these, and should only report and fail for unhandled rejected promises when the promise is collected or at program exit. |
Correct, I was trying to explain the difference if we accepted the proposal. Case 1 is the only difference, and Case 2 will continue to work the same as it does now. The only way to trigger the difference is to:
If the |
The |
I see what you're saying. In the case you have an escaped const als = new AsyncLocalStorage();
const makeDeferred = () => {
const deferred = {};
deferred.promise = new Promise((_, rej) => {
deferred.reject = rej;
});
return deferred;
};
process.on('unhandledRejection', () => {
console.log(als.getStore());
});
const deferred1 = als.run(123, makeDeferred);
const deferred2 = als.run(123, makeDeferred);
const deferred3 = als.run(123, makeDeferred);
const follow2 = als.run(234, () => deferred2.then(() => {}));
const follow3 = als.run(234, () => deferred3.then(() => {}));
als.run(345, () => follow3.then(() => {}));
als.run(456, () => {
deferred3.reject(); // prints 345
deferred2.reject(); // prints 234
deferred1.reject(); // prints ???
}); I think |
I don't see the point why resolution/reject location is the "more correct" propagation source. consider a simple web server using a database, reusing the connection like this: const http = require("http");
const db = require("my-fancy-db");
const dbCon = db.connect(dbSettings); // returns a promise
http.createServer((req, res) => {
als.run(myRequestContext, () => {
db.then(() => {
// I would expect myRequestContext here not undefined inherited from db.connect
}).catch((e) => {
// I would expect myRequestContext here not undefined inherited from db.connect
});
});
}); In case of If you adapt above sample to create the db connection lazy on first request the context of first request would be kept forever. |
Both of those would be |
The current behavior is correct. Let's imagine a case where we store the request identifier in AsyncLocalStorage. |
This is what makes this a tricky case. Presumably the user needs to have both. In some deferred promise flows, it's going to be more important to propagate the context associated with the task resolution. In other cases, it's going to be more important to propagate the context associated with the task creation. In the overwhelming majority of cases you're going to get exactly what you want here. In the deferred promise resolution case, however, if what you really want is to propagate the task creation context, we always have the let resolve, reject;
const p = new Promise((a,b) => {
resolve = AsyncResource.bind(a);
reject = AsyncResource.bind(b);
}); With the current behavior, I do not have the flexibility to capture the context at task resolution, which is a problem. I think we should support both and the current model forces us into only one. |
I mostly agree, but promises are not easy to modify so we'd want to do some validation that all the cases are reasonable. Perhaps we should write a doc enumerating possibly problematic promise patterns and identifying how they would behave in that context. For example: const storedPromise = asyncThing()
async function something() {
await storedPromise
// What will the store contain here?
}
storage.run('foo', something) |
Good idea. |
The bind option effects all I think a better solution is to add a configuration option to This strongly remembers me to discussions if trigger_id should be used to propagate context of current async_id. |
I'd hesititate to add new API surface area to cover a single edge case, especially since that would likely need to carry over into the To illustrate the issue, if we have two separate ALS instances, const als1 = new AsyncLocalStorage();
const als2 = new AsyncLocalStorage();
function deferredPromiseWithBind() {
let resolve, reject;
const p = new Promise((a,b) => {
resolve = AsyncResource.bind(a);
reject = AsyncResource.bind(b);
});
}
const { promise, reject } = als1.run(123, () => {
return als2.run(321, () => deferredPromiseWithBind());
});
addEventListener('unhandledrejection', () = {
console.log(als1.getStore()); // prints 123
console.log(als2.getStore()); // prints 321
});
als1.run('abc', () => reject()); Personally I consider this an acceptable limitation in that it's easy to reason about. I don't want to have to think about different |
If both variants are needed within a single application then we need either an option to I agree that in above code this looks strange but in a real world app the two store instance would never met. One is internal to module foo and the other to module bar and they use it for independent concerns. |
Keep in mind that the 'unhandledrejection' event listener itself can be bound to a specific context if you really want that. |
The path wanted will vary by use case, but also could vary by the code. For example one may want promises from the resolve path but event emitters from the place handlers are added. Thinking about it, what could be reasonable is to have configuration per-source. Something like: const store = new AsyncLocalStorage({
bindPromiseOnInit: true,
bindEmitterOnInit: true
}) The nice thing with following the resolve/reject path is that because it can be manually patched back to the other path via a |
It's of no help to overwrite the context. All participants here want async context propagation. Some want to find the resolver/rejected others the creator. And both should fit into a single application. |
I would suggest we consider what the future will look like when we have In a future where we have a simple If Node requires the current |
Well, that's where the bind/wrap function comes in. Having a friendly configuration pattern allows simplifying use of these different patterns in the future if we so choose, but they're really just simplifications of the manual bind so we can just continue using those too if pushback would be too much. I'm also not advocating we include that in the initial proposal. What I'm saying is that if we go the resolve/reject path we can use bind to map that back to what Node.js might expect, but we can't do the reverse so we should take that into consideration. |
This is going to hit the same performance constraints I commented in #46374 (comment). In short, I want us to design an API that does not impact the performance of running code. Adding this configuration requires that we change the wrap algorithm from O(1) to O(n) (n for all AsyncContexts currently allocated). Adding even the potential for configuration will really hurt all code that uses promsies, every time a continuation is created.
Agreed. |
In my humble opinion, the premise of this discussion is invalid. Neither As I suggested in nodejs/diagnostics#389, |
@AndreasMadsen ... first off, great to "see" you! It's been a while since we've talked!
I think we're all generally in agreement on this particular point! However: with an unhandled rejection there explicitly is no then we can use to propagate the context. This case is really about that particular issue. What I'm suggesting in this conversation is not that |
That's not exactly true. If we keep the configuration variety low you can bucket same configurations together and just pick which context bucket to copy at each viable connection point. You see a promise construction and you copy the promise construction flowing bucket. You see a promise resolve and you copy the promise resolve flowing bucket. The configuration would not change for the lifetime of that storage so it's easily optimizable into behaviour buckets. |
I think this is the clearest way to frame the problem as it's exactly what is happening. The spec has a host hook to inform the host when a promise becomes rejected and is unhandled, and when it becomes handled afterwards. These hooks are "called" synchronously but obviously the host doesn't trigger a program visible event from them immediately as that would be very noisy (and re-entrant), and instead waits for either the end of the current promise job, or the draining of the promise job queue depending on the host implementation (I'd still argue the host should wait for the finalization of the promise and simply trigger an uncaught error event, but that's another story). In any case, the host schedules a new job that will execute later when the engine synchronously informs it of an unhandled rejection. Capturing the current async context at the time of rejection is the natural and logical behavior. |
+1. This seems significantly better and would solve quite a lot of the problems. |
We're in agreement here. Just different ways of saying the same thing. Capturing the context at For instance, consider the following cases: // This example creates two separate promises.
const als = new AsyncLocalStorage();
const p = als.run(123, () => new Promise((res) => {
// This runs in a synchronous scope. The promise does not need
// to capture the async scope here.
// This schedules async activity, the setTimeout captures the async
// scope...
setTimeout(() => {
console.log(als.getStore());
res();
}, 1000);
// Using the init promise hook to capture the async context here,
// especially the way we do it currently in Node.js where context
// is propagated for every new promise, is unnecessary because
// it will *never* be used
}));
// The then here captures the async context on the continuation.
// The promise *itself* does not *need* the async context attached
// because it is only relevant to the continuation.
als.run(321, () => p.then(() => {
console.log(als.getStore());
})); Or this, which might be clearer: // This example creates two separate promises.
const als = new AsyncLocalStorage();
// Capturing the context at this New Promise is obviously pointless.
// It won't ever be used and is a very wasteful operation the way we have
// things currently implemented. There's just simply no reason to
// capture the context for new Promise.
const p = als.run(123, () => Promise.resolve());
als.run(321, () => p.then(() => {
console.log(als.getStore());
})); |
Take the following case:
What value should the
console.log(als.getStore())
print to the console?Currently, it prints
123
because the async context is captured as associated with the Promise at the moment it is created (in the kInit event).I'd argue, however, that it should print
321
-- or, more concretely, that it should capture the async context at the moment the promise is resolved, not at the moment the promise is created, but the current behavior could also be correct./cc @nodejs/async_hooks @bmeck @bengl @mcollina
The text was updated successfully, but these errors were encountered: