One comparison in MonotonicBlockPackedWriter.flush() adds before it compares: min + (long) (avg * i) > values[i] wraps when the block ends near Long.MAX_VALUE.
- It cuts a sequence of increasing, non-negative longs into fixed-size blocks (64 values here) and fits each block to a line: start
minand slopeavg = (last - first) / (n - 1), stored as a float. - For each value it stores only the correction
value - (min + avg * i), packed with just enough bits for the largest correction. A block that sits exactly on its line needs 0 bits. - Corrections must not be negative, so before packing, the writer lowers
minwherever the line lies above a value ("adjust min so that all deltas will be positive"). The reader adds line and correction back together.
- Write two increasing values, 1 and
Long.MAX_VALUE(9,223,372,036,854,775,807), withnew MonotonicBlockPackedWriter(out, 64), then read them back withMonotonicBlockPackedReader. You expect 1 and 9,223,372,036,854,775,807. - The writer stores the block as a line plus a non-negative correction per value. The line starts at 1 with slope 2^63 (as a float), so its point for the second value is 1 + 9,223,372,036,854,775,807, which does not fit in a
longand wraps to -9,223,372,036,854,775,808. - The writer lowers the line wherever it lies above a value, but the wrapped point looks far below, so nothing is lowered. The second correction is then -1; the bit-width step keeps only the largest non-negative correction, 0, and writes 0 bits.
- The reader returns line point + 0 = -9,223,372,036,854,775,808 (
Long.MIN_VALUE), with no error, assertions on or off. Mis-encoded blocks that the writer still packs with at least 1 bit per value trip an assertion while writing under-eaand read back wrong values under-da. - Only blocks ending within about 2^40 (10^12) of
Long.MAX_VALUEare hit, closer for small spans. 4.8.1 round-trips the same values; the overflow came with an optimization in 4.9.0.
Only code that calls the @lucene.internal writer directly with values ending within about 2^40 of Long.MAX_VALUE (closer for blocks with a small span). Lucene's only caller, the non-default blockterms terms index, stores file offsets far below that. No setting is involved, and assertions do not catch a 0-bit block.
Mechanism, terms and numbers803 words
MonotonicBlockPackedWriter (lucene/core util.packed, public, @lucene.internal) encodes blocks of non-decreasing, non-negative longs as a line min + avg * i plus a non-negative correction per value, packed with just enough bits for the largest correction. To keep every correction non-negative, flush() lowers min wherever min + (long) (avg * i) > values[i]. When a block ends close to Long.MAX_VALUE, that sum wraps to a negative number, the signed test is false, min is not lowered, the negative correction is ignored by Math.max, and the block gets too few bits. The reader decodes the stream faithfully, so get(i) is wrong with no error: {1, Long.MAX_VALUE} (block size 64) reads back as {1, Long.MIN_VALUE} with assertions on and off.
It is a regression from LUCENE-5721 (4.9.0), which replaced wrap-consistent zig-zag deltas with this loop; 4.2.0 and 4.8.1 round-trip the same inputs. It is latent: the only in-tree caller, the non-default blockterms terms index, stores file-pointer deltas and term-byte offsets far below the trigger, so only code that calls the internal writer directly with such values is hit. Comparing values[i] - (long) (avg * i) < min instead cannot overflow for increasing blocks; this sign-aware fix keeps the format and passes the repro, TestPackedInts and the codecs tests.
// MonotonicBlockPackedWriter.flush()
final float avg = off == 1 ? 0f : (float) (values[off - 1] - values[0]) / (off - 1); // :74
long min = values[0]; // :75
// adjust min so that all deltas will be positive // :76
for (int i = 1; i < off; ++i) {
final long actual = values[i];
final long expected = expected(min, avg, i); // min + (long) (avg * (long) i) // :79
if (expected > actual) { // signed compare // :80
min -= (expected - actual);
}
}
long maxDelta = 0;
for (int i = 0; i < off; ++i) {
values[i] = values[i] - expected(min, avg, i); // :87
maxDelta = Math.max(maxDelta, values[i]); // a negative delta is ignored // :88
}
// :91-98: writeZLong(min), the float bits of avg, then 0 bits if maxDelta == 0, else bitsRequired(maxDelta)
// MonotonicBlockPackedReader
static long expected(long origin, float average, int index) { // :37
return origin + (long) (average * (long) index);
}
// get(index) = expected(minValues[block], averages[block], idx) + subReaders[block].get(idx) // :123avg = (float) (Long.MAX_VALUE - 1) / 1 = 9.223372E18, exactly 2^63 as a float;min = 1.(long) (avg * 1)saturates toLong.MAX_VALUE, soexpected = 1 + Long.MAX_VALUEwraps to-9223372036854775808.-9223372036854775808 > 9223372036854775807is false:minstays 1.- Deltas
{0, Long.MAX_VALUE - Long.MIN_VALUE}={0, -1}after the wrap;Math.maxkeeps 0, so the block is written with 0 bits (no packed values, so no assertion can fire). - The reader returns
expected(1, avg, 1) + 0 = -9223372036854775808.
With the sign-aware fix the same block gets min = 0, deltas {1, 0}, 1 bit per value, and get(1) = Long.MAX_VALUE.
- Every observed failure coincides with the overflow. In a grid of 19,200 strictly increasing 64-value blocks (three grids of 6,400), the failing blocks are exactly those where
min + (long) (avg * i)overflows (0 failures without overflow, 0 overflows without failure); a Z3 model offlush()finds no failure without the overflow for 2- and 3-value blocks. Neither covers every block size. - How close to
Long.MAX_VALUE.minstarts at the first value and only goes down, so the sum overflows only through the float rounding of the slope, which scales with the block's span S. No failure with S <= 2^20; at S = 2^40 failures up to a headroom of 2^12; at S near 2^62 up to 2^36. The largest failing headroom seen was 2^39.4. A Z3 model that replaces Java's exact float arithmetic by a derived bound on the rounding error finds no failing 2- or 3-value block with a headroom of 2^41 or more. - Assertions. What decides is the bit width the stock writer picks from the largest non-negative delta (
:88,:93-98). A mis-encoded block that still has a non-negative delta above 0 is packed with 1 to 63 bits, and with-eaits negative delta tripsassert unsignedBitsRequired(v) <= bitsPerValue(BulkOperationPacked.java:222) while writing. The repro's block has only the deltas 0 and -1, so it is written with 0 bits and no assertion runs (its correct encoding would need 1 bit). - Siblings are fine.
PackedLongValues.monotonicBuilder()subtractsexpected(0, average, i)first (MonotonicLongValues.java:94-103), andDirectMonotonicWriter, which the default codec uses, sizes bits with|, which "has the benefit of working fine with negative values too (in case of overflow)" (DirectMonotonicWriter.java:93-96). Both round-trip the same inputs.
Exposure and likelihood180 words
- Direct callers of the public,
@lucene.internalMonotonicBlockPackedWriter(:51) whose block ends close toLong.MAX_VALUE: executed, wrong values with assertions on and off. - Lucene itself: nobody known. The only production caller is
FixedGapTermsIndexWriter(lucene/codecs blockterms,@lucene.experimental, not in the default codec). It storestermsFilePointer - termsStart(:169), which would need a terms file of about 8 EiB, and a running sum of indexed-term prefix lengths (:172-174), which stays below 2^46. In this tree only the test-framework formatsLuceneFixedGapandMockRandomPostingsFormatcreate it. - Downstream (read-only code search, not executed): the one use found, a port of the Lucene 5.4 doc-values writer in Elasticsearch's old-lucene-versions plugin, stores bounded file and buffer offsets.
- Scale. The trigger needs values within about 10^12 of 9.2 x 10^18. Offsets, ordinals and counts are many orders of magnitude smaller; a probe found 0 failures in 8,064 blocks ending at or below 2^62.
- Effect. Silent wrong numbers for that block (other blocks decode correctly), or an
AssertionErrorwhile writing under-ea. No hang, no extra memory. - Likelihood: contrived. Priority low.
One JUnit test, public API only, deterministic: write {1, Long.MAX_VALUE} with block size 64 into a ByteBuffersDirectory and read back value 1. Same failure with assertions on and off.
/*
* 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.util.packed;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.store.IOContext;
import org.apache.lucene.store.IndexInput;
import org.apache.lucene.store.IndexOutput;
import org.apache.lucene.tests.util.LuceneTestCase;
public class TestMonotonicBlockPackedNearMaxValue extends LuceneTestCase {
public void testTwoValuesEndingAtLongMaxValue() throws Exception {
try (Directory dir = new ByteBuffersDirectory()) {
try (IndexOutput out = dir.createOutput("values", IOContext.DEFAULT)) {
MonotonicBlockPackedWriter writer = new MonotonicBlockPackedWriter(out, 64);
writer.add(1);
writer.add(Long.MAX_VALUE);
writer.finish();
}
try (IndexInput in = dir.openInput("values", IOContext.DEFAULT)) {
MonotonicBlockPackedReader reader =
MonotonicBlockPackedReader.of(in, PackedInts.VERSION_CURRENT, 64, 2);
// expected Long.MAX_VALUE, actual Long.MIN_VALUE (min + (long) (avg * i) wraps in flush())
assertEquals(Long.MAX_VALUE, reader.get(1));
}
}
}
}
cp $REPRO/TestMonotonicBlockPackedNearMaxValue.java $LUCENE/lucene/core/src/test/org/apache/lucene/util/packed/
cd $LUCENE
./gradlew :lucene:core:test --tests org.apache.lucene.util.packed.TestMonotonicBlockPackedNearMaxValue -Ptests.seed=DEADBEEF
# assertions off: add -Ptests.asserts=false (same result)
- Expected
reader.get(1)returns 9223372036854775807 (Long.MAX_VALUE), the value that was written.- Observed
reader.get(1)returns -9223372036854775808 (Long.MIN_VALUE), no exception, with assertions on and off.
More files, environment, full output213 words
- Package. The test sits in
org.apache.lucene.util.packed, next toTestPackedInts, but uses only public members of the two public classes. - Smallest input. Two values; the 0-bit block makes
-eaand-daidentical (no assertion is involved). The seed does not matter: the input is fixed, and a second seed fails the same way. - Bigger blocks (the finder's second test, not needed for the repro): 64 strictly increasing values from 2^62 to
Long.MAX_VALUEtripAssertionErroratBulkOperationPacked.encode(BulkOperationPacked.java:222)under-ea, and read back element [62] asLong.MAX_VALUEinstead ofLong.MAX_VALUE - 1under-da. - As the regression test: this same method, for example added to
TestPackedInts. It passes with the fix.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT); the same failure on 10.6.0, 10.5.2, 9.12.3 and every release from 4.9.0 that was run |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux, aarch64 |
cp $REPRO/TestMonotonicBlockPackedNearMaxValue.java $LUCENE/lucene/core/src/test/org/apache/lucene/util/packed/
cd $LUCENE
./gradlew :lucene:core:test --tests org.apache.lucene.util.packed.TestMonotonicBlockPackedNearMaxValue -Ptests.seed=DEADBEEF
# assertions off: add -Ptests.asserts=false (same result)
# gradle, main 645d4fe14a20, seed DEADBEEF, assertions on (the same with -Ptests.asserts=false)
TestMonotonicBlockPackedNearMaxValue > testTwoValuesEndingAtLongMaxValue FAILED
java.lang.AssertionError: expected:<9223372036854775807> but was:<-9223372036854775808>
at __randomizedtesting.SeedInfo.seed([DEADBEEF:D9DE765148A8604]:0)
at org.junit.Assert.fail(Assert.java:89)
at org.junit.Assert.failNotEquals(Assert.java:835)
at org.junit.Assert.assertEquals(Assert.java:647)
at org.junit.Assert.assertEquals(Assert.java:633)
at org.apache.lucene.util.packed.TestMonotonicBlockPackedNearMaxValue.testTwoValuesEndingAtLongMaxValue(TestMonotonicBlockPackedNearMaxValue.java:40)
1 test completed, 1 failed
# with the sign-aware fix, assertions on and off
:lucene:core:test (SUCCESS): 1 test- The javadoc promises "large monotonically increasing sequences of positive longs" with no upper bound;
add()checks onlyl >= 0. - The code's own comment, "adjust min so that all deltas will be positive", is the invariant that breaks; under
-eaLucene's packing assertion fires. - A regression: 4.8.1 round-trips the same values; LUCENE-5721, a 4.9.0 speed optimization, brought the overflowing comparison.
The full argument184 words
- No upper bound is documented. "A writer for large monotonically increasing sequences of positive longs" (
MonotonicBlockPackedWriter.java:26);add()asserts onlyl >= 0(:66). The reader "Provides random access to a stream written with MonotonicBlockPackedWriter" (MonotonicBlockPackedReader.java:31). - The broken invariant is written in the code: "adjust min so that all deltas will be positive" (
:76), andBulkOperationPacked.encodeasserts it (:222). - Regression from an optimization. Before LUCENE-5721 the deltas were
zigZagEncode(values[i] - min - (long) (avg * i))and the reader did the same two's-complement arithmetic, so a wrap cancelled out; 4.2.0 and 4.8.1 round-trip both inputs. LUCENE-5721 is listed under 4.9.0 "Optimizations" (lucene/CHANGES.txt:12247-12248); its discussion (#6783) is about speed and bits per value, never a value bound. - The siblings handle overflow on purpose.
PackedLongValues.monotonicBuilder()subtracts the line before packing (MonotonicLongValues.java:94-103), andDirectMonotonicWriter's bit sizing is commented as "working fine with negative values too (in case of overflow)" (DirectMonotonicWriter.java:93-96). - No test sets a limit.
TestPackedInts#testMonotonicBlockPackedReaderWriter(TestPackedInts.java:1288-1330) asserts an exact round trip but uses values below about 2^31 + 2^24.
The devil's-advocate check228 words
Strongest case for "not a bug" (the cross-model reviews, OpenAI GPT-5.6, refute and intended lenses, not real with high confidence, rating this scope argument "strong"): both classes are @lucene.internal, so the contract is what in-tree callers need, and the only caller cannot produce such values. An independent verifier adds that the classes were added for "monotonically increasing positive values (e.g. file offsets)" (LUCENE-4547, lucene/CHANGES.txt:14383-14387), and the LUCENE-5721 issue itself opens with "This compression is used in lucene for monotonically increasing offsets, e.g. stored fields index, dv BINARY/SORTED_SET offsets, OrdinalMap ..." (#6783).
Why it fails: @lucene.internal renders as "This API is for internal purposes only and might change in incompatible ways in the next release", a stability caveat, not permission to return wrong values for inputs the javadoc accepts; "e.g. file offsets" and the LUCENE-5721 list are examples of use, not a bound. The reviewers themselves reproduced the bug with their own tests, agreed on the root cause and the fix, found nothing that documents a reduced numeric domain, and said maintainers may reasonably accept the fix as an internal correctness improvement.
What it does establish: no Lucene user is known to be affected, and a maintainer could reasonably close it as out of scope for an internal API rather than take the fix. It is an argument about scope, not that the wrong values are intended.
Recommended A. Sign-aware subtraction form
For increasing blocks, compare without adding: if (actual - offset < min) min = actual - offset with offset = (long) (avg * i). For the documented non-negative input both terms lie in [0, Long.MAX_VALUE], so nothing wraps; the old code stays for avg < 0. No format change. Validated: the repro, the util.packed suites and all codecs tests pass.
All options and trade-offs4 options ยท 721 words
Four options; option A was applied in a scratch worktree of main 645d4fe14a20 and run against the minimal test, the packed-ints suites, the codecs module's tests and core's static checks.
In flush() only (+12/-3). When the slope is non-negative, test values[i] - offset < min; otherwise keep the original comparison. This is the variant an independent verifier wrote after finding that the unconditional form (B) breaks a decreasing block; here the temporary is named offset and commented.
for (int i = 1; i < off; ++i) {
final long actual = values[i];
if (avg >= 0) {
// actual and offset are both in [0, Long.MAX_VALUE], so actual - offset cannot overflow,
// while min + offset can when the block ends close to Long.MAX_VALUE
final long offset = (long) (avg * (long) i);
if (actual - offset < min) {
min = actual - offset;
}
} else {
final long expected = expected(min, avg, i);
if (expected > actual) {
min -= (expected - actual);
}
}
}Trade-offs
- Format unchanged, reader untouched. By reading: when the old sum does not wrap, the new test makes the same decision and computes the same
min, so those blocks are written byte for byte as before. Only the mis-encoded blocks change: they get new, correct bytes in the same layout, which the unchanged reader decodes. So the format is the same, with corrected bytes for previously mis-encoded blocks; the output is not byte-for-byte identical. - Z3 proves the subtraction form gives deltas in [0, 2^63) for non-decreasing non-negative blocks of 2-4 values.
- The code comment's "both in [0, Long.MAX_VALUE]" holds for the documented non-negative input:
offsetis in that range foravg >= 0(the cast saturates), andactualis when the caller keeps to the javadoc.add()only assertsl >= 0, so with-danegative values are outside the guarantee, as they are outside the javadoc today. - Decreasing blocks are outside the javadoc, and the writer does not reject them (
add()asserts onlyl >= 0); the upstream test feeds local decreases of up to 16. So theavg < 0branch is compatibility handling for input outside the documented domain, the conservative choice. - Cost: one loop-invariant branch in a write-side loop, not on any read path. Not benchmarked.
What the checks validated
- Scratch worktree of main
645d4fe14a20, gradle, JDK 25.0.3 (2026-11-01). Stock: the minimal test fails with assertions on and off. With the fix: it passes with assertions on and off. org.apache.lucene.util.packed.*(seedDEADBEEF): 44 tests, 3 skipped, 0 failures (TestPackedInts,TestDirectMonotonic,TestDirectPacked, the minimal test).TestPackedInts#testMonotonicBlockPackedReaderWriterwith-Ptests.iters=20: 20 of 20 pass.- All of
:lucene:codecs:test(random seed): 693 tests, 78 skipped, 0 failures, includingTestFixedGapPostingsFormat, the format of the only in-tree caller. :lucene:core:check -x test: google-java-format, license checks, ecj lint and forbidden APIs pass (the minimal test included).- Earlier, the verifier's version of the same change passed its own test, the finder's two tests, three 6,400-block grids, the decreasing block and
testMonotonicBlockPackedReaderWriterx5.
min = Math.min(min, values[i] - expected(0, avg, i));, the form MonotonicLongValues uses.
Trade-offs
Smallest diff, but it assumes avg >= 0: on a block of 63 x Long.MAX_VALUE followed by Long.MAX_VALUE - 2^40, which stock main round-trips, it trips the packing assertion (-ea) or reads back -9223370937343148033 (-da). Acceptable only with a documented and asserted non-decreasing input.
A negative delta then forces 64 bits per value.
Trade-offs
Not recommended: it wastes 64 bits per value on exactly these blocks, and at 64 bits per value this reader drops the top byte (maskRight = (1L << 64) - 1 is 0 in Java, MonotonicBlockPackedReader.java:87; a probe reads 64 of 64 values wrong). The writer never emits 64 bits today, so that separate reader quirk is unreachable; it is not part of this bug. Not validated.
State that values must stay below some bound.
Trade-offs
The safe headroom depends on the block span, so there is no simple bound, and it would narrow the domain the javadoc states ("positive longs", with no upper bound). Not recommended.
Downstream code that calls MonotonicBlockPackedWriter directly should check whether a block's values can end within about 2^40 of Long.MAX_VALUE; offsets and counts cannot.
Semi-formal. While building a bit-vector SMT model of Lucene's packed monotonic encoders, an AI tool noticed that the writer's min adjustment adds before it compares. A Z3 counterexample and a differential harness on the stock jar (534-589 of 11,760 blocks wrong per run, 0 with the fix) confirmed it; a JUnit test then reproduced it.
The method in full176 words
- The hunt unit covered
util.packed's monotonic and direct encoders. The finder modelledMonotonicBlockPackedWriter.flush()in Z3 over 64-bit vectors (domain 0 <= v_0 <= ... <=Long.MAX_VALUE) with a model-to-Java line map. - One query found a counterexample; another showed that every failure in the model needs the int64 overflow of
min + (long) (avg * i)(2- and 3-value blocks); a third bounded the reach for 2- and 3-value blocks (no failure with the last value at or belowLong.MAX_VALUE - 2^41, with the float rounding replaced by a derived error bound); a fourth proved the subtraction-form fix for 2-4 values. - A differential harness compared encode/decode on the stock jar: it found 534-589 wrong blocks of 11,760 per run (
-eaand-da), for example a 1,023-value block (block size 1,024) from 1 toLong.MAX_VALUEthat read back -9223372036854775808 at index 1,021 instead of 9223372036854775807. - Independent verifiers reproduced it with their own tests, mapped the reach, found the decreasing-block flaw in the first fix, and showed the 4.8.1 to 4.9.0 regression on release jars.
No report found (searched 2026-10-26): 154 scripted queries over GitHub issues, PRs and commits, the JIRA archive, four mailing lists and downstream repos, plus 12 manual GitHub and 5 web searches and all 413 open PRs' files. Closest: LUCENE-5792 (#6854) rewrote the sibling copy of the loop without the overflow, never mentioning it.
Searches4 searches
Closest items, none the same bug: #6783 (LUCENE-5721, the introducing change; about speed and bits per value); #6854 (LUCENE-5792, replaced the other copy of the loop with PackedLongValues.monotonicBuilder(), whose arithmetic avoids the overflow, as an API refactor that left this writer unchanged; a good model for the fix); #85 / #10946 (LUCENE-9907, reader refactoring); #6191 (LUCENE-5127, made FixedGapTermsIndexWriter use this writer); #10201 (LUCENE-9161) and 960ce15fc89, different overflows; #12377 / #12379, DirectMonotonicWriter rounding; apache/lucenenet#1293, a diffstat only (the Lucene.NET writer still has the pre-4.9 zig-zag code). No open PR touched these files on 2026-10-26. Gaps: a search cannot prove absence; private reports cannot be searched.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene) | 2026-10-26 | scripted searches (class names, LUCENE-5721, the code comment as a phrase, expected > actual, overflow and Long.MAX_VALUE phrasings, the caller and sibling encoders) plus manual GitHub searches; the changed files of all 413 open PRs; git log -S on the loop | no report; no open PR touches the files |
| JIRA archive (LUCENE) | 2026-10-26 | 16 scripted queries; 16 issues read in full (LUCENE-5721, -5792, -9907, -5127 and others) | nothing about this defect; LUCENE-5721 is the origin |
| Mailing lists (dev@, java-user@, issues@, commits@) | 2026-10-26 | 80 scripted queries | no report (notification mirrors and one unrelated 2014 dev@ thread) |
| Downstream and web (Solr, Elasticsearch, OpenSearch, Lucene.NET, all of GitHub) | 2026-10-26 | manual GitHub searches and 5 web queries | no report; Lucene.NET still has the pre-4.9 code |
Introduced by LUCENE-5721 (b31ae698e5d, 2014-06-05, first released in 4.9.0), which replaced wrap-safe zig-zag deltas with this loop. It fails on every release run from 4.9.0 to 10.6.0 and on main; 4.2.0 and 4.8.1 pass. Never fixed.
Versions tested6 rows
- Introduced:
b31ae698e5d"LUCENE-5721: Monotonic compression doesn't use zig-zag encoding anymore." (Adrien Grand, 2014-06-05; JIRA LUCENE-5721, migrated as #6783; no PR), backported to branch_4x as1b450e1be80the same day. Listed under 4.9.0 Optimizations. - Before: 4.2.0 (LUCENE-4547, the classes' first release) to 4.8.1 stored
zigZagEncode(values[i] - min - (long) (avg * i)), and the reader repeated the same arithmetic, so a wrap cancelled out. - 4.9.x only: the block origin was written as a zig-zag vLong, which also fails for origins of 2^62 or more (with
-eaan assertion inDataOutput.writeVLongwhile writing, with-da"Invalid vLong detected" on read); LUCENE-5739 (cc3501de97b, 4.10.0) switched towriteZLong. The min-adjustment loop has been unchanged since:flush()andexpected()are identical from 4.10.0 to main. - Upstream main
2f424fd428d(2026-10-30),branch_11xand the 10.6.0 tag have the identical file.
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20, a9779ef3c193, ea57360912ed | affected | fails with assertions on and off (gradle and standalone); passes with the fix |
10.6.0 at 4ae946e5a9b, the releases/lucene/10.6.0 tag commit (jars built from source) | affected | fails with assertions on and off |
| 10.5.2, 9.12.3 | affected | fails with assertions on and off |
| 8.11.4, 7.7.3, 6.6.6, 5.5.5 | affected | fails (release jars, assertions off) |
| 4.10.4, 4.9.0 | affected | fails with assertions on and off; on 4.9.0 the finder's 2^62-origin block fails in the zig-zag vLong origin instead (-ea: an assertion in writeVLong while writing; -da: "Invalid vLong detected" on read) |
| 4.8.1, 4.2.0 | not affected | both repro inputs round-trip (pre-LUCENE-5721 zig-zag arithmetic) |
The finder, an independent reproducer, a devil's advocate and an adjudication call it real. Cross-model reviews (OpenAI GPT-5.6) reproduced it and agreed on cause and fix, but voted not real because the classes are @lucene.internal. Re-run on three main commits and 10.6.0.
Checks and verdicts269 words
The dissent is about scope, not facts: the GPT-5.6 reviews reproduced the bug with their own tests and accepted the fix. The independent reproduction, the intended-behaviour check and the adjudication answered that @lucene.internal withdraws API stability, not correctness, and rated the bug latent and minor.
| Check | Verdict | What it found |
|---|---|---|
| finder | reproduced | Z3 model of flush(), a differential harness (534-589 of 11,760 blocks wrong per run) and a 2-test JUnit failing on main, 10.5.2 and 9.12.3. |
| cross-model review, refute lens | not real (high), scope only | OpenAI GPT-5.6: own test {17, Long.MAX_VALUE - 7} reads back a negative value; a control 2^42 below MAX passes; root cause and fix agreed (25 of 25 TestPackedInts pass); out of contract because @lucene.internal. |
| Independent reproducer | real | Own test: {5, Long.MAX_VALUE - 3} reads back {5, -9223372036854775804}; a 19,200-block grid where failures equal the overflows exactly; found the decreasing-block flaw and wrote the sign-aware fix. |
| Cross-model adversary, intended lens | not real (high), scope only | OpenAI GPT-5.6: own test {3, Long.MAX_VALUE} reads back {3, -9223372036854775806}; sibling encoders pass; nothing documents a reduced numeric domain; out of contract because @lucene.internal. |
| Devil's advocate | real | No documented or tested limit; 4.8.1 fails 0 of 2,002 probe blocks, 4.9.0 onward fail: a regression. |
| adjudication | confirmed, high; impact minor | Re-ran the independent reproduction's and the finder's tests; corrected the reach (headroom scales with span), the fix (sign-aware) and the range. |
| re-runs | reproduces | Follow-up release sweep, main refreshes a9779ef3c193 and 645d4fe14a20, 10.6.0 (tag commit), the minimal repro, and this doc's gradle runs. |
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 411 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,291 words
- Registry title
- (Latent) MonotonicBlockPackedWriter writes a block whose values end close to Long.MAX_VALUE with too few bits, so MonotonicBlockPackedReader returns wrong values: min + (long) (avg * i) overflows in flush()
- Area
- util / packed ints (MonotonicBlockPackedWriter, MonotonicBlockPackedReader)
- Priority
- Low, rank 1197: MonotonicBlockPackedWriter mis-encodes blocks that end close to Long.MAX_VALUE, but its only in-tree caller (an experimental, non-default terms index) stores small offsets, so only external code that calls the @lucene.internal writer directly is exposed.
- Impact class
- minor
- Impact note
- Latent: no Lucene code path produces the trigger, because the only in-tree caller, FixedGapTermsIndexWriter (lucene/codecs blockterms, @lucene.experimental, not in the default codec, and in this tree created only by the test-framework LuceneFixedGap and MockRandomPostingsFormat postings formats), stores file-pointer deltas and term-byte offsets that stay far below 2^63; only external code that calls the @lucene.internal writer directly with its own non-negative, non-decreasing values whose block ends close to Long.MAX_VALUE (the allowed headroom shrinks with the block span, roughly span x 2^-24; the largest failing headroom seen was 2^39.4) is hit, and it then reads back silently wrong values with -da (also with -ea when the block is written with 0 bits per value; otherwise -ea trips an AssertionError in BulkOperationPacked.encode); ordinary block sizes, released 4.9.0 through 10.6.0.
- Summary
- You write a block of non-negative, increasing longs with MonotonicBlockPackedWriter, for example {1, Long.MAX_VALUE}, and read it back with MonotonicBlockPackedReader. You expect the same values back: the writer is documented as 'A writer for large monotonically increasing sequences of positive longs' with no upper bound, and flush() says 'adjust min so that all deltas will be positive'. Instead the predicted value min + (long) (avg * i) wraps past Long.MAX_VALUE, so the adjustment of min is skipped and the resulting negative delta is ignored when the bit width is chosen; get(1) returns Long.MIN_VALUE with no error (a larger block trips an AssertionError in BulkOperationPacked.encode under -ea and reads back wrong values under -da).
- Introduced
- b31ae698e5d (b31ae698e5d1574105d43901356ae752eedb6e4e) 'LUCENE-5721: Monotonic compression doesn't use zig-zag encoding anymore.' by jpountz (JIRA LUCENE-5721, migrated as apache/lucene#6783; svn r1600694, no GitHub PR); branch_4x backport 1b450e1be80 (svn r1600747), the commit that the 4.9.0-5.5.5 release tags contain. It replaced the wrap-consistent zig-zag deltas with the min-adjustment loop whose signed test 'expected > actual' runs on a possibly wrapped expected value [read: follow-up history (git log -S on the loop, whitespace-stripped flush()/expected() compared at every release tag: three code groups, 4.2.0-4.8.1, 4.9.0-4.9.1, 4.10.0-10.5.2 = main); exec: release bracketing, 4.8.1 passes and 4.9.0 fails; no commit-level build] (2014-06-05 (trunk b31ae698e5d and branch_4x 1b450e1be80, the same day))
- First affected version
- 4.9.0 [exec] (4.2.0, the first release with these classes, and 4.8.1, the last before LUCENE-5721, pass [exec])
- Affected versions
- every release 4.9.0 through 10.5.2 (105 release tags in the history clone, plus 8.9.0-8.11.x, which the clone does not tag), 10.6.0 (RC1 4ae946e5a9b [exec]) and main ea57360912ed through 645d4fe14a2; never fixed. Executed failing: 4.9.0 and 4.10.4 (-ea and -da), 5.5.5, 6.6.6, 7.7.3 and 8.11.4 (-da), 9.12.3, 10.5.2 and main (-ea and -da) [exec, finder repro or an API-adapted copy, follow-up history runs]; independently 9.12.3 and 10.5.2 by the independent reproduction (-ea and -da), 4.9.0, 4.10.4, 9.12.3, 10.5.2 and main by the intended-behaviour check's standalone probe, 9.12.3 by the intended-lens cross-model reviewer [exec]; the other releases by identical flush()/expected() code [read]. 4.9.x also fails to read a block whose origin is 2^62 or more (writeVLong(zigZagEncode(min)); 'Invalid vLong detected' with -da), which LUCENE-5739 removed in 4.10.0 [exec]. Not affected: 4.2.0-4.8.1 (pre-LUCENE-5721 zig-zag arithmetic) [exec on 4.2.0 and 4.8.1]; the classes do not exist before 4.2.0. Not executed: 9.0.0 (identical code). Upstream main 645d4fe14a2 and branch_10_6 RC1 4ae946e5a9b fail [exec 2026-10-29, -ea and -da]; branch_10x 657014b3c57 has the identical file [read] (MonotonicBlockPackedWriter.java last changed 2021 by @Override annotations)
- Status
- documented (brief, checks and a per-finding doc exist)
- Verification
- Confirmed by AI-run checks: an independent repro, a cross-model review that disputed it (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
- semi-formal: Z3, code reading
- Upstream and prior reports
- none found (2026-10-26: scripted searches, in total 46 GitHub issue/PR, 8 commit, 4 downstream, 80 mailing-list (dev@, java-user@, issues@, commits@) and 16 JIRA-archive queries; 12 manual GitHub searches on apache/lucene, apache/lucenenet, elastic/elasticsearch, opensearch-project/OpenSearch, apache/solr and all of GitHub; 5 web queries; full reads of 16 JIRA issues and 12 GitHub threads; the changed-file lists of all 413 open apache/lucene PRs; the upstream histories of both files on main, branch_10x, branch_10_6 and branch_9x; git log -S on the min-adjustment line; the independent reproduction's and the intended-behaviour check's own GitHub searches): no issue, PR, commit, JIRA ticket, list post, downstream report or web page describes this overflow or wrong values for blocks near Long.MAX_VALUE, and no open PR touches MonotonicBlockPackedWriter/Reader, AbstractBlockPackedWriter or FixedGapTermsIndex*. Related, not this bug: #6783 (LUCENE-5721, the introducing change; its discussion is about speed and bits per value and never mentions overflow or a value bound), #6854 (LUCENE-5792, 2014: replaced MonotonicAppendingLongBuffer, the other copy of the same loop, with PackedLongValues.monotonicBuilder, whose subtraction form has no overflow, as an API refactor that never mentions overflow and left this writer unchanged), #85 and #10946 (LUCENE-9907, reader refactoring), #6191 (LUCENE-5127, made FixedGapTermsIndexWriter use this writer, 2013), #10201 (LUCENE-9161, DirectMonotonicWriter block-count validation), commit 960ce15fc89 (2013, a numBlocks * blockSize overflow in the reader constructor), #12377 and #12379 (DirectMonotonicWriter), apache/lucenenet#1293 (lists the file only in a diffstat; the Lucene.NET port still has the pre-4.9 writer without this loop). Caveats: searches cannot prove absence
- Lucene commit tested
- main
645d4fe14a20(2026-10-28): the minimal test with assertions on and off through gradle in a scratch worktree (2026-11-01) and standalone (2026-10-29, two seeds); the sign-aware fix in that worktree with the test,org.apache.lucene.util.packed.*,TestPackedInts#testMonotonicBlockPackedReaderWriterx20, all of:lucene:codecs:testand:lucene:core:check -x test. Earlier runs of the finder's two-test repro: mainea57360912edanda9779ef3c193; 10.6.0 at its tag commit4ae946e5a9b(built from source), 10.5.2 and 9.12.3 (assertions on and off); 8.11.4, 7.7.3, 6.6.6 and 5.5.5 (off); 4.10.4 and 4.9.0 (on and off); 4.8.1 and 4.2.0 pass (release jars, SHA-1 checked). Read: upstream main2f424fd428d(2026-10-30) andbranch_11xhave the identical file. All runs used OpenJDK 25.0.3 on Linux aarch64.
- Finding record
lucene/core/src/java/org/apache/lucene/util/packed/MonotonicBlockPackedWriter.java:26 (class javadoc 'A writer for large monotonically increasing sequences of positive longs'), :51 (@lucene.internal), :66 (assert l >= 0), :74 (float avg), :76-83 (min-adjustment loop, 'adjust min so that all deltas will be positive', signed test at :80), :85-89 (deltas, Math.max at :88), :96-98 (bitsRequired, writeValues)lucene/core/src/java/org/apache/lucene/util/packed/MonotonicBlockPackedReader.java:31 ('Provides random access to a stream written with MonotonicBlockPackedWriter'), :33 (@lucene.internal), :37-39 (expected(origin, average, index) = origin + (long) (average * (long) index)); lucene/core/src/java/org/apache/lucene/util/packed/BulkOperationPacked.java:222 (assert PackedInts.unsignedBitsRequired(v) <= bitsPerValue)lucene/codecs/src/java/org/apache/lucene/codecs/blockterms/FixedGapTermsIndexWriter.java:41 (@lucene.experimental), :169 (termAddresses.add(termsFilePointer - termsStart)), :172-174 (currentOffset += indexedTermLength; termOffsets.add); lucene/test-framework/src/java/org/apache/lucene/tests/codecs/blockterms/LuceneFixedGap.java:66 and tests/codecs/mockrandom/MockRandomPostingsFormat.java:178 (the only 'new FixedGapTermsIndexWriter' in the tree); lucene/core/src/test/org/apache/lucene/util/packed/TestPackedInts.java:1288 (testMonotonicBlockPackedReaderWriter)
- The finding in 30 seconds
lucene/core/src/java/org/apache/lucene/util/packed/MonotonicBlockPackedWriter.java:26-30, :66, :74-98 (at 645d4fe14a20)lucene/core/src/java/org/apache/lucene/util/packed/MonotonicBlockPackedReader.java:31, :37-39, :119-124 (at 645d4fe14a20)lucene/core/src/java/org/apache/lucene/util/packed/BulkOperationPacked.java:222; MonotonicLongValues.java:94-103; DirectMonotonicWriter.java:93-96 (at 645d4fe14a20)
- Who is affected
lucene/codecs/src/java/org/apache/lucene/codecs/blockterms/FixedGapTermsIndexWriter.java:41, :114-120, :169, :172-174 (at 645d4fe14a20)lucene/test-framework/src/java/org/apache/lucene/tests/codecs/blockterms/LuceneFixedGap.java:66; tests/codecs/mockrandom/MockRandomPostingsFormat.java:178 (at 645d4fe14a20)
- Why it looks unintended
lucene/core/src/java/org/apache/lucene/util/packed/MonotonicBlockPackedWriter.java:26, :51, :66, :76; MonotonicBlockPackedReader.java:31, :33; BulkOperationPacked.java:222 (at 645d4fe14a20)lucene/core/src/test/org/apache/lucene/util/packed/TestPackedInts.java:1288-1330 (:1297, :1303, :1325) (at 645d4fe14a20)lucene/CHANGES.txt:12202, :12247-12248 (LUCENE-5721, 4.9.0 Optimizations), :14383-14387 (LUCENE-4547) (at 645d4fe14a20)build-tools/build-infra/src/main/java/org/apache/lucene/gradle/plugins/java/RenderJavadocPlugin.java:506 (@lucene.internal text) (at 645d4fe14a20)lucene/core/src/java/org/apache/lucene/util/packed/MonotonicLongValues.java:94-103; DirectMonotonicWriter.java:93-96 (at 645d4fe14a20)
- Suggested fix
lucene/core/src/java/org/apache/lucene/util/packed/MonotonicBlockPackedReader.java:87; MonotonicLongValues.java:99; DirectMonotonicWriter.java:93-96; lucene/core/src/test/org/apache/lucene/util/packed/TestPackedInts.java:1303 (at 645d4fe14a20)
- History
a Lucene clone: origin/main 2f424fd428d, origin/branch_11x 603af870375, releases/lucene/10.6.0 (git diff against 645d4fe14a20 empty for both files)
- TestMonotonicBlockPackedNearMaxValue.java:
repro/LFV-148/src/org/apache/lucene/util/packed/TestMonotonicBlockPackedNearMaxValue.java(44 lines)
assets/LFV-148/bug.png(411 KB)
