All findings · Lucene finding · LFV-1062 · iw-from-leaf-sorted-reader-commit-pairs-segments-by-position

An IndexWriter opened from a leaf-sorted reader's commit applies deletes to the wrong segment

Open an IndexWriter with setIndexCommit(reader.getIndexCommit()) on a reader that has a leaf sorter, and the writer pairs each segment with whatever reader sits at the same position in the sorted list. deleteDocuments(id:a) then deletes b, the commit saves it and CheckIndex stays clean, in every release since 8.9.0 (eight executed, the rest read from the code).

Summary

Priority
Medium, rank 78This is silent, durable wrong-document deletion in core IndexWriter that CheckIndex does not catch, but like its sibling LFV-593 it needs the Expert setIndexCommit-from-a-reader path plus a leaf sorter and equal-maxDoc reordered segments.
Impact
data corruption
Since
8.9.0 (2021, LUCENE-9507)introduced 2021-03-26 · about 5 years
Affected
8.9.0 to 10.6.0, and main
Status
documented
Found by
a planning pass with public-API probes; then a Z3 model and a bounded-exhaustive test

AI-assisted page: this finding was first signalled by code reading or testing 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

Two features meet here: a leaf sorter, which reorders a reader's segments for faster sorted search, and opening a writer from that reader's commit. The writer lines its segments up with the reader's leaves by position.

Background: segments, leaves and a writer opened from a reader

  1. An index is a list of segments in creation order (_0, _1, ...), recorded in the commit's SegmentInfos. A DirectoryReader opens one leaf reader per segment.
  2. A leaf sorter (opt-in, for example newest segment first for time-based data) reorders the reader's leaves. The reader's SegmentInfos stays in segment order.
  3. IndexWriterConfig#setIndexCommit(reader.getIndexCommit()) (Expert) opens a writer at that reader's point in time and reuses the reader's open segment readers, so in-memory deletes of a near-real-time reader carry over.
Diagram of the bug: segments _0 (id:a) and _1 (id:b); a reader whose leaf sorter reverses segment order (here the same as newest first) lists its leaves as [_1, _0] while the writer's segment list stays [_0, _1]; the writer opened from the reader's commit pairs them by position, so segment _1 is paired with the reader holding a; deleteDocuments(id:a) is saved into _1 and deletes b; after the commit id:a has 1 hit and id:b has 0, and CheckIndex is clean; pairing by segment name fixes it.
Two one-document segments, the reader's sorted leaves and the writer's segment list side by side: zipped by position, _1's slot holds a, so the delete of id:a removes b. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Commit two one-document segments: _0 holds id:a, _1 holds id:b. Open DirectoryReader.open(dir, sorter) with a sorter that puts the newer segment first, so its leaves are [_1, _0].
  2. Open an IndexWriter with setIndexCommit(reader.getIndexCommit()), the documented way to start a writer from a reader, then call deleteDocuments(new Term("id", "a")) and commit().
  3. You expect id:a 0 hits and id:b 1 hit, which is what you get without the sorter, or with the same commit taken from listCommits.
  4. You get id:a 1 hit and id:b 0. The writer's segment list is [_0, _1] and it was zipped with the leaves [_1, _0] by position, so _1's slot held a's data and the delete was saved into _1, which removed b.
  5. No error, and CheckIndex is clean. If the swapped segments differ in size, the writer fails to open instead: IllegalArgumentException: numDocs=2 but maxDoc=1.

Who is affected

