All findings · Lucene finding · LFV-003 · bulk-binaryvalues-2gib

Bulk BinaryDocValues#binaryValues sizes its buffer in int

The default codec's bulk BinaryDocValues#binaryValues, new in 10.6, sizes its buffer in int: one call for 2 GiB or more fails; from 4 GiB, a contiguous call with assertions off can return other documents' bytes. CheckIndex at level 2 or 3 can call healthy indexes of huge fixed-length values broken.

Summary

Priority
Low, rank 991An int overflow at 2 GiB per call in a bulk API new in 10.6.0 whose only in-tree caller is CheckIndex at a non-default level on huge values.
Impact
crash
Since
10.6.0 (released 2026-10-29)introduced 2026-06-30
Affected
main and branch_10_6 (branch_10x has the same code); no release up to 10.5.2 has the API
Status
documented
Found by
CodeQL inventory of int arithmetic, then Z3 bit-vector counterexamples; cvc5 agreed later

AI-assisted page: this finding was first signalled by a formal tool's own output and then checked with AI tools; the checks it went through, and what each found, are under Prior reports, history and checks. Not yet triaged by a human. Snapshot of 2026-11-06.

The finding in 30 seconds

  1. Index three documents with a 1 GiB binary doc value each on the default codec, and forceMerge(1) them into one segment.
  2. Fetch docs 0 and 2 in one bulk call, binaryValues(2, {0, 2}, values), as CheckIndex does on a 3-document segment. You expect the per-value loop's result, first bytes 0 and 2.
  3. Instead the codec sizes one shared array for both values as an int: 2 x 2^30 wraps to -2,147,483,648, and the call throws NegativeArraySizeException.
  4. The branch_10_6 or main CheckIndex -level 2, on such an index written by 10.5.2, prints docvalues ERROR [-2147483648] and "1 broken segments" and exits 1, where 10.5.2's finds no problems; with -exorcise, the branch_10_6 one dropped the segment and all 3 of its documents.
  5. From 4 GiB the size can wrap to a small positive number: one contiguous call for docs 0 to 31, 32 values of 138,547,333 bytes, with assertions off, returns normally with 31 of the 32 values wrong.

Who is affected

CheckIndex at level 2 or 3 (not the default 1) and callers of the new bulk API on the default codec via a plain DirectoryReader or SegmentReader. Rare: one call on a dense field must total 2^31 - 2 bytes or more; for CheckIndex, fixed-length values of 128 MiB or more. Older-release indexes are flagged too. First released in 10.6.0 (2026-10-29).

Mechanism, terms and numbers584 words

On the default codec, the bulk read of binary doc values that is new in 10.6 puts the requested values of a dense field into one shared array whose size is computed as a Java int. One call that asks for 2 GiB or more fails; at 4 GiB or more the size can wrap and, with assertions off, the call can return other documents' bytes. CheckIndex at level 2 or 3 makes such calls itself, so it can report a healthy index as broken.

Binary doc values store one byte string per document, column-wise (BinaryDocValuesField). A field is dense when every document has a value, and fixed length when all values have the same length. The writer accepts values of up to ArrayUtil.MAX_ARRAY_LENGTH bytes each (2,147,483,631 on this JVM), and segments may hold far more than 2 GiB in total.

#16286 added a bulk read, BinaryDocValues#binaryValues(size, docs, values), new in 10.6.0. Its javadoc says the result "behaves as if implemented as below, which is the default implementation": a loop of advanceExact(doc) plus BytesRef.deepCopyOf(binaryValue()). It states no size limit.

The default codec stores doc values with Lucene90DocValuesFormat, and for dense fields it overrides the bulk method. It reads all requested values into one shared byte[], and every returned BytesRef is a window into it. The array size is a Java int (at most 2^31 - 1 = 2,147,483,647), and larger values wrap around by 2^32:

  • fixed length: byte[] bulk = new byte[size * length]; (main Lucene90DocValuesProducer.java:1347), computed before the contiguity check, so non-contiguous requests use it too;
  • variable length, consecutive doc ids: int totalBytes = (int) (lastEnd - firstStart); then new byte[totalBytes] (main :1393-1394);
  • the window offsets use the same int arithmetic (:1352, :1356, :1399).

What a caller sees, by the total T requested in one call

