All findings · Lucene finding · LFV-058 · sparsefixedbitset-nextclearbit-last-block

SparseFixedBitSet.nextClearBit returns NO_MORE_DOCS in the last 4096-bit block

SparseFixedBitSet.nextClearBit returns NO_MORE_DOCS once its search reaches the last 4,096-bit block of a set over 2,147,479,552 bits: the block's end, 2,147,479,552 + 4,096, overflows int. BitSetIterator.docIDRunEnd then claims docs not in the set; this needs a segment within 3,966 docs of MAX_DOCS; no in-tree query path using it was found.

Summary

Priority
Low, rank 1187SparseFixedBitSet.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
silent wrong results
Since
10.5.0 (2026-05, #16072)introduced 2026-05-18
Affected
10.5.0 to 10.6.0 and main; only bit sets longer than 2,147,479,552 bits
Status
documented
Found by
formal: CodeQL inventory and a Z3 bit-vector query; JBMC independently

AI-assisted page: this finding was first signalled by a formal tool's own output 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

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.

Background: sparse bit sets, clear bits and run ends

  1. SparseFixedBitSet stores 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.
  2. nextClearBit(i) returns the first bit at or after i that is off, or NO_MORE_DOCS (2,147,483,647) when every bit from i to the end is on.
  3. BitSetIterator.docIDRunEnd() uses it to say where a run of consecutive set bits ends, so a scorer can handle the whole run at once.
  4. A Java int holds at most 2,147,483,647 (2^31 - 1). Adding 1 more wraps around to -2,147,483,648.
Diagram of the bug on an empty SparseFixedBitSet of 2,147,483,647 bits split into 4,096-bit blocks. nextClearBit(2147483646) should return 2147483646, a clear bit in the last block, which starts at 2,147,479,552. The block end 2,147,479,552 + 4,096 = 2^31 overflows int to -2,147,483,648, so the search skips the block and returns 2147483647 (NO_MORE_DOCS).
How the last 4,096-bit block is lost: it starts at 2,147,479,552, its end 2,147,479,552 + 4,096 wraps to -2,147,483,648, and 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.

The finding, step by step

  1. 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.
  2. Call nextClearBit(2147483646). You expect 2147483646: that bit is clear.
  3. 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 an int and wraps to -2,147,483,648, so the search skips the block without reading it.
  4. Every set longer than 2,147,479,552 bits has this block; shorter sets are fine. BitSetIterator.docIDRunEnd turns 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.

Who is affected

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.

The code (main 645d4fe14a20, SparseFixedBitSet.java)

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) and nextClearBit(int, int) (:418-428) call nextClearBitInRange (:484-500), which asks firstClearInBlock block by block and returns NO_MORE_DOCS when every block says none.
  • When. Block i4096 = 524287 exists exactly when length > 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. FixedBitSet is 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 (nextClearBitInRange moves on block by block, :488-498): with only bits 2147479542 to 2147479551 set, nextClearBit(2147479542) returns NO_MORE_DOCS instead 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) when length > 2^31 - 64. The early return hides it today. A fix of :436 alone exposes it: on a set of Integer.MAX_VALUE bits whose last word has all its bits set, nextClearBit(2147483584) then returns -2147483648. (The same expression at :459 never runs: inside while (pos < blockCap), the return pos just before it always fires.)

Downstream: BitSetIterator.docIDRunEnd (BitSetIterator.java:106-120)

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_DOCS becomes a run up to upTo: 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 breaks DocIdSetIterator#docIDRunEnd: "All docs in range [docID(), docIDRunEnd()) match this iterator" (DocIdSetIterator.java:251).
  • DenseConjunctionBulkScorer (:197-211) trusts a clause's run of at least WINDOW_SIZE / 2 and calls collector.collectRange(min, runEnd). Driven directly with a one-doc SparseFixedBitSet filter of length MAX_DOCS, it collected 3,966 docs instead of 1; the FixedBitSet control 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 including IndexWriter.MAX_DOCS = 2,147,483,519, so within 3,966 docs of it. A set built directly can be any length up to Integer.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.of and AcceptDocs build one for sparse filters.
  • Public reach (executed). LeafReader#getLiveDocsWithDeletedIterator() (@lucene.experimental) returns that LiveDocs, whose deletedDocsIterator() is a BitSetIterator over 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 with IndexWriter#addIndexes from a synthetic, field-less CodecReader whose 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 returns docIDRunEnd() = 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() or deletedDocsIterator(); the other production nextClearBit callers (HNSW building) get a FixedBitSet; the AcceptDocs iterators feed kNN, which only calls nextDoc() or bits(). 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 maxDoc in (2,147,479,552, 2,147,482,624].

Minimal repro

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.

TestSparseFixedBitSetLastBlock.java28 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.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));
  }
}
Command
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.

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

