-
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
Context termination #52
Comments
Since a task context can be captured by a wrapped function, it sounds like the termination of that task is intrinsically tied to the garbage collection of all functions that are tied to the context. We cannot introduce a mechanism that would allow to more precisely observe the garbage collection behavior than what is already allowed through Given the above, would it be acceptable to simply require that users interested in task termination use an object as the |
That was my initial suggestion, but apparently Chrome needs a much tighter bound on the time after the task terminates, so that timestamps can be recorded. What's been described isn't actually tied to the lifetime of a userland value, but to the global context mapping itself. The value placed into inside a run may or may not have been GC'd yet. What Chrome needs is a signal that the global context mapping (which is not userland observable) which held the value is no longer active and is not held by any snapshots. I imagine if user code does takes a manual snapshot, then task termination is controlled by GC of that snapshot (and would use the much slower CC @yoavweiss, what would be the behavior if user code manually snapshotted the global context? Would it be ok for that task to never terminate if the snapshot is never freed? |
I think it's acceptable for an internal host implementation to use a faster path, but what ends up exposed to the JS program must not be observably more accurate. If the task termination is recording timings accurately, the host should probably not reveal that timing information to the program until the program itself can observe the task termination (aka the host should not create an alternative path of observing the finalization of JS objects). |
taking a snapshot of a context is not bound to a specific operation/task. It's not the task which is captured its a context. in node.js the best fit API to monitor individual tasks would be the init and destroy hook of async_hooks. These details are intentionally not exposed by AsyncLocalStore. To my understanding the proposal here is more to provide the functionality similar to AsyncLocalStore and not the low level async hooks. |
I disagree. Capturing a snapshot of the current context should extend the current task, because I can reenter that task at a later time. While doing the endojs security review, we actually discovered that snapshotting is effectively the same as capturing a pending promise, it just exposes capability of creating a new subtask in a sync way.
Yah, this is effectively the |
But one can also "capture" the active context by calling The |
You can A snapshot captures the full context so it can be restored later, which I think should imply it's an extension of the current task. Eg, if I needed to |
What is the "overall context mapping"? Maybe I have the wrong understanding of the terms used.
What is the observable difference between capturing Or is some examples to illustrate the difference: opentelemetry node.js context manager (uses AsyncLocalStore internally) offers On the other hand the node.js built in So what is "the task" here? |
The mapping of all AsyncContext values (the same as the const a = new AsyncContext();
const b = new AsyncContext();
// context: {}
a.run(1, () => {
// ctx: { a: 1 }
b.run(2, () => {
//ctx: { a: 1, b: 2 }
});
// ctx: { a: 1 }
});
// ctx: {}
The first continues the same context (the mapping is referentially equal, not just deeply equal), where as the second creates a new sub-context under whatever is active at the time of the run.
I think this is the confusion. It's The only way to get a single value is via
In words, it's an execution started by any In psuedocode, a task is a instance of the |
I realize now that the old code doesn't model parent-child relationships explicitly, so a parent task will terminate before child tasks do. https://gist.github.com/jridgewell/a3a4144b2b7cec41bc9da255d3b3945a models termination lifetimes correctly. |
Thanks for the clarifications. Really helpful. So a task is some global state which is implict created by an individual instance of Adding one more Usually I would expect that individual I would also expect two variants of
Well, the second one can be implemented in userland therefore not required to be included in |
I don't think GC is an option here. Apologies on my delay, I plan to work on a sketch here soon as @jridgewell said. |
Looking forward to the sketch, but based on how GC already plays a role in this, I don't see how a user land API for task termination could be anything else than related to |
Not quite. The only way to extend the lifetime of task is to keep a pending promise around (or snapshot). Whether that promise is created as By calling into your code, I've made your code part of my task execution. Until your code completes, my task isn't complete. It doesn't matter if your code has its own
I tried looking through the OTel bind code. It seems like it's just: class AsyncContext {
bind(val, cb) {
return this.run.bind(this, val, cb);
}
} That seems fine, but I don't think this would change task termination. |
if the parent/child relationship is kept and the internal context object is the representation of the task it should work. Not sure how useful it is as a simple
Note also that this form of task termination is fundamental different to the current |
Given that if an e.g We could have a run function that tracks termination like: let snapshot;
variable.run(
"SOME_VALUE",
() => {
snapshot = new AsyncSnapshot();
},
// Optional callback that is called when the variable is no longer reachable in this state
() => {
console.log("Task terminated");
},
);
setTimeout(() => {
// At this point the ref count of AsyncContext.Snapshots that refer to the active
// mapping is zero so we can call the termination callback now
snapshot.discard();
}, 1000); Effectively the stack when executing this would be initially:
when {
[[AsyncContextMapping]]: {
variable => {
value: "SOME_VALUE",
terminationHandler: theTerminationHandler,
snapshots: [
// This is the snapshot that variable.run creates
ANONYMOUS_SNAPSHOT_CREATED_BY_RUN,
],
}
}
} then when
now
then sometime later when
and because snapshots is empty, there are no longer any referencing snapshots and so we can call the termination handler. Of course we could also make this more granular and provide a full observer for when snapshots are created and destroyed: variable.run("SOME_VALUE", () => {
}, {
snapshotCount: 0,
onSnapshot() {
this.snapshotCount += 1;
},
onSnapshotDiscard() {
this.snapshotCount -=1;
if (this.snapshotCount === 0) {
// Whatever for context termination
}
},
}); though this feels unneccessary as the only thing we can really do with this information is a ref-count which is presumably how the host would implement this anyway. For host APIs that use this for accurate tracking, they can just discard the associated snapshot in the job mapping. i.e. setTimeout could be written like: function setTimeout(cb, delay) {
const jobCallbackRecord = HOST_MAKE_JOB_CALLBACK(cb);
scheduleInSystemSomehow(() => {
HOST_CALL_JOB_CALLBACK(jobCallbackRecord);
// Discard the snapshot now
jobCallbackRecord.[[asyncContextSnapshot]].discard();
}, delay);
} |
I made a little proof of concept of what I described above using Node's import AsyncContext from "./async-context-with-termination/AsyncContext.js";
const variable = new AsyncContext.Variable();
// DO NOT REMOVE THIS LINE: https://github.com/nodejs/node/issues/48651
console.log("START");
variable.runWithTermination(
"TEST",
() => {
// This does get printed basically at the same time as the timeout callback is run
console.log(`terminated at: ${Date.now()}`);
},
() => {
setTimeout(() => {
console.log(`callback at: ${Date.now()}`);
}, 1000);
},
); |
Right, I believe @littledan idea is that basically there are 2 kinds of continuations:
A deterministic termination API would perform some refcount style tracking of the outstanding continuations:
Such an API would be deterministic, but will miss the case where all outstanding continuations (both one shot and reusable) are collected, and the context is effectively terminated. I am ok with such an API if it fulfills the need of authors. If a program needs to catch the outstanding continuation collection case, they can already use an object as the context value, and setup a |
Well for some prior art, Node's
I don't know this is actually true though, like if you create a const registry = new FinalizationRegistry(holdings => {
// We don't actually have the AsyncContext.Snapshot to call discard here as it's been
// collected so there's no way to discard it
});
const snapshot = new AsyncContext.Snapshot();
// There's no useful holdings we can provide here
registry.register(snapshot, ???); Only the implementation of |
Observability of GC through this API is one thing that I would be opposed to happen for a standardized feature as I stated earlier.
It's not the snapshot that you need to register, but the object value provided to |
Okay yes that makes sense. I suppose it gives a decent opportunity too for contexts that care about context termination to determine that the timing was not actually accurate as some API was used that didn't explictly discard their snapshot when they were done. If |
At this point, I think we should conclude that this idea is for a separate proposal, if it ever makes sense. It's very hard to define this concept! |
I actually had a solution to this a whole bunch of years ago which I made when much earlier iterations of context management were being proposed but I think I sadly failed to explain it effectively at the time. The general idea was to have a "Task" type which wraps continuations like callbacks or promise fulfillment handlers and essentially recursively reference-counts async branches created within the sync execution of each continuation. When a part of the branch resolves it'd reduce the counter for the level it originated from by one and when that count reaches zero it'd propagate upward another level until eventually everything in the async execution graph has resolved. Here's a rough example: const resolvers = new WeakMap()
const tasks = new WeakMap()
let current = undefined
function incTasks(task) {
if (!tasks.has(task)) return 0
const count = tasks.get(task) + 1
tasks.set(task, count)
return count
}
function decTasks(task) {
if (!tasks.has(task)) return 0
const count = tasks.get(task) - 1
tasks.set(task, count)
// If the count reaches zero, run the resolver
if (count === 0) {
const resolve = resolvers.get(task)
if (resolve) resolve(task)
}
return count
}
function taskify(task, resolve) {
if (current) {
resolve = nestResolver(current, resolve)
}
resolvers.set(task, resolve)
tasks.set(task, 0)
incTasks(task)
return function wrapped(...args) {
const prior = current
current = task
try {
return task.apply(this, args)
} finally {
current = prior
decTasks(task)
}
}
}
function nestResolver(current, resolve) {
incTasks(current)
return (task) => {
resolve(task)
decTasks(current)
}
}
const asyncTask = taskify(function outer(foo, bar) {
console.log('Hello, World!', foo, bar)
setTimeout(taskify(function sub1() {
console.log('I am the first subtask')
}, (task) => {
console.log('resolved first setTimeout', task)
}), 500)
setTimeout(taskify(function sub2() {
console.log('I am the second subtask')
}, (task) => {
console.log('resolved second setTimeout', task)
}), 250)
}, (task) => {
console.log('resolved outer', task)
})
asyncTask('foo', 'bar') Which produces the output:
The use of WeakMaps to store the resolver functions and task counts here is not important. That'd probably be better served by an actual wrapper type. That can act like the original function. (Or promise if an equivalent promise-handling thing was written) I'm sure this can be done much more efficiently if done properly at the VM level, but this is just a super rough sketch I threw together from memory of a proposal a decade ago soooo... 🤷🏻 |
@Qard, that sounds a lot like the deterministic ref counting that @littledan had proposed.
One problem with delaying to a future proposal is that any existing usage of Snapshot would cause context "leaks". As I mentioned, I would be opposed to non-deterministic context termination, and as such Is there any drawback to specifying an explicit disposal / revocation mechanism for snapshots without specifying a way to observe the termination of the context? |
Yes, basically the same idea. In my original implementation I solved the case of repeatable tasks by just adding one extra increment to represent the handle for the task which is only decremented when the task is cleared/unscheduled. And yeah, I think it's easy enough to specify a mechanism of following the async branches to track resolution without specifying yet what to actually do when a branch resolves so we can make a later decision on what to do with that information. An example use case where this could be valuable is Node.js domains--the domain can be held in a store through all the async branches to receive any uncaught exceptions, if the error isn't handled in some way by the time all the branches resolve it can then raise the error out to any outer domain or to the process-level exception handler. |
We can specify If we just treat these as hanging snapshots that prevent termination, I guess that works? It's just so brittle if you forget. Maybe devtools can help here. |
A nudge from devtools is definitely what I'm looking for here. |
We need to continue to discuss this. I think we're open to the idea, but having a disposable snapshot that persists means that whatever object is holding the snapshot now needs to be disposable itself, and its holder, etc. It might be ok, particularly with globally registered libraries that don't really go get collected. But then there's the question of when does a |
I'm wondering, how often does a wrapped function need to be re-usable. Could we get away with a |
If it is a call once use case this wrapped callback is no longer referenced afterwards and collected including it's wrapped context. Otherwise there would be a bigger leak, not just the wrapped context. |
The whole idea is to not rely on GC and have deterministic context termination. If you want to rely on GC, you can already do that today by using a unique object as the variable value, and registering it in a FinalizationRegistry. No need for any other API. I understand there are use cases requiring multiple callbacks, but do you think we could find a way to express them in such a way that the callbacks are revocable / disposable so that any captured context can be released by the application when no longer needed (e.g. like when doing |
Could have some sort of marker to produce somewhat Rust-like lifetime tracking (but greatly simplified) where a snapshot wrapped function is actually a new callable type, similar to BoundFunction, with the addition of a marker that can be applied when the receiver is sure it's "done" with it. Most functions could mark immediately after call. Something like setInterval could mark it only when clearInterval is called. Having a separate callable for it probably also has benefits to how we can optimize context restoring. |
Functions are just objects, so we could add |
This doesn't seem particularly viable in many cases. Just having a |
That's just the nature of dynamic languages, without some form of dispose method the only other timing that is possible to use is garbage collection timing, which you can already do the current proposal.
There's nothing special about snapshots/snapshot-wrapped-functions here, any concerns disposing objects at relevant times also applies to basically any object with explicit close steps. The explicit resource management proposal already has |
My concern here is around usage patterns. It's been discussed elsewhere that |
It shouldn't affect downstream users at all, only authors of scheduling mechanisms (events, timeouts, etc) should need to be concerned. For them they just dispose of the snapshot when the thing can no longer be scheduled, in particular cases:
Typical users shouldn't be using
From a user's persective, if an API that supports const timing = await measureTask(() = {
// Thing that schedules many subtasks
}); then the only thing explicit context termination does is make If a userland scheduling API didn't dispose of the callbacks, then you just fall back to garbage collection and the timing is later. (Also you could even warn here if some userland API didn't explictly close the context). Just to clarify what const measureTaskVariable = new AsyncContext.Variable<number>();
const finalizer = new FinalizationRegistry<() => void>((onDisposed) => {
onDisposed();
});
function measureTask(cb: () => void): Promise<{ time: number, precision: "gc" | "precise" }> {
return new Promise((resolve, reject) => {
const startTime = performance.now();
const taskToken = Symbol();
finalizer.register(taskToken, () => {
resolve({ precision: "gc", time: performance.now() - startTime });
}, taskToken);
measureTaskVariable.runWithTermination(
taskToken,
function onTermination() {
resolve({ precision: "precise", time: performance.now() - startTime });
finalizer.unregister(taskToken);
},
cb,
);
});
}
const timing = await measureTask(() => {
// ...jobs to run
}); |
I want to second this. In the example given |
Opening a placeholder issue on "Task termination", which Chrome has brought up as a necessary part of their Task Attribution API. They want to know when a particular task execution has finished, so that they can record the total time spent (eg, how long after a click does it take to paint the next page?). Someone has also mentioned that this is a usecase that Angular needed in their Zone.js implementation.
@littledan said he was thinking on an initial sketch on how this could be exposed in JS.
The text was updated successfully, but these errors were encountered: