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.
- Lucene marks deleted documents in a bitmap, the live docs: one bit per document of a segment, 1 = live, 0 = deleted.
- Deletes are buffered in memory. On
commit()the writer saves the bitmap of every segment with new deletes to a.livfile through the codec's live-docs format. - A Java
intholds at most 2,147,483,647 (2^31 - 1); adding 1 more wraps it to -2,147,483,648. One index may hold at mostIndexWriter.MAX_DOCS= 2,147,483,519 documents (2^31 - 129).
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.- 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 callcommit()again. You expect the delete to be saved, as it is in any segment of up to 2,147,482,624 documents. - 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. - The next step, 2,147,482,624 + 1,024 = 2,147,483,648, is one more than an
intcan 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. - Instead of saving,
commit()throwsArrayIndexOutOfBoundsException: Index -3 out of bounds for length 16(AssertionError: startIndex=-129with assertions on); with assertions off the writer stays open, every retry fails the same way andclose()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). - 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.
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 loop.
Lucene90LiveDocsFormat.writeBits(Lucene90LiveDocsFormat.java:196-216) uses a 1,024-bit scratch setcopy(:200) and loopsfor (int offset = 0; offset < bits.length(); offset += copy.length())(:201). Each pass computesnumBitsToCopy = Math.min(bits.length() - offset, copy.length())(:202), sets all 1,024 bits, clears the bits past the end withcopy.clear(numBitsToCopy, copy.length())(:206, only for a short batch), applies the live docs withbits.applyMask(copy, offset)(:208) and writes the words (:210-213). - The callers.
writeLiveDocscalls it at:180.IndexWriterreacheswriteLiveDocsthroughPendingDeletes.writeLiveDocs:193on commit (ReaderPool.commit:354viaReadersAndUpdates.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, withoutPendingDeletes(DocumentsWriterPerThread.java:697, code reading). - 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. Thenoffset += 1024= 2,147,483,648 = 2^31 wraps to -2,147,483,648. The test-2,147,483,648 < 2,147,483,519holds, so a phantom pass starts:bits.length() - offset= 4,294,967,167 wraps to -129,Math.min(-129, 1024)= -129, andcopy.clear(-129, 1024)runs. - The exception. With assertions on,
FixedBitSet.clearfails its assert (FixedBitSet.java:799-800):startIndex=-129, numBits=1024. With assertions off,startWord = -129 >> 6 = -3andbits[startWord](:821) throwsIndex -3 out of bounds for length 16(the scratch set has 16longs). - 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
maxDocfrom 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 negativenumBitsToCopy. The model covers only this step condition; reading the loop, without a wrap it ends normally. - No malformed file is left (on the normal path). The phantom pass resets the scratch set and then throws, before
applyMaskapplies any live-docs bit and before it writes another word; the earlier batches have already written most of the.livfile.PendingDeletes.writeLiveDocsthen 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 ofLiveDocsFormat.writeLiveDocswould be left with the partial file (code reading). - The CheckIndex copy.
CheckIndex.bitsCardinality(CheckIndex.java:1369-1383) has the identical loop (:1372, clear at:1377). The public staticCheckIndex.testLiveDocs(:1304) calls it at:1317for a segment with deletions;checkIndexruns that test only at level 2 or higher (:1114-1116); the command-line default is level 1 and aCheckIndexbuilt in code starts with no level set. WithfailFastoff (the default) the error makescheckIndexthrow "Live docs test failed" (:1165) and count the segment as broken (:978-980); withfailFaston,testLiveDocsrethrows it (:1350-1351). This half was executed only through directCheckIndex.testLiveDocscalls, never through a fullcheckIndex()run on a persisted index of this size, so the level gate and these consequences are code reading. - The contract it breaks.
Bits#applyMask: "offsetmust 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
maxDocfrom 2,147,482,625 to 2,147,483,519. Since the whole index is capped atMAX_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@MonstertesttestExactlyAtTrueLimitbuilds exactly such a segment withforceMerge(1). Natural merges rarely get there: the defaultTieredMergePolicymerges 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
IndexWriterConfigwith the current default codec (Lucene104Codec, pinned explicitly in the test because the test framework randomizes it) reproduces it end to end;Lucene90LiveDocsFormatis 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 (
deleteDocumentsby term or query,updateDocument,tryDeleteDocument, or hard deletes next to soft deletes) makescommit()fail.deleteAll(), anddeleteDocuments(new MatchAllDocsQuery()), whichIndexWriterturns intodeleteAll(), drop the segment instead. With assertions off (production) the writer stays open with the deletions pending, every latercommit()fails the same way, andclose()fails and then rolls the writer back (executed: commit and close fail). With assertions on, theAssertionErroris 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, becausePendingSoftDeletes.writeLiveDocswrites only hard deletes (code reading). - CheckIndex half (10.3.0 and later). CheckIndex at level 2 or higher, or a direct
CheckIndex.testLiveDocscall, reports the valid live docs of such a segment as an error. On 10.4.0+ the writer fails first, so such a.livfile 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. AMAX_DOCS.livfile written by 10.3.2 and validated bit by bit is reported as an error byCheckIndex.testLiveDocson 10.3.0, 10.3.2, 10.4.0, 10.5.2 and main (direct calls only; the fullcheckIndex()path is code reading). - Operator warning.
CheckIndex -exorciseat level 2 or 3 on such a segment (10.3.0 or later) would treat it as broken and write a newsegments_Nwithout 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.
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.
/*
* 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);
}
}
}
}
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
writeLiveDocsreturns and the.livfile is written, as for a segment of 2,147,482,624 documents.- Observed
AssertionError: startIndex=-129, numBits=1024(assertions on) orArrayIndexOutOfBoundsException: Index -3 out of bounds for length 16(off), thrown atLucene90LiveDocsFormat.writeBits:206.
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.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT); the same loop in 10.4.0-10.6.0 |
|---|---|
| jdk | OpenJDK 25.0.3 |
| os | Linux aarch64 |
# 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)IndexWriter.MAX_DOCS(2,147,483,519) is the only documented limit, and Lucene's own@MonstertesttestExactlyAtTrueLimitforce-merges that many documents into one segment.- Lucene's
@MonstertesttestOverflowround-trips exactlyMAX_DOCSlive 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, andLiveDocsFormat.writeLiveDocs("Persist live docs bits.") states no size restriction. - Lucene builds and tests this size.
TestIndexWriterMaxDocs.testExactlyAtTrueLimit(@Monster) addsMAX_DOCSdocuments and callsforceMerge(1);close()commits the merged segment, though the test's reads open only the commit made before the merge.BaseLiveDocsFormatTestCase.testOverflow(:75-78) callstestSerialization(IndexWriter.MAX_DOCS, IndexWriter.MAX_DOCS - 7, false), a write and read-back ofMAX_DOCSlive docs. It came with LUCENE-8303 (8dc69428e34, 2018-05-09) and two days later was kept as@Monsterrather 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 stock645d4fe14a20and 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#applyMaskrequires a non-negativeoffset(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.
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.
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.testOverflowwith-Ptests.monster=true -Ptests.heapsize=3gfails atwriteBits:206; - with the fix: the repro passes with assertions on and off;
testOverflowpasses (86 s);codecs.lucene90.*,TestLiveDocs,TestFixedBitSet,TestCheckIndex,TestPendingDeletes,TestPendingSoftDeletes,TestIndexWriterDeleteandTestIndexWriterMaxDocs: 26 classes, 940 tests, 0 failures (86 skipped);:lucene:core:check -x testpasses. - Earlier, every checker's tests passed with it, including a correct read-back at
MAX_DOCS.
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.
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, 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).
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#applyMaskcontract, the finder read every caller and saw theint offset += 1024loops inLucene90LiveDocsFormat.writeBitsandCheckIndex.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
maxDocvalues up toMAX_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
IndexWriterConfigand codec,MAX_DOCSdocuments 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).
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.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene; Elasticsearch, OpenSearch, Solr) | 2026-10-26 | the 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", testOverflow | no report; #16623 was the only high-overlap hit (read in full, unrelated) |
| Open and recently closed PRs | 2026-10-26 | changed-file lists of all 413 open PRs and of 1,488 closed or merged PRs updated since 2025-07-01 | only #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-26 | 32 tool queries and 30 more direct queries, including CI failure mail | no report |
| JIRA archive and web | 2026-10-26 | 4 JIRA and 8 web tool queries | LUCENE-8303 (origin of testOverflow) only |
| GitHub re-check (issues and PRs, apache/lucene) | 2026-10-31 | 13 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 contents | only #15045 and #16593 (listed above) and two unrelated PRs (#13692, #16); upstream main 2f424fd428da and branch_10_6 still have both loops |
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.
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | the minimal test fails with assertions on and off (standalone and gradle); passes with the fix |
main ea57360912ed, a9779ef3c193 | affected | both halves fail in every checker's test; the end-to-end commit fails |
| 10.5.2, 10.4.0 | affected | both halves fail (assertions off); testOverflow fails on 10.4.0 |
| 10.3.2, 10.3.0 | CheckIndex only | the 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 affected | both halves pass |
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.
| Check | Verdict | What it found |
|---|---|---|
| finder | candidate | Z3 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, high | Its 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 | real | Own 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, high | Intended 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 | real | Public-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, high | Re-ran the independent reproduction's tests on main with assertions on and off: 6 run, 4 failed, controls pass. |
| re-verification | still reproduces | Same 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 validated | Gradle 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. |
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 515 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,564 words
- 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@MonstertestOverflow, the 26 closest core test classes and:lucene:core:check -x test; mainea57360912ed: the end-to-endIndexWriterrun atMAX_DOCSand every checker's tests; maina9779ef3c193: 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 main2f424fd428da(2026-10-30) has the same two loops.
- 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-4754a 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:697a 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-4614a 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-101a 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:41a Lucene main checkout lucene/test-framework/src/java/org/apache/lucene/tests/index/BaseLiveDocsFormatTestCase.java:75-78a 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)
- TestLiveDocsWriteMaxDocs.java (one test):
repro/LFV-147/src/org/apache/lucene/codecs/lucene90/TestLiveDocsWriteMaxDocs.java(62 lines)
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.
assets/LFV-147/bug.png(515 KB)
