All findings · Lucene finding · LFV-381 · numeric-dv-bulk-decode-padded-size-overflow

Bulk NumericDocValues#longValues throws on a valid index for one batch size at 16/24/40/48/56 bits: its size guard checks the unpadded count (10.5.0+)

Since 10.5.0, one longValues call over exactly 357,913,941 docs of a dense 48-bit field throws read past EOF on a valid index; one doc fewer or more works. The size check ignores 2 padding bytes, so 2,147,483,646 + 2 wraps negative; four other widths have one such size each.

Summary

Priority
Low, rank 992The 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
crash
Since
10.5.0 (#16129, merged 2026-05-27)introduced 2026-05-27
Affected
10.5.0-10.6.0 and main; not 10.4.0 or earlier
Status
documented
Found by
non-formal: code reading, made exact by a Z3 bit-vector model, then executed tests

AI-assisted page: this finding was first signalled by code reading or testing 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

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.

Background

  1. 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.
  2. NumericDocValues#longValues(size, docs, values, defaultValue) fills values for a sorted list of doc ids. Its javadoc says it behaves like calling advanceExact and longValue for each doc.
  3. A Java int holds at most 2,147,483,647 (2^31 - 1); adding more wraps it to a negative number. A byte[] can be at most a few bytes shorter than that.
Diagram of the bug: a longValues call over 357,913,941 docs with 48-bit values needs 6 x 357,913,941 = 2,147,483,646 value bytes plus 2 padding bytes. The size check compares only the 2,147,483,646 with Integer.MAX_VALUE (2,147,483,647) and passes; adding the 2 padding bytes in int gives 2,147,483,648, which wraps to -2,147,483,648, and the read fails with EOFException: read past EOF on a valid index. One doc fewer fits; one doc more falls back to the per-document loop.
One 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.

The finding, step by step

  1. Index 357,913,941 docs with a NumericDocValuesField whose 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, and forceMerge(1).
  2. Call longValues(357913941, docs, values, -1) once for docs 0 to 357,913,940. You expect every value, as the per-document advanceExact/longValue loop of its javadoc returns them.
  3. 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 against Integer.MAX_VALUE (2,147,483,647): passes.
  4. 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 throws EOFException: read past EOF (pos=0) on a valid file.
  5. 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.

Who is affected

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.

Where it happens

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

The five failing sizes

widthdocs in the one callpadded byte countresult
40-bit429,496,7295N + 3 = 2^31, wraps to -2,147,483,648EOFException: read past EOF (pos=0) (memory-mapped directory)
48-bit357,913,9416N + 2 = 2^31, wrapssame
56-bit306,783,3787N + 1 = 2,147,483,647OutOfMemoryError: Requested array size exceeds VM limit
24-bit715,827,8823N + 1 = 2,147,483,647same
16-bit1,073,741,8232N = 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 largest byte[] is smaller, a few more sizes just below these would also fail with OutOfMemoryError (by arithmetic; not run).
  • On JDK 25 the largest byte[] that allocates is Integer.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 NIOFSDirectory or the in-heap ByteBuffersDirectory the 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 throws ArrayIndexOutOfBoundsException: 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's values and 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 SortedNumericDocValues field is served by the same iterator (DocValues.singleton(getNumeric(entry)), :2002-2003), so code that unwraps it with DocValues.unwrapSingleton reaches the same path; wrappers built on FilterNumericDocValues that do not override longValues run 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 longValues call, 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 (isContiguous compares only the first and the last doc). In Lucene itself, CheckIndex uses batches of 16 with gaps and the sandbox HistogramCollector batches of 64; the JMH benchmark NumericDocValuesBulkDecodeBenchmark uses 128 or 1,024; TermScorer and MultiNormsLeafSimScorer call it on norms, which a different producer serves.
  • Default, not opt-in. The default codec, IndexWriterConfig and 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[] and long[] 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 OutOfMemoryError as 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.

Minimal repro

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.

TestNumericDocValuesBulkLongValuesSize.java (one test)59 lines
/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.apache.lucene.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);
        }
      }
    }
  }
}
Command
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
longValues returns all 357,913,941 values, as the per-document loop does.
Observed
EOFException: read past EOF (pos=0) on the valid .dvd, thrown from Lucene90DocValuesProducer.bulkDecodeByteAlignedValues:691, with assertions on and off.

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

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.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT); also fails on 10.5.0, 10.5.2 and 10.6.0; passes on 10.4.0
jdkOpenJDK 25.0.3
osLinux aarch64

