All findings · Lucene finding · LFV-147 · livedocs-batch-offset-overflow

A delete in a segment of more than 2,147,482,624 documents can never be committed: the live-docs batch loop's int offset wraps (CheckIndex has the same loop)

If one segment holds more than 2,147,482,624 documents (the top 895 legal sizes, limit included), the loop that saves its deletions in 1,024-bit batches overflows its int position, so every commit() after a hard delete there throws (10.4.0+). CheckIndex has the same loop and calls such segments broken (10.3.0+).

Summary

Priority
Low, rank 982On 10.4 and later, segments within 894 docs of MAX_DOCS (the top 895 legal sizes) cannot commit hard deletes, and CheckIndex level 2 flags their valid live docs; loud and nothing is corrupted.
Impact
crash
Since
10.3.0 for CheckIndex (#15045, 2025-08-27); 10.4.0 for the writer (#14998, 2025-09-05)introduced 2025-08-27 · about 1 year
Affected
writer: 10.4.0-10.6.0 and main; CheckIndex: 10.3.0-10.6.0 and main; not 10.2.2 or 9.x
Status
documented
Found by
semi-formal: code reading and a Z3 bit-vector query, then executed tests

AI-assisted page: this finding was first signalled by a semi-formal check (an exact check of the real jar, or a modelling hypothesis that a tool then witnessed) and then checked with AI tools; the checks it went through, and what each found, are under Prior reports, history and checks. Not yet triaged by a human. Snapshot of 2026-11-06.

The finding in 30 seconds

Lucene saves a segment's deletions as a bitmap, copied 1,024 bits at a time with an int position. In a segment of more than 2,147,482,624 documents the step after the last batch overflows the int, the loop does not stop, and the extra batch has a negative size, which throws.

Background

  1. Lucene marks deleted documents in a bitmap, the live docs: one bit per document of a segment, 1 = live, 0 = deleted.
  2. Deletes are buffered in memory. On commit() the writer saves the bitmap of every segment with new deletes to a .liv file through the codec's live-docs format.
  3. A Java int holds at most 2,147,483,647 (2^31 - 1); adding 1 more wraps it to -2,147,483,648. One index may hold at most IndexWriter.MAX_DOCS = 2,147,483,519 documents (2^31 - 129).
Diagram of the bug: the live-docs bitmap of a segment with 2,147,483,519 documents is saved in batches of 1,024 bits. The last batch starts at offset 2,147,482,624 and holds the final 895 bits. Adding 1,024 to the int offset gives 2,147,483,648, which wraps to -2,147,483,648; that is still below 2,147,483,519, so the loop runs again with a batch size of -129 and throws, and the deletion is never committed.
One segment of IndexWriter.MAX_DOCS documents, one delete, commit: the live-docs bitmap is saved in 1,024-bit batches, the int position wraps negative after the last batch, and the extra batch throws. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Put IndexWriter.MAX_DOCS = 2,147,483,519 documents into one segment with an otherwise default config (the test pins the default codec), commit, delete one document by term and call commit() again. You expect the delete to be saved, as it is in any segment of up to 2,147,482,624 documents.
  2. To save deletes, Lucene writes the segment's live-docs bitmap (one bit per document) 1,024 bits at a time and keeps its position in an int. The last batch starts at 2,147,482,624 and holds the final 895 bits.
  3. The next step, 2,147,482,624 + 1,024 = 2,147,483,648, is one more than an int can hold and wraps to -2,147,483,648. That is still below 2,147,483,519, so the loop runs once more, with a batch size of -129.
  4. Instead of saving, commit() throws ArrayIndexOutOfBoundsException: Index -3 out of bounds for length 16 (AssertionError: startIndex=-129 with assertions on); with assertions off the writer stays open, every retry fails the same way and close() throws again; with them on, the writer closes itself at the first failure. Nothing on disk is damaged, and the last commit should stay readable (inferred).
  5. CheckIndex counts live docs with the same loop (since 10.3.0), so at level 2 or higher it reports such a healthy segment as broken.

Who is affected

Only indexes within 894 documents of the 2,147,483,519-document limit, held in one segment (in practice after forceMerge(1)), with the default config and codec. On 10.4.0+ no hard delete in that segment can be committed; on 10.3.0+ CheckIndex at level 2 or higher calls the segment broken. Always loud; nothing is corrupted.

Mechanism, terms and numbers819 words

Lucene90LiveDocsFormat.writeBits, which IndexWriter runs whenever it saves a segment's live-docs bitmap, copies the bits in batches of 1,024 with for (int offset = 0; offset < bits.length(); offset += copy.length()). In a segment of more than 2,147,482,624 documents the step after the last batch overflows the int: at IndexWriter.MAX_DOCS (2,147,483,519) the offset goes from 2,147,482,624 to -2,147,483,648, which still passes the loop test, so a phantom batch of -129 bits is requested and FixedBitSet.clear throws. On 10.4.0 and later the first hard delete in such a segment can therefore never be committed. With assertions off (production) commit() throws, every later commit() throws again and close() throws and rolls the writer back; with assertions on, the AssertionError is a tragic event that closes the writer at once.

CheckIndex.bitsCardinality has had the same loop since 10.3.0, so CheckIndex at level 2 or higher, or a direct CheckIndex.testLiveDocs call, reports the valid live docs of such a segment as an error. The failure is always loud: no wrong bits are written or counted, and the last commit is untouched. It takes one segment within 894 documents of the hard limit (one of the top 895 legal sizes, up to and including MAX_DOCS), so the priority is low. A long loop variable fixes both copies.

The bug, step by step (main 645d4fe14a20)

  1. The loop. Lucene90LiveDocsFormat.writeBits (Lucene90LiveDocsFormat.java:196-216) uses a 1,024-bit scratch set copy (:200) and loops for (int offset = 0; offset < bits.length(); offset += copy.length()) (:201). Each pass computes numBitsToCopy = Math.min(bits.length() - offset, copy.length()) (:202), sets all 1,024 bits, clears the bits past the end with copy.clear(numBitsToCopy, copy.length()) (:206, only for a short batch), applies the live docs with bits.applyMask(copy, offset) (:208) and writes the words (:210-213).
  2. The callers. writeLiveDocs calls it at :180. IndexWriter reaches writeLiveDocs through PendingDeletes.writeLiveDocs:193 on commit (ReaderPool.commit:354 via ReadersAndUpdates.writeLiveDocs:291, executed) and when readers are not pooled (ReaderPool.release:205, code reading). Separately, a newly flushed segment that already carries deletions calls the format directly, without PendingDeletes (DocumentsWriterPerThread.java:697, code reading).
  3. The numbers at maxDoc = 2,147,483,519. The batch at offset 2,147,482,624 (2^31 - 1024) is the real last one: 895 bits, copied and written correctly. Then offset += 1024 = 2,147,483,648 = 2^31 wraps to -2,147,483,648. The test -2,147,483,648 < 2,147,483,519 holds, so a phantom pass starts: bits.length() - offset = 4,294,967,167 wraps to -129, Math.min(-129, 1024) = -129, and copy.clear(-129, 1024) runs.
  4. The exception. With assertions on, FixedBitSet.clear fails its assert (FixedBitSet.java:799-800): startIndex=-129, numBits=1024. With assertions off, startWord = -129 >> 6 = -3 and bits[startWord] (:821) throws Index -3 out of bounds for length 16 (the scratch set has 16 longs).
  5. Which sizes. The loop reaches offset 2^31 - 1024 only for a length above 2^31 - 1024, and the step after it always wraps. So maxDoc from 2,147,482,625 to 2,147,483,519 fails: the top 895 legal sizes. 2,147,482,624 is fine. A Z3 bit-vector query proved that for every length up to 2^31 - 1024 the step from any 1,024-aligned in-loop offset never wraps into a running pass, and that above that bound the wrapped pass always has a negative numBitsToCopy. The model covers only this step condition; reading the loop, without a wrap it ends normally.
  6. No malformed file is left (on the normal path). The phantom pass resets the scratch set and then throws, before applyMask applies any live-docs bit and before it writes another word; the earlier batches have already written most of the .liv file. PendingDeletes.writeLiveDocs then deletes that partial file and advances only the next write generation (PendingDeletes.java:184-203); the segment keeps its previous delete generation and the deletions stay pending in memory. An executed probe shows exactly that, and a second attempt throws again. The format itself does no cleanup, so a direct caller of LiveDocsFormat.writeLiveDocs would be left with the partial file (code reading).
  7. The CheckIndex copy. CheckIndex.bitsCardinality (CheckIndex.java:1369-1383) has the identical loop (:1372, clear at :1377). The public static CheckIndex.testLiveDocs (:1304) calls it at :1317 for a segment with deletions; checkIndex runs that test only at level 2 or higher (:1114-1116); the command-line default is level 1 and a CheckIndex built in code starts with no level set. With failFast off (the default) the error makes checkIndex throw "Live docs test failed" (:1165) and count the segment as broken (:978-980); with failFast on, testLiveDocs rethrows it (:1350-1351). This half was executed only through direct CheckIndex.testLiveDocs calls, never through a full checkIndex() run on a persisted index of this size, so the level gate and these consequences are code reading.
  8. The contract it breaks. Bits#applyMask: "offset must be non-negative" (Bits.java:43); here Lucene's own loop produces the negative value.
Exposure and likelihood465 words
  • Who can be hit. One segment with maxDoc from 2,147,482,625 to 2,147,483,519. Since the whole index is capped at MAX_DOCS, that is an index or shard filled to within 894 documents of the limit and held in one segment (all other segments together then hold at most 894 documents). Lucene's own @Monster test testExactlyAtTrueLimit builds exactly such a segment with forceMerge(1). Natural merges rarely get there: the default TieredMergePolicy merges by byte size and keeps merged segments under about 5 GiB, about 2.5 bytes per document at this count (the end-to-end run's documents are empty); forceMerge(1) overrides that cap.
  • Default, not opt-in. An otherwise default IndexWriterConfig with the current default codec (Lucene104Codec, pinned explicitly in the test because the test framework randomizes it) reproduces it end to end; Lucene90LiveDocsFormat is the live-docs format of the default codec.
  • Writer half (10.4.0 and later). A hard deletion that removes a document of such a segment and leaves the segment in place (deleteDocuments by term or query, updateDocument, tryDeleteDocument, or hard deletes next to soft deletes) makes commit() fail. deleteAll(), and deleteDocuments(new MatchAllDocsQuery()), which IndexWriter turns into deleteAll(), drop the segment instead. With assertions off (production) the writer stays open with the deletions pending, every later commit() fails the same way, and close() fails and then rolls the writer back (executed: commit and close fail). With assertions on, the AssertionError is treated as a tragic event and the writer rolls back and closes at once (code reading). The last commit is untouched, so a reader reopened from it shows the deleted documents again, as after any failed commit (inferred). Soft-delete-only updates should not reach the loop, because PendingSoftDeletes.writeLiveDocs writes only hard deletes (code reading).
  • CheckIndex half (10.3.0 and later). CheckIndex at level 2 or higher, or a direct CheckIndex.testLiveDocs call, reports the valid live docs of such a segment as an error. On 10.4.0+ the writer fails first, so such a .liv file comes from an earlier writer whose loop is still correct (9.x or 10.0-10.3.x, for an index the checking version can still open), or the check meets an NRT reader whose deletions are in memory. A MAX_DOCS .liv file written by 10.3.2 and validated bit by bit is reported as an error by CheckIndex.testLiveDocs on 10.3.0, 10.3.2, 10.4.0, 10.5.2 and main (direct calls only; the full checkIndex() path is code reading).
  • Operator warning. CheckIndex -exorcise at level 2 or 3 on such a segment (10.3.0 or later) would treat it as broken and write a new segments_N without it, dropping up to 2.1 billion healthy documents (code reading, not executed).
  • Loud, not silent. Every failure is an exception; no wrong bits, counts or search results were observed. The scale is extreme, so the priority is low.

