All findings · Lucene finding · LFV-166 · uniformsplit-large-delta-flush-crash

UniformSplit postings formats fail to flush or merge a field when deltaNumLines > targetNumBlockLines / 3, a setting the constructor accepts

A UniformSplit (or SharedTermsUniformSplit) postings format built with deltaNumLines above a third of targetNumBlockLines, a setting its constructor accepts, cannot write some fields: under (4, 2), committing a field of 3 terms throws and closes the IndexWriter. The defaults (32, 3) are safe; every release since 8.3.0 is affected.

Summary

Priority
Low, rank 688A UniformSplit postings format built with deltaNumLines above targetNumBlockLines/3, which validation accepts, fails flushes and merges and closes the writer, loud and limited to a non-default setting nobody is known to use.
Impact
crash
Since
8.3.0 (LUCENE-8753, 2019)introduced 2019-09-06 · about 7 years
Affected
every release with the formats, 8.3.0 to 10.6.0, and main (representative 8.x, 9.x and 10.x releases run; the rest source-checked)
Status
documented
Found by
a failed assertion while building small-block test fixtures, then code reading and a settings sweep

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

To end a block, the writer looks back over the last 2 × delta terms for a split point. That window must start at index 1 or later: ordinary splits guarantee it, the splits at a field's end do not whenever 3 × delta > target. The constructor accepts such settings, so the flush throws.

Background

  1. Blocks of terms. UniformSplit writes a field's sorted terms in blocks of about targetNumBlockLines terms, give or take deltaNumLines (default 32 ± 3).
  2. Where a block ends. When a block reaches target + delta terms, the writer looks back over its last 2 × delta terms and starts the next block at the term with the shortest distinguishing prefix, which keeps the terms dictionary small.
  3. The last block. When the field's terms run out, the leftover terms go through the same split, unless there are at most target − delta of them: those are written as one block. The split repeats on whatever it leaves until no terms are left.
Diagram of the bug: with UniformSplitPostingsFormat(4, 2) a field with 3 terms reaches the last-block split; the look-back window of 2 × 2 = 4 terms would start at index 3 − 4 = −1, so the commit throws and the IndexWriter closes. With the suggested fix the 3 terms are written as one block.
The last block of a field under the settings (4, 2), and why its look-back window starts before the first term. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. You build new UniformSplitPostingsFormat(4, 2, null, null, false): blocks of 4 ± 2 terms. The constructor accepts it, because the documented rule is only 0 <= deltaNumLines < targetNumBlockLines.
  2. You add one document with the 3 values a, b, c in field f and commit. You expect one block of 3 terms, inside the promised 2 to 6.
  3. Instead the commit throws: AssertionError: blockLines size: 3 with assertions on, IndexOutOfBoundsException: Index -1 out of bounds for length 3 with them off. The IndexWriter closes and loses everything since the last commit.
  4. Why: at the end of a field the writer writes the leftover terms as one block only if there are at most 4 − 2 = 2. With 3 it looks back over the last 2 × 2 = 4 terms for a split point, starting at index 3 − 4 = −1.
  5. Every setting with 3 × delta > target can hit it: under (32, 12), any field whose end needs a split of 21 to 24 terms, either as the whole leftover or after a first split of a leftover of up to 43, in flushes and merges and for both formats. The defaults (32, 3) and formats loaded by name cannot fail.

Who is affected

Applications that build either format with deltaNumLines > targetNumBlockLines / 3; no such user is known. Then any flush, commit or merge can hit it, at any scale: the writer closes and uncommitted documents are lost; the last commit stays intact. Just past the boundary only 1 to 5 random fields in 400 fail, so tests can miss it.

Mechanism, terms and numbers1,241 words

UniformSplitPostingsFormat and STUniformSplitPostingsFormat (codecs module, opt-in) take targetNumBlockLines and deltaNumLines in their public constructors; the javadoc asks only 0 <= deltaNumLines < targetNumBlockLines and promises blocks of target ± delta terms, and validateSettings enforces exactly that. When the writer finishes a field, it writes the leftover terms as one block only if there are at most target − delta of them; otherwise it looks for a split point in the last 2 × delta terms, starting at index numLines − 2 × delta, and only asserts that this is at least 1. For a split of target − delta + 1 to 2 × delta terms, a range that exists exactly when 3 × delta > target, the start is 0 or negative; at a field's end such a split is either the whole leftover or what a first split of a larger leftover (up to target + delta − 1 terms) leaves. The flush, commit or merge then throws (AssertionError with assertions on; IndexOutOfBoundsException with them off, at exactly 2 × delta terms only for some terms), the IndexWriter closes through its tragic-event path and everything buffered since the last commit is lost; the last commit stays intact and passes CheckIndex. The smallest case is one document with 3 values under (4, 2). The defaults (32, 3) cannot fail. The code is unchanged since the formats were added (LUCENE-8753, 8.3.0).