Tresult
up to 2^31 - 3correct
2^31 - 2 or 2^31 - 1OutOfMemoryError: Requested array size exceeds VM limit (the JVM's own array limit)
2^31 to 2^32 - 1NegativeArraySizeException; 2 docs of 2^30 bytes give -2147483648
2^32 or morethe size can wrap to a small number; a contiguous request with assertions off can return normally with wrong windows

The wrong-bytes case

32 documents of 138,547,333 bytes, one call for docs 0 to 31: T = 4,433,514,656 wraps to 138,547,360, so the array holds doc 0 plus the first 27 bytes of doc 1. With assertions off the call returns normally and 31 of the 32 values differ from the per-value API. Doc 0's window is correct. Doc 31's offset, 31 x 138,547,333 = 4,294,967,323, wraps to 27: its window is in bounds and passes isValid(), but it holds doc 0's bytes from position 27 on. The other 30 windows are out of bounds. With -ea the call throws IllegalStateException: offset+length out of bounds from the BytesRef constructor's assertion.

CheckIndex

#16286 also made CheckIndex call the bulk API (checkBulkFetchBinaryDocValues, main CheckIndex.java:4193-4234): checkIndex() runs it at level 2 or higher, and the public static CheckIndex.testDocValues runs it with no level gate. It asks in batches of up to 16 documents with gaps (0, 2, 5, 9, ...). A batch of two or more documents is never contiguous, so only fixed-length fields are affected. When one batch totals at least 2^31 - 2 bytes, CheckIndex prints test: docvalues...........ERROR [...] and "1 broken segments", and exits 1. Following its advice, -exorcise dropped the segment and every document in it.

Exposure and likelihood339 words
  • Who is exposed. Callers of the new bulk API on the default codec that hold the codec's own iterator (a plain DirectoryReader or SegmentReader). Wrapping readers skip the override, because FilterBinaryDocValues does not forward binaryValues (for example ExitableDirectoryReader), and no query, collector or sort in Lucene calls it. The one in-tree production caller is CheckIndex at -level 2 or -level 3; the default CLI level 1 is not affected.
  • Symptoms. Usually loud: an OutOfMemoryError or NegativeArraySizeException. Silent wrong bytes need at least 4 GiB in one contiguous call with assertions off; the wrong bytes always belong to other documents in the same call, field and segment.
  • CheckIndex reports a valid index as broken. It is a read-side bug: indexes written by the released 10.5.2 and 9.12.3 jars are flagged as soon as the 10.6 CheckIndex reads them at level 2. Smallest triggering shapes: a 3-to-5-document segment of equal values of at least 2^30 - 1 bytes (about 3 GiB), or 40 or more documents of at least 128 MiB (5 GiB). -exorcise then removes the segment; opening an IndexWriter afterwards deletes its files for good.
  • Scale. Rare: one call must total at least 2^31 - 2 bytes, for example 2 x 1 GiB or 32,768 x 64 KiB. Typical binary values are small.
  • Test suites. TestUtil.checkIndex and the BaseDirectoryWrapper check on close (both at level 3), and TestUtil.checkReader (through the ungated CheckIndex.testDocValues), reach it only when the doc values are not wrapped by the Asserting codec, whose AssertingBinaryDocValues runs the default loop. Downstream test suites with such data can fail with some random codecs; Lucene's randomized tests with the Asserting codec cannot catch it.
  • Released in 10.6.0. The API first shipped in 10.6.0 (released 2026-10-29); releases 9.0.0 to 10.5.2 do not have it. The existing bulk tests use 512 documents of 8 bytes, or of 1 to 32 bytes. The large tests written for this bug (local branches, not upstream) are @Monster, which is off by default, and no in-repo workflow enables it.

Minimal repro

A 22-line @Monster test for TestLucene90DocValuesFormat: three 1 GiB values with the default codec, merged into one segment, then docs {0, 2} in one bulk call. It applies to main and branch_10_6 with git apply and needs an 8g heap and about 6 GB of free disk. Plain-Java versions are in the collapsed block below.

minimal-test.diff (JUnit, TestLucene90DocValuesFormat)42 lines
diff --git a/lucene/core/src/test/org/apache/lucene/codecs/lucene90/TestLucene90DocValuesFormat.java b/lucene/core/src/test/org/apache/lucene/codecs/lucene90/TestLucene90DocValuesFormat.java
index b151d8ca7e5..86a45d1fb7e 100644
--- a/lucene/core/src/test/org/apache/lucene/codecs/lucene90/TestLucene90DocValuesFormat.java
+++ b/lucene/core/src/test/org/apache/lucene/codecs/lucene90/TestLucene90DocValuesFormat.java
@@ -65,6 +65,7 @@ import org.apache.lucene.index.TermsEnum.SeekStatus;
 import org.apache.lucene.store.ByteBuffersDataInput;
 import org.apache.lucene.store.ByteBuffersDataOutput;
 import org.apache.lucene.store.Directory;
+import org.apache.lucene.store.FSDirectory;
 import org.apache.lucene.store.FilterDirectory;
 import org.apache.lucene.store.FilterIndexInput;
 import org.apache.lucene.store.IOContext;
@@ -374,6 +375,29 @@ public class TestLucene90DocValuesFormat extends BaseCompressingDocValuesFormatT
     }
   }
 
