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.
- Each new segment gets a name,
_0,_1, ..., when the writer starts it (at the firstaddDocument), 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. - 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).
- 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.
_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.- 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. - 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. - 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 .... - Why: writer 2's startup moved its name counter past the leftover
_0files and deleted them; its rollback lost the counter. So writer 3 named its new segment_0again, with a new id. The reopen matches old segment readers by name, sees the other id and throws a guard meant for applications that ranrm -rf. - 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.
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.
// 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 | segment counter | files on disk | note |
|---|---|---|---|
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.* kept | no commit; r still holds the files |
| open w2 | w2: 0, raised to 1 | none | infoStream: "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() | lost | none | no commit, so the raised counter is gone |
w3 adds 2 docs, close() | w3: 0, then 1 | _0.*, segments_1 | a 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 withopenIfChanged(reader, commit). Lucene'sSearcherManagermakes this call when built on a writer with aRefreshCommitSupplierthat returns a commit (SearcherManager.java:199-200, since 10.3.0; the default supplier returns null, which takes the writer path): after the same sequencemaybeRefresh()throws the same exception, while without the second writer it refreshes to 2 documents. - Configuration. Default
IndexWriterConfig, public API, single thread; the plainmain()probe (lucene-core only) uses the stock codec. Executed onByteBuffersDirectory, and onMMapDirectoryandNIOFSDirectoryon Linux. File systems that refuse to delete open files avoid it (the study'sWindowsFScontrol, 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
IllegalStateExceptionthat says the application illegally removed index files; its advice (IndexWriter.deleteAllorOpenMode.CREATE) does not apply, andOpenMode.CREATEwas run: the same exception. No wrong result was observed: the old reader keeps working andDirectoryReader.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
addIndexesof an old leaf can copy another segment's data on main. That one needs its own fix. - Impact class: wrong error; priority low.
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.
/*
* 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();
}
}
}
# $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 withnumDocs() == 2.- Observed
IllegalStateException: same segment _0 has invalid doc count change; likely you are re-opening a reader after illegally removing index files yourself ...
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.
| lucene | main 645d4fe14a20; also the 10.5.2 and 9.12.3 release jars (assertions on and off) |
|---|---|
| jdk | OpenJDK 25.0.3 |
| os | Linux aarch64 |
# 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)- 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) andTestIndexWriterReader.testReopenNRTReaderOnCommit(:1179). - Old readers keep working across writer sessions.
IndexWriter.java:103-108: withOpenMode.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-2177bumps 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:
- The note is written on
openIfChanged(DirectoryReader, IndexWriter)and its executor variant (DirectoryReader.java:330-332,:372-374) and on the package-privateIndexWriter.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.testAfterCloseasserts theAlreadyClosedExceptiononly foropenIfChanged(r). - 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.
- 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.
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=trueandfalse:TestDirectoryReaderReopen(including the guard's owntestDeleteIndexFilesWhileReaderStillOpenandtestOpenIfChangedNRTToCommit),TestIndexWriterReader(includingtestAfterCloseandtestReopenNRTReaderOnCommit),TestDirectoryReader,TestRollback,TestTransactionRollback,TestIndexFileDeleter,TestIndexWriterDelete,TestNRTReaderWithThreads,TestSearcherManager: 143 tests, 7 skipped, 0 failures each time. - All of
:lucene:core:testwith A (assertions on): 8,898 tests (gradle's count), 322 skipped, 0 failures.checkGoogleJavaFormatand the forbidden-API checks of:lucene:corepass. - With A shadowed into the main jars: a plain
main()probe returns the 2-document reader onByteBuffersDirectory,MMapDirectoryandNIOFSDirectory, also withOpenMode.CREATE, andSearcherManager.maybeRefresh()with aRefreshCommitSuppliersucceeds. 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 theOpenMode.CREATEadvice, which flips as expected.
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.
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.
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.
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.
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.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene), JIRA archive, mailing lists, downstream, web | 2026-10-31 | the 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-31 | 49 manual searches | no report |
| Open PRs | 2026-10-31 | changed files of all 410 open PRs; 22 touch the relevant files, 7 diffs read | none changes reuse by name, the guard, startup deletion or the rollback counter |
| Mailing lists and web | 2026-10-31 | 10 direct list queries (4 threads read in full) and 8 web queries | the 2022 "customize segment names?" thread (a different, multi-writer trigger) |
| GitHub and lists (the study) | 2026-10-22/23 | study searches and public-list check | no report |
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).
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | the 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 | affected | the 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 reader | no 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 refused | IllegalArgumentException: the NRT reader cannot reopen on a commit |
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.
| Check | Verdict | What it found |
|---|---|---|
| study verifiers | 5 new bug / 2 intended | Verifiers (the MBT and JBMC verifiers) read the guard as intended behaviour with a misleading message. |
| study adjudication | new bug, low | Re-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-claim | The 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 | real | Fails 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 severity | Independent 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. |
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.
- PNG 491 KB
Finding record, Lucene and upstream sources, repro files and diagram notes936 words
- 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 plainmain()probe on three directory types and throughSearcherManager, 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 main2f424fd428da(2026-10-30) has no commit touchingStandardDirectoryReader.java,IndexFileDeleter.java,IndexWriter.java,DirectoryReader.javaorSegmentInfos.javasince645d4fe14a20. OpenJDK 25.0.3, Linux aarch64 (the only platform run).
- 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)
- TestReopenAfterTwoRollbacks.java (one test):
repro/LFV-015/src/org/apache/lucene/index/TestReopenAfterTwoRollbacks.java(45 lines)
assets/LFV-015/bug.png(491 KB)