Applications that combine two opt-in options: a leaf sorter (DirectoryReader.open(dir, sorter), the commit overload, or IndexWriterConfig#setLeafSorter) and a writer opened with the Expert setIndexCommit(reader.getIndexCommit()). Silent when the swapped segments have equal maxDoc (and no committed deletions), which two one-document segments already give; with unequal sizes the writer fails to open. Every release since 8.9.0.

Mechanism, terms and numbers786 words

When an IndexWriter is opened from a reader's commit (IndexWriterConfig#setIndexCommit(reader.getIndexCommit())), it pre-enrolls the reader's segment readers and pairs them with its own segment list by position. A leaf sorter reorders the reader's leaves but not the segment list, so each segment gets another segment's reader. Deletes, updates and merges then act on the wrong documents and the next commit saves that, with CheckIndex clean. In the repro, deleteDocuments(id:a) deletes b and keeps a. Every release from 8.9.0, the first with a leaf sorter, through 10.6.0 and main is affected; pairing by segment name fixes it.

The mechanism (main 645d4fe14a20, lucene/core/src/java/org/apache/lucene/index/)

  1. IndexWriter.java:982-989: the writer takes config.getIndexCommit(). A commit from DirectoryReader#getIndexCommit is a ReaderCommit (StandardDirectoryReader.java:571-576) whose package-private getReader() returns the reader itself (:644-646). A commit from listCommits is a ReaderCommit built with a null reader (DirectoryReader.java:456, :481), so the same getReader() returns null; that path never pre-enrolls anything and is not affected.
  2. IndexWriter.java:1021-1050: in the reader branch ("Init from an existing already opened NRT or non-NRT reader", :1030), the writer clones reader.segmentInfos, which is in segment order [_0, _1] (:1050), and hands the reader to new ReaderPool(...) (:1149).
  3. BaseCompositeReader.java:75-76: Arrays.sort(subReaders, subReadersSorter). The same reader's leaves are in sorter order [_1, _0].
  4. ReaderPool.java:91-115 pre-enrolls the reader's segment readers "so any in-memory NRT live docs are correctly carried over, and so NRT readers pulled from this IW share the same segment reader". It pairs them by position. The only check that the two lists match is a size assert; the per-pair checks are in step 5:
List<LeafReaderContext> leaves = reader.leaves();          // [_1, _0]
assert segmentInfos.size() == leaves.size();               // ReaderPool.java:95
for (int i = 0; i < leaves.size(); i++) {                  // :96
  SegmentReader segReader = (SegmentReader) leaves.get(i).reader();
  SegmentReader newReader = new SegmentReader(segmentInfos.info(i), segReader, ...);  // :99-106
  readerMap.put(newReader.getOriginalSegmentInfo(), new ReadersAndUpdates(...));

So _0's pool entry gets _1's reader (document b) and _1's entry gets _0's reader (document a).

  1. SegmentReader.java:128-152: the copy constructor takes its identity from si and its data from sr (this.core = sr.core, :151). It rejects only numDocs > maxDoc (:136-138) and a live-docs length mismatch (:139-142). An equal-size swap passes silently; an unequal one throws. Within a cycle of the permutation, unequal sizes always put a bigger reader on a smaller segment somewhere, so a swap passes these two guards exactly when the moved segments have equal maxDoc (a Z3 model of the two guards for 2 to 8 segments agrees). Equal sizes are needed for the silent case but not always enough: with a committed deletion in a swapped segment, the next commit throws (below).
  2. deleteDocuments(id:a) is resolved against the pooled readers. Under _0 it finds nothing (that reader holds b); under _1 it finds doc 0 (that reader holds a) and marks doc 0 of _1 deleted. That delete is in memory, but it makes _1, which holds b on disk, fully deleted, so the commit drops _1 (IndexWriter.java:6750-6756): the new commit lists only _0, with a, no live-docs file is written, and CheckIndex is clean.

Other effects of the same root (executed on main, -ea and -da; not needed for the issue)

  • updateDocument(id:a) deletes b and leaves two copies of a; updateNumericDocValue(id:a) sets b's value; softUpdateDocument(id:a) soft-deletes b.
  • forceMerge(2) of three one-document segments a, b, c commits [a, a, b] (LogDocMergePolicy, merge factor 2, minMergeDocs 1): one document lost, one duplicated, CheckIndex clean. With the default minMergeDocs the merge takes all three segments and nothing is lost.
  • From a sorted near-real-time reader that carries one buffered delete, a bare commit() revives the deleted document and deletes a live one, while the new writer's own NRT reader still shows the right documents.
  • With equal sizes and a committed deletion in one of the swapped segments, the next commit throws NullPointerException (-da) or AssertionError (-ea) in PendingDeletes.writeLiveDocs. (Separately, PendingDeletes.java:41-44 counts the other segment's deletions as pending deletes of this one.)
  • An NRT reader pulled from the new writer shows the right documents, but each leaf carries the other segment's name (_0 holds b, _1 holds a). Through that reader, tryDeleteDocument on a's doc id deletes b, and tryUpdateDocValue on it sets b's value: both resolve the segment from the leaf's own segment info (tryModifyDocument, IndexWriter.java:1791-1812), which here is the wrong name. Through the original sorted reader they return -1 and change nothing.
  • Not affected: forceMerge(1) and a commit from listCommits.
Exposure and likelihood310 words
  • Who is hit. Applications that use a leaf sorter, through DirectoryReader.open(Directory, Comparator), the commit overload (DirectoryReader.open(IndexCommit, Comparator) in 8.10.x-8.11.x, open(IndexCommit, int, Comparator) from 9.0.0) or IndexWriterConfig#setLeafSorter for near-real-time readers (the javadoc suggests it for time-based indices: "usually a descending sort on timestamp"), and that open a writer with setIndexCommit(reader.getIndexCommit()), for example to roll back to a reader's point in time or to hand a near-real-time reader over to a new writer. The documented NRT hand-off, combined with the documented setLeafSorter option on both writers, fails the same way: id:a=1 id:b=0 instead of id:a=0 id:b=1. How often applications open a writer from the commit of the same leaf-sorted reader is unknown.
  • Silent and durable. No exception; the commit persists the wrong deletion; CheckIndex reports the index clean, because it is self-consistent, just wrong.
  • No scale needed. Two one-document segments. The test configures no codec, directory or merge policy (the test framework randomizes codec and directory; it failed on every seed tried), and a main() probe with the default codec gives the same result; assertions on and off.
  • Loud otherwise. When the moved segments differ in size, the writer constructor throws IllegalArgumentException: numDocs=N but maxDoc=M (or maxDoc=M but liveDocs.size()=N), so the documented path cannot open that index at all.
  • Upstream tests did not see it. TestIndexWriterFromReader never uses a leaf sorter, and TestIndexWriterReader.testIndexReaderWriterWithLeafSorter never opens a writer from a reader's commit. With a newest-first sorter injected into every config and reader open, TestIndexWriterFromReader.testRandom fails on 3 of 3 seeds (numDocs=N but maxDoc=1) and passes with the fix.
  • How likely. Rare: both options are opt-in, the silent case needs equal sizes, the Elasticsearch, OpenSearch and Solr call sites of setIndexCommit read for two related bugs pass commits that carry no reader (code reading, not run), and no report was found in the five years since 8.9.0.

Minimal repro

One JUnit test in org.apache.lucene.index, public API only, default IndexWriterConfig: two one-document commits, a reader whose sorter reverses segment order, a writer opened from that reader's commit, one delete and a commit. Its steps are deterministic (the test framework still randomizes codec and directory); it failed on every seed tried, with assertions on and off.

TestIndexWriterFromLeafSortedReader.java (one test)57 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.Comparator;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.StringField;
import org.apache.lucene.search.IndexSearcher;
import org.apache.lucene.search.TermQuery;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestIndexWriterFromLeafSortedReader extends LuceneTestCase {

  public void testDeleteThroughWriterOpenedFromLeafSortedReaderCommit() throws Exception {
    try (Directory dir = newDirectory()) {
      try (IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
        for (String id : new String[] {"a", "b"}) {
          Document doc = new Document();
          doc.add(new StringField("id", id, Field.Store.NO));
          w.addDocument(doc);
          w.commit();
        }
      }
      Comparator<LeafReader> reverseSegmentOrder =
          Comparator.comparing((LeafReader r) -> ((SegmentReader) r).getSegmentName()).reversed();
      try (DirectoryReader reader = DirectoryReader.open(dir, reverseSegmentOrder);
          IndexWriter w =
              new IndexWriter(
                  dir, new IndexWriterConfig().setIndexCommit(reader.getIndexCommit()))) {
        w.deleteDocuments(new Term("id", "a"));
        w.commit();
      }
      try (DirectoryReader reader = DirectoryReader.open(dir)) {
        IndexSearcher searcher = new IndexSearcher(reader);
        // expected: id:a deleted and id:b kept; actual: id:b is deleted and id:a survives
        assertEquals(0, searcher.count(new TermQuery(new Term("id", "a"))));
        assertEquals(1, searcher.count(new TermQuery(new Term("id", "b"))));
      }
    }
  }
}
Command
./gradlew :lucene:core:test --tests "org.apache.lucene.index.TestIndexWriterFromLeafSortedReader" -Ptests.seed=DEADBEEF -Ptests.asserts=true
# assertions off: -Ptests.asserts=false (same failure)
Expected
After the commit id:a has 0 hits and id:b has 1 hit.
Observed
id:a has 1 hit and id:b has 0: AssertionError: expected:<0> but was:<1> at the first assertion, assertions on and off.

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

More files, environment, full output226 words

Copy the file into lucene/core/src/test/org/apache/lucene/index/ and run the command. The test sits in org.apache.lucene.index like TestIndexWriterFromReader but uses nothing package-private. The reversed segment-name comparator is the smallest deterministic leaf sorter; on these two segments it gives the same order as a newest-first timestamp sorter (an earlier version of the test used one, with a ts doc value, and fails the same way). newDirectory() may wrap the directory in a MockDirectoryWrapper, whose close runs CheckIndex; it passes.

This is the no-noise gate's minimal repro with one line wrapped by google-java-format, so that :lucene:core:check passes with it; nothing else differs. Pass -Ptests.asserts explicitly, because by default the build picks it from the seed. The test is also the suggested regression test as it stands.

Environment

lucenemain 645d4fe14a20 (gradle); the same failure on the 8.9.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 and 10.6.0 jars
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux aarch64
Command (environment)
./gradlew :lucene:core:test --tests "org.apache.lucene.index.TestIndexWriterFromLeafSortedReader" -Ptests.seed=DEADBEEF -Ptests.asserts=true

Observed output

# gradle, main 645d4fe14a20, seed DEADBEEF, -Ptests.asserts=true (the same with false)
TestIndexWriterFromLeafSortedReader > testDeleteThroughWriterOpenedFromLeafSortedReaderCommit FAILED
    java.lang.AssertionError: expected:<0> but was:<1>
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:275702B30527020B]:0)
        ... (JUnit Assert frames)
        at org.apache.lucene.index.TestIndexWriterFromLeafSortedReader.testDeleteThroughWriterOpenedFromLeafSortedReaderCommit(TestIndexWriterFromLeafSortedReader.java:52)
:lucene:core:test (FAILURE): 1 test, 1 failure

# with the recommended fix, -Ptests.asserts=true:
:lucene:core:test (SUCCESS): 1 test
BUILD SUCCESSFUL

# with the fix, the test plus TestIndexWriterFromReader and TestReaderPool, -Ptests.asserts=true and false:
:lucene:core:test (SUCCESS): 19 tests
BUILD SUCCESSFUL

Why it looks unintended

  • deleteDocuments(Term...) "Deletes the document(s) containing any of the terms"; here it deletes the one without the term.
  • setIndexCommit documents opening a writer from a reader's getIndexCommit() and excludes no reader; a listCommits commit deletes correctly.
  • The positional loop predates the 2021 leaf sorter, which never updated it; reader reopen already pairs segments by name.
The full argument276 words
  • Contract. IndexWriter.java:1856: "Deletes the document(s) containing any of the terms."
  • The entry point names this flow. IndexWriterConfig.java:235-237: "Expert: allows to open a certain commit point. The default is null which opens the latest commit point. This can also be used to open IndexWriter from a near-real-time reader, if you pass the reader's DirectoryReader#getIndexCommit." The LUCENE-6524 CHANGES entry: "IndexWriter can now be initialized from an already open near-real-time or non-NRT reader." The leaf sorter is documented only as a search-order option (DirectoryReader.java:76-90; IndexWriterConfig.java:518-520: "A DirectoryReader opened from a IndexWriter with this configuration will have its leaf readers sorted with the provided leaf sorter."). No javadoc, CHANGES or MIGRATE entry, code comment or test restricts the combination.
  • History. The positional loop came with LUCENE-6524 (2d0ac9bb7f2, 2015, #7582), when a reader's leaves always followed segment order; the size assert shows the assumption. LUCENE-8260 (2018) moved it into ReaderPool unchanged. LUCENE-9507 (48715fe898b, 2021, #10546 / #32) made leaves sortable and changed neither ReaderPool.java nor IndexWriter.java.
  • Sibling code pairs by identity. Reader reopen maps old readers by segment name (StandardDirectoryReader.java:249-260), and tryDeleteDocument / tryUpdateDocValue resolve the segment from the leaf reader's own segment info (tryModifyDocument, IndexWriter.java:1791-1812).
  • Why tests never saw it. In the first review of LUCENE-9507 (apache/lucene-solr#2256, 2021-02-09), jimczi suggested a random leaf sorter in LuceneTestCase#newIndexWriterConfig; the author replied that a different order "will break some of our tests that rely on specific docIds", so it was not done.
  • Precedent. LUCENE-8466 (2018), "IndexWriter.deleteDocs(Query... query) incorrectly applies deletes on flush if the index is sorted", treated deletes landing on the wrong documents as a bug.
The devil's-advocate check144 words

Every API involved is marked advanced: setIndexCommit is "Expert", DirectoryReader#getIndexCommit is "Expert" and @lucene.experimental, IndexCommit is @lucene.experimental, and on #214 jpountz called leafSorter one of the "super expert parameters". So a maintainer could call the combination unsupported.

Why it fails: no such precondition is documented; setIndexCommit names exactly reader.getIndexCommit(); setLeafSorter is an IndexWriterConfig option, so the documented NRT hand-off combined with that documented option yields a sorted reader without any misuse, and it fails the same way; "experimental" lets the API change, it does not let deleteDocuments delete a document that lacks the term and commit that silently. The executing reviews from another model family and the independent reproducer rated the intended-behaviour case weak; the devil's advocate found that none of its points survives; the adjudication agreed, and the audit found that deleting a document without the term "cannot be intended".

Suggested fix

Recommended Pair each segment with the reader of the same name (validated)

Pair each segment with its own reader by segment name in the ReaderPool constructor (a map keyed by SegmentReader.getSegmentName()), not by position. Validated with gradle: the repro passes with assertions on and off, and so do TestIndexWriterFromReader, TestReaderPool, TestIndexWriterReader, TestDirectoryReader and TestDirectoryReaderReopen.

// ReaderPool constructor, reader != null: pair by segment name, not by position
Map<String, SegmentReader> readersByName = new HashMap<>();
for (LeafReaderContext leaf : leaves) {
  SegmentReader segReader = (SegmentReader) leaf.reader();
  readersByName.put(segReader.getSegmentName(), segReader);
}
for (SegmentCommitInfo info : segmentInfos) {
  SegmentReader segReader = readersByName.get(info.info.name);
  assert segReader != null : "no reader for segment " + info.info.name;
  SegmentReader newReader =
      new SegmentReader(info, segReader, segReader.getLiveDocs(),
          segReader.getHardLiveDocs(), segReader.numDocs(), true);
  // readerMap.put(...) unchanged
}
All options and trade-offs5 options ยท 559 words

One fix was built and validated; the other options come from the reviews and the brief and were not run.

Pair each segment with the reader of the same name (validated) (recommended)

In the ReaderPool constructor, map the reader's leaves by SegmentReader.getSegmentName() and look up each SegmentCommitInfo by info.info.name, instead of taking leaves.get(i). +10/-3 lines in ReaderPool.java; Map and HashMap are already imported.

// ReaderPool constructor, reader != null: pair by segment name, not by position
Map<String, SegmentReader> readersByName = new HashMap<>();
for (LeafReaderContext leaf : leaves) {
  SegmentReader segReader = (SegmentReader) leaf.reader();
  readersByName.put(segReader.getSegmentName(), segReader);
}
for (SegmentCommitInfo info : segmentInfos) {
  SegmentReader segReader = readersByName.get(info.info.name);
  assert segReader != null : "no reader for segment " + info.info.name;
  SegmentReader newReader =
      new SegmentReader(info, segReader, segReader.getLiveDocs(),
          segReader.getHardLiveDocs(), segReader.numDocs(), true);
  // readerMap.put(...) unchanged
}

Trade-offs

  • One HashMap per writer opened from a reader: linear, once, in the constructor.
  • Unchanged for unsorted readers, where position and name agree. It is the rule reader reopen already uses (StandardDirectoryReader.java:249-260).
  • The pool's map is keyed by SegmentCommitInfo, so the insertion order does not matter.

What the checks validated

  • Gradle, scratch worktree of 645d4fe14a20, seed DEADBEEF: stock, the repro fails with assertions on and off while TestIndexWriterFromReader (12) and TestReaderPool (6) pass; with the fix, all 19 tests pass with assertions on and off.
  • With the fix, assertions on: TestIndexWriterReader (24, one assumption skip), TestDirectoryReader (34) and TestDirectoryReaderReopen (30) pass; :lucene:core:check -x test (format, ecj lint, forbidden APIs) passes with the fix and the test.
  • Earlier, in the repro harness: the independent reproducer's and both reviewers' tests pass; the 5,220-run bounded-exhaustive differential test matches the no-sorter control in every run; with a newest-first sorter injected, TestIndexWriterFromReader passes 12 of 12 on 3 seeds; TestSoftDeletesDirectoryReaderWrapper (5) and TestSoftDeletesRetentionMergePolicy (14) pass.
  • Not run: the full Lucene test suite. The focused suites are enough for an issue and a candidate patch; run the full suite before calling the patch ready to merge.

The same, with an explicit exception for a missing name

Replace the assert segReader != null with an IllegalStateException, as one of the reviews suggested.

Trade-offs

A missing name cannot happen when the segment list and the leaves come from the same reader (the NRT path drops fully deleted segments from both), so this only changes how an internal inconsistency would surface. Not run.

Reject the combination

Throw IllegalArgumentException in the writer's reader branch when the reader's leaves are not in segment order.

Trade-offs

Small and safe, but it rejects a documented composition of options (rollback or NRT hand-off with a leaf sorter) that today either corrupts silently or fails loudly, instead of making it work, and the javadoc would have to say so. Not run.

Give ReaderPool the readers in segment order

Have StandardDirectoryReader or its ReaderCommit keep the segment readers in segment order next to the sorted view, and pre-enroll from that.

Trade-offs

Equivalent to pairing by name, but it changes reader internals for a constructor-local problem. Not run.

Optional: randomize the leaf sorter in tests

Setting a random setLeafSorter in LuceneTestCase#newIndexWriterConfig, as jimczi proposed on apache/lucene-solr#2256, would have exposed this through TestIndexWriterFromReader.testRandom.

Trade-offs

Coverage, not a fix; many tests depend on doc ids, which is why it was not adopted in 2021. The regression test for this bug is the one repro test.

Downstream note

In a product that takes this path and lets users delete or update their own documents by term, one user's request could remove or change another document.

How it was found

A planning pass proposed it and confirmed it with public-API probes on main; a skeptic pass re-ran and widened it. The hunt reduced it to one test and bounded it with a Z3 model of the writer's two size guards and a 5,220-run bounded-exhaustive differential test on the real jar.

The method in full219 words
  • Lead (non-formal). A planning pass of the hunt's round 18 proposed it and witnessed it with public-API main() probes on main: one with a directory reader opened with a newest-first sorter, one with setLeafSorter on both writers and a near-real-time reader. A skeptic pass re-ran it, widened it (forceMerge(2) with minMergeDocs 1 commits [a, a, b]) and found it on 8.9.0, 8.10.0 and 9.0.0. In the same round, injection replays (a newest-first sorter switched on in copied upstream test classes) hit the same root through TestIndexWriterFromReader.testRandom.
  • Hunt. The unit reduced it to one public-API JUnit test. Then two formal legs: a Z3 model of the writer constructor's two guards (2 to 8 segments) separates the leaf orders that pass silently (equal maxDoc) from those that fail loudly; a bounded-exhaustive differential test on the real jar (5,220 runs: 2 or 3 segments of 1 or 2 documents, with and without a deletion, every leaf order, directory and near-real-time readers, no-op, delete, update, doc-values update, forceMerge(1) and forceMerge(2); oracle: the same steps from the same commit without a sorter) matched the model's prediction in every run, and is 5,220 of 5,220 equal to the oracle with the fix.
  • Because the formal legs came after the lead, the hunt counts the bug as found by non-formal means.

Prior reports, history and checks

Prior reports

No prior report: issues, PRs, the JIRA archive, commits, four mailing lists, downstream trackers and the web were searched on 2026-10-30, and the open pull requests touching ReaderPool were read. The closest items are the two features that combine into it (2015 and 2021).

Searches5 searches

No issue, PR, commit, JIRA entry, mailing-list thread, downstream report or web page describes this bug or any of its symptoms. The closest items are the features whose combination creates it, and open PRs in the same constructor that keep the positional pairing.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues and PRs (apache/lucene, incl. the migrated JIRA archive)2026-10-30three runs of the scripted prior-report search (119 queries over issues, PRs, commits, downstream repositories, JIRA, the lists and the web) and 67 manual searches, e.g. leafSorter, "leaf sorter", setIndexCommit, getIndexCommit, "IndexWriter from reader", ReaderPool, "numDocs=" "but maxDoc=", "wrong segment", LUCENE-9507, LUCENE-10027no report of this bug
Feature threads (read in full)2026-10-30#7582 (LUCENE-6524), #10546 / #32 / lucene-solr#2256 / #2473 (LUCENE-9507), #11066 / #214 (LUCENE-10027)the origins of the positional loop (2015) and of the leaf sorter (2021); neither discusses opening a writer from a sorted reader's commit
Open PRs2026-10-30the changed files of all 410 open apache/lucene PRs; the diffs of those touching ReaderPool.java or the reader branch#16847, #16923, #16643, #15683 and #16948 touch the area; none changes the pairing
Mailing lists (dev@, java-user@, issues@, commits@)2026-10-30list queries on setIndexCommit, getIndexCommit IndexWriter, leaf sorter, LUCENE-9507, LUCENE-10027no report; a 2017 java-user thread about rolling back with a reader's commit was answered with listCommits, which is not affected
Downstream (Elasticsearch, OpenSearch, Solr) and the web2026-10-30leafSorter, "leaf sorter", setIndexCommit, "numDocs=" "but maxDoc="; web queriesleaf-sorter adoption and unrelated issues; OpenSearch #7878 (a document loss after a segment-order change) is at search level, with no writer opened from a reader's commit

History

Introduced in 8.9.0 by LUCENE-9507 (48715fe898b, 2021), which made a reader's leaves sortable without touching ReaderPool; the positional loop dates from LUCENE-6524 (2015). Every release from 8.9.0 through 10.6.0 and main is affected; 8.8.2 has no leaf-sorter API.

Versions tested7 rows

Introduced by LUCENE-9507 "Custom order for leaves in IndexReader and IndexWriter" (#32, issue #10546, 48715fe898b, 2021-03-26), released in 8.9.0 through its branch_8x backport 1cfd0df4e7bb (lucene-solr#2473). Latent precondition: the positional pre-enroll loop of LUCENE-6524 (2d0ac9bb7f2, 2015-06-28, #7582, released in 5.3.0), moved unchanged into ReaderPool by LUCENE-8260 (89756929537, 2018-04-23); it was correct while leaves always followed segment order. Widened by LUCENE-10027, another way to open a sorted reader: DirectoryReader.open(IndexCommit, Comparator) in 8.10.0 (branch_8x backport, lucene-solr#2540), which became open(IndexCommit, int, Comparator) in 9.0.0 (1daf7e7c742, #214). The rows below are the test shown above (ported by one import before 9.1), re-run on the release jars by the fact check; the follow-up's earlier sweep ran the finder's timestamp-sorter version of it (same steps) with the same outcomes. 8.10.0 was run with a main() probe of the same steps. The releases not listed are inferred from the code at their tags. Upstream main 716d6f216f5, branch_11x and branch_10_6 still pair by position.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedfails with assertions on and off (gradle and the repro harness)
10.6.0, 10.5.2, 10.0.0, 9.12.3 affectedfails (-da)
9.0.0, 8.11.4 affectedfails (-da; the test ported by one import)
8.10.0 affectedfails (-da; a main() probe of the same steps with a newest-first sorter)
8.9.0 first affectedfails (-ea and -da; ported by one import)
8.8.2 and earlier not affectedno leaf-sorter API: the test does not compile
fix (name pairing) not affectedthe test passes with assertions on and off

Independent checks

An independent reproducer, executing reviews by a reviewer from another model family (OpenAI GPT-5.6), an intended-behaviour devil's advocate, an adjudication and a credibility audit all call it real; each ran its own test, and the name-pairing fix made each pass.

Checks and verdicts283 words

Every check ran Java. The reviews ran on main ea57360912ed, whose ReaderPool, SegmentReader, BaseCompositeReader, StandardDirectoryReader, PendingDeletes and writer constructor are byte-identical to 645d4fe14a20. Fact checks of this doc then re-ran the test on main and on the release jars and probed the side legs.

Each independent check and its verdict
CheckVerdictWhat it found
independent reproducer realIts own test, written from the claim, fails on main with -ea (10 of 10 iterations) and -da and on 10.6.0, 10.5.2 and 9.12.3; its unsorted control passes; its own name-pairing fix passes. It also reproduced the loud case (numDocs=2 but maxDoc=1).
cross-model review, refute lens real, highOpenAI GPT-5.6, executing: its own two-segment test fails with -ea and -da (counts 1/0 instead of 0/1), the control passes; with the fix its test, TestIndexWriterFromReader (12/12) and TestIndexWriterReader (23/23) pass. Intended-behaviour case weak.
cross-model review, intended lens real, highOpenAI GPT-5.6, executing: its own test fails on main and on 10.6.0; its test of the javadoc's sorted-NRT flow fails; a listCommits commit deletes correctly. It suggested an explicit exception instead of the assert.
intended-behaviour devil's advocate real, highFound no javadoc, CHANGES or MIGRATE entry, test or review thread that restricts the combination; ran the setIndexCommit javadoc's NRT flow with setLeafSorter: id:a=1 id:b=0.
adjudication confirmed, highRe-ran the reproducer's test on main and 10.6.0 (fail), the finder's test on main (fail), the control and the fix (pass).
credibility audit and no-noise gate keep; claimThe audit re-ran the minimal test on another seed (fails with -ea and -da) and re-checked the size-guard reasoning; the gate accepted the one-test repro.

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

Finding record

LFV-1062, snapshot of 2026-11-06

Registry title
An IndexWriter opened from a leaf-sorted reader's IndexCommit pairs each segment with the segment reader at the same leaf position, so deleteDocuments(term) deletes another segment's documents and the commit persists that silently
Area
index / IndexWriter opened from a reader's IndexCommit (ReaderPool pre-enrollment) with a leaf-sorted reader (DirectoryReader leaf sorter, IndexWriterConfig#setLeafSorter)
Priority
Medium, rank 78: This is silent, durable wrong-document deletion in core IndexWriter that CheckIndex does not catch, but like its sibling LFV-593 it needs the Expert setIndexCommit-from-a-reader path plus a leaf sorter and equal-maxDoc reordered segments.
Impact class
data corruption
Impact note
Hits only applications that combine two opt-in options: a leaf sorter (DirectoryReader.open(Directory, Comparator), the IndexCommit overload with a sorter, or IndexWriterConfig#setLeafSorter for NRT readers, for example the newest-first order the javadoc suggests for time-based indices) and an IndexWriter opened with the Expert IndexWriterConfig#setIndexCommit(reader.getIndexCommit()) on such a reader; when the segments the sorter moves have equal maxDoc (two one-document segments suffice, no scale), deletes, updates, doc-values updates and merges act on another segment's documents and the next commit persists that with CheckIndex clean, and when their sizes differ the writer constructor throws IllegalArgumentException 'numDocs=N but maxDoc=M' instead; default codec, directory and merge policy, -ea and -da, every release from 8.9.0 through 10.6.0 and main.
Summary
You commit two one-document segments (_0 holds id=a, _1 holds id=b), open DirectoryReader.open(dir, newestFirst) so that its leaves are [_1, _0], open an IndexWriter with setIndexCommit(reader.getIndexCommit()), call deleteDocuments(new Term("id", "a")) and commit. You expect the index to keep only b, as it does when the reader has no sorter or when the same commit is taken from DirectoryReader.listCommits. Instead the writer deletes b and keeps a: ReaderPool pairs segmentInfos.info(i) (segment order) with reader.leaves().get(i) (sorted order) by position, so each segment's pooled reader carries the other segment's documents, the delete matches a's data but is recorded under _1, and the commit writes _1's live docs, with CheckIndex clean.
Introduced
48715fe898bf (apache/lucene main, LUCENE-9507 'Custom order for leaves in IndexReader and IndexWriter', issue #10546, PR #32, author Mayya Sharipova, author date 2021-03-26), first released through its branch_8x backport 1cfd0df4e7bb (apache/lucene-solr#2473, author date 2021-03-30, in 8.9.0): it made a reader's leaves sortable (BaseCompositeReader Arrays.sort(subReaders, subReadersSorter)) and changed neither ReaderPool.java nor IndexWriter.java [read: git blame and git log of ReaderPool.java:90-116 and of the commit's file list; exec: 8.8.2 has no leaf-sorter API (the repro does not compile), 8.9.0 fails]. Latent precondition, not the introducing commit: the positional pre-enroll loop of 2d0ac9bb7f22 (LUCENE-6524 / #7582, author date 2015-06-28, first released in 5.3.0), moved unchanged into ReaderPool by 89756929537 (LUCENE-8260, 2018-04-23), which was correct while a reader's leaves always followed segment order (2021-03-26 (author date of 48715fe898b on main; the branch_8x backport 1cfd0df4e7bb is dated 2021-03-30))
First affected version
8.9.0 [exec: the JUnit, ported to the pre-9.1 test package, fails with -ea and -da; the round-18 skeptic's main() probe also fails]; 8.8.2 and earlier are not affected [exec: DirectoryReader.open(Directory, Comparator) does not exist and the repro does not compile; the positional loop exists since 5.3.0 but cannot mispair without a sorter]
Affected versions
every release from 8.9.0 through 10.6.0 (released 2026-10-29, 4ae946e5a9b) and main 645d4fe14a20; never fixed. [exec, the JUnit TestIndexWriterFromLeafSortedReader, seed DEADBEEF] 8.9.0 (-ea and -da), 8.11.4, 9.0.0 (8.x and 9.0.0 through a one-import port), 9.12.3, 10.0.0, 10.5.2 and 10.6.0 (-da) and main (-ea and -da) all fail with the same assertion; the independent reproduction's own JUnit fails on 10.6.0, 10.5.2 and 9.12.3 (-da); the round-18 skeptic's main() probes fail on 8.9.0, 8.10.0 and 9.0.0 (-da). [read] the remaining 8.10.x, 8.11.x, 9.x and 10.x releases are inferred: every 9.x and 10.x release tag contains both the positional loop and the leaf sort (git show per tag), and 8.10.1-8.11.3 lie between executed 8.x releases that fail. Upstream main 716d6f216f5, branch_11x and branch_10_6 still pair by position (the last ReaderPool.java commit is 9092bda09d6, #16570, author date 2026-09-01; re-checked 2026-10-30). Not new in 10.6.
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that agreed, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
non-formal; also reached by Z3, exhaustive check
Upstream and prior reports
none found (2026-10-30, read-only; the prior-report record, verdict new): three scripted prior-report searches (119 queries over apache/lucene issues and PRs incl. the migrated JIRA archive, commits, downstream repositories, the dev@, java-user@, issues@ and commits@ lists, JIRA and the web; 453, 724 and 45 hits); 67 manual GitHub search queries (apache/lucene, Elasticsearch, OpenSearch, Solr); full reads of the feature threads #7582 (LUCENE-6524), #10546 / #32 / lucene-solr#2256 / lucene-solr#2473 (LUCENE-9507) and #11066 / #214 (LUCENE-10027); the changed files of all 410 open apache/lucene PRs (the five that touch ReaderPool.java or the reader-commit branch, #16847, #16923, #16643, #15683 and #16948, were read: none changes the pairing); the 66 items updated since 2026-10-28. Closest, not reports: #7582 (added the positional loop in 2015, when leaves were always in segment order), #10546 / #32 (added the leaf sorter in 2021 without touching ReaderPool; in its review jimczi proposed randomizing the leaf sorter in newIndexWriterConfig, which was not adopted because tests depend on doc ids), #11066 / #214 (another way to build a sorted reader), OpenSearch #7878 (downstream document loss after a segment-order change, at search level, with no writer opened from a reader commit). Re-check (2026-10-30): no apache/lucene item matches 'leafSorter setIndexCommit', 'ReaderPool' created since 2026-10-28 or '"leaf sorter" IndexWriter' created since 2026-10-01; the only items updated after a recent cutoff are #16952 and #16949 (unrelated); upstream main is still 716d6f216f5 and the last ReaderPool.java commit is 9092bda09d6 (#16570, author date 2026-09-01). Not fixed upstream (main, branch_11x, branch_10_6)
Lucene commit tested
main 645d4fe14a20 (2026-10-28): the one-test repro through gradle with assertions on and off, stock and with the fix, plus the closest upstream suites (2026-10-30). Earlier, in the repro harness: main with -ea and -da on several seeds, and the release jars 8.9.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 and 10.6.0 (8.8.2 does not have the API); the fact check (2026-10-30) re-ran this test on those jars, and 8.10.0 was run with a main() probe of the same steps. Upstream main 716d6f216f5, branch_11x and branch_10_6 were read, not run: they still pair by position.

Lucene and upstream sources

Finding record
  • a Lucene main checkout (645d4fe14a20): lucene/core/src/java/org/apache/lucene/index/ReaderPool.java:90-115 (pre-enroll loop: :95 'assert segmentInfos.size() == leaves.size()', :96-101 leaves.get(i) paired with segmentInfos.info(i)); BaseCompositeReader.java:74-76 (Arrays.sort(subReaders, subReadersSorter)); IndexWriter.java:1050 (segmentInfos = reader.segmentInfos.clone()), :1149 (new ReaderPool(...)), :1856 ('Deletes the document(s) containing any of the terms'); SegmentReader.java:128-142 (copy constructor rejects only numDocs > maxDoc and a liveDocs length mismatch); PendingDeletes.java:41-44 (pendingDeleteCount = reader.numDeletedDocs() - info.getDelCount()); IndexWriterConfig.java:235-241 (setIndexCommit javadoc: open IndexWriter from a near-real-time reader by passing its getIndexCommit), :517-527 (setLeafSorter); DirectoryReader.java:76-90 (open(Directory, Comparator): a search-speed option, 'for time-based indices this is usually a descending sort on timestamp')
  • gh api read-only re-check 2026-10-30: search/issues 'repo:apache/lucene updated:>=2026-10-30', 'leafSorter setIndexCommit', 'ReaderPool created:>=2026-10-28', '"leaf sorter" IndexWriter created:>=2026-10-01'; repos/apache/lucene/commits?path=lucene/core/src/java/org/apache/lucene/index/ReaderPool.java; repos/apache/lucene/commits?sha=main
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:982-989, 1021-1050, 1149, 1791-1812 (tryModifyDocument), 6750-6756 @ 645d4fe14a20
  • lucene/core/src/java/org/apache/lucene/index/StandardDirectoryReader.java:571-576, 644-646; DirectoryReader.java:448-481 (listCommits: ReaderCommit(null, ...) at :456, :481) @ 645d4fe14a20
  • lucene/core/src/java/org/apache/lucene/index/BaseCompositeReader.java:75-76; ReaderPool.java:91-115; SegmentReader.java:128-152; PendingDeletes.java:41-44 @ 645d4fe14a20
Who is affected
  • lucene/core/src/java/org/apache/lucene/index/DirectoryReader.java:76-90 (open(Directory, Comparator) javadoc); IndexWriterConfig.java:235-241, 518-527 @ 645d4fe14a20
  • lucene/core/src/test/org/apache/lucene/index/TestIndexWriterReader.java:1204 (testIndexReaderWriterWithLeafSorter); TestIndexWriterFromReader.java (no leaf sorter) @ 645d4fe14a20
  • lucene/CHANGES.txt:4769-4770 (9.0.0: open(IndexCommit, int, Comparator)), 5223-5224 (8.10.0: open(IndexCommit, Comparator)); javap on the 8.10.0 and 9.0.0 lucene-core jars
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:1856, 1030, 1791-1812; IndexWriterConfig.java:235-241, 518-527; DirectoryReader.java:76-90, 644-649; IndexCommit.java:35; StandardDirectoryReader.java:249-260 @ 645d4fe14a20
  • lucene/CHANGES.txt:10312-10314 (LUCENE-6524), 5345-5346 (LUCENE-9507), 5223-5224 (LUCENE-10027)
  • gh api read-only 2026-10-30: apache/lucene-solr pulls/2256/comments (jimczi 2021-02-09T15:49Z; mayya-sharipova reply 2021-03-22T16:02Z); apache/lucene pulls/214/comments (jpountz 2021-07-20T12:43Z, "super expert parameters")
Suggested fix
  • lucene/core/src/java/org/apache/lucene/index/StandardDirectoryReader.java:249-260 (mapPreviousReaders by segment name) @ 645d4fe14a20
  • gh api read-only 2026-10-30: apache/lucene-solr pulls/2256/comments (jimczi 2021-02-09; reply 2021-03-22)

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.