+  @Monster("writes 3GB of binary doc values, needs -Ptests.heapsize=8g")
+  public void testBinaryValuesBulkFetchOver2GB() throws Exception {
+    try (Directory dir = FSDirectory.open(createTempDir())) {
+      try (IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
+        for (int i = 0; i < 3; i++) {
+          byte[] bytes = new byte[1 << 30]; // 1GB value; the limit is ArrayUtil.MAX_ARRAY_LENGTH
+          bytes[0] = (byte) i;
+          Document doc = new Document();
+          doc.add(new BinaryDocValuesField("f", new BytesRef(bytes)));
+          w.addDocument(doc);
+        }
+        w.forceMerge(1);
+      }
+      try (DirectoryReader reader = DirectoryReader.open(dir)) {
+        BinaryDocValues values = getOnlyLeafReader(reader).getBinaryDocValues("f");
+        BytesRef[] actual = new BytesRef[2];
+        values.binaryValues(2, new int[] {0, 2}, actual); // 2 x 1GB in one call
+        assertEquals(0, actual[0].bytes[actual[0].offset]);
+        assertEquals(2, actual[1].bytes[actual[1].offset]);
+      }
+    }
+  }
+
   public void testSparseBinaryValuesBulkFetch() throws Exception {
     final int numDocs = 512;
     final BytesRef[] expected = new BytesRef[numDocs];
Command
git apply minimal-test.diff
./gradlew --no-daemon --max-workers=3 :lucene:core:test \
  --tests "org.apache.lucene.codecs.lucene90.TestLucene90DocValuesFormat.testBinaryValuesBulkFetchOver2GB" \
  -Ptests.monster=true -Ptests.heapsize=8g -Ptests.jvms=2 -Ptests.seed=DEADBEEF -Ptests.asserts=true
# also -Ptests.asserts=false; main randomizes tests.asserts from the seed, branch_10_6 defaults it to true
Expected
Both values come back as the per-value loop returns them: first bytes 0 and 2.
Observed
NegativeArraySizeException: -2147483648; on a 3 x 1 GiB index written by 10.5.2, the branch_10_6 or main CheckIndex -level 2 prints docvalues ERROR [-2147483648] and exits 1 (10.5.2's finds no problems).

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

More files, environment, full output2 more files (162 lines) · 1,047 words

A short @Monster test, a 22-line method plus one import, added to the existing TestLucene90DocValuesFormat. It writes three 1 GiB values with the default codec, merges them into one segment, and fetches docs {0, 2} (2 x 1 GiB) in one bulk call: the same documents CheckIndex asks for on a 3-document segment. It applies to main and to branch_10_6 with git apply. Use an 8g heap; a run needs about 6 GB of free disk while it merges, and a failing run leaves about 3.1 GB under lucene/core/build/tests-tmp (./gradlew :lucene:core:clean removes it).

The test assumes forceMerge(1) keeps the document order: TieredMergePolicy breaks size ties by segment name, the three 1-document segments should be the same size (not measured), and every passing run with the fix kept the order. To make it order-proof, compare each bulk value with the per-value API instead.

Plain Java on the released jars: MakeIndex.java (writes the index)

No test framework: write the index with the released 10.5.2 jar, then run the real CheckIndex CLI and a direct bulk call with a lucene-core jar built from branch_10_6 or main. java -Xmx8g -cp <classes>:<lucene-core-10.5.2.jar> MakeIndex <dir> 3 1073741824, then java -Xmx8g -cp <lucene-core jar> org.apache.lucene.index.CheckIndex <dir> -level 2.

Plain Java on the released jars: MakeIndex.java (writes the index)56 lines
import java.nio.file.Paths;
import java.util.Arrays;
import org.apache.lucene.document.BinaryDocValuesField;
import org.apache.lucene.document.Document;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.index.LeafReader;
import org.apache.lucene.index.SegmentReader;
import org.apache.lucene.store.Directory;
import org.apache.lucene.store.FSDirectory;
import org.apache.lucene.util.BytesRef;
import org.apache.lucene.util.Version;

/**
 * Writes one segment with a dense, fixed-length BinaryDocValuesField "f": numDocs docs, each value
 * `length` bytes. Every byte of doc d is ('A' + d), except the last byte, which is ('a' + d).
 * Public API only, default codec, default merge policy and scheduler. Compiles against 10.5.2.
 *
 * <p>Usage: java -cp lucene-core.jar:. MakeIndex DIR NUMDOCS LENGTH
 */
public class MakeIndex {
  public static void main(String[] args) throws Exception {
    String path = args[0];
    int numDocs = Integer.parseInt(args[1]);
    int length = Integer.parseInt(args[2]);
    System.out.println("lucene=" + Version.LATEST + " numDocs=" + numDocs + " length=" + length);
    byte[] bytes = new byte[length];
    try (Directory dir = FSDirectory.open(Paths.get(path));
        IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
      for (int d = 0; d < numDocs; d++) {
        Arrays.fill(bytes, (byte) ('A' + d));
        bytes[length - 1] = (byte) ('a' + d);
        Document doc = new Document();
        doc.add(new BinaryDocValuesField("f", new BytesRef(bytes)));
        w.addDocument(doc);
        w.flush();
      }
      w.forceMerge(1);
      w.commit();
    }
    try (Directory dir = FSDirectory.open(Paths.get(path));
        DirectoryReader r = DirectoryReader.open(dir)) {
      LeafReader leaf = r.leaves().get(0).reader();
      System.out.println(
          "leaves="
              + r.leaves().size()
              + " numDocs="
              + r.numDocs()
              + " codec="
              + ((SegmentReader) leaf).getSegmentInfo().info.getCodec().getName()
              + " dvFormat="
              + leaf.getFieldInfos().fieldInfo("f").getAttribute("PerFieldDocValuesFormat.format"));
    }
  }
}

Plain Java: BulkCall.java (one bulk call, compared with the per-value API)

java -Xmx10g -cp <classes>:<lucene-core jar> BulkCall <dir> 0,2 0,1; for the controls, add -ea, or put default as the first argument, before <dir>.

Plain Java: BulkCall.java (one bulk call, compared with the per-value API)106 lines
import java.nio.file.Paths;
import org.apache.lucene.index.BinaryDocValues;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.FilterBinaryDocValues;
import org.apache.lucene.index.LeafReader;
import org.apache.lucene.store.Directory;
import org.apache.lucene.store.FSDirectory;
import org.apache.lucene.util.BytesRef;
import org.apache.lucene.util.Version;

/**
 * Opens the index written by MakeIndex and makes ONE bulk BinaryDocValues#binaryValues call per
 * doc-id list given on the command line (e.g. "0,2" "0,1,2"). Each returned value is compared with
 * the per-value API (advanceExact + binaryValue) on a second iterator. With the first argument
 * "default", the iterator is wrapped in a FilterBinaryDocValues, which does not override
 * binaryValues, so the documented default implementation runs instead (control).
 *
 * <p>Usage: java -cp lucene-core.jar:. BulkCall [default] DIR DOCS...
 */
public class BulkCall {
  public static void main(String[] args) throws Exception {
    int a = 0;
    boolean useDefault = args[0].equals("default");
    if (useDefault) a++;
    String path = args[a++];
    boolean ea = false;
    assert ea = true;
    System.out.println(
        "lucene=" + Version.LATEST + " assertions=" + ea + " mode=" + (useDefault ? "default-impl" : "codec-bulk"));
    try (Directory dir = FSDirectory.open(Paths.get(path));
        DirectoryReader r = DirectoryReader.open(dir)) {
      LeafReader leaf = r.leaves().get(0).reader();
      for (; a < args.length; a++) {
        String[] parts = args[a].split(",");
        int[] docs = new int[parts.length];
        for (int i = 0; i < docs.length; i++) docs[i] = Integer.parseInt(parts[i]);
        BinaryDocValues bulk = leaf.getBinaryDocValues("f");
        if (useDefault) bulk = new FilterBinaryDocValues(bulk) {};
        BytesRef[] values = new BytesRef[docs.length];
        long requested = 0;
        BinaryDocValues single = leaf.getBinaryDocValues("f");
        for (int d : docs) {
          single.advanceExact(d);
          requested += single.binaryValue().length;
        }
        String head = "binaryValues(" + docs.length + ", {" + args[a] + "}) requested=" + requested + " B";
        try {
          bulk.binaryValues(docs.length, docs, values);
        } catch (Throwable t) {
          StackTraceElement top = t.getStackTrace().length > 0 ? t.getStackTrace()[0] : null;
          System.out.println(head + " -> THREW " + t + (top == null ? "" : "  at " + top));
          continue;
        }
        System.out.println(head + " -> returned normally");
        BinaryDocValues check = leaf.getBinaryDocValues("f");
        int wrong = 0;
        for (int i = 0; i < docs.length; i++) {
          BytesRef v = values[i];
          check.advanceExact(docs[i]);
          BytesRef expected = check.binaryValue();
          String valid;
          try {
            v.isValid();
            valid = "true";
          } catch (Throwable t) {
            valid = "false (" + t.getClass().getSimpleName() + ")";
          }
          String same;
          try {
            same = String.valueOf(expected.bytesEquals(v));
          } catch (Throwable t) {
            same = "n/a (" + t.getClass().getSimpleName() + ")";
          }
          String last;
          try {
            last = String.valueOf((char) v.bytes[v.offset + v.length - 1]);
          } catch (Throwable t) {
            last = t.getClass().getSimpleName();
          }
          if (!"true".equals(same)) wrong++;
          if (docs.length <= 8 || !"true".equals(same) && (i < 3 || i >= docs.length - 3)) {
            System.out.println(
                "  doc "
                    + docs[i]
                    + ": offset="
                    + v.offset
                    + " length="
                    + v.length
                    + " bytes.length="
                    + v.bytes.length
                    + " isValid="
                    + valid
                    + " equalsPerValue="
                    + same
                    + " lastByte="
                    + last
                    + " (expected "
                    + (char) expected.bytes[expected.offset + expected.length - 1]
                    + ")");
          }
        }
        System.out.println("  values differing from the per-value API: " + wrong + " of " + docs.length);
      }
    }
  }
}

Environment

lucenemain b83101cdd2 and branch_10_6 d04ee73177 fail; releases up to 10.5.2 do not have the API
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64

Observed output

# branch_10_6 d04ee73177, seed DEADBEEF
TestLucene90DocValuesFormat > testBinaryValuesBulkFetchOver2GB FAILED
    java.lang.NegativeArraySizeException: -2147483648
        at org.apache.lucene.codecs.lucene90.Lucene90DocValuesProducer$16.binaryValues(Lucene90DocValuesProducer.java:1350)
        at org.apache.lucene.index.BinaryDocValues.binaryValues(BinaryDocValues.java:84)
        at org.apache.lucene.codecs.lucene90.TestLucene90DocValuesFormat.testBinaryValuesBulkFetchOver2GB(TestLucene90DocValuesFormat.java:394)
# main b83101cdd2: the same exception at Lucene90DocValuesProducer.java:1347

# CheckIndex CLI on 3 x 1 GiB written by 10.5.2, branch_10_6 jar, -level 2 (exit status 1); excerpt
    test: docvalues...........ERROR [-2147483648]
WARNING: 1 broken segments (containing 3 documents) detected
# 10.5.2's CheckIndex on the same index: docvalues OK, no problems detected

Why it looks unintended

  • The javadoc's contract is the per-value loop with no size limit; that loop is correct in every failing case.
  • The writer accepts values up to MAX_ARRAY_LENGTH bytes, Lucene90 documents no read limit, and CheckIndex users cannot choose the batch.
  • The numeric sibling longValues checks its size as a long and falls back above Integer.MAX_VALUE; this int buffer arrived in review.
The full argument234 words
  • The contract is the per-value loop. The javadoc (BinaryDocValues.java:39-82) defines the result as the default loop and states only that doc ids are ascending with no duplicates. That loop, run through FilterBinaryDocValues, returns correct values in every failing case, and so does the per-value API on the same documents.
  • No documented limit. The writer accepts each value up to MAX_ARRAY_LENGTH, and Lucene90 documents no read-side limit. The expected objection is DocValuesType.BINARY's "different codecs may enforce their own limits"; Lucene90 enforces its limit at write time, on single values, and the same values read back correctly one at a time.
  • History shows an accident, not a design. The PR's first codec commit (5d5ad9b85d, 2026-06-23) allocated one array per value and could not overflow. After a reviewer asked on 2026-06-26 for something more compact than BytesRef[], commit d26fd36beb "Improve SIMD implementation by reducing allocations" added new byte[size * length] and the (int) cast. No comment or review in the PR mentions a size limit.
  • The numeric sibling has a guard. NumericDocValues#longValues (#16129, same author, shipped in 10.5.0) computes the byte count as a long and falls back to the default when it exceeds Integer.MAX_VALUE (main Lucene90DocValuesProducer.java:676-679), and #16286's description opens with "Mirrors NumericDocValues#longValues for binary fields".
  • Lucene's own caller cannot avoid it. CheckIndex chooses the batch size, and it reports as broken an index that 10.5.2 reports clean.
The devil's-advocate check168 words

The intended-behaviour checker argued the other side. The best case for "intended or acceptable": Java arrays are int-sized; this is a performance API where the caller picks size (the PR benchmarked 8-128 byte values in batches of 1024); DocValuesType.BINARY allows codec limits; SECURITY.md makes limits on unbounded input the caller's job; and the API was still unreleased then (10.6.0 has since shipped it).

Why that case fails: the javadoc contract is the per-value loop with no size precondition; Lucene90 documents no read limit and accepts the values at write time; CheckIndex picks the batch, so a -level 2 user cannot avoid it; calls of 4 GiB or more silently return wrong bytes, which no reading of the contract allows; and the int buffer arrived with a review-time allocation optimization. Verdict: not intended and not documented; a real bug that is hard to reach. Not a security issue: the size is chosen by the caller and the JVM's bounds checks hold, so a public issue is the right channel.

Suggested fix

Recommended Fall back to the per-value default above ArrayUtil.MAX_ARRAY_LENGTH

Before each allocation, compare the total, as a long, with ArrayUtil.MAX_ARRAY_LENGTH; above it, fall back to the documented per-value loop (super.binaryValues). Two guards, 10 lines plus one import, on local unpushed branches. With the fix, the lucene90 codec, CheckIndex and doc-values tests (844 on branch_10_6, 861 on main) have 0 failures; CheckIndex levels 1-3 are clean.

if ((long) size * length > ArrayUtil.MAX_ARRAY_LENGTH) {          // fixed length, before new byte[]
  // too many bytes for a single array
  super.binaryValues(size, docs, docsOffset, values, valuesOffset);
  return;
}
...
if (lastEnd - firstStart > ArrayUtil.MAX_ARRAY_LENGTH) {          // variable length, before the cast
  // too many bytes for a single array
  super.binaryValues(size, docs, docsOffset, values, valuesOffset);
  return;
}
All options and trade-offs5 options · 462 words

The checks validated the guard with a per-value fallback; the alternatives come from the brief's list for reviewers.

Fall back to the per-value default above ArrayUtil.MAX_ARRAY_LENGTH (validated by the checks) (recommended)

Local branches r106-bulk-binaryvalues-2gib-fix (f0f3745a2c, branch_10_6) and -main-fix (b9e0701027, main): 10 lines plus one import. super is the documented default loop; it leaves doc on the last requested document, as the bulk path and AssertingLeafReader expect. After the guards, the window offsets cannot wrap.

if ((long) size * length > ArrayUtil.MAX_ARRAY_LENGTH) {          // fixed length, before new byte[]
  // too many bytes for a single array
  super.binaryValues(size, docs, docsOffset, values, valuesOffset);
  return;
}
...
if (lastEnd - firstStart > ArrayUtil.MAX_ARRAY_LENGTH) {          // variable length, before the cast
  // too many bytes for a single array
  super.binaryValues(size, docs, docsOffset, values, valuesOffset);
  return;
}

Trade-offs

  • Behaviour changes only when one call totals more than MAX_ARRAY_LENGTH bytes. Between 2,147,483,632 and 2,147,483,645 bytes the old code worked; the fix now takes the slower, correct per-value path there.
  • Cost: one long multiply and compare per call (SECURITY.md asks that robustness fixes not slow hot paths). The fallback allocates the same total bytes, one array per value.
  • The CHANGES entry has GITHUB#TBD placeholders and says "2GB": write "2 GiB" or name the real cap. Open PR #16832 edits the same variable-length loop and keeps the int cast; the two patches apply in either order. #16896 fixes a different overflow in the same file.

What the checks validated

  • branch_10_6 plus fix: the minimal test, the fuller tests 2/2, and org.apache.lucene.codecs.lucene90.* with TestCheckIndex, TestBinaryDocValuesUpdates and TestDocValues: 844 tests, 0 failures (re-run by the code-history checker).
  • main plus fix: the minimal test with -ea and -da; the three monster methods; 861 tests, 0 failures; :lucene:core:check -x test passes.
  • Both: CheckIndex levels 1-3 clean; direct bulk calls correct; the wrap case has 0 of 32 wrong with and without -ea; the variable-length cases are correct.

A lower cap

Fall back earlier, to avoid single arrays of nearly 2 GiB.

Trade-offs

More calls take the per-value path; the cut-off is a judgement call.

Chunk the read into several arrays

Keep the bulk read but split it across arrays that each stay under the limit.

Trade-offs

More code in a hot path; the returned windows then point into several arrays.

Allocate the shared array only on the contiguous fixed-length path

Non-contiguous fixed-length requests would read value by value without the shared array.

Trade-offs

A complement to the guard, not a fix on its own: a contiguous request over 2 GiB still overflows.

Integer.MAX_VALUE as the cap, like longValues

Mirror the numeric sibling's guard exactly.

Trade-offs

Leaves the 2-byte band (2^31 - 2 and 2^31 - 1) where the JVM refuses the array with an OutOfMemoryError, so MAX_ARRAY_LENGTH is safer.

How it was found

An AI tool built a CodeQL inventory of 602 int multiplications and left shifts, ranked it by git blame age and turned the method at Lucene90DocValuesProducer.java:1347 into Z3 bit-vector checks, which gave counterexamples; a JUnit repro followed. The task had named the area, integer arithmetic that breaks only at scale. A separate AI verifier found the CheckIndex consequences.

The method in full278 words

Method: a CodeQL inventory of integer arithmetic, then bit-vector SMT checks of the transcribed method (the study's "formal" tier: the solver's counterexample came first). Run 3 of the study, experiment "overflow-at-scale" (2026-10-22, main 7cecf79523):

  1. An AI tool built a CodeQL 2.27.3 inventory of non-constant int multiplications and left shifts in a list of packages that includes codecs/lucene90: 602 rows. The query also includes x * C and drops products that CodeQL's range analysis can bound.
  2. It ranked the rows by git blame age. The row Lucene90DocValuesProducer.java:1347 binaryValues array-size dated from 2026-06-30, about four months old.
  3. It transcribed the method into Z3 4.12.2 bit-vector obligations: B1 "the int product is exact" gave a counterexample; B1w, the witness size = 2, length = 2^30, gives a negative size; B2, the variable-length narrowing cast (not a CodeQL row; the AI tool added it while transcribing), gave a counterexample. cvc5 1.4.2 later returned sat on all three.
  4. It wrote a JUnit repro on main (2 x 1 GiB). Order by file times (UTC): CodeQL 05:35, blame 05:43, first Z3 run 05:57, JUnit repro 06:14, final Z3 run and cvc5 06:59.

That AI tool believed, wrongly, that CheckIndex reaches only the per-document branch. A separate AI verifier, given the candidate, reproduced it with its own tests, overturned that claim and found the CheckIndex false positive, the -exorcise data loss and the 40 x 128 MiB threshold. The run-3 task text named the area ("integer arithmetic that only breaks at scale") and listed the doc-values writer, not the bulk API or the reader. Z3 checked the transcribed arithmetic; the test shows the bug itself, so it is not "formally proven".

Prior reports, history and checks

Prior reports

No prior report in GitHub issues, PRs or the dev@, issues@, java-user@ and builds@ lists (searched 2026-10-23). Open PR #16832 edits the same loop but keeps the int cast; #16896 is a different overflow.

Searches6 searches

Nothing reported this bug. One open PR (#16832) edits the same loop, and #16896 fixes a different overflow in the same file.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues and PRs (apache/lucene)2026-10-2342 gh search issues --include-prs phrasings (titles, bodies, comments), for example binaryValues overflow, NegativeArraySizeException binaryValues, checkBulkFetchBinaryDocValues, BinaryDocValues 2GB, Lucene90DocValuesProducer binaryValues; plus 3 GitHub-wide; all 99 items created since 2026-10-09 listed0 hits apart from the controls (only #16286); #16896 is a different overflow
#16286 and #16129 threads2026-10-23body, issue and review comments, reviews; grep for overflow, 2GB, 2^31, Integer.MAX, MAX_ARRAY, negative, size limitno mention of a size limit
Open PRs2026-10-23all 410 open PRs with file lists; 7 touch Lucene90DocValuesProducer.java; their diffs fetchedonly #16832 touches the bulk code; it keeps the int cast; both patch orders apply
Mailing lists (lists.apache.org)2026-10-23dev@, issues@, java-user@ and builds@, last 365 days0 relevant hits
Claim re-check2026-10-23items with binaryValues created since 2026-10-20, NegativeArraySizeException since 2026-10-010 each
JIRA archive-no separate search is recorded; the API dates from 2026-06-30-

History

Introduced by #16286 (b843b4c6c3, merged 2026-06-30 for 10.6.0) and its branch_10x backport f5d01417305; the int buffer came in during review (d26fd36beb). Both merged commits fail, their parents are clean, and no release from 9.0.0 to 10.5.2 has the API.

Versions tested8 rows

Introduced by #16286 " Add bulk BinaryDocValues#binaryValues API" (the title has a leading space), by @costin, approved and merged by @martijnvg on 2026-06-30 (merged 17:16:56Z), squash commit b843b4c6c3 on main, milestone 10.6.0. It was backported the same day to branch_10x as f5d01417305, which branch_10_6 inherits. The int-sized shared buffer came in during review, with commit d26fd36beb (2026-06-26). git blame puts the root-cause lines on these commits.

Versions and branches executed, with results
Version or commitVerdictResult
main b83101cdd2 affectedNegativeArraySizeException at :1347; CheckIndex -level 2 ERROR at CheckIndex.java:4209, exit 1
branch_10_6 d04ee73177 affectedthe same at :1350 and CheckIndex.java:3939
main b843b4c6c3 (#16286) affectedNASE at :1242; CheckIndex ERROR; parent b6ca26f121: API absent, clean
branch_10x f5d01417305 (backport) affectedNASE at :1245; CheckIndex ERROR; parent e8d0062c1c: API absent, clean
33 releases 9.0.0 to 10.5.2 (sha1 = Maven Central) not affectedjavap finds no bulk binaryValues and no checkBulkFetchBinaryDocValues; 10.5.0, 10.5.1, 10.5.2 and 9.12.3 CheckIndex clean on the 3 x 1 GiB index
index written by 9.12.3, read by branch_10_6 affectedlevel 2: docvalues ERROR [-2147483648], 1 broken segment: a read-side bug that also hits older indexes
wrap case, 32 x 138,547,333 B affectedon b843b4c6c3, f5d01417305, branch_10_6 and main with -da: returns normally, 31 of 32 values differ
fix f0f3745a2c / b9e0701027 not affectedevery case correct

Independent checks

Independent AI checkers, a lens each, and a claim re-check checked brief v1: the minimal test failed in all 12 runs and the fix passed every case; brief v2 applies their 12 must-fix and 17 should-fix items. The study's verifier reproduced the bug with its own tests.

Checks and verdicts265 words

Independent AI checkers each took a lens on brief v1, and a claim re-check re-checked every disputed claim; brief v2 applies the corrections.

Each independent check and its verdict
CheckVerdictWhat it found
exec-current confirmedThe minimal test fails all 12 runs (3 seeds, assertions on and off, on both heads); its own repro (MMapDirectory writer, NIOFS reader in a fresh JVM, the 5-argument variant) hits the OOM band, the NASE, the silent wrong value, the CheckIndex false positive and the exorcise loss; the fix makes every case pass.
exec-versions confirmed, with additionsNo release from 9.0.0 to 10.5.2 has the API (javap on 33 jars); the introducing commits fail and their parents are clean; a 9.12.3-written index is flagged by the 10.6 CheckIndex.
code-history confirmed, precision gapsCitations, PR facts, root cause and fix hold; re-ran the fix on main; noted that the public static CheckIndex.testDocValues has no level gate.
intended-behavior not intendedThe best case for intended fails; found the PR history that put the int buffer in during review.
dups-policy no duplicates; provenance correctedNo earlier report; the CodeQL inventory has 602 rows, not 603; cvc5 ran after the JUnit repro, not before; the verifier's prompt included the candidate finding; #16832 is compatible in both orders.
Claim re-check status readyRe-ran the static checks on main plus fix and re-read every single-source claim; 12 must-fix and 17 should-fix items, applied in v2. One claim refuted (603 rows).
study verifier new bugoverflow-at-scale verifier reproduced it with its own tests and found the CheckIndex consequences.

Sources and provenance

Finding record, Lucene and upstream sources and repro files445 words

Finding record

LFV-003, snapshot of 2026-11-06

Registry title
Bulk BinaryDocValues#binaryValues (new in 10.6) sizes its buffer in int: it fails at 2 GiB and can return wrong bytes at 4 GiB
Area
codecs / doc values
Priority
Low, rank 991: An int overflow at 2 GiB per call in a bulk API new in 10.6.0 whose only in-tree caller is CheckIndex at a non-default level on huge values.
Impact class
crash
Impact note
Default codec, but only callers of the new bulk API (released in 10.6.0 on 2026-10-29) (in-tree: CheckIndex at level >= 2) whose single call totals at least 2^31-2 bytes of binary values; rare.
Summary
You call the new bulk BinaryDocValues#binaryValues on the default codec for documents whose values add up to 2^31 bytes or more, which CheckIndex at level 2 or 3 does. You expect the same values as the per-document loop. Instead it throws NegativeArraySizeException, and at 4 GiB or more the size can wrap so that, with assertions off, returned BytesRefs point at other documents' bytes; CheckIndex then reports a valid index as broken, and -exorcise removed every document.
Introduced
#16286 (squash b843b4c6c3 on main; backport f5d01417305 on branch_10x, inherited by branch_10_6); the int-sized shared buffer came from commit d26fd36beb during review (2026-06-30)
First affected version
10.6.0 (released 2026-10-29)
Affected versions
main and branch_10_6 [exec]; branch_10x same code [read]; releases 9.0.0-10.5.2 lack the API (javap on 33 sha1-checked jars) [exec]
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed in the earlier formal verification study by independent AI checks and a rerun on Lucene main; not yet triaged by a person.
Found by
formal: Z3/cvc5
Upstream and prior reports
none found (2026-10-23: 42 gh query phrasings, all open PRs touching the file, dev@/issues@/java-user@/builds@ for the last year); open PR #16832 keeps the int cast; #16896 is a different overflow
Lucene commit tested
main b83101cdd2 and branch_10_6 d04ee73177 (2026-10-23), the introducing commits b843b4c6c3 and f5d01417305 and their parents, all 33 lucene-core releases 9.0.0-10.5.2 (API absent), and the fix branches f0f3745a2c and b9e0701027. The public-list rerun reproduced the repro on main b83101cdd2; the hunt's main ea57360912ed adds only #16883, which narrows LFV-004 alone.

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

Repro files

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.