[fix][hooks] index endpoint rules by path, and let the older hook keep it (24.05) - #7902
Open
ar2rsawseen wants to merge 1 commit into
Open
[fix][hooks] index endpoint rules by path, and let the older hook keep it (24.05)#7902ar2rsawseen wants to merge 1 commit into
ar2rsawseen wants to merge 1 commit into
Conversation
…p it An api endpoint hook's path is global while the hook belongs to apps, and nothing stopped a hook on one app claiming a path already held by a hook on another. Dispatch then matched on the path alone and assigned on every match without breaking, so the winner was whichever the hooks query returned last, and that query is unsorted. Where the colliding hooks belong to different apps, whichever wins receives the other's callback parameters, and the default email effect sends the whole parameter object. The losing hook stops firing. Between two hooks on the same app it is simply unpredictable, and natural order is not stable across updates or compaction, so a hook can stop working with nothing logged. Dispatch is now a lookup rather than a scan. The rules are indexed by path where the rule list is already rebuilt, every refreshRulesPeriod, so a collision is resolved once per refresh instead of being re-decided on each callback. That removes a linear scan per request in favour of an O(1) get, which makes this faster than it was rather than slower. A collision resolves by age: the older hook keeps the path. That is deterministic, where the previous behaviour depended on query order. It also means a hook created later cannot take over a path already in use, so whichever hook has been serving a path continues to, and nothing that works today stops working. created_at is absent on hooks predating it, so the id is the fallback, its leading bytes being the creation time anyway. Saving refuses a path another hook holds, on create and on update, with the update excluding the hook being edited. That is a read then a write, so a partial unique index backs it. Partial because only api endpoint hooks have a path and every other trigger type would collide on null. An instance already holding a duplicate cannot build the index: that is logged as a warning and otherwise left alone, so nothing breaks, new duplicates are still refused on save, and the existing collision resolves to the older hook.
ar2rsawseen
force-pushed
the
backport/hooks-endpoint-path-uniqueness-2405
branch
from
August 5, 2026 18:49
97507d0 to
cc1b98c
Compare
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.
An api endpoint hook's path is global while the hook belongs to apps, and nothing stopped a hook on one app claiming a path already held by a hook on another. Dispatch then matched on the path alone, assigning on every match with no break, so the winner was whichever the hooks query returned last — and that query is
find({"enabled": true}).toArray()with no sort, so the order is undefined.Where the colliding hooks belong to different apps, whichever wins receives the other's callback parameters, and the default email effect sends the whole parameter object, so a webhook's payload leaves the instance to an address chosen by the other app's hook. The losing hook stops firing. Between two hooks on the same app it is simply unpredictable, and natural order is not stable across updates or compaction, so a hook can stop working with nothing logged.
Dispatch is now a lookup, not a scan
The rules are indexed by path where the rule list is already rebuilt, every
refreshRulesPeriod. So a collision is resolved once per refresh rather than re-decided on every callback, and dispatch is an O(1)get. At a million callbacks a minute that removes a linear scan per request, which makes this faster than before the report rather than slower. The only added cost is one reference per hook in memory, and one extra pass over an array the refresh had just materialised anyway — next to the database query it already performs, that is noise.Collisions resolve to the older hook
By
created_at, falling back to_idfor hooks predating that field, since an ObjectID's leading bytes are the creation time. That gives three properties:Refusing both matches, which an earlier revision of this branch did, punishes the app that did nothing wrong. Serving the newest is the defect itself.
Uniqueness at save, backed by an index
Saving already refuses a path another hook holds, on create and on update, with the update excluding the hook being edited. That is a read then a write, so two concurrent saves could still slip a duplicate through; a partial unique index closes it. Partial because only api endpoint hooks have a path and every other trigger type would otherwise collide on null.
An instance that already holds a duplicate cannot build the index. That is deliberately left alone rather than special-cased: the error is logged as a warning, everything keeps working, new duplicates are still refused on save, and the existing collision resolves to the older hook at dispatch.
Verification
Nine checks on the collision resolution: the older hook wins whether it appears first or last in the array, the fallback to
_idwhencreated_atis absent, the oldest of three, distinct paths both surviving, pathless rules skipped, empty and undefined input, and that a log is emitted on collision and not otherwise.node --checkandnpx eslintclean.