All findings · Lucene finding · LFV-137 · fixedbitset-ensurecapacity-numbits-overflow

FixedBitSet.ensureCapacity overflows when it grows a set to bit 1,908,874,241 or more, so a sparse doc-values or norms field with a value past doc 1.91 billion can silently lose all its values

Growing a FixedBitSet to bit 2,000,000,000 gives it a length() of -2,044,967,232, because ensureCapacity computes words × 64 in an int. Sparse doc-values and norms fields use such a set, so a field that gets a value past doc about 1.91 billion silently loses all its values at flush.

Summary

Priority
Low, rank 985FixedBitSet.ensureCapacity wraps, so flush silently drops every value of sparse doc-values and norms fields; severe, but it takes one in-memory segment of about 1.9 billion nearly empty documents with no flush, so it is contrived.
Impact
data corruption
Since
4.7.0 (LUCENE-5440, 2014-02-10)introduced 2014-02-10 · over 12 years
Affected
4.7.0 through 10.6.0 and main; not fixed upstream
Status
documented
Found by
semi-formal: code reading, 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

FixedBitSet.ensureCapacity computes the grown length as words × 64 in an int. From 2^25 words, reached at bit 1,908,874,241 after one jump, the length wraps negative. DocsWithFieldSet, which records the documents that have a doc value or norm, grows its bit set this way, and a set with a negative length iterates nothing.

Background: how Lucene remembers which documents have a value

  1. While a segment is in memory, each doc-values or norms field keeps a DocsWithFieldSet. As long as documents 0, 1, 2, ... all have a value, it only counts them.
  2. At the first gap it switches to a FixedBitSet, one bit per document ID, and grows it with FixedBitSet.ensureCapacity as higher doc IDs arrive (with about 1/8 headroom each time).
  3. At flush, the writer walks that bit set's iterator and writes one value per document it returns; a document the iterator skips is written as having no value.
Diagram of the bug: a sparse doc-values field has values on docs 0, 2 and 2,000,000,000. Its docs-with-field bit set starts at 3 bits in 1 word; to fit doc 2,000,000,000, FixedBitSet.ensureCapacity grows it to 35,156,251 64-bit words and computes the length 35,156,251 × 64 = 2,250,000,064 in an int, which wraps to -2,044,967,232. The iterator stops at once because 0 >= -2,044,967,232, so the flush writes no value for any document. With a long shift capped at Integer.MAX_VALUE the length is 2,147,483,647 and the iterator returns docs 0, 2 and 2,000,000,000.
How adding doc 2,000,000,000 grows the docs-with-field bit set to 35,156,251 words, how 35,156,251 × 64 wraps in an int to -2,044,967,232, and why the flush then finds no document with a value. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. You index into one segment a field that only a few documents have, for example a NumericDocValuesField on docs 0, 2 and 2,000,000,000 with empty documents in between (doc IDs up to 2,147,483,518 are legal), and commit.
  2. Lucene remembers which documents have the field in a bit set. For doc 2,000,000,000 it grows the set to 35,156,251 64-bit words and computes the new length as 35,156,251 × 64 in an int.
  3. You expect 2,250,000,064 bits. An int holds at most 2,147,483,647, so the length wraps to -2,044,967,232.
  4. The set's iterator stops at once when the length is negative, so the flush writes the field with no documents: after reopening, doc 0 has no value. With assertions off (the default) there is no error; with them on, addDocument throws for that one document.
  5. Reaching it takes about 1.9 billion nearly empty documents in one in-memory segment, so it is rare; every release since 4.7.0 has the line.

Who is affected

An application whose one in-memory segment has a sparse doc-values or norms field that then gets a value past doc about 1.91 billion; default settings suffice. In practice that takes about 1.9 billion nearly empty documents with no flush in between, so it is contrived. With assertions off the values vanish silently; the postings stay intact.

Mechanism, terms and numbers835 words

FixedBitSet.ensureCapacity(bits, desiredBit) grows the backing long[] with about 1/8 headroom and returns new FixedBitSet(arr, arr.length << 6). The shift is int arithmetic, so once the array has 2^25 words the length wraps negative: asking for bit 2,000,000,000 from a small set gives 35,156,251 words and length() = -2,044,967,232, and the first failing bit after one jump is 1,908,874,241. The constructor accepts the negative length; with assertions on, its ghost-bits check throws ArrayIndexOutOfBoundsException.

DocsWithFieldSet, which the doc-values and norms writers use to remember which documents have a value, grows its bit set this way once a field is sparse, and its iterator stops at once for a negative length. So, with assertions off, once a sparse field gets a value past doc about 1.91 billion (below IndexWriter.MAX_DOCS = 2,147,483,519) and its bit set grows there, the flushed segment silently has no value for that field on any document; executed for numeric doc values, sorted doc values and norms, with the postings intact. It needs about 1.9 billion nearly empty documents in one in-memory segment, so the priority is low. The sibling LongBitSet had the same overflow and was fixed in 2015 (LUCENE-6409); casting to long and capping at Integer.MAX_VALUE fixes FixedBitSet too.