More files, environment, full output233 words
  • Smallest input. An empty set is enough, because the early return fires before any bit is read. SparseFixedBitSet is a public @lucene.internal class; 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 own blockCount assertion (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).

Environment

lucenemain 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)
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64
Command (environment)
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)

Observed output

# 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)

Why it looks unintended

  • BitSet#nextClearBit returns NO_MORE_DOCS only "if every bit in [index, length()) is set"; here every bit is clear.
  • The constructor accepts any length from 1 up, MAX_DOCS allows segments this large, and FixedBitSet returns 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 after index, or NO_MORE_DOCS if every bit in [index, length()) is set." :113-117: "Returns the first unset bit in [start, upperBound), or NO_MORE_DOCS if none." DocIdSetIterator.java:251: "All docs in range [docID(), docIDRunEnd()) match this iterator."
  • Any length is accepted. SparseFixedBitSet.java:60-67: "Create a SparseFixedBitSet that can contain bits between 0 included and length excluded"; only length < 1 is 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 at length; the wrap defeats it.
  • Precedent. #16347 ("Fix integer underflow in assertion in SparseFixedBitSet", romseygeek, merged 2026-07-03, 10.6.0) fixed the blockCount assertion at these lengths and added TestSparseFixedBitSet.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 testNextClearBit uses 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.

Suggested fix

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.unsignedMin is the idiom DenseConjunctionBulkScorer already uses against the same kind of wrap (DenseConjunctionBulkScorer.java:198).
  • unsignedMin takes 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 plus TestDenseConjunctionBulkScorer and TestLucene90LiveDocsFormat: 69 suites, 586 tests, 11 skipped, 0 failures.
  • A scratch check of the second wrap (last word full, nextClearBit(2147483584)) returns NO_MORE_DOCS with 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:test or check.

B. Compute the block end without overflow, same early return (validated by the checks)

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.

Not recommended: change only the block-end line (validated by the checks)

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 note

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.

How it was found

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-constant int multiplication, left shift and "x + C" window or block addition in the target packages (602 + 63 rows); SparseFixedBitSet.java:436, :459 and :475 were three of them (:459 later 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 that wStart + 64 is safe up to MAX_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 = -2147483648 in 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.

Prior reports, history and checks

Prior reports

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).

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues and PRs (apache/lucene, incl. migrated JIRA)2026-10-22 and 2026-11-01the 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 fullno report; 231 distinct items, none this bug
Open PRs2026-11-01the 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 archive2026-11-0152 scripted queries: 48 list and 4 JIRA queriesno report; only the mails of #16072 itself mention firstClearInBlock
Downstream projects and the web2026-11-01five downstream search projects; 6 web queriesone unrelated compile break after #16072; nothing relevant on the web

History

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 as 82335430e512 (milestone 10.5.0). It added BitSet#nextClearBit, the SparseFixedBitSet implementation with the wrapping line, and the BitSetIterator#docIDRunEnd override. git log -S 'blockBase + 4096' finds only this commit.
  • Later: #16849 (2026-10-19, in 10.6.0) made docIDRunEnd linear and capped its scan at 2^16 docs; it still calls nextClearBit(runEnd, upTo). #16107, #16347 and #16593 changed the file but not these methods.
  • Assertions on 10.5.x: with assertions on, the constructor's blockCount assertion 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.java on upstream main f815e679bf6c (2026-11-01) and in 10.6.0 is the same file as in 645d4fe14a20.
Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedminimal test fails with assertions on and off (gradle and standalone); passes with fix A or B
10.6.0 affectedfails with assertions on and off
10.5.0, 10.5.1, 10.5.2 affectedfail with assertions off; with them on, the #16347 constructor assertion fires first
10.4.0 not affectedno 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 bisectthe probe (assertions off) fails on #16072 (2147483519) and passes on the parent

Independent checks

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.

Each independent check and its verdict
CheckVerdictWhat it found
study verifier realOwn 8 tests: threshold sweep, docIDRunEnd, DenseConjunctionBulkScorer 3,966 vs 1, second-wrap probe; the patched class passes all 8.
main re-runs still reproducesb83101cdd2 (2026-10-23), a9779ef3c193 (2026-10-26) and 645d4fe14a20: 8 run, 6 failed.
minimal repro verifiedFails on main with assertions on and off and two seeds; the fix passes.
current-main verification realMain, 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), highOpenAI GPT-5.6: reproduced with its own test, root cause and fix agreed; judged the internal API out of contract.
Second adjudication realExecuted 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 BScratch 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 fixedAn 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.

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 notes459 words

Finding record

LFV-058, snapshot of 2026-11-06

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 pair de92f115a312 / 9f33b54a13a with a docIDRunEnd probe; the public live-docs path end to end on main, 10.6.0 and 10.5.2. Upstream main f815e679bf6c (2026-11-01) has the same file.

Lucene and upstream sources

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-105
  • lucene/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)

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

Repro files

About the diagrams

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.