Repository navigation
Remove async_hooks setTrigger API #14238
Description
- Version: master (016d81c)
- Platform: all
- Subsystem: async_hooks
As I mentioned a few months ago I'm not comfortable exposing the low-level async_hooks JS API. In the documentation PR #12953 we agreed to keep the low-level JS API undocumented. Some time have now passed and I think we are in a better position to discuss this.
The low-level JS API is quite large, thus I would like to separate the discussion into:
- a discussion about
setInitTriggerIdandtriggerIdScope. (this) - edit: a discussion about
runInAsyncIdScope. (Remove async_hooks runInAsyncIdScope API #14328) - a discussion about
newUid,emitInit,emitBefore,emitAfterandemitDestroy(Remove async_hooks Sensitive/low-level Embedder API #15572).
edit: there is some overlap between emitInit and setInitTriggerId in terms of initTriggerId. Hopefully, that won't be an issue.
As I personally believe setInitTriggerId and triggerIdScope are the most questionable, I would like to discuss those first.
Background
async_hooks uses a global state called async_uid_fields[kInitTriggerId] to set the triggerAsyncId of the soon to be constructed handle object [1] [2]. It has been mentioned eariler by @trevnorris that a better solution is wanted.
To set the async_uid_fields[kInitTriggerId] field from JavaScript there are two methods setInitTriggerId and triggerIdScope.
setInitTriggerIdsets theasync_uid_fields[kInitTriggerId]directly and is used in a single case in node-core. IfinitTriggerIdis not called after when creating the handle object this will have unintended side-effects for the next handle object.triggerIdScopeis a more safe version ofsetInitTriggerIdthat creates a temporary scope, outside said scope theasync_uid_fields[kInitTriggerId]value is not modified. This is not used anywere in node-core.
Note: This issue is about removing setInitTriggerId and triggerIdScope as a public API. Not removing async_uid_fields[kInitTriggerId] itself, that may come at later time.
Issues
setInitTriggerIdis extremely sensitive and wrong use can have unintended side-effects. Consider:
async_hooks.setInitTriggerId(asyncId);
return createUserHandle();
function createUserHandle() {
if (state.freeConnections) {
// no handle is created, no initTriggerId is called
return state.getFreeConnection();
} else {
// handle is created and initTriggerId is called
return state.newConnection();
}
}In the above example setInitTriggerId will have an unintended side-effect when state.freeConnections is true. The implementation is obviously wrong but in a real world case I don't think this will be obvious.
triggerIdScopeis a safer option, but even that may fail if more than one handle is created depending on a condition.
state.newConnection = function () {
if (state.numConnection >= state.maxConnections) {
// This may do logging causing `initTriggerId` to be called.
state.emit('warnConnectionLimit');
}
// calls initTriggerId
return newConnection();
};- It is not obvious for the user when
initTriggerIdis called because the handle logic in node-core is so complex. For example, when debugging withconsole.loga new handle can be created causinginitTriggerIdto be called.
state.newConnection = function () {
// may call `initTriggerId`
console.log('new connection');
// calls initTriggerId
return newConnection();
};Such side-effect may be obvious for us, but I don't want to tell the user that console.log can have unintended side-effects.
- Handle creation may change. How many handles and the exact timing of this is not something we test. I thus think it is reasonable to believe that this is something that will cause an unintended issue in the future. Possibly during a semver-patch update.
Note, in all of these cases the handle creation and setting the initTriggerId may happen in two separate modules.
Solution
- We remove
triggerIdScope - We move
setInitTriggerIdtorequire('internal/async_hooks.js') - The user should specify the
triggerAsyncIddirectly inAsyncResourceoremitInit. The API already allows for this.
Note: We may want to just deprecate the API in node 8 and remove it in a future version.
/cc @nodejs/async_hooks
Activity
refack commentedon Jul 14, 2017
👍
One question, why do you prefer to keep
setInitTriggerIdovertriggerIdScope? Why not help future core devs make less mistakes (globals are not fun)AndreasMadsen commentedon Jul 14, 2017
Eventually I want to, but
setInitTriggerIdis currently used by node-core,triggerIdScopeis not. IfsetInitTriggerIdwasn't already used I would like to have removed it too.How we can migrate away from
async_uid_fields[kInitTriggerId]internally is a completely different issue. But disallowing access from userland means that we can in the future.refack commentedon Jul 14, 2017
I keep forgetting that event handlers are synchronous.
refack commentedon Jul 14, 2017
I just dug around in the code and saw that
triggerIdScopedid not make it into core anyway... So why not replaceinternal/setInitTriggerIdwith aninternal/triggerIdScopeSync(block, id)as a short-term solution (similar to whatsetInitTriggerIdshould have been according to the EP)AndreasMadsen commentedon Jul 14, 2017
Oh, yeah :)
edit: I was actually confusing
triggerIdScopewithrunInAsyncIdScope. There will be a future discussion aboutrunInAsyncIdScope.If you like. The current use of
triggerIdScopeis so simple I doubt it causes any confusion. You should also be aware that in C++ there are quite a few calls toset_init_trigger_id. Personally, I would rather use my time solving the big issue than temporarily fixing something to prevent a future issue that may never happen.I don't really have an opinion on this. If we are going with a temporary solution for a hypothetical issue, there are some performance concerns by creating the extra scope. Personally, I don't care.
edit: I don't see any difference between EP
setInitTriggerIdand the implementedsetInitTriggerId.refack commentedon Jul 15, 2017
I'll do it just to be extra safe. Experience teaches us that "temporary" is the most permanent state 🤷♂️ .
Just for context
From the EP doc
While the implementation:
there's no
reset after it's been retrievedrefack commentedon Jul 15, 2017
Now grepping
setInitTriggerId:So the plot thickens
AndreasMadsen commentedon Jul 16, 2017
The "retrieving" part is in
initTriggerId.refack commentedon Jul 16, 2017
Ack.
But like you wrote, if
initTriggerIdis not called for some reason,setInitTriggerIddoes not restore the state on it's own.[addition]
Reading again
The value is reset after it's been retrieved.I agree it meansresetiffretrieved.So current implementation adheres to the EP, and is
brittle because if a constructor fails [the] then the internal field may not be reset.AndreasMadsen commentedon Jul 16, 2017
@refack Let's not derail this discussion further. I think we are in complete agreement.
This discussion is meant to be about removing
setInitTriggerIdandremovingnot implementingtriggerIdScope, as a public API.The EP contained these two functions, so while we are in agreement I suspect others may disagree with removing them.
refack commentedon Jul 16, 2017
Ack.
P.S. I'm writing a PR to do just that, and am "struggling" with those delicate details. I'm posting it for review, since I still have 3 failed tests. #14302
trevnorris commentedon Jul 17, 2017
emitInit()is for the implementor, not the user, and not all APIs can be changed to allow a customtrigger_idto be passed. For example:This is missing all the error handling and such for brevity, but the point comes across. This functionality cannot be removed.
Before I continue, I'd like to clarify whether removing these is the actual intent?
EDIT: Now see #14328. Will continue the discussion about
runInAsyncIdScope()there.AndreasMadsen commentedon Jul 18, 2017
Sorry, I wasn't aware of that terminology.
This is what I think
peopleimplementor should do:6 remaining items
AndreasMadsen commentedon Jul 24, 2017
Not true.
triggerAsyncIdScope()will in no way affectasync_hooks.triggerAsyncId(),async_hooks.triggerAsyncId()is only directly affected byemitBefore()andemitAfter(). What you write in the follow-up message makes more sense, so maybe you realized this yourself.Your new implementation appears to solve all the listed issues I initially mentioned. Thus, I think it is an acceptable solution that doesn't course the user or the implementer too much harm.
There are issues such as, what if the handle creation was changed from being in
.listen()to happen increateServer()(hypothetical scenario) thetriggerAsyncIdwould not be the same. However, the alternatives I have in mind suffers from the same issue so that is not a valid argument.That being said, I'm still not convinced that
initTriggerAsyncIdScope()is necessary to facilitate any of the primaryasync_hooksissues (long stack trace, continuation local storage, domain, performance monitoring).Let's assume this is true for now. I have thought a lot about how to separate these discussions and I think the
emit*()discussion is almost orthogonal to thesetTriggerAsyncId*discussion.Your example is more helpful, but I still think it is a poor illustration of where
initTriggerAsyncIdScope()is useful. I think the example boils down to:In this case, the user of
initTriggerAsyncIdScope()could write a code like:In this case
resource.triggerAsyncId()will not be 42, but it will point to thescoperesource and that will havescope.triggerAsyncId() === 42. Thus, the compressed DAG will remain the same. Thus, all the graph dependentasync_hooksuse cases are still satisfiable.compressed DAG: In the uncompressed DAG consider the path
A -> B -> Cwith no other connections to and fromB, then this can be compressed toA -> Cwithout changing how any other nodes in the network are connected.In this example,
A = 42,B = scope.asyncId(), andC = resource.asyncId().trevnorris commentedon Jul 25, 2017
Yup. That's the case.
NOTE: While writing some example code your code in #14238 (comment) clicked. I believe the reason it hadn't until now is because the paradigm was so drastically different from how
triggerAsyncIdis intended to function. Allow me to explain.I'll start with an important use case for
initTriggerAsyncIdScope()that was missing in my previous example. Which is when an asyncronous resource is created within a constructor of another asynchronous resource:Following your proposal it would look like so:
Assuming I now understand your proposal, here are my issues with this scenario:
Wrapping the call this way will trigger all four async hooks. This is an issue because:
AsyncResource. The hooks should only be triggered when an asynchronous task has completed. In practical usageinit()andbefore()should never fire in the same execution scope.triggerAsyncIdis a light-weight (and almost free) way of communicating similar information. (I understand API performance isn't a valid argument in terms of "pure correctness", but I believe for practical purposes it should be considered)init()calls. This method would require stack trace filtering to reduce redundant information.It removes the need for
triggerAsyncId. Which is an issue because:executionAsyncIdalso no longer exists because it pollutes the execution stack with orthogonal data. Meaning,triggerAsyncIdandexecutionAsyncIdwould essentially be compressed down toasyncId.triggerAsyncIdis properly set in userland modules, butexecutionAsyncIdmust either be set correctly or not set at all (there's probably an edge case where this isn't true, but we can make a resonable assumption that it will be). This means there will be descrepencies in use cases like long stack traces.So, IMO, the argument basically boils down to wither
triggerAsyncIdis necessary or not. If it is theninitTriggerAsyncIdScope()needs to exist. If not then we need to redefine what thebefore()/after()/destroy()hooks are meant to communicate.Hopefully my points above explain why combining
executionAsyncIdandtriggerAsyncIdwill not result in the ability to gather the same information. In short, the graph structure you refer to is lossy, not lossless.AndreasMadsen commentedon Aug 1, 2017
Sorry for the late response.
I think you do now. But, I think it very rare that setting
triggerAsyncIdthis way is necessary as there often is a better solution. Just like I also think thatinitTriggerAsyncIdScopewill be used very rarely, if implemented.We don't guard against that and I could easily imagine a caching module, where if the requested data is cached it just calls the callback synchronously. This may be bad practice, I'm not even sure it is. But certainly, one should not assume that
initandbeforeare always in different execution scopes.Agreed. I think performance should be the primary argument for
initTriggerAsyncIdScope.We already have that issue, simplest example is promises.
Promise.resolve(1).then(() => {})will invokeinittwice in the same execution scope. I'm acutually already doing this, some background can be found in AndreasMadsen/trace#34.I still diagree with this. Yes the
executionAsyncIdwill change, but the graph one gets from usingexecutionAsyncId()and from usingtriggerAsyncIdwill not be the same. In theResourceexample you show they are not the same.I don't see how that isn't already the case. Right now,
triggerAsyncIdcan be set as the user wish andinitTriggerAsyncIdScopedoesn't prevent that. IninitTriggerAsyncIdScopeit is also possible to use an invalidtriggerAsyncId.I agree that
triggerAsyncIdis necessary. But I don't understand the argument for howtriggerAsyncIdand executionAsyncId()` becomes the same.I don't think that is defined anywhere, if what you are saying is how it is ment to work then we should document and validate that. Right now the solution I propose is already possible, so there is nothing that prevents the user from doing it that way.
I disagree, inserting a proxy-node between two other nodes is not a graph-operation that causes an information loss. In fact it only adds information, although I would agree that from a practical point of view that information is redudant.
trevnorris commentedon Aug 1, 2017
No worries. I do the same. :-)
I've been trying to address too many points in each post. I'd like to start hammering out fewer, more discrete, points. I'll address two of them here.
They are defined in both the EPS and API documentation:
This is contradictory to the model you're proposing. Would you agree?
Second question is easier to explain with a code example:
By using
emitBefore()thetriggerAsyncIdand return value ofasync_hooks.executionAsyncId()in theResource'sinit()call forsetImmediate()will be that of theResourceinstance. By my definition of theexecutionAsyncIdthis shouldn't happen because, in part,emitBefore()will never be called asynchronously. Which violates the definition of the above definition forbefore(). I'd like to hear your opinion.AndreasMadsen commentedon Aug 5, 2017
That wasn't how I read it, but I do see that it can be read that way. I generally don't see any reason to limit the user this, synchronous callbacks may be a bad design but it certainly isn't rare. I don't think we can expect userland to use unconditional asynchronous callbacks.
Yes. The
triggerAsyncIdisn't explicitly set, that is how it is supposed to work. I agree that ifinitTriggerAsyncIdScopethis would not be the case since it wouldn't default toasync_hooks.executionAsyncId().What is that definition? The documentation says:
That is still true, the execution context is
ar.I'm not sure what you mean? Obviously it is possible to call
emitBefore()even if that is not intended (hypothetically).I think it is better to look at the entire graph. I understand that you would like
triggerAsyncIdto beResource.asyncId()and I agree it isn't. But If we look at the entire graph then the causality is what you would expect and I think that is what is important.trevnorris commentedon Aug 7, 2017
To verify, I'm talking about the
init()call corresponding to thesetImmediate()called synchronously in theResourceconstructor.From documentation of when
before()callbacks are called:The
before()callback is meant to communicate that an async resource has completed an asynchronous task.arhas done nothing asynchronous whenar.emitBefore()is called.The graph data is not the only contributing factor to your argument of how to use
emitBefore(). Above I've explained the fundamental reason why thebefore()hook exists and when it should be called.I can see the causality of the generated graph, but this compounds the use case for
AsyncResource. If information is to propagate in this way then the API needs to be redefined. As mentioned above, thebefore()hooks is only meant to notify the user when an asynchronous task has completed, and when the return callback is about to fire. Your proposal invalidates this.In short, I understand your arguments and I acknowledge the merits of your proposed API, but it doesn't fit within the context of how the current API is meant to operate or how it's documented.
AndreasMadsen commentedon Aug 9, 2017
I'm on vacation until the 18th, so I won't comment on this again until after the 18th.
If you say that is how the documentation should be read then I can't really argue against that. But I will say that I don't read it that strictly.
Indeed the strictest interpretation of asynchronous would mean that stacking (before(1), before(2), after(2), after(1)) isn't allowed.
In any case, I don't think how the API is meant to be used or how the documentation is supposed to be read are good arguments. What matters is:
trevnorris commentedon Aug 14, 2017
@AndreasMadsen
I don't think dismissing the documentation, or the intent of the API, is a sound direction to take. This comment makes it sound like the documentation was written prior to the API being developed, and not from hundreds of hours of brainstorming, experimentation and trial-and-error, and thus it's of little concern if the API is altered.
Before we question how the documentation is "supposed to be read" I will note that the documentation outlines when the hooks will be called in the API overview:
Also from official documentation:
It could only be more specific if it instead read "is only called just before the resource's callback is called," but I honestly didn't think that level of specificity was required to see that the hooks are only meant to wrap the callbacks resulting from an asynchronous operation.
This isn't a question of "relaxing the rules". It's a change to the fundamental nature of
'async_hooks'. One example is how to interpret the event when a hook is called. We need to come to an agreement on this point before proceeding.I don't see how this is possible being that there's a documented example of a scenario occurring similar to the one you've said "isn't allowed" (assuming that 1 and 2 are the hook ids).
AndreasMadsen commentedon Sep 17, 2017
Sorry, I have taken so much time. I find your argumentation very unproductive so I don't have a lot of motivation for discussing this.
The intent of the
async_hooksAPI is to provide all the information about all asynchronous actions. Anything beyond that, such as the timing of theinit,before, andafterhooks is just semantics that we can agree or disagree on.This is just yet another superiority argument, I won't respect it! If your paradigm is correct you should be able to argue for it without referring to the hours spent. Something does not become true just because we have thought long and hard about it, it doesn't even validate the claim.
There is a funny anecdote about big-integer multiplication. Since 2000 B.C. mankind has been multiplying multi-digit numbers. For two, two-digit numbers 4 one-digit multiplications were required. It was believed among mathematicians for 4000 years that it was impossible to find a faster method, surely we would otherwise have found it by now. In 1960 Kolmogorov expressed this conjecture like so many others before him. Within a week a student called Karatsuba then proved him wrong with some grade-school algebra.
To be clear, I actually think you are correct that the global variable is the best strategy. But I don't feel very certain about.
I would like a more productive discussion. But so far your argument style has been to not comment on my arguments or claim superiority. Neither of which makes anyone get any wiser.
I suppose that is strictly speaking correct. However, as a mear reader of the documentation, it is not the kind of details I'm able to notice. I test a certain timing of events. If the test doesn't throw an error, then I will have to take that timing into consideration when writing my userland code.
trevnorris commentedon Sep 21, 2017
It's also possible to provide wrong information.
Sorry. I'll be more direct in my discussion.
Based on the context in this thread, I assume you represent Karatsuba. Which would make me Kolmogorov? You think very well of yourself.
I've been reading this, then scrolling back and reading our conversation, for about 10 mins. Not sure what to say if this is how you feel. I'll again try to summarize the points to see if we're on the same page.
The problem we're trying to solve is propagating the correct ids to 1) the
init()hooks and 2) the constructors of those resources. My proposal is to bring backinitTriggerAsyncIdScope()while yours is to use a newAsyncResourceinstance. Here's an example of what I believe you want to do:Do I understand your proposal correctly?
AndreasMadsen commentedon Sep 21, 2017
Perhaps, this is where we don't align with our thoughts. Could you explain what the wrong information is?
Honestly, I just want self-sustaining closed arguments. As I said, I'm guessing that you are correct but I don't really see any bulletproof arguments, yet. PS: The Kolmogorov-Smiroth test is my favorite statistical tool, so I would rather be Kolmogorov :)
Yes.
setImmediate'striggerAsyncIdpoints tofoo.foo'striggerAsyncIdpoints toA1. Thus the link betweensetImmediateandA1is expressed in the graph.When did we have
initTriggerAsyncIdScope()implemented?trevnorris commentedon Oct 12, 2017
Stupid comment on my part. Please ignore it.
Hah, noted. :-)
Problem I see using the API this way:
init()call means an asynchronous resource is being instantiated, or whether it was called to wrap other resource allocation. Here's another example:Sorry. I should have been more specific due to the long duration of this conversation. Originally it was called
triggerIdScope()and was part of the originalasync_hooksPR until it was replaced due to discussion. Unfortunately I let that slip due to the number of comments I was addressing. You can see the original implementation at https://git.io/vdKFzI'd really like your feedback on the old
triggerIdScope()implementation. I feel comfortable with it conceptually, but could use a good set of eyes to verify my implementation. And let me know if there's another option that hasn't been discussed here that you think we should explore.AndreasMadsen commentedon Oct 13, 2017
Great. This is an excellent point.
Honestly, it seems rather wrong. The
idargument isn't even used in the implementation. Besides that, it doesn't actually set or unsetasync_uid_fields[kScopedTriggerId]which I assume is the idea. The only use oftrigger_scope_stackis withintriggerIdScopefunction, so it doesn't do anything. I'm also not sure howtrigger_scope_stack.length > 0could ever be the case or what the purpose ofasync_uid_fields[kScopedTriggerId] > 0is.I refuse to believe you could have made something so wrong, so I'm a little confused. In any case, this is what I'm imagining you want to implement:
AndreasMadsen commentedon Jan 10, 2018
I've deprecated
initTriggerIdin #16972.I've made a complete refactor if the internal use of
initTriggerId, such that it is bound to a scope and don't work as a one-time-use. If we need this feature in the future we can just expose that. PR: #17273