Minimal repro

One JUnit test, one method: it saves the live docs of a MAX_DOCS-document segment (last document deleted) through Lucene90LiveDocsFormat.writeLiveDocs, the call IndexWriter makes on commit. Deterministic, about 2 seconds, 300 MB of heap and 256 MiB of temporary disk.

TestLiveDocsWriteMaxDocs.java (one test)62 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.codecs.lucene90;

import java.util.Collections;
import org.apache.lucene.codecs.Codec;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.SegmentCommitInfo;
import org.apache.lucene.index.SegmentInfo;
import org.apache.lucene.store.Directory;
import org.apache.lucene.store.FSDirectory;
import org.apache.lucene.store.IOContext;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.util.FixedBitSet;
import org.apache.lucene.util.StringHelper;
import org.apache.lucene.util.Version;

public class TestLiveDocsWriteMaxDocs extends LuceneTestCase {

  public void testWriteLiveDocsOfMaxDocsSegment() throws Exception {
    int maxDoc = IndexWriter.MAX_DOCS;
    FixedBitSet liveDocs = new FixedBitSet(maxDoc);
    liveDocs.set(0, maxDoc - 1);
    try (Directory dir = FSDirectory.open(createTempDir())) {
      SegmentInfo si =
          new SegmentInfo(
              dir,
              Version.LATEST,
              Version.LATEST,
              "_0",
              maxDoc,
              false,
              false,
              Codec.getDefault(),
              Collections.emptyMap(),
              StringHelper.randomId(),
              Collections.emptyMap(),
              null);
      SegmentCommitInfo sci = new SegmentCommitInfo(si, 0, 0, -1, -1, -1, StringHelper.randomId());
      try {
        new Lucene90LiveDocsFormat().writeLiveDocs(liveDocs, dir, sci, 1, IOContext.DEFAULT);
      } catch (Throwable t) {
        // Expected: the .liv file is written; actual: the int batch offset wraps, writeBits throws.
        throw new AssertionError("writeLiveDocs with maxDoc=IndexWriter.MAX_DOCS threw " + t, t);
      }
    }
  }
}
Command
cd $LUCENE   # an apache/lucene checkout at 645d4fe14a20
cp $REPRO/TestLiveDocsWriteMaxDocs.java lucene/core/src/test/org/apache/lucene/codecs/lucene90/
./gradlew :lucene:core:test --tests org.apache.lucene.codecs.lucene90.TestLiveDocsWriteMaxDocs -Ptests.seed=DEADBEEF
# assertions off (the production exception): add -Ptests.asserts=false
Expected
writeLiveDocs returns and the .liv file is written, as for a segment of 2,147,482,624 documents.
Observed
AssertionError: startIndex=-129, numBits=1024 (assertions on) or ArrayIndexOutOfBoundsException: Index -3 out of bounds for length 16 (off), thrown at Lucene90LiveDocsFormat.writeBits:206.

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

