Two writer sessions on one directory, one kept NRT reader, and one version number, 10, used by both.
- 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. - Writer 1 ends without committing (
rollback(),close()withsetCommitOnClose(false), or a tragic I/O error), so the index is back tosegments_1, version 4. You keep the reader; the javadoc allows it. - 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'sisCurrent()to be false after each: new commits occurred, and it misses the new documents. - 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. - 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.
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).
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.
- Version: a counter in
SegmentInfosthat "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.
| step | what happens | latest commit on disk | the kept (old) reader |
|---|---|---|---|
| 1 | writer 1 adds doc A and commits | segments_1, version 4: [A] | |
| 2 | writer 1 adds W1-1, W1-2, W1-3, refreshing the reader after each | unchanged | version 6, 8, then 10: [A, W1-1, W1-2, W1-3] |
| 3 | writer 1 rolls back | back to segments_1, version 4: [A] | still version 10, 4 docs (3 never committed) |
| 4 | writer 2 opens the directory | it starts counting from version 4 | |
| 5 | writer 2 adds W2-1, commits | segments_2, version 7: [A, W2-1] | isCurrent() = false (right) |
| 6 | writer 2 adds W2-2, commits | segments_3, version 10: [A, W2-1, W2-2] | isCurrent() = true (wrong) |
| 7 | writer 2 adds W2-3, commits | segments_4, version 13 | isCurrent() = 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.
- An NRT reader carries an uncommitted version. It clones the writer's live
SegmentInfos(StandardDirectoryReader.java:126).SegmentInfos.changed()doesversion++(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) andadvanceSegmentInfosVersion(:1417-1423). - 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()withcommitOnClose=falsecallsrollback()(:1363-1368); a tragic error closes the writer throughrollbackInternal()(:6098-6105). - 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). - 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 returnssis.getVersion() == segmentInfos.getVersion()(:540). It never asks which commit: the reader'sSegmentInfosstill namessegments_1, the latest commit issegments_3.
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
IndexWriteron the same directory, and ask the old readerisCurrent()to decide when to open a fresh one. - Three triggers, all public API, all executed:
rollback()(ByteBuffersDirectory);close()withsetCommitOnClose(false)(FSDirectory); and a tragic event: anIOException("simulated disk full") fromcreateOutputduring 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 fromDirectoryReader.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
NoMergePolicyto keep the numbers stable; a plainnew 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 readerisCurrent()to decide gets the wrong answer. - Reach through Lucene's helpers.
SearcherManagerrefreshes withopenIfChanged, notisCurrent(), and itsisSearcherCurrent()is package-private (SearcherManager.java:232).FilterDirectoryReaderpassesisCurrent()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 throwingIllegalArgumentException("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.
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.
/*
* 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);
}
}
}
# 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-eaand-da.
More files, environment, full output46 words
| lucene | Lucene main e1faeba08bde (2026-11-02) |
|---|---|
| jdk | 25.0.3+9-LTS |
# 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)- 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,
PrimaryNodeforces 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 callopenIfChangedwhenisCurrent()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 readerr2afterwriter.close()and assertsassertFalse(r2.isCurrent())after that close (:177,:181), then again after a newIndexWriteradds a document (:189) and commits on close (:194). This is indirect:r2is already non-current before the new writer opens. Before LUCENE-6523 (2015), which made a commit itself a change for NRT readers,:177,:181and:189wereassertTrueand:194already 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.testIsCurrentasserts 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.internaladvanceSegmentInfosVersion(IndexWriter.java:1412-1423), for replication only. The comment is about the replication protocol, which needs unique versions, not a ruling on coreSegmentInfos.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 correctisCurrent()inside one writer session; its discussion does not mention rollback or a new session. ThetestIsCurrentthat LUCENE-2802 added in 2010 assertedtruefor an NRT reader after a commit-on-close that committed exactly what it showed; LUCENE-6523 changed that tofalsein 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.
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
trueintofalse, 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 thosefalseanswers are right by contract. - Using the segments file name as the commit's identity has a precedent in the same class:
doOpenNoWritertreats an equalgetSegmentsFileName()as "no change" (StandardDirectoryReader.java:487-489). The reopen path of commit readers, which callsisCurrent()(:480), is unchanged. - It does not stop the version reuse itself, so other code that treats "same version" as "same state" (the
SearcherLifetimeManagercase) is not covered. - It relies on an NRT reader's
SegmentInfosnaming 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,prepareCommitorOpenMode.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:testwith 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:testran too, but all 15 of its tests were skipped (14 are@Nightly;TestSimpleServerruns only as a nested suite), so it adds no evidence. - After: the fact check's explicit-commit-point probe (
setIndexCommiton the latest commit), which collides on unpatched main, passes with the patched class (-ea, seed DEADBEEF). :lucene:core:checkGoogleJavaFormatpasses; the patch applies cleanly toea57360912ed(applied withgit apply;git apply --checkalso 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
-eaand-da, all green.
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.
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.
Add a caveat to isCurrent() for readers whose writer ended without committing.
Trade-offs
Leaves the wrong answer in place; the weakest option.
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, propertyClosedWriterReaderCurrentSound, 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.
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).
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues and PRs (apache/lucene, incl. migrated JIRA) | 2026-10-25 | a 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 phrasings | no 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-25 | 40 queries | no 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, web | 2026-10-25 | 10 JIRA, 5 commit, 3 downstream and 6 web queries; OpenSearch, Elasticsearch and Solr searched | no report; one downstream PR handles version ordering across primary promotion in its own engine |
| Open PRs | 2026-10-25 | all 412 open apache/lucene PRs checked file by file; the 21 touching these files grepped for the comparison and the version-setting code | none changes the isCurrent comparison or how a new writer sets its version |
| Different bugs nearby | 2026-10-22 to 2026-10-25 | read | #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) |
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.
| Version or commit | Verdict | Result |
|---|---|---|
main ea57360912ed | affected | repro 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 | affected | repro 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 | affected | probe 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 | affected | probe reproduces for every k (step 2 and 2) |
| 2.9.4, 3.0.3 | not affected | false after every new commit (the version moved only on commits) |
trunk 6c9052d2311 (LUCENE-2805) vs parent 620b2a0619a | bisect | the commit reproduces, its parent does not (source builds) |
branch_3x 0196fb48869 (backport) vs parent 3ee85186e6f | bisect | the backport reproduces, its parent does not; 5137f949bec (LUCENE-2802, 22 minutes later) also reproduces |
fix on ea57360912ed | not affected | both repro tests pass; all of :lucene:core:test passes |
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.
| Check | Verdict | What it found |
|---|---|---|
| study verifiers | 8 new bug, 1 intended, 1 same root cause | proof-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 wrong | Supplied the devil's-advocate case, the commit-user-data collision (test4) and the SearcherLifetimeManager symptom (test5). |
| current-main reproducer | real | Main -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 confidence | Re-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.0 | 27 runs: 18 releases, main and 5 commits (some also with -da), commit-level A/B on both lines. |
| prior art | new | 124 tool queries, 1,047 hits, all 412 open PRs; no report. |
| fix validation | fix validated | Scratch 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 fixed | Re-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 fixed | An 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 fixed | Checked 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 setIndexCommit | The fact check's explicit-commit-point probe passes with the patched isCurrent(); git apply --check of the patch on ea57360912ed passes. |
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 315 KB
Finding record, Lucene and upstream sources, repro files and diagram notes652 words
- 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 (-eaand-da, 10 iterations) and through gradle in a clean worktree; the Maven Central jars 10.5.2 and 9.12.3 (SHA-1 checked); amain()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 ofea57360912ed. The study's public-list rerun reproduced it on mainb83101cdd2; no commit since touches the three files involved.
- The finding in 30 seconds
Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/StandardDirectoryReader.java:126, :529-544Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/SegmentInfos.java:140-141, :1106-1107Lucene 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-6105Lucene 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-638Lucene main ea57360912ed: lucene/core/src/test/org/apache/lucene/index/TestIndexWriterReader.java:132-200, :203-241lucene-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-1423lucene-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-2574Lucene 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
- TestOrphanedNrtReaderIsCurrent.java (JUnit test):
repro/LFV-012/src/org/apache/lucene/index/TestOrphanedNrtReaderIsCurrent.java(61 lines)
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.
assets/LFV-012/bug.png(315 KB)
