Since 10.5.0, a longValues call on a dense field stored with a fixed width of whole bytes copies all requested values with one read into a byte[], plus up to 3 padding bytes. Its check that the array fits forgets those padding bytes, and it compares with Integer.MAX_VALUE rather than a length the JVM can allocate.
- A dense numeric doc-values field (every document has a value) is usually stored with one fixed width per value, for example 48 bits = 6 bytes, after subtracting the minimum and dividing by the common divisor.
NumericDocValues#longValues(size, docs, values, defaultValue)fillsvaluesfor a sorted list of doc ids. Its javadoc says it behaves like callingadvanceExactandlongValuefor each doc.- A Java
intholds at most 2,147,483,647 (2^31 - 1); adding more wraps it to a negative number. Abyte[]can be at most a few bytes shorter than that.
longValues call over 357,913,941 docs of 48-bit values: the 2,147,483,646 value bytes pass the check, the 2 padding bytes added in int push the count past 2^31 - 1, it wraps negative, and the read throws "read past EOF". Select the still to open it at full size. Generated with AI, checked against the code.- Index 357,913,941 docs with a
NumericDocValuesFieldwhose values need 48 bits in every 16,384-doc block (the test uses(i & 1) << 47 | i), so the default codec stores every value in 6 bytes, andforceMerge(1). - Call
longValues(357913941, docs, values, -1)once for docs 0 to 357,913,940. You expect every value, as the per-documentadvanceExact/longValueloop of its javadoc returns them. - Lucene copies the values with one read into a
byte[]: 6 x 357,913,941 = 2,147,483,646 bytes, plus 2 padding bytes so the last value can be read with an 8-byte load. It checks only the 2,147,483,646 againstInteger.MAX_VALUE(2,147,483,647): passes. - It then adds the 2 padding bytes in
int: 2,147,483,648 wraps to -2,147,483,648. The negative length slips past the other checks, and the read throwsEOFException: read past EOF (pos=0)on a valid file. - One doc fewer fits; one doc more fails the check and falls back to the per-document loop: both return every value. 40 bits fail the same way at 429,496,729 docs; 56, 24 and 16 bits at one size each, with
OutOfMemoryError: Requested array size exceeds VM limit.
Only code that calls NumericDocValues#longValues itself with one contiguous batch of exactly the failing size (306,783,378 to 1,073,741,823 docs, by width) on a dense field stored as one fixed width, e.g. to read a whole segment's column at once. Default codec and settings suffice; outside benchmarks, Lucene's own callers use batches of at most 64 docs.
Mechanism, terms and numbers706 words
Since 10.5.0 (#16129), NumericDocValues#longValues on a dense numeric field stored as one fixed byte-aligned width reads all requested values with one readBytes into a byte[], plus 1 to 3 padding bytes so that 24-, 40-, 48- and 56-bit values can be decoded with 4- or 8-byte loads. Lucene90DocValuesProducer.bulkDecodeByteAlignedValues first checks that the request fits in a Java array, but it checks the byte count without the padding (size * bytesPerValue <= Integer.MAX_VALUE) and then adds the padding in int. For 48-bit values and a batch of 357,913,941 docs the padded count is 2^31, which wraps to Integer.MIN_VALUE; the negative length passes the slice and allocation checks, and the read throws EOFException: read past EOF (pos=0) on a valid index. 40-bit values wrap the same way at 429,496,729 docs; for 56, 24 and 16 bits one size each asks for a byte[] the JVM cannot allocate (on JDK 25), because the guard compares with Integer.MAX_VALUE rather than an allocatable length. The guard's own fallback, the per-document loop, would return every value.
Lucene90DocValuesProducer.bulkDecodeByteAlignedValues (main 645d4fe14a20) runs when the field has one fixed width that is a multiple of 8 bits (:644-646) and the requested docs are contiguous (:648-650). The decoder reads 4 bytes per 24-bit value and 8 bytes per 40/48/56-bit value, so the last value needs 1, 3, 2 or 1 padding bytes after it (paddingBytesNeededForBulkDecode, :652-659); the writer stores those bytes on disk.
final long byteCountLong = (long) size * bytesPerValue; // :676
if (byteCountLong > Integer.MAX_VALUE) { // :677 checks the count WITHOUT padding
return null; // null = use the per-document loop
}
final int byteCount = (int) byteCountLong;
final int readByteCount =
byteCount == 0 ? 0 : byteCount + paddingBytesNeededForBulkDecode(entry.bitsPerValue); // :681-682, int
if (offset + readByteCount > slice.length()) { return null; } // :684 a negative length passes
if (bytes.length < readByteCount) { bytes = new byte[readByteCount]; } // :687-688
slice.readBytes(offset, bytes, 0, readByteCount); // :691| width | docs in the one call | padded byte count | result |
|---|---|---|---|
| 40-bit | 429,496,729 | 5N + 3 = 2^31, wraps to -2,147,483,648 | EOFException: read past EOF (pos=0) (memory-mapped directory) |
| 48-bit | 357,913,941 | 6N + 2 = 2^31, wraps | same |
| 56-bit | 306,783,378 | 7N + 1 = 2,147,483,647 | OutOfMemoryError: Requested array size exceeds VM limit |
| 24-bit | 715,827,882 | 3N + 1 = 2,147,483,647 | same |
| 16-bit | 1,073,741,823 | 2N = 2,147,483,646 (no padding) | same |
- The guard admits sizes up to
Integer.MAX_VALUE / bytesPerValue; only that largest size gets close enough to 2^31 for the padding (or, at 16 bits, the JVM's array limit) to push it over. One doc fewer fits; one doc more is sent to the fallback. This holds for JDK 25's array limit; only the 40/48-bit wrap is independent of the JVM. On a JVM whose largestbyte[]is smaller, a few more sizes just below these would also fail withOutOfMemoryError(by arithmetic; not run). - On JDK 25 the largest
byte[]that allocates isInteger.MAX_VALUE - 2, so the 56/24/16-bit requests are refused outright; no heap is used. - With JDK 25's limit, 8-, 32- and 64-bit widths have no failing size (Z3 model of the arithmetic).
- With
NIOFSDirectoryor the in-heapByteBuffersDirectorythe negative read does nothing and the decoder reads the iterator's scratch array instead. On its first bulk call that array is empty, and the decoder throwsArrayIndexOutOfBoundsException: Index 0 out of bounds for length -7(40-bit and 48-bit runs). If an earlier bulk call left a larger array there, the decoder first decodes those stale bytes into part of the caller'svaluesand then throws (by reading). Every directory tried ended in an exception; no call that returns gives wrong values, because the decoder's last 8-byte read would need bytes up to offset 2^31, beyond any Java array. - Reach: a dense single-valued
SortedNumericDocValuesfield is served by the same iterator (DocValues.singleton(getNumeric(entry)),:2002-2003), so code that unwraps it withDocValues.unwrapSingletonreaches the same path; wrappers built onFilterNumericDocValuesthat do not overridelongValuesrun the per-document loop. The JDK 25 Panama decoder handles only 64-bit values and passes every other width to the default decoder, so the runtime's vector support changes none of this.
Exposure and likelihood261 words
- Who is hit. Application or library code that reads a huge contiguous run of docs in one
longValuescall, in practice a whole segment's column, on a segment of exactly that many docs, or a run of exactly that many contiguous docs anywhere in a larger segment (isContiguouscompares only the first and the last doc). In Lucene itself,CheckIndexuses batches of 16 with gaps and the sandboxHistogramCollectorbatches of 64; the JMH benchmarkNumericDocValuesBulkDecodeBenchmarkuses 128 or 1,024;TermScorerandMultiNormsLeafSimScorercall it on norms, which a different producer serves. - Default, not opt-in. The default codec,
IndexWriterConfigand directory suffice. The field must be dense and stored as one fixed width: after subtracting the minimum and dividing by the gcd its values need 13-16, 21-24, 33-40, 41-48 or 49-56 bits, and per-block widths would not save 10% or more of the space. Dictionary-encoded fields (at most 256 distinct values) use at most 8 bits and are never affected. - Scale. The caller's own
int[]andlong[]arrays take 12 bytes per doc: about 3.7 GB (56-bit) to 12.9 GB (16-bit), 4.3 GB at 48 bits. - Symptoms. An exception at once, with assertions on or off. The 40/48-bit message, "read past EOF" on a valid file, points users at index corruption. A host that treats every
OutOfMemoryErroras fatal could exit on the 16/24/56-bit sizes. - Not affected. No call that returns gives wrong values, and the index is not damaged; 8-, 32- and 64-bit fields, sparse fields, non-contiguous doc lists and (on JDK 25) every other batch size work.
One JUnit test, one method, public API: one segment of 357,913,941 docs with a dense 48-bit field (default codec, FSDirectory), then one longValues call over all docs; it asserts only that the call returns. @Monster: about 8 GB of heap, about 2.1 GB of index on disk (more at the peak of forceMerge, not measured) and a few minutes.
/*
* 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.index;
import java.io.IOException;
import java.util.Arrays;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.NumericDocValuesField;
import org.apache.lucene.store.Directory;
import org.apache.lucene.store.FSDirectory;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.tests.util.LuceneTestCase.Monster;
import org.apache.lucene.tests.util.TestUtil;
@Monster("indexes 357,913,941 documents, needs an 8 GB heap")
public class TestNumericDocValuesBulkLongValuesSize extends LuceneTestCase {
public void testLongValuesOfAllDocsWith48BitValues() throws Exception {
final int numDocs = 357_913_941;
try (Directory dir = FSDirectory.open(createTempDir())) {
IndexWriterConfig iwc = new IndexWriterConfig().setCodec(TestUtil.getDefaultCodec());
try (IndexWriter w = new IndexWriter(dir, iwc)) {
Document doc = new Document();
NumericDocValuesField field = new NumericDocValuesField("f", 0);
doc.add(field);
for (int i = 0; i < numDocs; i++) {
field.setLongValue((i & 1L) << 47 | i);
w.addDocument(doc);
}
w.forceMerge(1);
}
try (DirectoryReader reader = DirectoryReader.open(dir)) {
NumericDocValues dv = getOnlyLeafReader(reader).getNumericDocValues("f");
int[] docs = new int[numDocs];
Arrays.setAll(docs, i -> i);
try {
dv.longValues(numDocs, docs, new long[numDocs], -1L);
} catch (IOException | RuntimeException e) {
// Expected: all values; actual: 6 * numDocs + 2 padding bytes = 2^31 wraps, so it throws.
throw new AssertionError("expected all " + numDocs + " values, but it threw " + e, e);
}
}
}
}
}
cd $LUCENE # an apache/lucene checkout at 645d4fe14a20
cp $REPRO/TestNumericDocValuesBulkLongValuesSize.java lucene/core/src/test/org/apache/lucene/index/
./gradlew :lucene:core:test --tests org.apache.lucene.index.TestNumericDocValuesBulkLongValuesSize -Ptests.monster=true -Ptests.heapsize=8g -Ptests.seed=DEADBEEF
# assertions off (production): add -Ptests.asserts=false; the result is the same
- Expected
longValuesreturns all 357,913,941 values, as the per-document loop does.- Observed
EOFException: read past EOF (pos=0)on the valid.dvd, thrown fromLucene90DocValuesProducer.bulkDecodeByteAlignedValues:691, with assertions on and off.
More files, environment, full output263 words
The value pattern (i & 1) << 47 | i makes every 16,384-doc block need 48 bits, so the default codec stores the field as one dense 48-bit width and longValues takes the byte-aligned bulk path; the stack frames (bulkDecodeByteAlignedValues:691) confirm it. forceMerge(1) makes the single segment. 48 bits is the smallest int-wrap case and does not depend on the JVM's array limit. The size is inherent: the wrap needs a request of about 2^31 bytes, so the test is @Monster, like Lucene's own Test2BNumericDocValues; run it with -Ptests.monster=true.
As the regression test: the same test; it fails without the fix and passes with it. It checks only that the call returns, not the values: those are covered by the upstream testDenseNumericLongValuesBulkFetch (bulk vs per-document values) and by the finder's @Monster suite, which compares every value with the per-document loop and passed with the fix at 40, 48 and 56 bits.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT); also fails on 10.5.0, 10.5.2 and 10.6.0; passes on 10.4.0 |
|---|---|
| jdk | OpenJDK 25.0.3 |
| os | Linux aarch64 |
# gradle, main 645d4fe14a20, seed DEADBEEF, -Ptests.monster=true -Ptests.heapsize=8g
TestNumericDocValuesBulkLongValuesSize > testLongValuesOfAllDocsWith48BitValues FAILED
java.lang.AssertionError: expected all 357913941 values, but it threw java.io.EOFException: read past EOF (pos=0): MemorySegmentIndexInput(path="$TMP/_47_Lucene90_0.dvd") [slice=randomaccess]
at org.apache.lucene.index.TestNumericDocValuesBulkLongValuesSize.testLongValuesOfAllDocsWith48BitValues(TestNumericDocValuesBulkLongValuesSize.java:54)
Caused by:
java.io.EOFException: read past EOF (pos=0): MemorySegmentIndexInput(path="$TMP/_47_Lucene90_0.dvd") [slice=randomaccess]
at org.apache.lucene.store.MemorySegmentIndexInput.handlePositionalIOOBE(MemorySegmentIndexInput.java:147)
at org.apache.lucene.store.MemorySegmentIndexInput$SingleSegmentImpl.readBytes(MemorySegmentIndexInput.java:790)
at org.apache.lucene.codecs.lucene90.Lucene90DocValuesProducer.bulkDecodeByteAlignedValues(Lucene90DocValuesProducer.java:691)
at org.apache.lucene.codecs.lucene90.Lucene90DocValuesProducer$4.longValues(Lucene90DocValuesProducer.java:1007)
at org.apache.lucene.index.NumericDocValues.longValues(NumericDocValues.java:84)
1 test completed, 1 failed
# the same with assertions off (repro harness): same exception and frames
# 10.5.2: readBytes:791 <- bulkDecodeByteAlignedValues:583 <- $4.longValues:899
# with the fix, assertions on and off
:lucene:core:test (SUCCESS): 1 test- The javadoc says
longValues"behaves as if implemented as" the per-documentadvanceExact/longValueloop; its only precondition is sorted doc ids without duplicates. - The guard already sends requests too big for one array to that loop (
return null); it just measures the wrong number. - One doc more works, so it cannot be a deliberate limit.
The full argument288 words
- Contract.
NumericDocValues.java:43: "This API behaves as if implemented as below, which is the default implementation", followed by theadvanceExact/longValueloop (:45-58). The only stated precondition (:60-61): thedocsarray "is required to be sorted in ascending order with no duplicates." No javadoc, CHANGES or MIGRATE text limits the batch size. The javadoc's own example (:69-75) even suggests callinglongValuesover a dense run up todocIDRunEnd(), which on a dense field can be the whole segment. (The usual iterator rules still apply, as the test framework's asserting wrapper checks:size >= 0, docs from the currentdocID()up tomaxDoc - 1; the repro meets them.) - The code handles oversized requests. The guard at
Lucene90DocValuesProducer.java:677-679returnsnull, and the callers then run the per-document loop (:1009-1010), which strongly suggests that oversized requests were meant to work (it does not by itself promise that any size fits in memory; here the caller's arrays were already allocated, and the loop needs no more). It measures the wrong number: the count without padding, againstInteger.MAX_VALUEinstead of an allocatable length. - Not monotonic. N - 1 and N + 1 work, N fails.
- Lucene has a constant for this.
ArrayUtil.MAX_ARRAY_LENGTH(util/ArrayUtil.java:32-34) got its current definition,Integer.MAX_VALUEminus the array header, from LUCENE-5844 / #6906 (2014): "ArrayUtil.grow should not pretend you can actually allocate array[Integer.MAX_VALUE]". It was first added in 2013 (LUCENE-5296, asInteger.MAX_VALUE - 256) and is a deliberately conservative bound, not the JVM's exact limit. - History. The review of #16129 (one approving review, two comments about backporting, no inline comments) never discusses large requests or padding; its test uses 512 docs per width.
The devil's-advocate check163 words
Strongest case (an intended-behaviour checker and reviews by another model family): the bulk API is a performance tool for small batches, nobody passes hundreds of millions of docs in one call, callers should chunk, and giant allocations may fail anyway.
Why it fails: none of that is documented, and the code itself sends oversized requests to the fallback, so oversized requests were evidently meant to work. The caller's own 4.3 GB of arrays were allocated fine; the failure is a wrong exception on a valid index (40/48 bits), or a request for an array the JVM refuses to allocate while the documented fallback exists (16/24/56 bits), not resource exhaustion. Both refute reviews rated the intended-behaviour argument weak. A later read-only review by the same family rated the case for leaving it alone moderate as an API-design defence and strong only under a stricter bar that sets aside loud failures at extreme sizes, while agreeing that the 40/48-bit wrap is hard to call intended.
Recommended Check the padded size, in long, against ArrayUtil.MAX_ARRAY_LENGTH
Compute the padded size in long and compare it with ArrayUtil.MAX_ARRAY_LENGTH, so a request too big for one array falls back to the per-document loop (+6/-3 lines in Lucene90DocValuesProducer). With it the repro passes with assertions on and off, and so do the 9 closest doc-values suites (601 tests) and :lucene:core:check -x test.
final long byteCountLong = (long) size * bytesPerValue;
// The padding bytes are read into the same array, so they must fit as well.
final long readByteCountLong =
size == 0 ? 0 : byteCountLong + paddingBytesNeededForBulkDecode(entry.bitsPerValue);
if (readByteCountLong > ArrayUtil.MAX_ARRAY_LENGTH) {
return null;
}
final int byteCount = (int) byteCountLong;
final int readByteCount = (int) readByteCountLong;All options and trade-offs4 options · 417 words
One option was validated; the others are listed with their trade-offs.
Check the padded size, in long, against ArrayUtil.MAX_ARRAY_LENGTH (validated by the checks) (recommended)
Add the padding before the check, in long, and compare with Lucene's allocatable array limit instead of Integer.MAX_VALUE. size == 0 is the same test as the old byteCount == 0, because bytesPerValue >= 1 on this path.
final long byteCountLong = (long) size * bytesPerValue;
// The padding bytes are read into the same array, so they must fit as well.
final long readByteCountLong =
size == 0 ? 0 : byteCountLong + paddingBytesNeededForBulkDecode(entry.bitsPerValue);
if (readByteCountLong > ArrayUtil.MAX_ARRAY_LENGTH) {
return null;
}
final int byteCount = (int) byteCountLong;
final int readByteCount = (int) readByteCountLong;Trade-offs
Requests whose padded size lies between ArrayUtil.MAX_ARRAY_LENGTH (Integer.MAX_VALUE minus the array header, a deliberately conservative bound) and the JVM's real limit used to take the fast path and now take the per-document loop: same values, slower (for example 16-bit batches of 1,073,741,816 to 1,073,741,822 docs). Normal batch sizes should see no difference: still one long addition and one comparison per call (argued from the code; not benchmarked).
What the checks validated
- Gradle in a scratch worktree of
645d4fe14a20: without the fix the repro fails; with it the repro passes with assertions on and off (-Ptests.asserts=false). - The 9 closest suites pass, 601 tests, 0 failures:
TestLucene90DocValuesFormatand itsMergeInstancevariant (both with the upstream bulk testtestDenseNumericLongValuesBulkFetch),TestLucene90DocValuesFormatVariableSkipInterval,TestDocValues,TestDocValuesIndexing,TestMultiDocValues,TestOverlayNumericDocValues,TestNumericDocValuesUpdatesandTestCheckIndex. ./gradlew :lucene:core:check -x testpasses with the repro test in place (google-java-format, ecj lint, forbidden APIs).- The hunt's sketch of the same change also passed its 40-, 48- and 56-bit tests, and a Z3 model of the fixed arithmetic has no failing size.
Fixes the 40/48-bit wrap.
Trade-offs
The 56/24/16-bit sizes still request new byte[Integer.MAX_VALUE] or Integer.MAX_VALUE - 1 and fail with OutOfMemoryError. Not enough.
Split a contiguous request into pieces of bounded size and reuse one bounded scratch buffer.
Trade-offs
Keeps the fast path for every size and bounds the scratch array, which the instance keeps after the call (bulkBytes = bytes, today up to about 2 GiB). A bigger change for a rare case that needs a benchmark. Not tried.
Turns the wrap into an ArithmeticException.
Trade-offs
Still an exception on a valid request. Not a fix.
Downstream, a service that reads a whole segment's column with one longValues call would see only that request fail, with a misleading "read past EOF", when the segment has exactly the failing number of docs for the field's width; no wrong values or index damage follow.
An AI-planned code-reading pass flagged that the size check runs before the padding is added. A Z3 bit-vector model of the size arithmetic then listed every failing batch size (five, none for 8, 32 or 64 bits), and public-API tests on the stock jars reproduced each one.
The method in full185 words
Method: non-formal (code reading made exact by a solver). The hunt plan for the numeric doc-values bulk path (a hunt unit) listed the lead from reading: "byteCountLong is checked against Integer.MAX_VALUE before padding is added", naming the 40- and 48-bit sizes.
- The finder wrote a Z3 bit-vector model of the byte-count, offset and padding arithmetic with exact Java
int/longwrap,IndexWriter.MAX_DOCSand contiguous in-segment docs as preconditions and the measured JDK 25byte[]limit, and enumerated every violating size with blocking clauses: the two wrap sizes plus three sizes at the JVM array limit (16, 24 and 56 bits) that the lead did not name, and none for 8, 32 and 64 bits. With the fix the model has 0 violations. - A public-API
@MonsterJUnit on the stock jars then witnessed all five sizes on a job host. The first job-host run was vacuous (its value pattern made the writer pick per-block widths, which never reach the path) and was discarded. - A bounded-exhaustive translation check of the bulk decoder on tiny segments (about 185 million checks over every encoding) found no small-scale mismatch.
No prior report found in GitHub issues, PRs and commits, the Lucene mailing lists, the JIRA archive, downstream trackers or the web (searched 2026-10-26 and again 2026-10-31); not fixed upstream.
Searches4 searches
Closest items, none a report of this bug: #16129 and #16136 (introducing PR and backport; nothing on large requests or padding), #15149 (the longValues API), #16691, #16517, #16451 and #16677 (Panama decode performance; the two PRs change only PanamaDocValuesBulkDecodeSupport and its test, adding 8/16/32-bit kernels and reworking the 64-bit one; neither touches the guard), #16917 (range queries on packed values, not on this path), #6906 / LUCENE-5844 (background for ArrayUtil.MAX_ARRAY_LENGTH), #16286 and #16896 (a sibling bulk API and another overflow in the same file, with different fix sites). The upstream monster test Test2BNumericDocValues does not call longValues. Limits: GitHub search may miss some comments, and private reports are not visible.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene; Elasticsearch, OpenSearch, Solr), mailing lists, JIRA archive, web | 2026-10-26 | 220 queries: two runs of the hunt's prior-report tool (43 and 81 queries) and 96 manual queries | 26 hits read, none the same bug |
| Same sources, repeated and extended | 2026-10-31 | 205 query records: the prior-report tool (119 queries), 49 manual GitHub searches (guard identifiers, "read past EOF", "Requested array size exceeds VM limit", MAX_ARRAY_LENGTH, the sizes 429496729 and 357913941), every issue and PR updated since 2026-10-25 (124), 20 direct list queries | no report; 19 medium hits triaged, all different |
| Open and recently closed PRs | 2026-10-31 | changed files of all 410 open PRs and 598 PRs closed since 2026-05-26, patches grepped for the guard identifiers | none changes the guard |
| Upstream branches | 2026-10-31 | guard lines read through the GitHub API on main, branch_11x, branch_10_6, the 10.6.0 tag and branch_10_5 | byte-identical block everywhere; not fixed |
Introduced in 10.5.0 by #16129 (96691018f58b, merged 2026-05-27), which added the bulk decode path with this guard. Affects 10.5.0-10.6.0 and main; 10.4.0 and the parent commit pass (per-document loop only); older releases have no longValues.
Versions tested8 rows
Introduced by #16129 "Add bulk decode for dense NumericDocValues via longValues()" (costin, co-authored by benwtrent, approved by jainankitk, merged by benwtrent on 2026-05-27 as 96691018f58b; released through the same-day direct backport 4f27413dc998, backport PR #16136 closed as "backported directly"). The commit added the whole dense byte-aligned bulk path, guard included; git log -S on the guard fragments finds only these two commits. The longValues API itself came with #15149 (10.4.0) as the per-document default loop, which has no size limit.
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | 48-bit test fails with assertions on and off (repro harness and gradle); passes with the fix |
| 10.6.0 | affected | 48-bit test fails (EOFException at bulkDecodeByteAlignedValues:694) |
| 10.5.2 | affected | 48-bit test fails with assertions on and off; 40- and 56-bit fail in the study |
| 10.5.1 | affected | 40-bit test fails (study) |
| 10.5.0 | affected | 48-bit test fails; 40-bit fails with assertions on and off (study) |
96691018f58b (#16129) vs its parent 098ff7d4fc63 | bisect | #16129 fails (40-bit, assertions on and off); its parent passes |
| 10.4.0 | not affected | passes: the per-document default loop only |
| 10.3.2, 9.12.3 | no API | no NumericDocValues#longValues; the repro does not compile |
Confirmed with high confidence: an independent reproducer, an intended-behaviour devil's advocate and an adjudication found it real, and a review by another model family (OpenAI GPT-5.6) ran its own test: real, high. An earlier read-only review agreed on the root cause. Re-run on 645d4fe14a20; the fix validated through gradle.
Checks and verdicts285 words
Every checker ran or re-derived the failure independently; none dissents on the root cause.
| Check | Verdict | What it found |
|---|---|---|
| review, 2026-10-26 (OpenAI GPT-5.6) | real, high | Refute lens, read-only: root cause agrees, the fix fixes it, inputs in contract; no documented behaviour permits the failures. |
| finder | candidate | Z3 model (five failing sizes, none for 8/32/64 bits; 0 with the fix); five-width @Monster suite on a job host; N - 1 and N + 1 controls pass; 10.5.2 fails, 10.4.0 passes. |
| independent reproducer | real | Its own test, memory-mapped and NIOFS directories: 40/48/56 bits fail on main; 40 bits fail on 10.5.0 and 10.5.2 and pass on 10.4.0; vacuity check: the .dvd is one 40-bit block. |
| refute review, 2026-10-26 (OpenAI GPT-5.6) | not real (procedural, low) | Could not run Java; agreed on the root cause, the fix and the inputs, rated the intended argument weak. A separate re-run of that review then reproduced 40/48/56 bits with its own independent main() on a job host. |
| intended-behaviour devil's advocate | real | No javadoc, CHANGES, MIGRATE text, test or review comment limits the batch size. |
| adjudication | confirmed, high | Re-ran the finder's repro: fails as claimed. |
| refute review, 2026-10-31 (OpenAI GPT-5.6) | real, high | Its own 48-bit test with a different value pattern fails on main with assertions on and off and passes with the guard fix; re-run on 645d4fe14a20: fails. |
| re-verification | still reproduces | Main a9779ef3c193 and 645d4fe14a20, 10.5.2, with assertions on and off. |
| doc fact check, 2026-10-31 | still reproduces | Re-ran the repro on 645d4fe14a20 with assertions off (fails, same exception and frames) and with the fix shadowed, assertions on (passes); its wording and provenance corrections are applied. |
The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.
- PNG 517 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,510 words
- Registry title
- Bulk NumericDocValues#longValues (new in 10.5.0) throws on a valid dense segment for one exact request size per byte-aligned width, because its size guard checks the unpadded byte count against Integer.MAX_VALUE and then adds the padding bytes in int (40/48-bit: wraps to Integer.MIN_VALUE, 'read past EOF'; 16/24/56-bit: a byte[] above the JVM limit)
- Area
- codecs / doc values (Lucene90DocValuesProducer dense byte-aligned bulk decode, NumericDocValues#longValues)
- Priority
- Low, rank 992: The released 10.5 bulk numeric longValues fails loudly, with no wrong values, only for one exact batch size of several hundred million docs per width.
- Impact class
- crash
- Impact note
- Only code that itself calls the public NumericDocValues#longValues (Lucene 10.5.0 and later; no in-tree caller uses batches anywhere near this size) with one batch of exactly 429,496,729 contiguous docs of a dense field stored as one 40-bit width, or 357,913,941 docs at 48 bits (int wrap: EOFException 'read past EOF' on a valid .dvd with the default MMapDirectory, ArrayIndexOutOfBoundsException with NIOFSDirectory or an in-heap directory), or exactly 306,783,378 / 715,827,882 / 1,073,741,823 docs at 56 / 24 / 16 bits (OutOfMemoryError 'Requested array size exceeds VM limit'); the default codec, IndexWriterConfig and directory suffice, -ea and -da behave the same, the call fails at once with an exception, no wrong values are returned and the index is not damaged; a batch one doc smaller or larger succeeds, and 8-, 32- and 64-bit widths are never affected.
- Summary
- You index a dense numeric doc-values field that the default codec stores as one fixed 40-bit width, in a segment of 429,496,729 documents, and read every value with a single NumericDocValues#longValues call, expecting what the advanceExact/longValue loop in its javadoc returns. Instead Lucene90DocValuesProducer.bulkDecodeByteAlignedValues checks only the unpadded byte count (5 x 429,496,729 = 2,147,483,645) against Integer.MAX_VALUE and then adds 3 padding bytes in int arithmetic, which wraps to Integer.MIN_VALUE; the negative length passes the slice and allocation checks, and the call throws EOFException 'read past EOF (pos=0)' on a valid file (ArrayIndexOutOfBoundsException with NIOFS or an in-heap directory). The same wrap happens at 357,913,941 docs for 48-bit values, and at one size each for 16, 24 and 56 bits the padded count asks for a byte[] longer than the JVM allows (OutOfMemoryError 'Requested array size exceeds VM limit'), while the per-document loop and a batch one document smaller return every value.
- Introduced
- 96691018f58b3d9a870e724842af3254a47f5c67 on main (apache/lucene#16129 'Add bulk decode for dense NumericDocValues via longValues()', by GitHub costin, co-authored by benwtrent, approved by jainankitk, merged by benwtrent), released through the direct branch_10x backport 4f27413dc998 of the same day (identical patch for Lucene90DocValuesProducer.java, git patch-id 5b53d5d740df for both; backport PR #16136 closed unmerged as 'backported directly') [read: git log --all -S on 'paddingBytesNeededForBulkDecode', 'byteCountLong > Integer.MAX_VALUE' and 'byteCount + paddingBytesNeededForBulkDecode' returns only these two commits; git log -L and git blame attribute every line of the block to them; the block is byte-identical from the commit to main ea57360912ed and upstream main] [exec: the commit fails with -ea and -da, its parent 098ff7d4fc63 passes]. The commit wrote the whole dense byte-aligned bulk path (padding helper, unpadded guard, int addition of the padding, allocation, readBytes), so the bug is as old as the path and not a later regression (2026-05-27 (merged 2026-05-27T15:20:43Z))
- First affected version
- 10.5.0 [exec] (release commit f6eaee8148b7, tag 2026-06-25; CHANGES 10.5.0 Optimizations 'GITHUB#16129: Add bulk decode for dense NumericDocValues via longValues().'); 10.4.0, the first release with the longValues API (#15149), has only the per-document default loop there [exec: pass]
- Affected versions
- 10.5.0, 10.5.1 and 10.5.2 (10.5.2 was the latest lucene-core on Maven Central when checked on 2026-10-26) and 10.6.0 [exec 2026-10-31: the 48-bit minimal test fails on the 10.6.0 release jars (SHA-1 checked) with -ea: EOFException 'read past EOF (pos=0)'] [exec, 40-bit: all three 10.5.x releases fail with -ea, 10.5.0 and 10.5.2 also with -da; the independent reproduction's own test fails on 10.5.0 and 10.5.2; 10.5.2 also fails at 56 bits]; main from 96691018f58b to ea57360912ed [exec: -ea and -da at both ends; on ea57360912ed all five widths 16/24/40/48/56 fail]; upstream main cf398bfb142c (read 2026-10-26) and the unreleased branch_10x and branch_10_5 heads and branch_10_6 (10.6.0, released 2026-10-29) carry the byte-identical block [read]. Not affected: 10.4.0 and the parent commit 098ff7d4fc63 [exec: pass]. Not applicable: every release before 10.4.0, including all 9.x, which have no NumericDocValues#longValues [exec: 10.3.2 and 9.12.3 do not compile the repro]. All runs: OpenJDK 25.0.3, Linux aarch64; the 16/24/56-bit OutOfMemoryError depends on the JVM's maximum array length (JDK 25: Integer.MAX_VALUE - 2 is the largest byte[] it allocates), the 40/48-bit wrap does not
- Status
- documented (brief, checks and a per-finding doc exist)
- Verification
- Confirmed by AI-run checks: an independent repro, a cross-model review that could only read the code (its low-confidence “not real” is procedural), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
- Found by
- non-formal: code reading; also reached by Z3
- Upstream and prior reports
- none found (2026-10-26 by the finder, the independent reproduction, the intended-behaviour check, the adjudication and the follow-up; verdict new; all read-only): 220 queries in total, two scripted prior-report searches (43 and 81 queries over GitHub issues and PRs, commit messages, downstream trackers, the dev@, java-user@, issues@ and commits@ lists and JIRA) plus 96 manual queries (GitHub GraphQL over apache/lucene and downstream projects, list archives, JIRA, web); the changed files of all 410 open apache/lucene PRs (15 touch the affected files, none changes the guard; #16691 changes only the Panama 8/16/32-bit kernels) and of 561 closed PRs updated since 2026-05-26 (none changes it); the #16129 review thread (1 review, 2 comments about backporting, 0 inline comments, nothing on large requests, padding or the array-length limit); 26 hits read, none the same bug. Closest items, not reports: #16129 and #16136 (introducing PR and backport), #15149 (the longValues API and its contract), #16451, #16677, #16691 and #16517 (Panama decode performance), #16160 (names bulkDecodeByteAlignedValues only as an undocumented helper), #6906 / LUCENE-5844 (2014 background: Integer.MAX_VALUE is not an allocatable array length), elastic/elasticsearch#114941 (2024, a different binary doc-values allocation). The upstream monster test Test2BNumericDocValues does not call longValues. Not fixed on upstream main cf398bfb142c (read 2026-10-26). Gaps: GitHub full-text search may not index every comment, and private security@ reports cannot be searched
- Lucene commit tested
- main
645d4fe14a20: the one-test repro with assertions on and off through the repro harness, and through gradle in a scratch worktree without the fix (fails) and with it (passes, plus the 9 closest doc-values suites, 601 tests, 0 failures and:lucene:core:check -x test); release jars 10.6.0, 10.5.2, 10.5.0 (fail) and 10.4.0 (passes), SHA-1-checked against Maven Central. The study (mainea57360912ed) also ran every width on a job host, the introducing commit96691018f58band its parent, 10.5.1, and N - 1 / N + 1 controls. Linux aarch64, OpenJDK 25.0.3. Upstream main2f424fd428da(read 2026-10-31) has the same guard.
- Finding record
lucene/core/src/java/org/apache/lucene/codecs/lucene90/Lucene90DocValuesProducer.java:644-646 (at ea57360912ed) (canBulkDecodeByteAligned: one fixed width, bitsPerValue % 8 == 0), :648-650 (isContiguous), :652-659 (paddingBytesNeededForBulkDecode: 1, 3, 2, 1 bytes for 24, 40, 48, 56 bits), :676-679 (guard on the unpadded byteCountLong > Integer.MAX_VALUE, 'return null' = per-document fallback), :680-682 (int addition of the padding), :684-692 (slice check, new byte[readByteCount], slice.readBytes, decodeByteAligned), :1007 (dense raw longValues caller)lucene/core/src/java/org/apache/lucene/index/NumericDocValues.java:39-61 (at ea57360912ed) (longValues 'behaves as if implemented as' the advanceExact/longValue loop; only precondition: docs ascending, no duplicates)lucene/core/src/java/org/apache/lucene/internal/vectorization/DefaultDocValuesBulkDecodeSupport.java:85-110 (at ea57360912ed) (8-byte VarHandle reads for 40/48/56 bits; decode40:90 throws on the empty scratch buffer)
- The finding in 30 seconds
lucene/core/src/java/org/apache/lucene/codecs/lucene90/Lucene90DocValuesProducer.java:644-696 (canBulkDecodeByteAligned, isContiguous, paddingBytesNeededForBulkDecode, guard, int addition, slice check, allocation, readBytes) at 645d4fe14a20lucene/core/src/java/org/apache/lucene/internal/vectorization/DefaultDocValuesBulkDecodeSupport.java:67-110 (4- and 8-byte loads for 24/40/48/56 bits)lucene/core/src/java/org/apache/lucene/util/packed/DirectWriter.java:153-178 (padding bytes written after the values)lucene/core/src/java/org/apache/lucene/store/MemorySegmentIndexInput.java:143-147, 786-790 (negative length -> EOFException 'read past EOF')lucene/core/src/java/org/apache/lucene/codecs/lucene90/Lucene90DocValuesProducer.java:957, 980, 990, 1012, 1036, 1059 (bulkBytes starts empty, keeps the last array), :2002-2003 (sorted-numeric singleton); index/DocValues.java:189-195 (unwrapSingleton); index/FilterNumericDocValues.java (no longValues override); lucene/core/src/java25/org/apache/lucene/internal/vectorization/PanamaDocValuesBulkDecodeSupport.java:35-41 (only 64-bit specialised)
- Who is affected
lucene/core/src/java/org/apache/lucene/index/CheckIndex.java:4158-4176 (16-doc batches with gaps)lucene/sandbox/src/java/org/apache/lucene/sandbox/facet/plain/histograms/HistogramCollector.java:185, 210 (64-doc batches)lucene/benchmark-jmh/src/java/org/apache/lucene/benchmark/jmh/NumericDocValuesBulkDecodeBenchmark.java:72-73, 152 (batches of 128 or 1,024)lucene/core/src/java/org/apache/lucene/codecs/lucene90/Lucene90DocValuesProducer.java:648-650 (isContiguous: first and last doc only)lucene/core/src/java/org/apache/lucene/search/TermScorer.java:163, search/MultiNormsLeafSimScorer.java:133, 214 (norms)lucene/core/src/java/org/apache/lucene/codecs/lucene90/Lucene90DocValuesConsumer.java:434-489 (dictionary of at most 256 values), :504-511 (blocks only if they save 10%+; single width otherwise)lucene/core/src/java/org/apache/lucene/util/packed/DirectWriter.java:202-209, 234-235 (supported widths, rounding up)
- Why it looks unintended
lucene/core/src/java/org/apache/lucene/index/NumericDocValues.java:39-61 (contract and only precondition)lucene/core/src/java/org/apache/lucene/index/NumericDocValues.java:69-75 (javadoc example: longValues over a run up to docIDRunEnd()); lucene/test-framework/src/java/org/apache/lucene/tests/index/AssertingLeafReader.java:853-866 (iterator rules)lucene/core/src/java/org/apache/lucene/codecs/lucene90/Lucene90DocValuesProducer.java:677-679, 1006-1010 (return null -> super.longValues)lucene/core/src/java/org/apache/lucene/util/ArrayUtil.java:32-34 (MAX_ARRAY_LENGTH)lucene/core/src/test/org/apache/lucene/codecs/lucene90/TestLucene90DocValuesFormat.java:179-220 (testDenseNumericLongValuesBulkFetch, 512 docs)
- Suggested fix
lucene/core/src/java/org/apache/lucene/codecs/lucene90/Lucene90DocValuesProducer.java:980, 1012, 1059 (bulkBytes kept after the call)lucene/core/src/java/org/apache/lucene/util/ArrayUtil.java:32-34
- History
lucene/CHANGES.txt:1140 (GITHUB#16129, 10.5.0 Optimizations)a Lucene clone tags releases/lucene/10.4.0, 10.5.0, 10.5.2, 10.6.0 (guard present from 10.5.0)
- TestNumericDocValuesBulkLongValuesSize.java (one test):
repro/LFV-381/TestNumericDocValuesBulkLongValuesSize.java(59 lines)
Made in parallel with this doc from the code at 645d4fe14a20; every number is the arithmetic of bulkDecodeByteAlignedValues for 48-bit values and 357,913,941 docs.
assets/LFV-381/bug.png(517 KB)
