nextClearBit searches block by block and caps each block at Math.min(blockBase + 4096, length), in int. For the last block, which starts at 2,147,479,552, the sum is 2^31: it wraps to -2,147,483,648, so the cap is negative, the block is never read, and the answer is NO_MORE_DOCS.
SparseFixedBitSetstores bits (doc ids) in blocks of 4,096 and keeps only the non-empty 64-bit words, so a huge, mostly empty set is cheap. The default codec uses one for a segment's deleted docs when at most 1% are deleted.nextClearBit(i)returns the first bit at or afterithat is off, orNO_MORE_DOCS(2,147,483,647) when every bit fromito the end is on.BitSetIterator.docIDRunEnd()uses it to say where a run of consecutive set bits ends, so a scorer can handle the whole run at once.- A Java
intholds at most 2,147,483,647 (2^31 - 1). Adding 1 more wraps around to -2,147,483,648.
nextClearBit skips the block and returns NO_MORE_DOCS. Select the still to open it at full size. Generated with AI, checked against the code.- Create an empty
new SparseFixedBitSet(Integer.MAX_VALUE): 2,147,483,647 bits, all clear. It is cheap, because a sparse set only allocates its block index. - Call
nextClearBit(2147483646). You expect 2147483646: that bit is clear. - You get 2147483647, which is
NO_MORE_DOCS. The bit sits in the last 4,096-bit block, which starts at 2,147,479,552. The block's end, 2,147,479,552 + 4,096 = 2^31, does not fit in anintand wraps to -2,147,483,648, so the search skips the block without reading it. - Every set longer than 2,147,479,552 bits has this block; shorter sets are fine.
BitSetIterator.docIDRunEndturns the wrong answer into a run that is too long: on a 2,147,482,624-doc segment with two deletes, the deleted-docs iterator says 1,000 consecutive docs are deleted when only 1 is.
Only bit sets longer than 2,147,479,552 bits, when a search reaches their last 4,096 bits. In an index: one segment of more than 2,147,479,552 docs (the top 3,967 sizes, up to and including MAX_DOCS). The wrong value is silent and reachable through public reader API (the deleted-docs iterator), but no in-tree query path that consumes it was found.
Mechanism, terms and numbers654 words
SparseFixedBitSet is Lucene's bit set for sparse sets; the default codec uses it for the deleted docs of sparsely deleted segments. It splits its bits into blocks of 4,096. Its nextClearBit, added in 10.5.0 by #16072 to make BitSetIterator#docIDRunEnd fast, caps the search in each block at Math.min(blockBase + 4096, length), computed in int. For the last block an int-indexed set can have, which starts at 2^31 - 4096 = 2,147,479,552, the sum is 2^31 and wraps to Integer.MIN_VALUE: the block is skipped unread and nextClearBit returns NO_MORE_DOCS although clear bits exist.
Every set longer than 2,147,479,552 bits is affected whenever the search reaches its last block: a start inside it, or an earlier start with every bit from there up to the block set. BitSetIterator#docIDRunEnd turns the wrong answer into a run that covers docs not in the set. Through public reader API, the deleted-docs iterator of a sparsely deleted 2,147,482,624-doc segment (built from a synthetic reader for speed) claims 1,000 consecutive deleted docs where only 1 is. No in-tree query path that consumes the wrong value was found, so the practical impact is very low; the fix is two lines.
private int firstClearInBlock(int i4096, int fromGlobal, int upperBound) {
final int blockBase = i4096 << 12; // :435 last block: 524287 << 12 = 2147479552
final int blockEnd = Math.min(blockBase + 4096, length); // :436 2147479552 + 4096 wraps to Integer.MIN_VALUE
int from = Math.max(fromGlobal, blockBase);
final int blockCap = Math.min(upperBound, blockEnd); // :438 Integer.MIN_VALUE
if (from >= blockCap) { // :439 always true
return DocIdSetIterator.NO_MORE_DOCS; // :440 the block is never read
}- Call chain.
nextClearBit(int)andnextClearBit(int, int)(:418-428) callnextClearBitInRange(:484-500), which asksfirstClearInBlockblock by block and returnsNO_MORE_DOCSwhen every block says none. - When. Block
i4096 = 524287exists exactly whenlength > 2147479552. An empty set is enough: the early return fires before the block's bits are looked at. Lengths up to 2,147,479,552 work; 2147479553, 2147483519 (IndexWriter.MAX_DOCS) and 2147483647 fail.FixedBitSetis correct for the same bits. The search also reaches that block from an earlier start when every bit from there up to the block is set (nextClearBitInRangemoves on block by block,:488-498): with only bits 2147479542 to 2147479551 set,nextClearBit(2147479542)returnsNO_MORE_DOCSinstead of 2147479552 (a scratch check, assertions on and off; fix A passes it). - A second wrap behind it.
pos = wStart + 64(:475) overflows on the last 64-bit word (start 2^31 - 64) whenlength > 2^31 - 64. The early return hides it today. A fix of:436alone exposes it: on a set ofInteger.MAX_VALUEbits whose last word has all its bits set,nextClearBit(2147483584)then returns -2147483648. (The same expression at:459never runs: insidewhile (pos < blockCap), thereturn posjust before it always fires.)
int upTo = doc + Math.min(length - doc, MAX_RUN_END_SCAN); // :114, MAX_RUN_END_SCAN = 1 << 16 (:59)
if (runEnd < upTo) {
int end = bits.nextClearBit(runEnd, upTo); // :116
runEnd = end == NO_MORE_DOCS ? upTo : end; // :117
}- A wrong
NO_MORE_DOCSbecomes a run up toupTo: up to 65,536 docs ahead on 10.6.0 and main, up to the end of the set on 10.5.x (end == NO_MORE_DOCS ? length : end). The claimed run can end that far ahead, but the docs it wrongly includes all lie in the last block: fewer than 4,096. That breaksDocIdSetIterator#docIDRunEnd: "All docs in range[docID(), docIDRunEnd())match this iterator" (DocIdSetIterator.java:251). DenseConjunctionBulkScorer(:197-211) trusts a clause's run of at leastWINDOW_SIZE / 2and callscollector.collectRange(min, runEnd). Driven directly with a one-docSparseFixedBitSetfilter of lengthMAX_DOCS, it collected 3,966 docs instead of 1; theFixedBitSetcontrol collected 1.
Exposure and likelihood296 words
- Scale. Bit sets are sized to a segment's
maxDoc, so in an index this needs one segment with more than 2,147,479,552 docs: the 3,967 largest legal sizes, up to and includingIndexWriter.MAX_DOCS= 2,147,483,519, so within 3,966 docs of it. A set built directly can be any length up toInteger.MAX_VALUE(4,095 bad lengths). - Default code builds such sets. The default codec reads live docs with at most 1% deletions into a
SparseFixedBitSet(maxDoc)of the deleted docs (Lucene90LiveDocsFormat.java:69,:113-116,:141);BitSet.ofandAcceptDocsbuild one for sparse filters. - Public reach (executed).
LeafReader#getLiveDocsWithDeletedIterator()(@lucene.experimental) returns thatLiveDocs, whosedeletedDocsIterator()is aBitSetIteratorover it;docIDRunEnd()has no internal marker. On a single on-disk segment of 2,147,482,624 docs (default codec, two deletes, committed and reopened; built for speed withIndexWriter#addIndexesfrom a synthetic, field-lessCodecReaderwhose overridden accessors are@lucene.internal; normal indexing of that many docs was not run for this bug), the iterator on deleted doc 2,147,481,624 returnsdocIDRunEnd()= 2,147,482,624 instead of 2,147,481,625. The deleted doc outside the last block gives the right answer. Fails on main, 10.6.0 and 10.5.2. - No in-tree consumer found (a grep survey, not a proof). No main-source code calls
getLiveDocsWithDeletedIterator()ordeletedDocsIterator(); the other productionnextClearBitcallers (HNSW building) get aFixedBitSet; theAcceptDocsiterators feed kNN, which only callsnextDoc()orbits(). Wrong search hits need custom code, such as a query that hands such an iterator to a conjunction. - Silent. No exception and no assertion; the same with assertions on and off.
- Narrower live-docs window today. On 10.4.0 and later, deletions in a segment above 2,147,482,624 docs cannot be committed at all (a separate bug, LFV-147), so the live-docs path is open for
maxDocin (2,147,479,552, 2,147,482,624].
One JUnit test, one assertion, deterministic, single thread: an empty SparseFixedBitSet(Integer.MAX_VALUE) and nextClearBit(Integer.MAX_VALUE - 1). It allocates only two block-index arrays of 524,288 slots (a few MiB); the test JVM takes about 1.5 s, a whole gradle run 13 to 59 s.
/*
* 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.util;
import org.apache.lucene.tests.util.LuceneTestCase;
public class TestSparseFixedBitSetLastBlock extends LuceneTestCase {
public void testNextClearBitInLastBlock() {
SparseFixedBitSet set = new SparseFixedBitSet(Integer.MAX_VALUE);
// expected: the empty set's next clear bit is the index itself; actual: NO_MORE_DOCS
assertEquals(Integer.MAX_VALUE - 1, set.nextClearBit(Integer.MAX_VALUE - 1));
}
}
cp $REPRO/TestSparseFixedBitSetLastBlock.java $LUCENE/lucene/core/src/test/org/apache/lucene/util/
cd $LUCENE
./gradlew :lucene:core:test --tests org.apache.lucene.util.TestSparseFixedBitSetLastBlock -Ptests.seed=DEADBEEF
# assertions off: add -Ptests.asserts=false (same failure)
- Expected
nextClearBit(2147483646)returns 2147483646: the set is empty, so that bit is clear.- Observed
- It returns 2147483647 (
NO_MORE_DOCS), with assertions on and off.
More files, environment, full output233 words
- Smallest input. An empty set is enough, because the early return fires before any bit is read.
SparseFixedBitSetis a public@lucene.internalclass; the test sits in its package but uses no package-private member. - Assertions. It fails the same way with assertions on and off on main and 10.6.0. On 10.5.0 to 10.5.2 run it with
-Ptests.asserts=false: with assertions on, the constructor's ownblockCountassertion (fixed by #16347 in 10.6.0) fires first. - As the regression test: this same method, for example in
TestSparseFixedBitSet. It does not cover the fix's second hunk: it also passes with only the first line changed. The evidence for that hunk is the scratch second-wrap check (Suggested fix).
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT); also fails on 10.6.0 (assertions on and off) and on 10.5.0, 10.5.1, 10.5.2 (assertions off) |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux, aarch64 |
cp $REPRO/TestSparseFixedBitSetLastBlock.java $LUCENE/lucene/core/src/test/org/apache/lucene/util/
cd $LUCENE
./gradlew :lucene:core:test --tests org.apache.lucene.util.TestSparseFixedBitSetLastBlock -Ptests.seed=DEADBEEF
# assertions off: add -Ptests.asserts=false (same failure)
# gradle, main 645d4fe14a20, seed DEADBEEF, assertions on
TestSparseFixedBitSetLastBlock > testNextClearBitInLastBlock FAILED
java.lang.AssertionError: expected:<2147483646> but was:<2147483647>
at __randomizedtesting.SeedInfo.seed([DEADBEEF:23E3908A24453277]:0)
at org.junit.Assert.fail(Assert.java:89)
at org.junit.Assert.failNotEquals(Assert.java:835)
at org.junit.Assert.assertEquals(Assert.java:647)
at org.junit.Assert.assertEquals(Assert.java:633)
at org.apache.lucene.util.TestSparseFixedBitSetLastBlock.testNextClearBitInLastBlock(TestSparseFixedBitSetLastBlock.java:26)
1 test completed, 1 failed
# gradle, assertions off (-Ptests.asserts=false): the same AssertionError
# 10.6.0, assertions on and off; 10.5.0, 10.5.1, 10.5.2, assertions off: the same AssertionError
# with fix A or B: BUILD SUCCESSFUL, 1 test
# (2147483647 is DocIdSetIterator.NO_MORE_DOCS)BitSet#nextClearBitreturnsNO_MORE_DOCSonly "if every bit in[index, length())is set"; here every bit is clear.- The constructor accepts any length from 1 up,
MAX_DOCSallows segments this large, andFixedBitSetreturns the right answer for the same bits. - Maintainers already fixed an assertion overflow at exactly these lengths in this class (#16347), with a test at length 2,147,479,553.
The full argument186 words
- Contract.
BitSet.java:105-111: "Returns the index of the first unset (clear) bit at or afterindex, orNO_MORE_DOCSif every bit in[index, length())is set.":113-117: "Returns the first unset bit in[start, upperBound), orNO_MORE_DOCSif none."DocIdSetIterator.java:251: "All docs in range[docID(), docIDRunEnd())match this iterator." - Any length is accepted.
SparseFixedBitSet.java:60-67: "Create aSparseFixedBitSetthat can contain bits between 0 included andlengthexcluded"; onlylength < 1is rejected.IndexWriter.MAX_DOCS = Integer.MAX_VALUE - 128(IndexWriter.java:208) makes such segment sizes legal. - The code means to cap the block.
Math.min(blockBase + 4096, length)exists to stop atlength; the wrap defeats it. - Precedent. #16347 ("Fix integer underflow in assertion in SparseFixedBitSet", romseygeek, merged 2026-07-03, 10.6.0) fixed the
blockCountassertion at these lengths and addedTestSparseFixedBitSet.testLargeValuesDoNotOverflow(blockCount(2147479553), the smallest length that fails here). Its body: "the error is only in the assertion; the actual calculated block count is correct and there is no production bug". This one is in the returned value. - Why tests miss it. #16072's
testNextClearBituses sets of 1 to 40,000 bits.
The devil's-advocate check181 words
Strongest case for "not a bug" (a cross-model reviewer, OpenAI GPT-5.6, refute lens, who reproduced it and agreed on the cause and the fix): BitSet, SparseFixedBitSet and BitSetIterator are all @lucene.internal, so outside callers cannot rely on them, and no non-internal path had been shown. The reviewer rated this "moderate" and wrote that "nothing ... documents the wrong NO_MORE_DOCS result itself as intended" and that a maintainer "may still reasonably fix this".
Why it fails: the tie-break observed the wrong run end through a consumer path that names no internal type: DirectoryReader, LeafReader#getLiveDocsWithDeletedIterator, the @lucene.experimental LiveDocs, and DocIdSetIterator#docIDRunEnd. (To be fair to the objection: that test built its segment with IndexWriter#addIndexes from a synthetic CodecReader whose overridden accessors are @lucene.internal; an application would have to index more than 2,147,479,552 docs into one segment, which was not run for this bug.) @lucene.internal limits API stability, not correctness, and Lucene's own BitSetIterator relies on this contract. Nor is it absurd input failing loudly: the sizes are within the documented MAX_DOCS, and the failure is silent.
Recommended A. MathUtil.unsignedMin for the block end, stop at the first clear bit past the cap
Compute the block end with MathUtil.unsignedMin(blockBase + 4096, length), and return NO_MORE_DOCS as soon as a word's first clear bit is past the cap, so wStart + 64 cannot wrap either. Validated: the test fails before and passes after; 586 tests pass (every util test class plus the dense-conjunction and live-docs-format suites).
// SparseFixedBitSet.firstClearInBlock
- final int blockEnd = Math.min(blockBase + 4096, length);
+ final int blockEnd = MathUtil.unsignedMin(blockBase + 4096, length);
...
final int cand = pos + Long.numberOfTrailingZeros(inv);
- if (cand < blockCap) {
- return cand;
- }
+ return cand < blockCap ? cand : DocIdSetIterator.NO_MORE_DOCS;All options and trade-offs3 options ยท 660 words
Two validated options, both applied in a scratch worktree of main 645d4fe14a20 and run with gradle. Both need two changes: the block end, and an early return that keeps pos = wStart + 64 from wrapping on the last 64-bit word. Not recommended: changing only the block-end line (it passes the test but exposes the second wrap), or rewriting the loop in long arithmetic (a bigger change to a hot loop for no gain; not run). The minimal test does not cover the second hunk (it also passes with the first line only); the scratch second-wrap check does.
A. MathUtil.unsignedMin for the block end, stop at the first clear bit past the cap (validated by the checks) (recommended)
Two hunks in SparseFixedBitSet.firstClearInBlock. cand is the first clear bit at or after pos in this word. If it is at or past the cap, no clear bit lies in [pos, blockCap), so NO_MORE_DOCS is the right answer for this block, and returning it skips the increment. On every word but the last, the current code reaches the same answer one step later; on the last word, which starts at 2^31 - 64, it would compute wStart + 64, which wraps (with the first hunk only, the scratch check returns -2147483648). That word's top bit, 2^31 - 1, is below no int length, so it is always clear and the loop always returns at that word.
// SparseFixedBitSet.firstClearInBlock
- final int blockEnd = Math.min(blockBase + 4096, length);
+ final int blockEnd = MathUtil.unsignedMin(blockBase + 4096, length);
...
final int cand = pos + Long.numberOfTrailingZeros(inv);
- if (cand < blockCap) {
- return cand;
- }
+ return cand < blockCap ? cand : DocIdSetIterator.NO_MORE_DOCS;Trade-offs
- Two lines;
MathUtil.unsignedMinis the idiomDenseConjunctionBulkScoreralready uses against the same kind of wrap (DenseConjunctionBulkScorer.java:198). unsignedMintakes a moment to read: 2^31 read as unsigned is larger than any length.- No extra work on the hot path; the early return skips the rest of the word loop.
What the checks validated
- Gradle, scratch worktree of
645d4fe14a20, seed DEADBEEF: the minimal test fails before the change and passes after it, with assertions on and off. - Seed DEADBEEF:
TestSparseFixedBitSet(19),TestBitSetIterator(11),TestSparseFixedBitDocIdSet(7),TestFixedBitSet(34),TestLiveDocs(26),TestLucene90LiveDocsFormat(4, 1 skipped),TestDenseConjunctionBulkScorer(26),TestDocIdSetIterator(10) and the minimal test: 138 tests, 0 failures. - Seed 5EED1234: every
org.apache.lucene.util.Test*class plusTestDenseConjunctionBulkScorerandTestLucene90LiveDocsFormat: 69 suites, 586 tests, 11 skipped, 0 failures. - A scratch check of the second wrap (last word full,
nextClearBit(2147483584)) returnsNO_MORE_DOCSwith A and -2147483648 with only the first hunk. - The public-path end-to-end test (2,147,482,624-doc segment) passes with this class patched.
- Not run: the whole
:lucene:core:testorcheck.
final int blockEnd = blockBase + Math.min(4096, length - blockBase); (the study verifier's root-cause line), plus the same early return as A. length - blockBase is positive because a block only exists below length.
- final int blockEnd = Math.min(blockBase + 4096, length);
+ final int blockEnd = blockBase + Math.min(4096, length - blockBase);
...
- if (cand < blockCap) {
- return cand;
- }
+ return cand < blockCap ? cand : DocIdSetIterator.NO_MORE_DOCS;Trade-offs
Plain signed arithmetic, perhaps easier to read than unsignedMin; the same behaviour as A.
What the checks validated
Gradle, seed DEADBEEF: the minimal test, the second-wrap check, TestSparseFixedBitSet, TestBitSetIterator, TestSparseFixedBitDocIdSet, TestLiveDocs and TestDenseConjunctionBulkScorer: 91 tests, 0 failures.
Either form of the first line alone passes the minimal test but leaves pos = wStart + 64 (:475) free to wrap for lengths above 2^31 - 64.
Trade-offs
One line, but nextClearBit can then return a negative number.
What the checks validated
Gradle, seed DEADBEEF, unsignedMin line only: the minimal test passes; the second-wrap check fails with expected:<2147483647> but was:<-2147483648>.
Downstream relevance only: code that uses a sparse bit set or the deleted-docs iterator as a filter on a segment of more than 2,147,479,552 docs, and trusts docIDRunEnd, could treat docs outside the set as inside it.
Formal. A CodeQL inventory of int window additions listed this line; a Z3 bit-vector query, with Lucene's documented limits as preconditions, found a wrapping length and proved the line exact only up to 2^31 - 4096. JBMC found it independently on the real bytecode, and an independent verifier reproduced it.
The method in full205 words
- Sweep. The study's overflow-at-scale experiment (main
7cecf79523, 2026-10-22) used CodeQL to list every non-constantintmultiplication, left shift and "x + C" window or block addition in the target packages (602 + 63 rows);SparseFixedBitSet.java:436,:459and:475were three of them (:459later turned out to be unreachable). - Solver. Each scale-relevant expression was transcribed into Z3 bit-vector logic with Java wraparound and the documented limits as preconditions. For this line: a counterexample (length 2147483135, block 524287); a proof that the line is exact if and only if
length <= 2^31 - 4096; a proof thatwStart + 64is safe up toMAX_DOCS, and a counterexample for lengths above 2^31 - 64. - Bytecode. JBMC on the real lucene-core jar found its own counterexample (length 2147480574, start 2147480000, result 2147483647), with
blockEnd = blockCap = -2147483648in its trace; replayed on the JVM it gives the same wrong result. A JVM sweep of 556,920 (length, start) pairs in the safe region found no mismatch. - Verification. An independent verifier wrote its own 8 tests (threshold sweep,
docIDRunEnd,DenseConjunctionBulkScorer, the second-wrap probe) and reproduced it; the hunt then reduced it to the one-assertion test and found the public live-docs path.
No prior report found in the recorded searches (2026-10-22 and 2026-11-01): GitHub issues and PRs incl. the JIRA archive, mailing lists, downstream projects, the web, and the changed files of all 409 open PRs. Closest: #16347, which fixed a different overflow (an assertion) in the same class at the same sizes.
Searches4 searches
Closest, none this bug: #16347 (same class and sizes, the blockCount assertion only); #16072 (introduced the code; its review did not discuss large lengths); #16849 (rewrote the caller, still reaches the bug); #16155 / #16181 (the HNSW nextClearBit callers, on FixedBitSet); #15226 (open, iterating deleted docs, about performance); #16847 (open draft for more than 2^31 docs per index; segments stay at most MAX_DOCS); #14996 and #15161 (the only open PRs that edit the file, no conflict expected); #7469 / LUCENE-6409 (the same kind of overflow in LongBitSet, fixed in 2015).
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues and PRs (apache/lucene, incl. migrated JIRA) | 2026-10-22 and 2026-11-01 | the study's queries, 16 scripted queries and 153 search phrasings: class and method names, firstClearInBlock, "blockBase + 4096", "wStart + 64", 2147479552 / 2147479553 / 524287, overflow, wrap, last block, MAX_DOCS, stack frames; 36 related threads read in full | no report; 231 distinct items, none this bug |
| Open PRs | 2026-11-01 | the changed files of all 409 open apache/lucene PRs | #14996 and #15161 touch the file; neither changes the clear-bit search |
| Mailing lists (dev@, java-user@, issues@, commits@) and the JIRA archive | 2026-11-01 | 52 scripted queries: 48 list and 4 JIRA queries | no report; only the mails of #16072 itself mention firstClearInBlock |
| Downstream projects and the web | 2026-11-01 | five downstream search projects; 6 web queries | one unrelated compile break after #16072; nothing relevant on the web |
Introduced by #16072 (9f33b54a13a, 2026-05-18), which added nextClearBit to speed up docIDRunEnd; first released in 10.5.0. Fails on 10.5.0, 10.5.1, 10.5.2, 10.6.0 and main; 10.4.0 is clean, and the parent commit passes a docIDRunEnd probe (both with assertions off). Not fixed upstream.
Versions tested5 rows
- Introduced:
9f33b54a13a"Implement BitSetIterator#docIDRunEnd (#16072)" (iverase, approved by benwtrent, merged 2026-05-18), backported the same day as82335430e512(milestone 10.5.0). It addedBitSet#nextClearBit, theSparseFixedBitSetimplementation with the wrapping line, and theBitSetIterator#docIDRunEndoverride.git log -S 'blockBase + 4096'finds only this commit. - Later: #16849 (2026-10-19, in 10.6.0) made
docIDRunEndlinear and capped its scan at 2^16 docs; it still callsnextClearBit(runEnd, upTo). #16107, #16347 and #16593 changed the file but not these methods. - Assertions on 10.5.x: with assertions on, the constructor's
blockCountassertion overflows first for the same lengths (fixed by #16347 in 10.6.0), so 10.5.x shows this bug with assertions off, as in production. - Upstream:
SparseFixedBitSet.javaon upstream mainf815e679bf6c(2026-11-01) and in 10.6.0 is the same file as in645d4fe14a20.
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | minimal test fails with assertions on and off (gradle and standalone); passes with fix A or B |
| 10.6.0 | affected | fails with assertions on and off |
| 10.5.0, 10.5.1, 10.5.2 | affected | fail with assertions off; with them on, the #16347 constructor assertion fires first |
| 10.4.0 | not affected | no nextClearBit; the docIDRunEnd probe returns the right answer (2147483518) with assertions off; with them on, the old blockCount constructor assertion fires first |
9f33b54a13a (#16072) vs its parent de92f115a312 | bisect | the probe (assertions off) fails on #16072 (2147483519) and passes on the parent |
An independent study verifier, the current-main reproducer, a tie-break and this doc's gradle runs call it real. A cross-model reviewer (OpenAI GPT-5.6) reproduced it and agreed on the cause and the fix, but voted not real because the classes are @lucene.internal; the tie-break answered with an executed public-API path.
Checks and verdicts217 words
The cross-model reviewer was not re-run after the tie-break.
| Check | Verdict | What it found |
|---|---|---|
| study verifier | real | Own 8 tests: threshold sweep, docIDRunEnd, DenseConjunctionBulkScorer 3,966 vs 1, second-wrap probe; the patched class passes all 8. |
| main re-runs | still reproduces | b83101cdd2 (2026-10-23), a9779ef3c193 (2026-10-26) and 645d4fe14a20: 8 run, 6 failed. |
| minimal repro | verified | Fails on main with assertions on and off and two seeds; the fix passes. |
| current-main verification | real | Main, 10.5.0, 10.5.2 and 10.6.0 executed; the fix passes 37 upstream bit-set tests. |
| cross-model review, refute lens | not real (contract), high | OpenAI GPT-5.6: reproduced with its own test, root cause and fix agreed; judged the internal API out of contract. |
| Second adjudication | real | Executed the public live-docs path end to end on main, 10.6.0 and 10.5.2. |
| doc gradle runs | fails before, passes with A and B | Scratch worktree of 645d4fe14a20; 586 tests (util classes plus two suites) pass with A; the one-line variant leaves the second wrap. |
| doc checks | real; wording fixed | An independent fact check re-ran the repro on main, the releases and fix A and found the bug real as documented; a cross-model fact check (OpenAI GPT-5.6) reviewed the records; the diagram check passed. Their count and wording errors are fixed. |
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 406 KB
Finding record, Lucene and upstream sources, repro files and diagram notes459 words
- Registry title
- SparseFixedBitSet.nextClearBit returns NO_MORE_DOCS in the last 4096-bit block
- Area
- util / bitsets
- Priority
- Low, rank 1187: SparseFixedBitSet.nextClearBit returns NO_MORE_DOCS in the last 4096-bit block, which needs a segment past 2^31-4096 docs and custom code, with no in-tree path.
- Impact class
- silent wrong results
- Impact note
- Only bit sets longer than 2^31-4096 bits; no in-tree query path was found at that scale; released since 10.5.0.
- Summary
- You call nextClearBit on a SparseFixedBitSet longer than 2^31-4096 bits at a position in its last 4096-bit block. You expect the next clear bit. Instead blockBase + 4096 wraps, it returns NO_MORE_DOCS, and BitSetIterator.docIDRunEnd then claims absent documents (DenseConjunctionBulkScorer collected 3,966 docs instead of 1 in an independent verifier's test).
- Introduced
- #16072 (9f33b54a13a) (2026-05-18)
- First affected version
- 10.5.0
- Affected versions
- 10.5.0, 10.5.1 and 10.5.2 (assertions off; with them on, the constructor's own blockCount assertion fires first) and 10.6.0 (assertions on and off) [exec]; main 645d4fe14a20 [exec, assertions on and off]; 10.4.0 not affected (no nextClearBit; a docIDRunEnd probe returns the right answer) [exec]
- Status
- documented (brief, checks and a per-finding doc exist)
- Verification
- Confirmed in the earlier formal verification study by independent AI checks and a rerun on Lucene main; also a cross-model review that disputed it (the study's other checks rated it real); not yet triaged by a person.
- Found by
- formal: Z3; also reached by JBMC
- Upstream and prior reports
- none found (study searches 2026-10-22); #16347 fixed a different assertion
- Lucene commit tested
- main
645d4fe14a20(2026-10-28): the minimal test through gradle with assertions on and off, before and after fixes A and B, in a scratch worktree (2026-11-01), and standalone (2026-10-29 and 2026-11-01); releases 10.6.0 (assertions on and off) and 10.5.0, 10.5.1, 10.5.2 (assertions off); 10.4.0 and the commit pairde92f115a312/9f33b54a13awith adocIDRunEndprobe; the public live-docs path end to end on main, 10.6.0 and 10.5.2. Upstream mainf815e679bf6c(2026-11-01) has the same file.
- The finding in 30 seconds
lucene/core/src/java/org/apache/lucene/util/SparseFixedBitSet.java:418-428, :434-440, :448-460 (:459 cannot run), :475, :484-500 (at 645d4fe14a20)lucene/core/src/java/org/apache/lucene/util/BitSetIterator.java:59, :106-120 (at 645d4fe14a20); releases/lucene/10.5.0 BitSetIterator.java:97-105lucene/core/src/java/org/apache/lucene/search/DocIdSetIterator.java:245-253 (at 645d4fe14a20)lucene/core/src/java/org/apache/lucene/search/DenseConjunctionBulkScorer.java:197-211 (at 645d4fe14a20)
- Who is affected
lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:208 (at 645d4fe14a20)lucene/core/src/java/org/apache/lucene/codecs/lucene90/Lucene90LiveDocsFormat.java:69, :113-116, :141; lucene/core/src/java/org/apache/lucene/codecs/lucene104/Lucene104Codec.java:74 (at 645d4fe14a20)lucene/core/src/java/org/apache/lucene/util/BitSet.java:33-43; lucene/core/src/java/org/apache/lucene/search/AcceptDocs.java:93-114 (at 645d4fe14a20)lucene/core/src/java/org/apache/lucene/index/LeafReader.java:492-508; lucene/core/src/java/org/apache/lucene/util/SparseLiveDocs.java:164-167 (at 645d4fe14a20)
- Why it looks unintended
lucene/core/src/java/org/apache/lucene/util/BitSet.java:25, :105-117; lucene/core/src/java/org/apache/lucene/util/SparseFixedBitSet.java:36, :60-67; lucene/core/src/java/org/apache/lucene/util/BitSetIterator.java:30; lucene/core/src/java/org/apache/lucene/search/DocIdSetIterator.java:251 (at 645d4fe14a20)lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:208 (at 645d4fe14a20)
- Suggested fix
lucene/core/src/java/org/apache/lucene/search/DenseConjunctionBulkScorer.java:198 (at 645d4fe14a20)
- Diagrams
lucene/core/src/java/org/apache/lucene/util/SparseFixedBitSet.java:434-440 (at 645d4fe14a20)
- TestSparseFixedBitSetLastBlock.java:
repro/LFV-058/src/org/apache/lucene/util/TestSparseFixedBitSetLastBlock.java(28 lines)
assets/LFV-058/bug.png(406 KB)
