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.
- 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 Javaintcannot hold. - For
required -excludedwith FILTER/MUST clauses (no SHOULD clause) that match more than 0.3% of the segment,ReqExclBulkScorerworks 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 itsacceptDocs. - 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 callsacceptDocs.applyMask(bits, windowStart)to drop the documents that are not allowed.
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.- Index
IndexWriter.MAX_DOCS= 2,147,483,519 documents into one segment with default settings; one of them hasb:y. - Count
+*:* -b:y(MatchAllDocsQueryas FILTER,TermQueryb:yas MUST_NOT) with a plainIndexSearcher(query cache off). You expect 2,147,483,518: every document but one. - You get 2,147,479,551 and no error: all 3,967 documents from doc 2,147,479,552 to the end are missing.
- 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. - 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.
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.
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 aWindowBits(aBitsover that mask) asacceptDocsto the required clause's bulk scorer (ReqExclBulkScorer.java:54-93,:86). Its own window end is computed inlong:(int) Math.min((long) max, (long) windowMin + WINDOW_SIZE)(:61).Bits#applyMask(FixedBitSet bitSet, int offset): clears bitiofbitSetwhen documentoffset + iis not accepted (util/Bits.java:39-72).- When the bulk path is used:
BooleanScorerSupplier.java:203-207returns no bulk scorer when there is a MUST_NOT clause and the supplier'scost()is at most3L * maxDoc / 1000(then the iterator-basedReqExclScorerruns and is correct). That cost is the supplier's estimate for the positive side (with required clauses and nominShouldMatch, 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 getrequiredBulkScoreronly with no SHOULD clause andminShouldMatch0,:235-236),:265returnsnew ReqExclBulkScorer(positiveScorer, prohibitedScorer). The bug then needs that bulk scorer to callapplyMaskon 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
DenseConjunctionBulkScorerwhen they match at least 1/32 of maxDoc, elseConstantScoreBulkScorerwhen scores are not needed (ConstantScoreScorerSupplier.java:78-99);MatchAllDocsQuery(MatchAllDocsQuery.java:48),TermQuerywithout scores (TermQuery.java:168-173),PointRangeQuery,FieldExistsQueryand the multi-term constant-score wrapper use it. Both scorers callacceptDocs.applyMask(windowMatches, windowBase)on a 4096-bit set (ConstantScoreBulkScorer.java:142,DenseConjunctionBulkScorer.java:283).
ReqExclBulkScorer.java:124-145:
:125int bitSetEnd = Math.min(DocIdSetIterator.NO_MORE_DOCS, offset + bitSet.length());Withoffset= 2,147,479,552 and length 4096 the sum is 2,147,483,648 = 2^31, which wraps to -2,147,483,648.Math.minwithNO_MORE_DOCS(Integer.MAX_VALUE) cannot change anint, so it does not guard anything.:127from = Math.max(windowBase, offset)is about 2.1 billion;:128to = Math.min(windowEnd, bitSetEnd)is negative.:129-131if (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.
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
BooleanScorerSupplierroutes toReqExclBulkScorer(a MUST_NOT clause, a suppliercost()above3L * 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 byDenseConjunctionBulkScorerorConstantScoreBulkScorer. 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 andIndexSearcher; 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*:*:totalHits2,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
ReqExclScorerruns): a legal end-to-end run withFILTER a:xon the last 100 documents passes. Every release. - Not tested.
IndexSearcher#count, and the otherapplyMaskcallers that could receive aWindowBits(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.
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.
/*
* 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]);
}
}
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 (-eaand-da).
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).
| lucene | main 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 |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux aarch64 (plain int arithmetic, nothing platform-specific) |
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
# 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>- MUST_NOT means "must not appear in the matching documents" and FILTER is "Like MUST", so
+*:* -b:ymust match all documents withoutb:y. Math.min(NO_MORE_DOCS, offset + 4096)does nothing on anint: it looks like an overflow cap missing its widening.- #14750 added
MathUtil.unsignedMinfor 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-negativeoffsetand that set bits map belowlength(), and "Apart from that, this should behave the same way as the default implementation", which clears bitionly when documentoffset + iis not accepted. The failing calls meet both preconditions (offset 2,147,479,552; every set bit belowMAX_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 anintx. The otherwise redundant cap at:125is consistent with an intended overflow guard missing itslongwidening; 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.unsignedMinbecause dividing the doc-ID space into windows "is prone to integer overflows if the size of the window is greater thanInteger.MAX_VALUE - windowStart".BitSetDocIdStream.java:34uses 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
testWindowBitsApplyMaskuses 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 containsWindowBits.
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.
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.
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) isNO_MORE_DOCS, andtobecomes 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:
TestReqExclBulkScorer5,TestBooleanScorer20,TestBooleanScorerSupplier16,TestDenseConjunctionBulkScorer26,TestConstantScoreBulkScorer6,TestBooleanQuery30,TestBoolean210,TestBooleanRewrites31, the new test 1.:lucene:core:checkGoogleJavaFormatpasses. :lucene:core:test --tests "org.apache.lucene.search.*": 215 classes, 2,340 tests, 49 skipped, 0 failures.
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
TestReqExclBulkScorerpass (6 tests), andcheckGoogleJavaFormatpasses with the wrapped form. Fix B was not run against the wholeorg.apache.lucene.searchpackage. - Earlier hunt runs of the one-line form, all passing: the finder's 4 tests and
TestReqExclBulkScorer5 of 5, the independent reproducer's 9, both reviewers' own tests, both history witnesses atc5fa32ce49fand the unit witness at thebranch_10_6head2763db11b0a.
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).
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).
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#applyMaskimplementations against the contract:WindowBitsaddsoffset + bitSet.length()inint. (The intended-behaviour checker later confirmed that the other implementations cannot overflow:FixedBitSetcomputeslength() - offset, andDenseLiveDocsandSparseLiveDocscomputemaxDoc - 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 boundtoequals the exactmin(windowEnd, offset + n)wheneveroffset + n <= Integer.MAX_VALUE; O3c proves the overflow case takes thebitSet.clear()branch although the window is not empty. The model does not cover the clear andandRangepart 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_DOCSdocuments, both with-eaand-da. A sparse end-to-end run that takes theReqExclScorerpath passes, as the code predicts.
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.
| Where | When | How | Found |
|---|---|---|---|
| Search tool (GitHub issues/PRs, commits, downstream repositories, dev@, java-user@, issues@, commits@, JIRA archive, web) | 2026-10-25/26 | three 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-26 | 16 searches, e.g. ReqExclBulkScorer overflow, applyMask overflow, WindowBits, bitSetEnd, MUST_NOT MAX_DOCS, migrated JIRA issues | no report |
| Open and recently closed apache/lucene PRs | 2026-10-26 | listed 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 then | only #16669 touches ReqExclBulkScorer.java |
| The introducing PR #16669 | 2026-10-26 | 9 comments, 2 reviews, 4 review comments read | no mention of overflow |
| GitHub issues and PRs (apache/lucene), re-check | 2026-10-29 | ReqExclBulkScorer (newest item updated 2026-10-15), WindowBits, applyMask overflow; upstream main and branch_10_6 files read | no report; the line is still there |
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.
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | the minimal test fails (gradle and the standalone runner, assertions on and off) |
main ea57360912ed, a9779ef3c193 | affected | the 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 | affected | the minimal test fails (-ea and -da); earlier witnesses fail the same way |
c5fa32ce49f (#16669) vs its parent c80c9f53674 | bisect | the commit fails both witnesses, the parent passes; the commit passes with the fix |
92ce3eaf4bf (backport) vs its parent a7be0c1580b | bisect | the backport fails, the parent passes |
| 10.5.2 | not affected | the 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 affected | the public-API witness passes; none of these releases has WindowBits |
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.
| Check | Verdict | What it found |
|---|---|---|
| finder | candidate | Z3 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, high | OpenAI 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 | real | 9 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, high | OpenAI 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 | real | No javadoc, CHANGES, MIGRATE, test or discussion makes it intended; found the unsignedMin siblings. |
| adjudication | confirmed, high | Re-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 fixes | Wrote 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. |
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.
- PNG 488 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,287 words
- 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=trueandfalseand the standalone runner with-eaand-da; both fixes in a scratch worktree, fix A also with the wholeorg.apache.lucene.searchpackage; the legal end-to-endMAX_DOCSrun repeated with-eaand-dawhen the diagram was made), jars built frombranch_10_6at4ae946e5a9b, the 10.6.0 RC1 revision (a 10.6.0-SNAPSHOT build,-eaand-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-endMAX_DOCSrun ran on mainea57360912ed; the involved files are identical at645d4fe14a20. The version-history runs ran atc80c9f53674,c5fa32ce49f,a7be0c1580b,92ce3eaf4bf,2763db11b0aand on 9.12.3, 10.0.0, 10.1.0, 10.2.2, 10.3.2, 10.4.0 and 10.5.2.
- 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:323a 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-208a 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
- TestReqExclLastWindow.java (lucene/core, package org.apache.lucene.search, 1 test):
repro/LFV-139/src/org/apache/lucene/search/TestReqExclLastWindow.java(51 lines)
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.
assets/LFV-139/bug.png(488 KB)
