All findings · Lucene finding · LFV-1024 · primarynode-failed-refresh-leaves-stale-copystate

One I/O error in PrimaryNode.flushAndRefresh leaves replicas on the old NRT point, and the retry reports no changes

In NRT replication, one transient IOException while flushAndRefresh() reads the new files leaves the primary searching the new point but offering replicas the old one, and the retry returns false ("no changes"). In the repro: searching version 3, offered version 1, until the index changes again.

Summary

Priority
Low, rank 941After one transient I/O error in flushAndRefresh, experimental NRT replication stops offering the new point to every replica until the index changes again, which matches the failure-then-stale profile of LFV-312.
Impact
silent wrong results
Since
6.0.0 (LUCENE-5438, 2016)introduced 2016-01-24 · over 10 years
Affected
6.0.0 to 10.6.0 (the latest release) and main
Status
documented
Found by
non-formal: a failure-path code review with a probe, then a single-fault injection sweep

AI-assisted page: this finding was first signalled by code reading or testing 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

setCurrentInfos switches the primary to the new point and releases the old point's files first, then does the work that can fail (opening every new file to read its header), and sets the point it hands to replicas last. One exception in between leaves the two out of step, and the retry's "anything new?" check looks at the wrong one.

Background: how a primary publishes an NRT point

  1. In lucene-replicator's NRT segment replication (opt-in, experimental) a PrimaryNode owns the IndexWriter. Each flushAndRefresh() opens a new searcher and, if anything changed, publishes it as an NRT point with a version number and returns true.
  2. Publishing builds a CopyState: the point's files with their headers and checksums. Replicas ask for it with getCopyState() and copy the files they lack; getCopyStateVersion() says which version that is.
  3. The primary holds a reference on the published point's files, so the writer does not delete them while replicas copy them. Callers typically notify replicas only when flushAndRefresh() returned true.
Diagram of the repro: a PrimaryNode publishes the empty point, version 1. After one document, flushAndRefresh switches the primary to version 3 and releases version 1, then an IOException while reading _0.cfe stops it before the copy state is updated. The retry sees version 3 unchanged and returns false, so replicas are still offered version 1 until the next index change.
The repro's timeline: the primary publishes version 1, switches to version 3, one file read fails before the copy state is updated, and the retry returns "no changes" while replicas are still offered version 1. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. A PrimaryNode on a new index publishes the empty point, version 1. You add 1 document and call flushAndRefresh().
  2. While it opens the new segment's first file (_0.cfe) to read its header, storage throws one transient IOException (think "too many open files"). flushAndRefresh() throws it.
  3. The error is gone and you retry. You expect the retry to publish version 3 to replicas and return true.
  4. Instead it returns false ("no changes"): the primary searches version 3 but getCopyStateVersion() is still 1, so replicas are never told. Asking for the point now throws AssertionError with assertions on, or hands out the old point, whose files may already be deleted (those the new point no longer uses).
  5. Only the next index change plus another flushAndRefresh() heals it (one more document: versions 5 and 5). On a quiet index the last changes can stay off the replicas indefinitely.

Who is affected

Users of the opt-in, experimental NRT segment replication, in every release since 6.0.0, with the default config. It takes one transient I/O error other than not-found, EOF or corruption while the new files are read. A busy index heals at its next change and refresh; on a quiet index the last changes stay off the replicas indefinitely.

Mechanism, terms and numbers658 words

In lucene-replicator's NRT segment replication, PrimaryNode.flushAndRefresh() publishes each new NRT point through setCurrentInfos. That method first switches curInfos to the new SegmentInfos, increfs its files and decrefs the previous point's files, and only then serializes the infos (in memory) and opens every new file to read its header, footer and checksum, which can throw. copyState, the point handed to replicas, is assigned last. One IOException in between (any type except EOF, corrupt-index and not-found, which the metadata read turns into null) leaves curInfos at the new point and copyState at the old one, whose own reference was already released. The retry compares the writer's version with curInfos, sees no change and returns false, so replicas are not offered the new point until the index changes and flushAndRefresh() runs again. In that window getCopyState() throws AssertionError with assertions on, or offers the old point, whose files may already be deleted if the new point no longer uses them, so a copy fails with NoSuchFileException. Nothing wrong is served and the primary's index is intact. The order dates from the feature's first commit (LUCENE-5438) and ships in every release from 6.0.0 to 10.6.0; doing the fallible work first and then switching everything together fixes the repro and passes the module's nightly suites.

The code (main 645d4fe14a20)

