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.
- 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). - 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.
- At flush,
ByteSliceReaderwalks 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.
- Index one document with the default
IndexWriterConfigand codec. Its tokens carry payloads: 65,419 tokensawith 32,000-byte payloads (about 1.95 GiB), then a few planned tokens, ending with termband a 355-byte payload whose last 3 bytes are0x41 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. - You expect
b's payload back exactly, or the loudArithmeticExceptionLucene throws when the pool would grow past 2^31 bytes. - At flush the reader walks
b's chain of slices and asks of each next slice "is this the last one?" withnextIndex + newSize >= endIndexinint.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". - 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 0x43come back as0x11 0x11 0x11. - No error: the commit succeeds and
CheckIndexreports the segment clean. With that payload 3 bytes shorter, or with the fixed comparison, everything reads back correctly.
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 pool can be exactly 2^31 bytes.
ByteBlockPool.nextBufferadvancesbyteOffset = Math.addExact(byteOffset, BYTE_BLOCK_SIZE)(ByteBlockPool.java:199): the 65,536th block (offset 2^31 - 32,768) is admitted, the next throws. UpstreamTestByteBlockPool.testTooManyAllocsasserts this boundary. - A slice can end at the block's last byte.
ByteSlicePool.allocKnownSizeSliceopens a new block only ifpool.byteUpto > BYTE_BLOCK_SIZE - newSize(ByteSlicePool.java:117-119), so an exact fit stays; the last byte gets the marker16 | level(:137), and the previous slice's last 4 bytes the forwarding address (:134).newSliceuses the same rule (:79). - The overflow.
ByteSliceReader.nextSlicereads the address (ByteSliceReader.java:98) and the next size (:101), then testsif (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 setslimit = upto + newSize - 4(:116), as for a slice that points onward. Theassert endIndex - nextIndex > 0(:111) sits on the other branch, so-eadoes not catch it. - The misread. If the stream stops before the slice's last 3 data bytes, the reader stops at
endIndexfirst and nothing goes wrong. If it wrote into any of them, the reader reacheslimit(readByte:73,readBytes:129), callsnextSliceagain, 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 throwsArrayIndexOutOfBoundsExceptioninstead, as a probe on main showed.) - How
IndexWritergets there. At flush,FreqProxFieldsreads each term's doc/freq and position/payload streams throughTermsHashPerField.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. - The
inittwin.inithas the same pattern,startIndex + firstSize >= endIndex(:58), butTermsHashPerField.initStreamSlicesalways leaves room after a first slice (TermsHashPerField.java:158-162), so the indexing chain cannot produce that layout. Changing it is for consistency only.
| value | |
|---|---|
| last block | 65,535 (global offsets 2,147,450,880 .. 2,147,483,647) |
| stream | 5-byte first slice, then a 14-byte slice [2,147,483,634, 2,147,483,648) |
| written | the 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 + newSize | 2,147,483,634 + 14 = 2,147,483,648, wraps to -2,147,483,648 |
| address read instead | bytes 12, 13, 14, 0x11 = 0x110E0D0C = 286,133,516 (block 8,732, zero-filled) |
| read back | 1..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 publicTokenStreamAPI, 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
CheckIndexcannot 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-117uses the same reader),MemoryIndex's similar test on its int pool (MemoryIndex.java:360), andFSDirectory(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 (
0x11to0x19), 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 throwsArrayIndexOutOfBoundsExceptioninstead; throughIndexWriterthat would be an exception at flush (not executed).
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.
/*
* 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);
}
}
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.
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.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT); the same failure on the 10.5.2 and 10.6.0 jars |
|---|---|
| jdk | OpenJDK 25.0.3 |
| os | Linux aarch64 |
# 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=trueByteSliceReaderpromises 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.addDocumentdocuments no per-document size limit other thanMAX_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 byTestByteBlockPool.testTooManyAllocs); CHANGES.txt: "Each DWPT can address up to 2048 MB memory" (CHANGES.txt:15722-15729). Every offset fits in anint; 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);IndexingChainturns 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.
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 sliceAll 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).
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 sliceTrade-offs
- Two comparisons on the flush read path; no measurable cost expected, no benchmark run.
MemoryIndex.SliceReaderhas 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 testwith 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,TestByteSlicePoolandTestTermsHashPerFieldpass; the same full-pool end-to-end document reads back exactly with the fixed reader.
(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.
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.
A check in doAfterDocument.
Trade-offs
Runs between documents, so one document can still fill the pool; does not fix this.
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.
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
ByteSlicePoolandByteSliceReader(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' showsTermsHashPerField.initStreamSlicescannot produce theinittwin. - 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
TermsHashPerFieldwrite 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.
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.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene; Elasticsearch, OpenSearch, Solr, lucene.net) | 2026-10-26; repeated and extended 2026-10-31 | the 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-22 | no report; #10790 is the closest (different symptom and trigger) |
| Open PRs | 2026-10-31 | the changed-file lists of all 410 open apache/lucene PRs; diffs of PRs touching neighbouring files | none changes ByteSliceReader.java or ByteSlicePool.java |
| Mailing lists (dev@, java-user@, issues@, commits@) | 2026-10-31 | 24 tool queries and 32 manual searches (8 terms on each list) | no report; a 2008 java-user thread on a JVM bug |
| JIRA archive | 2026-10-31 | 6 tool queries and 5 JQL queries on LUCENE and SOLR | only the related items above |
| Web | 2026-10-31 | 6 tool queries and 2 extra phrasings | nothing related |
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).
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | the 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 | affected | the 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 | affected | the 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 | affected | the 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 | affected | the 4.0-9.8 port: the same bytes; 4.0.0 control passes |
| 3.6.2, 3.0.0, 2.4.0 | affected | the 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 | affected | the 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 absent | no ByteSliceReader or DocumentsWriter; the probe does not compile |
fix on 645d4fe14a20 | not affected | the minimal test passes with assertions on and off; the whole core suite (8,898 tests) passes |
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.
| Check | Verdict | What it found |
|---|---|---|
| finder | candidate | Z3 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 | real | Its 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, high | Reading only: intended argument moderate (internal classes, 2 GB per-thread limit) but it fails; root cause and fix agreed. |
| Intended-behaviour devil's advocate | real | No javadoc, test or discussion accepts the wrapped comparison; upstream chose a loud exception for this regime. |
| adjudication | confirmed, high | Re-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, high | Own 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 validated | The minimal test and the fix through gradle, with the closest suites, the whole core suite and check. |
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 503 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,414 words
- 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 andcheck); 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 main2f424fd428da(2026-10-30) has the same code.
- 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, :129lucene/core/src/java/org/apache/lucene/index/ByteSlicePool.java:46, (at 645d4fe14a20) :79, :117-123, :134, :137lucene/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-169lucene/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:130lucene/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:239lucene/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-1490lucene/core/src/java/org/apache/lucene/util/ByteBlockPool.java:199; (at 645d4fe14a20) lucene/core/src/test/org/apache/lucene/util/TestByteBlockPool.java:140-169lucene/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
- TestByteSliceReaderFinalSliceAtPoolEnd.java (one test):
repro/LFV-386/src/org/apache/lucene/index/TestByteSliceReaderFinalSliceAtPoolEnd.java(61 lines)
assets/LFV-386/bug.png(503 KB)
