All findings · Lucene finding · LFV-011 · index-sort-orphan-child-merge-failure

With index sorting and a parent field, deleting only a block's parent makes every later merge fail

With index sorting and a parent field, deleting only a block's parent can leave a segment that ends with a child and no parent after it. Every later sorted merge of that segment then throws and closes the IndexWriter, losing uncommitted documents, in every release since 9.10.0 and on main.

Summary

Priority
High, rank 7With opt-in index sorting, a parent field and document blocks, the common delete or update by a term only the parent carries leaves an orphan child that makes every later merge of the segment throw and close the writer, so a plausible usage pattern since 9.10 leaves an index that can never merge again, which keeps it in High although the feature combination is opt-in and the failure is loud.
Impact
index unusable
Since
9.10.0 (#12829, 2024)introduced 2024-01-11 · about 2 years
Affected
9.10.0 to 10.5.2, 10.6.0 (by reading) and main
Status
documented
Found by
model-based random testing (exception oracle)

AI-assisted page: this finding was first signalled by a testing baseline (randomized, fuzz, property-based or stress testing, or static analysis) 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

With an index sort and a parent field, a merge sorts each document by the value of the next parent after it. Delete only a block's parent, and a merge can leave a child as the last document of a segment, with no parent after it.

Summary card titled "With index sorting and a parent field, deleting only a block's parent makes every later merge fail" (Apache Lucene IndexWriter with setIndexSort and setParentField("parent"); doc(id, v) has a long sort field "sort" = v). Steps: 1, addDocuments c1 (50) then p1 (10), commit: one block, sorted by p1's value 10; 2, deleteDocuments id:p1: c1 stays live; 3, add x (5), commit, forceMerge(1): merge 1 works and drops p1, segment _2 = [x, c1], c1 is last with no parent after it; 4, add y (7), commit, add z (8), forceMerge(1): z is not committed; parents.nextSetBit(1) returns NO_MORE_DOCS (2147483647), used as c1's doc id. The segment _2 drawn as x and c1, with an arrow from c1 to 2147483647; reading doc 2147483647 of a 2-doc segment gives EOFException: read past EOF (pos=268435455). Expected: later merges work, the writer stays open. Actual: every later merge of _2 throws; tragic event, the writer closes, uncommitted z is lost. Notes: a new IndexWriter and merges the merge policy picks fail the same way; the same with updateDocuments(term, block) when only the parent has the term; control: deleting the whole block works. Affected: Lucene 9.10.0 to 10.5.2, and main.
One block [c1, p1] under an index sort: delete only p1, merge once (the segment ends with c1), merge again: the parent lookup returns 2147483647 and the merge reads past the end. Select the still to open it at full size. Generated with AI, checked against the code.
  1. Sort the index on a long field and set a parent field. Add the block [c1 sort=50, p1 sort=10] (parent last), delete only p1, add x sort=5, commit.
  2. forceMerge(1) works: c1 still sorts by its deleted parent's 10, after x's 5, and p1 is dropped. The segment is [x, c1]: it ends with a child that has no parent after it.
  3. Add y (7) and commit, add z (8) without committing, and merge again. You expect the merge to work and z to survive.
  4. Instead the merge looks up c1's parent with nextSetBit, gets NO_MORE_DOCS (2147483647), uses it as a doc id and reads past the end of the sort values: EOFException: read past EOF (pos=268435455). The writer closes and z is lost.
  5. It stays: a new writer with the same index sort and merges the merge policy picks fail the same way, and CheckIndex reports the index clean.

Who is affected

Users of index sorting with a parent field (required for indices created with 10.0 or later that sort blocks) who delete or update a block by a term only the parent carries (a common pattern). Default codec; four documents. Every later sorted merge of that segment then fails, in this or a new writer, until all its documents are deleted.

Mechanism, terms and numbers792 words

With an index sort and IndexWriterConfig#setParentField, a merge compares each document by the sort value of the next parent at or after it (parents.nextSetBit(docID)). If an application deletes only the parent of a block, with deleteDocuments or with updateDocuments by a term that only the parent carries, the next merge sorts the live child by its deleted parent's value and drops the parent. When that value sorts last, the merged segment ends with a child that has no parent after it. From then on every later sorted merge that includes the segment gets NO_MORE_DOCS from nextSetBit, uses it as a doc id and reads past the end of the sort field's doc values; IndexWriter treats the exception as tragic, closes and loses uncommitted documents. A new writer with the same index sort fails the same way, natural merges fail too, SortingCodecReader.wrap hits the same lookup, and CheckIndex reports the index clean. Every release from 9.10.0 (#12829) to 10.5.2, 10.6.0 (by reading) and main; four documents suffice.

Terms

  • Document block: documents added together with IndexWriter.addDocuments (or updateDocuments), parent last.
  • Parent field (IndexWriterConfig#setParentField, 9.10+; required for an index created with 10.0 or later that uses index sorting with blocks, DocumentsWriterPerThread.java:272-280): IndexWriter marks the last document of each block, and every single document, as a parent, "In order to maintain the API guarantee that the document order of a block is not altered by the IndexWriter" (IndexWriterConfig.java:579-590).
  • Sorted merge: a merge of index-sorted segments is a k-way merge (MultiSorter.sort). With blocks, each document is compared by the sort value of its block's parent, found with parents.nextSetBit(docID).

The bug

  1. MultiSorter.sort builds the parent bitset from the parent doc values over all documents, live or deleted (MultiSorter.java:52-64), and visits every document in the initial pass (:109) and the merge loop (:131); only the final doc maps drop deleted documents (:141-153).
  2. Merge 1 creates the orphan. c1 is compared by nextSetBit(0) = 1, its deleted parent p1 (value 10), so x (5) goes first and p1 is dropped: the merged segment is [x(P), c1]. It keeps hasBlocks=true (IndexWriter.java:5313-5319).
  3. Merge 2 uses the sentinel as a doc id. final int effectiveDocID = parents == null ? docID : parents.nextSetBit(docID); (MultiSorter.java:166) is passed to setTopValue (:168). For c1 there is no set bit at or after it, so nextSetBit returns NO_MORE_DOCS = 2147483647, as its javadoc says (BitSet.java:89-93).
  4. The LONG sorter calls values.advanceExact(docID) and values.longValue() (IndexSorter.java:296-298), although advanceExact needs "a valid doc ID, ie. >= 0 and < maxDoc" (DocValuesIterator.java:25-28). The 1-bit-per-value reader reads byte 2147483647 >>> 3 = 268435455: EOFException: read past EOF (pos=268435455), wrapped in a RuntimeException.
  5. handleMergeException ignores only an internal MergePolicy.MergeAbortedException (IndexWriter.java:5043-5053) and rethrows the rest; this RuntimeException reaches tragicEvent(t, "merge") (:5109-5113), which rolls back and closes the writer (:6098-6104): uncommitted changes are gone.
  6. The committed segment still ends with c1, so every later sorted merge that includes it, in this writer or a new one with the same index sort, repeats steps 3-5. Exceptions: a segment whose sort field is stored as a constant (a one-document orphan segment) merges fine, and a writer without the index sort merges the index but gives up the index sort for good (see Recovery under Who is affected).

Sorter.sort(LeafReader), used by SortingCodecReader.wrap, has the same lookup (Sorter.java:191-199): ArrayIndexOutOfBoundsException: Index 2147483647 out of bounds for length 2.

The numbers

stepcallsegments afterwards (printed by the diagram probe)
1addDocuments([c1 sort=50, p1 sort=10]), commit[c1, p1(P)]
2deleteDocuments(id:p1), addDocument(x sort=5), commit[c1, p1(P, deleted)] [x(P)]
3forceMerge(1): works[x(P), c1]: the last document is a child
4addDocument(y sort=7), commit; addDocument(z sort=8), not committed[x(P), c1] [y(P)] + z
5forceMerge(1)throws; the writer is closed; z is lost

The diagram probe runs this sequence with the repro's configuration and prints every committed segment; the JUnit asserts only the outcome and stops at the throw. The probe's committed index after the failure holds x, c1 and y (numDocs=3), and the study matrix logs the uncommitted document as lost.

The exception type depends on the setup: RuntimeException(EOFException) with the default codec on a file-system directory, IndexOutOfBoundsException on ByteBuffersDirectory, EOFException for a BinarySortField, AssertionError with the test framework's Asserting codec. LONG, INT, DOUBLE, STRING and binary sort fields all fail.

When the orphan does not end the merged segment, nothing throws: it silently joins the following block ([y(P) c1 x(P)]). That is the search-semantics hazard already discussed upstream, not this crash.

Exposure and likelihood469 words
  • Who is exposed. Applications that use index sorting, a parent field and document blocks, and delete or update a block by a term or query that matches the parent but not every child, for example an id stored only on the parent. The parent field is optional in 9.10-9.12 and required for an index created with 10.0 or later that uses index sorting with blocks (the check is indexMajorVersionCreated >= 10), so every such index has it; an index created before 10.0 may keep sorting blocks without one, and then MultiSorter does not reach this lookup. softUpdateDocument on the parent triggers it too.
  • Downstream (read, not run). Elasticsearch (#105738, implemented by #110251, 2024) and OpenSearch (#18536, 2025) set the Lucene parent field for index sorting with nested fields. Whether their own delete and update paths can produce a parent-only delete was not checked; neither tracker has a report of this crash.
  • Default or opt-in. Index sorting, the parent field and blocks are opt-in; the codec and the directory are the defaults. The repro also sets LogDocMergePolicy (merge factor 1000) and SerialMergeScheduler, so it merges only on forceMerge and in the test thread, and the natural-merge test uses LogDocMergePolicy(mergeFactor=2). With the default TieredMergePolicy and ConcurrentMergeScheduler the same steps fail the same way and the writer closes from the merge thread, so isOpen() can stay true for a moment after forceMerge throws.
  • Scale. None: four documents (c1, p1, x, y). Deterministic.
  • What it takes. The merge must place the orphan (or orphans) at the end of the merged segment's live documents, with no parent after them. With one ascending sort field, as here, the deleted parent's value sorts after every other live document's key; in general it depends on the whole sort (fields, direction, missing values, ties, the merge's tie-breaks) and on which documents are deleted. Otherwise the orphan is silently re-parented and nothing throws. A segment holding a single orphan and nothing else does not crash, by luck: its sort field is stored as a constant.
  • What you see. Loud: an unchecked exception from a merge, a closed writer, uncommitted documents lost; with ConcurrentMergeScheduler the background merge fails and the next addDocument throws AlreadyClosedException. Then the same in every new writer with the same index sort. addIndexes(CodecReader) of the index into another sorted writer fails too. Reads keep working and CheckIndex (default codec) reports no problem, which makes it hard to diagnose.
  • Recovery. The study found one: delete every remaining document of the affected segment, so it is dropped without a merge. A writer opened with the same parent field but no index sort also merges the index, but the merged segment has no index sort, and from then on a writer with the index sort cannot open the index (cannot change previous indexSort=null).

Minimal repro

The test shows that with an index sort and setParentField, deleting only the parent of a block [c1, p1] leaves the merged segment [x, c1] ending with a parentless child, and the next forceMerge(1) throws.

TestIndexSortDeletedParentMerge.java (JUnit test)63 lines
/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.apache.lucene.index;

import java.util.List;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.NumericDocValuesField;
import org.apache.lucene.document.StringField;
import org.apache.lucene.search.Sort;
import org.apache.lucene.search.SortField;
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 TestIndexSortDeletedParentMerge extends LuceneTestCase {

  private static Document doc(String id, long sortValue) {
    Document doc = new Document();
    doc.add(new StringField("id", id, Field.Store.NO));
    doc.add(new NumericDocValuesField("sort", sortValue));
    return doc;
  }

  public void testMergeAfterDeletingBlockParent() throws Exception {
    IndexWriterConfig iwc =
        new IndexWriterConfig()
            .setCodec(TestUtil.getDefaultCodec())
            .setIndexSort(new Sort(new SortField("sort", SortField.Type.LONG)))
            .setParentField("parent")
            .setMergeScheduler(new SerialMergeScheduler());
    try (Directory dir = new ByteBuffersDirectory();
        IndexWriter w = new IndexWriter(dir, iwc)) {
      w.addDocuments(List.of(doc("c1", 50), doc("p1", 10)));
      w.commit();
      w.deleteDocuments(new Term("id", "p1"));
      w.addDocument(doc("x", 5));
      w.forceMerge(1);
      w.addDocument(doc("y", 7));
      // expected: merging [x, c1] (c1 lost its parent) works; actual: IndexOutOfBoundsException
      try {
        w.forceMerge(1);
      } catch (Exception e) {
        throw new AssertionError("expected the second forceMerge(1) to succeed, got " + e, e);
      }
    }
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-011/, which holds src/)
# Lucene checkout at the tested commit, built with: ./gradlew --no-daemon assemble   (JDK 25)
LUCENE=/path/to/lucene; V=12.0.0-SNAPSHOT
CP=$LUCENE/lucene/core/build/libs/lucene-core-$V.jar
CP=$CP:$LUCENE/lucene/codecs/build/libs/lucene-codecs-$V.jar
CP=$CP:$LUCENE/lucene/test-framework/build/libs/lucene-test-framework-$V.jar
CP=$CP:randomizedtesting-runner-2.9.2.jar:junit-4.13.2.jar:hamcrest-3.0.jar   # test-framework dependencies (Maven Central)
CP=$CP:junit-platform-console-standalone-6.1.3.jar   # JUnit Platform console launcher (Maven Central)

javac -d classes -cp "$CP" $(find src -name '*.java')

# run 1: assertions on, seed DEADBEEF
java -ea -esa --add-modules jdk.incubator.vector --enable-native-access=ALL-UNNAMED -Dtests.seed=DEADBEEF \
     -cp "classes:$CP" org.junit.platform.console.ConsoleLauncher \
     execute --select-class org.apache.lucene.index.TestIndexSortDeletedParentMerge
Expected vs actual
Expected: the second merge succeeds; actual (-ea and -da): IndexOutOfBoundsException from MultiSorter.setComparableValues (MultiSorter.java:166-168) reading the sort value at parents.nextSetBit(docID) = NO_MORE_DOCS.

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

More files, environment, full output44 words

Environment

luceneLucene main e1faeba08bde (2026-11-02)
jdk25.0.3+9-LTS

Observed output

# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
    => java.lang.AssertionError: expected the second forceMerge(1) to succeed, got java.lang.IndexOutOfBoundsException
       org.apache.lucene.index.TestIndexSortDeletedParentMerge.testMergeAfterDeletingBlockParent(TestIndexSortDeletedParentMerge.java:59)
     Caused by: java.lang.IndexOutOfBoundsException
       java.base/java.nio.Buffer$1.apply(Buffer.java:756)
       java.base/java.nio.Buffer$1.apply(Buffer.java:753)
       java.base/jdk.internal.util.Preconditions$4.apply(Preconditions.java:213)
       java.base/jdk.internal.util.Preconditions$4.apply(Preconditions.java:210)
       java.base/jdk.internal.util.Preconditions.outOfBounds(Preconditions.java:98)
       [...]

Why it looks unintended

  • The parent field exists so that blocks "are maintained alongside their parent documents during sort and merge"; the addDocuments javadoc warns only that search features break after block deletes.
  • nextSetBit may return NO_MORE_DOCS; advanceExact needs a doc id below maxDoc.
  • IndexWriter's own merge creates the orphan, then cannot merge it.
The full argument343 words
  • The contracts. IndexWriterConfig#setParentField: the parent marker exists "to maintain the API guarantee that the document order of a block is not altered by the IndexWriter" (IndexWriterConfig.java:579-590). MIGRATE.md:875-880 and CHANGES for #12829 (CHANGES.txt:2945-2949): "Document blocks are maintained alongside their parent documents during sort and merge."
  • The warning is about search. IndexWriter#addDocuments (IndexWriter.java:1499-1504): "Today this is fine, because merging will preserve a block. The order of documents within a segment will be preserved, even when child documents within a block are deleted. Most search features (like result grouping and block joining) require you to mark documents; when these documents are deleted these search features will not work as expected." Nothing says IndexWriter's own merges can fail.
  • The API accepts the trigger. deleteDocuments(Term...) (IndexWriter.java:1855-1864) and updateDocuments(Term, Iterable) (:1593-1605) accept any term; nothing rejects or cascades a parent-only delete.
  • Internal contracts. nextSetBit documents NO_MORE_DOCS (BitSet.java:89-93); advanceExact requires a doc id below maxDoc (DocValuesIterator.java:25-28). MultiSorter passes one to the other unchecked.
  • forceMerge (IndexWriter.java:2213-2214): "If an Exception is hit, for example due to disk full, the index will not be corrupted and no documents will be lost." Disk full is only an example; the promise is general. Here the failed merge closes the writer and loses the uncommitted documents, and it fails again on every retry.
  • Tests. No upstream test deletes only a parent under index sort: TestIndexSorting.testIndexSortWithBlocks (:3287) deletes nothing, and testMixRandomDocumentsWithBlocks (:3370) deletes by an id (:3383) that every document of the block carries, so it always deletes whole blocks.
  • Controls. The same delete without an index sort merges fine (the orphan joins the next block), and the same scenario on 9.9.2, before the parent field, passes.
  • History. In #12711 s1monw asked "would we also delete all children?"; in the #12829 review they noted that a parent-only delete makes adjacent blocks "merge ... magically or erroneous together" and floated cascading deletes. No follow-up, and neither thread mentions a merge exception or a closed writer.
The devil's-advocate check282 words

The strongest case for "intended": deletes that break a block are caller misuse. mikemccand in LUCENE-4068 (2012): "you cannot delete the parent w/o deleting all of its children"; jpountz in #14067 (2024): "Block-joins require deletes to be consistent across parent and child documents. So I wouldn't call it a bug"; rmuir in LUCENE-6512: "this is a solr bug". addDocuments and updateDocuments are @lucene.experimental, and MultiSorter assumes every child has a parent after it. An independent reviewer from another model family (OpenAI GPT-5.6, reading only) rated this argument "moderate".

Why it fails: those statements are about block semantics and block-join search. mikemccand's LUCENE-4068 remark was made while wording the addDocuments javadoc, and the javadoc that came out of it names only search-time consequences ("these search features will not work as expected"). Here IndexWriter itself breaks: it accepts the delete, its own merge puts the orphan last and drops the parent, and from then on it cannot merge a segment it wrote. The result is an unchecked read past the end of doc values, a tragic close that drops unrelated uncommitted documents, and the same on every restart, while CheckIndex calls the index clean. Without an index sort the same delete merges fine. Even under the misuse reading the outcome should be a rejected delete or a clean error, not a writer that can never merge again. "Index files are trusted" (SECURITY.md lines 30-34) does not apply: Lucene wrote this index through public API calls. SortingCodecReader is the weaker case: it is @lucene.experimental, the caller wraps a reader explicitly, here one whose block layout is already broken, and its exception closes no writer and loses no documents. Verdict: not intended, a defect.

Suggested fix

Recommended A: a child with no parent after it sorts as its own block

Treat "no parent after me" as "I am my own parent": a helper Sorter.parentOrSelf returns the document itself when nextSetBit finds none. Used by MultiSorter and Sorter (+13/-2). The tests and 200 closest-suite tests pass. It stops the crashes; an orphan can still silently join the next block.

// Sorter.java
static int parentOrSelf(BitSet parents, int docID) {
  final int parent = parents.nextSetBit(docID);
  return parent == DocIdSetIterator.NO_MORE_DOCS ? docID : parent;
}
    in.compare(parentOrSelf(parents, docID1), parentOrSelf(parents, docID2));

// MultiSorter.java:166
final int effectiveDocID = parents == null ? docID : Sorter.parentOrSelf(parents, docID);
All options and trade-offs4 options ยท 516 words

One fix was built and validated in a throwaway clone at ea57360912ed, at the merge and SortingCodecReader lookups (parent-or-self.diff). The other options come from the study and the review and were not run.

A: a child with no parent after it sorts as its own block (validated by the checks) (recommended)

A package-private helper in Sorter replaces the unchecked lookups: MultiSorter.setComparableValues (merge, MultiSorter.java:166) and the comparator wrapper of Sorter.sort(LeafReader) (SortingCodecReader, Sorter.java:198). +12/-1 lines in Sorter.java, +1/-1 in MultiSorter.java.

// Sorter.java
static int parentOrSelf(BitSet parents, int docID) {
  final int parent = parents.nextSetBit(docID);
  return parent == DocIdSetIterator.NO_MORE_DOCS ? docID : parent;
}
    in.compare(parentOrSelf(parents, docID1), parentOrSelf(parents, docID2));

// MultiSorter.java:166
final int effectiveDocID = parents == null ? docID : Sorter.parentOrSelf(parents, docID);

Trade-offs

  • A crash stop, not a semantics fix. The orphan sorts by its own value. A probe with the fix and one more document, big (99), gives [x(P) y(P) c1 big(P)] after the second merge: c1 now belongs to big's block, the hazard upstream already knew; slow CheckIndex is clean.
  • Several trailing orphans each sort by their own value, so a later merge can place other documents between them. A variant could key every trailing orphan by the segment's last document to keep them together (not run).
  • Both sites. With only the MultiSorter guard, the SortingCodecReader test still fails.
  • Cost. One comparison per document visit in a sorted merge; no benchmark was run.

What the checks validated

  • Stock: this test 3 of 4 and the candidate test 4 of 5 fail through gradle, assertions on and off.
  • Fix: both pass (9 of 9). With TestIndexSorting (62), TestSortingCodecReader (3) and TestBinarySortField (25): 99 tests, 0 failures, assertions off (the flag is in the run command; a passing gradle log does not print it). With every test matching org.apache.lucene.index.*Sort*, TestAddIndexes and TestIndexWriterMerging: 26 classes, 200 tests, 0 failures, 2 skipped (the Test2B* monster tests), assertions on.
  • :lucene:core:tidy leaves the fix and the test unchanged; :lucene:core:check -x test passes (google-java-format, ecjLint, forbiddenApis).
  • Standalone harness with the fix shadowed in: 4 of 4 pass, assertions on and off. On 9.10.0 a one-line guard of the same kind turns the candidate test from 4 of 5 failing to 5 of 5 passing.

B1: cascade a parent delete to its children

When a parent field is configured, a delete that matches a parent also deletes the documents of its block, as s1monw floated in the #12829 review. Not built.

Trade-offs

Fixes the crash and the re-parenting, but changes what deleteDocuments and updateDocuments delete, which needs agreement upstream; the delete path must find each deleted parent's children.

B2: drop orphaned children at merge

A merge treats a live child whose parent is deleted as deleted. Not built.

Trade-offs

No orphans, no crash, but it silently removes documents the application did not delete.

C: detection only

CheckIndex could report a segment with blocks and a parent field whose last document is not a parent (the study's suggestion). Not built.

Trade-offs

Reports the damage instead of preventing it; today CheckIndex calls such an index clean.

How it was found

Model-based random testing of IndexWriter and its readers, with an exception oracle: random operation sequences checked against a reference model hit the merge failure about 16 s into the first run. A verifier then wrote its own minimal test and confirmed it on main and released jars; P, Dafny and OpenJML models pointed at the scenario reached it too.

The method in full192 words

Method: model-based random testing (the 2026-10-22 study's non-formal baseline), not a formal-methods find.

  • The harness drove IndexWriter and NRT readers with random operation sequences (adds, blocks, deletes, updates, commits, merges, reopens, optional index sort), compared every reader against an executable reference model, treated any unexpected exception as a failure, and shrank failing sequences automatically.
  • This root cause first appeared 15.8 s into the first smoke run (17.8 s and 3.1 s into the two seeded base runs).
  • A separate verifier read only the operation sequence, wrote its own minimal test and a 14-test matrix with different values, ran them with 5 seeds, assertions on and off, and on the 9.12.3, 10.1.0, 10.3.1 and 10.5.2 jars, and classed it as a new bug. More verifiers whose P, Dafny and OpenJML models were extended toward this scenario (hinted, not blind) agreed. TLA+ and Alloy models of the study could not express it.
  • 2026-10-26: re-verified on main ea57360912ed, a release sweep of every 9.x and 10.x release, a cross-model refute review (an independent reviewer from another model family, OpenAI GPT-5.6, reading only: real, high), a prior-report search, and this doc's test and gradle-validated fix.

Prior reports, history and checks

Prior reports

No prior report found (2026-10-26): 106 queries over GitHub, commits, downstream trackers, four mailing lists, the JIRA archive and the web, plus the files of all 411 open PRs. Known nearby: block-join query crashes from orphaned children (join module, the maintainers call such deletes misuse) and the #12829 review's remark on parent-only deletes; neither involves merges.

Searches5 searches

No issue, PR, commit, JIRA entry, list thread or downstream report describes a merge failure after a parent-only delete under index sorting, and no open PR changes the lookup. Two things are known: the trigger itself (the #12829 and #12711 reviews treat a parent-only delete as a block-semantics question) and the failure class in the join module (search-time crashes from trailing or orphaned children, where the maintainers' position since 2012 is that inconsistent block deletes are the application's responsibility). Private security reports cannot be searched.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene)2026-10-2625 issue/PR queries and 3 commit queries from the scripted prior-report search (parent field with delete or orphan, setParentField with merge exception, MultiSorter with nextSetBit or NO_MORE_DOCS, "Index 2147483647 out of bounds", document blocks delete parent, index sorting deleted parent, hasBlocks), plus 2 downstream queriesonly the introducing #12829, the design question #12711 and unrelated items
Threads read in full2026-10-26the comments of 23 top candidates by term overlap, and the JIRA issues LUCENE-4068, LUCENE-5520, LUCENE-6512, LUCENE-6589, LUCENE-6660 and LUCENE-7674join-module crashes and wrong results from orphaned children, and design remarks; none involves IndexWriter, index sorting or merges
Open PRs and recent filings2026-10-26the changed files of all 411 open apache/lucene PRs; gh pr list text searches (MultiSorter, Sorter.java, "parent field", setParentField, "document blocks", hasBlocks); the titles of all 491 issues and PRs updated since 2026-09-01#16571, #16847 and #16851 edit MultiSorter but not the lookup; with #16571's MultiSorter.java shadowed the candidate test still fails 4 of 5
Mailing lists, JIRA archive, downstream code, web2026-10-2656 queries over dev@, java-user@, issues@ and commits@; 6 JIRA-archive queries; code searches in Elasticsearch, OpenSearch and Solr; 14 web queriesno report; both Elasticsearch and OpenSearch set the parent field for nested index sorting, with no report of this crash
The study's searches2026-10-22/23about 30 phrasings on GitHub and the dev@ listnothing matching

History

Introduced by #12829 (merged 2024-01-11), which added the parent field and the unguarded lookup; first released in 9.10.0. Every release from 9.10.0 to 10.5.2 and main fail; 9.0.0-9.9.2 have no parent field. Not fixed upstream.

Versions tested7 rows

Introduced by #12829, "Add support for index sorting with document blocks" (s1monw; merged 2024-01-11), as df6bd25ce44 on main (first released in 10.0.0) and the branch_9x backport 0aa88910ca9 (first released in 9.10.0). It added the parent bitset and parents.nextSetBit(docId) to MultiSorter (9.10.0 MultiSorter.java:68) and to Sorter.sort (9.10.0 Sorter.java:220). #16202 ("Add BinarySortField", 10.5.0) moved the lookup into setComparableValues with the same behaviour. MultiSorter.java and Sorter.java are unchanged on main through e1c4184cb983 (2026-10-26); branch_10_6 (10.6.0, released 2026-10-29) has the same line at MultiSorter.java:169 (read).

Versions and branches executed, with results
Version or commitVerdictResult
9.0.0 to 9.9.2 (14 releases) not affectedno parent field (setParentField does not exist; the test does not compile on 9.9.2); the same scenario without it passes
9.10.0 first affectedthe candidate test 4 of 5 fail (assertions on and off); this doc's test 3 of 4
9.11.0 to 9.12.3 (6 releases) affectedthe candidate test 4 of 5 fail; this doc's test 3 of 4 on 9.12.3
10.0.0 to 10.5.2 (12 releases) affectedthe candidate test 4 of 5 fail; this doc's test 3 of 4 on 10.5.2 (assertions on and off)
main ea57360912ed affectedthis doc's test 3 of 4 and the candidate test 4 of 5 fail, through gradle and the harness, assertions on and off
9.10.0 with only the #12829 MultiSorter hunk reverted, or the line guarded commit levelthe candidate test passes 5 of 5 (single-class shadow runs)
main + the fix not affectedboth tests pass; 200 tests of the closest suites pass

Independent checks

The study's verifier and verifiers using P, Dafny and OpenJML models called it a new bug; a re-verification on current main and an OpenAI GPT-5.6 refute review (reading only) say real. Fact checks of this doc and a diagram check found smaller inaccuracies, all fixed here.

Checks and verdicts455 words

Every check ran with seed DEADBEEF, assertions on and off, unless noted.

Each independent check and its verdict
CheckVerdictWhat it found
Finder: model-based random testing (study) exception in the second sorted mergeHit within the first 16 s of the first smoke run; the shrunk sequence became the verifier's starting point.
Study verifier (MBT) new bug, crash/exceptionOwn minimal test (2 of 2 fail) and a 14-test matrix with controls; 5 seeds, assertions on and off; 9.12.3, 10.1.0, 10.3.1, 10.5.2 and main fail, the child-deleted control works.
Study verifiers with P, Dafny and OpenJML models new bugModels extended toward the scenario (hinted), each with its own test; for example the P verifier's tests fail on main and on 9.12.3, 10.0.0, 10.3.2 and 10.5.2.
Public-list re-run on main b83101cdd2 (2026-10-23) reproducesThe study's minimal test: 2 run, 2 failed.
Re-verification on main ea57360912ed realThe candidate test fails 4 of 5 on main, 10.5.2 and 9.10.0, with assertions on and off and 4 seeds; the study's matrix behaves as described (14 of 14); the code is unchanged since the study.
Refute review (OpenAI GPT-5.6) real (high), reading onlyTraced the failure statically (it was not allowed to run Java), found the same root cause and the Sorter sibling, rated the 'intended' argument moderate; a separate run of the candidate test failed 4 of 5.
History, prior reports, security screen 9.10.0 to 10.5.2 and main; new; out of scope66 release runs; 106 prior-art queries; the study's security screen classed it out of scope (public route).
This doc's test and fix 3 of 4 fail on stock, 4 of 4 pass with the fixGradle and harness on main, harness on 9.10.0, 9.12.3 and 10.5.2; the fix passes 200 tests of the closest suites and the core static checks.
Fact check of this doc (re-ran everything) core bug confirmedRe-ran the doc's gradle command, the harness on main, 10.5.2 and 9.10.0, a public-API check in another package and the fix; re-read every cited line, commit, count and quote. Found 7 minor points. Fixed in this version.
Cross-model fact check (OpenAI GPT-5.6, reading only) core bug real; 15 accuracy findingsRead the code, the run logs and raw git output (it could not run Java). Found over-broad wording (every later merge, every 10.x user, everything else default, no wrong results) and missing caveats. Fixed in this version.
Diagram check the summary card's step list fixedChecked every value and citation against the code and re-ran the diagram probe. The summary card named z and segment _2 without the steps that make them; the card now lists the executed sequence, and the diagram was re-rendered.

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 notes606 words

Finding record

LFV-011, snapshot of 2026-11-06

Registry title
With index sorting and a parent field, deleting only a block's parent makes every later merge fail
Area
index / IndexWriter merge
Priority
High, rank 7: With opt-in index sorting, a parent field and document blocks, the common delete or update by a term only the parent carries leaves an orphan child that makes every later merge of the segment throw and close the writer, so a plausible usage pattern since 9.10 leaves an index that can never merge again, which keeps it in High although the feature combination is opt-in and the failure is loud.
Impact class
index unusable
Impact note
Users of opt-in index sorting with IndexWriterConfig.setParentField and document blocks who delete only a parent; released since 9.10.0; the segment then fails every merge.
Summary
You use index sorting with a parent field and document blocks, and delete only the parent document of a block. You expect later merges to work. Instead MultiSorter finds no parent for the orphaned child (nextSetBit returns NO_MORE_DOCS), every later merge of that segment throws (an EOFException with the default codec on a file-system directory, IndexOutOfBoundsException on ByteBuffersDirectory), the writer closes tragically and uncommitted documents are lost.
Introduced
#12829 (df6bd25ce44 on main; backport 0aa88910ca9) introduced the parent field (2024-01-11)
First affected version
9.10.0
Affected versions
9.10.0+ [read]; 9.12.3, 10.1.0, 10.3.1, 10.5.2, 10.6.0 and main [exec]
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed in the earlier formal verification study by independent AI checks and a rerun on Lucene main; also a cross-model review that agreed; not yet triaged by a person.
Found by
non-formal baseline: randomized/MBT; also reached by P, Dafny, KeY/OpenJML
Upstream and prior reports
none found (study searches 2026-10-22; public-list check 2026-10-20..23)
Lucene commit tested
main ea57360912ed (2026-10-26), seed DEADBEEF, assertions on and off, through gradle and through a standalone JUnit harness; this doc's test also on the Maven Central jars of 9.10.0 (assertions on), 9.12.3 (off) and 10.5.2 (on and off); the longer candidate test on every release from 9.10.0 to 10.5.2 and on main (40 runs, all affected; on 9.9.2 it does not compile, because setParentField does not exist) and a no-parent-field variant on 9.0.0-9.9.2 and on 9.10.0 with the #12829 hunk reverted or guarded; the study's own tests on main b83101cdd2 and ea57360912ed; the fix in a scratch worktree at ea57360912ed (the two tests, the closest suites, :lucene:core:tidy and :lucene:core:check -x test), removed afterwards.

Lucene and upstream sources

The finding in 30 seconds
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/MultiSorter.java:52-64, 109, 131, 141-153, 163-170
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/util/BitSet.java:89-93; core/src/java/org/apache/lucene/index/DocValuesIterator.java:25-28
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/IndexSorter.java:103, 296-298; Sorter.java:191-199
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:5109-5113, 5313-5319, 6098-6104; IndexWriterConfig.java:579-590
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/DocumentsWriterPerThread.java:272-280; IndexWriter.java:5043-5053
Who is affected
  • Lucene main ea57360912ed: lucene/CHANGES.txt:2945-2949 (9.10.0) and 2278-2281 (10.0.0) for #12829
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/DocumentsWriterPerThread.java:272-280; lucene/MIGRATE.md:875-880
Why it looks unintended
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/IndexWriterConfig.java:579-590; IndexWriter.java:1499-1504, 1593-1605, 1855-1864, 2213-2214
  • Lucene main ea57360912ed: lucene/MIGRATE.md:875-880; CHANGES.txt:2945-2949
  • Lucene main ea57360912ed: lucene/core/src/test/org/apache/lucene/index/TestIndexSorting.java:3287, 3370, 3383, 3389-3419
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/SortingCodecReader.java:54-56

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

Repro files

About the diagrams

The summary card (bug.png) shows the calls of this doc's first test in four steps, the segment as the second merge reads it, the parent lookup off its end, expected against actual, and the affected versions. The values are those of this doc's first test.

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.