The bug, step by step (main 645d4fe14a20, lucene/core/src/java/org/apache/lucene/)

  1. The growth. util/FixedBitSet.java:62-83 (ensureCapacityInternal, shared by ensureCapacity :49-51 and ensureCapacityAndClear :58-60): numWords = bits2words(desiredBit) (:72), arr = ArrayUtil.grow(arr, numWords + 1) (:76; ArrayUtil.oversize adds minTargetSize >> 3, util/ArrayUtil.java:157, :166), then return new FixedBitSet(arr, arr.length << 6); (:81), an int shift.
  2. The numbers. desiredBit 2,000,000,000: 31,250,000 words needed, grown to 35,156,251 words (281 MB); 35,156,251 × 64 = 2,250,000,064 wraps to -2,044,967,232. Threshold after one jump from a small set: 1,908,874,241 needs 29,826,161 words and grows to 29,826,162 + 3,728,270 = 33,554,432 = 2^25 words, whose × 64 is 2^31, i.e. Integer.MIN_VALUE; 1,908,874,240 still gives 2,147,483,584. The grown size depends only on desiredBit, so every growth step to a bit of 1,908,874,241 or more wraps and every lower one does not (exhaustive sweep over all int values); when a growth happens depends on the set's history: growth in 1M-bit steps first fails at 2,105,000,003. Both are below IndexWriter.MAX_DOCS = 2,147,483,519 (index/IndexWriter.java:208).
  3. No rejection. The constructor FixedBitSet(long[], int) (util/FixedBitSet.java:160-170) computes numWords = bits2words(numBits) (((numBits - 1) >> 6) + 1, :86-90). For the 2e9 witness that is -31,952,613, so its "too small" check passes. With assertions on, assert verifyGhostBitsClear() (:169) reads bits[-31952613] (:184-185) and throws ArrayIndexOutOfBoundsException. Integer.MIN_VALUE is the special case: numBits - 1 wraps to Integer.MAX_VALUE, so the word count is +2^25, exactly the array size, the check passes and length() is Integer.MIN_VALUE in both modes. Later calls never restore a valid length: desiredBit < numBits (:64) is never true, and for any doc ID the array is already big enough, so the call keeps the same negative length; only a request for bit 2,147,483,584 or more (beyond any doc ID) can grow a 2^25-word set again, and that length wraps too.
  4. The consumer. index/DocsWithFieldSet.java: the first gap creates new FixedBitSet(docID + 1) (:54-58); every later add grows it with FixedBitSet.ensureCapacity(set, docID) (:52; addRange :84). So the field must already be sparse when a value arrives that grows the set past bit 1,908,874,241; if the first gap is itself that high, the direct constructor is fine and only a later value takes the bad path. iterator() returns new BitSetIterator(set, cardinality) (:103), which caches length = bits.length() (util/BitSetIterator.java:72) and returns NO_MORE_DOCS whenever target >= length (:94-95): at once for a negative length. DocsWithFieldSet.cardinality(), a separate counter, still counts every add. A probe on the stock jar shows the three bits are set (get(2000000000) is true), yet the bit set's own cardinality() is 0 and the iterator returns NO_MORE_DOCS at once.
  5. The writers. NumericDocValuesWriter flushes through docsWithField.iterator() (index/NumericDocValuesWriter.java:134-142, :167), and so do NormValuesWriter (:70, :93) and SortedDocValuesWriter (:249-256, :286); the binary, sorted-numeric and sorted-set writers use the same class (code reading). So the codec writes the field with no documents.
  6. Merges and vectors. Doc-values and norms merges do not use DocsWithFieldSet, so for them the loss is on the flush path. By code reading only, vectors use it on both paths: at flush, Lucene99FlatVectorsWriter's field writer adds every document with a vector (codecs/lucene99/Lucene99FlatVectorsWriter.java:499, :529) and writeMeta (:335-354) passes the set to OrdToDocDISIReaderConfiguration.writeStoredMeta; at merge, writeVectorData (:377-391) builds one over the merged doc IDs. So a sparse vector field that gets a value past doc 1.91e9, or is merged into a segment of 1.91e9+ documents, reaches the same growth; with a non-zero count and an empty iterator, DirectMonotonicWriter.finish() should then throw IllegalStateException (codecs/lucene95/OrdToDocDISIReaderConfiguration.java:86-111, util/packed/DirectMonotonicWriter.java:137-141), a loud failure (not executed).
Exposure and likelihood373 words
  • Who can be hit. A doc-values or norms field in a single in-memory segment (one DocumentsWriterPerThread) that is already sparse (an earlier document lacks it, so its DocsWithFieldSet uses a bit set) and then gets a value whose doc ID makes that bit set grow past bit 1,908,874,241. A sparse field with no value that high is not affected. Executed end to end for numeric doc values, sorted doc values and norms; binary, sorted-numeric and sorted-set doc values use the same class (code reading).
  • Defaults. An otherwise default IndexWriterConfig pinned to the default codec (Lucene104Codec, through TestUtil.getDefaultCodec(), since the test framework randomizes the codec) and an FSDirectory reproduce it. There is no per-segment document cap below IndexWriter.MAX_DOCS (DocumentsWriterPerThread.reserveOneDoc checks only the index-wide limit), and maxBufferedDocs is off by default.
  • Silent. With assertions off (the JVM default) the field's values vanish with no error. CheckIndex reports nothing for the numeric field and the norms, and flags the sorted field only as "reports wrong maxOrd=2 but this is not the case: -1". The postings are intact (docFreq 3), so the documents are still found; they only lose the field's values (sorting, faceting, norms-based scoring, FieldExistsQuery). With assertions on, addDocument throws a non-aborting ArrayIndexOutOfBoundsException for that one document and the writer stays open.
  • Scale. The field's bit set counts toward the RAM buffer, so with values spread out the default 16 MB buffer would flush long before doc 1.9e9 (code reading). The executed trigger therefore uses about 1.9 billion empty documents and one commit (344-913 s per run). The RAM limits cannot stop the final jump, though (code reading): the writer updates its RAM accounting only after DocsWithFieldSet.add returns, so the growth to 281 MB and the wrap happen before any flush can react, and the 281 MB set stays below the default per-thread hard limit of 1,945 MiB. The vector routes (a sparse vector field at flush or at merge) are code reading only and likely fail loudly.
  • Other callers (code reading only): SloppyPhraseMatcher, FieldUpdatesBuffer, ReadersAndUpdates and HnswGraphSearcher (ensureCapacityAndClear) and the misc module's MemoryAccountingBitsetCollector would get the same negative length for a bit of about 1.91e9 or more; none was run at that size.