More files, environment, full output354 words

The test sits in org.apache.lucene.codecs.lucene90 and calls the same writeLiveDocs that PendingDeletes.writeLiveDocs calls on commit. It instantiates the format directly because LuceneTestCase randomizes the default codec (the Codec argument of SegmentInfo is only stored), and writes to an FSDirectory so the 256 MiB .liv file stays off the heap; it runs under the default 512 MB test heap. The 256 MiB bitmap is inherent: the wrap needs more than 2^31 - 1024 bits. It asserts only that the write succeeds. It covers the writer half; the CheckIndex half is the same loop with the same fix and was shown by the checkers' own tests.

End to end, with public API only (FSDirectory, new IndexWriterConfig().setCodec(TestUtil.getDefaultCodec()), an otherwise default config pinned to the default codec): 2,147,483,519 addDocument calls (the test does not assert the segment layout; the failure shows the segment holding the deleted document has more than 2,147,482,624 of them), commit(), deleteDocuments by term, commit(). That run takes 10-16 minutes and a 3 GB heap; the second commit() fails with the same exception from PendingDeletes.writeLiveDocs, and with assertions off close() throws it again.

As the regression test: the same test. Because it allocates 256 MiB of heap and disk, it may suit @Monster or @Nightly.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT); the same loop in 10.4.0-10.6.0
jdkOpenJDK 25.0.3
osLinux aarch64

Observed output

# stock 645d4fe14a20, gradle, seed DEADBEEF, assertions on
TestLiveDocsWriteMaxDocs > testWriteLiveDocsOfMaxDocsSegment FAILED
    java.lang.AssertionError: writeLiveDocs with maxDoc=IndexWriter.MAX_DOCS threw java.lang.AssertionError: startIndex=-129, numBits=1024
        Caused by:
        java.lang.AssertionError: startIndex=-129, numBits=1024
            at org.apache.lucene.util.FixedBitSet.clear(FixedBitSet.java:799)
            at org.apache.lucene.codecs.lucene90.Lucene90LiveDocsFormat.writeBits(Lucene90LiveDocsFormat.java:206)
            at org.apache.lucene.codecs.lucene90.Lucene90LiveDocsFormat.writeLiveDocs(Lucene90LiveDocsFormat.java:180)
            at org.apache.lucene.codecs.lucene90.TestLiveDocsWriteMaxDocs.testWriteLiveDocsOfMaxDocsSegment(TestLiveDocsWriteMaxDocs.java:55)
