Skip to content

Align the two QueryIndex traversal workers - #1295

Merged
brharrington merged 1 commit into
Netflix:mainfrom
brharrington:queryindex-haskey-position
Sep 8, 2026
Merged

Align the two QueryIndex traversal workers#1295
brharrington merged 1 commit into
Netflix:mainfrom
brharrington:queryindex-haskey-position

Conversation

@brharrington

Copy link
Copy Markdown
Contributor

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 forEachMatchImpl

It 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:

eqIdx.forEachMatch(tags, consumer);   // compiled fine, re-wrapped at every node

DedupConsumer<T> IS-A Consumer<T>, so nothing caught it. After the rename that call does not compile: the only 2-arg forEachMatchImpl takes a Function. DedupConsumer.from becomes 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 pass j + 1. That sub-tree is built by hasKeyIdx.add(queries, j, value) from the queries after the ones for this key, so its own key sorts strictly after keyRef and the tag at j can never match it — the scan spent one iteration and one compareTagKey on it before moving on.

Safe at the boundary too: if the :has query is the last one, the sub-tree gets key == null and returns after matches.forEach, so the start position is irrelevant there.

The two methods now check the same sub-trees in the same place

otherKeysIdx/missingKeysIdx were inside the key != null guard here and outside it in the Function method, and keyPresent was scoped differently to match. The two agree today only 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 though one of them was wrong. This takes the shape that does not depend on the invariant.

Also

DedupConsumer.from's type parameter was T, shadowing the enclosing DedupConsumer<T>, so (DedupConsumer<T>) consumer read as if it referenced the class parameter. Renamed to V, 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 assertEquals helper already runs every expectation through both findMatches(Id) and findMatches(Function), so it cross-checks the two workers against each other automatically — which is what the divergence above needed.

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 brharrington added this to the 1.10.7 milestone Sep 8, 2026
@brharrington
brharrington merged commit 08ac1af into Netflix:main Sep 8, 2026
1 check passed
@brharrington
brharrington deleted the queryindex-haskey-position branch September 8, 2026 21:20
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant