All findings · Lucene finding · LFV-015 · segment-name-reuse-after-rollback

After rolled-back segment names are reused, reopening an old NRT reader throws a misleading IllegalStateException

An NRT reader kept across two rolled-back writer sessions cannot be reopened on a later commit: openIfChanged(reader, commit) throws an IllegalStateException that blames the application for deleting index files, although Lucene's own file deleter removed them and a later writer reused the segment name _0. Default configuration, since 6.2.0.

Summary

Priority
Low, rank 948Reopening an old NRT reader after rolled-back segment names are reused throws a misleading IllegalStateException, a wrong error type in an unusual session sequence that matters mainly as LFV-004's enabler.
Impact
wrong error
Since
6.2.0 as this exception (LUCENE-5931, 2016); 4.3.0 (LUCENE-4898, 2013) as a stale readerintroduced 2013-04-04 · over 13 years
Affected
6.2.0 to 10.6.0 (the latest release) and main; 4.3.0 to 6.1.0 as a stale reader
Status
documented
Found by
model-based randomized testing; re-found by six formal tools (hinted)

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

A reopen reuses the old reader's per-segment readers by segment name. Here a name comes back for a different segment, because the counter that hands out names is kept only by a completed commit and two writer sessions in a row were rolled back.

Background: segment names, NRT readers and reopen

  1. Each new segment gets a name, _0, _1, ..., when the writer starts it (at the first addDocument), from a counter in the writer's memory. Only a completed commit keeps the counter (prepareCommit() writes it to a pending file that rollback removes); rollback() closes the writer without a commit.
  2. When a writer starts, its file deleter moves the counter past any segment files it finds, then deletes the files no commit refers to (leftovers of crashed or rolled-back sessions).
  3. An NRT reader (DirectoryReader.open(writer)) can see segments that were never committed, and it keeps their files alive. openIfChanged(reader, commit) builds a reader on another commit and reuses the old per-segment readers whose segment names match; each segment also carries a random id, and a guard throws when the same name has a different id.
Summary card of the bug: the condensed repro (w1 adds a document, NRT reader r opened on w1, w1.rollback(), a second writer opened and rolled back, w3 commits 2 documents, DirectoryReader.openIfChanged(r, commit)); expected a new reader on segments_1 with 2 documents, actual IllegalStateException 'same segment _0 ...' that blames the application although Lucene deleted the files; a flow of what happens inside Lucene: open(w1) flushes _0, w1.rollback() keeps the files, the second writer raises its counter to 1 and deletes _0, its rollback loses the counter, w3 names _0 again with another id and commits, openIfChanged finds the same name with another id; what still works: with one rollback w3 names its segment _1 and the reopen gives the 2-document reader, the old reader keeps its 1 document, DirectoryReader.open(commit) opens the 2 documents.
The repro step by step: the second writer's startup deletes the rolled-back _0 files and its rollback loses the raised counter, so the third writer names a new _0; the reopen finds the old _0 by name, sees another id and blames the application. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Writer 1 adds 1 document and you open an NRT reader on it: segment _0, never committed. Roll writer 1 back, then open a second writer and roll it back too.
  2. Writer 3 adds 2 documents and commits. You call DirectoryReader.openIfChanged(reader, commit) and expect a new reader with 2 documents, as its javadoc says. With one rolled-back writer fewer, that is what you get.
  3. Instead it throws IllegalStateException: same segment _0 has invalid doc count change; likely you are re-opening a reader after illegally removing index files yourself ....
  4. Why: writer 2's startup moved its name counter past the leftover _0 files and deleted them; its rollback lost the counter. So writer 3 named its new segment _0 again, with a new id. The reopen matches old segment readers by name, sees the other id and throws a guard meant for applications that ran rm -rf.
  5. No wrong results were observed: the old reader still finds its 1 document and DirectoryReader.open(commit) sees 2. The message's advice, OpenMode.CREATE, does not help.

Who is affected

Applications that keep an NRT reader (or an NRT SearcherManager whose RefreshCommitSupplier returns a commit) open while two writer sessions in a row end in rollback() without a commit, then reopen it on a later commit. Default configuration, deterministic, but an unusual sequence. The reopen fails with a false diagnosis; no wrong results were observed.

Mechanism, terms and numbers627 words