1 test completed, 1 failed

# stock, -Ptests.asserts=false
    java.lang.AssertionError: writeLiveDocs with maxDoc=IndexWriter.MAX_DOCS threw java.lang.ArrayIndexOutOfBoundsException: Index -3 out of bounds for length 16
        Caused by:
        java.lang.ArrayIndexOutOfBoundsException: Index -3 out of bounds for length 16
            at org.apache.lucene.util.FixedBitSet.clear(FixedBitSet.java:821)
            at org.apache.lucene.codecs.lucene90.Lucene90LiveDocsFormat.writeBits(Lucene90LiveDocsFormat.java:206)
            at org.apache.lucene.codecs.lucene90.Lucene90LiveDocsFormat.writeLiveDocs(Lucene90LiveDocsFormat.java:180)
1 test completed, 1 failed

# with the long-offset fix, assertions on and off
:lucene:core:test (SUCCESS): 1 test

# end to end through IndexWriter (main ea57360912ed, assertions off, 617 s)
testDeleteOneDocumentAndCommit: java.lang.ArrayIndexOutOfBoundsException: Index -3 out of bounds for length 16
   org.apache.lucene.util.FixedBitSet.clear(FixedBitSet.java:821)
   org.apache.lucene.codecs.lucene90.Lucene90LiveDocsFormat.writeBits(Lucene90LiveDocsFormat.java:206)
   org.apache.lucene.codecs.lucene90.Lucene90LiveDocsFormat.writeLiveDocs(Lucene90LiveDocsFormat.java:180)
   org.apache.lucene.index.PendingDeletes.writeLiveDocs(PendingDeletes.java:193)
   [...]
   Suppressed (from IndexWriter.close()): the same exception, via ReadersAndUpdates.writeLiveDocs(ReadersAndUpdates.java:291)

Why it looks unintended

  • IndexWriter.MAX_DOCS (2,147,483,519) is the only documented limit, and Lucene's own @Monster test testExactlyAtTrueLimit force-merges that many documents into one segment.
  • Lucene's @Monster test testOverflow round-trips exactly MAX_DOCS live docs; it fails on 10.4.0 and main, passes on 10.3.0.
  • Both loops came from speed-ups that replaced overflow-safe code; the wrapped offset breaks the non-negative-offset rule of Bits#applyMask.
The full argument219 words
  • The only limit. "Hard limit on the maximum number of documents that may be added to the index." (IndexWriter.java:201-204). No javadoc, CHANGES or MIGRATE entry sets a smaller per-segment limit, and LiveDocsFormat.writeLiveDocs ("Persist live docs bits.") states no size restriction.
  • Lucene builds and tests this size. TestIndexWriterMaxDocs.testExactlyAtTrueLimit (@Monster) adds MAX_DOCS documents and calls forceMerge(1); close() commits the merged segment, though the test's reads open only the commit made before the merge. BaseLiveDocsFormatTestCase.testOverflow (:75-78) calls testSerialization(IndexWriter.MAX_DOCS, IndexWriter.MAX_DOCS - 7, false), a write and read-back of MAX_DOCS live docs. It came with LUCENE-8303 (8dc69428e34, 2018-05-09) and two days later was kept as @Monster rather than removed ("LUCENE-8303: Make the overflow test a Monster rather than Nightly.", d764156f913). With monster tests enabled (-Ptests.monster=true -Ptests.heapsize=3g; the default test heap was not tried) it fails on stock 645d4fe14a20 and passes with the fix.
  • Speed-ups, not a new limit. #14998 ("Speed up flushing of live docs.") replaced a per-word writer loop and #15045 ("Use FixedBitSet#cardinality for counting liveDocs in CheckIndex") a per-bit count; neither old loop can overflow at MAX_DOCS, and neither PR discusses a size limit.
  • Its own callee's contract. Bits#applyMask requires a non-negative offset (Bits.java:43); the negative value is produced by Lucene's loop, not by a caller.
The devil's-advocate check145 words

The intended-behaviour checker and the works-as-designed reviewer built the strongest case: segments this close to the limit are unsupported in practice (with the default TieredMergePolicy only forceMerge or tiny documents build one), monster tests do not run in regular CI, LiveDocsFormat and CheckIndex are @lucene.experimental, the unit repro calls the codec directly, CheckIndex runs the check only at level 2 or higher, deleteAll() still works, and with assertions on the writer closes itself rather than staying stuck.

Why it fails: these points lower reach and severity, but none is a contract. The end-to-end run reaches the bug through IndexWriter with defaults, the kept @Monster tests show that a one-segment MAX_DOCS index and its live-docs round trip are meant to work, and the negative offset comes from Lucene's own loop. Both reviewers that rated the argument rated it weak, and every checker found it real.

Suggested fix

Recommended A long loop variable in both loops

Make the loop variable a long in both Lucene90LiveDocsFormat.writeBits and CheckIndex.bitsCardinality (+3/-1 lines each), so the step after the last batch ends the loop instead of wrapping. With it the repro and Lucene's own monster testOverflow pass, and so do 940 tests in the 26 closest core test classes.

