All findings · Lucene finding · LFV-386 · byteslicereader-final-slice-int-overflow-at-pool-end

A postings stream that ends at the very last byte of the 2 GiB indexing pool reads back with up to 3 wrong bytes (int overflow in ByteSliceReader)

When one document fills its indexing thread's 2 GiB in-RAM postings pool to the exact last byte, an int overflow in ByteSliceReader can commit a term's last 1-3 postings bytes wrong: a payload ending 0x41 0x42 0x43 reads back 0x11 0x11 0x11, and CheckIndex reports the segment clean.

Summary

Priority
Low, rank 966Silent postings corruption that CheckIndex accepts is severe, but it needs a contrived 2 GiB pool filled to its exact last byte with exact slice alignment, so it follows LFV-385.
Impact
data corruption
Since
2.3.0 (LUCENE-843, 2007); latent, not a regressionintroduced 2007-07-04 · over 19 years
Affected
2.3.0-10.6.0 and main
Status
documented
Found by
formal: Z3 bit-vector model of the slice arithmetic, then executed tests

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

The finding in 30 seconds

The reader's test "is the next slice the last one?" adds two ints. For a slice that ends exactly at byte 2^31 of the pool the sum overflows, so the last slice is read as if it pointed onward, and its last 3 bytes are taken as an address.

Background: how Lucene buffers postings in RAM

  1. While indexing, each thread keeps its terms' postings (doc numbers, frequencies, positions, payloads) in one in-RAM byte pool of 32 KiB blocks. The pool holds at most 65,536 blocks = 2^31 bytes; asking for one more throws ArithmeticException (since 9.8.0).
  2. Each term's stream is a chain of slices of growing size: 5 bytes, then 14, 20, 30, 40, 40, 80, 80, 120 and 200. A slice that fits exactly at the end of a block is placed there. A slice's last byte is a level marker; when a slice is full, its last 4 bytes become the address of the next slice.
  3. At flush, ByteSliceReader walks the chain: in a slice that is not the last, it stops 4 bytes before the end, reads the address and jumps; in the last slice it reads up to the stream's end.
Summary card of the bug. Title: in-RAM postings, an int sum wraps at the 2 GiB pool end, so IndexWriter silently commits wrong payload bytes. Three columns: what you do (one document whose token payloads fill the pool, ending with term b and a 355-byte payload ending 41 42 43; addDocument, close, getPayload); expected 41 42 43 versus actual 11 11 11, copied from offset 0x19434241, with no error and CheckIndex clean; and what stays correct (a stream ending 3 bytes earlier, slices ending below 2^31, one block more throws ArithmeticException). A four-box flow at flush: the pool is full to byte 2147483647 and b's last slice is 2147483448 + 200; the int sum nextIndex + newSize wraps from 2147483648 to -2147483648; the final-slice test says not last, so limit is 32764 instead of 32767; the bytes 41 42 43 19 are read as the address 0x19434241, which supplies 11 11 11. Below: trigger, effect, affected versions, the one-test repro and the fix direction.
The last block of the 2 GiB pool, the stream's final slice ending at byte 2^31, the wrapped sum, and the 3 bytes read from the wrong place. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Index one document with the default IndexWriterConfig and codec. Its tokens carry payloads: 65,419 tokens a with 32,000-byte payloads (about 1.95 GiB), then a few planned tokens, ending with term b and a 355-byte payload whose last 3 bytes are 0x41 0x42 0x43. Together they fill the indexing thread's in-RAM postings pool, 65,536 blocks of 32 KiB = 2^31 bytes, to its very last byte.
  2. You expect b's payload back exactly, or the loud ArithmeticException Lucene throws when the pool would grow past 2^31 bytes.
  3. At flush the reader walks b's chain of slices and asks of each next slice "is this the last one?" with nextIndex + newSize >= endIndex in int. b's last slice is the 200 bytes ending at byte 2^31, so the sum is exactly 2,147,483,648, which wraps to -2,147,483,648: "not the last".
  4. So the reader takes the slice's last 3 data bytes plus its marker byte as the address of a next slice, and reads the payload's last 3 bytes from there: 0x41 0x42 0x43 come back as 0x11 0x11 0x11.
  5. No error: the commit succeeds and CheckIndex reports the segment clean. With that payload 3 bytes shorter, or with the fixed comparison, everything reads back correctly.

Who is affected

Anyone whose single indexing call (one document or one addDocuments block) fills its thread's in-RAM postings pool to exactly 2 GiB with this alignment; the default config and codec suffice. It needs about 2 GB of postings in one call plus exact alignment, so it is contrived; the result is up to 3 wrong committed bytes that CheckIndex accepts.

Mechanism, terms and numbers787 words

During indexing, each thread buffers its postings (doc deltas, frequencies, positions, payloads) in one in-RAM byte pool of 32 KiB blocks, as chains of small "slices"; at flush ByteSliceReader walks each chain and follows the forwarding address at the end of every slice but the last. Its test for "the next slice is the last one", nextIndex + newSize >= endIndex (ByteSliceReader.java:109), is computed in int. The pool deliberately admits its 65,536th block, whose last byte is global offset 2^31 - 1, and the allocator places a slice that fits exactly at the end of a block, so a stream's final slice can end exactly at 2^31. The sum is then 2^31, which wraps negative; the reader treats the final slice as non-final and, if the stream wrote any of the slice's last 3 data bytes, returns those 1-3 bytes from an unrelated place in the pool.

