Every commit stores a version number, and a plain reader decides whether it is stale by comparing its version with the newest commit's. A writer opened on a reader's commit point copies that reader's version and ignores newer commits, so its commits can repeat a version that a newer commit already has.
- Every commit (
segments_N) records a version that grows as the index changes. A reader fromDirectoryReader.open(dir)treats "the newest commit has my version" as "nothing changed": that isisCurrent(), andopenIfChangedandSearcherManagerrefreshes rely on it. - With a deletion policy that keeps old commits (
NoDeletionPolicy, aSnapshotDeletionPolicysnapshot), you can open anIndexWriteron an old commit withIndexWriterConfig#setIndexCommitand continue from there: a rollback. - The commit point can come from
DirectoryReader.listCommits(dir)or from an open reader'sgetIndexCommit(), which lets the writer reuse that reader. Since 5.3.0 the two take different paths in theIndexWriterconstructor.
segments_3, and the reader on segments_3 sees no change. Select the still to open it at full size. Generated with AI, checked against the code.- Keep every commit (
NoDeletionPolicy). Commit 1 document (segments_1, version 4) and open readerolderon it; add and commit 2 more, one at a time (segments_3: 3 documents, version 10), and open readernewer. - Go back: open a writer with
setIndexCommit(older.getIndexCommit())and commit twice. You expectnewer.isCurrent()to turn false: its javadoc says it "checks if any further commits ... have occurred in the directory". - Instead the writer counts on from
older's version 4, not from the newest commit's 10. Its commits get versions 8, then 10:segments_5holds 1 document but carries the same version assegments_3. isCurrent()compares only versions, sonewer.isCurrent()returns true,openIfChanged(newer)returns null and aSearcherManagerrefreshed only after both commits keeps serving the 3 old documents. The next commit (version 12) ends it.- Control: the same commit taken from
DirectoryReader.listCommitskeeps the versions above 10, as this very call did in 5.2.1. The index on disk is correct throughout.
Applications that roll back through an open reader's commit point (setIndexCommit(reader.getIndexCommit()), Expert) while a reader or SearcherManager stays on a newer commit. Stale only when a new commit hits that commit's version exactly (14 of 96 snapshot-rollback histories) and until the next commit; deleted documents stay findable meanwhile. The index on disk is correct.
Mechanism, terms and numbers754 words
IndexWriterConfig#setIndexCommit can open a writer on an older commit that a deletion policy kept, and its javadoc invites passing an open reader's DirectoryReader#getIndexCommit. Since LUCENE-6524 (5.3.0) such a reader-backed commit takes its own branch of the IndexWriter constructor, which clones the reader's SegmentInfos, version included, and never reads the newest commit on disk; the branch taken for the same commit from DirectoryReader.listCommits keeps the newest commit's version "to preserve write-once. This is important if readers are open against the future commit points." So the new writer's versions restart below commits already in the directory, and one of them can equal the version of a newer commit with different content: in the repro segments_3 (3 documents) and segments_5 (1 document) are both version 10. A reader opened on segments_3 compares only versions, so it reports isCurrent() == true, openIfChanged returns null, a refreshed SearcherManager keeps serving the superseded commit (a deleted and committed document stays findable) and SearcherLifetimeManager.record throws IllegalArgumentException, until a later commit moves the version on. The index on disk is correct. It needs the Expert rollback through a reader's commit point while another reader stays on a newer commit, and an exact version match that depends on operation counts. An 11-line fix that raises the version to the newest commit's passes the repro and the whole core test suite.
// IndexWriter.java:982-990: a commit from DirectoryReader#getIndexCommit carries its reader
IndexCommit commit = config.getIndexCommit();
StandardDirectoryReader reader = commit == null ? null : commit.getReader(); // (condensed)
...
} else if (reader != null) { // :1021 the reader branch
...
segmentInfos = reader.segmentInfos.clone(); // :1050 version = the reader's (4)
...
if (reader.writer != null) { // :1062 only for an NRT reader:
// In case the old writer wrote further segments (which we are now dropping),
// update SIS metadata so we remain write-once: // :1067-1068
segmentInfos.updateGenerationVersionAndCounter(reader.writer.segmentInfos);
...
}
rollbackSegments = lastCommit.createBackupSegmentInfos(); // :1073 no look at the newest commit
} else { // :1074 the commit-point branch
String lastSegmentsFile = SegmentInfos.getLastCommitSegmentsFileName(files);
segmentInfos = SegmentInfos.readCommit(directoryOrig, lastSegmentsFile); // :1085 newest commit
if (commit != null) {
// Swap out all segments, but keep metadata in
// SegmentInfos, like version & generation, to
// preserve write-once. This is important if
// readers are open against the future commit
// points. // :1088-1092
...
segmentInfos.replace(oldInfos); // :1103 keeps the newest version (10)
changed();- Which branch runs.
DirectoryReader#getIndexCommitreturns a commit that carries the reader (StandardDirectoryReader.java:570-574,getReader()at:644-646).DirectoryReader.listCommitsbuilds the same kind of commit with no reader (DirectoryReader.java:456,:481), so itsgetReader()returns null; aSnapshotDeletionPolicy.snapshot()commit inheritsgetReader() == nullfromIndexCommit(IndexCommit.java:112-118). Both take the commit-point branch. - The reader branch copies the old version.
IndexWriter.java:1050clones the reader'sSegmentInfos, so the writer starts atsegments_1's version 4. It raises the version only from the reader's own writer (:1062-1071), and only for an NRT reader; a plain reader has none. It never readssegments_3(version 10). - The writer counts up from there. The constructor adds 2 at open (
:1192-1196, "we always assume we are carrying over incoming changes"), and each commit-data commit adds 2 more in the repro:
| after | newest commit | its version | newer (segments_3, version 10) isCurrent() |
|---|---|---|---|
| writer commit 1 | segments_4, 1 doc | 8 | false (right) |
| writer commit 2 | segments_5, 1 doc | 10 | true (wrong) |
| writer commit 3 | segments_6, 1 doc | 12 | false (right again) |
| with fix A | segments_4, _5, _6 | 14, 16, 18 | false at every step |
- File names never collide, versions do.
IndexFileDeleter.inflateGens(IndexFileDeleter.java:257) raises the generation from the files in the directory, so the new commits aresegments_4andsegments_5; nothing repairs the version. isCurrent()compares only versions. For a reader without a live writer,StandardDirectoryReader.java:529-540reads the newest commit and returnssis.getVersion() == segmentInfos.getVersion(): 10 == 10.openIfChangedreturns null whenisCurrent()(:477-482), soSearcherManager.refreshIfNeeded(SearcherManager.java:200) keeps the old searcher, andSearcherLifetimeManager.record, which keys searchers by version, throwsIllegalArgumentExceptionfor a searcher onsegments_5(SearcherLifetimeManager.java:146-161).
Not every history collides: adding one document instead of commit data gives version 9 and no stale reader. The version restarts from the older reader's on every such open; whether a commit then lands below, exactly on, or above a newer commit's version depends on the operation counts, and only the exact match makes a reader stale.
Exposure and likelihood332 words
- Who is hit. Applications that go back to an older commit through a reader's commit point,
new IndexWriter(dir, new IndexWriterConfig().setIndexCommit(reader.getIndexCommit())), an Expert path thesetIndexCommitjavadoc documents, while a reader orSearcherManageropened on the directory is still on a newer commit. The older commit must still be on disk, so a deletion policy has to keep it:NoDeletionPolicy, a custom policy, or aSnapshotDeletionPolicysnapshot. The reader whose commit is passed may be a plain reader or an NRT reader whose writer closed before a later writer session committed. - A realistic flow. A writer with
SnapshotDeletionPolicysnapshots commit 1 (documenta) and opens a reader on the snapshot, then commitsbandc; aSearcherManager(dir, null)servessegments_3. A rollback writer on the default deletion policy, opened withsetIndexCommit(snapReader.getIndexCommit()), addsdand commits. AftermaybeRefreshBlocking()theSearcherManagerstill servessegments_3(version 10, 3 documents) although the only commit on disk issegments_4(version 10, 2 documents). Run on main645d4fe14a20and, over 20 seeds, ona9779ef3c193. - How often. It needs an exact version match, which depends on operation counts. An independent enumeration of 96 single-commit rollback histories (snapshot,
SearcherManager, default policy on the rollback writer) left theSearcherManagerstale in 14 throughreader.getIndexCommit()and in 0 through the snapshot commit itself. No report was found in the 11 years since 5.3.0. - What goes wrong. Silent and transient:
isCurrent()true,openIfChangednull, a refreshedSearcherManagerkeeps serving the superseded commit, so a document that was deleted and committed is still found, until a later commit moves the version on (the follow-up's stale-searcher probe, maina9779ef3c193, 10.5.2 and 9.12.3).SearcherLifetimeManager.recordthrowsIllegalArgumentExceptionwhen two commits share a version. - What does not go wrong. The index on disk is correct: CheckIndex is clean and a freshly opened reader sees the newest commit. Not the default configuration. Assertions on or off make no difference.
- Impact class: silent wrong results; priority low (Expert path, rare, transient).
One JUnit test on the public API: ByteBuffersDirectory, empty documents, single thread, deterministic (the seed sets only the test framework's randomization; seeds DEADBEEF and 5EED1234, 5 iterations, fail the same way). The failure message prints both versions. Put it in lucene/core/src/test/org/apache/lucene/index/ and run it with gradle.
/*
* 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.Map;
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 TestWriterFromReaderCommitVersion extends LuceneTestCase {
private static IndexWriterConfig keepAllCommits() {
return new IndexWriterConfig().setIndexDeletionPolicy(NoDeletionPolicy.INSTANCE);
}
public void testWriterFromCommitReader() throws Exception {
Directory dir = new ByteBuffersDirectory();
IndexWriter w1 = new IndexWriter(dir, keepAllCommits());
w1.addDocument(new Document());
w1.commit();
DirectoryReader older = DirectoryReader.open(dir);
w1.addDocument(new Document());
w1.commit();
w1.addDocument(new Document());
w1.commit();
w1.close();
DirectoryReader newer = DirectoryReader.open(dir);
IndexWriter w2 = new IndexWriter(dir, keepAllCommits().setIndexCommit(older.getIndexCommit()));
w2.setLiveCommitData(Map.of("step", "1").entrySet());
w2.commit();
w2.setLiveCommitData(Map.of("step", "2").entrySet());
w2.commit();
// expected false (segments_5, 1 doc, came after the reader's segments_3, 3 docs); actual true
assertFalse(
"isCurrent() expected false but was true; reader version "
+ newer.getVersion()
+ ", latest commit version "
+ SegmentInfos.readLatestCommit(dir).getVersion(),
newer.isCurrent());
w2.close();
older.close();
newer.close();
dir.close();
}
}
# $REPRO holds TestWriterFromReaderCommitVersion.java; $LUCENE is a Lucene checkout
cp $REPRO/TestWriterFromReaderCommitVersion.java $LUCENE/lucene/core/src/test/org/apache/lucene/index/
cd $LUCENE
./gradlew :lucene:core:test --tests TestWriterFromReaderCommitVersion -Ptests.seed=DEADBEEF
- Expected
newer.isCurrent()is false after the writer's two commits (segments_5, 1 document, came aftersegments_3, 3 documents).- Observed
isCurrent()is true:isCurrent() expected false but was true; reader version 10, latest commit version 10.
More files, environment, full output163 words
The class sits in org.apache.lucene.index by Lucene test convention but uses nothing package-private. Use this one test as the regression test too.
The same failure, by the repro harness: main 645d4fe14a20 and the 10.6.0 jars, assertions on and off. The hunt's longer class (an NRT-reader variant, a SearcherLifetimeManager test and a listCommits control) fails 3 of 4 on main, 10.5.2 and 9.12.3; its control passes.
| lucene | main 645d4fe14a20 and 10.6.0: reproduced, assertions on and off; 5.3.0 to 10.5.2: reproduced by the hunt's probe and tests; 5.0.0 and 5.2.1: clean |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux aarch64 |
# gradle, main 645d4fe14a20, seed DEADBEEF, assertions on
TestWriterFromReaderCommitVersion > testWriterFromCommitReader FAILED
java.lang.AssertionError: isCurrent() expected false but was true; reader version 10, latest commit version 10
at org.apache.lucene.index.TestWriterFromReaderCommitVersion.testWriterFromCommitReader(TestWriterFromReaderCommitVersion.java:50)
# repro harness, the same message: main -ea, main -da, 10.6.0 -ea, 10.6.0 -da (1 run, 1 failed each)
# with fix A: the test passes (gradle, with 15 related suites, assertions on and off)isCurrent()javadoc: a reader fromopen"checks if any further commits ... have occurred in the directory"; two did.- The other branch of the same constructor keeps the newest version "to preserve write-once", because "readers are open against the future commit points".
- The same commit from
listCommitsworks, as did this very call in 5.2.1.
The full argument322 words
- Contract.
DirectoryReader.java:628-629(isCurrent): "If this reader was created by calling #open, then this method checks if any further commits (see IndexWriter#commit) have occurred in the directory.":637-638: "if this returns false, you should call #openIfChanged to get a new reader that sees the changes.":620-621(getVersion): "This version is advanced every time a change is made with IndexWriter." - The API invites the call.
IndexWriterConfig.java:235-237: "Expert: allows to open a certain commit point. ... This can also be used to open IndexWriter from a near-real-time reader, if you pass the reader's DirectoryReader#getIndexCommit." UpstreamTestIndexWriterFromReader#testAfterCommitThenIndexKeepCommitsopens a writer from a reader that is behind the latest commit; that test class never checksisCurrent()of a reader left on the newer commit. - Both branches keep the version on purpose. The commit-point branch keeps the version "to preserve write-once" (
IndexWriter.java:1088-1092;SegmentInfos.java:1084-1086:replace"keeps generation, version, counter so that future commits remain write once"). The reader branch itself updates the metadata "so we remain write-once" (IndexWriter.java:1067-1068), but only from the reader's own writer. - Same commit, two outcomes. The same commit from
listCommitsgives correct versions, and the same user call did in 5.2.1. Every other open path (create over an existing index, append, alistCommitscommit) keeps the version at or above the newest commit's. - Precedent.
CHANGES.txt:18287-18288, LUCENE-2513 (2010): "when opening writable IndexReader on a not-current commit, do not overwrite \"future\" commits." There a writableIndexReaderon an older commit overwrote a future commit'ssegments_Nfile (#3587): the file-level form of the same rule; here the file names are safe and the version is reused. Michael McCandless in LUCENE-3607 (#4681): "the version is still comparable across IRs opened at different times against the same index". - History: a side effect. LUCENE-6524 (#7582) added the reader branch so the existing call could reuse the open reader; its 16 comments never mention the version, write-once or
isCurrent.
The devil's-advocate check188 words
Strongest case for "intended": setIndexCommit is Expert and getIndexCommit is @lucene.experimental, and Lucene never documents commit versions as strictly increasing or unique (LUCENE-2805 only says the version moves on every change, not on every commit). Going back to an old commit starts a new branch of history, and versions across branches are not comparable.
Why it fails:
- On main every commit that is written gets a higher version than the writer's previous one (
IndexWriter.java:3828-3835, from LUCENE-6523, 2015), and a commit without changes is not written at all (:5934-5936). Here two commits with different content,segments_3(3 documents) andsegments_5(1 document), share one version, and a reader that the javadoc says detects "any further commits" does not. - Lucene already treats going back as one history in this directory: the commit-point branch keeps the newest version for exactly this reason, and the result must not depend on whether the
IndexCommitobject came fromlistCommitsor from a reader. - Expert lowers the reach, not the correctness bar; no javadoc, CHANGES or MIGRATE entry, test or issue found documents a weaker guarantee for a reader's commit.
Recommended A. In the reader branch, never go below the newest commit's version (11 lines)
In the reader branch of the IndexWriter constructor, raise the version to the newest commit's before the writer starts, as the commit-point branch already does (11 lines in IndexWriter.java). The repro and 15 related suites pass (294 tests, assertions on and off), as do all 8,898 core tests.
All options and trade-offs3 options · 661 words
One code fix was run; A is recommended. Workaround for users today: pass the matching commit from DirectoryReader.listCommits(dir) (same segments file name as reader.getIndexCommit()) instead of the reader's commit; it takes the commit-point branch, which keeps the versions right. This works for plain readers of a commit; an NRT reader's uncommitted changes are in no listed commit. The writer then opens its own segment readers instead of sharing the held reader's (ReaderPool.java:90-112 pre-enrolls them only for a reader-backed commit).
A. In the reader branch, never go below the newest commit's version (11 lines) (validated by the checks) (recommended)
Read the newest segments_N (the directory listing is taken under the write lock) and raise segmentInfos.version to its version when it is higher. The writer still reuses the reader's segments and open segment readers; the constructor then adds 2 as before, so its first commit is above the newest commit's version. Cost: one extra read of the newest commit file at open, as the commit-point branch already does.
--- a/lucene/core/src/java/org/apache/lucene/index/IndexWriter.java
+++ b/lucene/core/src/java/org/apache/lucene/index/IndexWriter.java
@@ -1070,6 +1070,17 @@ public class IndexWriter
lastCommit.updateGenerationVersionAndCounter(reader.writer.segmentInfos);
}
+ // Like the commit-point branch below, never go below the newest commit's version, to
+ // preserve write-once: readers may be open against commits newer than the reader's:
+ String latestSegmentsFile = SegmentInfos.getLastCommitSegmentsFileName(files);
+ if (latestSegmentsFile != null) {
+ long latestVersion =
+ SegmentInfos.readCommit(directoryOrig, latestSegmentsFile).getVersion();
+ if (latestVersion > segmentInfos.version) {
+ segmentInfos.version = latestVersion;
+ }
+ }
+
rollbackSegments = lastCommit.createBackupSegmentInfos();
} else {
// Init from either the latest commit point, or an explicit prior commit point:Trade-offs
It fixes new opens only; commits that already share a version keep it. It compares with the newest commit only, as the commit-point branch does. On an index where an affected release already wrote a newest commit with a lower version than an older one (segments_4 version 8 after segments_3 version 10), a later collision with an older commit's version is not ruled out: through the commit-point branch, which A does not change (a plain commit() right after opening segments_1 from listCommits writes segments_5 version 10, and a reader on segments_3 reports isCurrent() == true, stock and with A), or through the reader branch when the older commit's version is more than 2 above the newest's. In this example the reader branch with A commits at 11 or higher. B, applied to both branches, closes that.
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 15 related suites with A,
-Ptests.asserts=trueandfalse:TestIndexWriterFromReader,TestDeletionPolicy,TestSnapshotDeletionPolicy,TestPersistentSnapshotDeletionPolicy,TestDirectoryReader,TestDirectoryReaderReopen,TestTransactionRollback,TestRollback,TestIndexWriterCommit,TestIndexCommit,TestIndexFileDeleter,TestIndexWriterReader,TestCrash,TestIndexWriter,TestSearcherManager: 294 tests, 5 skipped, 0 failures each time. - All of
:lucene:core:testwith A (assertions on): 8,898 tests in 749 classes, 322 skipped, 0 failures. - With A shadowed into the main jars: the hunt's 4 tests (incl.
SearcherLifetimeManagerand the NRT-reader variant) and the snapshot-rollback probe pass, 5 of 5 (stock: 4 of 5 fail); the version probe gives 14, 16, 18.
Read every segments_N in the listing (or reuse the commits the file deleter loads at init) and take the maximum. Raised by the cross-model root-cause reviewer.
Trade-offs
Also covers indexes already made non-monotonic by an affected release. Costs one read per retained commit at open (many with NoDeletionPolicy), and the commit-point branch would need the same change to stay consistent. Not run.
For example also the commit generation.
Trade-offs
Hides the isCurrent/openIfChanged symptom but leaves two commits with different content and one version on disk, which SearcherLifetimeManager (keyed by version) still trips on. The hunt's validated isCurrent patch for the sibling LFV-012 leaves all 3 failures of the hunt's class. Not recommended alone.
Elasticsearch and OpenSearch pass listCommits commits or their own IndexCommit subclasses to setIndexCommit, which take the unaffected path, and Solr's main code does not call it (code reading of their default branches, not tested).
Semi-formal: while modelling "open a writer from a reader's commit" for a bounded-exhaustive harness of reader reopen behaviour (every sequence of up to 4 operations, 32 configurations, stock jars), the finder read that this path ignores the newest commit's version. The harness then flagged it on its own, and it was reduced to one JUnit test.
The method in full158 words
- Method (semi-formal): bounded-exhaustive translation validation in a hunt unit on reopen equivalence. Every sequence of up to k operations over a 22-28-operation alphabet (adds, deletes, doc-values and soft updates,
addIndexes, flushes, merges, commits, rollbacks, writer reopens, writers from a reader's commit, NRT reopens) ran on the stock jars in 32 configurations, with oracles for reopen equivalence,isCurrentsoundness and write-once commit versions. - Signal. The hypothesis came from reading the constructor while modelling the operation "writer from a reader's commit"; the harness then witnessed it independently at k=4 (308 sequences per configuration group; shortest: force-merge, commit, writer from reader commit, commit, giving
segments_2andsegments_3both version 9). - Attribution. With this fix sketch shadowed in, together with other bugs' sketches, the "commit versions not increasing" signature disappeared in all 32 configurations; with only the other sketches it remained.
- Reduction. The finder wrote a public-API JUnit (4 tests, one control); it was later minimized to the one test above.
No prior report: three tool runs over GitHub issues, PRs and commits, the JIRA archive, the mailing lists and downstream repositories, plus manual and web searches (2026-10-26), refreshed on GitHub 2026-10-31. Closest: #13151 (other branch of the same constructor, opposite effect) and LUCENE-2513 (2010, the file-level form of the same rule, on a removed code path).
Searches4 searches
Nearest items, none this bug: #13151 (open; the commit-point branch, an unneeded commit), #3587 / LUCENE-2513 (fixed 2010; a writable IndexReader on a not-current commit overwrote a future commit's segments_N file: the file-level form of the same rule, on a removed code path; a precedent), #9780 / #9781 / #10364 (per-segment generations when a writer opens an old commit), #10082 / LUCENE-9040 (IndexCommit identity of NRT readers), #8550 / LUCENE-7499 (a data race on the version). Origin, not a report: #7582 / LUCENE-6524. Open PRs #16948 (the commit-point branch, for #13151) and #16923 (the NRT isCurrent path) touch these files but not the reader branch or the non-NRT isCurrent. Limits: GitHub search is capped per query; list snippets are short; no search proves absence.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene) | 2026-10-26 | three runs of the scripted prior-report search (core terms, symptoms, mechanism; 16 issue/PR and 3 commit queries each) plus 18 manual searches | no report; #13151 and LUCENE-2513 closest |
| Open PRs | 2026-10-26 | all 406 open PRs listed with their changed files; 5 diffs read | 16 touch IndexWriter.java; none changes how the reader branch sets the version |
| JIRA archive, mailing lists (dev@, java-user@, issues@, commits@), downstream repositories, web | 2026-10-26 | 4 JIRA and 20 list queries per run, 9 manual list queries, 5 downstream GitHub searches, 12 web queries | no report |
| GitHub refresh | 2026-10-31 | 16 issue/PR and 3 commit queries (208 hits); the 17 open PRs created since 2026-10-26 with their files; gh pr diff of #16948 and #16923 | no report; #16948 and #16923 touch other code paths |
Introduced in 5.3.0 by LUCENE-6524 (2015), which sent a reader's commit point down a new constructor branch that copies the reader's version; 5.2.1 runs the same call correctly. Every release run from 5.3.0 to 10.6.0, and main, is affected; unchanged upstream.
Versions tested6 rows
Introduced by LUCENE-6524 "Create an IndexWriter from an already opened NRT or non-NRT reader" (#7582, Michael McCandless): 2d0ac9bb7f2 on trunk (2015-06-28, first released in 6.0.0) and the branch_5x backport 7ab3ca7d608 (first released in 5.3.0). It made the reader's IndexCommit carry the reader and added the reader branch, so the existing call setIndexCommit(reader.getIndexCommit()) left the version-keeping commit-point branch. Later commits only reformatted the branch or changed its messages and checks. 8.8.2-8.11.3 are counted by inference only. Unchanged on upstream main 2f424fd428da and in 10.6.0.
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | the one test fails (gradle; harness -ea and -da) |
| 10.6.0 | affected | the one test fails (-ea and -da) |
main a9779ef3c193, 10.5.2, 9.12.3 | affected | the hunt's 4-test class: 3 of 4 fail (-ea and -da on main); control passes |
| 5.3.0, 5.5.5, 6.0.0, 6.6.6, 7.7.3, 8.11.4, 9.0.0, 10.0.0 | affected | history probe: the writer commits version 8, then 10 = segments_3's; isCurrent() true, openIfChanged() null; listCommits control clean |
| 5.2.1 | last before | history probe: the same call writes versions 13 and 15, like the listCommits control (-ea and -da) |
| 5.0.0 | not affected | history probe: not affected |
An independent reproducer (own JUnit and a snapshot-rollback probe), a devil's advocate on intended behaviour, an adjudication (confirmed, high) and a credibility audit re-ran repros and found it real. A cross-model reviewer (OpenAI GPT-5.6, reading only) agreed on the root cause in all the reviews; one verdict was "not real" on procedure only, as it could not run code.
Checks and verdicts312 words
Each check had its own lens. The reproducer, the devil's advocate, the adjudication and the audit ran repros themselves; for the read-only cross-model reviews a separate run re-ran the hunt's repro (it failed each time).
| Check | Verdict | What it found |
|---|---|---|
| independent reproducer | real | Own JUnit fails 3 of 4 on main (-ea and -da), 10.5.2 and 9.12.3, 60 of 80 over 20 seeds, control passes; the snapshot-rollback probe with the default policy leaves a SearcherManager stale, 20 of 20 seeds; passes with the fix; LFV-012's patch does not help. |
| cross-model review, refute lens | not real (procedural, low) | Read-only, could not run code; agreed with the root cause, called the intended case weak and named no flaw; a separate re-run failed 3 of 4. |
| cross-model review, works-as-designed lens | real, medium | Strongest counter-argument: Expert API, versions not promised unique across rollbacks; judged weak against the isCurrent javadoc and the sibling branch's write-once comment. |
| cross-model review, root-cause lens | real, medium | Root cause in the constructor's reader branch, not in isCurrent; the verifier's runs pass with the fix and 85 related upstream tests pass at 3 seeds; asked about the maximum over retained commits (option B). |
| intended-behaviour devil's advocate | real | Examined seven not-a-bug arguments; none survives. Re-runs fail on main and 10.5.2; 53 related upstream tests pass stock and with the fix. |
| adjudication | confirmed, high | Re-ran the reproducer's JUnit at a new seed (3 of 4 fail, -ea and -da) and the hunt's JUnit with -da; impact class silent wrong results; described the backwards version as the mechanism and the shared version as the contract break. |
| credibility audit | keep | Re-ran the hunt's JUnit at new seeds (3 of 4 fail, -ea and -da); own enumeration: 14 of 96 snapshot-rollback histories stale through the reader's commit, 0 of 96 through the snapshot commit. |
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 492 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,516 words
- Registry title
- IndexWriter opened from an older reader's IndexCommit takes SegmentInfos.version from that reader, not from the newest commit, so its commits can reuse the version of a newer commit and a reader still open on that commit reports isCurrent()=true and openIfChanged returns null
- Area
- index / IndexWriter construction from a reader's IndexCommit (SegmentInfos.version; DirectoryReader.isCurrent and openIfChanged)
- Priority
- Low, rank 935: A stale reader reports isCurrent() as true after an exact version collision, but only on the Expert reader-IndexCommit rollback path, and it clears at the next change while the index on disk stays correct.
- Impact class
- silent wrong results
- Impact note
- Hits only applications that go back to an older commit by opening an IndexWriter with the Expert IndexWriterConfig#setIndexCommit(reader.getIndexCommit()) on a reader of a commit that a deletion policy or snapshot still keeps (NoDeletionPolicy, or a SnapshotDeletionPolicy snapshot followed by a rollback writer on the default policy), while a reader, SearcherManager or SearcherLifetimeManager still holds a newer commit; no scale is needed, but the visible symptom needs one of the new writer's commits to get exactly the newer commit's version, which depends on operation counts (it did for commit-data-only commits, for one added document and for some delete and update histories, but not for every history): then isCurrent() stays true, openIfChanged returns null and a refreshed SearcherManager keeps serving the superseded commit (a committed delete or a restricting update stays unseen) until a later change moves the version on, and SearcherLifetimeManager.record throws IllegalArgumentException; the index on disk is correct (CheckIndex clean, a fresh reader sees the latest commit). The same commit taken from DirectoryReader.listCommits, or any IndexCommit not obtained from a reader (what the Elasticsearch and OpenSearch main code pass; Solr's main code has no setIndexCommit call; read, not tested), takes the other constructor branch and is not affected.
- Summary
- You keep an older commit (for example with NoDeletionPolicy or a snapshot), open a reader on it, and later go back to it by opening a new IndexWriter with setIndexCommit(thatReader.getIndexCommit()) and committing, while another reader is still open on a newer commit. You expect that reader to notice the new commits (isCurrent() false, openIfChanged returning the latest commit), as it does when the same commit is taken from DirectoryReader.listCommits. Instead the writer restarts SegmentInfos.version from the old reader instead of from the newest commit, so one of its commits can get exactly the newer commit's version (segments_3 holding [a, b, c] and segments_5 holding [a] are both version 10), and the stale reader reports isCurrent()=true, openIfChanged returns null and SearcherLifetimeManager.record throws IllegalArgumentException.
- Introduced
- 2d0ac9bb7f2276d5a277efda29e676096ea034b4 (trunk, svn r1687992, first released in 6.0.0) and its branch_5x backport 7ab3ca7d608 (svn r1687993, first released in 5.3.0): LUCENE-6524 'Create an IndexWriter from an already opened NRT or non-NRT reader' (GitHub #7582, by mikemccand; pre-GitHub, reviewed as patch attachments, no PR). It added the 'else if (reader != null)' branch of the IndexWriter constructor and made StandardDirectoryReader.ReaderCommit carry its reader, so the existing call setIndexCommit(reader.getIndexCommit()) left the commit-point branch, which keeps the newest commit's version, for a branch that takes the version from the reader [read: git blame and git log -L of IndexWriter.java:985-990 and :1021-1073 on main; later commits did not change its version handling (reformatting, messages, the created-major check of LUCENE-9669); exec at release granularity: 5.2.1 not affected, 5.3.0 affected]. The issue's 16 comments never mention the version, write-once or isCurrent. (2015-06-28)
- First affected version
- 5.3.0 [exec: the writer opened from the older reader's commit wrote v8, then v10, the version of the newer commit segments_3:v10; isCurrent()=true and openIfChanged()=null, -ea and -da]; 5.2.1, the last release before, is not affected [exec: the same call wrote v13 and v15, exactly like the listCommits control, -ea and -da]
- Affected versions
- every release from 5.3.0 through 10.6.0 (10.6.0 executed with the one-test repro, -ea and -da, 2026-10-31), including 9.12.3 (the latest 9.x), and main a9779ef3c193; the previous hunt main ea57360912ed too (the unit's k=4 enumeration ran on its jars, and none of the 7 commits in between touches IndexWriter, StandardDirectoryReader, SegmentInfos or DirectoryReader); never fixed. [exec, history probe, public API only] 5.3.0, 5.5.5, 6.0.0, 6.6.6, 7.7.3, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 and main are affected (5.3.0, 10.5.2 and main run with -ea and -da), the listCommits control is clean everywhere; 5.0.0 and 5.2.1 are not affected. [exec, the candidate JUnit] 9.12.3, 10.5.2 and main with -ea and -da: 4 run / 3 fail, control passes; the independent reproduction's own JUnit fails the same way on main, 10.5.2 and 9.12.3, the cross-model review and the intended-behaviour check re-ran on main and 10.5.2. [read] 8.8.2-8.11.3 are counted by inference only (released from the lucene-solr branch_8x line, not in the history clone; 8.8.1 contains the commit and 8.11.4 was run); branch_10_6 and branch_10x have a byte-identical reader branch; upstream main a95c754ee003 does not change it.
- Status
- documented (brief, checks and a per-finding doc exist)
- Verification
- Confirmed by AI-run checks: an independent repro, a cross-model review that could only read the code (its low-confidence “not real” is procedural), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
- Found by
- semi-formal: exhaustive check, code reading
- Upstream and prior reports
- none found (2026-10-26, read-only; the prior-report record, verdict new): three scripted prior-report searches (core terms, symptoms, mechanism) over apache/lucene issues, PRs and commits, downstream repositories, the dev@, java-user@, issues@ and commits@ lists and the JIRA archive; 23 manual GitHub searches (18 on apache/lucene, 5 on Elasticsearch, OpenSearch and Solr); 9 manual list queries; 12 web queries; and the changed files of all 406 open apache/lucene PRs (16 touch IndexWriter.java; the diffs of #15683, #16888, #16643, #16847 and #16571 were read, and none changes how the reader branch sets the version). Closest, not the same bug: #13151 (open, the same constructor's commit-point branch, the opposite effect: an unneeded changed()), #3587 / LUCENE-2513 (fixed 2010: the same 'do not clobber future commits' invariant on a removed IndexReader write path), #9780 / #9781 / #10364 (per-segment del and doc-values generations when a writer opens an old commit, the basis of LFV-109; they do not cover the commit version), #10082 / LUCENE-9040 (open, IndexCommit identity of NRT readers). Origin, not a report: #7582 / LUCENE-6524. Not fixed: upstream main a95c754ee003 and branch_10x keep the reader branch unchanged. Search gaps: 50 results per GitHub query, list snippets of about 200 characters, some planned web queries replaced by manual ones because of rate limits.
- 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); fix A through gradle (the repro and 15 related suites, 294 tests, assertions on and off; all of:lucene:core:test, 8,898 tests, assertions on); fix A shadowed into the main jars with the hunt's 4-test class and the independent snapshot-rollback probe; a version-history probe stock and with fix A. The 10.6.0 release jars with the one-test repro (assertions on and off). The hunt ran on maina9779ef3c193(the same constructor lines), with history runs on 5.0.0, 5.2.1, 5.3.0, 5.5.5, 6.0.0, 6.6.6, 7.7.3, 8.11.4, 9.0.0, 9.12.3, 10.0.0 and 10.5.2. Upstream main2f424fd428da(2026-10-30) has no commit touchingIndexWriter.java,StandardDirectoryReader.java,SegmentInfos.javaorDirectoryReader.javasince645d4fe14a20. OpenJDK 25.0.3, Linux aarch64 (the only platform run).
- Finding record
lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:985-990 (at a9779ef3c193) (reader = commit.getReader()), :1021-1073 (reader branch: :1050 segmentInfos = reader.segmentInfos.clone(); :1062-1071 version, generation and counter raised only from reader.writer.segmentInfos when reader.writer != null; no read of the newest commit), :1075-1104 (commit-point branch: reads the latest commit, comment :1088-1092 'keep metadata in SegmentInfos, like version & generation, to preserve write-once. This is important if readers are open against the future commit points.', :1103 segmentInfos.replace(oldInfos))lucene/core/src/java/org/apache/lucene/index/StandardDirectoryReader.java:477-482 (at a9779ef3c193) (doOpenNoWriter returns null when isCurrent()), :529-540 (isCurrent of a non-NRT reader compares only versions), :571-576 (getIndexCommit returns a ReaderCommit carrying the reader); IndexCommit.java:116 (package-private getReader()); SegmentInfos.java:1084-1088 (replace keeps 'generation, version, counter so that future commits remain write once'); DirectoryReader.java:618-642 (getVersion 'advanced every time a change is made with IndexWriter'; isCurrent 'checks if any further commits ... have occurred in the directory'); IndexWriterConfig.java:235-241 (setIndexCommit, Expert, documents passing a reader's getIndexCommit); search/SearcherManager.java:200 (refresh through openIfChanged); search/SearcherLifetimeManager.java:146-158 (record keyed by version)
- The finding in 30 seconds
a Lucene main checkout lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:979-990, 1021-1113, 1192-1196, 2923-2926 (read)a Lucene main checkout lucene/core/src/java/org/apache/lucene/index/StandardDirectoryReader.java:477-482, 529-544, 570-574, 644-646; IndexCommit.java:112-118; SegmentInfos.java:924-928, 1084-1092; IndexFileDeleter.java:257 (read)a Lucene main checkout lucene/core/src/java/org/apache/lucene/search/SearcherManager.java:194-205; SearcherLifetimeManager.java:141-162 (read)
- Who is affected
a Lucene main checkout lucene/core/src/java/org/apache/lucene/index/IndexWriterConfig.java:234-241 (setIndexCommit, Expert)
- Why it looks unintended
a Lucene main checkout lucene/core/src/java/org/apache/lucene/index/DirectoryReader.java:617-642; IndexWriterConfig.java:234-241; IndexWriter.java:1010-1013, 1067-1068, 1088-1092; SegmentInfos.java:1084-1092 (read)a Lucene main checkout lucene/core/src/test/org/apache/lucene/index/TestIndexWriterFromReader.java:462-496 (read)a Lucene main checkout lucene/CHANGES.txt:18287-18288 (LUCENE-2513), 17665-17668 (LUCENE-2805); lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:3828-3835, 5929-5936 (every written commit bumps the version; a no-change commit is skipped) (read)
- TestWriterFromReaderCommitVersion.java (one test):
repro/LFV-591/src/org/apache/lucene/index/TestWriterFromReaderCommitVersion.java(62 lines)
assets/LFV-591/bug.png(492 KB)