Minimal repro

One JUnit test: ensureCapacity(new FixedBitSet(64), 2_000_000_000) must return a set longer than 2,000,000,000 bits. Deterministic, single thread, about 2 seconds and a 281 MB array. The end-to-end loss (about 2 billion documents) is described, not part of the repro.

TestFixedBitSetEnsureCapacityOverflow.java (one test)34 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 TestFixedBitSetEnsureCapacityOverflow extends LuceneTestCase {

  public void testEnsureCapacityTo2BillionBits() {
    // Expected: length() > 2,000,000,000; actual: negative (-da), or AIOOBE from the -ea check.
    FixedBitSet bits;
    try {
      bits = FixedBitSet.ensureCapacity(new FixedBitSet(64), 2_000_000_000);
    } catch (ArrayIndexOutOfBoundsException e) {
      throw new AssertionError("ensureCapacity(bits, 2000000000) threw " + e, e);
    }
    assertTrue(
        "expected length() > 2000000000 but was " + bits.length(), bits.length() > 2_000_000_000);
  }
}
Command
cd $LUCENE   # an apache/lucene checkout at 645d4fe14a20
cp $REPRO/TestFixedBitSetEnsureCapacityOverflow.java lucene/core/src/test/org/apache/lucene/util/
./gradlew :lucene:core:test --tests org.apache.lucene.util.TestFixedBitSetEnsureCapacityOverflow
# assertions off (the silent negative length): add -Ptests.asserts=false
Expected
ensureCapacity(new FixedBitSet(64), 2_000_000_000).length() is greater than 2,000,000,000.
Observed
length() is -2,044,967,232 (assertions off); with assertions on, ensureCapacity throws ArrayIndexOutOfBoundsException: Index -31952613 out of bounds for length 35156251.

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

More files, environment, full output389 words

The test sits in org.apache.lucene.util, next to TestFixedBitSet, and calls the public static FixedBitSet.ensureCapacity (the class is marked @lucene.internal). The try/catch only turns the assertions-on ArrayIndexOutOfBoundsException, thrown inside ensureCapacity by the constructor's ghost-bits check, into a failure that names the call.

As a regression test it is the same test, for example as TestFixedBitSet.testHugeCapacity next to TestLongBitSet.testHugeCapacity; it fails on stock and passes with the fix. It needs a 281 MB array and passed under gradle's default 512 MB test heap; the LongBitSet sibling is tagged @Monster("needs hundreds of MB of heap") and so does not run by default, and maintainers may want the same tag.

The end-to-end consequence (a NumericDocValuesField on docs 0, 2 and 2,000,000,000 with empty documents in between, one commit: "doc 0 lost its value" with assertions off, ArrayIndexOutOfBoundsException inside addDocument with assertions on) takes about 2 billion addDocument calls and 6-15 minutes, so it is deliberately not part of the repro.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT); the same failure on 10.6.0
jdkOpenJDK 25.0.3
osLinux aarch64 (64-bit JVM)

Observed output

# gradle, main 645d4fe14a20, seed DEADBEEF, assertions on
TestFixedBitSetEnsureCapacityOverflow > testEnsureCapacityTo2BillionBits FAILED
    java.lang.AssertionError: ensureCapacity(bits, 2000000000) threw java.lang.ArrayIndexOutOfBoundsException: Index -31952613 out of bounds for length 35156251
        Caused by:
        java.lang.ArrayIndexOutOfBoundsException: Index -31952613 out of bounds for length 35156251
            at org.apache.lucene.util.FixedBitSet.verifyGhostBitsClear(FixedBitSet.java:185)
            at org.apache.lucene.util.FixedBitSet.<init>(FixedBitSet.java:169)
            at org.apache.lucene.util.FixedBitSet.ensureCapacityInternal(FixedBitSet.java:81)
            at org.apache.lucene.util.FixedBitSet.ensureCapacity(FixedBitSet.java:50)

# assertions off (-Ptests.asserts=false)
TestFixedBitSetEnsureCapacityOverflow > testEnsureCapacityTo2BillionBits FAILED
    java.lang.AssertionError: expected length() > 2000000000 but was -2044967232

# 10.6.0 release jars: the same two failures (FixedBitSet.java:187 <- :171 <- :83 <- :52 with assertions on)

# with the fix (gradle, seed DEADBEEF): the test passes with assertions on and off

# end to end, stock main jar, assertions off (independent reproducer, 344 s)
numeric doc values lost: expected docs [1, 3, 1950000000]
sorted doc values lost: expected docs [1, 3, 1950000000]
norms lost: expected docs [1, 3, 1950000000]
maxDoc=1950000001 numDocs=1950000001 leaves=1
numeric docs with value: []
sorted docs with value: [] valueCount=3
norms docs with value: []
docFreq(t:hello)=3
CheckIndex: dv for field: s reports wrong maxOrd=2 but this is not the case: -1 (nothing reported for the numeric field or the norms)

# end to end, assertions on (665 s)
addDocument(doc 1950000000) threw java.lang.ArrayIndexOutOfBoundsException: Index -32831520 out of bounds for length 34277344
writer open after that: true, tragic=null; docs 1 and 3 keep their values

