All findings · Lucene finding · LFV-591 · iw-from-reader-commit-reuses-commit-version

IndexWriter opened from an older reader's IndexCommit can reuse a newer commit's version, so readers on that commit report isCurrent() == true

A writer opened with setIndexCommit(reader.getIndexCommit()) on an older commit counts versions from that reader, not the newest commit, so it can repeat a newer commit's version (10 and 10 in the repro). A reader on that commit then reports isCurrent() == true and a SearcherManager still on it does not refresh; since 5.3.0.

Summary

Priority
Low, rank 935A 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
silent wrong results
Since
5.3.0 (LUCENE-6524, 2015)introduced 2015-06-28 · over 11 years
Affected
5.3.0 to 10.6.0 (the latest release) and main
Status
documented
Found by
semi-formal: bounded-exhaustive harness of reader reopen behaviour

AI-assisted page: this finding was first signalled by a semi-formal check (an exact check of the real jar, or a modelling hypothesis that a tool then witnessed) 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

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.

Background: commit versions and going back to an old commit

  1. Every commit (segments_N) records a version that grows as the index changes. A reader from DirectoryReader.open(dir) treats "the newest commit has my version" as "nothing changed": that is isCurrent(), and openIfChanged and SearcherManager refreshes rely on it.
  2. With a deletion policy that keeps old commits (NoDeletionPolicy, a SnapshotDeletionPolicy snapshot), you can open an IndexWriter on an old commit with IndexWriterConfig#setIndexCommit and continue from there: a rollback.
  3. The commit point can come from DirectoryReader.listCommits(dir) or from an open reader's getIndexCommit(), which lets the writer reuse that reader. Since 5.3.0 the two take different paths in the IndexWriter constructor.
Diagram of the repro: commits segments_1 (1 document, version 4) and segments_3 (3 documents, version 10) from the first writer, a reader on each; a second writer opened with setIndexCommit(older.getIndexCommit()) writes segments_4 (version 8) and segments_5 (1 document, version 10); the reader on segments_3 compares 10 with 10 and reports isCurrent() true, while the same commit taken from listCommits keeps the versions above 10.
The repro's commit timeline: the writer opened on the older reader's commit point counts from version 4, so its second commit gets version 10, the version of 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.

The finding, step by step

  1. Keep every commit (NoDeletionPolicy). Commit 1 document (segments_1, version 4) and open reader older on it; add and commit 2 more, one at a time (segments_3: 3 documents, version 10), and open reader newer.
  2. Go back: open a writer with setIndexCommit(older.getIndexCommit()) and commit twice. You expect newer.isCurrent() to turn false: its javadoc says it "checks if any further commits ... have occurred in the directory".
  3. 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_5 holds 1 document but carries the same version as segments_3.
  4. isCurrent() compares only versions, so newer.isCurrent() returns true, openIfChanged(newer) returns null and a SearcherManager refreshed only after both commits keeps serving the 3 old documents. The next commit (version 12) ends it.
  5. Control: the same commit taken from DirectoryReader.listCommits keeps the versions above 10, as this very call did in 5.2.1. The index on disk is correct throughout.

Who is affected

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.

The code (main 645d4fe14a20)

// 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();

Step by step, on the repro

  1. Which branch runs. DirectoryReader#getIndexCommit returns a commit that carries the reader (StandardDirectoryReader.java:570-574, getReader() at :644-646). DirectoryReader.listCommits builds the same kind of commit with no reader (DirectoryReader.java:456, :481), so its getReader() returns null; a SnapshotDeletionPolicy.snapshot() commit inherits getReader() == null from IndexCommit (IndexCommit.java:112-118). Both take the commit-point branch.
  2. The reader branch copies the old version. IndexWriter.java:1050 clones the reader's SegmentInfos, so the writer starts at segments_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 reads segments_3 (version 10).
  3. 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:
