Repository navigation
Sync Hooks - #114
Open
DaddyWarbucks wants to merge 1 commit into
Open
Sync Hooks#114DaddyWarbucks wants to merge 1 commit into
DaddyWarbucks wants to merge 1 commit into
Conversation
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
See also: feathersjs/feathers#3732
Summary
Regular hooks currently pay the cost of Promise scheduling even when they complete synchronously. This change executes consecutive synchronous hooks in a loop and pauses that loop when a hook returns a Promise or thenable. Public hook APIs continue to return Promises.
For example, a stack of ten synchronous
beforehooks previously created a Promise continuation for each hook's result and another continuation to advance to the next middleware. The new implementation runs that list as one middleware group, completes synchronous hooks immediately, and proceeds to the next middleware once the list finishes.Local Node benchmarks measured approximately 2.5× faster dispatch for ten synchronous hooks, 3.7× for fifty synchronous hooks, and 1.7× for a mixed stack. The fully asynchronous case had the same measured runtime. These measurements cover hook dispatch and an inexpensive async method; they do not represent total service request latency.
Why this change
The existing implementation introduces asynchronous boundaries in three places:
runHook()always callsPromise.resolve(hook(...)).then(...), including for hooks that returnundefined, a plain value, or an updated context.runHooks()reduces the entire hook list into a Promise chain before execution.collect()wraps everybeforeandafterhook as individual middleware, adding continuations to enter or leave each hook.Synchronous context updates, argument transformations, and checks do not need those boundaries. The added Promise work becomes more noticeable as a regular-hook list grows. Removing it also reduces temporary allocations.
The implementation uses the same principle as the synchronous resolver optimization: retain the public Promise API and let internal code complete synchronously whenever the supplied function does.
Implementation
Execute one hook with
runHookValue()A new private helper returns
void | Promise<void>:context.selfas its receiver.Promise.resolve()and perform the same completion work once it resolves.isPromiseLike()recognizes objects and functions with a callablethenproperty. This allows Promise and thenable results to take the asynchronous path without requiringinstanceof Promise.Execute a sequence with
runHooksValue()The sequence runner holds a cursor for each invocation and advances through synchronous hooks in a loop:
When a hook returns an asynchronous result, the runner waits for that result and then resumes at the next index. Hook execution remains sequential, so later hooks see the completed context changes from earlier hooks. The cursor belongs to the invocation, allowing the same collected middleware to handle overlapping calls independently.
Group regular hooks in
collect()collect()creates one middleware for itsbeforelist and one for itsafterlist:next().next()and then completes its sequence.Error hooks continue to use
fromErrorHook(runHooks(error)). The sequence insiderunHooks()benefits from the new loop while retaining the existing error wrapper's recovery and propagation behavior.Keep Promise results at public boundaries
runHook()wraps the private helper's result inPromise.resolve().runHooks()retains an initial Promise continuation and runs the optimized sequence inside it. An emptyrunHooks()list still resolves to the supplied context; a completed nonempty list resolves toundefined.fromBeforeHook()advances immediately after a synchronous hook and chains to the hook's Promise when it is asynchronous.fromAfterHook()continues to wait fornext(), then uses the private single-hook runner.The core
compose()dispatcher, the Promise contract ofnext(), and the original-method wrapper retain their existing implementations.Observable scheduling change
Consecutive synchronous hooks now execute without a microtask boundary between them. An entirely synchronous before group can reach the next middleware in the same call stack.
For example:
Previously, the events were
['first', 'queued', 'second']. With this change, they are['first', 'second', 'queued'].Hooks that start asynchronous work needed by subsequent hooks must return that work's Promise. Returning a Promise or thenable causes the runner to wait before advancing. Wrapped functions and
next()still return Promises, and middleware usingawait next(),next().then(), ornext().catch()continues to use that contract.Benchmark results
Node: comparison with the original implementation
Environment and methodology:
regular.tsfrom commitf9e6e43compared with the updated working-tree implementation. Other source modules were identical.async () => 1method with a collected before-hook list. Hooks incremented a context property.async_hooksPROMISEinitialization events. Instrumentation was disabled for elapsed-time measurements. Counts include common wrapper and measurement overhead and represent created Promise objects, not peak live objects.The synchronous cases remove Promise work that previously grew with every hook. Mixed stacks also benefit because synchronous runs between async hooks complete in the loop. Fully asynchronous hooks still require their continuations; that case created one additional Promise in this harness and had unchanged measured runtime.