Skip to content

Skip runs of excluded docs in ReqExclBulkScorer for two-phase clauses#16307

Merged
jimczi merged 2 commits into
apache:mainfrom
jimczi:reqexcl-twophase-runend
Jul 6, 2026
Merged

Skip runs of excluded docs in ReqExclBulkScorer for two-phase clauses#16307
jimczi merged 2 commits into
apache:mainfrom
jimczi:reqexcl-twophase-runend

Conversation

@jimczi

@jimczi jimczi commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

ReqExclBulkScorer skips runs of excluded docs via docIDRunEnd() only for a plain DocIdSetIterator excluded clause; a two-phase clause advances one doc at a time. This uses TwoPhaseIterator#docIDRunEnd() in the two-phase branch too — as DenseConjunctionBulkScorer already does — clamped to at least upTo + 1 since its default returns the current doc.

Speeds up filters with a two-phase excluded clause that matches most docs, e.g. a doc-values != v query. Covered by the existing randomized TestReqExclBulkScorer#testRandomTwoPhase (its RandomTwoPhaseView overrides docIDRunEnd()).

Use TwoPhaseIterator#docIDRunEnd() to skip a run of excluded docs for a
two-phase excluded clause, like the plain DocIdSetIterator branch already
does, instead of advancing one doc at a time. Clamp to at least upTo+1
since TwoPhaseIterator#docIDRunEnd() defaults to the current doc.

@romseygeek romseygeek left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

since its default returns the current doc.

Is this something we should fix? I can see it being a source of bugs in future

The default returned the current doc ID of the approximation, contradicting
the method's own contract ("one plus the last doc ID of the run") and the
DocIdSetIterator#docIDRunEnd default. Since the run always contains the current
doc, the conservative single-doc default must be docID()+1. Align it and update
the ReqExclBulkScorer comment; the Math.max(upTo+1, ...) clamp stays as defense
against third-party TwoPhaseIterator implementations.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@jimczi jimczi merged commit 446dda9 into apache:main Jul 6, 2026
12 of 13 checks passed
@jimczi jimczi deleted the reqexcl-twophase-runend branch July 6, 2026 13:54
jimczi added a commit that referenced this pull request Jul 6, 2026
…#16307)

Use TwoPhaseIterator#docIDRunEnd() to skip a run of excluded docs for a
two-phase excluded clause, like the plain DocIdSetIterator branch already
does, instead of advancing one doc at a time. Clamp to at least upTo+1
since TwoPhaseIterator#docIDRunEnd() defaults to the current doc.

(cherry picked from commit 446dda9)
@dweiss

dweiss commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Hi @jimczi - this merge causes a test failure that looks like an off-by-one somewhere.
https://jenkins.thetaphi.de/job/Lucene-nightly-main-Linux/1781/console

gradlew :lucene:facet:test --tests "org.apache.lucene.facet.range.TestRangeFacetCounts.testRandomLongsMultiValued" -Ptests.asserts=true -Ptests.file.encoding=US-ASCII -Ptests.gui=true -Ptests.haltonfailure=false -Ptests.jvmargs= -Ptests.jvms=6 -Ptests.multiplier=3 -Ptests.nightly=true -Ptests.seed=4C2167479558CC44 -Ptests.vectorsize=default

This wasn't present (or was masked?) prior to this change.

@dweiss

dweiss commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

LLM says this, sorry I can't offer more help.

The mechanism

  The DV drill‑down goes SortedNumericDocValuesRangeQuery → (no skipper) DocValuesValueRangeIterator, a two‑phase iterator whose matches() must confirm that a value is in range. For
  dense ranges the bulk scorer is DenseConjunctionBulkScorer. In scoreWindow (DenseConjunctionBulkScorer.java:193‑208) it buckets clauses:

  int docIdRunEnd = w.docIDRunEnd();
  if (w.docID() > min || docIdRunEnd < minRunEndThreshold) {
    windowClauses.add(w);          // needs per-doc matches() confirmation
  } else {
    minDocIDRunEnd = Math.min(minDocIDRunEnd, docIdRunEnd);   // treated as FULLY MATCHING
  }
  ...
  if (acceptDocs == null && windowClauses.isEmpty()) {
    collector.collectRange(min, minDocIDRunEnd);   // collect WITHOUT calling matches()

  For a width‑1 window (max == min+1, i.e. a partition boundary), minRunEndThreshold = min(min+2048, max) = min+1. The clause sits at w.docID() == min, so:
  - Before #16307: docIDRunEnd() == min < min+1 → goes to windowClauses → matches() is confirmed. Correct.
  - After #16307: docIDRunEnd() == min+1 >= min+1 → goes to the "fully matching" bucket → collectRange(min, min+1) collects the boundary doc without confirming matches().

  That boundary doc has the field (so the approximation is positioned on it) but its value may be out of range — it gets counted anyway → the ±1. The default docIDRunEnd()
  optimistically assumes the current (unconfirmed) doc matches, which is fine for ReqExclBulkScorer (which calls it only after matches()), but DenseConjunctionBulkScorer consults it
  before confirmation.

@dweiss

dweiss commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

The LLM came up with a single-line fix and a bloated test I don't understand.
fix.patch

@romseygeek

Copy link
Copy Markdown
Contributor

Is this something we should fix? I can see it being a source of bugs in future

Anyone who wants to know next week's lottery numbers just email me now.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants