All findings · Lucene finding · LFV-148 · monotonic-block-packed-writer-min-overflow

MonotonicBlockPackedWriter mis-encodes a block that ends near Long.MAX_VALUE: {1, Long.MAX_VALUE} reads back as {1, Long.MIN_VALUE}

MonotonicBlockPackedWriter, Lucene's internal encoder for increasing longs, overflows a 64-bit sum when a block ends near Long.MAX_VALUE, writes too few bits, and the reader silently returns wrong values: {1, Long.MAX_VALUE} reads back as {1, Long.MIN_VALUE}. Latent (Lucene's own caller stores far smaller values); in every release since 4.9.0.

Summary

Priority
Low, rank 1197MonotonicBlockPackedWriter 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
minor
Since
4.9.0 (2014, LUCENE-5721); 4.8.1 is not affectedintroduced 2014-06-05 · over 12 years
Affected
every release from 4.9.0 to 10.6.0, and main
Status
documented
Found by
semi-formal: an SMT model of the encoder, then a JUnit test

AI-assisted page: this finding was first signalled by a semi-formal check (an exact check of the real jar, or a modelling hypothesis that a tool then witnessed) 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

One comparison in MonotonicBlockPackedWriter.flush() adds before it compares: min + (long) (avg * i) > values[i] wraps when the block ends near Long.MAX_VALUE.

Background: how MonotonicBlockPackedWriter stores a block

  1. It cuts a sequence of increasing, non-negative longs into fixed-size blocks (64 values here) and fits each block to a line: start min and slope avg = (last - first) / (n - 1), stored as a float.
  2. 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.
  3. Corrections must not be negative, so before packing, the writer lowers min wherever the line lies above a value ("adjust min so that all deltas will be positive"). The reader adds line and correction back together.
MonotonicBlockPackedWriter encoding the block {1, Long.MAX_VALUE}: the fitted line's point for the second value, 1 + Long.MAX_VALUE, wraps to Long.MIN_VALUE, so the check that should lower min is false, the correction -1 is dropped, the block is written with 0 bits per value, and MonotonicBlockPackedReader.get(1) returns Long.MIN_VALUE instead of Long.MAX_VALUE.
The block {1, Long.MAX_VALUE} as a line plus corrections: the line's point for the second value wraps to Long.MIN_VALUE, the min adjustment is skipped, and 0 bits are written. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Write two increasing values, 1 and Long.MAX_VALUE (9,223,372,036,854,775,807), with new MonotonicBlockPackedWriter(out, 64), then read them back with MonotonicBlockPackedReader. You expect 1 and 9,223,372,036,854,775,807.
  2. 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 long and wraps to -9,223,372,036,854,775,808.
  3. 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.
  4. 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 -ea and read back wrong values under -da.
  5. Only blocks ending within about 2^40 (10^12) of Long.MAX_VALUE are hit, closer for small spans. 4.8.1 round-trips the same values; the overflow came with an optimization in 4.9.0.

Who is affected

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.

The code (main 645d4fe14a20)

// 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)   // :123

The walk for {1, Long.MAX_VALUE} (each value printed by running the expressions on JDK 25)

  1. avg = (float) (Long.MAX_VALUE - 1) / 1 = 9.223372E18, exactly 2^63 as a float; min = 1.
  2. (long) (avg * 1) saturates to Long.MAX_VALUE, so expected = 1 + Long.MAX_VALUE wraps to -9223372036854775808.
  3. -9223372036854775808 > 9223372036854775807 is false: min stays 1.
  4. Deltas {0, Long.MAX_VALUE - Long.MIN_VALUE} = {0, -1} after the wrap; Math.max keeps 0, so the block is written with 0 bits (no packed values, so no assertion can fire).
  5. 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.

What bounds it

  • 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 of flush() finds no failure without the overflow for 2- and 3-value blocks. Neither covers every block size.
  • How close to Long.MAX_VALUE. min starts 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 -ea its negative delta trips assert 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() subtracts expected(0, average, i) first (MonotonicLongValues.java:94-103), and DirectMonotonicWriter, 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.internal MonotonicBlockPackedWriter (:51) whose block ends close to Long.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 stores termsFilePointer - 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 formats LuceneFixedGap and MockRandomPostingsFormat create 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 AssertionError while writing under -ea. No hang, no extra memory.
  • Likelihood: contrived. Priority low.

Minimal repro

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.

TestMonotonicBlockPackedNearMaxValue.java44 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.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));
      }
    }
  }
}
Command
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.

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