The code (main 645d4fe14a20)

  1. Ordinary splits. BlockWriter.addLine appends one line per term and calls splitAndWriteBlock once the block holds targetNumBlockLines + deltaNumLines lines (BlockWriter.java:137-139). There the window starts at (target + delta) − 2 × delta = target − delta >= 1.
  2. The end of a field. UniformSplitTermsWriter.writeFieldTerms calls finishLastBlock after the last term ("Flush remaining terms.", UniformSplitTermsWriter.java:333-334), which loops splitAndWriteBlock while lines remain (BlockWriter.java:147-153); the leftover has 1 to target + delta − 1 lines. When the first call splits normally (deltaStart >= 1), it leaves at most 2 × delta lines, and the loop splits those again.
  3. The split. splitAndWriteBlock (BlockWriter.java:161-191) writes numLines <= target − delta as one block (:165-169); otherwise int deltaStart = numLines - deltaNumLines * 2; (:170), assert deltaStart >= 1 : "blockLines size: " + numLines; (:171), then blockLines.get(i) for i from deltaStart (:175-176), keeping the last line with the shortest prefix (<= at :178), and writes subList(0, minMdpEndIndex) (:184). A call with target − delta + 1 to 2 × delta lines has deltaStart <= 0. At a field's end it comes either directly (the leftover r has that many lines) or second: for a leftover r of 2 × delta + 1 to target + delta − 1 lines, the first call ends the block at the last shortest-prefix line k of the window and leaves r − k lines, which land in that range when k < r − (target − delta). With 3 × delta <= target every remainder is at most 2 × delta <= target − delta and is written as one block.
  4. What is thrown. Assertions on: the assert fails. Assertions off, deltaStart < 0: blockLines.get(deltaStart) throws at :176 (here Index -1). Assertions off, deltaStart == 0 (a call of exactly 2 × delta lines): the loop picks index 0 only when line 0 has the strictly shortest prefix, and then addBlockKey's blockLines.get(0) on the empty sub-list throws (BlockWriter.java:278-282); otherwise (last shortest prefix at k >= 1) the split writes a valid block of k lines, but finishLastBlock splits the remaining 2 × delta − k lines again, and that split throws (Index -k at :176) when k < 3 × delta − target, possible when 3 × delta >= target + 2, e.g. (6, 4) or (32, 12); otherwise the field is written. The fact check ran this under (6, 4) with one 8-value field: a to h commits, a, aa … ag throws Index 0, a, b, ba, bb, bc, bd, be, bf throws Index -1 out of bounds for length 7; with assertions on all three throw blockLines size: 8, and with the fix all three commit. So with assertions off the failures are a strict subset of those with assertions on: each follows a call with deltaStart <= 0.
  5. Both formats. STBlockWriter.finishLastBlock only calls super (STBlockWriter.java:86-90).
  6. Validation lets it through. validateSettings (UniformSplitTermsWriter.java:238-260), called by the constructor (UniformSplitPostingsFormat.java:105), checks only target > 0, delta >= 0, delta < target and target + delta <= 1,000.
  7. Why the writer closes. A flush failure is recorded as aborting, abort() runs and the exception is passed to IndexWriter.onTragicEvent (DocumentsWriterPerThread.java:585-589, :594-605; IndexWriter.java:453-454), the same for both exceptions; prepareCommitInternal then rolls the writer back (IndexWriter.java:3879-3883, :6098-6106). A merge failure calls tragicEvent(t, "merge") (IndexWriter.java:5109-5113).

Which sizes fail

The failing splitAndWriteBlock call always has target − delta + 1 to 2 × delta lines. It is either the field's whole leftover or what finishLastBlock's first split of a larger leftover leaves, so any leftover from target − delta + 1 to target + delta − 1 lines can fail, the larger ones only for some terms.

setting (target, delta)3 × delta > target?lines in the failing callleftovers that can fail
(32, 3), the defaultnononenone
(10, 2), used by TestBlockWriternononenone
(4, 2)yes3, 43 to 5; 5 only for some terms (executed)
(32, 11)yes (33 > 32)22 only (the exact 2 × delta case)22 to 42; above 22 only for some terms (from the code)
(32, 12)yes21 to 2421 to 43; above 24 only for some terms (from the code)

The (4, 2) 5-term case, run by the diagram probe: a, b, ba, bb, bc throws blockLines size: 4 with assertions on and Index 0 out of bounds for length 0 with them off; a, ab, b, ba, bb throws blockLines size: 3 and Index -1 out of bounds for length 3; a to e commits.

Executed beyond the minimal test

  • Writer state (UniformSplit, both assertion modes): under (4, 2), commit 1 with one value succeeds; a 3-value document plus a document in another field then make commit 2 throw. The writer is closed (isOpen() false, a tragic exception recorded, the next addDocument throws AlreadyClosedException) and the reopened index has 1 of the 3 documents added; CheckIndex is clean.
  • Merge path (both formats): two committed segments with 2 and 1 values each flushed fine; forceMerge(1) writes a 3-term field and throws, and the writer closes; the last commit (2 documents, all 3 values readable) is intact and CheckIndex-clean.
  • Moderate settings: (32, 12) with a 22-value field, (64, 22) with 43 values and (100, 34) with 67 values throw with assertions on and off.
  • Sweep: all 78 accepted settings with target 1..12, fields of 1..40 values, two term shapes, both formats: 0 failures when 3 × delta <= target; with assertions off 1,164 of 3,120 flushes fail for sequential terms and 914 of 3,120 for random terms. Fields of more than 2 × delta values, which reach the failing range only after a first split, account for 806 of 1,268 (sequential) and 600 of 1,268 (random) of them.
Exposure and likelihood286 words
  • Who. Applications that build UniformSplitPostingsFormat or STUniformSplitPostingsFormat through the public 5-argument constructors, or through a subclass and the protected constructors, with deltaNumLines > targetNumBlockLines / 3. The formats are opt-in (codecs module, @lucene.experimental) and the setting is non-default. Default-constructed and SPI-loaded instances use (32, 3) and cannot fail.
  • No known user. A code search found no code that builds these formats with non-default settings (only copies of Lucene's own default-settings test), and Solr selects postings formats by SPI name, which uses the defaults. The search is not exhaustive.
  • What it costs. The flush, commit or merge throws and the IndexWriter closes; everything buffered since the last commit is lost, including other threads' documents, since it is a writer-wide rollback (this follows from the rollback; all runs were single-threaded). The committed index stays consistent and CheckIndex-clean, and in the executed checks every assertions-off flush that did not throw read back all its terms. It is not index corruption.
  • Merges stay blocked. A merge whose output field needs such a split fails again in each new writer session until the setting is changed; switching the field back to the defaults recovers (forceMerge(1) succeeds, CheckIndex clean).
  • Scale. None needed: one document with 3 values under (4, 2). With a wide window failures are frequent: about a third of fields, e.g. 143 of 400 random fields under (32, 16) with assertions off. Just past the boundary failures are rare on ordinary data. With assertions off: 1 of 400 random fields under (32, 11), 4 of 400 under (64, 22), 5 of 400 under (100, 34), 26 of 400 under (32, 12).
  • How likely: rare. Loud, and limited to a non-default setting nobody is known to use.

Minimal repro

One JUnit test, one thread, deterministic, public constructor only: UniformSplitPostingsFormat(4, 2), one document with 3 StringField values in one field, then commit(). It fails with assertions on and off.

TestUniformSplitLargeDeltaFlush.java (one test, public constructor)52 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.codecs.uniformsplit;

import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.StringField;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.tests.util.TestUtil;

public class TestUniformSplitLargeDeltaFlush extends LuceneTestCase {

  public void testCommitThreeTermsWithTarget4Delta2() throws Exception {
    IndexWriterConfig iwc =
        new IndexWriterConfig()
            .setCodec(
                TestUtil.alwaysPostingsFormat(
                    new UniformSplitPostingsFormat(4, 2, null, null, false)));
    try (Directory dir = new ByteBuffersDirectory();
        IndexWriter w = new IndexWriter(dir, iwc)) {
      Document doc = new Document();
      doc.add(new StringField("f", "a", Field.Store.NO));
      doc.add(new StringField("f", "b", Field.Store.NO));
      doc.add(new StringField("f", "c", Field.Store.NO));
      w.addDocument(doc);
      try {
        w.commit();
      } catch (Throwable t) {
        // Expected: commit succeeds, (4, 2) meets the documented 0 <= deltaNumLines < targetNumBlockLines; actual: it throws.
        throw new AssertionError("commit with UniformSplitPostingsFormat(4, 2) threw " + t, t);
      }
    }
  }
}
Command
cd $LUCENE && ./gradlew :lucene:codecs:test --tests TestUniformSplitLargeDeltaFlush -Ptests.seed=DEADBEEF -Ptests.asserts=true
Expected
commit() succeeds and field f is written as one block of 3 terms.
Observed
commit() throws AssertionError: blockLines size: 3 with assertions on, IndexOutOfBoundsException: Index -1 out of bounds for length 3 with them off.

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

More files, environment, full output287 words

(4, 2) meets the documented rule 0 <= deltaNumLines < targetNumBlockLines. The test sits in org.apache.lucene.codecs.uniformsplit, where an upstream test would go, but uses only the public constructor; ByteBuffersDirectory and an explicit postings format fix the directory and codec choices that matter; the test framework still randomizes unrelated things such as locale and time zone, which do not affect the path. Line 45 is w.commit(); line 48 is the test's own wrapper, which says the commit was expected to succeed. If you add it upstream, wrap the long comment line for the formatter.

The writer close, the merge path and STUniformSplitPostingsFormat share the root cause and were executed by the longer checks (in the collapsed explanation above); they are not part of this repro.

Regression test: this same test; it passes with the recommended fix.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT): fails; with the fix: passes. Also fails on the 10.6.0 and 10.5.2 release jars
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64 (the only platform run; the code path is pure Java)
Command (environment)
cp $REPRO/TestUniformSplitLargeDeltaFlush.java $LUCENE/lucene/codecs/src/test/org/apache/lucene/codecs/uniformsplit/
cd $LUCENE && ./gradlew :lucene:codecs:test --tests TestUniformSplitLargeDeltaFlush -Ptests.seed=DEADBEEF -Ptests.asserts=true
# assertions off fails too, with IndexOutOfBoundsException: -Ptests.asserts=false

Observed output

# gradle, stock 645d4fe14a20, -Ptests.seed=DEADBEEF -Ptests.asserts=true (stack trimmed)
TestUniformSplitLargeDeltaFlush > testCommitThreeTermsWithTarget4Delta2 FAILED
    java.lang.AssertionError: commit with UniformSplitPostingsFormat(4, 2) threw java.lang.AssertionError: blockLines size: 3
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:778092D94C5D7E5]:0)
        at org.apache.lucene.codecs.uniformsplit.TestUniformSplitLargeDeltaFlush.testCommitThreeTermsWithTarget4Delta2(TestUniformSplitLargeDeltaFlush.java:48)
        Caused by:
        java.lang.AssertionError: blockLines size: 3
            at org.apache.lucene.codecs.uniformsplit.BlockWriter.splitAndWriteBlock(BlockWriter.java:171)
            at org.apache.lucene.codecs.uniformsplit.BlockWriter.finishLastBlock(BlockWriter.java:149)
            at org.apache.lucene.codecs.uniformsplit.UniformSplitTermsWriter.writeFieldTerms(UniformSplitTermsWriter.java:334)
            at org.apache.lucene.codecs.uniformsplit.UniformSplitTermsWriter.write(UniformSplitTermsWriter.java:274)
            at org.apache.lucene.codecs.perfield.PerFieldPostingsFormat$FieldsWriter.write(PerFieldPostingsFormat.java:162)
            at org.apache.lucene.index.FreqProxTermsWriter.flush(FreqProxTermsWriter.java:133)
            ...
            at org.apache.lucene.index.IndexWriter.commit(IndexWriter.java:4215)
            at org.apache.lucene.codecs.uniformsplit.TestUniformSplitLargeDeltaFlush.testCommitThreeTermsWithTarget4Delta2(TestUniformSplitLargeDeltaFlush.java:45)