afternewest commitits versionnewer (segments_3, version 10) isCurrent()
writer commit 1segments_4, 1 doc8false (right)
writer commit 2segments_5, 1 doc10true (wrong)
writer commit 3segments_6, 1 doc12false (right again)
with fix Asegments_4, _5, _614, 16, 18false at every step
  1. File names never collide, versions do. IndexFileDeleter.inflateGens (IndexFileDeleter.java:257) raises the generation from the files in the directory, so the new commits are segments_4 and segments_5; nothing repairs the version.
  2. isCurrent() compares only versions. For a reader without a live writer, StandardDirectoryReader.java:529-540 reads the newest commit and returns sis.getVersion() == segmentInfos.getVersion(): 10 == 10. openIfChanged returns null when isCurrent() (:477-482), so SearcherManager.refreshIfNeeded (SearcherManager.java:200) keeps the old searcher, and SearcherLifetimeManager.record, which keys searchers by version, throws IllegalArgumentException for a searcher on segments_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 the setIndexCommit javadoc documents, while a reader or SearcherManager opened 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 a SnapshotDeletionPolicy snapshot. 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 SnapshotDeletionPolicy snapshots commit 1 (document a) and opens a reader on the snapshot, then commits b and c; a SearcherManager(dir, null) serves segments_3. A rollback writer on the default deletion policy, opened with setIndexCommit(snapReader.getIndexCommit()), adds d and commits. After maybeRefreshBlocking() the SearcherManager still serves segments_3 (version 10, 3 documents) although the only commit on disk is segments_4 (version 10, 2 documents). Run on main 645d4fe14a20 and, over 20 seeds, on a9779ef3c193.
  • 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 the SearcherManager stale in 14 through reader.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, openIfChanged null, a refreshed SearcherManager keeps 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, main a9779ef3c193, 10.5.2 and 9.12.3). SearcherLifetimeManager.record throws IllegalArgumentException when 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).

Minimal repro

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.

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

import java.util.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();
  }
}
Command
# $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 after segments_3, 3 documents).
Observed
isCurrent() is true: isCurrent() expected false but was true; reader version 10, latest commit version 10.

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

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.

Environment

lucenemain 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
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux aarch64

Observed output

# 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)

Why it looks unintended

  • isCurrent() javadoc: a reader from open "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 listCommits works, 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." Upstream TestIndexWriterFromReader#testAfterCommitThenIndexKeepCommits opens a writer from a reader that is behind the latest commit; that test class never checks isCurrent() 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 listCommits gives correct versions, and the same user call did in 5.2.1. Every other open path (create over an existing index, append, a listCommits commit) 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 writable IndexReader on an older commit overwrote a future commit's segments_N file (#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:

  1. 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) and segments_5 (1 document), share one version, and a reader that the javadoc says detects "any further commits" does not.
  2. 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 IndexCommit object came from listCommits or from a reader.
  3. 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.

Suggested fix

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=true and false: 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:test with 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. SearcherLifetimeManager and 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.

B. Use the highest version over all retained commits

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.

C. Make isCurrent() compare more than the version

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.

Downstream note

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).

How it was found

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, isCurrent soundness 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_2 and segments_3 both 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.

Prior reports, history and checks

Prior reports

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.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene)2026-10-26three runs of the scripted prior-report search (core terms, symptoms, mechanism; 16 issue/PR and 3 commit queries each) plus 18 manual searchesno report; #13151 and LUCENE-2513 closest
Open PRs2026-10-26all 406 open PRs listed with their changed files; 5 diffs read16 touch IndexWriter.java; none changes how the reader branch sets the version
JIRA archive, mailing lists (dev@, java-user@, issues@, commits@), downstream repositories, web2026-10-264 JIRA and 20 list queries per run, 9 manual list queries, 5 downstream GitHub searches, 12 web queriesno report
GitHub refresh2026-10-3116 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 #16923no report; #16948 and #16923 touch other code paths

History

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.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedthe one test fails (gradle; harness -ea and -da)
10.6.0 affectedthe one test fails (-ea and -da)
main a9779ef3c193, 10.5.2, 9.12.3 affectedthe 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 affectedhistory probe: the writer commits version 8, then 10 = segments_3's; isCurrent() true, openIfChanged() null; listCommits control clean
5.2.1 last beforehistory probe: the same call writes versions 13 and 15, like the listCommits control (-ea and -da)
5.0.0 not affectedhistory probe: not affected

Independent checks

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).

Each independent check and its verdict
CheckVerdictWhat it found
independent reproducer realOwn 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, mediumStrongest 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, mediumRoot 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 realExamined 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, highRe-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 keepRe-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.

Diagram file

The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.

Sources and provenance

Finding record, Lucene and upstream sources, repro files and diagram notes1,516 words

Finding record

LFV-591, snapshot of 2026-11-06

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 main a9779ef3c193 (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 main 2f424fd428da (2026-10-30) has no commit touching IndexWriter.java, StandardDirectoryReader.java, SegmentInfos.java or DirectoryReader.java since 645d4fe14a20. OpenJDK 25.0.3, Linux aarch64 (the only platform run).

Lucene and upstream sources

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)

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.