Why it looks unintended

  • The javadoc promises the result "can store a value at desiredBit index", with no bound; the class sends users to LongBitSet only above 2.1B bits, and new FixedBitSet(Integer.MAX_VALUE) works.
  • The class's own assertion rejects the result, and doc IDs up to 2,147,483,518 (below IndexWriter.MAX_DOCS) are legal.
  • The identical overflow in LongBitSet.ensureCapacity was fixed as a bug in 2015 (LUCENE-6409).
The full argument256 words
  • The contract. "Ensure the given bits can store a value at desiredBit index. If the current [#length()] is sufficient, bits is simply returned. Otherwise, a new, larger bitset is allocated, with contents of bits copied." (FixedBitSet.java:44-46). A set whose length() is negative cannot store bit 2,000,000,000.
  • The class's own limit is higher. "accessed with an int index ... If you need to manage more than 2.1B bits, use LongBitSet" (FixedBitSet.java:24-27). The failing requests start at 1,908,874,241, and new FixedBitSet(Integer.MAX_VALUE) works (executed), so the failure depends only on how the set grew.
  • Its own invariant check. With assertions on, the constructor's assert verifyGhostBitsClear() throws on the result (:169, :184-185).
  • Legal input. IndexWriter.MAX_DOCS = Integer.MAX_VALUE - 128 is the only document cap (a count, so doc IDs run to 2,147,483,518). DocsWithFieldSet is public (since 9.1.0) with no @lucene.internal tag; its javadoc states no limit on doc IDs, and add only rejects out-of-order IDs.
  • Intent and precedent. The method was added in LUCENE-5440 (2014) at a reviewer's request so the doc-values writers could grow their docs-with-field set by doc ID. The same commit gave LongBitSet.ensureCapacity the same shift; LUCENE-6409 (#7469, 5.2.0, "Fixed integer overflow in LongBitSet.ensureCapacity") cast it to long and added TestLongBitSet.testHugeCapacity. That commit edited FixedBitSet.java but not this line; a comment in #7469 quotes the FixedBitSet line only about over-allocation. TestFixedBitSet.testEnsureCapacity uses sets of at most 384 bits (literal sizes 5, 8 and 72; growth makes them 320 and 384 bits, and the reuse call asks for bit 318).
The devil's-advocate check191 words

The strongest case for "intended": FixedBitSet is @lucene.internal, the workload is absurd (one in-memory segment of about 1.9 billion nearly empty documents; with ordinary documents the 16 MB RAM buffer and the 1,945 MiB per-thread hard limit flush long before), and the upstream 2B monster tests use only dense fields, so sparse fields in near-2B segments were never meant to be supported.

Why it fails: the path is reached through public API (IndexWriter.addDocument and the public DocsWithFieldSet) with inputs inside every documented limit; no document says sparse fields stop working below MAX_DOCS; the RAM limits make the trigger rare but cannot prevent it, since the jump happens inside one add before any flush can react; the result breaks the method's own javadoc and assertion; and upstream treated the identical LongBitSet overflow as a bug. Internal status and scale lower the priority, not the validity. The reviews by another model family rated the intended-behaviour case weak (a later review by the same family rated it moderate, citing the RAM limits, and still found it defeated), and the intended-behaviour checker found no limit in javadoc, CHANGES, MIGRATE or tests.

Suggested fix

Recommended Compute the length in long and cap it at Integer.MAX_VALUE

Compute the length in long and cap it at Integer.MAX_VALUE, one line in FixedBitSet.ensureCapacityInternal. Validated through gradle: the repro passes with assertions on and off, the closest suites (81 tests), the util package plus the doc-values and norms tests (1,327 tests) and check -x test pass.

-      return new FixedBitSet(arr, arr.length << 6);
+      return new FixedBitSet(arr, (int) Math.min((long) arr.length << 6, Integer.MAX_VALUE));
All options and trade-offs4 options · 447 words

The hunt's one-line fix (without its comment line) was run through gradle in a scratch worktree of 645d4fe14a20 (OpenJDK 25.0.3, seed DEADBEEF); the worktree was removed afterwards.

Compute the length in long and cap it at Integer.MAX_VALUE (validated by the checks) (recommended)

In FixedBitSet.ensureCapacityInternal (:81). Below 2^25 words nothing changes. From 2^25 words the length is Integer.MAX_VALUE, which holds every bit index up to 2,147,483,646, so every doc ID below MAX_DOCS; bits2words(Integer.MAX_VALUE) = 2^25 words fits the array, the extra words are zero, and the top bit of the last word can never have been set, so the ghost-bits invariant holds. A Z3 query proves the capped length is greater than desiredBit and fits the array for one growth step from a set of 1 to 64 bits and every desiredBit up to 2^31 - 2. No int-length FixedBitSet can hold bit index Integer.MAX_VALUE itself; that is NO_MORE_DOCS, not a doc ID.

-      return new FixedBitSet(arr, arr.length << 6);
+      return new FixedBitSet(arr, (int) Math.min((long) arr.length << 6, Integer.MAX_VALUE));

Trade-offs

One long shift and a min on the growth path only; set and get are unchanged. A request for bit 2e9 still allocates 35,156,251 words (281 MB), of which 33,554,432 are addressable.

What the checks validated

  • The repro fails on stock (assertions on and off) and passes with the fix in both modes.
  • Closest suites (TestFixedBitSet, TestLongBitSet, TestSparseFixedBitSet, TestBitSetIterator, TestDocsWithFieldSet): 5 classes, 81 tests, 0 failures (1 skipped).
  • org.apache.lucene.util.* plus index.*DocValues*, index.*Norms* and TestDocsWithFieldSet: 144 classes, 1,327 tests, 0 failures (36 skipped).
  • :lucene:core:check -x test (formatting, ecj lint, forbidden APIs) passes with the fix and the test in place.
  • Earlier, with the class shadowed: the hunt's and the checkers' tests and 40 upstream TestFixedBitSet and TestDocsWithFieldSet tests pass.

