All findings · Lucene finding · LFV-012 · segmentinfos-version-collision-across-sessions

A rolled-back NRT reader can call different index content "current"

A kept NRT reader of a rolled-back IndexWriter can silently report isCurrent() true after a new writer commits to the same directory, while showing 3 rolled-back documents and no new ones: the closed-writer isCurrent() compares only version numbers, which the new writer reuses. Every executed release since 3.1.0 has it.

Summary

Priority
Low, rank 934A stale NRT reader can report isCurrent() for different content after an uncommitted writer session and an exact version collision, transient until the next commit and an unusual sequence.
Impact
silent wrong results
Since
3.1.0 (LUCENE-2805, 2010)introduced 2010-12-07 · over 15 years
Affected
3.1.0 to 10.5.2 and main (16 releases run, all reproduce)
Status
documented
Found by
semi-formal LLM proof-obligation audit

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

Two writer sessions on one directory, one kept NRT reader, and one version number, 10, used by both.

The repro's version sequence for two writer sessions on one directory. Writer 1 commits doc A at version 4 (segments_1), then refreshes an NRT reader to versions 6, 8 and 10 with three uncommitted documents and rolls back; the reader stays at version 10 holding A, W1-1, W1-2 and W1-3. Writer 2 starts again from version 4 and commits at version 7 (segments_2, isCurrent false), 10 (segments_3, isCurrent true, although the index holds A, W2-1 and W2-2) and 13 (isCurrent false).
The repro's version numbers on one line shared by two writer sessions: the reader left at version 10 by the rolled-back session, and the new session's second commit landing on the same number. Select the still to open it at full size. Generated with AI, checked against the code.
  1. Writer 1 adds doc A and commits (segments_1, version 4), then adds W1-1, W1-2 and W1-3, refreshing an NRT reader after each. The reader is at version 10 with 4 documents.
  2. Writer 1 ends without committing (rollback(), close() with setCommitOnClose(false), or a tragic I/O error), so the index is back to segments_1, version 4. You keep the reader; the javadoc allows it.
  3. A new writer opens the directory, counts again from version 4 and commits W2-1 (segments_2, version 7), then W2-2 (segments_3, version 10). You expect the old reader's isCurrent() to be false after each: new commits occurred, and it misses the new documents.
  4. Instead it returns true after the second commit. With its writer closed, isCurrent() compares only the version numbers, 10 and 10, not which commit they belong to.
  5. No error, and nothing on disk is wrong; the next commit (version 13) makes it false again. It needs an exact match: in a sweep over 1 to 8 refreshes on main, only 3 and 6 collide.

Who is affected

Applications that keep an NRT reader after its writer ends without committing (rollback(), close() with setCommitOnClose(false), or a tragic I/O error), then open a new IndexWriter on the same directory and ask the old reader isCurrent() whether to open a fresh one. No special configuration: a plain new IndexWriterConfig() fails the same way. SearcherManager refreshes with openIfChanged, not isCurrent().

Mechanism, terms and numbers917 words

Keep a near-real-time (NRT) reader after its IndexWriter is rolled back, open a new IndexWriter on the same directory and commit: the old reader's isCurrent() can return true, although it still shows 3 documents that were never committed and none of the new ones. The new writer hands out the same SegmentInfos version numbers again, and once the reader's writer is closed, isCurrent() compares only that number. It needs no special configuration (it also fails with a plain new IndexWriterConfig()), and every executed release since 3.1.0 reproduces it; silent and transient, no corruption. A small extra check in StandardDirectoryReader.isCurrent() fixes it and passes all of :lucene:core:test (one seed; nightly tests not run).

What you do

You index with an IndexWriter and search through a near-real-time (NRT) reader that you refresh from the writer, so new documents become searchable before they are committed. Then the writer ends without committing: you call rollback() to discard a failed batch, or close() with setCommitOnClose(false), or the writer hits an I/O error during a refresh and closes itself. Lucene allows you to keep using the last reader ("Once the writer is closed, any outstanding readers may continue to be used"). Later a new IndexWriter opens the same directory (plainly, or on an explicit commit point from DirectoryReader.listCommits) and commits new documents. To decide whether to switch to a fresh reader, you ask the old one isCurrent().

It should say false: new commits have happened, and the old reader shows documents that were rolled back. Sometimes it says true.

Two terms

  • Version: a counter in SegmentInfos that "counts how often the index has been changed". The writer adds to it on every change and stores it in each commit. An NRT reader remembers the writer's counter at the moment it was opened, including changes that were never committed.
  • Commit (segments_N): each commit is a new file with a larger N. It is the only place the counter is saved.

A tiny example (numbers from the repro on Lucene main)

