All findings · Lucene finding · LFV-139 · reqexcl-windowbits-applymask-offset-overflow

Dense FILTER/MUST + MUST_NOT queries silently drop every match in the last 4096-doc window of a segment near MAX_DOCS (int overflow in ReqExclBulkScorer)

On a segment of more than 2,147,479,552 documents, a MUST_NOT query whose dense FILTER/MUST side is bulk-scored in 4096-doc windows silently loses the last doc IDs: +*:* -b:y on a full MAX_DOCS segment counts 2,147,479,551 instead of 2,147,483,518. An int overflow in 10.6 code (#16669), released in 10.6.0 on 2026-10-29; a one-line fix.

Summary

Priority
Low, rank 970A 10.6.0 regression that silently drops matches only on segments within 3,967 docs of MAX_DOCS.
Impact
silent wrong results
Since
#16669, 2026-09-24 (10.6.0, released 2026-10-29)introduced 2026-09-24
Affected
main, branch_10x and branch_10_6, including the 10.6.0 RC1 revision, released as 10.6.0 on 2026-10-29; no earlier release (10.5.2 is correct)
Status
documented
Found by
code reading of the Bits#applyMask contract, then a Z3 bit-vector check

AI-assisted page: this finding was first signalled by a semi-formal check (an exact check of the real jar, or a modelling hypothesis that a tool then witnessed) 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 bug is one line: WindowBits.applyMask adds the window start and 4096 in an int. For a window that starts at 2,147,479,552 or later, the sum passes 2^31 and wraps negative, so the method treats the window as empty and clears it.

Background: how a MUST_NOT query is scored in windows

  1. Doc IDs in a segment run from 0 to maxDoc - 1. One index holds at most IndexWriter.MAX_DOCS = 2,147,483,519 documents, just below 2^31 = 2,147,483,648, the first number a Java int cannot hold.
  2. For required -excluded with FILTER/MUST clauses (no SHOULD clause) that match more than 0.3% of the segment, ReqExclBulkScorer works in windows of 4096 doc IDs. For each window it marks the excluded documents, flips that into an "allowed" mask, and passes the mask to the required clause's scorer as its acceptDocs.
  3. The required clause's scorer (for *:*, or a constant-score filter such as a term or range) collects its own matches of a 4096-doc window into a bit set and calls acceptDocs.applyMask(bits, windowStart) to drop the documents that are not allowed.
The last 4096-doc window of a segment of IndexWriter.MAX_DOCS = 2,147,483,519 documents starts at doc 2,147,479,552 (2^31 - 4096). WindowBits.applyMask computes 2,147,479,552 + 4,096 = 2,147,483,648 in int, which wraps to -2,147,483,648, so the window looks empty and every bit is cleared. +*:* -b:y counts 2,147,479,551 instead of 2,147,483,518: the 3,967 documents of the last window are missing. Fix shown (executed): (int) Math.min(NO_MORE_DOCS, (long) offset + bitSet.length()), the long-widening option.
The last 4096-doc window of a segment at IndexWriter.MAX_DOCS: its start, 2,147,479,552, plus 4096 overflows the int, so the mask clears the window and its 3,967 matches are lost. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Index IndexWriter.MAX_DOCS = 2,147,483,519 documents into one segment with default settings; one of them has b:y.
  2. Count +*:* -b:y (MatchAllDocsQuery as FILTER, TermQuery b:y as MUST_NOT) with a plain IndexSearcher (query cache off). You expect 2,147,483,518: every document but one.
  3. You get 2,147,479,551 and no error: all 3,967 documents from doc 2,147,479,552 to the end are missing.
  4. Why: Lucene scores this query in windows of 4096 doc IDs and masks each window with the documents that are not excluded. The mask code adds the window start and 4096 in an int: 2,147,479,552 + 4,096 = 2,147,483,648 = 2^31, which wraps to -2,147,483,648. The window then looks empty, and all its matches are cleared.
  5. Only code since #16669 has it: 10.6.0 (released 2026-10-29 from the RC1 revision) and main. 10.5.2 returns the right count. A one-method test over the last 100 doc IDs of such a segment shows it in a second: 0 hits instead of 99.

Who is affected

Only an index with one segment of more than 2,147,479,552 documents, queried with MUST_NOT plus FILTER/MUST clauses that Lucene routes to the windowed bulk scorer (cost above 0.3% of the segment, no SHOULD clause) and bulk-scores with a constant-score scorer, such as *:*. Default settings. Counts, full collection and exact-count top-k are silently wrong. Affected: 10.6.0 (released 2026-10-29) and main.

Mechanism, terms and numbers735 words

Since #16669 (merged 2026-09-24, released in 10.6.0), ReqExclBulkScorer scores required -excluded in windows of 4096 doc IDs and hands the required clause's bulk scorer a WindowBits mask of the documents that are not excluded. WindowBits.applyMask computes the end of the caller's 4096-bit set as Math.min(DocIdSetIterator.NO_MORE_DOCS, offset + bitSet.length()) in int (ReqExclBulkScorer.java:125 on main 645d4fe14a20). For a window that starts at 2,147,479,552 (2^31 - 4096) or later the sum wraps negative, the method sees an empty range and clears every bit. On a segment with more than 2,147,479,552 documents, a BooleanQuery that BooleanScorerSupplier routes to ReqExclBulkScorer (a MUST_NOT clause, a supplier cost above 0.3% of maxDoc, which for a single required clause is that clause's cost, and FILTER/MUST clauses with no SHOULD clause) and whose required side is bulk-scored by DenseConjunctionBulkScorer or ConstantScoreBulkScorer (for example *:*) therefore silently loses up to the last 3,967 doc IDs: end to end, +*:* -b:y on one segment of IndexWriter.MAX_DOCS documents counts 2,147,479,551 instead of 2,147,483,518, with assertions on and off. main, branch_10x and branch_10_6, including the 10.6.0 RC1 revision, are affected; 10.5.2 and every older release are correct. MathUtil.unsignedMin(DocIdSetIterator.NO_MORE_DOCS, offset + bitSet.length()) fixes it.

Terms

  • ReqExclBulkScorer: the bulk scorer for "required minus excluded". Since #16669 it collects the excluded documents of each 4096-doc window into a bit set, flips it into an accept mask, and passes a WindowBits (a Bits over that mask) as acceptDocs to the required clause's bulk scorer (ReqExclBulkScorer.java:54-93, :86). Its own window end is computed in long: (int) Math.min((long) max, (long) windowMin + WINDOW_SIZE) (:61).
  • Bits#applyMask(FixedBitSet bitSet, int offset): clears bit i of bitSet when document offset + i is not accepted (util/Bits.java:39-72).
  • When the bulk path is used: BooleanScorerSupplier.java:203-207 returns no bulk scorer when there is a MUST_NOT clause and the supplier's cost() is at most 3L * maxDoc / 1000 (then the iterator-based ReqExclScorer runs and is correct). That cost is the supplier's estimate for the positive side (with required clauses and no minShouldMatch, the cost of the cheapest required clause, :82-101); for a single required clause it is that clause's cost. Otherwise, if the clause shape has a positive bulk scorer (FILTER/MUST clauses get requiredBulkScorer only with no SHOULD clause and minShouldMatch 0, :235-236), :265 returns new ReqExclBulkScorer(positiveScorer, prohibitedScorer). The bug then needs that bulk scorer to call applyMask on a window at 2^31 - 4096 or later; not every MUST_NOT query with a dense required side meets both conditions.
  • The callers: constant-score clauses get DenseConjunctionBulkScorer when they match at least 1/32 of maxDoc, else ConstantScoreBulkScorer when scores are not needed (ConstantScoreScorerSupplier.java:78-99); MatchAllDocsQuery (MatchAllDocsQuery.java:48), TermQuery without scores (TermQuery.java:168-173), PointRangeQuery, FieldExistsQuery and the multi-term constant-score wrapper use it. Both scorers call acceptDocs.applyMask(windowMatches, windowBase) on a 4096-bit set (ConstantScoreBulkScorer.java:142, DenseConjunctionBulkScorer.java:283).

The bug

ReqExclBulkScorer.java:124-145:

  1. :125 int bitSetEnd = Math.min(DocIdSetIterator.NO_MORE_DOCS, offset + bitSet.length()); With offset = 2,147,479,552 and length 4096 the sum is 2,147,483,648 = 2^31, which wraps to -2,147,483,648. Math.min with NO_MORE_DOCS (Integer.MAX_VALUE) cannot change an int, so it does not guard anything.
  2. :127 from = Math.max(windowBase, offset) is about 2.1 billion; :128 to = Math.min(windowEnd, bitSetEnd) is negative.
  3. :129-131 if (from >= to) { bitSet.clear(); return; } clears all 4096 bits: every match of that window is gone.

A Z3 bit-vector (32-bit) model of the bounds gives 2,147,479,552 as the smallest failing offset for a 4096-bit set. Under the model's preconditions (a set of 1 to 4096 bits, 0 <= windowBase <= offset < windowEnd <= MAX_DOCS) it proves that the upper bound to equals the exact min(windowEnd, offset + bitSet.length()) whenever offset + bitSet.length() <= Integer.MAX_VALUE, and that the overflow case takes the bitSet.clear() branch although the window is not empty. The model covers the bounds only, not the clear and andRange part of the method.

The numbers

Only doc IDs 2,147,479,552 to MAX_DOCS - 1 = 2,147,483,518 can be lost: 3,967 per segment and query at most. Over *:* the windows start at multiples of 4096 (the last at 524,287 x 4,096 = 2,147,479,552), so all 3,967 are lost: the end-to-end count is 2,147,479,551 instead of 2,147,483,518. A ConstantScoreBulkScorer window starts at its first match, so it loses the matches of any window that starts at 2,147,479,552 or later; a window that starts below that is correct.

Exposure and likelihood260 words
  • Who is exposed. An index with one segment of more than 2,147,479,552 documents. IndexWriter.MAX_DOCS = 2,147,483,519 limits the whole index (IndexWriter.java:201-208), so such a segment leaves room for at most 3,966 documents in all other segments together.
  • Which queries. A BooleanQuery that BooleanScorerSupplier routes to ReqExclBulkScorer (a MUST_NOT clause, a supplier cost() above 3L * maxDoc / 1000, which for a single required clause is that clause's cost, and a positive bulk scorer, such as FILTER/MUST clauses with no SHOULD clause), and whose FILTER or MUST side is bulk-scored by DenseConjunctionBulkScorer or ConstantScoreBulkScorer. Executed with *:* as FILTER and as MUST. By code reading also a single constant-score filter such as a term, point range or exists query (not executed).
  • Default configuration. Default IndexWriterConfig, codec and IndexSearcher; assertions on or off; single thread, no race.
  • What is wrong. Counting and exhaustive collection, and an exact-count top-10 search (TopScoreDocCollectorManager(10, Integer.MAX_VALUE) with MUST *:*: totalHits 2,147,479,551 instead of 2,147,483,518). Silent: no exception. The query itself never returns an excluded or deleted document; the method only clears bits.
  • Not affected. A sparse required side (the iterator-based ReqExclScorer runs): a legal end-to-end run with FILTER a:x on the last 100 documents passes. Every release.
  • Not tested. IndexSearcher#count, and the other applyMask callers that could receive a WindowBits (MaxScoreBulkScorer.java:246, BooleanScorer.java:149).
  • How likely: contrived. The trigger needs a single segment within 3,966 documents of the limit, which is expected to be rare. The point of fixing it now is that 10.6.0 (released 2026-10-29) ships it.

Minimal repro

One JUnit method in org.apache.lucene.search (the scorers are package-private, as in the upstream TestReqExclBulkScorer). Over the doc-ID range of a MAX_DOCS segment, the required clause matches the last 100 doc IDs and the excluded clause one of them. No large index is needed; deterministic; fails with assertions on and off.

TestReqExclLastWindow.java (lucene/core, package org.apache.lucene.search, 1 test)51 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 java.util.List;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestReqExclLastWindow extends LuceneTestCase {

  /** Required: the last 100 doc IDs of a MAX_DOCS segment. Excluded: one of them. */
  public void testLastDocsOfMaxDocsSegment() throws IOException {
    int maxDoc = IndexWriter.MAX_DOCS; // 2,147,483,519
    int base = maxDoc - 100;
    BulkScorer required =
        new DenseConjunctionBulkScorer(
            List.of(DocIdSetIterator.range(base, maxDoc)), List.of(), maxDoc, 1f);
    BulkScorer reqExcl =
        new ReqExclBulkScorer(required, DocIdSetIterator.range(base + 50, base + 51));
    int[] hits = new int[1];
    reqExcl.score(
        new LeafCollector() {
          @Override
          public void setScorer(Scorable scorer) {}

          @Override
          public void collect(int doc) {
            hits[0]++;
          }
        },
        null,
        0,
        maxDoc);
    assertEquals(99, hits[0]);
  }
}
Command
cp $REPRO/TestReqExclLastWindow.java $LUCENE/lucene/core/src/test/org/apache/lucene/search/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.search.TestReqExclLastWindow -Ptests.seed=DEADBEEF
# also with -Ptests.asserts=false (same failure)
Expected
99 hits (the last 100 doc IDs minus the excluded one), as on 10.5.2.
Observed
0 hits: AssertionError: expected:<99> but was:<0> on main (assertions on and off) and on jars built from the 10.6.0 RC1 revision (-ea and -da).

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

More files, environment, full output333 words

TestReqExclLastWindow (51 lines with the license header, one test method). It builds the scorer pair a real +*:* -b:y search gets on such a segment, a ReqExclBulkScorer over a DenseConjunctionBulkScorer, and scores it over [0, MAX_DOCS) without building a 2-billion-document index. The test uses no randomness, and :lucene:core:checkGoogleJavaFormat passes with it in the tree. As a regression test it can go into the existing TestReqExclBulkScorer as one more method.

End to end, the finder's legal run (one default-codec segment of IndexWriter.MAX_DOCS documents, about 2.1 billion addDocument calls, 7 to 15 minutes) counted +*:* -b:y as 2,147,479,551 instead of 2,147,483,518, with -ea and -da, on main ea57360912ed, whose involved files are identical at 645d4fe14a20. The same test, repeated on 645d4fe14a20 when the diagram was made, gave the same count with -ea and -da. These are the only runs of this legal-index test (the other legal-index run is the sparse control, which passes); the independent checks used unit tests and a public-API partial check (a reader that reports maxDoc = MAX_DOCS over a one-document segment).

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT) and jars built from branch_10_6 at 4ae946e5a9b, the 10.6.0 RC1 revision (a 10.6.0-SNAPSHOT build; the RC1 release artifacts themselves were not run): fail; 10.5.2: passes
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux aarch64 (plain int arithmetic, nothing platform-specific)
Command (environment)
cp $REPRO/TestReqExclLastWindow.java $LUCENE/lucene/core/src/test/org/apache/lucene/search/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.search.TestReqExclLastWindow -Ptests.seed=DEADBEEF

Observed output

# gradle, main 645d4fe14a20, -Ptests.seed=DEADBEEF (-Ptests.asserts=true and false)
TestReqExclLastWindow > testLastDocsOfMaxDocsSegment FAILED
    java.lang.AssertionError: expected:<99> but was:<0>

# standalone runner, seed DEADBEEF (the -da run at 4ae946e5a9b is the fact check's)
main 645d4fe14a20, -ea                       tests_run=1 failures=1   expected:<99> but was:<0>
main 645d4fe14a20, -da                       tests_run=1 failures=1   expected:<99> but was:<0>
branch_10_6 4ae946e5a9b (RC1 revision), -ea  tests_run=1 failures=1   expected:<99> but was:<0>
branch_10_6 4ae946e5a9b (RC1 revision), -da  tests_run=1 failures=1   expected:<99> but was:<0>
10.5.2, -ea                                  tests_run=1 failures=0

# end to end, one segment of IndexWriter.MAX_DOCS docs, +*:* -b:y (main ea57360912ed and 645d4fe14a20, -ea and -da)
TestMatchAllMustNotInMaxDocsSegment > testCountAllButOne FAILED
    java.lang.AssertionError: expected:<2147483518> but was:<2147479551>

Why it looks unintended

  • MUST_NOT means "must not appear in the matching documents" and FILTER is "Like MUST", so +*:* -b:y must match all documents without b:y.
  • Math.min(NO_MORE_DOCS, offset + 4096) does nothing on an int: it looks like an overflow cap missing its widening.
  • #14750 added MathUtil.unsignedMin for exactly this window overflow; both callers here use it for their own window ends.
The full argument325 words
  • Contracts. BooleanClause.java:58: MUST_NOT is for clauses that "must not appear in the matching documents"; :35: FILTER is "Like MUST except that these clauses do not participate in scoring". Bits#applyMask (Bits.java:39-63) requires a non-negative offset and that set bits map below length(), and "Apart from that, this should behave the same way as the default implementation", which clears bit i only when document offset + i is not accepted. The failing calls meet both preconditions (offset 2,147,479,552; every set bit below MAX_DOCS).
  • The limit. IndexWriter.MAX_DOCS = Integer.MAX_VALUE - 128, "Hard limit on the maximum number of documents that may be added to the index" (IndexWriter.java:201-208). The end-to-end index was built with default settings inside it.
  • The code appears to cap the sum. Math.min(DocIdSetIterator.NO_MORE_DOCS, x) cannot change an int x. The otherwise redundant cap at :125 is consistent with an intended overflow guard missing its long widening; this is an inference, since the #16669 discussion never mentions overflow. score() in the same class widens its own window end (:61).
  • The project fixes this pattern elsewhere. #14750 (2025-06-03) added MathUtil.unsignedMin because dividing the doc-ID space into windows "is prone to integer overflows if the size of the window is greater than Integer.MAX_VALUE - windowStart". BitSetDocIdStream.java:34 uses it on the same expression, MathUtil.unsignedMin(Integer.MAX_VALUE, offset + bitSet.length()), and so do both callers here for their own window ends (ConstantScoreBulkScorer.java:132, DenseConjunctionBulkScorer.java:198, :214).
  • History. #16669 ("Use intoBitSet to speed up ReqExclBulkScorer with dense exclusions", the PR title, also the title of performance issue #16668 and of its CHANGES.txt entry under 10.6.0; the commit subject drops "with dense exclusions") is a speed-up. Its 9 comments, 2 reviews and 4 review comments never mention overflow, and its test testWindowBitsApplyMask uses offsets 96 to 108 (TestReqExclBulkScorer.java:263-293). The parent commit and the seven releases run return the right result; no release before 10.6.0 contains WindowBits.
The devil's-advocate check119 words

The strongest case for "not a bug": the classes are package-private, Bits is @lucene.experimental (Bits.java:24), and nobody runs a segment within 3,967 documents of 2^31, so this is an unsupported corner.

Why it fails: the observable contract is the public BooleanQuery semantics, not the internal class; the trigger is a legal index built with defaults, inside the only documented limit; and the code points to an intended cap (the otherwise no-op Math.min, the long at :61, the unsignedMin siblings; an inference). The argument limits impact, not correctness. The reviewers from another model family rated the intended-behaviour case weak; the independent reproducer, the devil's advocate and the adjudication reached the same conclusion without giving a rating.

Suggested fix

Recommended MathUtil.unsignedMin (validated)

Compute the end in unsigned arithmetic, as the sibling scorers do: int bitSetEnd = MathUtil.unsignedMin(DocIdSetIterator.NO_MORE_DOCS, offset + bitSet.length()); in WindowBits.applyMask, plus the import. The test then passes, and so do the 2,340 tests of org.apache.lucene.search.

    public void applyMask(FixedBitSet bitSet, int offset) {
      int bitSetEnd = MathUtil.unsignedMin(DocIdSetIterator.NO_MORE_DOCS, offset + bitSet.length());
All options and trade-offs3 options ยท 341 words

Two fixes were built and validated with gradle in a scratch worktree at 645d4fe14a20 (JDK 25, -Ptests.seed=DEADBEEF); the third option was not built.

MathUtil.unsignedMin (validated) (recommended)

ReqExclBulkScorer.java:125, one file, +2/-1 (the line and the import of org.apache.lucene.util.MathUtil):

    public void applyMask(FixedBitSet bitSet, int offset) {
      int bitSetEnd = MathUtil.unsignedMin(DocIdSetIterator.NO_MORE_DOCS, offset + bitSet.length());

Trade-offs

  • A wrapped sum is a large unsigned value (2^31 or more), so the unsigned minimum with NO_MORE_DOCS (2^31 - 1) is NO_MORE_DOCS, and to becomes the window end. Unchanged when the sum fits.
  • The idiom #14750 introduced for this case, written like BitSetDocIdStream.java:34. The line is exactly 100 columns and passes the formatter unwrapped. One unsigned compare instead of a signed one.

What the checks validated

  • The minimal test passes; it fails without the fix in the same gradle setup.
  • The nine closest suites pass, 145 tests: TestReqExclBulkScorer 5, TestBooleanScorer 20, TestBooleanScorerSupplier 16, TestDenseConjunctionBulkScorer 26, TestConstantScoreBulkScorer 6, TestBooleanQuery 30, TestBoolean2 10, TestBooleanRewrites 31, the new test 1. :lucene:core:checkGoogleJavaFormat passes.
  • :lucene:core:test --tests "org.apache.lucene.search.*": 215 classes, 2,340 tests, 49 skipped, 0 failures.

Widen the sum to long, as at line 61 (validated)

Same line, +2/-1, no import:

      int bitSetEnd =
          (int) Math.min(DocIdSetIterator.NO_MORE_DOCS, (long) offset + bitSet.length());

Trade-offs

Mirrors the outer window computation in the same class. Written on one line it is 101 columns and fails checkGoogleJavaFormat, so it must be wrapped as shown.

What the checks validated

  • The minimal test and TestReqExclBulkScorer pass (6 tests), and checkGoogleJavaFormat passes with the wrapped form. Fix B was not run against the whole org.apache.lucene.search package.
  • Earlier hunt runs of the one-line form, all passing: the finder's 4 tests and TestReqExclBulkScorer 5 of 5, the independent reproducer's 9, both reviewers' own tests, both history witnesses at c5fa32ce49f and the unit witness at the branch_10_6 head 2763db11b0a.

Guard at the callers (not recommended, not built)

Make ConstantScoreBulkScorer and DenseConjunctionBulkScorer avoid sets that reach past 2^31.

Trade-offs

Fixes only those two callers and leaves WindowBits breaking the Bits#applyMask contract for any other caller (MaxScoreBulkScorer.java:246, BooleanScorer.java:149).

Downstream note

On its own the query only loses matches, but a result reused as an exclusion turns that around: in a partial check, a +A -B deny clause cached by an application-configured LRUQueryCache and used as MUST_NOT let through 3,967 documents it should hide (the default cache does not cache such a segment).

How it was found

Code reading while modelling the Bits#applyMask contract across its implementations (bit sets, live docs, acceptDocs). A Z3 bit-vector model of the bounds found the smallest failing window start, 2,147,479,552, and proved the bound exact below it, under the model's preconditions. A same-package JUnit and a legal end-to-end run on a MAX_DOCS segment confirmed it.

The method in full201 words

Method: semi-formal (the hunt's hunt unit on bit sets, live docs and acceptDocs, main ea57360912ed, 2026-10-25).

  • Lead. Reading the Bits#applyMask implementations against the contract: WindowBits adds offset + bitSet.length() in int. (The intended-behaviour checker later confirmed that the other implementations cannot overflow: FixedBitSet computes length() - offset, and DenseLiveDocs and SparseLiveDocs compute maxDoc - offset.)
  • Z3. A 32-bit bit-vector model of the bounds, under the preconditions of a set of 1 to 4096 bits and 0 <= windowBase <= offset < windowEnd <= MAX_DOCS: O3 is a counterexample with the smallest failing offset 2,147,479,552 for a 4096-bit set; O3s proves that the bound to equals the exact min(windowEnd, offset + n) whenever offset + n <= Integer.MAX_VALUE; O3c proves the overflow case takes the bitSet.clear() branch although the window is not empty. The model does not cover the clear and andRange part of the method.
  • Execution. A same-package unit test (the required clause on the last 100 doc IDs, controls 8192 docs lower pass) and a legal end-to-end run on one segment of IndexWriter.MAX_DOCS documents, both with -ea and -da. A sparse end-to-end run that takes the ReqExclScorer path passes, as the code predicts.

Prior reports, history and checks

Prior reports

No prior report found (2026-10-26: three search-tool runs, 529 hits, none relevant; GitHub issues and PRs, including the changed files of the 148 open and 133 closed PRs updated since 2026-09-24, when #16669 was merged; the mailing lists; the JIRA archive; re-checked on GitHub 2026-10-29). Related: #16669 introduced it; #14750 added unsignedMin for this pattern.

Searches5 searches

Related, not reports of this bug: #16669 (introduced it) and #16668 (its issue); #14750 (MathUtil.unsignedMin for the same overflow pattern at other call sites, 2025); #16702 and #16715 (later MUST_NOT bulk-scoring performance work); #16847 (open draft for more than 2^31 documents per index; it keeps the per-segment limit and does not touch the file). Limits: GitHub search does not index diff code; private reports to the security list are not visible.

What was searched, when, and what turned up
WhereWhenHowFound
Search tool (GitHub issues/PRs, commits, downstream repositories, dev@, java-user@, issues@, commits@, JIRA archive, web)2026-10-25/26three runs: identifiers (ReqExclBulkScorer, WindowBits, applyMask, bitSetEnd, unsignedMin), keywords and symptoms ("MUST_NOT missing documents large segment", "integer overflow bulk scorer window", "last 4096 documents")58, 96 and 375 hits, none about this bug
GitHub issues and PRs (apache/lucene, elastic/elasticsearch, opensearch-project/OpenSearch, apache/solr)2026-10-2616 searches, e.g. ReqExclBulkScorer overflow, applyMask overflow, WindowBits, bitSetEnd, MUST_NOT MAX_DOCS, migrated JIRA issuesno report
Open and recently closed apache/lucene PRs2026-10-26listed all 413 open PRs; read the changed-file lists of the 148 open PRs updated since 2026-09-24 (any fix must postdate #16669) and of the 133 closed PRs updated since thenonly #16669 touches ReqExclBulkScorer.java
The introducing PR #166692026-10-269 comments, 2 reviews, 4 review comments readno mention of overflow
GitHub issues and PRs (apache/lucene), re-check2026-10-29ReqExclBulkScorer (newest item updated 2026-10-15), WindowBits, applyMask overflow; upstream main and branch_10_6 files readno report; the line is still there

History

Introduced by #16669 (c5fa32ce49f, merged 2026-09-24) and its same-day branch_10x backport 92ce3eaf4bf; in branch_10_6, including the 10.6.0 RC1 revision. Bisected: both parent commits pass. No release before 10.6.0 (released 2026-10-29) is affected: none of them contains WindowBits, and the seven releases run, 9.12.3 to 10.5.2, are correct.

Versions tested7 rows

Introduced by c5fa32ce49f, apache/lucene#16669 "Use intoBitSet to speed up ReqExclBulkScorer with dense exclusions" (the PR title, also the title of performance issue #16668 and of the CHANGES.txt entry; the commit subject drops "with dense exclusions"; by kkewwei; reviewed and merged by romseygeek, 2026-09-24), which replaced the leap-frog loop with 4096-doc windows and added WindowBits. Backported to branch_10x as 92ce3eaf4bf the same day; branch_10_6 contains the backport. The line is in the PR's first commit and unchanged through all 8. CHANGES.txt lists it under 10.6.0. Before it, the required clause's bulk scorer received the caller's acceptDocs unchanged.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedthe minimal test fails (gradle and the standalone runner, assertions on and off)
main ea57360912ed, a9779ef3c193 affectedthe finder's unit test fails 2 of 4; the end-to-end MAX_DOCS run counts 2,147,479,551 instead of 2,147,483,518 (on ea57360912ed)
branch_10_6 4ae946e5a9b (the 10.6.0 RC1 revision; jars built from it, not the RC1 release artifacts), branch_10_6 2763db11b0a affectedthe minimal test fails (-ea and -da); earlier witnesses fail the same way
c5fa32ce49f (#16669) vs its parent c80c9f53674 bisectthe commit fails both witnesses, the parent passes; the commit passes with the fix
92ce3eaf4bf (backport) vs its parent a7be0c1580b bisectthe backport fails, the parent passes
10.5.2 not affectedthe minimal test and both witnesses pass
9.12.3, 10.0.0, 10.1.0, 10.2.2, 10.3.2, 10.4.0 not affectedthe public-API witness passes; none of these releases has WindowBits

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 or the finder's tests; all call it real and agree on the root cause and the fix.

Checks and verdicts343 words

The adjudication did not re-run the 2.1-billion-document end-to-end build; the checks used unit tests and a public-API partial check (a reader that reports maxDoc = MAX_DOCS, not a legal index), which reproduce the same count. The legal-index +*:* -b:y test ran twice: the finder's run (main ea57360912ed) and a repeat on 645d4fe14a20 made with the diagram. Fact checks of this doc (one by a reviewer from another model family) found it substantively correct; their precision fixes are applied.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidateZ3 counterexample and a proof that the bound is exact below it (under the model's preconditions); 4-test unit class fails 2 of 4 (-ea, -da), controls pass; legal end-to-end run fails; sparse end-to-end run passes, as predicted.
cross-model review, refute lens real, highOpenAI GPT-5.6. Its own boundary test fails on main and passes with the fix. It showed that the first end-to-end example (+a:x -b:y, few hits) never reaches ReqExclBulkScorer, which led to the +*:* -b:y run.
Independent reproducer real9 own tests fail 7 (-ea, -da); window base 2^31 - 4097 passes, 2^31 - 4096 fails; the public-API partial check counts 2,147,479,551; the exact-count top-10 search is wrong too; fix 9 of 9; 10.5.2 and 9.12.3 pass.
Cross-model review, works-as-designed lens real, highOpenAI GPT-5.6; intended argument weak; its test fails on main and passes with the fix and on 10.5.2.
Intended-behaviour devil's advocate realNo javadoc, CHANGES, MIGRATE, test or discussion makes it intended; found the unsignedMin siblings.
adjudication confirmed, highRe-ran the independent reproduction's tests on main (9 run, 7 failed), with the fix (0 failed) and on 10.5.2 (0 failed), and the finder's test with -da.
this doc minimal test, both fixesWrote the one-method test and ran it on main 645d4fe14a20 (gradle and standalone, assertions on and off), on jars built from the 10.6.0 RC1 revision 4ae946e5a9b (fails) and on 10.5.2 (passes); validated both fixes with gradle.

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,287 words

Finding record

LFV-139, snapshot of 2026-11-06

Registry title
ReqExclBulkScorer.WindowBits.applyMask adds offset + bitSet.length() in int, so FILTER/MUST + MUST_NOT queries silently drop every match in a 4096-doc window based at doc 2^31-4096 or above (up to the last 3,967 doc IDs of a segment near IndexWriter.MAX_DOCS)
Area
search / BooleanQuery MUST_NOT bulk scoring (ReqExclBulkScorer)
Priority
Low, rank 970: A 10.6.0 regression that silently drops matches only on segments within 3,967 docs of MAX_DOCS.
Impact class
silent wrong results
Impact note
Holders of one segment with more than 2,147,479,552 documents (within 3,967 docs of IndexWriter.MAX_DOCS, for example a full index force-merged to one segment) who run a BooleanQuery with a MUST_NOT clause and a FILTER/MUST side that costs more than 0.3% of maxDoc and is bulk-scored by ConstantScoreBulkScorer or DenseConjunctionBulkScorer (for example +: -b:y; counting and exhaustive collection, and per the independent reproduction also an exact-count top-k search) silently lose the matches of the last 4096-doc window (all 3,967 docs end to end) with the default IndexWriterConfig, codec and IndexSearcher, under -ea and -da; extreme scale; a regression from #16669 in 10.6.0 (released 2026-10-29), main and branch_10x; 10.5.2 is correct.
Summary
You index one segment of IndexWriter.MAX_DOCS (2,147,483,519) documents with default settings, one of which has b:y, and count the matches of +: -b:y (MatchAllDocsQuery as FILTER plus a MUST_NOT term query) with a plain IndexSearcher; you expect 2,147,483,518. Instead you get 2,147,479,551: all 3,967 documents of the last window [2,147,479,552, MAX_DOCS) are silently missing, because ReqExclBulkScorer's WindowBits.applyMask computes Math.min(NO_MORE_DOCS, offset + bitSet.length()) in int, the sum wraps negative for a 4096-bit window based at 2^31-4096 or above, and the method then clears the whole window. Only segments with more than 2,147,479,552 documents and required clauses denser than 0.3% of maxDoc are affected, and only code since #16669 has the bug: 10.6.0 (released 2026-10-29), main and branch_10x.
Introduced
c5fa32ce49f6 (apache/lucene#16669 'Use intoBitSet to speed up ReqExclBulkScorer', by kkewwei for performance issue #16668, reviewed and merged by romseygeek on main 2026-09-24; branch_10x backport 92ce3eaf4bf5 the same day, inherited by branch_10_6) [exec: both history witnesses pass at the parents c80c9f53674 (main) and a7be0c1580b (branch_10x) and fail at c5fa32ce49f and at 92ce3eaf4bf; read: git blame and git log -S 'offset + bitSet.length()' on ReqExclBulkScorer.java return only c5fa32ce49f]; the line is in the PR's first commit b3094fab914 and unchanged in all 8 PR commits; a regression: before it, the required clause's bulk scorer received the caller's acceptDocs unchanged and WindowBits did not exist (2026-09-24 (#16669 merged on main at 08:30:33Z; branch_10x backport committed at 08:33:25Z))
First affected version
10.6.0 (released 2026-10-29; branch_10_6 head 2763db11b0a contains it [exec])
Affected versions
10.6.0 (released 2026-10-29) and unreleased lines: main (11.0.0-SNAPSHOT) from c5fa32ce49f through ea57360912ed [exec, -ea and -da]; branch_10x (10.7.0-SNAPSHOT) from the backport 92ce3eaf4bf [exec at 92ce3eaf4bf; head 1309534296e read, file byte-identical]; branch_10_6 (10.6.0-SNAPSHOT) head 2763db11b0a [exec, -ea and -da]; no release before 10.6.0 is affected: 9.12.3, 10.0.0, 10.1.0, 10.2.2, 10.3.2, 10.4.0 and 10.5.2 return the correct result [exec], and WindowBits exists in no release before 10.6.0 [read]; upstream main, branch_10x and branch_10_6 still carry the line (read-only GitHub API, 2026-10-26), so the released 10.6.0 has it; executed on aarch64 Linux with JDK 25 only (pure int arithmetic)
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
semi-formal: Z3, code reading
Upstream and prior reports
none found (2026-10-26, follow-up prior-report record verdict new: three scripted prior-report searches (the finder's run, 58 hits; run A on identifiers, 96 hits; run B on symptoms, 375 hits; GitHub issues/PRs, commits, downstream repos, dev@/java-user@/issues@/commits@ lists, the JIRA archive and web; 0 high-overlap hits), 16 read-only GitHub searches in apache/lucene, elastic/elasticsearch, opensearch-project/OpenSearch and apache/solr, the changed-file lists of all 413 open apache/lucene PRs and of the 133 closed PRs updated since 2026-09-24 (none but #16669 touches ReqExclBulkScorer.java), the commit list of the file on main, lists.apache.org searches, a web search, and the #16669 discussion read in full (9 comments, 2 reviews, 4 review comments; no mention of overflow); also the finder's and the intended-behaviour check's searches); upstream main, branch_10x and branch_10_6 still carry the line; related, not prior art: #16669 (introduced it), #16668 (its issue), #14750 (MathUtil.unsignedMin for the same window-overflow pattern at other call sites, 2025), #16702 and #16715 (later MUST_NOT bulk-scoring performance work), #16847 (open draft for more than 2^31 docs per index, keeps per-segment MAX_DOCS and does not touch the file); limits: GitHub search does not index diff code, and private reports to the Lucene security list are not visible
Lucene commit tested
main 645d4fe14a20 (2026-10-29: the one-method test through gradle with -Ptests.asserts=true and false and the standalone runner with -ea and -da; both fixes in a scratch worktree, fix A also with the whole org.apache.lucene.search package; the legal end-to-end MAX_DOCS run repeated with -ea and -da when the diagram was made), jars built from branch_10_6 at 4ae946e5a9b, the 10.6.0 RC1 revision (a 10.6.0-SNAPSHOT build, -ea and -da; the RC1 release artifacts themselves were not run), and the 10.5.2 release jars. The hunt, its checks and the first legal end-to-end MAX_DOCS run ran on main ea57360912ed; the involved files are identical at 645d4fe14a20. The version-history runs ran at c80c9f53674, c5fa32ce49f, a7be0c1580b, 92ce3eaf4bf, 2763db11b0a and on 9.12.3, 10.0.0, 10.1.0, 10.2.2, 10.3.2, 10.4.0 and 10.5.2.

Lucene and upstream sources

Finding record
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/search/ReqExclBulkScorer.java:61 (outer window end widened to long), :83 (outer acceptDocs.applyMask on the window mask), :86 (windowBits passed as acceptDocs to req.score), :124-145 (WindowBits.applyMask; :125 'int bitSetEnd = Math.min(DocIdSetIterator.NO_MORE_DOCS, offset + bitSet.length());', :129-131 'if (from >= to) { bitSet.clear(); return; }')
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/search/BooleanScorerSupplier.java:203-205 (MUST_NOT with required cost <= 0.3% of maxDoc returns no bulk scorer, so ReqExclScorer is used), :259-265 (otherwise new ReqExclBulkScorer(positiveScorer, prohibitedScorer))
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/search/ConstantScoreBulkScorer.java:131-132 (windowMax via MathUtil.unsignedMin), :142 (acceptDocs.applyMask(windowMatches, windowBase)); DenseConjunctionBulkScorer.java:283 (acceptDocs.applyMask(windowMatches, windowBase)) and :198/:214 (unsignedMin, per the independent reproduction/the intended-behaviour check)
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/util/Bits.java:39-72 (applyMask contract: offset non-negative, set bits map below length(), otherwise behave like the default per-bit implementation)
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:201-208 (MAX_DOCS = Integer.MAX_VALUE - 128, the documented hard limit)
The finding in 30 seconds
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/ReqExclBulkScorer.java:25, :54-93 (:61 long window end, :86 windowBits as acceptDocs), :124-145 (:125 the int sum, :128-131 the clear branch)
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/BooleanScorerSupplier.java:203-207 (0.3% threshold), :265 (new ReqExclBulkScorer), :389-404 (single required clause)
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/ConstantScoreScorerSupplier.java:78-99; MatchAllDocsQuery.java:48; TermQuery.java:168-173; PointRangeQuery.java:297-299; FieldExistsQuery.java:217; AbstractMultiTermQueryConstantScoreWrapper.java:323
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/ConstantScoreBulkScorer.java:129-149 (:142 applyMask); DenseConjunctionBulkScorer.java:283 (applyMask)
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/util/Bits.java:39-72 (applyMask contract and default); index/IndexWriter.java:201-208 (MAX_DOCS)
Who is affected
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:201-208; search/BooleanScorerSupplier.java:203-207; search/ConstantScoreScorerSupplier.java:78-99; search/TermQuery.java:168-173; search/MaxScoreBulkScorer.java:246; search/BooleanScorer.java:149
Why it looks unintended
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/BooleanClause.java:35, :58; util/Bits.java:24, :39-63; index/IndexWriter.java:201-208
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/ReqExclBulkScorer.java:61, :125; search/BitSetDocIdStream.java:34; search/ConstantScoreBulkScorer.java:132; search/DenseConjunctionBulkScorer.java:198, :214 (15 unsignedMin call sites under lucene/core/src/java)
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/test/org/apache/lucene/search/TestReqExclBulkScorer.java:263-293
Suggested fix
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/util/MathUtil.java:196-199; search/BitSetDocIdStream.java:34; search/ReqExclBulkScorer.java:61
Diagrams
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/ReqExclBulkScorer.java:125-131

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. The card shows the long-widening fix (option B, the one the hunt validated first); this doc recommends MathUtil.unsignedMin (option A). 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.