Also cap the grown array at 2^25 words (not run)

Cap the grown size at bits2words(Integer.MAX_VALUE) words, with an exact allocation on both the preserving and the clearing path.

Trade-offs

Saves up to about 12.8 MB at this size (35,156,251 versus 33,554,432 words), at the cost of more code for a size nobody reaches today; a possible follow-up.

Set the length from the request (desiredBit + 1) instead of the array (not recommended)

Return new FixedBitSet(arr, desiredBit + 1).

Trade-offs

Callers rely on the length covering the whole array (TestFixedBitSet.testEnsureCapacity reuses up to length() - 2), and every later DocsWithFieldSet.add past that length would allocate a new FixedBitSet object.

Throw instead (Math.toIntExact or IllegalArgumentException) (not recommended)

Fail loudly when the length does not fit.

Trade-offs

Turns silent loss into a loud failure, but rejects doc IDs that IndexWriter accepts (below MAX_DOCS).

Downstream note

Downstream: an application that excludes documents by such a field (a MUST_NOT clause or a negated FieldExistsQuery) would let the affected documents through, because their values are gone.

How it was found

Semi-formal: while listing int-overflow risks in Lucene's bit sets, an AI tool read FixedBitSet.ensureCapacity and saw the int shift where LongBitSet casts to long. A Z3 bit-vector query found the smallest failing bit, 1,908,874,241, and proved the capped fix; unit tests and an end-to-end indexing run on the stock jar confirmed it.

The method in full137 words

Method: semi-formal (code reading, then an SMT solver, then executed tests), a hunt unit on bit sets, live docs and accept docs.

  • The unit wrote int-overflow obligations for the bit-set code; the 2026-10-22 study's overflow inventory had not covered FixedBitSet.
  • A Z3 bit-vector model of the growth found a counterexample, the smallest failing desiredBit from a 64-bit set (1,908,874,241), proved the method correct below it, found failing doc IDs below MAX_DOCS from larger earlier arrays too, and proved the saturating long-shift fix.
  • JUnit tests on the stock main jar confirmed the negative length and the empty DocsWithFieldSet iterator in both assertion modes, and an end-to-end test (2,000,000,001 documents, one commit) showed the lost value.
  • An independent verifier then wrote its own tests from the claim, including its own end-to-end run with numeric, sorted and norms fields.

Prior reports, history and checks

Prior reports

No prior report: 105 search-tool queries and 68 GitHub GraphQL queries over issues, PRs, commits, four mailing lists, the JIRA archive, downstream projects and the web, plus the changed files of all 413 open PRs (2026-10-26); re-checked on GitHub on 2026-10-31. The closest item is the fixed LongBitSet twin, LUCENE-6409.

Searches6 searches

Closest items, none a report of this bug: #7469 (LUCENE-6409, the same overflow in LongBitSet.ensureCapacity, fixed in 5.2.0; a comment quotes the FixedBitSet line only about over-allocation); #7486 (LUCENE-6427, the ghost-bits assertion); #6503 (LUCENE-5440, where the line came in); #8596 (LUCENE-7545, DocsWithFieldSet); #15582 (the ensureCapacityAndClear refactor); #11910 and #11921 (error-prone int-overflow passes that did not flag it); #16847 (long global doc IDs, does not touch bit-set growth). Keyword search cannot prove nobody has noticed it.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene)2026-10-25 and 2026-10-26the hunt's search tool (32 issue/PR and 6 commit queries) and 48 GraphQL queries: FixedBitSet ensureCapacity, arr.length << 6, FixedBitSet negative length, DocsWithFieldSet 2 billion, bits2words, sparse doc values lost; 63 items fetched in full (62 in the main pass plus #16566)no report; #7469 (precedent), #7486, #6503, #8596, #15582
Open PRs2026-10-26; re-checked 2026-10-31changed files of all 413 open PRs (13 touch the involved files, their diffs grepped); on 2026-10-31 the open PRs matching FixedBitSetnone changes the shift or bits2words
Mailing lists (dev@, java-user@, issues@, commits@)2026-10-2640 tool queriesno report; the 2014 dev@ notification of the pull request later filed as LUCENE-6409 names the overflow for LongBitSet only
JIRA archive2026-10-268 tool queries and read-only REST queriesLUCENE-6409, LUCENE-6427, LUCENE-5440 only
Elasticsearch, OpenSearch, Solr; web2026-10-264 downstream-repository and 20 GraphQL queries; 15 web queriesno report
GitHub re-check2026-10-31FixedBitSet ensureCapacity, FixedBitSet overflow, FixedBitSet negative length, DocsWithFieldSet, ensureCapacity overflow, arr.length << 6, bitset integer overflow, ensureCapacityInternal (issues and PRs); the diffs of the 8 open PRs matching FixedBitSet grepped for the shiftonly the items above; no open PR changes the line

History

In the method's first version: LUCENE-5440 (2014-02-10), first released in 4.7.0. An equivalent unit test fails on 26 releases from 4.7.0 to 10.6.0 (every 8.8.2-8.11.4 release, which the history clone has no tags for, included) and on main; the lost-values path needs DocsWithFieldSet (7.0.0). LUCENE-6409 fixed the LongBitSet twin in 5.2.0 but left this line. Not fixed upstream.

Versions tested9 rows