DirectoryReader.openIfChanged(DirectoryReader, IndexCommit) promises a new reader when the commit differs, and Lucene has supported it for near-real-time readers since LUCENE-4898. Keep such a reader open while its writer is rolled back without ever committing, then open a second writer and roll it back as well: the second writer's IndexFileDeleter moves the segment counter past the leftover _0 files and deletes them as unreferenced, and its rollback discards the raised counter. A third writer then names its new segment _0 again, with a new segment id, and commits. Reopening the old reader on that commit looks old segment readers up by name, finds a different id and throws the LUCENE-5931 guard's IllegalStateException, whose message says the application illegally removed index files and advises OpenMode.CREATE, which does not help. No wrong result was observed: the old reader keeps working and a fresh DirectoryReader.open(commit) is correct. From 4.3.0 to 6.1.0 the same call silently returned the stale 1-document reader. A 19-line change that opens such segments fresh for NRT readers passes the repro and the whole core test suite.

The code (main 645d4fe14a20)

// IndexWriter.java:2169-2181 (abridged): names come from an in-memory counter
private final String newSegmentName() {
  synchronized (segmentInfos) {
    // Important to increment changeCount so that the segmentInfos is written on close.  Otherwise we
    // could close, re-open and re-return the same segment name that was previously returned ...
    changeCount.incrementAndGet();
    segmentInfos.changed();
    return "_" + Long.toString(segmentInfos.counter++, Character.MAX_RADIX);
  }
}

// IndexFileDeleter.java:217-233 (writer startup)
inflateGens(segmentInfos, relevantFiles, infoStream);   // :318-327  counter = 1 + max segment name on disk
Set<String> toDelete = fileDeleter.getUnrefedFiles();   // files no commit refers to
fileDeleter.deleteFilesIfNoRef(toDelete);

// StandardDirectoryReader.java:507-511: reopening to a commit passes the old leaves ...
return StandardDirectoryReader.open(directory, infos, getSequentialSubReaders(), subReadersSorter, executorService);
// :249-261 ... which are looked up by segment NAME
previousSegmentReaders.put(sr.getSegmentName(), Integer.valueOf(i));
// :274-283: same name, different id -> blame the application
// Make a best effort to detect when the app illegally "rm -rf" their
// index while a reader was open, and then called openIfChanged:
if (oldReader != null
    && Arrays.equals(commitInfo.info.getId(), oldReader.getSegmentInfo().info.getId()) == false) {
  throw new IllegalStateException("same segment " + commitInfo.info.name + " has invalid doc count change; ...");
}

Step by step, on the repro

stepsegment counterfiles on disknote
w1.addDocument, r = DirectoryReader.open(w1)w1: 1_0.cfe, _0.cfs, _0.si_0 (1 doc, id A), never committed; r incRefs its files in w1's deleter (StandardDirectoryReader.java:148)
w1.rollback()lost_0.* keptno commit; r still holds the files
open w2w2: 0, raised to 1noneinfoStream: "init: inflate infos.counter to 1 vs current=0", then "init: removing unreferenced file" for the three _0 files (IndexFileDeleter.java:217-233)
w2.rollback()lostnoneno commit, so the raised counter is gone
w3 adds 2 docs, close()w3: 0, then 1_0.*, segments_1a new _0 (2 docs, id B) (IndexWriter.java:2169-2181)
openIfChanged(r, commit)NRT reader: doOpenFromWriter -> doOpenFromCommit (StandardDirectoryReader.java:407-414, :456-459, :496-511); name _0 found, id A != id B, so the guard throws (:274-283)