FixedBitSet copy = new FixedBitSet(1024);
// long loop variable: offset += 1024 would overflow int for lengths > 2^31 - 1024
for (long longOffset = 0; longOffset < bits.length(); longOffset += copy.length()) {
  final int offset = (int) longOffset;
  int numBitsToCopy = Math.min(bits.length() - offset, copy.length());
  ... // unchanged
}
All options and trade-offs3 options ยท 354 words

One option was built and validated; the other two are sketches that were not run.

A long loop variable in both loops (validated by the checks) (recommended)

Iterate with long longOffset and derive final int offset = (int) longOffset inside the body, in Lucene90LiveDocsFormat.writeBits and in CheckIndex.bitsCardinality (the same change in both).

FixedBitSet copy = new FixedBitSet(1024);
// long loop variable: offset += 1024 would overflow int for lengths > 2^31 - 1024
for (long longOffset = 0; longOffset < bits.length(); longOffset += copy.length()) {
  final int offset = (int) longOffset;
  int numBitsToCopy = Math.min(bits.length() - offset, copy.length());
  ... // unchanged
}

Trade-offs

The smallest change; behaviour is unchanged for every length up to 2^31 - 1024, where the old loop's step provably never wraps. One cast per 1,024 bits; no benchmark was run. The two copies of the loop remain. Both loops are on the 10.4+ branches; branch_10_3 has the CheckIndex loop only.

What the checks validated

Gradle in a scratch worktree of 645d4fe14a20 (2026-10-31):

  • stock: the repro fails with assertions on and off; upstream TestLucene90LiveDocsFormat.testOverflow with -Ptests.monster=true -Ptests.heapsize=3g fails at writeBits:206;
  • with the fix: the repro passes with assertions on and off; testOverflow passes (86 s); codecs.lucene90.*, TestLiveDocs, TestFixedBitSet, TestCheckIndex, TestPendingDeletes, TestPendingSoftDeletes, TestIndexWriterDelete and TestIndexWriterMaxDocs: 26 classes, 940 tests, 0 failures (86 skipped); :lucene:core:check -x test passes.
  • Earlier, every checker's tests passed with it, including a correct read-back at MAX_DOCS.

Count down the remaining bits (sketch, not run)

for (int offset = 0, remaining = bits.length(); remaining > 0; offset += 1024, remaining -= 1024) with numBitsToCopy = Math.min(remaining, 1024): remaining never overflows, and the wrapped final offset is never used.

Trade-offs

Stays all-int, but is a slightly larger rewrite and was not validated.

One shared helper for both loops (sketch, not run)

Move the batch walk into one helper that writeBits and bitsCardinality both call, with either fix inside, so a future fix cannot miss one copy (the loop was copied from one PR into the other).

Trade-offs

A new internal method for a two-line fix; harder to backport.

Downstream note

Downstream, a service that keeps a near-full shard in one segment (for example after a force-merge to one segment) would see every commit fail after a hard delete or hard-delete update in that shard; updates done only through soft deletes should not reach the loop (code reading).

How it was found

While modelling the contract of Bits#applyMask, an AI tool read its callers and noticed that two loops step an int position by 1,024. A Z3 bit-vector query found the 895 failing segment sizes and proved that the loop's step never wraps below them; unit tests and an end-to-end IndexWriter run at MAX_DOCS confirmed it.

The method in full147 words

Method: semi-formal.

  • While modelling the Bits#applyMask contract, the finder read every caller and saw the int offset += 1024 loops in Lucene90LiveDocsFormat.writeBits and CheckIndex.bitsCardinality.
  • A Z3 query over 32-bit bit-vectors gave a counterexample (length 2,147,483,136 at offset 2,147,482,624), the smallest failing length 2,147,482,625 (895 legal maxDoc values up to MAX_DOCS), a proof that for every length up to 2^31 - 1024 the step from any aligned in-loop offset never wraps, and a proof that the wrapped pass always clears from a negative index.
  • Same-package unit tests then failed on the stock main jars with assertions on and off, with the 2^31 - 1024 controls passing, and a legal end-to-end run (default IndexWriterConfig and codec, MAX_DOCS documents in one segment, one delete, commit) failed in 617 s (assertions off) and 935 s (on).
  • Independent checkers then wrote their own tests from the claim (see the checks).

Prior reports, history and checks

Prior reports

No prior report found in GitHub issues, PRs and commits, the Lucene lists, the JIRA archive or downstream projects (2026-10-26); a GitHub re-check on 2026-10-31 found nothing new.

Searches5 searches

Related, none a report of this bug: #14998 and #15045 (introduced the loops; their discussions never mention overflow), #14134 (added Bits#applyMask), #16593 / #16661 (the non-negative offset contract), #16768, #16623 and #15413 (other live-docs work), #16847 (open draft for more than 2^31 docs per index; keeps the per-segment MAX_DOCS, and its overflow is a different merge-sizing defect), LUCENE-8303 / #9350 (origin of testOverflow), and opensearch-project/OpenSearch#3104 and elastic/elasticsearch#51136 (deletes rejected at a full shard, a different mechanism on older Lucene). Limits: GitHub search does not index diff code, and private reports are not visible.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene; Elasticsearch, OpenSearch, Solr)2026-10-26the hunt's prior-report tool (30 issue/PR, 6 commit and 4 downstream queries) and 65 GraphQL searches, for example writeBits, bitsCardinality, applyMask offset, live docs MAX_DOCS, "startIndex=-129", testOverflowno report; #16623 was the only high-overlap hit (read in full, unrelated)
Open and recently closed PRs2026-10-26changed-file lists of all 413 open PRs and of 1,488 closed or merged PRs updated since 2025-07-01only #14998 and #15045 change the loops (#16404 and #15986, closed unmerged, carry the same lines in branch-wide diffs); no open PR changes either loop
Mailing lists (dev@, java-user@, issues@, commits@, builds@)2026-10-2632 tool queries and 30 more direct queries, including CI failure mailno report
JIRA archive and web2026-10-264 JIRA and 8 web tool queriesLUCENE-8303 (origin of testOverflow) only
GitHub re-check (issues and PRs, apache/lucene)2026-10-3113 searches, for example bitsCardinality, "writeBits overflow", "live docs MAX_DOCS", startIndex=-129, "applyMask offset overflow", "Index -3 out of bounds for length 16"; upstream file contentsonly #15045 and #16593 (listed above) and two unrelated PRs (#13692, #16); upstream main 2f424fd428da and branch_10_6 still have both loops

