-
Notifications
You must be signed in to change notification settings - Fork 14
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
Add new AsyncContext.callingContext()
API
#77
base: master
Are you sure you want to change the base?
Conversation
Microsoft attempted to describe a similar formalization of context in this two paths scenario many years ago which sadly didn't go very far unfortunately, but I do think it's a good idea. Personally I'm wondering if we actually need to restore the context for a window or just be able to read the context. We could alternatively just have a different getter rather than needing the extra closure: const asyncVar = new AsyncContext.Variable();
const obj = new EventEmitter();
asyncVar.run("registration", () => {
obj.on("foo", () => {
// EventEmitter restored the registration time context before
// invoking our callback. If the callback wanted to get the context
// active during the `emit()` call, it would have to receive a
// Snapshot instance passed by the caller.
console.log(asyncVar.get()); // => 'registration'
// But with `store.getCallingContext()`, we're able to restore
// the caller's context without changing EventEmitter's API.
// EventEmitter can continue to assume that registration is default
// that is most useful to developers.
console.log(asyncVar.getCallingContext()); // => 'call'
});
});
asyncVar.run("call", () => {
obj.emit("foo");
}); I'm wondering if this satisfies the needs or if there's a specific scenario for actually restoring the context that you have in mind? 🤔 |
If there are more tools (e.g. OTel tracing, some security monitoring and some context enabling logger,...) around in an application it might happen that there are nested |
I'm very skeptical of this proposal as a general solution.
The big example here was events, where there are two relevant contexts: registration time (the only option when the system is the one dispatching it) or calling time (when explicitly dispatching events). These are well-defined. One idea for handling events sent from JS: making the ambient snapshot be registration time, but passing the call-time snapshot (or undefined when it's missing) as a property of the event. |
See below. If you only expose it at
I think the idea is that if you need it, you use it immediately inside the function body, rather than expecting some more deeply-transitive function to check it. The point is that it provides plumbing that can "undo" the (usually correct) registration-context default in cases where that's necessary. Just like function wrapWithCallingContext(fn) {
return function() {
return AsyncContext.callingContext(() => fn.apply(this, arguments));
}
} Now if you've got a scheduler that you know is going to call your callback in the registration context but it needed to access the calling context, you can instead I also don't think calls to
I think it's pretty straightforward: when the current context ends, the engine knows what context it's going to restore. That's the context that Snapshot.prototype.run = function(fn, ...args) {
const savedCurrentContext = $currentContext;
const savedCallingContext = $callingContext; // (added)
$callingContext = $currentContext; // (added)
$currentContext = this.context;
try {
return fn.apply(null, args);
} finally {
$currentContext = savedCurrentContext;
$callingContext = savedCallingContext; // (added)
}
} I don't know whether a similar change should be made to
You're right that this doesn't save work - good defaults are definitely still important. I don't think it will produce significant extra work. The only cases where we might possibly choose anything other than the obvious "immediately surrounding caller" would be cases where there's already something interesting enough going on that we'd already not be using the registration context - so we're already thinking about the relevant non-registration context. In those cases, the calling context should probably just be null, and the interesting third context should be the default.
Allow me to turn this question around somewhat. As the proposal stands today, this is already an issue: if we're talking about making a decision for every callback-accepting API in the web platform, then we will likely end up with some functions going with registration context and some going with calling (or some other) context. Even if every builtin API uses registration context, there will inevitably be userland scheduling libraries that end up defaulting into using the calling context (or something even less relevant) because that's what you get without updating the library to account for
I disagree with this approach: it's a much more invasive solution that requires updating one or more disparate APIs and doesn't solve the general case, whereas keeping the solution local to |
Is this another case where there's more than two possibilities? What about the following? const p1 = v.run(1, () => Promise.resolve());
const p2 = v.run(2, () => p1.then(() => {
// main context: 2; calling context: 1?
return v.run(3, () => Promise.resolve());
}));
const p3 = p2.then(() => {
// main context: null; calling context: 2 or 3?
}); May be worth following up in a separate issue so as not to derail this discussion. |
EventEmitters are often used deep inside libraries. There are a lot case where registration/calling happens quite distant from code in your control. There can be several layers/wrappers between the emitter and the handler.
I don't understand this. A snapshot restore is done via I don't think there should be a difference if I call |
Basically. There are 3 possibilities:
I think 2? But I haven't put much thought into it besides "it's possible".
I think this doesn't apply? The cases that I imagine is first party code, where the emitter and the handler are controlled by the same dev. It could even be inside library, where the library is in control of the emitter and handler. The default should be registration time (and that's directly under the control of the dev when using 3p code). If they're using 3p code, then the calling context may or may not be relevant depending on if that code has snapshotted something unexpected. But this isn't any different than the code today where you only have access to the registration–time context.
This API is specifically to help with registration-time or call-time problem. So it's directly tied to |
I could see going either way on this: treat them the same for consistency, or don't do anything for |
ok. for this very specific scenario it might be fine. But docs should clearly state that this API is only useful is such special cases. I'm also not sure if registration time is the correct time to capture context for EventEmitters in general. |
Making this statically-scoped further suffers from the grafting problem. If we follow the longest path through the internal machinery of everything, which loses relevance in cases like connection pools, we can graft around those internals to produce a more optimal graph for the use case of following user intent. However by applying that graft globally we risk breaking store users which don't want that grafting decision and we lose the possibility to restore that branch of the tree. With I think this is another case where we need to think more at instance scope rather than static scope. As with my prior suggestion of having an instance-scoped By always following the internal path and providing tools to reduce the graph where needed we provide a graph which can always be reduced to the more optimal graph for a given case, but we can't do the reverse if a path taken has already orphaned a branch. However the The following graph shows an example of a scenario where both interpretations of the graph may be valid depending on the desire of the store owner. This graph corresponds to a call to The solid line represents the user-facing execution path which may be the path a user desires to take for context management of application data. However there's a bunch of inner calls which actually led to the callback being called and each of those calls could be setting their own context data. A tracing product may want to produce spans for that inner behaviour which flow back to the user code, but that link would be broken if context is forced to flow only via the solid line. If viewing this problem with static scope we make a choice for all stores to follow--either the solid path or the dotted path. If we approach the problem with instance-scoped tools, we can follow either path per-store and the other user can either instance-scoped I think perhaps I would change it slightly though to just return the snapshot it was in prior to the current snapshot restoring. |
If we want to focus on "instance-scoped" snapshots (#25), then we have an |
Thinking through this a bit... there are two contexts to be concerned about here. For
Both are important in different scenarios, and both may be important at the same time depending on use case. So if I am understanding the issue in this PR correctly, the question is how we should propagate all of them? For this, based on experience with als.run(1, () => et.addEventListener('foo', () => {
console.log(als.get()); // 2
});
als.run(2, () => et.dispatchEvent(new Event('foo'))); And if the code that is registering the event listener explicitly opts in to wanting the als.run(1, () => et.addEventListener('foo', AsyncContext.Snapshot.wrap(() => {
console.log(als.get()); // 1
}));
als.run(2, () => et.dispatchEvent(new Event('foo'))); This makes using both options possible. If the But what if we want both? That's certainly a tad trickier but not impossible using als.run(1, () => {
const snapshot = new AsyncContext.Snapshot();
et.addEventListener('foo', () => {
const callContextValue = als.get(); // 2
snaphot.run(() => {
const registrationContextValue = als.get(); // 1
});
});
});
als.run(2, () => et.dispatchEvent(new Event('foo'))); For an event emitter and For sync and async generators (e.g. For a sync generator, the body of the generator itself should capture the async context that exists when the generator is created and should have no visibility into the async context of whatever code is calling function* foo() {
yield acval.get();
}
const gen = acval.run(123, () => foo());
acval.run(321, () => {
console.log(gen.next().value); // 123
}); |
It likely just needs to be recognized that in some cases, events are going to be triggered from the null-context ... that is, where no context is in scope when the event is actually dispatched. In such cases, either there is no value or the object that is emitting the events (in the case of #22 the Looking at the example from #22, function makeRequestThen(callback) {
const req = new XMLHttpRequest();
req.addEventListener('load', AsyncContext.Snapshot.wrap(() => callback(null, req.responseText)));
req.addEventListener('error', AsyncContext.Snapshot.wrap((err) => callback(err)));
req.open('GET', url);
req.send();
} |
From a quick skim, I think adding two contexts will significantly increase the overhead of AsyncContext because it would potentially keep them alive longer, increasing memory consumption and GC work. The pattern in #77 (comment) makes sense to me. |
Can you elaborate on your reasoning here? As mentioned in #75 (comment), there are also cases where a generator wants to run each continuation in the |
In my head, OpenTelemetry would instrument the const requestContext = new AsyncContext.Variable();
http.createServer = (handler) => {
const server = new Server(); // or whatever;
server.handlerRequest = () => {
const { req, res } = internalStuffToCreateReqRes();
requestContext.run({ req, res }, handler);
};
}; It doesn't matter that Does this match what you're thinking?
I think this case is neatly handled by defaulting to registration-time, and using function handleRequest() {
const span = openSpan();
fs.readFile('foo.txt', (err, data) => {
closeSpan();
});
}
But from the dev's perspective, I think registration-time is still correct. I don't care how OTel adds data, just that any context data that I put there is available in my callback once the file is read. Yes, they could wrap, but it seems better ergonomics to do it by default.
I think that if we make the null-context something that developers routinely encounter, then we've really failed on the ergonomics. What I imagine is that nearly everything is going to be calling And this will lead to friction. We reintroduced
Can you explain where you see them being kept alive longer? |
I'm not sure how we can reasonably avoid the null-context entirely. But the point is well taken, Let's see if we can tease things out a bit more. Let's imagine a case where we actually have multiple const requestId = new AsyncContext.Variable();
const processId = new AsyncContext.Variable();
function log(msg) {
console.log(`${processId.get()}::${requestId.get()} - ${msg}`);
}
processId.run(123, () => {
addEventListener('error', (event) => {
log(`There was an error: ${event.error.message}`);
return true;
});
});
requestId.run('abc', () => {
reportError(new Error('boom'));
}); What should
Perhaps we're thinking about it the wrong way. Consider my example where we access both the calling context and registration context: als.run(1, () => {
const snapshot = new AsyncContext.Snapshot();
et.addEventListener('foo', () => {
const callContextValue = als.get(); // 2
snaphot.run(() => {
const registrationContextValue = als.get(); // 1
});
});
});
als.run(2, () => et.dispatchEvent(new Event('foo'))); What is clear within the event handler callback is that there is an initial context (in this case the calling context), and we enter a new context, effectively forming a stack. What if we modeled it as such? als.run(1, () => {
const snapshot = new AsyncContext.Snapshot();
// Let's define this such that the snapshot is automatically taken when addEventListener
// is called. Then, when we emit, we enter the registration context. The calling context will
// naturally be the parent on the stack...
et.addEventListener('foo', () => {
console.log(als.get()); // 1 ... registration context
context.log(als.parent.get()); // 2 ... calling context
});
});
als.run(2, () => et.dispatchEvent(new Event('foo'))); |
It's worth considering that if we always propagate by calling context by default and then bind around registration points (including within the language itself) then the calling context will always be synchronously available at the point when the bind would change the context and we therefore only need to capture the value when the bind occurs and store it only for the synchronous window of the scope function. In any nested async code that would no longer be the calling context, something else will become the calling context. I don't think it's nearly as expensive to hold as people are thinking. |
IIUC, async function* gen() {
console.log(AsyncContext.callingContext(() => v.get()));
yield;
console.log(AsyncContext.callingContext(() => v.get()));
await 0;
console.log(AsyncContext.callingContext(() => v.get()));
}
const it = v.run(1, () => gen());
await v.run(2, () => it.next()); // logs 2
await v.run(3, () => it.next()); // logs 3, null The fact that |
The life cylce of a HTTP operation and all the inner operations (like DB requests triggered) is a bit more complex. My main point is that
As a result I question why they should capture context on default just to force users to restore the original context to workaround the default. Node for example offers a non capturing |
@jridgewell consider the example of #77 (comment). Developers unfortunately like to "fork" the promise/context chain further, so I expect the calling context to always leak over its original lifetime |
The context would already flow to that point anyway though, unless you're proposing cutting off a branch at the start of a bind point, which would mean internals would all be context-less and unable to connect to anything useful. Referencing my graph in #77 (comment) that would mean breaking the link between From both performance and flexibility perspective the best possible choice is to always follow call context path and then allow snapshot restores to bind around internal paths where desired but with an escape hatch for individual stores to restore the calling context path for their case. This also means the calling context only needs to be stored for the cases where a bind graph reduction is applied as every other case the calling context and the stored context would be the same. Internals can do snapshot restores by default around cases where it makes sense, but any internal paths, where observable, would continue to follow the calling context and so would not have an unexpected null context. It would only have an immediate switch at the point of the snapshot restore, which can be used to capture the current context as the calling context for the sync window of that snapshot restore. |
If we're imagining that (This might be a case where
Both of these code samples work fine, because the first is using But my scenario from #77 (comment) was that devs will reach for
Oh, I missed that. That is concerning, I would expect the final log to be
Is it known when the event emitter is constructed whether it will always be registration or call time?
Because the registration-time one has the dev's data from when the called
My example was a bad because I didn't nest the callbacks. It seems everyone is arguing for call-context by default. We'll need to discuss this in the next meeting. The issue is that we have #22 arguing that switching from a |
I'm just now realizing that #22 is handled if |
I think the conversation around call-time vs registration time is a bit fuzzy and could use some clarification. Call-based context is technically what you have by default if you don't do anything to manage propagation. Data "flows" in that way because the application execution itself flows in that way and the data doesn't change. Registration time is simply the act of capturing context at some point and swapping out the state that already flowed along with the calling path to begin with and replacing that with the value capture earlier. Capturing context and restoring it around registration time flows makes perfect sense in certain cases. What I'm saying is that if access to calling context is done right the argument of which one is "correct" no longer matters because you can always just follow calling context out of a bound context or manually bind a context as-needed. Accessing calling context is essentially a way to undo the context you just restored and return to the one you would have been in if not for the bind, which means both paths are accessible and the "correct" path becomes a purely user experience default, not a critical decider of what is possible to do with the graph. |
I saw your image earlier but didn't exactly follow it (I don't have a strong Node.js background). What stood out to me from your earlier post was
I understood "instance-scoped That said, I think the question of how much a userland scheduler should wrap does come down to details of how/why it's doing the scheduling. Imagine a simple thread pool that takes async functions and limits the number of pending promises - this one should absolutely go with registration-time context for all variables, since call-time will be essentially meaningless. On the other hand, imagine a data production pipeline where one registers a function for each step in the pipeline (e.g. computed signals). In that case, tracing or cancellation would both benefit from propagating the call-time context. There's clearly no one-size-fits-all answer here, and even at the I'm intrigued by the idea that both paths in the graph might be available, but I'm skeptical whether that's at all feasible in terms of implementation. Can it be done without significantly increasing the runtime cost (in particular, on folks who don't actually use the thing) or sacrificing the general usability (as demonstrated above)? |
Having instance-scoped bind doesn't mean we remove static bind. We should have both and generally path decisions will be made statically. The instance-scoped version would exist as an escape hatch for the handful of scenarios where the correct path is use-case dependent and a particular store user disagrees. They can, with some additional effort, follow a different path where preferred. An instance-scoped bind lets them route around internal paths they don't want to follow, like the implementation details of a connection pool. An instance-scoped calling context provides the ability to go the other direction where a bind has been applied statically and a particular store disagrees with that default selection they can return to the internal path which was orphaned when the snapshot was restored. As for feasibility and runtime cost, it absolutely can be done as it has been done before with AsyncLocalStorage. I have put significant effort into ensuring it is capable of these things as the primary use case it targets is observability providers which need this capability. To reach calling context all you need to do is have a snapshot of the state before a bound snapshot is restored so you can return to that snapshot, effectively undoing the bind for a limited scope. |
Can you provide some example code showing what this (would/could/does) look like? In my imagination you're stuck digging around a complex graph, which seems infeasible, so I think I'm missing your point. To put it concretely, suppose I have the following scheduler outside of my control: class Scheduler {
callbacks = [];
register(callback) {
this.callbacks.push(AsyncContext.Snapshot.wrap(callback));
}
call() {
this.callbacks.forEach(fn => fn());
}
} Now I want to call register in such a way as to get the calling context inside the registered function: scheduler.register(() => console.log(v.get())); How do I change that |
No, not multiple graph edges. We just need one level up to restore the orphaned branch because, if it's following call context by default it will have already propagated through that branch on its own anyway. In the graph I posted earlier the fs.readFile would propagated through the dotted line branch all the way up until the callback to If there are multiple levels of binds then it would just be up to the user to handle each to follow the path it wants to follow. There's no reasonable way to describe the full path a context propagation should take, it needs to be handled on a case-by-case basis. This is why we define a best-effort default path which propagates automatically and then provide tools to bind or restore away from that path. In that scheduler case you use function wrap(fn) {
const snapshot = new AsyncContext.Snapshot()
return (...args) => snapshot.run(fn, ...args)
} This does nothing to change the propagation behaviour of the async tasks scheduled within the call receiving this wrapped function. All it does is replace what has already been propagated when that I think people are expecting this to be a lot more complicated and expensive than it actually needs to be. |
I'd prefer that we adopt the direction described in #100 where we instead provide explicit APIs to access certain snapshots, rather than try to define a general one as in this PR. |
That doesn't help APMs though as we would then still need to patch everything to get at those context objects and flow them ourselves when they're passed around on event objects. We need an interface decoupled from the execution itself to be able to know what contexts are current. For example, say we instrument something that somewhere internally deals with events, and we instrument something that would trigger in an event handler, but we don't have any specific awareness of the event emitter itself we would have no way to establish the link between those two points unless the event object was passed directly through to that inner API, which is generally not the case. This sort of scenario is the whole point of context in the first place--to be able to pass data through the execution graph without needing explicit access to anything in particular between the two points. |
This is a small helper function that allows you to temporarily restore the previous ambient context state (like a
snapshot.run(fn)
call). This allows us continue with registration-time being the recommended behavior for every API, but allows call-time usecases in the cases that it's necessary.In yesterday's meeting, we identified a few general cases:
setTimeout()
)EventEmitter
/EventTarget
) or asyncobj.emit('foo')
orel.dispatchEvent(click)
), either registration-time or call-time could be needed.unhandledrejection
unhandledrejection
behavior #16This API allows us to handle all use cases, without requiring extensive API changes (eg, accepting an options bag that allows the dev to choose at registration time).
This also allows us to settle #18. Async/Generators will capture their init-time context and restore it during every call. But, the generator body itself can restore the call-time context easily:
One thing we haven't discussed is what to do with Promises. @mhofman had requested being able to receive both the
resolve()
call-time context and thep.then(fn)
registration time context when invokingfn
. With a bit of extra spec work, we could expose that with this same API. I just haven't done the work to propagate the call-time through the Promise reaction jobs yet.