Through IndexWriter with the default config and codec, one document with payloads that fills the pool this way commits normally, a 355-byte payload reads back with its last 3 bytes wrong, and CheckIndex reports the segment clean. A slightly larger input fails loudly with the ArithmeticException that #12392 added for this regime. The expression dates from LUCENE-843 (2007, first released in 2.3.0); comparing lengths (endIndex - nextIndex <= newSize) fixes it. Priority is low: it needs about 2 GB of postings in one indexing call and exact alignment.

The bug, step by step (main 645d4fe14a20)

  1. The pool can be exactly 2^31 bytes. ByteBlockPool.nextBuffer advances byteOffset = Math.addExact(byteOffset, BYTE_BLOCK_SIZE) (ByteBlockPool.java:199): the 65,536th block (offset 2^31 - 32,768) is admitted, the next throws. Upstream TestByteBlockPool.testTooManyAllocs asserts this boundary.
  2. A slice can end at the block's last byte. ByteSlicePool.allocKnownSizeSlice opens a new block only if pool.byteUpto > BYTE_BLOCK_SIZE - newSize (ByteSlicePool.java:117-119), so an exact fit stays; the last byte gets the marker 16 | level (:137), and the previous slice's last 4 bytes the forwarding address (:134). newSlice uses the same rule (:79).
  3. The overflow. ByteSliceReader.nextSlice reads the address (ByteSliceReader.java:98) and the next size (:101), then tests if (nextIndex + newSize >= endIndex) (:109). endIndex, the stream's write position, is at most 2^31 - 1, because the slice's last byte is the marker. For the slice [2^31 - newSize, 2^31) the sum is 2^31, which wraps to -2,147,483,648: the test is false, and the reader sets limit = upto + newSize - 4 (:116), as for a slice that points onward. The assert endIndex - nextIndex > 0 (:111) sits on the other branch, so -ea does not catch it.
  4. The misread. If the stream stops before the slice's last 3 data bytes, the reader stops at endIndex first and nothing goes wrong. If it wrote into any of them, the reader reaches limit (readByte :73, readBytes :129), calls nextSlice again, reads those bytes plus the marker as a little-endian address (:98) and returns the stream's last 1-3 bytes from there. (For a few byte values that address lands so close to a block's end that the remaining 2-3 bytes do not fit; then the read runs off the 32 KiB block and throws ArrayIndexOutOfBoundsException instead, as a probe on main showed.)
  5. How IndexWriter gets there. At flush, FreqProxFields reads each term's doc/freq and position/payload streams through TermsHashPerField.initReader (TermsHashPerField.java:94) and this reader (FreqProxFields.java:320, :435-436). Term bytes and postings share one pool (TermsHash.java:50-55). The per-thread RAM hard limit is checked only after a whole document (DocumentsWriter.java:427-434, DocumentsWriterFlushControl.java:239), so one document can fill the pool to its last byte.
  6. The init twin. init has the same pattern, startIndex + firstSize >= endIndex (:58), but TermsHashPerField.initStreamSlices always leaves room after a first slice (TermsHashPerField.java:158-162), so the indexing chain cannot produce that layout. Changing it is for consistency only.

The numbers in the minimal test

value
last block65,535 (global offsets 2,147,450,880 .. 2,147,483,647)
stream5-byte first slice, then a 14-byte slice [2,147,483,634, 2,147,483,648)
writtenthe 14 bytes 1..14: byte 1 stays in the 5-byte slice (its last 4 bytes become the address), bytes 2..14 go into the 14-byte slice, then its marker 0x11
nextIndex + newSize2,147,483,634 + 14 = 2,147,483,648, wraps to -2,147,483,648
address read insteadbytes 12, 13, 14, 0x11 = 0x110E0D0C = 286,133,516 (block 8,732, zero-filled)
read back1..11, then 0, 0, 0

Boundary (executed by the tie-break's own controls): 11 bytes read back correctly; 12, 13 and 14 bytes give 1, 2 and 3 wrong bytes; the same 14 bytes one block earlier read back correctly.

Exposure and likelihood358 words
  • Defaults. Default IndexWriterConfig (16 MB RAM buffer, 1,945 MB per-thread hard limit) and the default codec, executed end to end. The payloads come from the public TokenStream API, not a setting.
  • Scale and alignment. With the 16 MB buffer the thread flushes after any larger document, so one document must bring about 2 GiB of term bytes and postings by itself. One stream's final slice must be the exact-fit slice ending at byte 2^31, with the stream's last write in that slice's last 3 data bytes, and the call must end before anything else allocates from the pool (the next allocation throws the known ArithmeticException). A planned token stream hits it deterministically; ordinary workloads essentially never. Reproducing end to end takes an 8 GiB heap and about 6 s.
  • Large RAM buffers (code reading, not executed): the hard limit is checked only between documents, so with a large non-default buffer earlier documents can pre-fill the pool and the crossing document needs only the rest, about 103 MiB with the default 1,945 MB hard limit and about 1 MiB with the maximum 2,047 MB. Exact alignment is still needed.
  • What goes wrong. Up to 3 wrong bytes at the end of one term's stream are committed with no error, and CheckIndex cannot tell. Executed: the last 3 bytes of a payload. Code reading only: the bytes could also be the last position or offset codes, and a hit in a doc/freq stream could change a doc delta or frequency or make the flush fail.
  • Not tested, not claimed: term vectors (TermVectorsConsumerPerField.java:114-117 uses the same reader), MemoryIndex's similar test on its int pool (MemoryIndex.java:360), and FSDirectory (the wrong bytes come from the in-RAM buffer before the codec writes, so the directory should not matter).
  • Bounds. The bogus address's high byte is the slice's level marker (0x11 to 0x19), so it always points 272-416 MiB into the full pool (code reading; the runs that print it agree). No hang, no memory unsafety. In the rare byte-value case above the read throws ArrayIndexOutOfBoundsException instead; through IndexWriter that would be an exception at flush (not executed).

Minimal repro

One JUnit test in org.apache.lucene.index (the classes are package-private, as in upstream TestByteSliceReader). It advances a byte pool to its last block with a memory-saving allocator, writes the 14 bytes 1..14 into two slices whose second ends exactly at byte 2^31, and reads them back. No index, about 1 s, deterministic.

TestByteSliceReaderFinalSliceAtPoolEnd.java (one test)61 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 org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.util.ByteBlockPool;

public class TestByteSliceReaderFinalSliceAtPoolEnd extends LuceneTestCase {

  public void testFinalSliceEndingAtTwoToThe31() {
    byte[] shared = new byte[ByteBlockPool.BYTE_BLOCK_SIZE];
    ByteBlockPool pool =
        new ByteBlockPool(
            new ByteBlockPool.Allocator(ByteBlockPool.BYTE_BLOCK_SIZE) {
              int count;

              @Override
              public byte[] getByteBlock() {
                return ++count < 1 << 16 ? shared : new byte[blockSize];
              }

              @Override
              public void recycleByteBlocks(byte[][] blocks, int start, int end) {}
            });
    for (int i = 0; i < 1 << 16; i++) {
      pool.nextBuffer();
    }
    ByteSlicePool slicePool = new ByteSlicePool(pool);
    slicePool.newSlice(ByteBlockPool.BYTE_BLOCK_SIZE - 5 - 14);
    int upto = slicePool.newSlice(ByteSlicePool.FIRST_LEVEL_SIZE);
    int startIndex = pool.byteOffset + upto;
    byte[] expected = new byte[14];
    for (int i = 0; i < expected.length; i++) {
      if ((pool.buffer[upto] & 16) != 0) {
        upto = slicePool.allocSlice(pool.buffer, upto);
      }
      expected[i] = (byte) (i + 1);
      pool.buffer[upto++] = expected[i];
    }
    ByteSliceReader reader = new ByteSliceReader();
    reader.init(pool, startIndex, pool.byteOffset + upto);
    byte[] actual = new byte[expected.length];
    reader.readBytes(actual, 0, actual.length);
    // expected: the 14 bytes written; actual: the last 3 come from elsewhere in the pool (0, 0, 0)
    assertArrayEquals(expected, actual);
  }
}
Command
cd $LUCENE   # an apache/lucene checkout at 645d4fe14a20
cp $REPRO/TestByteSliceReaderFinalSliceAtPoolEnd.java lucene/core/src/test/org/apache/lucene/index/
./gradlew :lucene:core:test --tests org.apache.lucene.index.TestByteSliceReaderFinalSliceAtPoolEnd
# assertions off (production-like): add -Ptests.asserts=false
Expected
The 14 bytes written, 1..14, read back unchanged.
Observed
arrays first differed at element [11]; expected:<12> but was:<0>: the last 3 bytes read back as 0, 0, 0, with assertions on and off.

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

More files, environment, full output278 words

The allocator hands out one shared zeroed 32 KiB array for the first 65,535 blocks (the memory-saving pattern of upstream TestByteBlockPool.testTooManyAllocs) and a fresh array for the last, so the test needs about 64 KiB of block memory. A filler slice of 32,768 - 5 - 14 bytes makes the stream's 5-byte and 14-byte slices tile the rest of the last block. The write loop is upstream TestByteSliceReader's.

The shared array is not what causes the failure: a full-size test with the stock DirectTrackingAllocator, all 65,536 real blocks and the real TermsHashPerField write path fails the same way, and so does the public-API end-to-end program (one document, default config and codec, 8 GiB heap); both are impact evidence, not the repro. As a regression test it is the same test: it fails on stock and passes with the fix, and needs no @Monster annotation.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT); the same failure on the 10.5.2 and 10.6.0 jars
jdkOpenJDK 25.0.3
osLinux aarch64

Observed output

# gradle, stock 645d4fe14a20, seed DEADBEEF, assertions on and off (same output)
TestByteSliceReaderFinalSliceAtPoolEnd > testFinalSliceEndingAtTwoToThe31 FAILED
    arrays first differed at element [11]; expected:<12> but was:<0>
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:8031BDBC8E36FC30]:0)
        at app//org.junit.internal.ComparisonCriteria.arrayEquals(ComparisonCriteria.java:78)
        ...
        at app//org.apache.lucene.index.TestByteSliceReaderFinalSliceAtPoolEnd.testFinalSliceEndingAtTwoToThe31(TestByteSliceReaderFinalSliceAtPoolEnd.java:59)
        Caused by:
        java.lang.AssertionError: expected:<12> but was:<0>