More files, environment, full output213 words
  • Package. The test sits in org.apache.lucene.util.packed, next to TestPackedInts, but uses only public members of the two public classes.
  • Smallest input. Two values; the 0-bit block makes -ea and -da identical (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_VALUE trip AssertionError at BulkOperationPacked.encode(BulkOperationPacked.java:222) under -ea, and read back element [62] as Long.MAX_VALUE instead of Long.MAX_VALUE - 1 under -da.
  • As the regression test: this same method, for example added to TestPackedInts. It passes with the fix.

Environment

lucenemain 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
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64
Command (environment)
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)

Observed output

# 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

Why it looks unintended

  • The javadoc promises "large monotonically increasing sequences of positive longs" with no upper bound; add() checks only l >= 0.
  • The code's own comment, "adjust min so that all deltas will be positive", is the invariant that breaks; under -ea Lucene'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 only l >= 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), and BulkOperationPacked.encode asserts 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), and DirectMonotonicWriter'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.

Suggested fix

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.

A. Sign-aware subtraction form (validated by the checks) (recommended)

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: offset is in that range for avg >= 0 (the cast saturates), and actual is when the caller keeps to the javadoc. add() only asserts l >= 0, so with -da negative 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 only l >= 0); the upstream test feeds local decreases of up to 16. So the avg < 0 branch 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.* (seed DEADBEEF): 44 tests, 3 skipped, 0 failures (TestPackedInts, TestDirectMonotonic, TestDirectPacked, the minimal test).
  • TestPackedInts#testMonotonicBlockPackedReaderWriter with -Ptests.iters=20: 20 of 20 pass.
  • All of :lucene:codecs:test (random seed): 693 tests, 78 skipped, 0 failures, including TestFixedGapPostingsFormat, 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 testMonotonicBlockPackedReaderWriter x5.

B. Unconditional subtraction form

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.

C. Size the bits with an OR, as DirectMonotonicWriter does

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.

D. Document an upper bound

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 note

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.

How it was found

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 modelled MonotonicBlockPackedWriter.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 below Long.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 (-ea and -da), for example a 1,023-value block (block size 1,024) from 1 to Long.MAX_VALUE that 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.

Prior reports, history and checks

Prior reports

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.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene)2026-10-26scripted 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 loopno report; no open PR touches the files
JIRA archive (LUCENE)2026-10-2616 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-2680 scripted queriesno report (notification mirrors and one unrelated 2014 dev@ thread)
Downstream and web (Solr, Elasticsearch, OpenSearch, Lucene.NET, all of GitHub)2026-10-26manual GitHub searches and 5 web queriesno report; Lucene.NET still has the pre-4.9 code

History

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 as 1b450e1be80 the 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 -ea an assertion in DataOutput.writeVLong while writing, with -da "Invalid vLong detected" on read); LUCENE-5739 (cc3501de97b, 4.10.0) switched to writeZLong. The min-adjustment loop has been unchanged since: flush() and expected() are identical from 4.10.0 to main.
  • Upstream main 2f424fd428d (2026-10-30), branch_11x and the 10.6.0 tag have the identical file.
Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20, a9779ef3c193, ea57360912ed affectedfails 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) affectedfails with assertions on and off
10.5.2, 9.12.3 affectedfails with assertions on and off
8.11.4, 7.7.3, 6.6.6, 5.5.5 affectedfails (release jars, assertions off)
4.10.4, 4.9.0 affectedfails 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 affectedboth repro inputs round-trip (pre-LUCENE-5721 zig-zag arithmetic)

Independent checks

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.

Each independent check and its verdict
CheckVerdictWhat it found
finder reproducedZ3 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 onlyOpenAI 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 realOwn 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 onlyOpenAI 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 realNo 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 minorRe-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 reproducesFollow-up release sweep, main refreshes a9779ef3c193 and 645d4fe14a20, 10.6.0 (tag commit), the minimal repro, and this doc's gradle runs.

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,291 words

Finding record

LFV-148, snapshot of 2026-11-06

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#testMonotonicBlockPackedReaderWriter x20, all of :lucene:codecs:test and :lucene:core:check -x test. Earlier runs of the finder's two-test repro: main ea57360912ed and a9779ef3c193; 10.6.0 at its tag commit 4ae946e5a9b (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 main 2f424fd428d (2026-10-30) and branch_11x have the identical file. All runs used OpenJDK 25.0.3 on Linux aarch64.

Lucene and upstream sources

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)

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.