Introduced by b872f4b5c3c5 (LUCENE-5440, migrated as #6503, 2014-02-10; same-day branch_4x backport bc1797f526ae). ensureCapacity was added in that review so the doc-values writers could grow their docs-with-field bit set; the overflowing line is in its first version, so it is not a regression. Found by git blame and git log -S over the full history; not bisected at commit level.

Later: LUCENE-6409 (69af3beaed8; in 5.2.0 through the backport 84820b04187) cast the LongBitSet shift to long and edited FixedBitSet.java without touching this line. LUCENE-6427 (5.2.0) added the ghost-bits assertion, so from 5.2.0 assertions-on runs throw; before it the negative length was silent in both modes. LUCENE-7545 (7.0.0) added DocsWithFieldSet, which grows through this method. #15582 (10.4.0) moved the line into ensureCapacityInternal, so ensureCapacityAndClear shares it.

Not fixed upstream (2026-10-31): main 2f424fd428d, branch_11x, branch_10_6 and the 10.6.0 tag still compute arr.length << 6; branch_9x was not checked. The history clone has no 8.8.2-8.11.4 tags; those nine releases were executed instead. What a 4.7.0-6.6.6 flush does at this scale was not executed.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedthe minimal test: length -2,044,967,232 with assertions off, ArrayIndexOutOfBoundsException with assertions on (gradle and the standalone runner)
10.6.0 affectedthe minimal test fails the same way with assertions on and off
main ea57360912ed end to endone segment of 1,950,000,001 docs lost every numeric, sorted and norms value (assertions off); 2,000,000,001 docs: doc 0 lost its value
7.0.0, 7.7.3, 8.11.4, 9.12.3, 10.5.2 affectedDocsWithFieldSet with docs 0, 2 and 2,000,000,000: cardinality 3, the iterator returns no document
5.2.0 through 10.5.2 (13 releases run) affectedensureCapacity to bit 2e9: 35,156,251 words, length -2,044,967,232 (assertions off); with assertions on it throws (run on 5.2.0, 8.11.4, 9.12.3 and 10.5.2)
8.8.2, 8.9.0, 8.10.0, 8.10.1, 8.11.0, 8.11.1, 8.11.2, 8.11.3 affectedensureCapacity to bit 2e9: 35,156,251 words, length -2,044,967,232 (assertions off); the history clone has no tags for these releases, so they were run on the release jars (2026-10-31)
4.7.0, 4.10.4, 5.0.0, 5.1.0 affectedthe same negative length; silent even with assertions on (run on 4.7.0 and 5.1.0; no ghost-bits assertion yet)
4.6.1 not affectedno FixedBitSet.ensureCapacity (compile error)
LongBitSet twin: 4.7.0, 5.1.0 vs 5.2.0, main sibling classthe same overflow fails on 4.7.0 and 5.1.0 and is fixed from 5.2.0 (LUCENE-6409)

Independent checks

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

Checks and verdicts320 words

Each checker that executed wrote its own test from the claim; the adjudication re-ran the finder's and the independent reproducer's tests with a new seed.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidateZ3: smallest failing bit 1,908,874,241, correct below it, the capped fix proved. Unit tests fail with assertions on and off on main, 9.12.3 and 10.5.2; the end-to-end flush loses the value (assertions off) or throws in addDocument (on).
refute review (OpenAI GPT-5.6) real, highIts own probe fails at exactly 1,908,874,241 (Integer.MIN_VALUE) and its DocsWithFieldSet probe iterates nothing for cardinality 3; both pass with the fix, and 40 upstream tests pass.
Independent reproducer realIts own tests fail on main, 9.12.3 and 10.5.2 in both modes with the exact boundary; its own end-to-end run lost all numeric, sorted and norms values of a 1,950,000,001-document segment with the postings intact.
Works-as-designed review (OpenAI GPT-5.6) real, highIts own 6-test class fails 2 on main in both modes while 4 controls pass; intended-behaviour argument weak.
Intended-behaviour devil's advocate real, highNo documented limit below 2.1B bits; gradual growth fails from 2,105,000,003; new FixedBitSet(Integer.MAX_VALUE) works.
adjudication confirmed, highRe-ran the finder's tests and the independent reproduction's tests with seed 7A11E5ED in both modes, and the independent reproduction's tests with the fix (all pass); read the independent reproduction's end-to-end output instead of re-running 2 billion documents.
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 validatedThe minimal test on the 10.6.0 jars (fails, both modes) and on main through gradle; the fix through gradle with the closest suites, the util package, the doc-values and norms tests and check. Also on 2026-10-31: the 8.8.2-8.11.3 releases, a probe of the statements on this page and the prior-report re-check.

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,569 words

Finding record

LFV-137, snapshot of 2026-11-06

Registry title
FixedBitSet.ensureCapacity computes the grown length as int arr.length << 6, which wraps negative once the requested bit reaches about 1.91 billion, so DocsWithFieldSet iterates no documents and a flushed segment silently drops every value of its sparse doc-values and norms fields
Area
util / FixedBitSet growth (ensureCapacity) and index / DocsWithFieldSet (doc-values and norms flush)
Priority
Low, rank 985: FixedBitSet.ensureCapacity wraps, so flush silently drops every value of sparse doc-values and norms fields; severe, but it takes one in-memory segment of about 1.9 billion nearly empty documents with no flush, so it is contrived.
Impact class
data corruption
Impact note
Hits an application whose single in-memory segment reaches doc IDs of about 1.91e9 or more (still below IndexWriter.MAX_DOCS = 2^31-129) while a doc-values or norms field is sparse, with the default IndexWriterConfig, codec and directory: with assertions disabled (the production default) the committed segment silently has no value for that field on any document (executed for numeric doc values, sorted doc values and norms; postings stay intact; CheckIndex flags only the sorted field), while with -ea addDocument throws a non-aborting ArrayIndexOutOfBoundsException for that one document; the executed trigger needs about 1.9 billion nearly empty documents with no flush in between, so it is extreme-scale and contrived; a sparse vector field merged into a segment of 1.91e9+ docs reaches the same growth by code reading only (not executed); released since 4.7.0.
Summary
You index into one segment until doc IDs pass about 1.91 billion (legal: IndexWriter.MAX_DOCS is 2^31-129) with a doc-values or norms field that only some documents have, then commit; you expect every indexed value to be in the segment. Instead, when the field's DocsWithFieldSet grows its FixedBitSet to the new doc ID, FixedBitSet.ensureCapacity computes the new length as arr.length << 6 in int arithmetic, which wraps negative once the backing array has 2^25 words, so the set's iterator returns no document although cardinality() counts them, and the flushed segment has no document with a value for that field (with -da; with -ea addDocument throws ArrayIndexOutOfBoundsException for that document). The same call on a small set, ensureCapacity(new FixedBitSet(64), 2_000_000_000), returns a set with length() = -2,044,967,232; the twin LongBitSet.ensureCapacity had the identical overflow and was fixed in 2015 (LUCENE-6409), but FixedBitSet was not.
Introduced
b872f4b5c3c5 (LUCENE-5440, migrated as apache/lucene#6503 'Add LongFixedBitSet and replace usage of OpenBitSet', by shaie, svn r1566662 on trunk; same-day branch_4x backport bc1797f526ae, svn r1566670) added FixedBitSet.ensureCapacity with the line 'return new FixedBitSet(arr, arr.length << 6);' during review (mikemccand asked for an ensureCapacity so the doc-values writers could grow a FixedBitSet; the same commit switched NumericDocValuesWriter and BinaryDocValuesWriter to it) [read: git blame attributes FixedBitSet.java:81 at main to b872f4b5c3c and git log -S/-G on the line over all refs finds no other commit that adds or changes it, in the history clone; release-level execution only: 4.6.1 has no such method (compile error), 4.7.0 fails; no commit-level bisect]; later commits kept the shift: 69af3beaed8 / 84820b04187 (LUCENE-6409, #7469, 2015-04-13, fixed the same overflow in LongBitSet and touched FixedBitSet.bits2words only), 701b7ddf3a2 / 8ea0408615c (LUCENE-6427, #7486, 2015-04-28, added the ghost-bit assertion that throws under -ea), 59df9e799ce (#15582, 2026-01-16, moved the line into ensureCapacityInternal, shared with the new ensureCapacityAndClear) (2014-02-10 (LUCENE-5440 committed on trunk and backported to branch_4x the same day))
First affected version
4.7.0 [exec]; 4.6.1 and older have no FixedBitSet.ensureCapacity (compile error) [exec]
Affected versions
every release from 4.7.0 through 10.6.0 and main ea57360912ed. The unit witness (ensureCapacity(new FixedBitSet(64), 2e9) returns length() = -2044967232 under -da; the 1.9e9 control passes) is executed and failing on 4.7.0, 4.10.4, 5.0.0, 5.1.0, 5.2.0, 5.5.5, 6.0.0, 6.6.6, 7.0.0, 7.7.3, 8.0.0, 8.11.4, 9.0.0, 9.1.0, 9.12.3, 10.0.0, 10.5.2 and main [exec]; all 110 release tags from 4.7.0 to 10.5.2 in the history clone carry the identical line (the clone lacks the 8.8.2-8.11.4 tags; 8.11.4 executed, and 8.8.2, 8.9.0, 8.10.0, 8.10.1, 8.11.0, 8.11.1, 8.11.2 and 8.11.3 executed on the release jars on 2026-10-31, -da) [read]. The DocsWithFieldSet symptom (cardinality 3, empty iterator) needs DocsWithFieldSet (7.0.0, LUCENE-7545) and is executed on 7.0.0, 7.7.3, 8.11.4, 9.12.3, 10.5.2 and main [exec]; whether a 4.7.0-6.6.6 flush at this scale loses values was not executed and is not claimed. The end-to-end silent loss is executed on main only. Under -ea, 4.7.0-5.1.0 return the negative length silently; from 5.2.0 the ghost-bit assertion (LUCENE-6427) throws ArrayIndexOutOfBoundsException [exec]. Upstream main, branch_10x, branch_10_6 and branch_9_12 still have the line (2026-10-26) [read], and on 10.6.0 (released 2026-10-29) the minimal test fails on the release jars (SHA-1 checked against Maven Central) with -ea and -da [exec 2026-10-31]; branch_9x not checked; not fixed upstream [read]; executed on aarch64 Linux with JDK 25 (64-bit JVM) only
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-25 and 2026-10-26: follow-up scripted prior-report searches with 105 queries (32 GitHub issue/PR, 6 commit, 4 downstream-repo, 40 mailing-list (dev@, java-user@, issues@, commits@), 8 JIRA and 15 web), 68 GitHub GraphQL queries (48 on apache/lucene, 20 on Elasticsearch, OpenSearch, Solr, the archived lucene-solr and all of GitHub), 62 items fetched in full and searched for overflow terms, read-only JIRA REST queries, the changed-file lists of all 413 open apache/lucene PRs (13 touch the involved files; none changes ensureCapacity, the shift or bits2words), FixedBitSet.java on upstream main, branch_10x, branch_10_6 and branch_9_12 read (line present on all four), git history; the finder's earlier 51-query run, the intended-behaviour check's 27-query run and the adjudication's read-only gh searches also found nothing; gaps: branch_9x returned 404 for the file, and the reused 2014-2015 lucene-solr PR numbers were read through the JIRA mirror and the dev@ notification); related prior items, none a report of this bug: #7469 (LUCENE-6409, the identical int-shift overflow in LongBitSet.ensureCapacity, fixed in 5.2.0 by 69af3beaed8, which edited FixedBitSet.java but not its shift; a comment there quotes the FixedBitSet line only about over-allocation), #7486 (LUCENE-6427, ghost-bit assertion), #6503 (LUCENE-5440, introduction of the line), #8596 (LUCENE-7545, DocsWithFieldSet), #15582 (ensureCapacityAndClear refactor), #11910/#11921 (error-prone int-overflow passes that did not flag it), LUCENE-7791 (a different DV-flush AIOOBE), #16847 (long global doc IDs; does not touch bit-set growth)
Lucene commit tested
main 645d4fe14a20 (gradle and the standalone runner, assertions on and off; the fix through gradle in a scratch worktree of the same commit); the minimal test on the 10.6.0 release jars (fails with assertions on and off); the checkers' equivalent tests on 17 releases from 4.7.0 to 10.5.2 and on main ea57360912ed, where the involved files are identical, and the same check on the eight releases 8.8.2 to 8.11.3, which the history clone has no tags for (assertions off); the end-to-end runs (about 2 billion documents) on main ea57360912ed only. All runs on Linux aarch64 with OpenJDK 25.0.3 (64-bit JVM). Upstream main 2f424fd428d (2026-10-30), branch_11x, branch_10_6 and the 10.6.0 tag have the same line.

Lucene and upstream sources

Finding record
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/util/FixedBitSet.java:24-29 (class javadoc: 'accessed with an int index ... If you need to manage more than 2.1B bits, use LongBitSet', @lucene.internal), :44-51 (ensureCapacity javadoc 'Ensure the given bits can store a value at desiredBit index ... a new, larger bitset is allocated'), :53-60 (ensureCapacityAndClear), :62-83 (ensureCapacityInternal; :76-78 ArrayUtil.grow / oversize; :81 'return new FixedBitSet(arr, arr.length << 6);'), :160-170 (FixedBitSet(long[], int): 'too small' check passes for a negative numBits; :169 assert verifyGhostBitsClear()), :184 (ghost-bit loop that reads bits[-31952613] under -ea)
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/util/LongBitSet.java:55 ('return new LongBitSet(arr, (long) arr.length << 6);'); lucene/core/src/test/org/apache/lucene/util/TestLongBitSet.java:355-365 (testHugeCapacity asserts length() >= the requested size); lucene/core/src/test/org/apache/lucene/util/TestFixedBitSet.java:381 (testEnsureCapacity, small sizes only)
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/index/DocsWithFieldSet.java:25-29 (public final class, 'Accumulator for documents that have a value for a field', no @lucene.internal), :46-61 (add; :52 FixedBitSet.ensureCapacity(set, docID)), :70-94 (addRange; :84 ensureCapacity), :102-103 (iterator: new BitSetIterator(set, cardinality))
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/util/BitSetIterator.java:72 (length = bits.length()), :93-98 (advance returns NO_MORE_DOCS when target >= length)
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/index/NumericDocValuesWriter.java:44, :60, :107 (DocsWithFieldSet add and flush through docsWithField.iterator()); NormValuesWriter.java:38, :55, :79, :93; IndexWriter.java:208 (MAX_DOCS = Integer.MAX_VALUE - 128)
The finding in 30 seconds
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/util/FixedBitSet.java:24-29, :44-90, :160-198
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/util/ArrayUtil.java:133-200, :409-414
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/index/DocsWithFieldSet.java:25-109
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/util/BitSetIterator.java:67-98
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/index/NumericDocValuesWriter.java:60, :134-167; NormValuesWriter.java:55, :70-93; SortedDocValuesWriter.java:93, :249-286; IndexWriter.java:208
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/codecs/lucene99/Lucene99FlatVectorsWriter.java:300-391, :487-532; codecs/lucene95/OrdToDocDISIReaderConfiguration.java:68-111; util/packed/DirectMonotonicWriter.java:137-141 (code reading)
Who is affected
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/index/DocumentsWriterPerThread.java:211-237; IndexWriterConfig.java:78, :83, :92-93; DocumentsWriterFlushControl.java:239-243; NumericDocValuesWriter.java:60-62, :96-100; DocsWithFieldSet.java:51-58; lucene/test-framework/src/java/org/apache/lucene/tests/util/TestUtil.java:1330-1332
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/search/SloppyPhraseMatcher.java:331; index/FieldUpdatesBuffer.java:172; index/ReadersAndUpdates.java:754; util/hnsw/HnswGraphSearcher.java:372; lucene/misc/src/java/org/apache/lucene/misc/search/MemoryAccountingBitsetCollector.java:57
Minimal repro
  • a Lucene main checkout lucene/core/src/test/org/apache/lucene/util/TestLongBitSet.java:354 (@Monster); build-tools/build-infra/src/main/java/org/apache/lucene/gradle/plugins/java/TestsAndRandomizationPlugin.java:132 (tests.heapsize 512m)
Why it looks unintended
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/util/FixedBitSet.java:24-29, :44-60, :169, :183-193
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/index/DocsWithFieldSet.java:25-50; IndexWriter.java:202-208; DocumentsWriterPerThread.java:211-218
Suggested fix
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/util/FixedBitSet.java:62-90, :183-193; lucene/core/src/test/org/apache/lucene/util/TestFixedBitSet.java:381-401
History
  • a Lucene clone: git show 2f424fd428d, origin/branch_11x, origin/branch_10_6 and releases/lucene/10.6.0 of FixedBitSet.java (read, 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 its probe runs on the stock main and 10.6.0 jars and with the fix.

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.