// PrimaryNode.setCurrentInfos(), abridged
if (curInfos != null && infos.getVersion() == curInfos.getVersion()) {
  return false;                                       // :280-288  "no change"
}
SegmentInfos oldInfos = curInfos;                     // :290
writer.incRefDeleter(infos);                          // :291
curInfos = infos;                                     // :292  1. switched to the new point
if (oldInfos != null) {
  writer.decRefDeleter(oldInfos);                     // :294  2. old point's files released
}
infos.write(tmpIndexOutput);                          // :303  declared IOException (in memory)
for (SegmentCommitInfo info : infos) {
  for (String fileName : info.files()) {
    FileMetaData metaData = readLocalFileMetaData(fileName);   // :310  3. can throw
    ...
copyState = new CopyState(..., infos.getVersion(), ..., curInfos);   // :327-335  4. only now
return true;

Step by step, on the repro

  1. Publish the empty point. The constructor's setCurrentInfos publishes version 1 (no files).
  2. Add 1 document, refresh. flushAndRefresh() refreshes the searcher to version 3 (PrimaryNode.java:165) and calls setCurrentInfos. It sets curInfos to version 3 and releases version 1 (:290-295).
  3. One read fails. readLocalFileMetaData opens _0.cfe to read its header (Node.java:213). It returns null only for EOFException | CorruptIndexException (:219) and FileNotFoundException | NoSuchFileException (:232); this plain IOException propagates out of flushAndRefresh(). There is no catch or finally between the switch and copyState, so copyState stays at version 1.
  4. The retry. flushAndRefresh() finds nothing new to refresh, and the version check against curInfos (:280) sees 3 == 3 and returns false. getCopyStateVersion() is still 1 (:183-185).
  5. Asking for the point. getCopyState() asserts curInfos == copyState.infos() (:236): with assertions on it throws; with them off it increfs and returns version 1 (no files here; in general, files only the old point used may already be deleted).
  6. Healing. One more addDocument changes the writer's version; the next flushAndRefresh() passes the check and runs a full publish (versions 5 and 5). Nothing else publishes: the private setCurrentInfos is called only by the constructor (:126) and flushAndRefresh() (:166).

When the new point drops the old point's segment the window is worse: in the reproducer's probe (publish 1 document, delete it, add another, fail the read of _1.cfs, retry) the retry returns false with searching 7 and copy state 3; with assertions off getCopyState() offers _0.cfe, _0.si and _0.cfs, all already deleted, and releaseCopyState throws NoSuchFileException: _0.cfe. When the old segment stays in the new point its files keep a reference: a check probe (publish 1 document, add a second, fail the read of _1.cfs, retry, assertions off) gets false with searching 5 and copy state 3, but the offered _0.cfe, _0.si and _0.cfs all still exist and releaseCopyState succeeds.

Exposure and likelihood420 words
  • Who is hit. Applications that use lucene-replicator's NRT segment replication (a PrimaryNode subclass; the package is @lucene.experimental). Every release since 6.0.0, default IndexWriterConfig, codec and merge policy. Reproduced on small indexes; the order does not depend on the index size once the new point has files to read.
  • What it takes. One transient IOException other than EOF / corrupt / not-found while flushAndRefresh() opens or reads the new point's files, for example a FileSystemException "Too many open files" or an EIO. The hunt's single-fault sweep (a NoMergePolicy script) injected one IOException at one representative event of each (operation, file) class inside setCurrentInfos, 72 classes covering 632 events: 57 of the 72 leave the stale state, with assertions on and off; the other 15 hit the constructor's first publish, which throws; with fix A none leaves the stale state. Likelihood: rare; nothing has been reported since 2016. All of it is injected faults on one platform: it shows the behaviour, not how often it happens in the field.
  • What goes wrong. The caller's retry gets a false "no changes". A primary that pushes points only when flushAndRefresh() returns true, as Lucene's own reference test primary does (SimplePrimaryNode.java:344), never tells replicas about the point. A busy index heals at the next change and refresh; for a quiet index (the end of a bulk load, an idle shard) the last changes stay off the replicas indefinitely.
  • In the window. A replica that asks for the current point (a new or restarted replica) is offered the old point. With assertions off, files only the old point used may already be deleted (when the new point dropped its segment), so the copy fails with NoSuchFileException and releaseCopyState throws; that failing release leaks the copy count, which can make close() wait forever under the default remoteCloseTimeoutMs = -1 (a separate bug with its own fix site, publicly reported as #11674). With assertions on every getCopyState() throws AssertionError until the next change and refresh. When the old segments are still in the new point, the stale point is offered with intact files.
  • What does not go wrong. No wrong bytes are served: Lucene file names are write-once, so a deleted name makes the copy fail loudly. The primary's own index, searcher and commits are intact.
  • Impact class: silent wrong results (the wrong result is the false "no changes" and the stale getCopyStateVersion(), not wrong bytes); priority low (opt-in experimental module, rare trigger, heals at the next change and refresh).

Minimal repro

One JUnit test in the replicator module's test package: an in-memory primary, 1 empty document, the default config, single thread, deterministic. A FilterDirectory throws one IOException from the file open done while the primary reads the new files' metadata, standing in for a transient storage error. Run it with gradle (command below).

TestPrimaryRetryAfterFailedPublish.java (one test)66 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.replicator.nrt;

import java.io.IOException;
import java.util.Map;
import org.apache.lucene.document.Document;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.index.SegmentCommitInfo;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.FilterDirectory;
import org.apache.lucene.store.IOContext;
import org.apache.lucene.store.IndexInput;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestPrimaryRetryAfterFailedPublish extends LuceneTestCase {

  public void testRetryPublishesNewPoint() throws Exception {
    boolean[] failOnce = {false};
    StackWalker walker = StackWalker.getInstance();
    FilterDirectory dir =
        new FilterDirectory(new ByteBuffersDirectory()) {
          @Override
          public IndexInput openInput(String name, IOContext context) throws IOException {
            String method = "readLocalFileMetaData";
            if (failOnce[0] && walker.walk(s -> s.anyMatch(f -> f.getMethodName().equals(method)))) {
              failOnce[0] = false;
              throw new IOException("transient I/O error opening " + name);
            }
            return super.openInput(name, context);
          }
        };
    IndexWriter writer = new IndexWriter(dir, new IndexWriterConfig());
    PrimaryNode primary =
        new PrimaryNode(writer, 0, 0, -1, null, null) {
          @Override
          protected void preCopyMergedSegmentFiles(
              SegmentCommitInfo info, Map<String, FileMetaData> files) {}
        };
    try {
      writer.addDocument(new Document());
      failOnce[0] = true;
      expectThrows(IOException.class, primary::flushAndRefresh);
      primary.flushAndRefresh();
      // expected: the retry publishes the new point to replicas; actual: copy state stays old
      assertEquals(primary.getCurrentSearchingVersion(), primary.getCopyStateVersion());
    } finally {
      primary.close();
    }
  }
}
Command
# $REPRO holds TestPrimaryRetryAfterFailedPublish.java; $LUCENE is a Lucene checkout
cp $REPRO/TestPrimaryRetryAfterFailedPublish.java $LUCENE/lucene/replicator/src/test/org/apache/lucene/replicator/nrt/
cd $LUCENE
./gradlew :lucene:replicator:test --tests TestPrimaryRetryAfterFailedPublish -Ptests.seed=DEADBEEF
# assertions off: add -Ptests.asserts=false (same failure)
Expected
After the retry, replicas are offered the point being searched: getCopyStateVersion() == getCurrentSearchingVersion() == 3.
Observed
The retry returns false and the offered point stays at version 1: expected:<3> but was:<1>.

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

More files, environment, full output305 words

Public API only: an anonymous PrimaryNode with its one abstract method, and an anonymous FilterDirectory. The directory finds the metadata read by method name (StackWalker), so the fault hits only that open, not the searcher refresh that runs just before it (a fault there would throw before anything is switched). The expectThrows line proves the fault fired inside flushAndRefresh(). Use this one test as the regression test too.

The seed only drives the test framework's randomization: -Dtests.iters=20 gives 20 of 20 failures with the same message, with assertions on and off. The test compiles from 9.1.0 on; older releases were run with a main() port of the same steps.

Environment

lucenemain 645d4fe14a20, 10.5.2 and 10.6.0: reproduced, assertions on and off; 6.0.0 to 10.6.0: reproduced by a main() port; 5.5.5: no NRT replication API
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux aarch64

Observed output

# gradle, main 645d4fe14a20, seed DEADBEEF, assertions on (assertions off: identical)
TestPrimaryRetryAfterFailedPublish > testRetryPublishesNewPoint FAILED
    java.lang.AssertionError: expected:<3> but was:<1>
        at org.apache.lucene.replicator.nrt.TestPrimaryRetryAfterFailedPublish.testRetryPublishesNewPoint(TestPrimaryRetryAfterFailedPublish.java:61)

# repro harness, the same message: main -ea/-da, 10.5.2 -ea/-da, 10.6.0 -ea/-da (1 run, 1 failed each);
# -Dtests.iters=20 on main: 20 of 20 fail, -ea and -da
# with fix A: the test passes (gradle, assertions on and off)

# probe with the same steps, main, assertions off (assertions on: getCopyState threw java.lang.AssertionError)
initial: searching=1 copyState=1
first flushAndRefresh threw: java.io.IOException: transient I/O error opening _0.cfe
retry flushAndRefresh returned false; searching=3 copyState=1
getCopyState offers version 1 files []
after next change: flushAndRefresh returned true; searching=5 copyState=5

# probe with a published document in the old point, main, assertions off
after refresh1: searching=3 copyState=3 files=[_0.cfe, _0.cfs, _0.si]
refresh2 threw java.io.IOException: simulated transient I/O error opening _1.cfs
retry=false
after retry: searching=7 copyState=3 files=[_1.cfe, _1.cfs, _1.si]
  offered _0.cfe exists=false
  offered _0.si exists=false
  offered _0.cfs exists=false
releaseCopyState threw java.nio.file.NoSuchFileException: _0.cfe

Why it looks unintended

  • copyState is documented as the "IncRef'd state of the last published NRT point" handed to replicas, and getCopyState() asserts curInfos == copyState.infos(); both break.
  • flushAndRefresh() "Returns true if there were changes"; the retry returns false.
  • Nothing says to discard a primary after an exception, and the core ReferenceManager refresh swaps only after its fallible work succeeded.
The full argument289 words
  • The module defines copyState as the published point. PrimaryNode.java:59-61: "IncRef'd state of the last published NRT point; when a replica comes asking, we give it this as the current NRT point". CopyState.java:26: "Holds incRef'd file level details for one point-in-time segment infos on the primary node". After the failure the copy state holds no reference of its own: the primary's reference for that point was released at :294, so files only it used are deleted.
  • The code asserts the invariant. getCopyState(): assert curInfos == copyState.infos(); (:236).
  • The API promises a change signal. flushAndRefresh() (:155-158): "Flush all index operations to disk and opens a new near-real-time reader. new NRT point, to make the changes visible to searching. Returns true if there were changes."
  • No discard rule. No javadoc, package doc, CHANGES.txt or MIGRATE.md entry says a PrimaryNode must be thrown away after flushAndRefresh() throws; the node stays open and the IndexWriter has no tragic event.
  • The core refresh is failure-atomic. ReferenceManager.doMaybeRefresh swaps only after refreshIfNeeded succeeded and releases the new reference on failure (ReferenceManager.java:167-178). The replica orders part of its cut-over the same way: ReplicaNode.finishNRTCopy reads the new infos (readCommit, ReplicaNode.java:433-434) before its cut-over call (:440) and increfs the new files before it decrefs the old ones ("Must first incRef new NRT files, then decRef old ones", :442-454). The replica's cut-over itself is not failure-atomic: SegmentInfosSearcherManager.setCurrentInfos sets currentInfos (:99) before the fallible reader open (:100, :116).
  • History. The order dates from the class's first commit (2016) and was never discussed; no upstream test injects I/O errors during point publication. Maintainers have accepted robustness fixes in this module (#11822, LUCENE-9967).
The devil's-advocate check191 words

Strongest case for "intended" (the devil's-advocate verifier): NRT replication follows a crash-and-promote failure model. The module's tests crash nodes and flip bits but never inject I/O errors, and the reference test server fails on any unexpected exception, so a primary whose flushAndRefresh() threw is never used again. readLocalFileMetaData's javadoc says it "returns null ... if there are any problems" (Node.java:193-194), and PrimaryNode.java:312 asserts the file exists, so the author assumed this step cannot fail. The module is @lucene.experimental.

Why it fails:

  1. Nothing documents a discard rule, and instead of failing loudly the node answers the retry with a silent false.
  2. The module's own invariants (the copyState comment, the getCopyState assert, the CopyState javadoc) break.
  3. The method declares IOException, and the metadata read does propagate other IOExceptions despite its javadoc; a real filesystem raises them.
  4. The reference server failing on exceptions is a test-failure mechanism, not a recovery contract; a downstream server rethrows such an exception rather than discarding the primary (read, not run).
  5. Experimental lowers the reach, not the correctness bar. The cross-model reviews, the devil's advocate and the adjudication rejected the "intended" reading.

Suggested fix

Recommended A. Read everything first, then publish atomically

In setCurrentInfos, do the fallible work first: incref the new point, serialize it and read every file's metadata into a local map (on failure decref it and rethrow). Only then switch curInfos and copyState together and decref the old point. The repro passes, and so does the module's nightly TestNRTReplication (13 tests).

All options and trade-offs3 options ยท 867 words

One fix was run (A), the study's patch. It keeps the incref-new-before-decref-old order of the replica's cut-over. A reviewer asked that its handling of an exception from the final decref of the old point be looked at (see trade-offs).

A. Read everything first, then publish atomically (validated by the checks) (recommended)

Incref the new infos so their files stay alive, serialize them and read all metadata into a local map; on failure decref the new infos and rethrow, leaving curInfos, lastFileMetaData, copyState and the old point's references exactly as they were. After everything was read, set curInfos, lastFileMetaData and copyState together, then decref the previous point. The retry then sees the old version in curInfos and publishes the new point.

--- lucene/replicator/src/java/org/apache/lucene/replicator/nrt/PrimaryNode.java
+++ lucene/replicator/src/java/org/apache/lucene/replicator/nrt/PrimaryNode.java
@@ -287,34 +287,44 @@
       return false;
     }
 
-    SegmentInfos oldInfos = curInfos;
+    // Protect the new point's files while we read their metadata, but publish nothing (curInfos,
+    // the deleter references of the previous point, copyState) until all of it was read:
     writer.incRefDeleter(infos);
-    curInfos = infos;
-    if (oldInfos != null) {
-      writer.decRefDeleter(oldInfos);
-    }
-
-    message("top: switch to infos=" + infos.toString() + " version=" + infos.getVersion());
-
-    // Serialize the SegmentInfos.
-    ByteBuffersDataOutput buffer = new ByteBuffersDataOutput();
-    try (ByteBuffersIndexOutput tmpIndexOutput =
-        new ByteBuffersIndexOutput(buffer, "temporary", "temporary")) {
-      infos.write(tmpIndexOutput);
-    }
-    byte[] infosBytes = buffer.toArrayCopy();
-
+    byte[] infosBytes;
     Map<String, FileMetaData> filesMetaData = new HashMap<>();
-    for (SegmentCommitInfo info : infos) {
-      for (String fileName : info.files()) {
-        FileMetaData metaData = readLocalFileMetaData(fileName);
-        // NOTE: we hold a refCount on this infos, so this file better exist:
-        assert metaData != null : "file \"" + fileName + "\" is missing metadata";
-        assert filesMetaData.containsKey(fileName) == false;
-        filesMetaData.put(fileName, metaData);
+    boolean success = false;
+    try {
+      // Serialize the SegmentInfos.
+      ByteBuffersDataOutput buffer = new ByteBuffersDataOutput();
+      try (ByteBuffersIndexOutput tmpIndexOutput =
+          new ByteBuffersIndexOutput(buffer, "temporary", "temporary")) {
+        infos.write(tmpIndexOutput);
+      }
+      infosBytes = buffer.toArrayCopy();
+
+      for (SegmentCommitInfo info : infos) {
+        for (String fileName : info.files()) {
+          FileMetaData metaData = readLocalFileMetaData(fileName);
+          // NOTE: we hold a refCount on this infos, so this file better exist:
+          assert metaData != null : "file \"" + fileName + "\" is missing metadata";
+          assert filesMetaData.containsKey(fileName) == false;
+          filesMetaData.put(fileName, metaData);
+        }
+      }
+      success = true;
+    } finally {
+      if (success == false) {
+        try {
+          writer.decRefDeleter(infos);
+        } catch (Throwable t) {
+          // keep the original exception
+        }
       }
     }
 
+    message("top: switch to infos=" + infos.toString() + " version=" + infos.getVersion());
+    SegmentInfos oldInfos = curInfos;
+    curInfos = infos;
     lastFileMetaData = Collections.unmodifiableMap(filesMetaData);
 
     message(
@@ -333,6 +343,9 @@
             completedMergeFiles,
             primaryGen,
             curInfos);
+    if (oldInfos != null) {
+      writer.decRefDeleter(oldInfos);
+    }
     return true;
   }
 

Trade-offs

  • The previous point's files are held until the new metadata was read, which is the point of the fix (the cost was not measured).
  • An exception from the final decRefDeleter(oldInfos) (a failed file delete, or AlreadyClosedException after a tragic event) now propagates after the new point is published: state stays consistent, but the caller sees an exception instead of true. Today that call runs before the metadata read and leaves the stale state, so A is not worse; a maintainer may prefer to catch and log it there (not run).
  • The failure path's catch (Throwable t) drops a secondary exception; addSuppressed would be more idiomatic (cosmetic, not run).

What the checks validated

  • Scratch worktree of main 645d4fe14a20 (removed afterwards), seed DEADBEEF: stock, the test fails through gradle with assertions on and off; with A it passes with both.
  • With A, the whole :lucene:replicator:test task passes (16 tests: the repro passes; 15 are skipped, the 14 @Nightly tests and TestSimpleServer, which runs only nested).
  • With A and nightly tests enabled: TestNRTReplication 13 tests, 0 failures (53 s); TestStressNRTReplication 1 test, 0 failures (one run of 324 s, one seed only).
  • Earlier, with A shadowed into the stock jars: the reproducer's, the finder's and the cross-model reviewers' tests pass, and in the single-fault sweep of setCurrentInfos no class leaves the stale state (0 violations of its version, file-existence, release and close checks).
  • Not fixed by A: the 15 constructor-fault classes still leave unreferenced files after close, and a separate sweep of faults inside releaseCopyState itself still fails its release check (3 of 3; that is the sibling bug).

B. Compare the retry against copyState instead of curInfos

Change the version check at PrimaryNode.java:280 to copyState.version(), so the retry re-runs the publish and the new point does reach replicas.

Trade-offs

Between the failure and the retry copyState still points at a point whose reference was released (files only it used deleted, yet offered; or AssertionError). Smaller diff, weaker fix. Not run; not recommended.

C. Treat a failed publish as fatal

Close or mark the PrimaryNode failed when setCurrentInfos throws, like IndexWriter's tragic event, so callers cannot continue with a silent false.

Trade-offs

Matches the crash-and-promote model, but turns one transient IOException (for example a momentary file-handle shortage) into a primary failover. Not run; not recommended.

Downstream note

Downstream, the open-source nrtsearch server uses this API the same way: it notifies replicas only when flushAndRefresh() returns true, and its reference manager rethrows a refresh exception rather than discarding the primary (read, not run; its outer caller was not checked).

How it was found

A review of failure paths asked what one exception in setCurrentInfos leaves behind, and a probe on the stock jars showed it. A single-fault sweep then injected one IOException per file-event class inside the method: 57 of its 72 classes leave the stale state (15 hit the constructor), none with the fix. An independent reproducer's test became the repro.

The method in full196 words
  • Method (non-formal first signal). A failure-path review read PrimaryNode.setCurrentInfos with a failure-atomicity question and ran a probe on the stock main jars: one injected read error on a new .liv file, then searching version 6 against copy state version 3 and a getCopyState listing an already deleted file.
  • Mapping (semi-formal). The finder reduced it to a JUnit and ran a bounded-exhaustive single-fault enumeration on the real jar: one IOException at every Directory/IndexInput event inside setCurrentInfos, one representative per (kind, file) class, five checks (copy state version equals searching version, offered files exist, release does not throw, close returns, no leaked files). With a NoMergePolicy script all 72 classes (covering 632 events) fail a check with assertions on and off: 57 leave the stale state, and the 15 that hit the constructor's first publish only leave unreferenced files; with the fix 0 fail the first four checks (the 15 leftover-file cases remain).
  • Masking. With TieredMergePolicy in tiny test indexes a delete-reclaiming merge often gives the retry a new version and hides the defect; the repro adds one document and deletes nothing.
  • Reduction. The independent reproducer's one-test JUnit, trimmed to the minimum, is the repro.

Prior reports, history and checks

Prior reports

No prior report in GitHub issues and PRs, the file lists of all 410 open PRs, the git history, four mailing lists, the JIRA archive or downstream repositories (2026-10-29, repeated 2026-10-31). Closest: #11674, a close() hang from a copy count that never reaches 0, which is the separate bug this one can lead into.

Searches3 searches

Nearest items, none this bug: #11674 / LUCENE-10638 (2022, closed by the opt-in close timeout of #11822): PrimaryNode.close() hangs because a CopyState count never reaches 0, cause unknown; it matches the releaseCopyState leak this bug can lead into, which has its own root cause and fix site. #11913 (open): the constructor publishes this, a different defect. Open PRs #13142 and #13045 touch PrimaryNode.java but not setCurrentInfos. nrtsearch#135: a late-joining replica gets no point until a document is indexed, a different cause with no I/O error. #6501 / LUCENE-5438: the feature issue, no discussion of failures while publishing. Limits: GitHub search matches tokens, not code (covered by the history check and the open-PR file lists); private reports cannot be searched.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene), downstream repositories, web2026-10-29 and 2026-10-31the scripted prior-report search (30 issue/PR, 4 commit, 4 downstream, 6 web queries; 1988 distinct hits) and 50 manual GitHub searches over apache/lucene, nrtsearch, OpenSearch, Elasticsearch, Solr and globalno report; 0 high-overlap hits
Open PRs and git history2026-10-31the changed files of all 410 open PRs; gh pr list --search for PrimaryNode, CopyState, flushAndRefresh; upstream commits to PrimaryNode.java#13142 and #13045 touch PrimaryNode.java, not setCurrentInfos; newest commit dbaf97f5dcc (2025-07-18)
Mailing lists (dev@, java-user@, issues@, commits@), JIRA archive2026-10-3128 list and 6 JIRA queries; every replicator-component JIRA issue since 2016; relevant threads read in fullno report; the 2022 java-user thread behind #11674 is about the close() hang

History

Present since the first version of PrimaryNode (LUCENE-5438, 1ae7291429b, 2016) and first released in 6.0.0. Every release run from 6.0.0 to 10.6.0, and main, fails the same way; unchanged on upstream main 2f424fd428da.

Versions tested6 rows

Introduced by 1ae7291429b ("current patch", mikemccand, 2016-01-24), the first commit of the LUCENE-5438 feature branch, which added the whole org.apache.lucene.replicator.nrt package with setCurrentInfos already in this order; merged into the main branch by 12b8721a44d (2016-02-11). LUCENE-5438 "add near-real-time replication" is now #6501, with no PR. Later commits on these lines only reformatted or changed APIs (LUCENE-9570, LUCENE-8474, LUCENE-9324, an assert message, a diamond). All 84 release tags from 6.0.0 to 10.6.0 in the history clone and all 19 commits that touched PrimaryNode.java have the same order. Not fixed upstream.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedthe one test fails (gradle and harness, -ea and -da); passes with fix A
10.6.0, 10.5.2 affectedthe one test fails (-ea and -da)
9.1.0 affectedthe one test fails (-ea and -da); first release it compiles on
6.6.6, 7.0.0, 7.7.3, 8.0.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0 affecteda main() port of the same steps: retry false, searching 3, copy state 1
6.0.0 first releasethe main() port fails with -ea and -da; so does the introducing commit's code
5.5.5 no APIno NRT replication package; the probe does not compile

Independent checks

An independent reproducer, a devil's advocate on intended behaviour, executing cross-model reviews (OpenAI GPT-5.6), an adjudication (confirmed, high), a credibility audit and a re-verification on 2026-10-31 each found it real and reproduced it.

Checks and verdicts218 words

Each check had its own lens and ran tests itself.

Each independent check and its verdict
CheckVerdictWhat it found
independent reproducer realOwn one-test JUnit written from the claim fails on main (20 of 20 seeds), 10.5.2 and 9.12.3 with -ea and -da; passes with the fix; its impact probe shows deleted files offered, the AssertionError and the healing.
cross-model review, refute lens real, highExecuting; its own 3-document test shows searching 5 against copy state 4 and, with -da, a deleted .liv offered; the fix fixes it; intended argument moderate, rejected.
cross-model review, intended lens real, highExecuting; its own test fails with -ea and -da on 645d4fe14a20 (re-run separately); intended argument weak; flagged the fix's final decref for review.
intended-behaviour devil's advocate realBuilt the crash-and-promote case and rejected it for five reasons; re-ran the finder's test (fails -ea and -da).
adjudication confirmed, highRe-ran the reproducer's test at a new seed (fails -ea and -da; passes with the fix).
credibility audit keepRe-ran the test at another seed (fails -ea and -da); noted that replicas get a stale or failing copy, never lost or corrupt data.
re-verification real2026-10-31: fails on main, 10.5.2 and 10.6.0 (-ea and -da); passes with the fix shadowed; not fixed upstream.

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,311 words

Finding record

LFV-1024, snapshot of 2026-11-06

Registry title
NRT PrimaryNode: one I/O error during flushAndRefresh leaves the new point unpublished to replicas, and the retry reports no changes
Area
replicator / NRT segment replication (PrimaryNode.flushAndRefresh, setCurrentInfos: NRT point publication)
Priority
Low, rank 941: After one transient I/O error in flushAndRefresh, experimental NRT replication stops offering the new point to every replica until the index changes again, which matches the failure-then-stale profile of LFV-312.
Impact class
silent wrong results
Impact note
Applications that use lucene-replicator's NRT segment replication (a PrimaryNode subclass; opt-in and @lucene.experimental), in every release since 6.0.0, with the default IndexWriterConfig and at any index size, are hit when one transient I/O error other than file-not-found, EOF or corruption (for example too many open files) occurs while flushAndRefresh opens the new point's files to read their headers: the retry returns false ('no changes') and replicas are not offered the new point until the next indexing change or completed merge, which for a quiet index can be indefinitely; in that window a replica that asks for the current point is offered the previous one, whose files the primary may already have deleted (with -da the copy then fails with NoSuchFileException; with -ea getCopyState throws AssertionError).
Summary
You run Lucene's NRT segment replication, index a document and call PrimaryNode.flushAndRefresh; it throws because opening one of the new files hits a transient I/O error, and once the error is gone you call flushAndRefresh again. You expect the retry to publish the new point to replicas, or the failed call to have left the previous point fully in place. Instead the failed call had already switched the primary's current point and released the previous point's files, but not the copy state that replicas are given, so the retry returns false ('no changes'), getCopyStateVersion() stays behind getCurrentSearchingVersion(), and replicas are not offered the new point until the index changes again.
Introduced
1ae7291429bad742715344f86cfa5200229b3698 (mikemccand, 2016-01-24, 'current patch', the first commit of the LUCENE-5438 feature branch jira/lucene-5438-nrt-replication; LUCENE-5438 'add near-real-time replication', migrated as #6501, developed as JIRA patches with no GitHub PR): it added the whole org.apache.lucene.replicator.nrt package, with PrimaryNode.setCurrentInfos already in the order incRefDeleter(new), curInfos = new, decRefDeleter(old), then serialize and readLocalFileMetaData per file, then copyState = new CopyState(...) [read: git blame on the history clone; later commits on these lines only reformatted or changed APIs (2cbf261032d LUCENE-9570, d4e016afdf4 LUCENE-8474, 113043b1ed2 LUCENE-9324, 380e6792530 assert message, dbaf97f5dcc diamond), none changed the order]. It reached master with the merge 12b8721a44d (mikemccand, 2016-02-11). The bug has been there since the class was written; there is no later regression commit. (2016-01-24 (1ae7291429b, feature branch); 2016-02-11 (merge 12b8721a44d into master))
First affected version
6.0.0 [exec] (the first release with the org.apache.lucene.replicator.nrt package; the history probe fails with -ea and -da: the retry returns false, searching version 4, copy state version 3, fault inside PrimaryNode.setCurrentInfos -> Node.readLocalFileMetaData); 5.5.5 and earlier: not applicable, the API does not exist (the probe does not compile on 5.5.5) [exec]
Affected versions
every release from 6.0.0 through 10.6.0, and main 645d4fe14a20 (also ea57360912ed). [exec] 10.6.0: the one-test repro fails on the 10.6.0 release jars (lucene-replicator 10.6.0 from Maven Central, SHA-1 checked) with -ea and -da, with the same message as on main (2026-10-31 re-verification and version-history runs). [exec] follow-up history probe (a main() port of the same steps with the default merge policy and codec): 6.0.0, 6.6.6, 7.7.3, 8.11.4, 9.12.3, 10.5.2 and main with -ea and -da; 7.0.0, 8.0.0, 8.8.2, 8.9.0, 8.10.0, 8.10.1, 8.11.0-8.11.3, 9.0.0 and 10.0.0 with -ea; the candidate JUnit unchanged on 9.1.0 and 10.5.2 (-ea and -da) and 10.0.0 (-ea); every run gives the same numbers, with the fault inside PrimaryNode.setCurrentInfos -> Node.readLocalFileMetaData; the independent reproduction's one-test repro also fails on 10.5.2 and 9.12.3 with -ea and -da. [read] the same statement order in all 83 release tags of the history clone that contain 12b8721a44d; branch_10x 657014b3c579 and 10.6.0 (4ae946e5a9b) have a PrimaryNode.java byte-identical to main. Not fixed upstream: the newest commit to PrimaryNode.java is dbaf97f5dcc (2025-07-18).
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that agreed, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
non-formal: code reading; also reached by exhaustive check
Upstream and prior reports
none found (2026-10-29, read-only): the prior-report record (verdict new): a scripted prior-report search with 16 GitHub issue/PR, 3 commit, 2 downstream, 20 mailing-list (dev@, java-user@, issues@, commits@), 4 JIRA-archive and 6 web queries (111 distinct hits, 0 high overlap); the finder's unit-level run (53 hits, 0 high); 33 manual GitHub searches over apache/lucene, Yelp/nrtsearch, OpenSearch, Elasticsearch and Solr; the changed files of all 415 open apache/lucene PRs (only #13045 and #13142 touch PrimaryNode.java, neither touches setCurrentInfos); all 73 apache/lucene PRs updated since 2026-10-26 (none changes a lucene/replicator file); LUCENE-5438's 19 comments (no discussion of failures while publishing a point). Re-checked on 2026-10-29: the 28 PRs updated since 2026-10-28 touch no lucene/replicator file, none of the 61 issues and PRs updated since 2026-10-27 mentions PrimaryNode, CopyState, flushAndRefresh, setCurrentInfos or the replicator, and the newest upstream commit to PrimaryNode.java is still dbaf97f5dcc (2025-07-18). Related, not this bug: #11674 / LUCENE-10638 (2022, closed by the opt-in close timeout of #11822): PrimaryNode.close() hung because a CopyState count never reached 0, cause unidentified; it relates to the separate copy-count leak LFV-1025, not to this mechanism. #11913 (open): PrimaryNode publishes this during construction, a different defect. Yelp/nrtsearch#135: a late-joining replica gets no point until a document is indexed, a different cause with no I/O error.
Lucene commit tested
main 645d4fe14a20 (2026-10-28): the one-test repro through gradle in a scratch worktree (assertions on and off; removed afterwards) and through the hunt repro harness (assertions on and off, also 20 iterations each); fix A through gradle (the repro with assertions on and off; the whole :lucene:replicator:test task; with nightly tests enabled TestNRTReplication, 13 tests, and TestStressNRTReplication, one run). The 10.5.2 and 10.6.0 release jars with the one-test repro (assertions on and off). A main() port of the same steps on 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, 10.6.0 and the introducing commit 1ae7291429b; the one-test repro on 9.1.0. Upstream main 2f424fd428da (2026-10-30) has PrimaryNode.java byte-identical to 645d4fe14a20. OpenJDK 25.0.3, Linux aarch64 (the only platform run).

Lucene and upstream sources

Finding record
  • lucene/replicator/src/java/org/apache/lucene/replicator/nrt/PrimaryNode.java:50 (@lucene.experimental), :59-61 (copyState field comment 'IncRef'd state of the last published NRT point; when a replica comes asking, we give it this as the current NRT point'), :155-158 (flushAndRefresh javadoc, 'Returns true if there were changes'), :225 (commit stores VERSION_KEY = copyState.version()), :236 (getCopyState: 'assert curInfos == copyState.infos()'), :280-288 (version-unchanged early return), :290-295 (incRefDeleter(new), curInfos = new, decRefDeleter(old)), :299-305 (serialize the infos), :307-316 (readLocalFileMetaData per file; :312 assert), :327-335 (copyState assigned last); identical at Lucene main ea57360912ed
  • lucene/replicator/src/java/org/apache/lucene/replicator/nrt/Node.java:196 (readLocalFileMetaData), :219 (catches EOFException | CorruptIndexException), :232 (catches FileNotFoundException | NoSuchFileException); any other IOException propagates
  • lucene/replicator/src/java/org/apache/lucene/replicator/nrt/CopyState.java:26 ('Holds incRef'd file level details for one point-in-time segment infos on the primary node')
The finding in 30 seconds
  • a Lucene main checkout lucene/replicator/src/java/org/apache/lucene/replicator/nrt/PrimaryNode.java:50, 59-61, 155-185, 232-241, 267-337 (read)
  • a Lucene main checkout lucene/replicator/src/java/org/apache/lucene/replicator/nrt/Node.java:42, 188-246 (read)
Who is affected
  • a Lucene main checkout lucene/replicator/src/test/org/apache/lucene/replicator/nrt/SimplePrimaryNode.java:342-356 (read)
  • a Lucene main checkout lucene/replicator/src/java/org/apache/lucene/replicator/nrt/PrimaryNode.java:64, 200-216 (remoteCloseTimeoutMs default -1) (read)
Why it looks unintended
  • a Lucene main checkout lucene/replicator/src/java/org/apache/lucene/replicator/nrt/PrimaryNode.java:59-61, 155-158, 236, 312; CopyState.java:26; Node.java:193-194 (read)
  • a Lucene main checkout lucene/replicator/src/java/org/apache/lucene/replicator/nrt/ReplicaNode.java:399-454; SegmentInfosSearcherManager.java:92-120; a Lucene main checkout lucene/core/src/java/org/apache/lucene/search/ReferenceManager.java:155-185 (read)
Suggested fix
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/util/FileDeleter.java:95-117; lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:6325-6336 (decRef can throw after decrementing) (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.