Observed output

# 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

Why it looks unintended

  • The javadoc says longValues "behaves as if implemented as" the per-document advanceExact/longValue loop; 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 the advanceExact/longValue loop (:45-58). The only stated precondition (:60-61): the docs array "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 calling longValues over a dense run up to docIDRunEnd(), 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 current docID() up to maxDoc - 1; the repro meets them.)
  • The code handles oversized requests. The guard at Lucene90DocValuesProducer.java:677-679 returns null, 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, against Integer.MAX_VALUE instead 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_VALUE minus 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, as Integer.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.

Suggested fix

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: TestLucene90DocValuesFormat and its MergeInstance variant (both with the upstream bulk test testDenseNumericLongValuesBulkFetch), TestLucene90DocValuesFormatVariableSkipInterval, TestDocValues, TestDocValuesIndexing, TestMultiDocValues, TestOverlayNumericDocValues, TestNumericDocValuesUpdates and TestCheckIndex.
  • ./gradlew :lucene:core:check -x test passes 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.

Only add the padding in long, keep Integer.MAX_VALUE

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.

Decode large requests in bounded chunks

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.

Math.addExact / Math.toIntExact

Turns the wrap into an ArithmeticException.

Trade-offs

Still an exception on a valid request. Not a fix.

Downstream note

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.

How it was found

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/long wrap, IndexWriter.MAX_DOCS and contiguous in-segment docs as preconditions and the measured JDK 25 byte[] 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 @Monster JUnit 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.

Prior reports, history and checks

Prior reports

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.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene; Elasticsearch, OpenSearch, Solr), mailing lists, JIRA archive, web2026-10-26220 queries: two runs of the hunt's prior-report tool (43 and 81 queries) and 96 manual queries26 hits read, none the same bug
Same sources, repeated and extended2026-10-31205 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 queriesno report; 19 medium hits triaged, all different
Open and recently closed PRs2026-10-31changed files of all 410 open PRs and 598 PRs closed since 2026-05-26, patches grepped for the guard identifiersnone changes the guard
Upstream branches2026-10-31guard lines read through the GitHub API on main, branch_11x, branch_10_6, the 10.6.0 tag and branch_10_5byte-identical block everywhere; not fixed

History

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.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affected48-bit test fails with assertions on and off (repro harness and gradle); passes with the fix
10.6.0 affected48-bit test fails (EOFException at bulkDecodeByteAlignedValues:694)
10.5.2 affected48-bit test fails with assertions on and off; 40- and 56-bit fail in the study
10.5.1 affected40-bit test fails (study)
10.5.0 affected48-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 affectedpasses: the per-document default loop only
10.3.2, 9.12.3 no APIno NumericDocValues#longValues; the repro does not compile

Independent checks

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.

Each independent check and its verdict
CheckVerdictWhat it found
review, 2026-10-26 (OpenAI GPT-5.6) real, highRefute lens, read-only: root cause agrees, the fix fixes it, inputs in contract; no documented behaviour permits the failures.
finder candidateZ3 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 realIts 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 realNo javadoc, CHANGES, MIGRATE text, test or review comment limits the batch size.
adjudication confirmed, highRe-ran the finder's repro: fails as claimed.
refute review, 2026-10-31 (OpenAI GPT-5.6) real, highIts 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 reproducesMain a9779ef3c193 and 645d4fe14a20, 10.5.2, with assertions on and off.
doc fact check, 2026-10-31 still reproducesRe-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.

Diagram file

The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.

Sources and provenance

Finding record, Lucene and upstream sources, repro files and diagram notes1,510 words

Finding record

LFV-381, snapshot of 2026-11-06

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 (main ea57360912ed) also ran every width on a job host, the introducing commit 96691018f58b and its parent, 10.5.1, and N - 1 / N + 1 controls. Linux aarch64, OpenJDK 25.0.3. Upstream main 2f424fd428da (read 2026-10-31) has the same guard.

Lucene and upstream sources

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 645d4fe14a20
  • lucene/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)

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

Repro files

About the diagrams

Made in parallel with this doc from the code at 645d4fe14a20; every number is the arithmetic of bulkDecodeByteAlignedValues for 48-bit values and 357,913,941 docs.

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.