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.
- In
lucene-replicator's NRT segment replication (opt-in, experimental) aPrimaryNodeowns theIndexWriter. EachflushAndRefresh()opens a new searcher and, if anything changed, publishes it as an NRT point with a version number and returnstrue. - Publishing builds a
CopyState: the point's files with their headers and checksums. Replicas ask for it withgetCopyState()and copy the files they lack;getCopyStateVersion()says which version that is. - 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()returnedtrue.
- A
PrimaryNodeon a new index publishes the empty point, version 1. You add 1 document and callflushAndRefresh(). - While it opens the new segment's first file (
_0.cfe) to read its header, storage throws one transientIOException(think "too many open files").flushAndRefresh()throws it. - The error is gone and you retry. You expect the retry to publish version 3 to replicas and return
true. - Instead it returns
false("no changes"): the primary searches version 3 butgetCopyStateVersion()is still 1, so replicas are never told. Asking for the point now throwsAssertionErrorwith assertions on, or hands out the old point, whose files may already be deleted (those the new point no longer uses). - 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.
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.
// 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;- Publish the empty point. The constructor's
setCurrentInfospublishes version 1 (no files). - Add 1 document, refresh.
flushAndRefresh()refreshes the searcher to version 3 (PrimaryNode.java:165) and callssetCurrentInfos. It setscurInfosto version 3 and releases version 1 (:290-295). - One read fails.
readLocalFileMetaDataopens_0.cfeto read its header (Node.java:213). It returnsnullonly forEOFException | CorruptIndexException(:219) andFileNotFoundException | NoSuchFileException(:232); this plainIOExceptionpropagates out offlushAndRefresh(). There is nocatchorfinallybetween the switch andcopyState, socopyStatestays at version 1. - The retry.
flushAndRefresh()finds nothing new to refresh, and the version check againstcurInfos(:280) sees 3 == 3 and returnsfalse.getCopyStateVersion()is still 1 (:183-185). - Asking for the point.
getCopyState()assertscurInfos == 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). - Healing. One more
addDocumentchanges the writer's version; the nextflushAndRefresh()passes the check and runs a full publish (versions 5 and 5). Nothing else publishes: the privatesetCurrentInfosis called only by the constructor (:126) andflushAndRefresh()(: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 (aPrimaryNodesubclass; the package is@lucene.experimental). Every release since 6.0.0, defaultIndexWriterConfig, 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
IOExceptionother than EOF / corrupt / not-found whileflushAndRefresh()opens or reads the new point's files, for example aFileSystemException"Too many open files" or an EIO. The hunt's single-fault sweep (aNoMergePolicyscript) injected oneIOExceptionat one representative event of each (operation, file) class insidesetCurrentInfos, 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()returnstrue, 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
NoSuchFileExceptionandreleaseCopyStatethrows; that failing release leaks the copy count, which can makeclose()wait forever under the defaultremoteCloseTimeoutMs = -1(a separate bug with its own fix site, publicly reported as #11674). With assertions on everygetCopyState()throwsAssertionErroruntil 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).
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).
/*
* 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();
}
}
}
# $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
falseand the offered point stays at version 1:expected:<3> but was:<1>.
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.
| lucene | main 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 |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux aarch64 |
# 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.cfecopyStateis documented as the "IncRef'd state of the last published NRT point" handed to replicas, andgetCopyState()assertscurInfos == copyState.infos(); both break.flushAndRefresh()"Returns true if there were changes"; the retry returnsfalse.- Nothing says to discard a primary after an exception, and the core
ReferenceManagerrefresh swaps only after its fallible work succeeded.
The full argument289 words
- The module defines
copyStateas 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.txtorMIGRATE.mdentry says aPrimaryNodemust be thrown away afterflushAndRefresh()throws; the node stays open and theIndexWriterhas no tragic event. - The core refresh is failure-atomic.
ReferenceManager.doMaybeRefreshswaps only afterrefreshIfNeededsucceeded and releases the new reference on failure (ReferenceManager.java:167-178). The replica orders part of its cut-over the same way:ReplicaNode.finishNRTCopyreads 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.setCurrentInfossetscurrentInfos(: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:
- Nothing documents a discard rule, and instead of failing loudly the node answers the retry with a silent
false. - The module's own invariants (the
copyStatecomment, thegetCopyStateassert, theCopyStatejavadoc) break. - The method declares
IOException, and the metadata read does propagate otherIOExceptions despite its javadoc; a real filesystem raises them. - 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).
- Experimental lowers the reach, not the correctness bar. The cross-model reviews, the devil's advocate and the adjudication rejected the "intended" reading.
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).
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, orAlreadyClosedExceptionafter a tragic event) now propagates after the new point is published: state stays consistent, but the caller sees an exception instead oftrue. 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;addSuppressedwould 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:testtask passes (16 tests: the repro passes; 15 are skipped, the 14@Nightlytests andTestSimpleServer, which runs only nested). - With A and nightly tests enabled:
TestNRTReplication13 tests, 0 failures (53 s);TestStressNRTReplication1 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
setCurrentInfosno 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
releaseCopyStateitself still fails its release check (3 of 3; that is the sibling bug).
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.
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, 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).
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.setCurrentInfoswith a failure-atomicity question and ran a probe on the stock main jars: one injected read error on a new.livfile, then searching version 6 against copy state version 3 and agetCopyStatelisting 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
IOExceptionat everyDirectory/IndexInputevent insidesetCurrentInfos, 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 aNoMergePolicyscript 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
TieredMergePolicyin 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.
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.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene), downstream repositories, web | 2026-10-29 and 2026-10-31 | the 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 global | no report; 0 high-overlap hits |
| Open PRs and git history | 2026-10-31 | the 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 archive | 2026-10-31 | 28 list and 6 JIRA queries; every replicator-component JIRA issue since 2016; relevant threads read in full | no report; the 2022 java-user thread behind #11674 is about the close() hang |
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.
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | the one test fails (gradle and harness, -ea and -da); passes with fix A |
| 10.6.0, 10.5.2 | affected | the one test fails (-ea and -da) |
| 9.1.0 | affected | the 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 | affected | a main() port of the same steps: retry false, searching 3, copy state 1 |
| 6.0.0 | first release | the main() port fails with -ea and -da; so does the introducing commit's code |
| 5.5.5 | no API | no NRT replication package; the probe does not compile |
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.
| Check | Verdict | What it found |
|---|---|---|
| independent reproducer | real | Own 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, high | Executing; 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, high | Executing; 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 | real | Built the crash-and-promote case and rejected it for five reasons; re-ran the finder's test (fails -ea and -da). |
| adjudication | confirmed, high | Re-ran the reproducer's test at a new seed (fails -ea and -da; passes with the fix). |
| credibility audit | keep | Re-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 | real | 2026-10-31: fails on main, 10.5.2 and 10.6.0 (-ea and -da); passes with the fix shadowed; not fixed upstream. |
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 464 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,311 words
- 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:testtask; with nightly tests enabledTestNRTReplication, 13 tests, andTestStressNRTReplication, one run). The 10.5.2 and 10.6.0 release jars with the one-test repro (assertions on and off). Amain()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 commit1ae7291429b; the one-test repro on 9.1.0. Upstream main2f424fd428da(2026-10-30) hasPrimaryNode.javabyte-identical to645d4fe14a20. OpenJDK 25.0.3, Linux aarch64 (the only platform run).
- 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 ea57360912edlucene/replicator/src/java/org/apache/lucene/replicator/nrt/Node.java:196 (readLocalFileMetaData), :219 (catches EOFException | CorruptIndexException), :232 (catches FileNotFoundException | NoSuchFileException); any other IOException propagateslucene/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)
- TestPrimaryRetryAfterFailedPublish.java (one test):
repro/LFV-1024/TestPrimaryRetryAfterFailedPublish.java(66 lines)
assets/LFV-1024/bug.png(464 KB)