# same, -Ptests.asserts=false (JDK frames trimmed)
    java.lang.AssertionError: commit with UniformSplitPostingsFormat(4, 2) threw java.lang.IndexOutOfBoundsException: Index -1 out of bounds for length 3
        Caused by:
        java.lang.IndexOutOfBoundsException: Index -1 out of bounds for length 3
            at java.base/java.util.ArrayList.get(ArrayList.java:428)
            at org.apache.lucene.codecs.uniformsplit.BlockWriter.splitAndWriteBlock(BlockWriter.java:176)
            at org.apache.lucene.codecs.uniformsplit.BlockWriter.finishLastBlock(BlockWriter.java:149)

Why it looks unintended

  • The constructor accepts any 0 <= deltaNumLines < targetNumBlockLines, promises blocks of target ± delta terms and says the defaults "can be tuned".
  • Writing the failing split's terms (at most 2 × delta < target + delta) as one block keeps that promise.
  • No javadoc, test or design discussion limits delta further; the internal assert is the only trace.
The full argument309 words
  • The settings are inside the documented contract. The constructor javadoc (UniformSplitPostingsFormat.java:73-76, the same at UniformSplitTermsWriter.java:169-172): deltaNumLines "Must be greater than or equal to 0 and strictly less than {@code targetNumBlockLines}. The block size will be {@code targetNumBlockLines}+-{@code deltaNumLines}." The class javadoc: "Each block size (number of terms) is {@code targetNumBlockLines}+-{@code deltaNumLines}" (UniformSplitTermsWriter.java:48-50) and "A good sweet spot for the target block size is 32 with delta of 3 (10%) (default values). This can be tuned in the constructor." (:70-71). The range is loose at a field's end even today: the stock end-of-field split can write a block below target − delta (e.g. 24 lines for a 30-line leftover under the defaults (32, 3), BlockWriter.java:165-185); the defect is the exception on accepted settings, not the block size.
  • Validation accepts them. validateSettings (UniformSplitTermsWriter.java:238-260) checks exactly the documented rule; (4, 2), (32, 11), (32, 12) and (64, 22) pass.
  • A valid result exists. A failing split has at most 2 × delta < target + delta terms, so one block keeps the promised range, and the reader needs no block-size setting: with the fix every value reads back and CheckIndex is clean.
  • Nothing limits delta further. No javadoc, test, CHANGES.txt or MIGRATE.md entry. Upstream tests use only (32, 3) (TestUniformSplitPostingFormat.java:43-48, TestSTUniformSplitPostingFormat.java:32-37) and BlockWriter(..., 10, 2, ...) (TestBlockWriter.java:45), all with 3 × delta <= target. The design discussion (#9798, lucene-solr #633, #676, #828) never discusses delta bounds or the last block, and git log -L on the body of splitAndWriteBlock finds only the introducing commit (with its javadoc, also a reformatting); the file's other commits do not touch the method.
  • Sibling practice. BlockTree's validateSettings rejects settings its splitter cannot handle: "maxItemsInBlock must be at least 2*(minItemsInBlock-1)" (Lucene103BlockTreeTermsWriter.java:354-360). UniformSplit accepts the settings and then fails at flush.
The devil's-advocate check189 words

The strongest case: these are @lucene.experimental formats in the codecs module; the javadoc calls 10% the sweet spot and the design describes delta as an "allowed delta variation (10%)"; the assert records the author's precondition; and nobody uses such settings.

Why it fails: @lucene.experimental warns that the API may change, not that documented arguments may crash the writer. The 10% is a recommended default, never a stated limit, and the same javadoc says the defaults can be tuned. The assert is internal, invisible to users and false for every accepted setting with 3 × delta > target. Low reach bears on priority, not on whether it is a bug. If large deltas are unwanted, the fix is to reject them in validateSettings, not to fail the flush and close the writer. The reading-only GPT reviews rated the intended-behaviour case "weak"; a later cross-model fact check rated it "moderate" (experimental, opt-in, an artificial setting, a loud failure that keeps the last commit) and noted that maintainers may reasonably prefer rejecting such settings in validation. That bears on which fix to lead with, not on whether the flush should throw.

Suggested fix

Recommended Write the leftover as one block when the window would start below 1 (validated)

In BlockWriter.splitAndWriteBlock, when the look-back window would start below index 1 (only possible for a field's last block), write the leftover terms as one block instead of asserting (+8/−1 lines). The repro passes with assertions on and off, and so do the uniformsplit tests, the whole codecs test module (693 tests) and lint.

int deltaStart = numLines - deltaNumLines * 2;
if (deltaStart < 1) {
  // Only reachable from finishLastBlock, for the last lines of a field, when
  // 3 * deltaNumLines > targetNumBlockLines: write them as one block (numLines <= 2 *
  // deltaNumLines < targetNumBlockLines + deltaNumLines, so the block size stays in range).
  writeBlock(blockLines, dictionaryBuilder);
  blockLines.clear();
  return;
}
All options and trade-offs3 options · 593 words

Option A was built and run here. B, a simpler clamp, was run by the study and has a drawback. C, tightening validation, was not run.

Write the leftover as one block when the window would start below 1 (validated) (recommended)

Replace the assert deltaStart >= 1 in BlockWriter.splitAndWriteBlock (BlockWriter.java:171) with a branch that writes the remaining lines as one block and returns. addLine always splits at target + delta lines, where deltaStart >= 1, so the branch is reached only from finishLastBlock, directly or on its second call, and the blocks written before it are unchanged; settings with 3 × delta <= target behave exactly as today.

int deltaStart = numLines - deltaNumLines * 2;
if (deltaStart < 1) {
  // Only reachable from finishLastBlock, for the last lines of a field, when
  // 3 * deltaNumLines > targetNumBlockLines: write them as one block (numLines <= 2 *
  // deltaNumLines < targetNumBlockLines + deltaNumLines, so the block size stays in range).
  writeBlock(blockLines, dictionaryBuilder);
  blockLines.clear();
  return;
}

Trade-offs

  • Block size. The last block then has target − delta + 1 to 2 × delta lines, inside the promised target ± delta; its split point is not chosen by the shortest-prefix rule, which affects only the dictionary key of one block per field.
  • No format change. The reader needs no setting; with the fix every value reads back (iteration and seekExact), merges succeed and CheckIndex is clean.
  • It drops the assert; the condition it guarded is now handled.

What the checks validated

Applied to a scratch worktree of 645d4fe14a20 and run through gradle with OpenJDK 25.0.3, seed DEADBEEF (2026-10-31):

  • stock: the minimal test fails with assertions on and off;
  • with the fix: the minimal test passes with assertions on and off;
  • the org.apache.lucene.codecs.uniformsplit.* tests (the 7 upstream classes plus the minimal test) pass with assertions on and off: 8 classes, 83 tests, 0 failures, 4 skipped;
  • the whole :lucene:codecs:test passes (26 classes, 693 tests, 0 failures, 78 skipped; run after the copied test was removed, assertions on); checkGoogleJavaFormat, ecjLintMain and forbiddenApisMain for codecs pass;
  • earlier, with the fix loaded over the stock jars: the settings sweeps give 0 failures (3,120 flushes in the study, 12,480 in the tie-break), and the independent reproducer's 5 tests and the tie-break's production-API check pass with assertions on and off.

Clamp the window start to 1 (run by the study)

int deltaStart = Math.max(1, numLines - deltaNumLines * 2); and no assert.

Trade-offs

The finder's 4-case test (which includes the repro's (4, 2) 3-term case), the settings sweep (0 of 3,120 flushes fail) and the 7 upstream uniformsplit test classes pass. But, as the study's refute reviewer noted, it can write a 1-line block that is not the last block of the field (when the shortest prefix is at index 1). The stock end-of-field split also writes blocks below target − delta, but not 1-line ones when 3 × delta < target; so the first option is preferred. Not run in this doc's validation.

Reject 3 × deltaNumLines > targetNumBlockLines in validateSettings (not run)

Add the check to validateSettings and the constructor javadoc, like BlockTree's 2*(minItemsInBlock-1) rule.

Trade-offs

Simple and fails fast at construction. But it narrows a documented and accepted range: applications whose data happens to avoid the window today (under (32, 11) about 1 field in 400 fails) would fail at construction after an upgrade; existing indexes stay readable, because the reader does not use the settings. The minimal test would have to expect an IllegalArgumentException from the constructor instead of a successful commit.

Downstream note

No downstream product is known to build these formats with non-default settings; Solr loads them by name, which uses the safe defaults.

How it was found

While building small-block UniformSplit test fixtures, (4, 2), for a differential test of automaton term intersection, the finder hit the writer's own assertion. Code reading of the last-block split, an exhaustive sweep of all 78 accepted settings with target up to 12, and a public-API JUnit on the stock jars confirmed it.

The method in full128 words

Method: semi-formal. An assertion failure seen while building fixtures for another hunt unit (a codec differential of automaton term intersection), outside that unit's target files; then code reading of BlockWriter.splitAndWriteBlock.

  • Bounded-exhaustive sweep. All 78 settings with targetNumBlockLines <= 12 that the constructor accepts, every field size 1..40, on the stock jar: 1,164 of 3,120 flushes fail with assertions off, every one with 3 × delta > target and none otherwise; 0 with either fix sketch.
  • Widening. An independent reproducer found the merge path and that the assertions-off failures are a subset; a devil's-advocate verifier reached it from another package through production API; the follow-up measured failure rates on random data just past the boundary.
  • The minimal repro above is the smallest case: 3 terms under (4, 2).

Prior reports, history and checks

Prior reports

None found: no issue, PR, commit, list post or downstream report (searched 2026-10-26 and 2026-10-30/31). Closest: the design review of the formats (#9798, lucene-solr #828), which never discusses delta bounds, and #10277, a block-size tuning thread that does not change delta.

Searches3 searches
  • #9798 (LUCENE-8753) and lucene-solr #633, #676, #828: where the code came from; delta appears only as the design's "allowed delta variation (10%)"; nothing on bounds, the last block or validateSettings.
  • #10277 (LUCENE-9237): the only tuning discussion, block size 26 against 32; the thread does not change delta, and a target of 26 is outside the window for any delta of 8 or less.
  • #10647 (LUCENE-9607, open): a different test failure, on the read side.
  • #10148 (LUCENE-9106): the last functional change to BlockWriter (serializer hooks); the split logic untouched.
  • Open PRs #16851, #14716, #13525 touch uniformsplit/ but not BlockWriter, STBlockWriter or validateSettings.
  • Limits: forks and private trackers are invisible; lucene-solr PRs before 2019-03 were not scanned (the formats did not exist); web results were noise.
What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene incl. the migrated JIRA archive, lucene-solr, solr, Elasticsearch, OpenSearch, all of GitHub)2026-10-30/31a prior-art run of 97 queries (GitHub 40, commits 6, downstream 5, lists 32, JIRA 6, web 8), 48 GraphQL and 12 REST searches (UniformSplit, deltaNumLines, targetNumBlockLines, splitAndWriteBlock, BlockWriter, the exception messages), and the changed files of every open and closed-unmerged apache/lucene PR (1,032) and of 2,094 lucene-solr PRs since 2019-03related items only (#9798, lucene-solr #633/#676/#828, #10277, #10647, #10148, open PRs #16851, #14716, #13525); none the same bug
Mailing lists (dev@, java-user@, issues@, commits@, builds@), JIRA SOLR and LUCENE, web2026-10-30/31the list queries above plus a second run of 32 list queries including builds@; full comment threads of the nearest issues and PRsno report (7 builds@ CI mails name UniformSplit tests, with no BlockWriter frame)
Earlier searches2026-10-26 and 2026-10-30the study follow-up's 113 logged queries, 44 GraphQL searches and a changed-file scan of 413 open PRs; the re-verification's gh searchesno report (verdict new)

History

Introduced with the formats by b963b7c3dbe (LUCENE-8753, 2019-09-06), first released in 8.3.0. Fails on every sampled release from 8.3.0 to 10.6.0 and on main; 8.2.0 and older lack the formats. Not fixed upstream.

Versions tested7 rows

Introduced by b963b7c3dbecda86c2917ad341caee63b93815ac, "LUCENE-8753: New UniformSplit and SharedTermsUniformSplit PostingsFormats" (committed by David Smiley, 2019-09-06; code from lucene-solr #828 by Bruno Roustant, following #633 and absorbing #676; JIRA issue now #9798; branch_8x cherry-pick b8a1857b0bd). The split code and the four validation checks are in this first version; on mainline history git log -S on the deltaStart line and the assert finds only this commit (across all refs also the branch_8x cherry-pick and a branch-bookkeeping commit), and git log -L on the method body finds only this commit (with its javadoc included, also the reformatting 7f8b7ffbcad, 2021-01-03).

Every one of the 48 release tags from 8.3.0 to 10.6.0 in the history clone has both lines verbatim and the same four checks; releases not executed are covered by that reading, and 8.8.2 to 8.11.4, not tagged there, were executed. Not fixed: unchanged on upstream main 2f424fd428da (2026-10-30), branch_10_6 and branch_11x; no CHANGES.txt entry.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedminimal test fails with assertions on and off (gradle and standalone runner); passes with the fix
10.6.0 affectedminimal test and the study's portable main() fail, assertions on and off
10.5.2 affectedminimal test fails, assertions on and off
9.0.0, 9.12.3, 10.0.0 affectedminimal test (imports ported for 9.0.0) or the portable main() fails
8.4.1, 8.5.0, 8.8.2 to 8.11.4 affecteda port of the minimal test or the portable main() fails
8.3.0 first affectedminimal test (8.x port) and the portable main() fail, assertions on and off
8.2.0 and older no such APIthe test does not compile: the formats do not exist

Independent checks

An independent reproducer, a devil's-advocate verifier, an adjudication, a re-verification and a tie-breaker reproduced it, each running its own or the minimal test. Of OpenAI GPT-5.6 reviews, the one that could run code reproduced it (real, high); both reading-only ones agreed on the root cause.

Checks and verdicts447 words

Each check ran its own tests or re-ran an earlier one on stock jars; two of the cross-model reviewers could only read code.

Each independent check and its verdict
CheckVerdictWhat it found
Finder candidateIts 4-case JUnit ((4, 2) with 3 and 4 terms, (10, 4) with 7 and 8) fails on main with assertions on and off and on 10.5.2 and 9.12.3 with them off; the 78-setting sweep; both fix sketches pass.
Refute lens (OpenAI GPT-5.6, executed) real (high)Own test (one document with 4 terms under (5, 2)) fails for both formats with assertions on and off, (5, 1) passes; root cause agreed; noted the clamp fix's 1-line block.
Independent reproducer realOwn 5-test JUnit fails 3 of 5 on main, 10.5.2 and 9.12.3; a portable main() fails 10 of 10 bug cases on 8.3.0, 8.11.4 and main with all controls passing; found the merge path and that the assertions-off failures are a subset.
Works-as-designed lens (OpenAI GPT-5.6, reading only) real (medium)Intended-behaviour argument weak; root cause agreed.
Intended-behaviour devil's advocate real (high)Public-API test from another package fails 3 of 6, including a (32, 16) merge of two healthy segments; the last commit survives a failed merge and CheckIndex is clean.
Adjudication confirmed (high)Re-ran the independent reproduction's test: 3 of 5 fail with assertions on and off; 0 of 5 with the fix.
Re-verification on main 645d4fe14a20 realThe minimal test fails on main and 10.5.2 with assertions on and off; the fix passes it.
Second refute lens (OpenAI GPT-5.6, reading only) not real (low, procedural)Could not run anything, so it could not reproduce; agreed on the root cause, inputs in contract, intended argument weak, and asked for a re-run that can execute code.
Tie-breaker realMinimal test on main and 10.5.2, the intended-behaviour check's test on main, and its own production-API check (writer state, merge, settings sweeps) on main and 10.5.2; everything passes with the fix.
This doc's fix validation realGradle in a scratch worktree: the minimal test fails on stock main and passes with the recommended fix, as do the uniformsplit tests, the whole codecs module and lint.
Fact check of this doc (executed) realRe-ran the gradle command (stock fails, the fix passes, assertions on and off) and the 10.6.0 jars; found two wording errors, now fixed: the third assertions-off outcome at exactly 2 × delta lines and the wide-window failure rate.
Cross-model fact check (OpenAI GPT-5.6, reading only) realAgreed the bug is real and rated the intended-behaviour case moderate; found the incomplete trigger table, three wording errors and four overclaims, now fixed.

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

Finding record

LFV-166, snapshot of 2026-11-06

Registry title
UniformSplit postings formats fail to flush or merge a field when deltaNumLines > targetNumBlockLines/3, a setting validateSettings accepts
Area
codecs / UniformSplit postings formats (BlockWriter last-block split; UniformSplit and SharedTermsUniformSplit)
Priority
Low, rank 688: A UniformSplit postings format built with deltaNumLines above targetNumBlockLines/3, which validation accepts, fails flushes and merges and closes the writer, loud and limited to a non-default setting nobody is known to use.
Impact class
crash
Impact note
Only applications that build UniformSplitPostingsFormat or STUniformSplitPostingsFormat (opt-in formats in the codecs module) with a non-default deltaNumLines > targetNumBlockLines/3, which validateSettings accepts, are hit: a flush, commit or merge that writes a field whose last block has between target-delta+1 and 2*delta lines throws (AssertionError with -ea, IndexOutOfBoundsException with -da; with -da the exact-2*delta case throws only for some term sets), the IndexWriter closes through its tragic-event path and buffered uncommitted documents are lost, while the last commit stays intact and passes CheckIndex; the defaults (32,3), SPI-loaded instances and every setting with 3*delta <= target cannot fail; no scale needed.
Summary
You build a UniformSplit (or SharedTermsUniformSplit) postings format with targetNumBlockLines=4 and deltaNumLines=2, which the constructor accepts because its documented rule is only 0 <= delta < target, and commit a field with 3 terms. You expect the commit to succeed and the blocks to hold target +- delta lines. Instead the flush throws (AssertionError 'blockLines size: 3' with assertions on, IndexOutOfBoundsException 'Index -1 out of bounds for length 3' with them off) and the IndexWriter closes, because the code that splits a field's last block assumes its look-back window of 2*delta lines starts at line 1 or later, which is false whenever 3*delta > target and the last block has between target-delta+1 and 2*delta lines.
Introduced
b963b7c3dbe (b963b7c3dbecda86c2917ad341caee63b93815ac, LUCENE-8753 'New UniformSplit and SharedTermsUniformSplit PostingsFormats', committed by dsmiley; PR apache/lucene-solr#828 by bruno-roustant, following #633 and absorbing #676; the JIRA issue is now apache/lucene#9798; branch_8x cherry-pick b8a1857b0bd) [read: git blame, git log -L on splitAndWriteBlock and git log -S on the deltaStart and assert lines return only this commit; validateSettings has had the same four checks since it] [exec: 8.2.0 has no uniformsplit package, 8.3.0 fails] (2019-09-06)
First affected version
8.3.0 [exec] (the first release with UniformSplit; 8.2.0 lacks the package, so the repro does not compile there [exec]); main line 9.0.0 [exec]
Affected versions
every release that ships the UniformSplit and SharedTermsUniformSplit formats: 8.3.0-8.11.4, 9.0.0-9.12.3, 10.0.0-10.5.2 and main ea57360912ed [exec: the follow-up's history main() fails 10 of 10 bug cases (4 flushes and 1 merge per format) with 0 control failures on 8.3.0, 8.4.1, 8.5.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 and main, each with -ea and -da; the independent reproduction's JUnit on 10.5.2 and 9.12.3 and its main() on 8.11.4 and 8.3.0; the intended-behaviour check's public-API test on 10.5.2 with -da] [read: the releases in between by code identity, the split methods being unchanged since b963b7c3dbe]; not a regression; not fixed upstream (BlockWriter.java last changed 2021-01-03 on main and branch_10x, reformatting only; no CHANGES entry; no open PR touches BlockWriter or validateSettings) [read 2026-10-26]; executed on aarch64 with JDK 25 only
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that could only read the code (its low-confidence “not real” is procedural), an adjudication that 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; also reached by exhaustive check
Upstream and prior reports
none found (2026-10-26, follow-up prior-report record verdict new: 113 logged query lines: a scripted prior-report search over apache/lucene issues, PRs and commits, the Elasticsearch, OpenSearch and Solr trackers, the dev@, java-user@, issues@ and commits@ lists and the JIRA archive (249 distinct hits, 0 high overlap); 44 GitHub GraphQL issue and PR searches over apache/lucene, apache/lucene-solr, apache/solr, elastic/elasticsearch and opensearch-project/OpenSearch; 7 web searches; the changed-file lists of all 413 open apache/lucene PRs; a local full-history git search; plus the finder's, the independent reproduction's and the intended-behaviour check's manual searches): no issue, PR, commit, review comment, list post or downstream item reports it; the nearest items are the introducing design review (LUCENE-8753 #9798, lucene-solr #633, #676 and #828: no discussion of delta bounds or last-block splitting), LUCENE-9237 (#10277: a block-size benchmark of 26 against 32 with the default delta, outside the window) and the open LUCENE-9607 (#10647, a different test failure); 3 open PRs touch uniformsplit/ (#16851, #14716, #13525) but none touches BlockWriter or validateSettings; not fixed upstream; gaps: GitHub search cannot select closed, unmerged PRs by touched file, and web results were noise
Lucene commit tested
main 645d4fe14a20 (2026-10-28): the minimal test fails with assertions on and off, through gradle in a scratch worktree and through a standalone runner; with the recommended fix it passes, together with the uniformsplit tests and the whole codecs test module. Also fails on the 10.6.0 and 10.5.2 release jars; the version sweep covers 8.2.0 (no formats) to 10.6.0. Upstream main 2f424fd428da (2026-10-30) has the same code.

Lucene and upstream sources

Finding record
  • a Lucene main checkout lucene/codecs/src/java/org/apache/lucene/codecs/uniformsplit/BlockWriter.java:129-140 (addLine: splitAndWriteBlock at exactly target+delta lines), :147-153 (finishLastBlock: loops splitAndWriteBlock on the residual), :161-191 (splitAndWriteBlock: :165 single-block case for numLines <= target-delta, :170 'int deltaStart = numLines - deltaNumLines * 2;', :171 'assert deltaStart >= 1 : "blockLines size: " + numLines;', :175-182 MDP loop with '<=' at :178, :184 subList(0, minMdpEndIndex)), :278-282 (addBlockKey: blockLines.get(0))
  • a Lucene main checkout lucene/codecs/src/java/org/apache/lucene/codecs/uniformsplit/UniformSplitTermsWriter.java:70-71 ('A good sweet spot for the target block size is 32 with delta of 3 (10%) (default values). This can be tuned in the constructor.'), :165-172 (constructor parameter contract), :188 (calls validateSettings), :238-260 (validateSettings: target > 0, delta >= 0, delta < target, target + delta <= MAX_NUM_BLOCK_LINES)
  • a Lucene main checkout lucene/codecs/src/java/org/apache/lucene/codecs/uniformsplit/UniformSplitPostingsFormat.java:59-66 (no-arg constructor, used by SPI: the defaults), :69-76 (javadoc: delta 'Must be greater than or equal to 0 and strictly less than targetNumBlockLines'; 'The block size will be targetNumBlockLines+-deltaNumLines'), :85-105 (public 5-argument constructor; validateSettings at :105); sharedterms/STBlockWriter.java:87-89 (finishLastBlock delegates to super)
The finding in 30 seconds
  • lucene/codecs/src/java/org/apache/lucene/codecs/uniformsplit/BlockWriter.java:129-140 (at 645d4fe14a20) (:137-139), :147-153, :161-191 (:165-169, :170, :171, :175-182, :184), :278-282; :184-185 (subList, the end-of-field block sizes in not_intended)
  • lucene/codecs/src/java/org/apache/lucene/codecs/uniformsplit/UniformSplitTermsWriter.java:48-54, (at 645d4fe14a20) :102-111, :238-260, :333-334
  • lucene/codecs/src/java/org/apache/lucene/codecs/uniformsplit/UniformSplitPostingsFormat.java:58-66, (at 645d4fe14a20) :68-76, :85-105
  • lucene/codecs/src/java/org/apache/lucene/codecs/uniformsplit/sharedterms/STBlockWriter.java:86-90; (at 645d4fe14a20) sharedterms/STUniformSplitPostingsFormat.java:69-86
  • lucene/core/src/java/org/apache/lucene/index/DocumentsWriterPerThread.java:585-589, (at 645d4fe14a20) :594-605; IndexWriter.java:453-454, :3879-3883, :5109-5113, :6068-6106
  • lucene/codecs/src/test/org/apache/lucene/codecs/uniformsplit/TestBlockWriter.java:45 (at 645d4fe14a20)
Who is affected
  • lucene/codecs/src/java/org/apache/lucene/codecs/uniformsplit/UniformSplitPostingsFormat.java:36, (at 645d4fe14a20) :58-66; UniformSplitTermsWriter.java:102-108
  • lucene/codecs/src/java/org/apache/lucene/codecs/uniformsplit/UniformSplitPostingsFormat.java:97-105; (at 645d4fe14a20) sharedterms/STUniformSplitPostingsFormat.java:78-86 (protected constructors)
Why it looks unintended
  • lucene/codecs/src/java/org/apache/lucene/codecs/uniformsplit/UniformSplitPostingsFormat.java:36, (at 645d4fe14a20) :73-76; UniformSplitTermsWriter.java:48-50, :70-71, :169-172, :238-260
  • lucene/codecs/src/test/org/apache/lucene/codecs/uniformsplit/TestUniformSplitPostingFormat.java:43-48; (at 645d4fe14a20) sharedterms/TestSTUniformSplitPostingFormat.java:32-37; TestBlockWriter.java:45
  • lucene/core/src/java/org/apache/lucene/codecs/lucene103/blocktree/Lucene103BlockTreeTermsWriter.java:343-361 (at 645d4fe14a20) (:354-360)
Suggested fix
  • lucene/core/src/java/org/apache/lucene/codecs/lucene103/blocktree/Lucene103BlockTreeTermsWriter.java:354-360 (at 645d4fe14a20)

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

Repro files

About the diagrams

Made in parallel with this doc from the code at 645d4fe14a20, the minimal repro and the fix diff; re-rendered after the diagram check (section 4's wording, two more probe cases and the 10.6.0 line on the summary card).

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.