History

Introduced by two speed-ups that replaced overflow-safe loops: #15045 (CheckIndex, 10.3.0) and #14998 (writer, 10.4.0). 10.2.2 and 9.x are clean; not fixed upstream.

Versions tested5 rows

CheckIndex half: 039e7874f7c8, #15045 "Use FixedBitSet#cardinality for counting liveDocs in CheckIndex" (easyice, merged on main 2025-08-27, backport 97740fc27c65), first release 10.3.0. Writer half: 2550eb2a72a6, #14998 "Speed up flushing of live docs." (jpountz, opened 2025-07-28, merged 2025-09-05, backport ece138c3445f), first release 10.4.0.

The loop was first written in the then-open #14998; during review of #15045 its author wrote "The new approach is similar to #14998, so I reused part of the code", and #15045 merged nine days earlier (2025-08-27 vs 2025-09-05), so the CheckIndex copy shipped first. The only upstream test at this size, testOverflow, is @Monster and skipped in regular runs.

Read at the tags: the CheckIndex loop is in 10.3.0-10.3.2, 10.4.0, 10.5.0-10.5.2 and 10.6.0; the writer loop in 10.4.0, 10.5.0-10.5.2 and 10.6.0; neither in 9.12.3, 10.0.0, 10.1.0 or 10.2.2. Release caveat: with assertions on, 10.4.0 and 10.5.2 also fail the 2^31 - 1024 writer control inside readLiveDocs, on a separate assertion-only overflow that main fixed in #16347, so the release runs cite assertions off.

Not fixed upstream (2026-10-31): upstream main 2f424fd428da and branch_10_6 have both loops; on 2026-10-26 so did branch_10x, branch_10_5 and branch_10_4, and branch_10_3 had the CheckIndex loop.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedthe minimal test fails with assertions on and off (standalone and gradle); passes with the fix
main ea57360912ed, a9779ef3c193 affectedboth halves fail in every checker's test; the end-to-end commit fails
10.5.2, 10.4.0 affectedboth halves fail (assertions off); testOverflow fails on 10.4.0
10.3.2, 10.3.0 CheckIndex onlythe writer passes; CheckIndex flags valid live docs, including a .liv file written by 10.3.2; testOverflow passes on 10.3.0
10.2.2, 9.12.3 not affectedboth halves pass

Independent checks

Confirmed with high confidence: an independent reproducer, an intended-behaviour devil's advocate and reviews by another model family (OpenAI GPT-5.6) found it real, with no dissent; re-run on 645d4fe14a20 and the fix validated through gradle.

Checks and verdicts308 words

Each checker that executed wrote its own test from the claim; the adjudication re-ran the independent reproducer's tests.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidateZ3 bit-vector query (smallest failing length 2,147,482,625; safe up to 2^31 - 1024); unit repro fails 3 of 5 with assertions on and off, controls pass; end-to-end IndexWriter run fails; the long fix passes.
refute review (OpenAI GPT-5.6) real, highIts own boundary tests pass the 2^31 - 1024 controls and fail at MAX_DOCS in both loops; the writer test passes on 10.3.0; upstream testOverflow fails unpatched and passes with the fix.
Independent reproducer realOwn public-API tests (default-codec round trip, and CheckIndex.testLiveDocs on a reader that reports maxDoc N): 4 of 6 fail on main, 10.5.2 and 10.4.0, 2 of 6 (CheckIndex) on 10.3.0; 10.2.2 and 9.12.3 pass; 6/6 with the fix, including a correct MAX_DOCS read-back.
Works-as-designed review (OpenAI GPT-5.6) real, highIntended argument weak: the experimental tags and the applyMask precondition set no smaller segment limit; its own test fails on main, its writer control passes, the fix passes.
Intended-behaviour devil's advocate realPublic-API repro from outside org.apache.lucene fails on main, 10.4.0 and 10.5.2, the CheckIndex half on 10.3.0; passes on 10.2.2. Found no documented per-segment limit.
adjudication confirmed, highRe-ran the independent reproduction's tests on main with assertions on and off: 6 run, 4 failed, controls pass.
re-verification still reproducesSame failures on main a9779ef3c193 (2026-10-26); the one-test repro fails on 645d4fe14a20 and passes with the fix shadowed (2026-10-29).
this doc (2026-10-31) real, fix validatedGradle on 645d4fe14a20: the repro fails stock with assertions on and off and passes with the fix; with the fix, the monster testOverflow passes, 940 tests in the 26 closest core test classes pass and :lucene:core:check -x test passes.

Diagram file

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

Sources and provenance

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

Finding record

LFV-147, snapshot of 2026-11-06