# with the fix, assertions on and off
:lucene:core:test (SUCCESS): 1 test

# impact evidence: public-API end-to-end program, one document, default config and codec
indexed; files: [_0.cfe, _0.cfs, _0.si, segments_1]
term b: freq=2, payload 1 (355 bytes) differs [352]: indexed 0x41, read 0x11 [353]: indexed 0x42, read 0x11 [354]: indexed 0x43, read 0x11;
CheckIndex clean=true
# control (that payload 3 bytes shorter): term b: freq=2, positions and payloads OK; CheckIndex clean=true

Why it looks unintended

  • ByteSliceReader promises to read each slice to its end, then follow its forwarding address; here it follows an address that was never written.
  • The pool admits its 65,536th block on purpose; for growing past it Lucene chose a loud ArithmeticException (#12392), not silent wrong data.
  • With the two-comparison fix the same full 2 GiB document reads back exactly.
The full argument184 words
  • The reader's contract. "We read the bytes in each slice until we hit the end of that slice at which point we read the forwarding address of the next slice and then jump to it" (ByteSliceReader.java:25-29).
  • The public contract. PostingsEnum.getPayload: "Returns the payload at this position" (PostingsEnum.java:96-100). IndexWriter.addDocument documents no per-document size limit other than MAX_TERM_LENGTH (IndexWriter.java:1480-1481).
  • The state is legal. The pool admits the 65,536th block and only the next throws (ByteBlockPool.java:199, asserted by TestByteBlockPool.testTooManyAllocs); CHANGES.txt: "Each DWPT can address up to 2048 MB memory" (CHANGES.txt:15722-15729). Every offset fits in an int; only the intermediate sum does not.
  • Upstream's answer for this regime is a loud failure. "GITHUB#9660: Throw an ArithmeticException when the offset overflows in a ByteBlockPool", under Bug Fixes (CHANGES.txt:3445, #12392); IndexingChain turns it into an aborting exception (IndexingChain.java:2098-2100). This exact fit slips past that guard.
  • Nothing else is exhausted. With the fix, the same full-pool document indexes and reads back exactly (executed). No javadoc, test, CHANGES entry or discussion accepts the wrapped comparison.
The devil's-advocate check156 words

The strongest case for "unsupported": setRAMPerThreadHardLimitMB calls the hard limit a safety limit against "address space exhaustion due to its internal 32 bit signed integer based memory addressing. The given value must be less that 2GB (2048MB)" (IndexWriterConfig.java:374-378); DocumentsWriterFlushControl.java:240-241 says "we can not address more than 2048 MB per DWPT"; on PR #15330 rmuir wrote "But this is not supported: the limits on IndexWriter are 2GB"; on #10790 mikemccand called the hard limit "best effort" and said a gigantic document "can lead to exceptions like this"; and the classes are package-private.

Why it fails: the failing state is inside the documented 2,048 MB, Lucene itself lets one document reach it, and every statement above accepts an exception there, never silent wrong postings that CheckIndex accepts. It argues for low priority, not for "not a bug". The works-as-designed reviewer from another model family (OpenAI GPT-5.6) rated the argument moderate and still called the bug real.

Suggested fix

Recommended Compare lengths instead of int sums

Compare lengths instead of sums in ByteSliceReader: endIndex - nextIndex <= newSize in nextSlice (and endIndex - startIndex <= firstSize in init, for consistency). The differences cannot overflow. The repro passes with assertions on and off, and so do the closest suites (109 tests), the whole core suite (8,898 tests) and check.

-    if (startIndex + firstSize >= endIndex) {
+    if (endIndex - startIndex <= firstSize) { // no int overflow when the slice ends at 2^31
       // There is only this one slice to read
@@
-    if (nextIndex + newSize >= endIndex) {
+    if (endIndex - nextIndex <= newSize) { // no int overflow when the slice ends at 2^31
       // We are advancing to the final slice
All options and trade-offs4 options · 456 words

The study's two-line fix was run through gradle in a scratch worktree of 645d4fe14a20 (OpenJDK 25.0.3, seed DEADBEEF).

Compare lengths instead of int sums (validated by the checks) (recommended)

At ByteSliceReader.java:109 and :58. Because 0 <= nextIndex < endIndex <= 2^31 - 1 (and startIndex <= endIndex), the differences cannot overflow; the test is otherwise the same. No allocator or format change. The :58 line cannot be reached through the indexing chain and is changed for consistency.

-    if (startIndex + firstSize >= endIndex) {
+    if (endIndex - startIndex <= firstSize) { // no int overflow when the slice ends at 2^31
       // There is only this one slice to read
@@
-    if (nextIndex + newSize >= endIndex) {
+    if (endIndex - nextIndex <= newSize) { // no int overflow when the slice ends at 2^31
       // We are advancing to the final slice

Trade-offs

  • Two comparisons on the flush read path; no measurable cost expected, no benchmark run.
  • MemoryIndex.SliceReader has a similar test on its int pool (MemoryIndex.java:360); it was not tested and is not part of this fix.

What the checks validated

  • Stock: the repro fails with assertions on and off.
  • Fix: the repro passes with assertions on and off.
  • The closest core suites (TestByteSliceReader, TestByteSlicePool, TestTermsHashPerField, TestByteBlockPool, TestPayloads, TestPayloadsOnVectors, TestPostingsOffsets, TestTermVectorsWriter, TestDocumentWriter, TestFlushByRamOrCountsPolicy, TestIndexWriterExceptions): 11 classes, 109 tests, 0 failures, 3 skipped.
  • The whole core suite with the repro in the tree: 749 classes, 8,898 tests, 0 failures, 322 skipped.
  • :lucene:core:check -x test with the repro in the tree passes (google-java-format, ecjLint, forbiddenApis).
  • Earlier, shadowed onto the stock jars: the minimal test, the tie-break's boundary controls (5/5) and, on the study commit, the study's package-level repros and upstream TestByteSliceReader, TestByteSlicePool and TestTermsHashPerField pass; the same full-pool end-to-end document reads back exactly with the fixed reader.

Compute the sums in long (validated by the checks)

(long) nextIndex + newSize >= endIndex at :109 and (long) startIndex + firstSize >= endIndex at :58: same effect, keeps the original shape.

What the checks validated

By the study's independent reproducer on its commit: its JUnits, the full-size test with the stock allocator and the end-to-end program pass. Not run through gradle here.

Never place a slice at the pool's last byte

Make the allocator open no exact-fit slice at the end of block 65,535.

Trade-offs

Changes the allocator for one edge case and leaves an overflow-prone test in the reader. Not written or run.

Flush before the pool's last block (as open PR #15330 proposes)

A check in doAfterDocument.

Trade-offs

Runs between documents, so one document can still fill the pool; does not fix this.

Downstream note

Not a Lucene security issue; downstream, a service that indexes untrusted documents without a size cap far below 2 GiB would let a submitter plant up to 3 wrong bytes in their own document's postings.

How it was found

Formal: an AI tool modelled the pool's slice arithmetic as Z3 bit-vector constraints. The solver showed that the reader's final-slice test goes wrong only for a slice ending exactly at byte 2^31, and that data is misread exactly when the stream uses one of that slice's last 3 data bytes. Tests on the real code and through IndexWriter confirmed it.

The method in full149 words

Method: formal (an SMT model produced the signal), a hunt unit on Lucene's byte and int pools.

  • A Z3 bit-vector model of ByteSlicePool and ByteSliceReader (query group Q4): Q4c is satisfiable at every slice level with block 65,535 and a slice ending at 2^31; Q4c' (a wrap anywhere else) is unsatisfiable; Q4d / Q4d' show the reader follows a bogus address if and only if one of the slice's last 3 data bytes is used; Q4e' shows TermsHashPerField.initStreamSlices cannot produce the init twin.
  • A bounded-exhaustive round-trip harness below the pool end (3,564,378 write schedules, 6,300,936 read-backs in 5 read modes) found no other defect.
  • The witness was executed as a JUnit through the real TermsHashPerField write path; an independent verifier then wrote a full-size test with the stock allocator and a public-API end-to-end program, using a planner that replays the indexing chain's pool writes to compute the token schedule.

Prior reports, history and checks

Prior reports

No prior report: 192 query records over GitHub issues and PRs, commits, the mailing lists, JIRA, downstream projects and the web, plus the changed files of all 410 open PRs (2026-10-31; an earlier 296-record search on 2026-10-26 agrees). Closest: #10790, a different ByteSliceReader assertion with an unknown cause.

Searches5 searches

Closest items, none this bug: #10790 / LUCENE-9751 (open) trips the init assertion endIndex - startIndex >= 0 with a 368 MB per-thread buffer that cannot fill a 2^31-byte pool, cause unknown; #9660 / #12392 added the 2^31 cap this case slips past; #15152 and open PR #15330 are about the loud ArithmeticException and check between documents; #9166 and #10080 are loud overflows before the cap; #7750 and #6232 were JVM bugs or bad memory. Tracking issue #16937 and a dev@ thread link this bug.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene; Elasticsearch, OpenSearch, Solr, lucene.net)2026-10-26; repeated and extended 2026-10-31the hunt's prior-report tool (74 queries on 2026-10-31) plus 74 manual GraphQL queries, including every apache/lucene issue and PR created since 2026-10-22no report; #10790 is the closest (different symptom and trigger)
Open PRs2026-10-31the changed-file lists of all 410 open apache/lucene PRs; diffs of PRs touching neighbouring filesnone changes ByteSliceReader.java or ByteSlicePool.java
Mailing lists (dev@, java-user@, issues@, commits@)2026-10-3124 tool queries and 32 manual searches (8 terms on each list)no report; a 2008 java-user thread on a JVM bug
JIRA archive2026-10-316 tool queries and 5 JQL queries on LUCENE and SOLRonly the related items above
Web2026-10-316 tool queries and 2 extra phrasingsnothing related

History

Born with LUCENE-843 (cff5767e443, 2007-07-04, first released in 2.3.0), which introduced the in-RAM byte slices; unchanged since. Every release from 2.3.0 to 10.6.0 and main is affected: the root cause was executed on 17 releases, end to end through IndexWriter on 7 releases from 8.11.4 to 10.6.0, and main. Not fixed upstream.

Versions tested9 rows

Introduced by cff5767e443 (svn r553236, 2007-07-04), "LUCENE-843: speed up IndexWriter performance", by Michael McCandless (JIRA LUCENE-843, migrated as #1918; no PR). It added DocumentsWriter with a nested ByteSliceReader whose nextIndex + newSize >= endIndex and startIndex+firstSize >= endIndex are today's two lines. e5f9b4e1cb3 (LUCENE-1212, 2008) moved them unchanged to their own file; 2d6ad2fee6d (2020) only reformatted the init line.

Latent, not a regression. #12392 (b88d3e19889, 2023, 9.8.0) added the 2^31 cap; it neither caused nor fixed this: 9.7.0 (no cap) and 9.8.0 (cap) both fail.

Scope of execution. The minimal test needs ByteSlicePool (9.9.0+), so it was ported with the same layout and 14-byte stream to the 2.3.x, 2.4-3.6 and 4.0-9.8 APIs; every port fails, and the one-block-earlier control passes wherever it was run (2.3.0, 2.3.2, 2.4.0, 3.6.2, 4.0.0 and 9.8.0). Before 8.11.4, reachability through IndexWriter rests on identical code only (the end-to-end schedule was built for the 8.x+ layout).

Not fixed upstream (2026-10-31): main 2f424fd428da, branch_11x and branch_10_6 still have both lines; the newest commit on ByteSliceReader.java is a70432c110c (#12795, 2023-11-13).

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedthe minimal test fails with assertions on and off (gradle and the standalone runner); end to end: 3 wrong payload bytes, CheckIndex clean
10.6.0, 10.5.2 affectedthe minimal test fails with assertions on and off; end to end on both: the same 3 wrong bytes, controls pass
10.0.0, 9.12.3, 9.7.0, 9.0.0, 8.11.4 affectedthe end-to-end program: the same 3 wrong payload bytes; CheckIndex clean on 10.0.0, 9.12.3, 9.0.0 and 8.11.4 (not run on 9.7.0); controls pass on 9.12.3 and 8.11.4
9.9.0, 9.8.0 affectedthe minimal test (9.9.0) and its 4.0-9.8 port (9.8.0, the first release with the 2^31 cap): 1..11, then 0, 0, 0
7.7.3, 6.6.6, 5.5.5, 4.0.0 affectedthe 4.0-9.8 port: the same bytes; 4.0.0 control passes
3.6.2, 3.0.0, 2.4.0 affectedthe 2.4-3.6 port: the same bytes; controls pass on 3.6.2 and 2.4.0
2.3.0, 2.3.1, 2.3.2 affectedthe 2.3.x port (classes nested in DocumentsWriter): the same bytes; controls pass on 2.3.0 and 2.3.2
2.2.0 code absentno ByteSliceReader or DocumentsWriter; the probe does not compile
fix on 645d4fe14a20 not affectedthe minimal test passes with assertions on and off; the whole core suite (8,898 tests) passes

Independent checks

Confirmed with high confidence by the study's judge after an independent reproducer, a devil's advocate and a works-as-designed review by another model family (OpenAI GPT-5.6). A later refute review that could not run anything returned a procedural "not real"; a tie-break re-ran everything and found it real, high confidence.

Checks and verdicts255 words

Each checker that executed wrote or re-ran its own tests; the tie-break is by the same model family as the finder and rests on the executed runs.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidateZ3 model, then package-level JUnits through TermsHashPerField that fail on main, 10.5.2 and 9.12.3; the block-65534 control passes; the length fix passes.
Independent reproducer realIts own JUnit fails on main, 10.5.2, 9.12.3, 9.8.0 and 9.7.0; its full-size test with the stock allocator on main; its public-API end-to-end program on main, 10.5.2, 9.12.3 and 9.7.0. Controls and its long fix pass.
Works-as-designed review (OpenAI GPT-5.6) real, highReading only: intended argument moderate (internal classes, 2 GB per-thread limit) but it fails; root cause and fix agreed.
Intended-behaviour devil's advocate realNo javadoc, test or discussion accepts the wrapped comparison; upstream chose a loud exception for this regime.
adjudication confirmed, highRe-ran the end-to-end program with assertions on (CheckIndex clean) and off, and its control and fix.
refute review (OpenAI GPT-5.6) not real (procedural)Could not run anything, so its "not real" is procedural; it agreed with the root cause.
tie-break (2026-10-31) real, highOwn boundary controls (11 bytes pass; 12-14 give 1-3 wrong bytes), the stock-allocator full-size test, the end-to-end program on main and 10.6.0, and the fixed reader on the full-pool document.
this doc (2026-10-31) fix validatedThe minimal test and the fix through gradle, with the closest suites, the whole core suite and check.

Diagram file

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

Sources and provenance

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

Finding record

LFV-386, snapshot of 2026-11-06

Registry title
ByteSliceReader's final-slice test (nextIndex + newSize >= endIndex) overflows int when a postings stream's last slice ends exactly at the 2^31-byte end of the in-RAM byte pool, so IndexWriter silently commits up to 3 wrong postings bytes that CheckIndex accepts
Area
index / in-RAM postings byte slices (ByteSliceReader, TermsHashPerField)
Priority
Low, rank 966: Silent postings corruption that CheckIndex accepts is severe, but it needs a contrived 2 GiB pool filled to its exact last byte with exact slice alignment, so it follows LFV-385.
Impact class
data corruption
Impact note
Anyone whose single indexing call (one document or one addDocuments block) grows the indexing thread's in-RAM postings byte pool to exactly its 2 GiB (65,536-block) cap so that one term's stream ends in the slice occupying the pool's last bytes, with its last write in that slice's last 3 data bytes; default IndexWriterConfig and codec (executed end to end through the public API, -ea and -da), extreme scale (about 2 GB of postings in one call with the default 16 MB RAM buffer, 6-8 GiB of heap) plus exact alignment; the segment commits with up to 3 wrong postings bytes (executed: the last 3 bytes of a payload) and CheckIndex reports it clean, where a slightly larger input fails loudly with the #12392 ArithmeticException; released (executed end to end on 8.11.4 through 10.6.0 and at unit level from 2.3.0; the code dates from 2007).
Summary
You add one very large document whose postings fill the indexing thread's in-RAM byte pool to its very last byte (global offset 2^31), and you expect the flushed segment to hold exactly the positions and payloads you indexed, or a loud failure such as the ArithmeticException Lucene has thrown since 9.8.0 once the pool would grow further. Instead, if one term's postings stream ends in the slice that occupies the pool's last bytes, ByteSliceReader.nextSlice computes nextIndex + newSize = 2^31 in int, which wraps negative, so it treats that final slice as non-final, reads its last 3 data bytes plus the level marker as a forwarding address, and returns the stream's last 1-3 bytes from an unrelated part of the pool. IndexWriter commits with no exception (with -ea and -da) and CheckIndex reports the segment clean; in the executed run the last 3 bytes of a 355-byte payload, 0x41 0x42 0x43, read back as 0x11.
Introduced
cff5767e443411c78b199fbf9686d1ec672e38f0 (svn r553236, LUCENE-843 'speed up IndexWriter performance', JIRA era, no GitHub PR; committed by mikemccand): it added DocumentsWriter with a nested ByteSliceReader whose 'if (nextIndex + newSize >= endIndex)' (DocumentsWriter.java:2432) and 'if (startIndex+firstSize >= endIndex)' (:2385) are the two lines on main today (ByteSliceReader.java:109 and :58); moved unchanged to its own file by e5f9b4e1cb3 (LUCENE-1212, 2008-03-12) [read: git log -S on the expression over the full history; git blame on main stops at the svn path copy 675597141b7]. Latent since then, not a regression: #12392 (b88d3e19889, 2023-06-28, the pool's 2^31 cap) neither caused nor fixed it (9.7.0 and 9.8.0 both fail) [exec] (2007-07-04)
First affected version
2.3.0 [exec: a 2.3.x port of the minimal repro fails on 2.3.0 with -ea and -da and its one-block-earlier control passes; read: the first release tag containing cff5767e443]; 2.2.0 has no ByteSliceReader (the probe does not compile) [exec]; end to end through IndexWriter the earliest release executed is 8.11.4 [exec]
Affected versions
Every release from 2.3.0 to 10.6.0 and main (155 lucene-core versions on Maven Central; none fixed). Executed, all with the same silent wrong bytes: the minimal test on 10.5.2, 10.6.0 and main 645d4fe14a20 (-ea and -da) and on 9.9.0 (-ea); ports of it with the same layout on 2.3.0, 2.3.1, 2.3.2, 2.4.0, 3.0.0, 3.6.2, 4.0.0, 5.5.5, 6.6.6, 7.7.3, 8.11.4 and 9.8.0 (the one-block-earlier control passes on 2.3.0, 2.3.2, 2.4.0, 3.6.2, 4.0.0 and 9.8.0); the independent reproduction JUnit on 9.7.0, 9.8.0, 9.12.3 and 10.5.2 (-ea); end to end through IndexWriter on 8.11.4, 9.0.0, 10.0.0 (-ea), 9.7.0 (-da), 9.12.3 and 10.5.2 (-ea and -da), 10.6.0 and main 645d4fe14a20 (-da), and main ea57360912ed (all repros, -ea and -da); the 3-byte-shorter control passes on 8.11.4, 9.12.3, 10.5.2, 10.6.0 and main [exec]. [read] the expression is unchanged on every ref from 2.3.0 (git log -S); before 8.11.4 IndexWriter reachability is argued from identical code only (the end-to-end schedule was built for the 8.x+ layout); upstream main 2f424fd428da, branch_11x and branch_10_6 still have both lines (2026-10-31). Not affected: 2.2.0 and earlier (no ByteSliceReader) [exec: the probe does not compile on 2.2.0].
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 and voted “not real” while agreeing with the root cause (the adjudication ruled it real), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
formal: Z3
Upstream and prior reports
none found (2026-10-26: scripted prior-report searches by the finder (16 GitHub issue/PR, 3 commit, 2 downstream, 20 mailing-list, 4 JIRA and 6 web queries), by the intended-behaviour check (6 GitHub, 16 list) and in a follow-up search (the same set again plus 97 GitHub GraphQL searches, 56 mailing-list archive searches with no date cut-off, 10 JIRA REST searches and 2 extra web searches; 296 queries, 290 executed; 231 distinct hits, the 24 closest read in full, none the same bug); the changed-file lists of all 411 open apache/lucene PRs: none changes ByteSliceReader.java or ByteSlicePool.java; upstream main cf398bfb142c and branch_10x still have both lines); related prior items, none of them this bug: #10790 (LUCENE-9751, open, 'Assertion error (int overflow) in ByteSliceReader': a different mechanism and symptom, an init() assertion on 8.7 with a 368 MB per-thread buffer that cannot fill a 2^31-byte pool, root cause unknown), #9660 (LUCENE-8614) fixed by #12392 (the pool's 2^31 cap with an ArithmeticException, the fail-fast guard this exact-fit case slips through), #15152 and open PR #15330 (flush before the pool's last block, checked only between documents; would not fix it), #9166 (LUCENE-8118) and #10080 (LUCENE-9037) (loud overflows beyond the pool end before the cap), #15296 (raise the per-thread limit), #7750 (LUCENE-6692, closed invalid: memory corruption on one machine) and #6232 (LUCENE-5168, a JVM bug)
Lucene commit tested
main 645d4fe14a20 (the standalone runner and gradle, assertions on and off; the fix through gradle in a scratch worktree of the same commit, with the closest suites, the whole core suite and check); the minimal test on the 9.9.0, 10.5.2 and 10.6.0 release jars; ports of it with the same layout on 12 releases from 2.3.0 to 9.8.0; the public-API end-to-end program on 8.11.4, 9.0.0, 9.7.0, 9.12.3, 10.0.0, 10.5.2, 10.6.0 and main. All runs on Linux aarch64 with OpenJDK 25.0.3. Upstream main 2f424fd428da (2026-10-30) has the same code.

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/index/ByteSliceReader.java:25-29 (class javadoc: read each slice to its end, then follow the forwarding address), :58 ('if (startIndex + firstSize >= endIndex)'), :98 (forwarding address read), :109 ('if (nextIndex + newSize >= endIndex)'), :111 (assert endIndex - nextIndex > 0, skipped when the sum wraps), :116 ('limit = upto + newSize - 4')
  • lucene/core/src/java/org/apache/lucene/index/ByteSlicePool.java:46 (LEVEL_SIZE_ARRAY 5..200), :117-123 (an exact-fit slice stays at the end of the block, no nextBuffer), :137 (level byte 16 | level)
  • lucene/core/src/java/org/apache/lucene/util/ByteBlockPool.java:43-46 (32 KiB blocks), :186-200 (nextBuffer; Math.addExact at :199 admits block 65535 and throws for the next one)
  • lucene/core/src/java/org/apache/lucene/index/TermsHash.java:50-55 (term bytes and postings share one byte pool); lucene/core/src/java/org/apache/lucene/index/IndexingChain.java:128 (DirectTrackingAllocator)
  • lucene/core/src/java/org/apache/lucene/index/IndexWriterConfig.java:83, :93 (DEFAULT_RAM_BUFFER_SIZE_MB 16, DEFAULT_RAM_PER_THREAD_HARD_LIMIT_MB 1945); lucene/core/src/java/org/apache/lucene/index/DocumentsWriterFlushControl.java:239-242 (hard limit checked only after a document)
  • lucene/core/src/java/org/apache/lucene/index/FreqProxFields.java:304-320, :410-436 (flush reads postings through initReader + ByteSliceReader); lucene/core/src/java/org/apache/lucene/index/TermVectorsConsumerPerField.java:114-117 (term vectors use the same reader; not tested)
  • lucene/memory/src/java/org/apache/lucene/index/memory/MemoryIndex.java:360 (same 'startOffset + firstSize >= endOffset' pattern on the int pool; not tested)
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/index/ByteSliceReader.java:25-29, (at 645d4fe14a20) :58, :73, :98, :101, :109, :111, :116, :129
  • lucene/core/src/java/org/apache/lucene/index/ByteSlicePool.java:46, (at 645d4fe14a20) :79, :117-123, :134, :137
  • lucene/core/src/java/org/apache/lucene/util/ByteBlockPool.java:43-46, (at 645d4fe14a20) :121-126, :199; lucene/core/src/test/org/apache/lucene/util/TestByteBlockPool.java:140-169
  • lucene/core/src/java/org/apache/lucene/index/TermsHashPerField.java:94, (at 645d4fe14a20) :158-175; index/FreqProxFields.java:304-320, :410-436; index/TermsHash.java:50-55; index/IndexingChain.java:130
  • lucene/core/src/java/org/apache/lucene/index/DocumentsWriter.java:427-434; (at 645d4fe14a20) index/DocumentsWriterFlushControl.java:239-242
Who is affected
  • lucene/core/src/java/org/apache/lucene/index/IndexWriterConfig.java:83, (at 645d4fe14a20) :93; index/DocumentsWriterFlushControl.java:239
  • lucene/core/src/java/org/apache/lucene/index/TermVectorsConsumerPerField.java:114-117; (at 645d4fe14a20) lucene/memory/src/java/org/apache/lucene/index/memory/MemoryIndex.java:360
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/index/ByteSliceReader.java:25-29; (at 645d4fe14a20) index/PostingsEnum.java:96-100; index/IndexWriter.java:1462-1490
  • lucene/core/src/java/org/apache/lucene/util/ByteBlockPool.java:199; (at 645d4fe14a20) lucene/core/src/test/org/apache/lucene/util/TestByteBlockPool.java:140-169
  • lucene/CHANGES.txt:3445, (at 645d4fe14a20) :15722-15729; lucene/core/src/java/org/apache/lucene/index/IndexingChain.java:2098-2100 (at 645d4fe14a20)
  • lucene/core/src/java/org/apache/lucene/index/IndexWriterConfig.java:374-378, (at 645d4fe14a20) :383; index/DocumentsWriterFlushControl.java:240-241

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

Repro files

About the diagrams

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.