All findings · Lucene finding · LFV-1044 · reqexcl-bulkscorer-excluded-run-skip-lost-16669

MUST_NOT queries no longer skip long runs of excluded documents (ReqExclBulkScorer, since #16669)

When a MUST_NOT clause excludes a long run of doc IDs, +R -X now steps R through all its matches inside the run instead of jumping it: 10,001 iterator moves instead of 1,001 for 1,000 hits. Results stay correct; it came with #16669 and ships in 10.6.0.

Summary

Priority
Low, rank 362A released 10.6.0 core default-config slowdown of 8-16x with correct results that shows only when long excluded runs hold most of the required clause's matches, so it heads the Low performance rows above LFV-1057 and LFV-791.
Impact
minor
Since
#16669, merged 2026-09-24; first release 10.6.0introduced 2026-09-24
Affected
10.6.0 (released 2026-10-29) and main; 10.0.0 to 10.5.2 are not affected
Status
documented
Found by
a 10.5.2-vs-10.6 differential review, then an operation-count test and a bounded-exhaustive cost model

AI-assisted page: this finding was first signalled by code reading or testing and then checked with AI tools; the checks it went through, and what each found, are under Prior reports, history and checks. Not yet triaged by a human. Snapshot of 2026-11-06.

The finding in 30 seconds

The 4096-doc window loop that #16669 introduced runs the required clause over every window, including windows the exclusion covers completely.

Background: runs of doc IDs and MUST_NOT scoring

  1. Doc IDs in a segment follow indexing order or the index sort, so documents indexed together sit next to each other: "everything older than t" is often one long run of consecutive doc IDs.
  2. For +R -X where the required side (its cheapest MUST or FILTER clause) is estimated to match more than 0.3% of the segment, Lucene uses a bulk scorer, ReqExclBulkScorer, that collects R's matches and drops the documents X matches.
  3. An iterator can report a run: docIDRunEnd() says that every doc from the current one up to some end matches, so a caller can jump past all of them in one step. The default postings report runs for dense blocks.
A segment of 10,000 doc IDs; doc IDs 0 to 8,999 match the MUST_NOT clause grp:old, 9,000 to 9,999 are hits. Lucene 10.5.2 jumps the excluded run with docIDRunEnd and the required clause's iterator moves 1,001 times. Lucene 10.6.0 and main split the segment into 4096-doc windows and run the required clause over every window, also those the exclusion covers completely: 10,001 moves for the same 1,000 hits.
The minimal repro: a 10,000-doc segment whose first 9,000 doc IDs are excluded. 10.5.2 jumps the run and moves the required clause 1,001 times; 10.6.0 runs it through every 4096-doc window and moves it 10,001 times. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Index 10,000 documents into one segment; the first 9,000 (doc IDs 0 to 8,999) get grp:old, like the older documents of an append-ordered index.
  2. Search +R -grp:old, where R matches every document and counts how often its iterator moves. You expect 1,000 hits and about 1,001 moves: Lucene 10.3.0 to 10.5.2 jumps the excluded run with docIDRunEnd, and no release before 10.6.0 steps R through it.
  3. You get the right 1,000 hits, but R moved 10,001 times, once per document of the segment, on main and on 10.6.0.
  4. Why: since #16669 the scorer works in windows of up to 4,096 docs. It loads the exclusion into a bit mask and always runs R over the window, even when the mask accepts nothing, and R's default bulk scorer steps through all its matches before it checks the mask.
  5. With stock queries it is real work: at 1M documents with 99.9% in one excluded run, a phrase query as R reads 2,000,000 postings instead of 2,002, and indicative latency grows about 8x to 16x (term top-10: 0.30 to 2.5 ms).

Who is affected

Default settings. Any +R -X query whose required side matches more than 0.3% of a segment and whose MUST_NOT matches form long doc-ID runs: older documents of an append-ordered or time-sorted index, an index-sorted range, a flag on a contiguous block, a cached dense filter. Hits stay exact; the query costs about as much as R alone.

Mechanism, terms and numbers666 words

Since #16669 ("Use intoBitSet to speed up ReqExclBulkScorer with dense exclusions", merged 2026-09-24, first released in 10.6.0 on 2026-10-29), ReqExclBulkScorer scores +R -X in windows of at most 4,096 docs, each starting where the previous call to R's bulk scorer returned: it loads the exclusion of each window into a bit mask, flips it into an accept mask and always calls R's bulk scorer for the window (ReqExclBulkScorer.java:53-93, line 86 on main 645d4fe14a20), even when the mask accepts nothing. The default bulk scorer visits every match of R before it checks the mask (Weight.java:293-315). #16669 also removed the docIDRunEnd skip that 10.3.0 to 10.5.2 used to jump runs of excluded documents without moving R (#14976), and the two-phase variant added in the same cycle (#16307).

So when X excludes a long run of doc IDs (the older documents of an append-ordered or time-sorted segment, an index-sorted range, a flag on a contiguous block, a cached dense filter) and the required side costs more than 0.3% of maxDoc, +R -X costs about as much as R alone. Hits, counts and top-k were identical in every executed case; only the work changes. In the minimal repro (one 10,000-doc segment, run [0, 9000)), a counting match-all R moves 10,001 times on main and on the 10.6.0 release jars, against 1,001 on every release from 10.0.0 to 10.5.2. At 1M documents with 99.9% excluded, a PhraseQuery as R reads 2,000,000 postings instead of 2,002. The 10.6.0 release notes still say that ReqExclBulkScorer "now skips runs of excluded docs". A 12-line change that scores R only over the accepted part of each window never steps R inside an excluded run that crosses a window boundary (any run of 4,096 documents or more), as in 10.5.2, and passes the org.apache.lucene.search tests.

Where it happens (main 645d4fe14a20)

  1. Routing. BooleanScorerSupplier.java:203-207: with a MUST_NOT clause and a supplier cost (the cheapest MUST/FILTER clause's cost, computeCost) of at most 3L * maxDoc / 1000, the doc-at-a-time ReqExclScorer runs (not affected). Otherwise FILTER/MUST clauses without SHOULD get requiredBulkScorer() (:235-236) and :259-265 returns new ReqExclBulkScorer(positiveScorer, prohibitedScorer); several MUST_NOT clauses become one disjunction.
  2. The window loop. ReqExclBulkScorer.java:53-93: each window is [windowMin, min(max, windowMin + 4096)) (:60-61), where windowMin is what the previous req.score call returned; the exclusion is advanced to the window (:63-65), loaded with intoBitSet (:67-72), flipped into an accept mask and combined with acceptDocs (:73-84); then upTo = req.score(collector, windowBits, windowMin, windowMax); (:86) runs for every window. The class has no docIDRunEnd call left.
  3. Why a rejected window still costs. The default bulk scorer loops for (int doc = iterator.docID(); doc < max; doc = iterator.nextDoc()) and only then checks acceptDocs.get(doc) (Weight.java:293-301; the two-phase loop :303-315 also calls matches()). So a window that X covers completely costs one step per match of R in it.
  4. What it replaced. 10.5.2 ReqExclBulkScorer.java:51-78, the leap-frog loop of LUCENE-6940 (5.5.0/6.0.0): when a non-two-phase exclusion sits on upTo, upTo = Math.min(exclApproximation.docIDRunEnd(), max); (:60-62, from #14976, 10.3.0), a two-phase one is confirmed with matches() and passed one doc at a time (:63-65), else req.score(collector, acceptDocs, upTo, Math.min(exclDoc, max)) (:69), so R is only scored on the gaps between excluded documents.

The numbers

Minimal repro: 10,001 required-iterator moves on main and 10.6.0 against 1,001 on 10.0.0 to 10.5.2, 1,000 hits everywhere. At 1M documents with the run [0, 999000): 1,000,001 against 1,001 moves for five kinds of exclusion (postings run, index-sort range, cached bit set, two-phase doc-values range with a skip index, 2-term disjunction), count and top-10 alike; a stock PhraseQuery as R reads 2,000,000 postings against 2,002. Indicative wall clock on a shared host, 10.5.2 to main: term top-10 0.299 to 2.479 ms, 8-term OR top-10 2.330 to 18.506 ms, phrase count 0.711 to 11.368 ms. The claim rests on the counts, not the timings.

Exposure and likelihood245 words
  • Configuration. Default codec, IndexWriterConfig and IndexSearcher; assertions on or off; single thread.
  • Which queries. A BooleanQuery with MUST/FILTER clauses and MUST_NOT clauses that BooleanScorerSupplier sends to ReqExclBulkScorer (the required side costs more than 0.3% of maxDoc). By code reading the same wrapping also serves a pure SHOULD disjunction with MUST_NOT (not run).
  • Which data. Many of R's matches must lie inside long runs of excluded doc IDs. Doc IDs follow indexing order and index sorting, so time-based exclusions on append-ordered or time-sorted segments, index-sorted ranges, flags on contiguous blocks and cached dense filters produce such runs; all five kinds of exclusion tested regress the same way.
  • How bad. About the cost of R alone: at most linear in maxDoc per segment, no hang, no extra memory beyond a 4096-bit mask. Largest when R decodes or scores postings per document (term top-k, phrase, disjunction): about 8x to 16x in the indicative timings. Probably small for MatchAllDocsQuery or another dense constant-score R whose bulk scorer applies the mask word by word (code reading, not measured).
  • Not affected: required clauses at or below 0.3% of maxDoc (doc-at-a-time path). Not claimed: interleaved, non-run exclusions, the case #16669 targets: there the window path moves work from the exclusion to R (on #16669's dense shape 1,000,001 required and 3 exclusion moves on main, against 40,001 and 980,003 on 10.5.2), and #16669 reports large speed-ups.
  • Versions. 10.6.0 (released 2026-10-29) and main. All releases from 10.0.0 to 10.5.2 are fine.

Minimal repro

One JUnit method in org.apache.lucene.search: one 10,000-doc segment, grp:old on docs 0 to 8,999, and a match-all MUST clause that counts its iterator's moves. Deterministic, single thread, about 1.5 s; fails with assertions on and off.

TestReqExclSkipsExcludedRun.java (lucene/core, package org.apache.lucene.search, 1 test)123 lines
/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.apache.lucene.search;

import java.io.IOException;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.StringField;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.index.LeafReaderContext;
import org.apache.lucene.index.Term;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.tests.util.TestUtil;

public class TestReqExclSkipsExcludedRun extends LuceneTestCase {

  public void testRequiredClauseSkipsExcludedRun() throws IOException {
    try (Directory dir = new ByteBuffersDirectory();
        IndexWriter w =
            new IndexWriter(dir, new IndexWriterConfig().setCodec(TestUtil.getDefaultCodec()))) {
      for (int i = 0; i < 10_000; i++) {
        Document doc = new Document();
        if (i < 9_000) {
          doc.add(new StringField("grp", "old", Field.Store.NO));
        }
        w.addDocument(doc);
      }
      w.forceMerge(1);
      try (DirectoryReader reader = DirectoryReader.open(w)) {
        IndexSearcher searcher = new IndexSearcher(reader);
        searcher.setQueryCache(null);
        int[] moves = new int[1];
        Query all =
            new Query() {
              @Override
              public Weight createWeight(IndexSearcher s, ScoreMode scoreMode, float boost) {
                return new ConstantScoreWeight(this, boost) {
                  @Override
                  public ScorerSupplier scorerSupplier(LeafReaderContext ctx) {
                    DocIdSetIterator it = DocIdSetIterator.all(ctx.reader().maxDoc());
                    DocIdSetIterator counting =
                        new DocIdSetIterator() {
                          @Override
                          public int docID() {
                            return it.docID();
                          }

                          @Override
                          public int nextDoc() throws IOException {
                            moves[0]++;
                            return it.nextDoc();
                          }

                          @Override
                          public int advance(int target) throws IOException {
                            moves[0]++;
                            return it.advance(target);
                          }

                          @Override
                          public long cost() {
                            return it.cost();
                          }
                        };
                    return new DefaultScorerSupplier(
                        new ConstantScoreScorer(score(), scoreMode, counting));
                  }

                  @Override
                  public boolean isCacheable(LeafReaderContext ctx) {
                    return false;
                  }
                };
              }

              @Override
              public String toString(String field) {
                return "all";
              }

              @Override
              public void visit(QueryVisitor visitor) {}

              @Override
              public boolean equals(Object o) {
                return this == o;
              }

              @Override
              public int hashCode() {
                return System.identityHashCode(this);
              }
            };
        Query query =
            new BooleanQuery.Builder()
                .add(all, BooleanClause.Occur.MUST)
                .add(new TermQuery(new Term("grp", "old")), BooleanClause.Occur.MUST_NOT)
                .build();
        assertEquals(1_000, searcher.count(query));
        // Expected: the required iterator jumps the excluded run [0, 9000) (about 1,001 moves, as in 10.5.2); actual on main: about 10,001
        assertTrue("required iterator moved " + moves[0] + " times", moves[0] <= 2_000);
      }
    }
  }
}
Command
cp $REPRO/TestReqExclSkipsExcludedRun.java $LUCENE/lucene/core/src/test/org/apache/lucene/search/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.search.TestReqExclSkipsExcludedRun -Ptests.seed=1044B21F
# the count does not depend on the seed or on assertions
Expected
1,000 hits, and the required iterator moves about 1,001 times (10.0.0 to 10.5.2: 1,001).
Observed
1,000 hits, but "required iterator moved 10001 times" on main and 10.6.0, with assertions on and off.

All files of this repro: repro/LFV-1044/

More files, environment, full output286 words

TestReqExclSkipsExcludedRun (123 lines with the license header, one test method). The counting clause is a plain Query whose ConstantScoreWeight wraps a counting DocIdSetIterator in a ConstantScoreScorer and Weight.DefaultScorerSupplier; these are public but marked @lucene.internal, fine for an in-tree test (for a stock-query demonstration, use the PhraseQuery postings count above). Its Weight keeps count() at the default -1, because with a stock MatchAllDocsQuery IndexSearcher#count would shortcut through BooleanWeight#count and never score. The query cache is off. The test first checks the hit count (1,000, passes everywhere), then that the required iterator moved at most 2,000 times.

The <= 2_000 bound is a judgement; the observable fact is maxDoc + 1 moves (10,001) against hits + 1 (1,001). It is every document of the run only because R matches all documents; in general R steps through every one of its matches inside the run. The comment line in the test is longer than 100 columns; wrap it before adding the test to the tree. As a regression test it can go into the existing TestReqExclBulkScorer as one more method.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT) and the 10.6.0 release jars (commit 4ae946e5a9b): fail; 10.0.0 to 10.5.2: pass
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux aarch64 (nothing platform-specific; the count is deterministic)
Command (environment)
cp $REPRO/TestReqExclSkipsExcludedRun.java $LUCENE/lucene/core/src/test/org/apache/lucene/search/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.search.TestReqExclSkipsExcludedRun -Ptests.seed=1044B21F

Observed output

# main 645d4fe14a20, gradle, -Ptests.seed=1044B21F
TestReqExclSkipsExcludedRun > testRequiredClauseSkipsExcludedRun FAILED
    java.lang.AssertionError: required iterator moved 10001 times
        at org.apache.lucene.search.TestReqExclSkipsExcludedRun.testRequiredClauseSkipsExcludedRun(TestReqExclSkipsExcludedRun.java:119)
1 test completed, 1 failed

# standalone runner
main 645d4fe14a20, -ea / -da               failures=1  required iterator moved 10001 times
10.6.0 (Maven Central jars), -ea / -da     failures=1  required iterator moved 10001 times
10.5.2, -da                                failures=0  (1,001 moves)
main + recommended fix, -ea                failures=0

Why it looks unintended

  • 10.6.0's release notes still say "ReqExclBulkScorer now skips runs of excluded docs" (#16307), next to #16669.
  • #16669 deleted the old loop holding the skip (#14976) to simplify, after benchmarks with interleaved and sparse exclusions only; runs were never measured.
  • A fix can keep #16669's window path (its dense shape: 988,289 vs 1,000,001 required moves with fix C).
The full argument351 words
  • The same release notes say the opposite. The 10.6.0 section of lucene/CHANGES.txt lists "GITHUB#16669: Use intoBitSet to speed up ReqExclBulkScorer with dense exclusions." (:687 on main) and, earlier in the same Optimizations section, "GITHUB#16307: ReqExclBulkScorer now skips runs of excluded docs via TwoPhaseIterator#docIDRunEnd for two-phase excluded clauses (e.g. a doc-values not-equals filter), instead of advancing one doc at a time." (:632-634; :183-185 and :238 in the 10.6.0 release commit). The shipped class has no docIDRunEnd call (0 references in the bytecode of main and of the RC1 core jar, 1 in 10.5.2).
  • The skip was built on purpose. #14976 added it (10.3.0, CHANGES.txt:1699: "Utilize docIdRunEnd on ReqExclBulkScorer."); #16142 made it cheap for several MUST_NOT clauses (10.5.0, :1158: "Optimize disjunction #docIDRunEnd to skip runs of excluded documents."; it changed DisjunctionDISIApproximation, and its PR body says "ReqExclBulkScorer uses the prohibited scorer's docIDRunEnd() to skip runs of excluded documents"); #16307 extended it to two-phase clauses in the 10.6 cycle.
  • #16669 removed the old loop on purpose, but never measured runs. Its issue #16668 notes that "docIDRunEnd() only helps when the iterator can expose a consecutive run of matching document IDs". Its first commit (b3094fab914) kept the old leap-frog loop, with both docIDRunEnd branches, as a sparseScore path used only when excl.cost() < req.cost() >>> 7, so any exclusion costing at least 1/128 of R (the minimal repro's 9,000 against 10,000, for example) already took the window path. The author then wrote "I'm exploring whether we can remove the sparse path and use the dense path universally, which would simplify the implementation", and commit e0c955a58c7 ("delete spare path") removed it. The benchmarks behind that (round-robin interleaved MUST_NOT terms, luceneutil ReqExclSparse/ReqExclDense, not-equals ranges) contain no run-shaped exclusion, and the PR body, its 9 comments and 4 review comments do not mention long runs or the skip.
  • Why no test caught it. #14976 and #16307 added no test; #16669's tests check results, not cost.
  • Precedent. Upstream fixed comparable MUST_NOT slowdowns (#16049, fixed by #16142; #16702, addressed by #16715) and the same window-vs-leap-frog trade-off in DenseConjunctionBulkScorer (#16259).
The devil's-advocate check157 words

The strongest case for "intended": Lucene documents matching semantics, not cost; #16669 chose the window path deliberately and deleted the leap-frog path on purpose, to simplify; and +R -X now costs about what R alone costs, which a maintainer may accept. The cross-model intended-lens review rated this case moderate, the intended-behaviour checker weak to moderate, and upstream may label the report an enhancement, as it did #16049.

Why it does not settle it: the release notes of the very release that ships the change advertise the skip; nothing in #16669 shows the run case was measured or the loss of the skip weighed; and, as far as measured, a fix need not undo #16669, because skipping the required clause where the mask rejects every document keeps the window path for interleaved exclusions (operation counts only, no end-to-end benchmark). Verdict: an unintended performance regression against 10.3.0 to 10.5.2, results correct; a maintainer may still label it an enhancement.

Suggested fix

Recommended C: score R only over the accepted part of each window (validated)

Score R only from the first to the last document each window's mask accepts, and skip R when the mask accepts none (+12/-1 in ReqExclBulkScorer.score). R is then never stepped inside a run that crosses a window boundary, as in 10.5.2. The repro and the 2,340 tests of org.apache.lucene.search pass; measured by operation counts, not benchmarked end to end.

      // Score the required clause only from the first to the last doc the mask accepts, and not at
      // all when the mask accepts nothing, e.g. when a run of excluded docs covers the window.
      int first = windowMask.nextSetBit(0, windowLength);
      if (first == DocIdSetIterator.NO_MORE_DOCS) {
        upTo = windowMax;
        continue;
      }
      int last = windowMask.prevSetBit(windowLength - 1);
      upTo = req.score(collector, windowBits, windowMin + first, windowMin + last + 1);
      // No doc after the last accepted one can match: the next window starts at windowMax at the
      // earliest, where the exclusion iterator is positioned after intoBitSet.
      upTo = Math.max(upTo, windowMax);
All options and trade-offs4 options ยท 908 words

Three variants were written against 645d4fe14a20 and measured on the same shapes (required-iterator moves; hits identical everywhere):

shape10.5.2mainAC (recommended)A+C
minimal repro, 10,000 docs, run [0, 9000)1,00110,001passpasspass
1M docs, run [0, 999000), 5 exclusion kinds, count and top-101,0011,000,0011,0011,0011,001
1M docs, PhraseQuery R, postings reads2,0022,000,0002,0022,0022,002
100k docs, run [500, 99000) (starts inside a window)1,502100,0015,0981,5021,502
100k docs, runs of 4,000, 1 kept doc between51100,00153,1414,05052,013
100k docs, runs of 2,000, 1 kept doc between101100,00169,50552,07568,017
100k docs, alternating blocks of 10050,500100,00199,81798,71398,713
#16669's dense shape, 3 interleaved MUST_NOT terms, 1M docs40,0011,000,001999,272988,289988,337

On #16669's dense shape 10.5.2 pays on the exclusion side instead (980,003 exclusion moves).

C: score R only over the accepted part of each window (validated) (recommended)

ReqExclBulkScorer.java, replaces line 86; the 12 added lines of fix-optC.diff, verbatim:

      // Score the required clause only from the first to the last doc the mask accepts, and not at
      // all when the mask accepts nothing, e.g. when a run of excluded docs covers the window.
      int first = windowMask.nextSetBit(0, windowLength);
      if (first == DocIdSetIterator.NO_MORE_DOCS) {
        upTo = windowMax;
        continue;
      }
      int last = windowMask.prevSetBit(windowLength - 1);
      upTo = req.score(collector, windowBits, windowMin + first, windowMin + last + 1);
      // No doc after the last accepted one can match: the next window starts at windowMax at the
      // earliest, where the exclusion iterator is positioned after intoBitSet.
      upTo = Math.max(upTo, windowMax);

Trade-offs

  • General: works for every exclusion, whether or not its iterator reports runs, and calls no docIDRunEnd. R is never stepped inside an excluded run that crosses a window boundary (any run of 4,096 documents or more): windows inside the run are skipped and the run's part of its first and last window is trimmed off.
  • upTo must not fall below windowMax: intoBitSet has moved the exclusion iterator there, so a window starting earlier would miss excluded documents.
  • Leaves: a run strictly inside a window with accepted documents on both sides is still stepped (at most that window's 4,096 documents); the exclusion is still loaded into a mask for every window inside a run, as on main; if R runs out of matches inside a long run, masks are loaded until the next window with an accepted document (code reading, not measured); the unreleased two-phase skip of #16307 stays gone; measured by operation counts only, not benchmarked end to end.

What the checks validated

  • gradle in a scratch worktree at 645d4fe14a20, -Ptests.seed=1044B21F: the minimal test fails without the fix and passes with it; with it, the nine closest suites pass (TestReqExclBulkScorer, TestBooleanQuery, TestBoolean2, TestBooleanScorer, TestBooleanMinShouldMatch, TestBooleanScorerSupplier, TestBooleanOr, TestBooleanRewrites and the new test: 138 tests), and so does the whole org.apache.lucene.search package (215 classes, 2,340 tests, 49 skipped, 0 failures).
  • 1,152 count identities, 0 mismatches; a randomized partition test (run, interleaved, two-phase doc-values and disjunction exclusions, MUST and FILTER, 10% deletions, random sub-ranges against brute force), 3 seeds, pass; independent verifiers' run tests pass.
  • A verifier's two-phase test still fails with C (512 matches() calls where it asserts 112; that run was launched expecting a pass, so the runner marks it unexpected): C does not restore #16307's two-phase skip.

A: restore the docIDRunEnd skip at window starts (validated)

Insert after the exclusion is advanced to windowMin (:63-65), +12:

      if (exclApproximation.docID() == windowMin) {
        int runEnd =
            exclTwoPhase == null ? exclApproximation.docIDRunEnd() : exclTwoPhase.docIDRunEnd();
        if (runEnd > windowMin) {
          upTo = (int) Math.min((long) max, (long) runEnd);
          continue;
        }
      }

Trade-offs

Brings back the released mechanism: a run that reaches a window start is jumped in docIDRunEnd steps without loading masks, and a two-phase exclusion with a proven run is skipped without matches() (#16307); when no run is proven, the existing intoBitSet path still calls matches() per candidate. Limits: a run that starts inside a window is stepped up to the next window boundary (5,098 against 1,502 moves above); only exclusions whose iterator reports runs benefit.

What the checks validated

The minimal test passes with assertions on and off; 99 upstream tests of TestReqExclBulkScorer, TestBooleanQuery, TestBoolean2, TestBooleanScorer, TestBooleanMinShouldMatch and TestBooleanScorerSupplier, 30 iterations of TestReqExclBulkScorer, 1,152 count identities and 576 partitioned checks pass.

A+C: both (validated)

+24/-1. C's trimming plus A's run jump and two-phase skip.

Trade-offs

Equal to C on most shapes and restores the two-phase skip, but worse than C on runs of 2,000 and 4,000 (68,017 and 52,013 against 52,075 and 4,050 moves), because A's jumps end at run ends and move the window starts onto the kept documents.

What the checks validated

The minimal test, the independent verifiers' run tests including the two-phase one, the partition test (2 seeds), a mid-window-run test and the 1,152 count identities pass. Through gradle at 645d4fe14a20 it passes the minimal test and the same nine suites (138 tests).

D: per-window choice between mask and leap-frog (direction only) (not run)

To restore 10.5.2's cost for runs inside a window too, a window whose mask has only a few accepted stretches could score R once per stretch, as #16259 brought leap-frog back for sparse windows in DenseConjunctionBulkScorer.

Trade-offs

More code and a heuristic threshold.

What the checks validated

Not written or measured.

Downstream note

Not security-sensitive: results stay exact and the extra work is bounded by the cost of the required clause alone, so a service that lets users compose +R -X queries sees at most the CPU cost of R, which those users could already run.

How it was found

A review of what changed between 10.5.2 and the 10.6 release candidate flagged #16669's rewrite of the MUST_NOT bulk scorer. A timing-free count of the required iterator's moves turned it into numbers (1,001 against 1,000,001 at 1M documents), and a bounded-exhaustive cost and correctness model of the old, new and fixed loops backed it.

The method in full115 words

Method: non-formal lead, semi-formal backing. The hunt's "10.5.2 vs 10.6.0 RC1" differential lens read the #16669 diff and compared query costs on 10.5.2, RC1 and main with identical hits; MUST_NOT queries with a contiguous excluded range stood out. A skeptic re-run replaced timings with a count of the required iterator's moves (1,001 on 10.2.2 to 10.5.2 against 1,000,001 on RC1 and main at 1M documents).

The hunt then reduced it to one JUnit, measured five kinds of exclusion and stock PhraseQuery/TermQuery postings reads, and built a bounded-exhaustive model of the 10.5.2, main and fixed loops: 790,324 cases for cost and correctness plus 16.4M partitioned and chunked re-entrant correctness checks, with two mutants caught.

Prior reports, history and checks

Prior reports

No prior report found (GitHub issues and PRs including the JIRA archive, the changed files of all open PRs, the 10.6.0 release threads on dev@, downstream projects, luceneutil, the web; re-checked 2026-10-30). Related: #16669 introduced it; #14976, #16142 and #16307 added the skip it removed.

Searches5 searches

Related, not reports: #16669 and its issue #16668 (the introducing change); #14806/#14976, #16142 and #16307 (they added the skip); #16049 (interleaved MUST_NOT cost, fixed by #16142); #16702/#16715 (lead cost; #16715 sets the 0.3% routing threshold); #16259 (the same window-vs-leap-frog trade-off in DenseConjunctionBulkScorer); #12194 (an old long-runs API draft). Limits: GitHub search does not index diffs; private channels cannot be searched.

What was searched, when, and what turned up
WhereWhenHowFound
Search tool (GitHub issues/PRs, commits, downstream repositories, dev@, java-user@, issues@, commits@, JIRA archive)2026-10-29three runs: identifiers, symptoms, new phrasing ("excluded docs run skip lost", "MUST_NOT 10.6 regression", "docIDRunEnd no longer used")349, 774 and 358 hits, none a report
GitHub issues and PRs (apache/lucene, Elasticsearch, OpenSearch, Solr, luceneutil)2026-10-2982 searches, incl. everything updated in the last 48 hoursno report
Open and recent apache/lucene PRs2026-10-29changed files of all 409 open PRs and of the 139 closed PRs created since 2026-09-10only #16669 (and the stale 2023 #12194) touch ReqExclBulkScorer.java
#16669 and #16668; dev@ 10.6.0 RC1 vote and release threads, java-user@; luceneutil nightly annotations2026-10-29read in fullno mention of runs or of dropping the skip
GitHub, re-check2026-10-30ReqExclBulkScorer, docIDRunEnd, MUST_NOT PRs by update date; all issues updated since 2026-10-29 by title; the file's upstream commit history on main and branch_10_6no report; not fixed (last commit still #16669)

History

Introduced by #16669 (c5fa32ce49f, merged 2026-09-24) and its same-day branch_10x backport (inherited by branch_10_6); first released in 10.6.0 (2026-10-29). Bisected: the parent commit passes. All twelve releases 10.0.0 to 10.5.2 and 9.12.3 are clean (1,001 moves).

Versions tested7 rows

Introduced by c5fa32ce49f, apache/lucene#16669 "Use intoBitSet to speed up ReqExclBulkScorer with dense exclusions" (by kkewwei for their issue #16668; reviewed and merged by romseygeek, 2026-09-24), backported to branch_10x as 92ce3eaf4bf the same day; branch_10_6 inherited it when it was cut from branch_10x on 2026-10-14. It replaced the leap-frog loop of LUCENE-6940 and removed both docIDRunEnd branches: the DocIdSetIterator one from #14976 (10.3.0), the released behaviour that regresses, and the two-phase one from #16307 (same 10.6 cycle, never released). Putting back only the pre-#16669 ReqExclBulkScorer.java makes main and RC1 pass. 10.0 to 10.2 kept R out of excluded runs but stepped the excluded iterator once per excluded document, so the fair cost baseline is 10.3.0 to 10.5.2.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affected10,001 moves (gradle and the standalone runner, assertions on and off)
10.6.0 (Maven Central jars, 4ae946e5a9b) affected10,001 moves (-ea and -da)
10.6.0 RC1 (staged jars and a build of 4ae946e5a9b) affected10,001 moves (-ea and -da)
c5fa32ce49f (#16669) vs its parent c80c9f53674 bisectthe commit fails (10,001), the parent passes (1,001)
10.3.0 to 10.5.2 (7 releases) not affected1,001 moves each (the docIDRunEnd skip)
10.0.0 to 10.2.2 (5 releases) not affected1,001 moves each (leap-frog; the excluded iterator steps per excluded doc)
9.12.3 (a port of the counting clause) not affected1,001 moves

Independent checks

An independent reproducer, reviewers from another model family (OpenAI GPT-5.6), an intended-behaviour devil's advocate and an adjudication each ran their own tests or the minimal one; all call it real and performance only, and agree on the root cause.

Checks and verdicts392 words

Every check counts operations rather than timing them; the timings in this doc are indicative only. The minimal test failed on main with 10,001 moves under eight seeds (the finder, the history sweep, an intended-behaviour verifier, the adjudication, the registration check, the audit, the no-noise gate and this doc), and under a ninth in the independent fact check of this doc.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidate100,000-doc test: 100,001 moves on main, 1,001 on 10.0.0, 10.2.2, 10.3.2 and 10.5.2; five exclusion kinds at 1M docs; stock PhraseQuery 2,000,000 vs 2,002 postings reads; bounded-exhaustive cost and correctness model; fix A with 99 upstream tests.
cross-model review, refute lens real, highOpenAI GPT-5.6. Its own public-API test (20,000 docs, 18,000-doc excluded prefix) moves 20,001 times for 2,000 hits on main; its alternating control passes; 10.5.2 and fix A pass.
cross-model review, intended lens real, highOpenAI GPT-5.6. Its own test (FILTER MatchAllDocsQuery, 11,700 of 12,000 docs excluded) moves 12,001 times on main; rates the by-design case moderate but finds nothing that documents giving up the skip.
Independent reproducer realStock +f:a -x:y: 50,001 vs 5,001 postings reads. A run that starts inside a window, [2000, 29000) of 30,000 docs: 30,001 moves vs 3,002 on 9.12.3 to 10.5.2; fix A passes but restores only part (5,098).
Cross-model review, root-cause lens real, highOpenAI GPT-5.6, re-run on 645d4fe14a20: 8,193 moves for 1,192 hits; a two-phase exclusion makes 512 vs 112 matches() calls; fix A correct over 576 partitioned checks; flagged A's window-start limit.
Intended-behaviour devil's advocate real, high0 docIDRunEnd references in the bytecode of main and RC1 (1 in 10.5.2); no cost promise in the javadocs; no run-shaped benchmark or review comment in #16669; the stale CHANGES entry.
adjudication confirmed, highRe-ran the minimal test: main fails with -ea and -da (10,001 moves), 10.5.2 passes. Impact class minor (performance only).
audit and no-noise gate keep; claim-lowRe-ran the minimal test with new seeds; the claim is the one minimal test, nothing left to cut.
this doc reproduced; fix validatedThe minimal test through gradle on a clean 645d4fe14a20 worktree (fails) and on the 10.6.0 Maven Central jars (fails, -ea and -da); fix C written and validated; A and A+C re-measured on the same shapes.

Diagram file

The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.

Sources and provenance

Finding record, Lucene and upstream sources, repro files and diagram notes1,537 words

Finding record

LFV-1044, snapshot of 2026-11-06

Registry title
Since #16669 (10.6.0 and main), ReqExclBulkScorer no longer skips long runs of excluded doc IDs: for +R -X it scores the required clause in every 4096-doc window, even windows X fully excludes, so R's iterator steps through all of its matches inside X's runs (10,001 moves for 1,000 hits in a 10,000-doc segment, where 10.0.0-10.5.2 need 1,001; performance regression, results correct)
Area
search / BooleanQuery MUST_NOT bulk scoring (ReqExclBulkScorer)
Priority
Low, rank 362: A released 10.6.0 core default-config slowdown of 8-16x with correct results that shows only when long excluded runs hold most of the required clause's matches, so it heads the Low performance rows above LFV-1057 and LFV-791.
Impact class
minor
Impact note
Performance regression only, with the default codec, IndexWriterConfig and IndexSearcher, on main and 10.6.0 (released 2026-10-29): a BooleanQuery with a MUST/FILTER clause R that costs more than 0.3% of maxDoc and a MUST_NOT clause X whose matches form long doc-ID runs (for example excluding the older documents of an append-ordered or time-sorted segment, a flag set on a contiguous block of documents, a cached dense filter) now steps R through all of its matches inside X's runs instead of jumping each run in one step, so it costs about as much as R alone; hits, counts and top-k are identical, the extra work is at most linear in maxDoc per segment, and indicative wall-clock on a shared host at 1M docs with 99.9% of the docs in one excluded run went from 0.30 to 2.5 ms (term query, top-10), 2.3 to 18.5 ms (8-term OR, top-10) and 0.71 to 11.4 ms (phrase, count) between 10.5.2 and main.
Summary
You search +R -grp:old, where R matches every document and grp:old matches one run of consecutive doc IDs covering 9,000 of the 10,000 documents of a segment. You expect Lucene to jump over the excluded run, as 10.3.0 to 10.5.2 do in one docIDRunEnd step (on every release from 10.0.0 to 10.5.2 the required clause's iterator moves 1,001 times for the 1,000 hits). Instead, since #16669 ReqExclBulkScorer loads the exclusion into a bit mask per 4096-doc window and always runs the required clause over every window, even windows the exclusion covers completely, so the iterator moves 10,001 times; the 1,000 hits are still right, only the work now grows with the length of the excluded run.
Introduced
c5fa32ce49f6 (apache/lucene#16669 'Use intoBitSet to speed up ReqExclBulkScorer with dense exclusions', by kkewwei for their issue #16668; reviewed and merged by romseygeek on main 2026-09-24; branch_10_6 backport 92ce3eaf4bf the same day) [exec: commit-level bracket with the minimal repro, the parent c80c9f53674 passes (1,001 required-iterator moves) and c5fa32ce49f fails (10,001) with -ea and -da; putting back only the pre-#16669 ReqExclBulkScorer.java makes main 645d4fe14a20 and 10.6.0 RC1 pass (1,001); read: blame gives 27 of the 41 lines of score() to c5fa32ce49f, the rest is the LUCENE-6940 method frame, and the file is byte-identical on c5fa32ce49f, main 645d4fe14a20 and RC1]. It replaced the leap-frog loop (LUCENE-6940, 5.5.0/6.0.0) that called the required bulk scorer only on the gaps between excluded docs, and removed both docIDRunEnd branches: the DocIdSetIterator one from #14976 (10.3.0; the released behaviour that regresses) and the two-phase one from #16307 (added in the same 10.6 cycle, never released). (2026-09-24 (author date of c5fa32ce49f, 2026-09-24T08:30:33Z, equal to the PR merge time; the branch_10_6 backport 92ce3eaf4bf has the same author date and was committed at 08:33:25Z the same day))
First affected version
10.6.0, released 2026-10-29 (the first affected release: 10.6.0 RC1 4ae946e5a9b reproduces it [exec, official staged jars and a build of the RC1 commit] and is the commit released as 10.6.0; on the main line 11.0.0, unreleased; the last unaffected release is 10.5.2)
Affected versions
[exec] main 645d4fe14a20 (minimal repro, -ea and -da; seeds DEADBEEF, 5EED4351, 7A11E5ED, A11CE5ED and 0D055E17 each give 10,001 required-iterator moves for 1,000 hits), the introducing commit c5fa32ce49f (-ea and -da) and 10.6.0 RC1 4ae946e5a9b (official staged core, codecs and test-framework jars, SHA-512 checked, and a build of the RC1 commit; -ea and -da); [read] upstream main head 70a428b5ba9, branch_11x head 203699279f5 and branch_10_6 head 6dde6c73bd1 (the last commit on ReqExclBulkScorer.java is still #16669 on all three, checked 2026-10-29). Not affected [exec, 1,001 moves each]: all 12 releases 10.0.0 to 10.5.2 (-da; 10.5.2 also -ea), 9.12.3 (with a two-hunk 9.x API port of the measuring clause only, -ea and -da) and the parent commit c80c9f53674; 9.0.0-9.12.3 have a byte-identical ReqExclBulkScorer.java [read]. 10.0-10.2 also kept R out of excluded runs but stepped the excluded iterator once per excluded doc, so the fair cost baseline is 10.3.0-10.5.2.
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that agreed, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
non-formal; also reached by exhaustive check
Upstream and prior reports
none found (2026-10-29, read-only; the prior-report record verdict new: first pass, refresh, follow-up-step check): three scripted prior-report searches on identifier, symptom and new phrasing (62 GitHub issue/PR, 9 commit, 6 downstream, 60 mailing-list and 12 JIRA-archive queries; 349, 774 and 358 distinct hits, none a report); about 50 own GitHub searches in the first pass and 34 more in the refresh over apache/lucene, Elasticsearch, OpenSearch, Solr and luceneutil, including everything updated in the last 48 h; the changed files of all 409 open apache/lucene PRs (only the stale 2023 #12194 touches ReqExclBulkScorer.java) and of the 139 closed PRs created since 2026-09-10 (only #16669); the 70 issues and PRs updated since 2026-10-27, read by title; the dev@ '[VOTE] Release Lucene 10.6.0 RC1', feature-freeze and 'Lucene 10.6 as final minor 10.x release' threads and java-user@ October; luceneutil's nightly-benchmark annotations (no entry for #16669); the JIRA archive; and web searches. Context, not reports: #16669 and its issue #16668 (the introducing change; benchmarks cover interleaved and sparse exclusions only, and nobody mentions long runs or the dropped docIDRunEnd skip), #14806/#14976, #16142 and #16307 (they added the run skip), #16049 (other root, fixed by #16142), #16702/#16715 (lead cost, other root; #16715 narrows the trigger to required clauses above 0.3% of maxDoc), #16259 (the same window-vs-leap-frog trade-off fixed in DenseConjunctionBulkScorer), #16849, #16568, #12194, #11915. Not fixed upstream: the last commit on ReqExclBulkScorer.java is still #16669 on main (head 70a428b5ba9), branch_10_6 (head 6dde6c73bd1) and branch_11x, and no fix exists, so there is no fix commit whose author date could predate this report. Tracking issue #16937 does not mention it.
Lucene commit tested
main 645d4fe14a20 (gradle in a clean worktree and a standalone runner, assertions on and off); the 10.6.0 release jars from Maven Central (commit 4ae946e5a9b) and the 10.6.0 RC1 staged jars; the introducing commit c5fa32ce49f and its parent c80c9f53674; the releases 10.0.0 to 10.5.2 and 9.12.3 (a port of the counting clause). OpenJDK 25.0.3, Linux aarch64.

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/search/ReqExclBulkScorer.java:53-93 (at 645d4fe14a20) (score: fixed 4096-doc windows, intoBitSet of the exclusion, flip, then req.score(collector, windowBits, windowMin, windowMax) at line 86 for every window; no docIDRunEnd call in the class)
  • lucene/core/src/java/org/apache/lucene/search/BooleanScorerSupplier.java:203-207 (at 645d4fe14a20) (MUST_NOT queries whose required cost is at most 0.3% of maxDoc go to ReqExclScorer; all others reach ReqExclBulkScorer)
  • lucene/core/src/java/org/apache/lucene/search/Weight.java:293-315 (at 645d4fe14a20) (DefaultBulkScorer.scoreIterator/scoreTwoPhaseIterator step the iterator over every doc below max and check acceptDocs per doc)
  • lucene/core/src/java/org/apache/lucene/search/DocIdSetIterator.java:245-262 (at 645d4fe14a20) (docIDRunEnd contract) and TwoPhaseIterator.java:113-132 (TwoPhaseIterator#docIDRunEnd, callable before matches())
  • lucene/CHANGES.txt:632-634 (at 645d4fe14a20) (10.6.0: 'GITHUB#16307: ReqExclBulkScorer now skips runs of excluded docs via TwoPhaseIterator#docIDRunEnd ...'), :687 (10.6.0: GITHUB#16669) and :245 (the #16307 entry again in the 11.0.0 section)
The finding in 30 seconds
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/BooleanScorerSupplier.java:203-207, :235-236, :259-265
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/ReqExclBulkScorer.java:53-93 (line 86; no docIDRunEnd call)
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/Weight.java:293-315 (DefaultBulkScorer.scoreIterator, scoreTwoPhaseIterator)
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/DocIdSetIterator.java:245-263, TwoPhaseIterator.java:112-132; lucene/core/src/java/org/apache/lucene/codecs/lucene104/Lucene104PostingsReader.java:1100-1125
  • a Lucene clone releases/lucene/10.5.2 lucene/core/src/java/org/apache/lucene/search/ReqExclBulkScorer.java:51-78
Who is affected
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/BooleanScorerSupplier.java:203-207, :210-232, :259-265
  • Maven Central lucene-core maven-metadata.xml (release 10.6.0, lastUpdated 20261029224919) and GitHub tag releases/lucene/10.6.0 -> 4ae946e5a9b (tagged 2026-10-29), read 2026-10-30
Why it looks unintended
  • a Lucene main checkout (645d4fe14a20) lucene/CHANGES.txt:245, :632-634, :687, :1158, :1699; a Lucene clone 4ae946e5a9b lucene/CHANGES.txt:183-185, :238
  • GitHub apache/lucene PR #16669, read 2026-10-30: commit b3094fab914 (ReqExclBulkScorer.java: sparseScore with both docIDRunEnd branches; shouldUseBitSet = excl.cost() >= (req.cost() >>> 7)), commit e0c955a58c7 'delete spare path', kkewwei's comments of 2026-09-17 ('I'm exploring whether we can remove the `sparse` path ...', 'I remove the `sparse` path ...')
  • GitHub apache/lucene PR #16142 body and changed files (DisjunctionDISIApproximation.java, MustNotBooleanQueryBenchmark.java, CHANGES.txt, a test), read 2026-10-30
History
  • GitHub tag releases/lucene/10.6.0 -> 4ae946e5a9b (tagged 2026-10-29), read 2026-10-30
  • a Lucene clone: git merge-base --is-ancestor 92ce3eaf4bf origin/branch_10x (yes); merge-base of origin/branch_10x and origin/branch_10_6 = 3e416dce059 (2026-10-14); release tags 10.0.0 to 10.5.2

Other statements trace to the hunt's working files (models, logs, reviews and briefs), which are not published.

Repro files

About the diagrams

A small diagram, made in parallel with this doc from the executed runs. Its fix part shows fix A (restore docIDRunEnd at window starts) with its limit; this doc recommends fix C. Both are validated.

Built 2026-11-06 from the hunt's record of this finding and its doc file. Lucene main under test in this hunt: ea57360912ed. Every listed finding was re-run on Lucene main e1faeba08bde on 2026-11-02.