Align the two QueryIndex traversal workers - #1295
Merged
brharrington merged 1 commit intoSep 8, 2026
Merged
Conversation
Three changes to the Id based traversal, none of which alter what it returns. They remove waste and two divergences from the equivalent Function based method next to it. The private worker is renamed to forEachMatchImpl, matching the Function one. It was an arity overload of the public forEachMatch, which is the shape that caused the repeated wrapping fixed in Netflix#1291: dropping the position argument from a recursive call bound to the public method and re-wrapped the consumer, silently. With the rename that call no longer compiles, so DedupConsumer.from is a guard rather than the mechanism. The has key descent started scanning from the position of the tag that matched this node's key. That sub-tree is built from the queries after the ones for this key, so its own key sorts strictly after keyRef and the tag at that position can never match it: the scan spent one iteration and one compareTagKey on it before moving on. Start past it, as the exact and other-condition descents from the same position already do. The other-keys and missing-keys descents sat inside the "key != null" guard, where the Function method has them outside it. The two agree today because add() assigns key before it can create either sub-tree, so a node with no key has neither, but nothing recorded that and the two methods read as if one of them was wrong. Taking the shape that does not depend on the invariant leaves them symmetric. Also renames the type parameter on DedupConsumer.from from T to V. It shadowed the enclosing DedupConsumer<T>, so the cast read as though it referenced the class parameter; the other static methods on this class use V for the same reason. Tests for the has key descent with several keys following it, with one of those keys absent, and with the matching tag last so the sub-tree has nothing left to scan. They pass before the change as well, since the behaviour is unchanged, but they do constrain the position: an off by one in that descent fails six tests in the class.
brharrington
added a commit
that referenced
this pull request
Sep 8, 2026
DedupConsumer exists for :or clauses, where the same value is registered under more than one term and a traversal can reach it twice. Every lookup paid for it: a DedupConsumer plus a HashSet, and on the first delivery a 16 slot table and a node. For an index built only from :and queries none of that can ever suppress anything. Instrumenting the QueryIndexMatch workload, 5000 subscriptions matched against 200 ids, counted 932 deliveries and 0 suppressions. Track in add() whether a value was registered under more than one term and only wrap when it was. A value reachable from a single term can be found once, so there is nothing to dedup. The flag is set before the value is registered anywhere rather than after. Lookups run concurrently with updates, so setting it afterwards leaves a window where a lookup finds the value under two terms and still reads the old flag, delivering it twice. It counts the terms in the dnf list rather than the ones that end up registered, which over-approximates and only ever costs a dedup that was not needed. Sticky: a remove() can only make deduping unnecessary, never necessary, and recomputing would mean walking the index. That leaves the wrapper allocated for an index with any :or in it, so also defer its set until a second distinct value arrives, keeping the first in a field. Most lookups deliver nothing or one value. The workers now take a plain Consumer, since the wrapping decision is made once at the public entry rather than being carried in the parameter type, and DedupConsumer.from goes with it: the type it guarded against no longer reaches them. The forEachMatchImpl naming from #1295 is what keeps a recursive call from reaching the public method. QueryIndexMatch with the gc profiler, against 08ac1af: no :or in the index 126.1 -> 116.2 us/op 49953 -> 3201 B/op one :or among 5000 126.1 -> 121.5 us/op 49953 -> 39441 B/op The residual 3201 B/op over 200 lookups is the benchmark's own consumer lambda, so the index allocates nothing per lookup in the first case. The second is the limit of the flag: it is index wide, so one :or turns it off for everything and only the deferred set helps there.
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.
Three changes to the Id-based traversal in
QueryIndex, none of which alter what it returns. They remove waste and two divergences from the equivalent Function-based method sitting next to it. Follow-up to #1291.The private worker is renamed to
forEachMatchImplIt was an arity overload of the public
forEachMatch(Id, Consumer<T>)— the shape that caused the repeated wrapping #1291 fixed. Dropping the position argument from a recursive call bound to the public method and silently re-wrapped the consumer:DedupConsumer<T>IS-AConsumer<T>, so nothing caught it. After the rename that call does not compile: the only 2-argforEachMatchImpltakes aFunction.DedupConsumer.frombecomes a guard rather than the mechanism.The has-key descent started one position early
It passed
j, the position of the tag that matched this node's key, where the exact-match and other-condition descents from the same point passj + 1. That sub-tree is built byhasKeyIdx.add(queries, j, value)from the queries after the ones for this key, so its own key sorts strictly afterkeyRefand the tag atjcan never match it — the scan spent one iteration and onecompareTagKeyon it before moving on.Safe at the boundary too: if the
:hasquery is the last one, the sub-tree getskey == nulland returns aftermatches.forEach, so the start position is irrelevant there.The two methods now check the same sub-trees in the same place
otherKeysIdx/missingKeysIdxwere inside thekey != nullguard here and outside it in the Function method, andkeyPresentwas scoped differently to match. The two agree today only becauseadd()assignskeybefore it can create either sub-tree, so a node with no key has neither — but nothing recorded that, and the two methods read as though one of them was wrong. This takes the shape that does not depend on the invariant.Also
DedupConsumer.from's type parameter wasT, shadowing the enclosingDedupConsumer<T>, so(DedupConsumer<T>) consumerread as if it referenced the class parameter. Renamed toV, which is what the other static methods on this class use.Tests
Three cases for the has-key descent: several keys following the
:has, one of those keys absent, and the matching tag last so the sub-tree has nothing left to scan.They pass before the change as well — the behaviour is unchanged, so they are guards rather than bug demonstrations. What makes them worth having is that they constrain the position: an off-by-one in that descent fails six tests in the class.
Worth noting the existing
assertEqualshelper already runs every expectation through bothfindMatches(Id)andfindMatches(Function), so it cross-checks the two workers against each other automatically — which is what the divergence above needed.