stepwhat happenslatest commit on diskthe kept (old) reader
1writer 1 adds doc A and commitssegments_1, version 4: [A]
2writer 1 adds W1-1, W1-2, W1-3, refreshing the reader after eachunchangedversion 6, 8, then 10: [A, W1-1, W1-2, W1-3]
3writer 1 rolls backback to segments_1, version 4: [A]still version 10, 4 docs (3 never committed)
4writer 2 opens the directoryit starts counting from version 4
5writer 2 adds W2-1, commitssegments_2, version 7: [A, W2-1]isCurrent() = false (right)
6writer 2 adds W2-2, commitssegments_3, version 10: [A, W2-1, W2-2]isCurrent() = true (wrong)
7writer 2 adds W2-3, commitssegments_4, version 13isCurrent() = false again

At step 6 the old reader shows 4 documents, 3 of which no longer exist, and misses W2-1 and W2-2. It reports itself up to date because writer 2's second commit landed on version 10, the number writer 1 had given the reader for changes it then threw away.

The mechanism (main ea57360912ed)

  1. An NRT reader carries an uncommitted version. It clones the writer's live SegmentInfos (StandardDirectoryReader.java:126). SegmentInfos.changed() does version++ (SegmentInfos.java:1106-1107); the writer calls it when it names a new segment (IndexWriter.java:2179), on other changes (:2923-2925) and once more on a commit with changes (:3828-3835). These are the bumps this repro hits; others exist, e.g. deleteAll (:2799), setLiveCommitData (:4171) and advanceSegmentInfosVersion (:1417-1423).
  2. Rollback keeps no record of it. Rollback restores the committed segment list (IndexWriter.java:2655) and closes the writer; nothing on disk remembers version 10. close() with commitOnClose=false calls rollback() (:1363-1368); a tragic error closes the writer through rollbackInternal() (:6098-6105).
  3. A new writer counts again from the last commit. It reads the latest commit (IndexWriter.java:1085) and keeps its version, 4, so its commits climb through the same range again (7, 10, 13, ...). Opening on an explicit commit point (setIndexCommit) reads the same latest commit and keeps its version plus one (:1077-1104); its "preserve write-once" comment (:1088-1092) is about newer commits on disk, not about versions a discarded session gave only to NRT readers, and it collides too (the fact check's probe: k = 2, 5 and 8). Only opening from an NRT reader of the old writer, i.e. on the orphan's own commit point, copies the old writer's version over "so we remain write-once" (:1062-1071).
  4. The closed-writer check compares only the number. isCurrent() (StandardDirectoryReader.java:529-544): if the reader's writer is closed (:531), it reads the latest commit and returns sis.getVersion() == segmentInfos.getVersion() (:540). It never asks which commit: the reader's SegmentInfos still names segments_1, the latest commit is segments_3.

Why only some commits

In these runs each add-and-refresh moves the version by 2 and each add-and-commit by 3. The old reader sits at 4 + 2k after k refreshes, and writer 2's commits land on 7, 10, 13, ... In a sweep over k = 1 to 8 with 8 one-document commits, k = 3 (version 10) and k = 6 (version 16) collide; the other six values never do. Other workloads step differently: in the study, a writer-2 commit that only set commit user data landed on the old reader's version 4 too.

Exposure and likelihood387 words
  • Who is exposed. Applications that keep an NRT reader after its writer ends without committing, open a new IndexWriter on the same directory, and ask the old reader isCurrent() to decide when to open a fresh one.
  • Three triggers, all public API, all executed: rollback() (ByteBuffersDirectory); close() with setCommitOnClose(false) (FSDirectory); and a tragic event: an IOException ("simulated disk full") from createOutput during an NRT refresh makes the writer close itself (isOpen() false, getTragicException() set), with no rollback call by the application. Writer 2 may be opened plainly or on an explicit commit point from DirectoryReader.listCommits (executed in the fact check); only opening it on the old reader's own commit point avoids the collision.
  • No opt-in. The minimal repro fixes NoMergePolicy to keep the numbers stable; a plain new IndexWriterConfig() (default merge policy and scheduler) gives the same wrong answer at version 10 (second test). No scale needed: one document per commit.
  • Silent. No exception and no log line. The application is told that a reader showing 3 never-committed documents, and missing the newly committed ones, is current, so it keeps serving that view.
  • Limits. The wrong answer lasts only while the latest commit's version equals the old reader's; the next commit ends it. It needs that exact match (2 of 8 values of k in the sweep); how often real workloads align was not measured. Nothing on disk is wrong. The old reader cannot be reopened anyway (AlreadyClosedException, documented), so the application must open a new reader. One that knows a rollback or tragic close happened can do that unconditionally; one that asks the old reader isCurrent() to decide gets the wrong answer.
  • Reach through Lucene's helpers. SearcherManager refreshes with openIfChanged, not isCurrent(), and its isSearcherCurrent() is package-private (SearcherManager.java:232). FilterDirectoryReader passes isCurrent() through (FilterDirectoryReader.java:152-153), so wrapped readers are affected.
  • Same root, another symptom (study only). Two readers of different writer sessions can share a version with different content; the study showed SearcherLifetimeManager.record() then throwing IllegalArgumentException ("the provided searcher has the same underlying reader version yet the searcher instance differs from before", SearcherLifetimeManager.java:156-158). Not re-run in this hunt.
  • Released. Every release since 3.1.0 (2011) contains the change, as far as the history clone's tags show; the 16 releases that were run all reproduce.

Minimal repro

The test shows that an NRT reader whose writer was rolled back reports isCurrent() == true after a new writer on the same directory has committed different content, because the new session's second commit lands on the reader's SegmentInfos version (10) and the closed-writer branch of isCurrent compares only versions.

TestOrphanedNrtReaderIsCurrent.java (JUnit test)61 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.io.IOException;
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 TestOrphanedNrtReaderIsCurrent extends LuceneTestCase {

  public void testIsCurrentAfterRollbackAndNewWriterCommits() throws IOException {
    try (Directory dir = new ByteBuffersDirectory()) {
      IndexWriter w1 = new IndexWriter(dir, new IndexWriterConfig());
      w1.addDocument(new Document());
      w1.commit();
      DirectoryReader reader = DirectoryReader.open(w1);
      for (int i = 0; i < 3; i++) {
        w1.addDocument(new Document());
        DirectoryReader newReader = DirectoryReader.openIfChanged(reader, w1);
        reader.close();
        reader = newReader;
      }
      w1.rollback();
      try (IndexWriter w2 = new IndexWriter(dir, new IndexWriterConfig())) {
        w2.addDocument(new Document());
        w2.commit();
        w2.addDocument(new Document());
        w2.commit();
      }
      boolean current = reader.isCurrent();
      String state =
          "reader: maxDoc "
              + reader.maxDoc()
              + ", version "
              + reader.getVersion()
              + "; latest commit: maxDoc "
              + SegmentInfos.readLatestCommit(dir).totalMaxDoc()
              + ", version "
              + SegmentInfos.readLatestCommit(dir).getVersion();
      reader.close();
      // expected false (a new writer committed different content); actual true (same version)
      assertFalse("isCurrent() expected false, was true; " + state, current);
    }
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-012/, which holds src/)
# Lucene checkout at the tested commit, built with: ./gradlew --no-daemon assemble   (JDK 25)
LUCENE=/path/to/lucene; V=12.0.0-SNAPSHOT
CP=$LUCENE/lucene/core/build/libs/lucene-core-$V.jar
CP=$CP:$LUCENE/lucene/codecs/build/libs/lucene-codecs-$V.jar
CP=$CP:$LUCENE/lucene/test-framework/build/libs/lucene-test-framework-$V.jar
CP=$CP:randomizedtesting-runner-2.9.2.jar:junit-4.13.2.jar:hamcrest-3.0.jar   # test-framework dependencies (Maven Central)
CP=$CP:junit-platform-console-standalone-6.1.3.jar   # JUnit Platform console launcher (Maven Central)

javac -d classes -cp "$CP" $(find src -name '*.java')

# run 1: assertions on, seed DEADBEEF
java -ea -esa --add-modules jdk.incubator.vector --enable-native-access=ALL-UNNAMED -Dtests.seed=DEADBEEF \
     -cp "classes:$CP" org.junit.platform.console.ConsoleLauncher \
     execute --select-class org.apache.lucene.index.TestOrphanedNrtReaderIsCurrent
Expected vs actual
Expected isCurrent() to be false (reader maxDoc 4, latest commit maxDoc 3); actual true, with -ea and -da.

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

More files, environment, full output46 words

Environment

luceneLucene main e1faeba08bde (2026-11-02)
jdk25.0.3+9-LTS

Observed output

# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
    => java.lang.AssertionError: isCurrent() expected false, was true; reader: maxDoc 4, version 10; latest commit: maxDoc 3, version 10
       org.junit.Assert.fail(Assert.java:89)
       org.junit.Assert.assertTrue(Assert.java:42)
       org.junit.Assert.assertFalse(Assert.java:65)
       org.apache.lucene.index.TestOrphanedNrtReaderIsCurrent.testIsCurrentAfterRollbackAndNewWriterCommits(TestOrphanedNrtReaderIsCurrent.java:58)

Why it looks unintended

  • Per its javadoc, isCurrent() checks whether "a new commit has occurred"; two did.
  • Keeping the reader is documented ("outstanding readers may continue to be used"); a Lucene test expects an NRT reader kept after a commit-on-close to stay non-current across a new session.
  • For replication only, PrimaryNode forces versions forward, else "versions can be illegally reused but show different results".
The full argument519 words
  • The javadoc. DirectoryReader#isCurrent (DirectoryReader.java:631-635): for a near real-time reader "this method checks if either a new commit has occurred, or any new uncommitted changes have taken place via the writer". Two new commits occurred. It has no caveat for readers whose writer has closed, and adds (:637-638) "if this returns false, you should call openIfChanged to get a new reader that sees the changes".
  • Keeping the reader is supported. DirectoryReader.java:330-332: "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."
  • The docs are not airtight here either way. getVersion() "returns the version recorded in the commit that the reader opened" (DirectoryReader.java:620), which does not literally describe an NRT reader's uncommitted version, and the advice to call openIfChanged when isCurrent() is false cannot work for an orphan (it throws). Neither text says that such a reader may be reported current after new commits.
  • Lucene's own test expects a kept NRT reader to stay non-current across a new session. TestIndexWriterReader.testUpdateDocument (:132-200) keeps NRT reader r2 after writer.close() and asserts assertFalse(r2.isCurrent()) after that close (:177, :181), then again after a new IndexWriter adds a document (:189) and commits on close (:194). This is indirect: r2 is already non-current before the new writer opens. Before LUCENE-6523 (2015), which made a commit itself a change for NRT readers, :177, :181 and :189 were assertTrue and :194 already asserted false once the new writer had committed a new document: a direct check that a new writer's commit makes a kept reader non-current. testIsCurrent asserts false after the writer's close (:232-233). All of this covers close-with-commit only, where versions keep increasing.
  • Lucene's replication layer treats version reuse as invalid. PrimaryNode.java:116-119 (replicator): "We forcefully advance the SIS version to an unused future version. This is necessary if the previous primary crashed and we are starting up on an "older" index, else versions can be illegally reused but show different results." It works around this with the @lucene.internal advanceSegmentInfosVersion (IndexWriter.java:1412-1423), for replication only. The comment is about the replication protocol, which needs unique versions, not a ruling on core SegmentInfos.version. In core, only opening a writer from an NRT reader carries the old writer's version over (:1062-1071).
  • History. The closed-writer branch came with LUCENE-1976 (#3051, 052e1f5630d, 2009), an issue titled "isCurrent() and getVersion() on an NRT reader are broken". LUCENE-2805 (#3879, 6c9052d2311, 2010) turned the bug on while aiming for a correct isCurrent() inside one writer session; its discussion does not mention rollback or a new session. The testIsCurrent that LUCENE-2802 added in 2010 asserted true for an NRT reader after a commit-on-close that committed exactly what it showed; LUCENE-6523 changed that to false in 2015. Under either meaning of "current" the orphan should be non-current: its content differs and new commits occurred.
  • Effect size. The answer is a boolean and it is wrong, for a reader 3 of whose 4 documents were rolled back.
The devil's-advocate check221 words

The strongest case for "intended" came from one of the study's verifiers (P-model), and the cross-model reviewer rated the same case "moderate": versions are only promised to advance ("This version is advanced every time a change is made with IndexWriter", DirectoryReader.java:617-623), never to be unique across writer sessions; LUCENE-3672 (#4746, 2012) removed, on purpose, the timestamp that a new index's version started from; callers that need uniqueness, such as replication, are expected to force the version forward; opening NRT readers is @lucene.experimental; and an old reader cannot be reopened, so its isCurrent() is only a hint.

Why it fails: this report does not ask for unique versions; it is about the documented answer of isCurrent(), which has no such caveat. Keeping the reader is explicitly allowed, and Lucene's own test expects a kept reader to stay non-current across a new session. The timestamp seed never made versions unique across writer sessions: it applied only to a new index, and 3.1.0 to 3.6.2, which still have it, reproduce this bug with timestamp-sized versions. @lucene.experimental is about API stability, not about wrong answers. P-model verifier itself reproduced the isCurrent() consequence and called it a javadoc violation; it classed only the version scheme as by design. The validated fix needs no unique versions. Verdict: not intended, a defect of low-to-moderate severity.

Suggested fix

Recommended In the closed-writer branch, also check that no newer commit exists (reader side)

For an NRT reader whose writer is closed, StandardDirectoryReader.isCurrent() also requires the latest commit's segments file name to equal the reader's own; one file, no format change. Both repro tests then pass, and :lucene:core:test passes (8,872 tests, one seed, nightly tests not run). It does not stop the version reuse itself.

// we loaded SegmentInfos from the directory
if (sis.getVersion() != segmentInfos.getVersion()) {
  return false;
}
// An NRT reader can hold an uncommitted version. If its writer ended without committing, a
// later writer starts again from the last commit's version and can commit that same number
// with different content, so also require that no newer commit exists:
return writer == null || sis.getSegmentsFileName().equals(segmentInfos.getSegmentsFileName());
All options and trade-offs4 options · 802 words

One fix was applied in a scratch worktree of ea57360912ed and validated with the repro and the module's tests. The other options are ideas with their trade-offs; they were not run.

In the closed-writer branch, also check that no newer commit exists (reader side) (validated by the checks) (recommended)

For an NRT reader (writer != null) whose writer is closed, require the latest commit's segments file name to equal the one in the reader's SegmentInfos, in addition to equal versions. Readers opened from a commit keep today's check. Same logic as the candidate's sketch, reformatted for google-java-format.

// we loaded SegmentInfos from the directory
if (sis.getVersion() != segmentInfos.getVersion()) {
  return false;
}
// An NRT reader can hold an uncommitted version. If its writer ended without committing, a
// later writer starts again from the last commit's version and can commit that same number
// with different content, so also require that no newer commit exists:
return writer == null || sis.getSegmentsFileName().equals(segmentInfos.getSegmentsFileName());

Trade-offs

  • One file, no format change; it costs one string comparison. It can only turn true into false, and only when a commit newer than the reader's exists, which the javadoc counts as a change (since LUCENE-6523 a commit is a change for an NRT reader even when the content is the same), so those false answers are right by contract.
  • Using the segments file name as the commit's identity has a precedent in the same class: doOpenNoWriter treats an equal getSegmentsFileName() as "no change" (StandardDirectoryReader.java:487-489). The reopen path of commit readers, which calls isCurrent() (:480), is unchanged.
  • It does not stop the version reuse itself, so other code that treats "same version" as "same state" (the SearcherLifetimeManager case) is not covered.
  • It relies on an NRT reader's SegmentInfos naming the last commit its writer saw. That holds in every run here, including the control with a legitimately current reader of a closed writer, but it was not proven for every path.
  • The segments file name comes from the commit generation (SegmentInfos.java:250-253), which keeps growing in a directory that Lucene manages; if the directory is erased or replaced outside Lucene, a name could repeat. No focused test was run for an NRT reader without an initial commit, prepareCommit or OpenMode.CREATE; the explicit-commit-point path was run (below).

What the checks validated

  • Before the patch (gradle, clean worktree): the repro 2/2 fail (seed DEADBEEF); with the second test added, 7 tests, 5 failures (seed 7E1EB4EA), the 2 controls pass.
  • After: the repro plus 14 closest suites (TestIndexWriterReader, TestDirectoryReader, TestDirectoryReaderReopen, TestRollback, TestIndexWriterCommit, TestIndexWriterFromReader, TestNRTReaderWithThreads, TestStressNRT, TestIndexWriterExceptions, TestTragicIndexWriterDeadlock, TestIndexWriter, TestIndexWriterOnDiskFull, TestSearcherManager, TestControlledRealTimeReopenThread): 303 tests, 0 failures, 8 skipped (seed DEADBEEF).
  • After: all of :lucene:core:test with both repro tests included, seed 5EED1234 (one seed, nightly tests not run): 749 classes, 8,872 tests, 0 failures, 0 errors, 320 skipped. :lucene:replicator:test ran too, but all 15 of its tests were skipped (14 are @Nightly; TestSimpleServer runs only as a nested suite), so it adds no evidence.
  • After: the fact check's explicit-commit-point probe (setIndexCommit on the latest commit), which collides on unpatched main, passes with the patched class (-ea, seed DEADBEEF).
  • :lucene:core:checkGoogleJavaFormat passes; the patch applies cleanly to ea57360912ed (applied with git apply; git apply --check also passes).
  • Earlier, independently: the study ran the same change against 13 existing Lucene test classes plus its own test class (300 tests OK), and the second adjudication ran it as a shadow class against both tests with -ea and -da, all green.

Never hand out a version that an earlier session gave to a reader (writer side)

Make a new IndexWriter start above every version a discarded session handed out. Not tried.

Trade-offs

Would cover every consumer of the version, not only isCurrent(). But a new writer cannot learn those versions: rollback() leaves the index "the same as it was when commit() was last called" (IndexWriter.java:2566-2571) and writes nothing. Seeding each new session from the clock or a random offset would be a new mechanism: the clock seed that LUCENE-3672 removed in 2012 applied only to new indexes and did not prevent this bug in 3.x (3.1.0 to 3.6.2 reproduce). Needs a maintainer's view.

Application workaround: open a fresh reader after any rollback

After a rollback or a tragic close, open a new reader from the directory or the new writer instead of asking the old reader isCurrent(); it cannot be reopened anyway. A replication-style alternative is to call advanceSegmentInfosVersion(oldReader.getVersion()) on the new writer, as PrimaryNode does. Not executed.

Trade-offs

No Lucene change, but every application has to know about it; advanceSegmentInfosVersion is @lucene.internal.

Document the limitation

Add a caveat to isCurrent() for readers whose writer ended without committing.

Trade-offs

Leaves the wrong answer in place; the weakest option.

How it was found

A semi-formal LLM proof-obligation audit: an AI tool wrote invariants of the NRT bookkeeping, checked one obligation per state-changing operation by hand, found that "new IndexWriter(dir) after rollback" fails, and wrote a Java repro. Model-based randomized testing found it later; TLA+ model checking (TLC) and the Alloy solver re-found it blind.

The method in full251 words

Method: an LLM proof-obligation audit (semi-formal), in run 1 of the study (2026-10-22, main 7cecf79523). The study's contamination note lists this finding among those its task text did not hint at.

  • An AI tool wrote down invariants of the NRT bookkeeping and discharged one obligation per mutator by hand. Invariant I4: after the writer is closed, a reader whose version equals the latest commit's version shows that commit's content. Obligation O-X7, "new IndexWriter(dir) after rollback", failed: the new writer restarts numbering at the last commit's version, below versions already shown to the old session's NRT readers. The AI tool wrote a Java repro; Z3 and KeY were used only afterwards, to mechanise the argument.
  • Model-based randomized testing found the same failure later, without formal tools.
  • TLA+ model checking (TLC) re-found it blind as a counterexample of property IsCurrentSound (an 11-state trace: write, NRT open, rollback, new writer, commit at the reader's version), and the Alloy solver re-found it (finding E, property ClosedWriterReaderCurrentSound, 8 steps), both translated into failing Java tests.
  • Study verifiers then checked it: 8 classed it a new bug, 1 classed the version scheme itself as intended (P-model verifier, which still reproduced the isCurrent() consequence), and 1 as the same root cause in a variant. In this hunt it was re-run on main and 10.5.2, reviewed by an independent reviewer from another model family (OpenAI GPT-5.6), tie-broken, bisected and searched for prior reports; this page was then fact-checked twice and its diagram checked once.

Prior reports, history and checks

Prior reports

No earlier report found in GitHub issues and PRs, the mailing lists, JIRA, commits, downstream projects or the web (searched 2026-10-25); no open PR changes the isCurrent() comparison. Known in code: the replicator calls version reuse illegal, and a writer opened from an NRT reader carries the old writer's version over (LUCENE-6524).

Searches5 searches

No earlier report of this bug was found, and it is not fixed upstream. Part of the mechanism is known in code: the replicator's PrimaryNode comment calls version reuse illegal for replication, and IndexWriter carries the old writer's version over when it is opened from an NRT reader (LUCENE-6524).

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues and PRs (apache/lucene, incl. migrated JIRA)2026-10-25a scripted prior-report search, 60 queries (class and method names such as isCurrent, nrtIsCurrent, advanceSegmentInfosVersion, rollbackInternalNoCommit; symptoms such as "version reused", "same version different content", "orphaned reader"), comments of the top 25 read; 7 more manual phrasingsno report; closest are background items: #3051 LUCENE-1976, #3879 LUCENE-2805, #3876 LUCENE-2802, #2309 LUCENE-1232, #4746 LUCENE-3672, #6501 LUCENE-5438, #7582 LUCENE-6524
Mailing lists (dev@, java-user@, issues@, commits@)2026-10-2540 queriesno report; the closest, LUCENE-3486 and a 2023 java-user thread about SearcherLifetimeManager, concern searchers keyed by reader version, with no collision reported
JIRA archive, commits, downstream projects, web2026-10-2510 JIRA, 5 commit, 3 downstream and 6 web queries; OpenSearch, Elasticsearch and Solr searchedno report; one downstream PR handles version ordering across primary promotion in its own engine
Open PRs2026-10-25all 412 open apache/lucene PRs checked file by file; the 21 touching these files grepped for the comparison and the version-setting codenone changes the isCurrent comparison or how a new writer sets its version
Different bugs nearby2026-10-22 to 2026-10-25read#16900 (doc-values-only flush, open-writer path; the study checked by execution that neither fix covers the other), #8550 LUCENE-7499 (unsynchronized version++), #3659 LUCENE-2585 and #4655 LUCENE-3581 (other open isCurrent issues), #16625 / PR #16643 (rollback deletes files under pooled readers)

History

Introduced by LUCENE-2805 (2010), first released in 3.1.0; pinned by commit-vs-parent runs on trunk and branch_3x. Every executed release from 3.1.0 to 10.5.2 and main reproduces; 2.9.4 and 3.0.3 do not. In executed releases from 5.5.5 on, only some refresh counts collide (LUCENE-6523). Not fixed upstream.

Versions tested8 rows

Introduced by LUCENE-2805, "SegmentInfos shouldn't blindly increment version on commit" (JIRA, migrated as #3879): trunk 6c9052d2311 on 2010-12-07, backported to branch_3x as 0196fb48869 on 2010-12-08. Before it, the version moved only when a segments_N file was written, so NRT refreshes after a commit did not move the version past the last recorded one. The closed-writer branch of isCurrent() is older (LUCENE-1976, 2009) and harmless on its own: 2.9.4 and 3.0.3 have it and pass. First affected release: 3.1.0.

From 3.1.0 to 5.0.0 writer 2 steps by 2 like the old reader, so every k collides. LUCENE-6523, first released in 5.2.1, adds one more bump per commit; from 5.5.5, the next executed release, only some k collide (k = 3 and 6 with this cadence). It changes which readers collide, not whether they can.

Every release tag from 3.1 to 10.5.2 in the full-history clone (131 tags) contains the change; the clone has no 8.x tags after 8.8.1, but the executed 8.11.4 jar reproduces. Releases between the executed points were not run.

Versions and branches executed, with results
Version or commitVerdictResult
main ea57360912ed affectedrepro 2/2 fail with -ea and -da, under the harness and gradle; probe reproduces for k = 3 and 6
10.5.2, 9.12.3 affectedrepro 2/2 fail (Maven Central jars, SHA-1 checked)
5.5.5, 6.0.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 affectedprobe reproduces for k = 3 and 6 (version step 2 per refresh, 3 per commit)
3.1.0, 3.6.2, 4.0.0, 4.10.4, 5.0.0 affectedprobe reproduces for every k (step 2 and 2)
2.9.4, 3.0.3 not affectedfalse after every new commit (the version moved only on commits)
trunk 6c9052d2311 (LUCENE-2805) vs parent 620b2a0619a bisectthe commit reproduces, its parent does not (source builds)
branch_3x 0196fb48869 (backport) vs parent 3ee85186e6f bisectthe backport reproduces, its parent does not; 5137f949bec (LUCENE-2802, 22 minutes later) also reproduces
fix on ea57360912ed not affectedboth repro tests pass; all of :lucene:core:test passes

Independent checks

Study verifiers (8 call it a new bug; 1 calls the version scheme intended but reproduced the wrong isCurrent()), a re-run on main and 10.5.2, a cross-model refute review whose own tests never reached isCurrent(), a second adjudication that confirmed it, fact checks and a diagram check. Every valid correction was applied.

Checks and verdicts544 words

The study's verifiers, then the hunt's reproducer, a cross-model refute review, a second adjudication, the history and prior-art passes, the fix validation, independent fact checks of this page's brief and text, and a diagram check. Every valid correction from the fact checks and the diagram check is applied.

Each independent check and its verdict
CheckVerdictWhat it found
study verifiers 8 new bug, 1 intended, 1 same root causeproof-obligation verifier, model-based testing verifier, Alloy verifier, TLA+ verifier, Dafny verifier, JBMC verifier, JML verifier and P-model verifier call it new; P-model verifier classes the version scheme as by design but reproduced the isCurrent() consequence and called it a javadoc violation.
study dissent (P-model verifier) version reuse intended; isCurrent wrongSupplied the devil's-advocate case, the commit-user-data collision (test4) and the SearcherLifetimeManager symptom (test5).
current-main reproducer realMain -ea/-da 2/2 fail, 20/20 over 10 iterations, 10.5.2 2/2 fail; the fix sketch turns the repro green.
cross-model refute review not real, low confidence (procedural)An independent reviewer from another model family (OpenAI GPT-5.6). Its own tests were off by one flush and stopped at their setup guard, so none reached isCurrent(); on substance it agreed on the root cause, found the inputs in contract, and marked the code items, the isCurrent() javadoc and the upstream test as supporting the report; it counted one javadoc item (NRT opening is experimental; outstanding readers cannot be reopened) against it and one CHANGES entry as neutral.
Second adjudication real, high confidenceRe-ran the repro on main, 10.5.2 and 9.12.3; ran the reviewer's corrected construction (fails at isCurrent()); wrote a second test with the sweep, default config, tragic trigger and controls.
history introduced by LUCENE-2805, first release 3.1.027 runs: 18 releases, main and 5 commits (some also with -da), commit-level A/B on both lines.
prior art new124 tool queries, 1,047 hits, all 412 open PRs; no report.
fix validation fix validatedScratch worktree of ea57360912ed: both repro tests fail before and pass after; 8,872 core tests, 0 failures.
fact check (independent AI tool) bug confirmed; 5 text errors fixedRe-ran the repro on main -ea/-da and 10.5.2 (2/2 fail each) and checked every file:line, quote, count and history claim. One medium error: the explicit-commit-point open path does not guard against the collision; its own probe collides at k = 2, 5 and 8. Four low errors, three overclaims and four missing caveats; all fixed.
fact check (cross-model reviewer) bug confirmed; 4 text errors fixedAn independent reviewer from another model family (OpenAI GPT-5.6). Two medium errors (LUCENE-6523 first shipped in 5.2.1, not 5.3.0; the pre-LUCENE-2805 sentence was too absolute) and two low ones, plus 7 overclaims and 5 missing caveats; all fixed or reworded. It rated the case for intended behaviour moderate.
diagram check no errors; 3 of 5 warnings fixedChecked every on-screen number, label and code line against ea57360912ed and the executed trace. The brace label, the faded "+3" and the legend marks were fixed and the diagram re-rendered.
this page's own re-runs fix also covers setIndexCommitThe fact check's explicit-commit-point probe passes with the patched isCurrent(); git apply --check of the patch on ea57360912ed passes.

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

Finding record

LFV-012, snapshot of 2026-11-06

Registry title
An orphaned NRT reader can report isCurrent() true for different index content after a new writer session
Area
index / IndexWriter NRT
Priority
Low, rank 934: A stale NRT reader can report isCurrent() for different content after an uncommitted writer session and an exact version collision, transient until the next commit and an unusual sequence.
Impact class
silent wrong results
Impact note
Default configuration; applications that keep an NRT reader after its writer is rolled back or closed and then open a new writer on the same directory; released (6.6.6+).
Summary
You keep an NRT reader after its writer is rolled back or closed, then open a new writer on the same directory and change the index. You expect reader.isCurrent() to return false. Instead SegmentInfos.version restarts in each writer session and the closed-writer branch of isCurrent compares only versions, so the stale reader reports true for different content.
Introduced
LUCENE-2805 (6c9052d2311 on trunk, 0196fb48869 on branch_3x) [exec: commit-vs-parent runs] (2010-12-07)
First affected version
3.1.0 [exec]
Affected versions
3.1.0 to 10.5.2 and main (16 releases run, all reproduce; 2.9.4 and 3.0.3 do not) [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 could only read the code (its low-confidence “not real” is procedural); not yet triaged by a person.
Found by
semi-formal: proof-obligation audit; also reached by randomized/MBT, TLA+/TLC, Alloy
Upstream and prior reports
none found (study searches 2026-10-22; public-list check 2026-10-20..23)
Lucene commit tested
main ea57360912ed (11.0.0-SNAPSHOT): the JUnit repro through the repro harness (-ea and -da, 10 iterations) and through gradle in a clean worktree; the Maven Central jars 10.5.2 and 9.12.3 (SHA-1 checked); a main() probe on 18 releases from 2.9.4 to 10.5.2 and on the LUCENE-2805 commits and their parents; the fix in a scratch worktree of ea57360912ed. The study's public-list rerun reproduced it on main b83101cdd2; no commit since touches the three files involved.

Lucene and upstream sources

The finding in 30 seconds
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/StandardDirectoryReader.java:126, :529-544
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/SegmentInfos.java:140-141, :1106-1107
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:1062-1071, :1077-1104, :1363-1368, :1417-1423, :2179, :2655, :2799, :2923-2925, :3828-3835, :4171, :6098-6105
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/DirectoryReader.java:330-332 (outstanding readers may continue to be used)
Who is affected
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/SearcherManager.java:194, :232; lucene/core/src/java/org/apache/lucene/index/FilterDirectoryReader.java:152-153; lucene/core/src/java/org/apache/lucene/search/SearcherLifetimeManager.java:146-158
Why it looks unintended
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/DirectoryReader.java:330-332, :617-623 (:620), :631-638
  • Lucene main ea57360912ed: lucene/core/src/test/org/apache/lucene/index/TestIndexWriterReader.java:132-200, :203-241
  • lucene-history: git show 90799745102 and 90799745102^ (LUCENE-6523 turned assertTrue into assertFalse in TestIndexWriterReader); git show 064e8dc60ab (LUCENE-2802 testIsCurrent)
  • Lucene main ea57360912ed: lucene/replicator/src/java/org/apache/lucene/replicator/nrt/PrimaryNode.java:116-123; lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:1062-1071, :1412-1423
  • lucene-history: git show 2e5be2f75cb (LUCENE-3672); releases/lucene-solr/3.6.2 SegmentInfos.java:113 (timestamp seed)
Suggested fix
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/StandardDirectoryReader.java:477-494; IndexWriter.java:1412-1423, :2566-2574
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/SegmentInfos.java:250-253
History
  • lucene-history: git tag --contains 90799745102 (releases/lucene-solr/5.2.1); git merge-base --is-ancestor over the 131 release tags 3.1 .. 10.5.2

Other statements trace to the hunt's working files (models, logs, reviews and briefs), which are not published.

Repro files

About the diagrams

The picture follows the tiny example above: one version number line shared by two writer sessions, the reader left at version 10 by the rolled-back session, and the new session's second commit landing on the same number.

An independent diagram check compared every number, label and code line with the code at ea57360912ed and the executed trace, and the alt text with the still. After it, the brace over versions 6, 8 and 10 was reworded to "NRT reader versions that writer 1 never commits", the faded "+3" toward the next commit was darkened, the legend marks became bars so they cannot be read as commits, and the diagram was re-rendered.

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.