Without the second writer, w3 itself sees the leftover _0 files, moves its counter to 1 and writes _1: no name collides and the reopen returns the 2-document reader. On a file system that cannot delete open files (the test framework's WindowsFS, a simulation, not a run on Windows), the files stay as pending deletions, which the next writer also counts (IndexFileDeleter.java:211-217), so it does not collide either. With an index that already has a commit, the same sequence collides on the uncommitted _1 instead.

The guard does a real job: before it existed (4.3.0 to 6.1.0), the same call silently reused the stale _0 reader. What is wrong is the decision to throw, with a diagnosis that is false here.

Exposure and likelihood296 words
  • Who is hit. An application that keeps an NRT reader open across writer sessions in one directory, where two consecutive sessions end in rollback() with no commit in between, and then reopens the old reader with openIfChanged(reader, commit). Lucene's SearcherManager makes this call when built on a writer with a RefreshCommitSupplier that returns a commit (SearcherManager.java:199-200, since 10.3.0; the default supplier returns null, which takes the writer path): after the same sequence maybeRefresh() throws the same exception, while without the second writer it refreshes to 2 documents.
  • Configuration. Default IndexWriterConfig, public API, single thread; the plain main() probe (lucene-core only) uses the stock codec. Executed on ByteBuffersDirectory, and on MMapDirectory and NIOFSDirectory on Linux. File systems that refuse to delete open files avoid it (the study's WindowsFS control, a simulation, not a run on Windows).
  • How often. Rare: it needs two rolled-back sessions in a row with no commit, while an old NRT reader stays open. As few as 1 old and 2 new documents trigger it. It also happens on an index that already has a commit (the collision is then on the uncommitted _1).
  • What goes wrong. The reopen fails with an IllegalStateException that says the application illegally removed index files; its advice (IndexWriter.deleteAll or OpenMode.CREATE) does not apply, and OpenMode.CREATE was run: the same exception. No wrong result was observed: the old reader keeps working and DirectoryReader.open(commit) is correct.
  • Why it matters beyond the message. The same name reuse is the trigger for a separate, more serious defect in the same hunt: merge instances re-open segment files by name, so addIndexes of an old leaf can copy another segment's data on main. That one needs its own fix.
  • Impact class: wrong error; priority low.

Minimal repro

One JUnit test on the public API: ByteBuffersDirectory, plain IndexWriterConfig, empty documents, single thread, no randomness in the test itself (the seed's codec choice does not matter: SimpleText and 20 of 20 iterations under another seed fail too). Put it in lucene/core/src/test/org/apache/lucene/index/ and run it with gradle. It is also the regression test.

TestReopenAfterTwoRollbacks.java (one test)45 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 org.apache.lucene.document.Document;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestReopenAfterTwoRollbacks extends LuceneTestCase {

  public void testReopenNrtReaderOnCommitAfterTwoRollbacks() throws Exception {
    try (Directory dir = new ByteBuffersDirectory()) {
      IndexWriter w1 = new IndexWriter(dir, new IndexWriterConfig());
      w1.addDocument(new Document());
      DirectoryReader r = DirectoryReader.open(w1);
      w1.rollback();
      new IndexWriter(dir, new IndexWriterConfig()).rollback();
      IndexWriter w3 = new IndexWriter(dir, new IndexWriterConfig());
      w3.addDocument(new Document());
      w3.addDocument(new Document());
      w3.close();
      IndexCommit commit = DirectoryReader.listCommits(dir).get(0);
      // expected a new 2-doc reader on w3's commit, actual IllegalStateException "same segment _0"
      DirectoryReader r2 = DirectoryReader.openIfChanged(r, commit);
      assertEquals(2, r2.numDocs());
      r2.close();
      r.close();
    }
  }
}
Command
# $REPRO holds TestReopenAfterTwoRollbacks.java; $LUCENE is a Lucene checkout
cp $REPRO/TestReopenAfterTwoRollbacks.java $LUCENE/lucene/core/src/test/org/apache/lucene/index/
cd $LUCENE
./gradlew :lucene:core:test --tests TestReopenAfterTwoRollbacks -Ptests.seed=DEADBEEF
Expected
openIfChanged(r, commit) returns a new reader on w3's commit with numDocs() == 2.
Observed
IllegalStateException: same segment _0 has invalid doc count change; likely you are re-opening a reader after illegally removing index files yourself ...

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

More files, environment, full output203 words

The class sits in org.apache.lucene.index by Lucene test convention but uses nothing package-private.

Control: delete the line new IndexWriter(dir, new IndexWriterConfig()).rollback(); and the test passes on main, so the second rolled-back writer is the trigger.

The same failure through the repro harness: main 645d4fe14a20 with assertions on and off (10 of 10 runs with assertions off), and the 10.5.2 and 9.12.3 release jars with assertions on and off.

Environment

lucenemain 645d4fe14a20; also the 10.5.2 and 9.12.3 release jars (assertions on and off)
jdkOpenJDK 25.0.3
osLinux aarch64

Observed output

# gradle, main 645d4fe14a20, seed DEADBEEF, assertions on (stack abridged)
TestReopenAfterTwoRollbacks > testReopenNrtReaderOnCommitAfterTwoRollbacks FAILED
    java.lang.IllegalStateException: same segment _0 has invalid doc count change; likely you are re-opening a reader after illegally removing index files yourself and building a new index in their place.  Use IndexWriter.deleteAll or open a new IndexWriter using OpenMode.CREATE instead
        at org.apache.lucene.index.StandardDirectoryReader.getOldSegmentReader(StandardDirectoryReader.java:279)
        at org.apache.lucene.index.StandardDirectoryReader.createSegmentReaders(StandardDirectoryReader.java:236)
        at org.apache.lucene.index.StandardDirectoryReader.open(StandardDirectoryReader.java:181)
        at org.apache.lucene.index.StandardDirectoryReader.doOpenIfChanged(StandardDirectoryReader.java:509)
        ...
        at org.apache.lucene.index.StandardDirectoryReader.doOpenFromCommit(StandardDirectoryReader.java:504)
        at org.apache.lucene.index.StandardDirectoryReader.doOpenFromWriter(StandardDirectoryReader.java:459)
        at org.apache.lucene.index.StandardDirectoryReader.doOpenIfChanged(StandardDirectoryReader.java:413)
        at org.apache.lucene.index.DirectoryReader.openIfChanged(DirectoryReader.java:284)
        at org.apache.lucene.index.TestReopenAfterTwoRollbacks.testReopenNrtReaderOnCommitAfterTwoRollbacks(TestReopenAfterTwoRollbacks.java:39)
:lucene:core:test (FAILURE): 1 test, 1  failure

# repro harness, the same exception: main -ea and -da, 10.5.2 -ea and -da, 9.12.3 -ea and -da
# with fix A: the test passes (gradle, with 9 related suites, assertions on and off)

Why it looks unintended

  • The openIfChanged(reader, commit) javadoc says "open and return a new reader"; reopening an NRT reader on a commit is supported since LUCENE-4898 and tested upstream.
  • The guard exists to catch an application that "illegally 'rm -rf' their index"; here Lucene's own file deleter removed the files.
  • The same calls with one rolled-back writer fewer return the 2-document reader.
The full argument309 words
  • Contract. DirectoryReader.java:276-281: "If the IndexCommit differs from what the provided reader is searching, open and return a new reader; else, return null." No NRT or closed-writer exception is stated. CHANGES.txt:14052-14054 (LUCENE-4898): "DirectoryReader.openIfChanged now allows opening a reader on an IndexCommit starting from a near-real-time reader (previously this would throw IllegalArgumentException)."
  • Upstream tests reopen an NRT reader on a commit: TestDirectoryReaderReopen.testOpenIfChangedNRTToCommit (:649-671) and TestIndexWriterReader.testReopenNRTReaderOnCommit (:1179).
  • Old readers keep working across writer sessions. IndexWriter.java:103-108: with OpenMode.CREATE "The old readers will continue to search the "point in time" snapshot they had opened, and won't see the newly created index until they re-open." This documents searching, not reopening an orphaned NRT reader (see the devil's advocate).
  • The guard is for application abuse. StandardDirectoryReader.java:274-275; CHANGES.txt:9027-9029 (LUCENE-5931): "Detect when an application tries to reopen an IndexReader after (illegally) removing the old index and reindexing"; CHANGES.txt:12286-12290 (LUCENE-5684): "when IndexReader is reopened after all files in its index were removed and recreated by the application". The guard's test calls its trigger "Abuse" (TestDirectoryReaderReopen.testDeleteIndexFilesWhileReaderStillOpen, :1043-1090). In #6993 rmuir asked "Can you make a test change to trip it without manually removing files from your index?" and mikemccand answered "I don't think so ... the only way I know of this happening is if an app has a reader open, then removes the index, rebuilds it, then tries to openIfChanged the reader". This sequence trips it with no file removed by the application.
  • Lucene knows names get reused after its own cleanup. util/FileDeleter.java:176-181: "it's very unusual yet possible ... if you open IW on a crashed index, and it removes a bunch of unref'd files, and then you add new docs / do merging, and it reuses that segment name." IndexWriter.java:2173-2177 bumps the change count so a writer does not "close, re-open and re-return the same segment name".
The devil's-advocate check285 words

Strongest case for "works as designed": the NRT note "Once the writer is closed, any outstanding readers may continue to be used. However, if you attempt to reopen any of those readers, you'll hit an AlreadyClosedException" makes reopening an orphaned NRT reader unsupported; the guard is deliberate, results are never wrong and DirectoryReader.open(commit) is an easy workaround; maintainers have said that mixing files from several writers is unsupported (dev@/java-user@ 2022, "customize segment names?"). Two of the study's verifiers and one noise triage leaned this way, and the read-only cross-model reviewer rated this argument "strong".

Why it fails:

  1. The note is written on openIfChanged(DirectoryReader, IndexWriter) and its executor variant (DirectoryReader.java:330-332, :372-374) and on the package-private IndexWriter.getReader (:507-509), whose readers' openIfChanged "will simply forward back to this method" (:493). The commit overload has not gone through the writer since LUCENE-4898 (StandardDirectoryReader.java:456-459) and returns the new reader after a rollback in every control; TestIndexWriterReader.testAfterClose asserts the AlreadyClosedException only for openIfChanged(r).
  2. Even under the broad reading, the outcome is not the documented one: a different exception with a false diagnosis, and only when a name was reused.
  3. A one-file change returns the right reader and keeps the abuse detection the upstream tests assert, so the failure is not a necessary cost of the guard. No files are mixed: the writers run one after another, each holding the write lock.

What stays open: the docs do not say what reopening an NRT reader whose writer was rolled back should do, so this is a gray zone (a later cross-model fact check rated the intended-behaviour case "moderate"). A maintainer could reasonably choose a message or javadoc fix (C) over fix A.

Suggested fix

Recommended A. For an NRT reader, reuse an old segment reader only when the segment id matches (+19/-1)

For an NRT reader, reuse an old segment reader on reopen only when its segment id matches the commit's; otherwise open that segment fresh (+19/-1 in StandardDirectoryReader.doOpenIfChanged). Plain readers keep the guard. The repro passes, as do 9 related suites and all 8,898 core tests.

All options and trade-offs3 options ยท 744 words

Fix A was run and is recommended; B and C were not run. Workaround for users today: after rolling writers back, open a fresh reader with DirectoryReader.open(commit) instead of openIfChanged(oldNrtReader, commit), or close (commit) the middle writer instead of rolling it back, which saves the counter.

A. For an NRT reader, reuse an old segment reader only when the segment id matches (+19/-1) (validated by the checks) (recommended)

In StandardDirectoryReader.doOpenIfChanged(SegmentInfos, ExecutorService), the path behind openIfChanged(r, commit), drop old leaves whose segment id differs from the same-named segment of the commit when the reader is NRT (writer != null). Those segments are then opened fresh from the files on disk; leaves with matching ids are reused as before. Plain readers still throw the guard's exception. This is a policy choice, not only a better message: for NRT readers the abuse exception on this path goes away.

--- a/lucene/core/src/java/org/apache/lucene/index/StandardDirectoryReader.java
+++ b/lucene/core/src/java/org/apache/lucene/index/StandardDirectoryReader.java
@@ -506,8 +506,26 @@ public final class StandardDirectoryReader extends DirectoryReader {
 
   DirectoryReader doOpenIfChanged(SegmentInfos infos, ExecutorService executorService)
       throws IOException {
+    List<? extends LeafReader> oldReaders = getSequentialSubReaders();
+    if (writer != null) {
+      // An NRT reader may hold segments that were never committed. Once its writer is rolled back,
+      // a later writer can reuse those segment names for different segments, so only reuse an old
+      // segment reader if its segment id matches:
+      Map<String, byte[]> ids = HashMap.newHashMap(infos.size());
+      for (SegmentCommitInfo info : infos) {
+        ids.put(info.info.name, info.info.getId());
+      }
+      List<LeafReader> sameIds = new ArrayList<>(oldReaders.size());
+      for (LeafReader leaf : oldReaders) {
+        SegmentInfo info = ((SegmentReader) leaf).getSegmentInfo().info;
+        if (Arrays.equals(ids.get(info.name), info.getId())) {
+          sameIds.add(leaf);
+        }
+      }
+      oldReaders = sameIds;
+    }
     return StandardDirectoryReader.open(
-        directory, infos, getSequentialSubReaders(), subReadersSorter, executorService);
+        directory, infos, oldReaders, subReadersSorter, executorService);
   }
 
   @Override

Trade-offs

For NRT readers this path no longer reports an application that deleted and rebuilt the index; it returns a correct fresh reader instead (plain readers still throw, as the guard's upstream test asserts). Only openIfChanged(nrtReader, commit) changes; the writer-based reopen and the replicator's direct StandardDirectoryReader.open are untouched. The name reuse itself remains, so the merge-instance defect that it triggers still needs its own fix. Keying on SegmentReader.isNRT instead does not work: a pooled NRT segment reader without deletes has isNRT == false; a variant that passes a flag into getOldSegmentReader was also run green.

What the checks validated

  • Scratch worktree of main 645d4fe14a20 (removed afterwards), seed DEADBEEF: stock, the test fails through gradle; with A it passes.
  • The repro and 9 related suites with A, -Ptests.asserts=true and false: TestDirectoryReaderReopen (including the guard's own testDeleteIndexFilesWhileReaderStillOpen and testOpenIfChangedNRTToCommit), TestIndexWriterReader (including testAfterClose and testReopenNRTReaderOnCommit), TestDirectoryReader, TestRollback, TestTransactionRollback, TestIndexFileDeleter, TestIndexWriterDelete, TestNRTReaderWithThreads, TestSearcherManager: 143 tests, 7 skipped, 0 failures each time.
  • All of :lucene:core:test with A (assertions on): 8,898 tests (gradle's count), 322 skipped, 0 failures. checkGoogleJavaFormat and the forbidden-API checks of :lucene:core pass.
  • With A shadowed into the main jars: a plain main() probe returns the 2-document reader on ByteBuffersDirectory, MMapDirectory and NIOFSDirectory, also with OpenMode.CREATE, and SearcherManager.maybeRefresh() with a RefreshCommitSupplier succeeds. The study's two 8-test classes: every bug case passes, including the existing-commit case that collides on _1; the one remaining failure is a test that asserts the stock exception for the OpenMode.CREATE advice, which flips as expected.

B. Keep segment names unique across rolled-back sessions

Make a writer never re-issue a name whose files its deleter removed at startup: for example, do not delete unreferenced files newer than the last commit (rmuir's proposal in LUCENE-6171 to "only delete files older than the current commit point", never committed), at least until a commit has saved a counter past them; or skip extra names at writer startup (rmuir on #6965: "gobble up extra generations on IW bootup").

Trade-offs

Attacks the root cause and would also cover the merge-instance defect and #6965, but only a completed commit leaves a saved counter, and rollback() must not write one; keeping leftover files changes cleanup behaviour; a counter jump only makes collisions unlikely. Larger and riskier than A. Not run.

C. Make the exception message truthful

Say that a segment name can also be reused after rolled-back writer sessions, and suggest DirectoryReader.open(commit).

Trade-offs

Cheap, but the documented call still fails (the repro test still fails) and the message loses the clear abuse diagnosis it exists for. At most a complement to A. Not run.

Downstream note

Lucene's own SearcherManager, built on a writer with a RefreshCommitSupplier that returns a commit (10.3.0+), hits it too: maybeRefresh() throws the same exception after the two rollbacks.

How it was found

Model-based randomized testing, the study's non-formal baseline, found it first: random sequences of writer operations, NRT opens, rollbacks, commits and reopens on the stock jars, each reader checked against an executable reference model. TLA+, Alloy, P, Dafny, OpenJML and JBMC experiments re-found it after it was on their task list (hinted).

The method in full109 words
  • Method: model-based randomized testing (the study's non-formal baseline), 2026-10-22, main 7cecf79523: an executable reference model of writer and reader state, random operation sequences, a conformance check at every reader-producing step and automatic shrinking of failing sequences. First hit in the first run after 161 s (sequence 1,809).
  • Re-finds: TLA+ (TLC), Alloy, P, Dafny, OpenJML and JBMC experiments reached the same sequence; all were hinted (the candidate was on their task list), so none is a blind formal discovery.
  • Reduction: the study's two 8-test classes (3 of 8 fail in each) were minimized on 2026-10-29 to the one test above, and a control without the second rollback was run.

Prior reports, history and checks

Prior reports

No report of the reader-side failure (GitHub, JIRA archive, mailing lists, downstream projects and the web, 2026-10-31). Open #6965 (LUCENE-5903, since 2014) reports the writer half: a new writer re-issues a segment name whose files an earlier session's deleter removed.

Searches5 searches

Nearest items: #6965 / LUCENE-5903 (open; the writer half: "we start a new IW on the same dir, and the next segment it tries to create is _8 (how else would it know?)"; rmuir, on the Windows case where the files stay busy for both deleter runs: "I think its too hard to improve the situation for windows, its unavoidable really", the file-system case where this bug does not occur), #6993 / LUCENE-5931 (origin of the guard and its message), #6746 / LUCENE-5684 (the first guard), #6966 / LUCENE-5904 (the counter raise at startup, which covers only files that still exist), #14362 / #14417 (segment-counter collisions in segment replication; an opt-in advanceSegmentInfosCounter, 10.3.0), #7233 / LUCENE-6171 (an unmerged idea behind fix B), #9780, #9781, #10364 (other counters across writer sessions). Open PRs #16923, #16643, #16948 and #16571 touch these files but change none of the reuse, the guard, the startup deletion or the rollback. Limits: GitHub search matches tokens; list search returns snippets; no search proves absence.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene), JIRA archive, mailing lists, downstream, web2026-10-31the scripted prior-report search: 32 GitHub, 5 commit, 4 downstream, 32 mailing-list, 8 JIRA-archive and 6 web queries (1,411 hits, 5 high and 49 medium overlap, all triaged)no reader-side report; #6965 the writer half
GitHub (apache/lucene, all of GitHub for the exception text, downstream projects)2026-10-3149 manual searchesno report
Open PRs2026-10-31changed files of all 410 open PRs; 22 touch the relevant files, 7 diffs readnone changes reuse by name, the guard, startup deletion or the rollback counter
Mailing lists and web2026-10-3110 direct list queries (4 threads read in full) and 8 web queriesthe 2022 "customize segment names?" thread (a different, multi-writer trigger)
GitHub and lists (the study)2026-10-22/23study searches and public-list checkno report

History

The exception ships from 6.2.0 (LUCENE-5931, 2016) through 10.6.0 and main. From 4.3.0 (LUCENE-4898, 2013) to 6.1.0 the same call silently returned the stale 1-document reader; 4.2.1 and older refused the call. Unchanged upstream.

Versions tested4 rows

The exception comes from LUCENE-5931 (#6993, reported by Vitaly Funstein; commit by Mike McCandless, 2016-06-12): 6b0b119074f on branch_6x (first released in 6.2.0), twin 664e39292bd on master (7.0.0). It added the segment-id check before reusing a reader found by name. The wrong outcome before it starts with LUCENE-4898 (#5963, 2013-04-04): 34e56d51f0d on branch_4x (4.3.0), twin c18676ed4fb on trunk, which let openIfChanged(nrtReader, commit) reopen through the name-keyed path. From 4.3.0 to 4.10.4 even one rollback was enough; from 5.0.0 the one-rollback control passes (attributed to LUCENE-5969 by reading the code, not bisected). Releases between the executed ones follow from the code (git blame).

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedthe one test fails (gradle; harness -ea and -da)
6.2.0, 6.6.6, 7.0.0, 7.7.3, 8.0.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2, 10.6.0 affectedthe misleading IllegalStateException (history probe; the one test on 9.12.3, 10.5.2, 10.6.0)
4.3.0 to 6.1.0 (19 releases run) stale readerno exception: a reader labelled with the new commit that still shows the rolled-back 1-document segment
4.0.0 to 4.2.1 call refusedIllegalArgumentException: the NRT reader cannot reopen on a commit

Independent checks

Study verifiers (5 "new bug", 2 "intended"), an adjudication (new bug, low), a reproducer on current main, a read-only cross-model reviewer (OpenAI GPT-5.6: "not real" on procedure, root cause agreed) and a tie-break with an independent probe (real, medium confidence) checked it. A noise triage challenged it; the challenger kept it.

Checks and verdicts235 words

Each check re-ran the repro except the cross-model review, which could only read; a separate re-run of the test failed with assertions on and off.

Each independent check and its verdict
CheckVerdictWhat it found
study verifiers 5 new bug / 2 intendedVerifiers (the MBT and JBMC verifiers) read the guard as intended behaviour with a misleading message.
study adjudication new bug, lowRe-ran both 8-test classes (3 of 8 fail in each); the controls (one rollback, middle writer closed, WindowsFS) pass; a javadoc-only fix is not enough because the message is false.
noise audit noise candidate, then keep-claimThe triage read the defect as the guard's wording only (results never wrong, contrived trigger); the challenger kept the claim (the documented call fails with a false diagnosis) and asked for the one-test repro used here.
reproducer on current main realFails on main -ea and -da (10 of 10), 10.5.2 and 9.12.3; the one-rollback control passes.
cross-model review, refute lens not real (procedural, medium)Read-only, could not run code; agreed with the root cause and rated the intended-behaviour argument "strong"; its main point, the AlreadyClosedException note, is on the writer-based overloads.
Second adjudication real, medium confidence, low severityIndependent main() probe with only lucene-core: fails on three directory types and through SearcherManager, on main and 10.5.2; a fix shadow passes it and 53 upstream reopen tests.

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

Finding record

LFV-015, snapshot of 2026-11-06

Registry title
After rolled-back segment names are reused, reopening an old NRT reader throws a misleading IllegalStateException
Area
index / IndexWriter NRT
Priority
Low, rank 948: Reopening an old NRT reader after rolled-back segment names are reused throws a misleading IllegalStateException, a wrong error type in an unusual session sequence that matters mainly as LFV-004's enabler.
Impact class
wrong error
Impact note
Default configuration; needs an NRT reader kept open across two consecutive rolled-back, never-committed writer sessions (the second session's startup deletes the first one's leftover files and its rollback discards the raised segment counter), after which a later writer reuses the segment name and commits, and the old reader is reopened on that commit; low severity on its own, but it enables the merge-instance re-open bug.
Summary
You open an NRT reader from a writer that is then rolled back without committing, a second writer is opened and rolled back too, a third writer reuses the same segment names and commits, and you call openIfChanged(oldReader, commit). You expect a new reader or an AlreadyClosedException, as documented. Instead the LUCENE-5931 guard throws an IllegalStateException that blames the application for removing index files, although Lucene's own IndexFileDeleter deleted them, and its OpenMode.CREATE advice also fails.
Introduced
34e56d51f0d (branch_4x; trunk twin c18676ed4fb), LUCENE-4898 'openIfChange now allows targeting an IndexCommit from an NRT reader' (#5963, Michael McCandless): openIfChanged(nrtReader, commit) now reopens through the path that reuses the old reader's segment readers by segment name only, so from 4.3.0 the bug's call sequence silently returns a stale 1-document reader; the misleading IllegalStateException comes from 6b0b119074f (branch_6x, first released in 6.2.0; master twin 664e39292bd, first released in 7.0.0), LUCENE-5931 'detect when segments were (illegally) replaced when re-opening an IndexReader' (#6993, Mike McCandless), which added the segment-id guard in place of the stale reader [read: git show, git blame, tag containment; exec: the history probe on every boundary release from 4.0.0 to 10.6.0 and main] (2013-04-04 (LUCENE-4898, the stale-reader form, first released in 4.3.0); 2016-06-12 (LUCENE-5931, the misleading IllegalStateException, first released in 6.2.0))
First affected version
4.3.0 [exec] as a silently stale reader (openIfChanged(r, commit) returns the rolled-back 1-document segment instead of the commit's 2 documents, 4.3.0 to 6.1.0); 6.2.0 [exec] is the first release with the misleading IllegalStateException; 4.0.0 to 4.2.1 refuse the call with IllegalArgumentException [exec]
Affected versions
4.3.0 to 6.1.0: silently stale reader [exec: 4.3.0, 4.3.1, 4.4.0, 4.5.0, 4.5.1, 4.6.0, 4.6.1, 4.7.0, 4.7.1, 4.7.2, 4.8.0, 4.8.1, 4.9.0, 4.10.0, 4.10.1, 5.0.0, 5.5.5, 6.0.0, 6.1.0]; 6.2.0 to 10.6.0 and main 645d4fe14a20: misleading IllegalStateException [exec: 6.2.0, 6.6.6, 7.0.0, 7.7.3, 8.0.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2, 10.6.0 and main; the one-test repro on 9.12.3, 10.5.2, 10.6.0 and main]; releases in between follow from the code [read: git blame]; not affected: 4.0.0 to 4.2.1 (call refused) [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 disputed it (the study's other checks rated it real); not yet triaged by a person.
Found by
non-formal baseline: randomized/MBT; also reached by TLA+/TLC, Alloy, P, Dafny, JBMC, KeY/OpenJML
Upstream and prior reports
no report of the reader-side failure found (study searches 2026-10-22/23, public-list check 2026-10-20..23, and a prior-report search on 2026-10-31: 87 scripted queries over GitHub issues, PRs and commits, downstream projects, the dev@, java-user@, issues@ and commits@ lists, the JIRA archive and the web, manual GitHub, list and web searches, and the changed files of all 410 open apache/lucene PRs); related, not this bug: open #6965 (LUCENE-5903, 2014) reports the writer half of the mechanism, a later IndexWriter re-issuing a never-committed segment name whose files an earlier session's deleter removed, without readers, the second rolled-back writer or the misleading guard; #6993 (LUCENE-5931) added the guard and its message; not fixed upstream (main 2f424fd428da, 2026-10-30)
Lucene commit tested
main 645d4fe14a20 (2026-10-28): the one-test repro through gradle in a scratch worktree (assertions on; removed afterwards) and through the hunt repro harness (assertions on and off, 10 of 10 runs with assertions off); fix A through gradle (the repro and 9 related suites, 143 tests, assertions on and off; all of :lucene:core:test, 8,898 tests, assertions on; format and forbidden-API checks); fix A shadowed into the main jars with a plain main() probe on three directory types and through SearcherManager, and with the study's two 8-test classes. The 10.5.2 and 9.12.3 release jars with the one-test repro (assertions on and off). History probe on 4.0.0 to 10.6.0. Upstream main 2f424fd428da (2026-10-30) has no commit touching StandardDirectoryReader.java, IndexFileDeleter.java, IndexWriter.java, DirectoryReader.java or SegmentInfos.java since 645d4fe14a20. OpenJDK 25.0.3, Linux aarch64 (the only platform run).

Lucene and upstream sources

The finding in 30 seconds
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:1143, 2169-2181, 2566-2584, 2601, 2643; DocumentsWriter.java:125-127; SegmentInfos.java:605-621, 649 (read)
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/index/IndexFileDeleter.java:211-233, 257, 318-327 (read)
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/index/StandardDirectoryReader.java:148, 249-261, 263-286, 407-414, 456-459, 496-511 (read)
Who is affected
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/search/SearcherManager.java:194-205; RefreshCommitSupplier.java:30-36; lucene/CHANGES.txt:1617 (GITHUB#14443, 10.3.0) (read)
Why it looks unintended
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/index/DirectoryReader.java:276-287, 330-332, 372-374; IndexWriter.java:103-108, 493, 507-509, 2173-2177; StandardDirectoryReader.java:274-283, 456-459 (read)
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/util/FileDeleter.java:176-181; lucene/CHANGES.txt:9027-9029, 12286-12290, 14052-14054 (read)
  • a Lucene main checkout lucene/core/src/test/org/apache/lucene/index/TestDirectoryReaderReopen.java:649-671, 1043-1090; TestIndexWriterReader.java:716-741, 1179 (read)

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.