Registry title
Lucene90LiveDocsFormat.writeLiveDocs and CheckIndex.bitsCardinality step an int offset by 1024 that wraps to Integer.MIN_VALUE for segments of more than 2,147,482,624 documents, so a deletion in such a segment can never be committed (10.4.0+) and CheckIndex at level 2 or higher reports its valid live docs as an error (10.3.0+)
Area
codecs / live docs (Lucene90LiveDocsFormat) and index / CheckIndex live-docs check
Priority
Low, rank 982: On 10.4 and later, segments within 894 docs of MAX_DOCS (the top 895 legal sizes) cannot commit hard deletes, and CheckIndex level 2 flags their valid live docs; loud and nothing is corrupted.
Impact class
crash
Impact note
Holders of one segment with maxDoc from 2,147,482,625 to 2,147,483,519 (the top 895 legal sizes up to IndexWriter.MAX_DOCS, for example a whole index flushed or force-merged into one segment) cannot commit a hard deletion or hard-delete update that leaves the segment in place (deleteAll() drops it instead) on 10.4.0 and later with the default IndexWriterConfig and codec: commit() throws ArrayIndexOutOfBoundsException with assertions off (the writer stays open, every retry fails, close() throws and rolls back) or AssertionError with -ea (a tragic event that closes the writer at once; code reading), while the last commit stays readable (inferred) and nothing is corrupted; on 10.3.0 and later CheckIndex at level 2 or higher (not the command-line default) or a direct call of the public static CheckIndex.testLiveDocs reports the valid live docs of such a segment as an error, and -exorcise would then drop the segment (code reading only); extreme scale.
Summary
You index IndexWriter.MAX_DOCS (2,147,483,519) documents into one segment with the default IndexWriterConfig and codec, delete one of them by term and call commit(); you expect the deletion to be saved. Instead commit() throws 'ArrayIndexOutOfBoundsException: Index -3 out of bounds for length 16' ('AssertionError: startIndex=-129, numBits=1024' with -ea) and close() throws it again, because Lucene90LiveDocsFormat.writeBits copies the live docs in 1024-bit batches with 'for (int offset = 0; offset < bits.length(); offset += copy.length())': after the batch at 2^31-1024 the offset wraps to Integer.MIN_VALUE, which is still below the length, so the next batch clears a negative range. CheckIndex.bitsCardinality has the same loop, so on 10.3.0 and later CheckIndex (level 2 or higher) also reports the valid live docs of such a segment as an error; only segments with more than 2,147,482,624 documents are affected.
Introduced
039e7874f7c8 (apache/lucene#15045 'Use FixedBitSet#cardinality for counting liveDocs in CheckIndex', by easyice, merged on main 2025-08-27, branch_10x backport 97740fc27c65; the CheckIndex half, first released in 10.3.0) and 2550eb2a72a6 (apache/lucene#14998 'Speed up flushing of live docs.', by jpountz, opened 2025-07-28, merged on main 2025-09-05, branch_10x backport ece138c3445f; the writer half, first released in 10.4.0) [read: git blame in the history clone, the release tags and both PR discussions; the loop was written first in the then-open #14998 and copied into #15045 during review (its author: 'The new approach is similar to #14998, so I reused part of the code'), which merged nine days earlier; exec: release-level runs only (10.2.2 passes both halves, 10.3.0 and 10.3.2 fail the CheckIndex half and pass the writer half, 10.4.0 fails both), no commit-level bisect]; a regression in both places: the loops these PRs replaced (a per-bit count in CheckIndex, a per-word writer loop) cannot overflow at MAX_DOCS (2025-08-27 (#15045, CheckIndex half); 2025-09-05 (#14998, writer half))
First affected version
10.3.0 [exec] (CheckIndex half, #15045); the writer half first ships in 10.4.0 [exec] (#14998); 10.2.2 is the last release without either [exec]
Affected versions
CheckIndex half: 10.3.0, 10.3.1, 10.3.2, 10.4.0, 10.5.0, 10.5.1, 10.5.2, 10.6.0 (by reading) and main [exec on 10.3.0, 10.3.2, 10.4.0, 10.5.2 and main ea57360912ed (-ea and -da); read at the 10.3.1, 10.5.0 and 10.5.1 tags]; writer half: 10.4.0, 10.5.0, 10.5.1, 10.5.2, 10.6.0 (by reading) and main [exec on 10.4.0, 10.5.2 and main; read at the 10.5.0 and 10.5.1 tags]; 10.3.x still writes such .liv files correctly, but its own CheckIndex flags them [exec: a MAX_DOCS .liv file written on 10.3.2 and validated bit by bit is reported as an error by CheckIndex.testLiveDocs on 10.3.0, 10.3.2, 10.4.0, 10.5.2 and main]; not affected: 9.x and 10.0.0 to 10.2.2 [exec on 9.12.3 and 10.2.2; read at the other tags]; both loops are still on upstream main, branch_10x, branch_10_6, branch_10_5 and branch_10_4, and branch_10_3 has the CheckIndex loop only (read-only GitHub API, 2026-10-26); 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, verdict new: a scripted prior-report search with 84 planned queries (30 GitHub issue/PR, 6 commit, 4 downstream-repo, 32 mailing-list over dev@, java-user@, issues@ and commits@, 4 JIRA, 8 web; 74 ran, and each of the other 10 is a subset of a broader query that ran), 495 distinct hits, 1 high (#16623, read in full, no overflow discussion) and 29 medium overlap, all triaged; a lists and JIRA pass (36 queries, 380 hits, 0 high); 42 GitHub GraphQL searches on apache/lucene, 18 on elastic/elasticsearch, opensearch-project/OpenSearch and apache/solr and 5 GitHub-wide (including 'startIndex=-129'); 30 more mailing-list queries including builds@ (CI failure mail, indexed 2019-2026); the changed-file lists of all 413 open apache/lucene PRs (none changes either loop) and of 1,488 closed or merged PRs updated since 2025-07-01 (only #14998 and #15045 touch the loops, plus two closed unmerged branch-carrying PRs); both PR discussions read in full; also the finder's 51-query run, the independent reproduction's and the intended-behaviour check's read-only searches and the intended-behaviour check's 25-query list run); upstream main, branch_10x, branch_10_6, branch_10_5 and branch_10_4 still carry both loops, branch_10_3 the CheckIndex loop; related, not prior art: #14998 and #15045 (introduced the loops), #14134 (added Bits#applyMask), #16593 / #16661 (added the applyMask 'offset must be non-negative' contract that the wrapped loop breaks), #16768 and #16623 (other live-docs work), #15413 (sparse live docs, read path only), #16847 (open draft for more than 2^31 docs per index; keeps per-segment MAX_DOCS, its reported overflow is a different merge-sizing defect), LUCENE-8303 / #9350 (origin of the @Monster testOverflow), opensearch-project/OpenSearch#3104 and elastic/elasticsearch#51136 (doc-limit rejection at a full shard, a different mechanism on older Lucene); limits: GitHub search does not index diff code, the file list of closed PR #15360 was capped at 3,000 of 4,172 files, and private reports to the Lucene security list are not visible
Lucene commit tested
main 645d4fe14a20: the one-test repro (standalone and through gradle, assertions on and off), and the fix through gradle in a scratch worktree with Lucene's own @Monster testOverflow, the 26 closest core test classes and :lucene:core:check -x test; main ea57360912ed: the end-to-end IndexWriter run at MAX_DOCS and every checker's tests; main a9779ef3c193: re-verification; release jars 10.5.2, 10.4.0, 10.3.2, 10.3.0, 10.2.2 and 9.12.3: the checkers' tests. Linux aarch64, OpenJDK 25.0.3. Upstream main 2f424fd428da (2026-10-30) has the same two loops.

Lucene and upstream sources

Finding record
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/codecs/lucene90/Lucene90LiveDocsFormat.java:164-193 (writeLiveDocs; :180 delCount = writeBits(output, bits)), :196-215 (writeBits; :201 'for (int offset = 0; offset < bits.length(); offset += copy.length())', :202 numBitsToCopy, :206 copy.clear(numBitsToCopy, copy.length()))
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/index/CheckIndex.java:1303-1361 (public static testLiveDocs; :1317 bitsCardinality(liveDocs)), :1369-1383 (bitsCardinality; loop at :1372, clear at :1377), :1114-1116 (testLiveDocs only at level >= MIN_LEVEL_FOR_INTEGRITY_CHECKS), :1164-1165 ('Live docs test failed'), :1208 ('exorciseIndex() would remove reference to this segment'), :4673 (command-line level = Level.DEFAULT_VALUE), :4742-4754 (MIN_VALUE = 1 = DEFAULT_VALUE, MIN_LEVEL_FOR_INTEGRITY_CHECKS = 2)
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/index/PendingDeletes.java:171-212 (writeLiveDocs: assert liveDocs.length() == maxDoc, codec writeLiveDocs at :193, on failure advanceNextWriteDelGen and delete partial files, delGen advanced only on success)
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/index/DocumentsWriterPerThread.java:697 (flush of a new segment with deletions calls writeLiveDocs; not executed)
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/util/Bits.java:39-50 (applyMask contract: offset must be non-negative)
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/util/FixedBitSet.java:797-799 (clear(int,int) assertion 'startIndex=..., numBits=...'), :821 (array write that throws under -da)
  • 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)
  • a Lucene main checkout lucene/test-framework/src/java/org/apache/lucene/tests/index/BaseLiveDocsFormatTestCase.java:75-78 (@Monster testOverflow: testSerialization(IndexWriter.MAX_DOCS, IndexWriter.MAX_DOCS - 7, false))
The finding in 30 seconds
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/codecs/lucene90/Lucene90LiveDocsFormat.java:164-216 (writeLiveDocs, writeBits)
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/index/CheckIndex.java:499, 978-984, 1114-1116, 1165, 1200-1208, 1304-1383, 4673, 4748-4754
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/index/PendingDeletes.java:171-212; index/ReadersAndUpdates.java:290-291; index/ReaderPool.java:187-205, 354; index/DocumentsWriterPerThread.java:697
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/util/FixedBitSet.java:798-823; util/Bits.java:39-49; index/IndexWriter.java:201-208
Who is affected
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/index/PendingSoftDeletes.java:97-108; index/CheckIndex.java:978-984, 4606-4614
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:1328-1335 (close rolls back on failure), 1886-1890 (MatchAllDocsQuery to deleteAll), 3879-3884 and 6090-6105 (tragic Error in prepareCommit); index/TieredMergePolicy.java:42-59, 77, 97-101
  • a Lucene main checkout lucene/core/src/test/org/apache/lucene/index/TestIndexWriterMaxDocs.java:47-90 (testExactlyAtTrueLimit)
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/codecs/lucene104/Lucene104Codec.java:74 (default codec live-docs format)
Why it looks unintended
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:201-208; util/Bits.java:39-49; codecs/LiveDocsFormat.java:41
  • a Lucene main checkout lucene/test-framework/src/java/org/apache/lucene/tests/index/BaseLiveDocsFormatTestCase.java:75-78
  • a Lucene main checkout lucene/core/src/test/org/apache/lucene/index/TestIndexWriterMaxDocs.java:47-90
History
  • a Lucene clone release tags (loop text searched in both files at each tag, 2026-10-31)

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

Repro files

About the diagrams

Made in parallel with this doc from the code at 645d4fe14a20 and executed probe runs; every number is the arithmetic of Lucene90LiveDocsFormat.writeBits at maxDoc = 2,147,483,519.

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.