- Index three documents with a 1 GiB binary doc value each on the default codec, and
forceMerge(1)them into one segment. - Fetch docs 0 and 2 in one bulk call,
binaryValues(2, {0, 2}, values), asCheckIndexdoes on a 3-document segment. You expect the per-value loop's result, first bytes 0 and 2. - 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 throwsNegativeArraySizeException. - The
branch_10_6or mainCheckIndex -level 2, on such an index written by 10.5.2, printsdocvalues ERROR [-2147483648]and "1 broken segments" and exits 1, where 10.5.2's finds no problems; with-exorcise, thebranch_10_6one dropped the segment and all 3 of its documents. - 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.
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];(mainLucene90DocValuesProducer.java:1347), computed before the contiguity check, so non-contiguous requests use it too; - variable length, consecutive doc ids:
int totalBytes = (int) (lastEnd - firstStart);thennew byte[totalBytes](main:1393-1394); - the window offsets use the same
intarithmetic (:1352,:1356,:1399).
| T | result |
|---|---|
| up to 2^31 - 3 | correct |
| 2^31 - 2 or 2^31 - 1 | OutOfMemoryError: Requested array size exceeds VM limit (the JVM's own array limit) |
| 2^31 to 2^32 - 1 | NegativeArraySizeException; 2 docs of 2^30 bytes give -2147483648 |
| 2^32 or more | the size can wrap to a small number; a contiguous request with assertions off can return normally with wrong windows |
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.
#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
DirectoryReaderorSegmentReader). Wrapping readers skip the override, becauseFilterBinaryDocValuesdoes not forwardbinaryValues(for exampleExitableDirectoryReader), and no query, collector or sort in Lucene calls it. The one in-tree production caller isCheckIndexat-level 2or-level 3; the default CLI level 1 is not affected. - Symptoms. Usually loud: an
OutOfMemoryErrororNegativeArraySizeException. 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
CheckIndexreads 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).-exorcisethen removes the segment; opening anIndexWriterafterwards 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.checkIndexand theBaseDirectoryWrappercheck on close (both at level 3), andTestUtil.checkReader(through the ungatedCheckIndex.testDocValues), reach it only when the doc values are not wrapped by the Asserting codec, whoseAssertingBinaryDocValuesruns 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.
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.
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];
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, thebranch_10_6or mainCheckIndex -level 2printsdocvalues ERROR [-2147483648]and exits 1 (10.5.2's finds no problems).
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.
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.
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"));
}
}
}
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>.
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);
}
}
}
}
| lucene | main b83101cdd2 and branch_10_6 d04ee73177 fail; releases up to 10.5.2 do not have the API |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux, aarch64 |
# 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- 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_LENGTHbytes, Lucene90 documents no read limit, andCheckIndexusers cannot choose the batch. - The numeric sibling
longValueschecks its size as alongand falls back aboveInteger.MAX_VALUE; thisintbuffer 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 throughFilterBinaryDocValues, 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 isDocValuesType.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 thanBytesRef[], commitd26fd36beb"Improve SIMD implementation by reducing allocations" addednew 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 alongand falls back to the default when it exceedsInteger.MAX_VALUE(mainLucene90DocValuesProducer.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.
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_LENGTHbytes. 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#TBDplaceholders 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
-eaand-da; the three monster methods; 861 tests, 0 failures;:lucene:core:check -x testpasses. - 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.
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.
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.
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.
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.
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):
- An AI tool built a CodeQL 2.27.3 inventory of non-constant
intmultiplications and left shifts in a list of packages that includescodecs/lucene90: 602 rows. The query also includesx * Cand drops products that CodeQL's range analysis can bound. - It ranked the rows by
git blameage. The rowLucene90DocValuesProducer.java:1347 binaryValues array-sizedated from 2026-06-30, about four months old. - 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 returnedsaton all three. - 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".
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.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues and PRs (apache/lucene) | 2026-10-23 | 42 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 listed | 0 hits apart from the controls (only #16286); #16896 is a different overflow |
| #16286 and #16129 threads | 2026-10-23 | body, issue and review comments, reviews; grep for overflow, 2GB, 2^31, Integer.MAX, MAX_ARRAY, negative, size limit | no mention of a size limit |
| Open PRs | 2026-10-23 | all 410 open PRs with file lists; 7 touch Lucene90DocValuesProducer.java; their diffs fetched | only #16832 touches the bulk code; it keeps the int cast; both patch orders apply |
| Mailing lists (lists.apache.org) | 2026-10-23 | dev@, issues@, java-user@ and builds@, last 365 days | 0 relevant hits |
| Claim re-check | 2026-10-23 | items with binaryValues created since 2026-10-20, NegativeArraySizeException since 2026-10-01 | 0 each |
| JIRA archive | - | no separate search is recorded; the API dates from 2026-06-30 | - |
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.
| Version or commit | Verdict | Result |
|---|---|---|
main b83101cdd2 | affected | NegativeArraySizeException at :1347; CheckIndex -level 2 ERROR at CheckIndex.java:4209, exit 1 |
branch_10_6 d04ee73177 | affected | the same at :1350 and CheckIndex.java:3939 |
main b843b4c6c3 (#16286) | affected | NASE at :1242; CheckIndex ERROR; parent b6ca26f121: API absent, clean |
branch_10x f5d01417305 (backport) | affected | NASE at :1245; CheckIndex ERROR; parent e8d0062c1c: API absent, clean |
| 33 releases 9.0.0 to 10.5.2 (sha1 = Maven Central) | not affected | javap 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 | affected | level 2: docvalues ERROR [-2147483648], 1 broken segment: a read-side bug that also hits older indexes |
| wrap case, 32 x 138,547,333 B | affected | on b843b4c6c3, f5d01417305, branch_10_6 and main with -da: returns normally, 31 of 32 values differ |
fix f0f3745a2c / b9e0701027 | not affected | every case correct |
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.
| Check | Verdict | What it found |
|---|---|---|
| exec-current | confirmed | The 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 additions | No 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 gaps | Citations, 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 intended | The best case for intended fails; found the PR history that put the int buffer in during review. |
| dups-policy | no duplicates; provenance corrected | No 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 ready | Re-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 bug | overflow-at-scale verifier reproduced it with its own tests and found the CheckIndex consequences. |
Finding record, Lucene and upstream sources and repro files445 words
- 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
b83101cdd2and branch_10_6d04ee73177(2026-10-23), the introducing commitsb843b4c6c3andf5d01417305and their parents, all 33 lucene-core releases 9.0.0-10.5.2 (API absent), and the fix branchesf0f3745a2candb9e0701027. The public-list rerun reproduced the repro on mainb83101cdd2; the hunt's mainea57360912edadds only #16883, which narrows LFV-004 alone.
- minimal-test.diff (JUnit, TestLucene90DocValuesFormat):
repro/LFV-003/minimal-test.diff(42 lines) - Plain Java on the released jars: MakeIndex.java (writes the index):
repro/LFV-003/MakeIndex.java(56 lines) - Plain Java: BulkCall.java (one bulk call, compared with the per-value API):
repro/LFV-003/BulkCall.java(106 lines)