newNRTPoint calls maybeNewPrimary before any version check. maybeNewPrimary treats any generation different from the current one as a new primary and first deletes the pre-copied merged files; its only check that the generation went up is an assert after that loop. So an older generation deletes them, with assertions on and off.
- In
lucene-replicator's NRT segment replication (opt-in, experimental) onePrimaryNodeindexes andReplicaNodes copy its files. After each refresh the primary tells each replicanewNRTPoint(primaryGen, version). - Each newly promoted primary gets a higher primary generation, so replicas can tell which primary they follow. Versions keep growing across a promotion; a replica skips a notification whose version is older than its own.
- Before a merged segment becomes visible, the primary pre-copies it to the replicas. A replica keeps those files as pending until a point uses them, and then does not copy them again.
- You fail over cleanly: primary P1 (generation 1) is closed, then replica A is promoted to P2 (generation 2), and replica B follows P2 at version 11.
- P2 merges, and B pre-copies the merged segment
_3(3 files, 2167 bytes) so that its next refresh does not have to copy it. - P1's last notification, delayed in transit, now reaches B:
newNRTPoint(1, v)with a version below B's. You expect B to ignore it, as it already ignores older versions. - Instead B treats generation 1 as a new primary and deletes the 3 pre-copied files before its only check that the generation went up, an
assert. With assertions off it also sets its generation back to 1. - B's cut-over to P2's merged point (version 13) then fails with
CorruptSegmentInfoException: segment info file: _3.si cannot be read - it may be missing or corruptand B stays at version 11, or B copies the 3 files again. It recovers at P2's next point (version 15).
Users of the opt-in, experimental NRT segment replication, in every release since 6.0.0, with assertions on or off. It takes one notification or pre-copy request from the old primary that arrives after a replica moved to the new primary, while a merged segment is pre-copied; Lucene's reference transport does not order them. Rare, and it recovers at the next point.
Mechanism, terms and numbers608 words
In lucene-replicator's NRT segment replication every promoted primary gets a higher primary generation. ReplicaNode.newNRTPoint calls maybeNewPrimary before any version check, and maybeNewPrimary treats any generation different from lastPrimaryGen as a new primary: it deletes the merged segment files pre-copied from the current primary (pendingMergeFiles), and only then asserts that the generation went up. So after a clean fail-over (the old primary closed, then a replica promoted), one delayed notification or merge pre-copy request from the old primary deletes the new primary's pre-copied merged segment, with assertions on and off; with assertions off the replica also goes back to the old generation. The replica's next cut-over to the merged point then fails with a misleading "may be missing or corrupt" exception, or the merged segment is copied again. It recovers at the primary's next point, and nothing wrong is searched. Present since 6.0.0 (LUCENE-5438); ignoring an older generation before anything is touched fixes the repro and passes the module's nightly suites.
// ReplicaNode.newNRTPoint(), abridged
public synchronized CopyJob newNRTPoint(long newPrimaryGen, long version) throws IOException {
maybeNewPrimary(newPrimaryGen); // :545 before any check
if (version == curVersion) { return null; } // :558 same version: skip
if (version < curVersion) { return null; } // :564 older version: skip ("This can happen")
...
// ReplicaNode.maybeNewPrimary()
protected synchronized void maybeNewPrimary(long newPrimaryGen) throws IOException {
if (newPrimaryGen != lastPrimaryGen) { // :707 any change, older too
for (String fileName : pendingMergeFiles) {
deleter.deleteIfNoRef(fileName); // :718 1. pre-copied files deleted
}
assert newPrimaryGen > lastPrimaryGen ...; // :721 2. the only ordering check
lastPrimaryGen = newPrimaryGen; // :723 3. assertions off: back to 1
pendingMergeFiles.clear(); // :724
} ...launchPreCopyMerge (a merge pre-copy request) calls maybeNewPrimary the same way (:736).
- Primary and replica at generation 2. The primary starts with generation 2 and
forcePrimaryVersion11, past every version (up to 10) the generation-1 primary published, as a promotion does; the replica starts following it. - Two segments, one sync. Two empty documents in two segments (
_0,_1),flushAndRefresh(), and the replica copies that point. - Pre-copy.
forceMerge(1)makes_2; the primary's merged-segment warmer pre-copies[_2.cfe, _2.cfs, _2.si]to the replica throughlaunchPreCopyMerge, which records them inpendingMergeFiles. They have no reference yet: one is taken only when a point that uses them is installed (:446,:450). - The late call.
newNRTPoint(1, 10)arrives.maybeNewPrimary(1)sees 1 != 2 and runsdeleteIfNoRefon the three files, which deletes them. - Assertions on: the assert throws
newPrimaryGen=1 vs lastPrimaryGen=2, after the deletion, and also skipspendingMergeFiles.clear(), soclose()later tries to delete the files again (NoSuchFileException: _2.si, suppressed). Assertions off:lastPrimaryGenbecomes 1 and the call returns at the older-version skip.
- Late call while B copies P2's merged point: the copy listed the pre-copied files as identical (
CopyJob.java:85), so it fetches nothing; the cut-over reads the newSegmentInfos(:433-434), which references the deleted_3.si, and throwsRuntimeException: CorruptSegmentInfoException: segment info file: _3.si cannot be read - it may be missing or corruptbefore the searcher is switched (:440). B keeps serving version 11. - Late call before that point: the merged point's copy fetches all three files again, 2167 bytes in the probe.
- Assertions off: a commit in the window records
__primaryGen=1(:368); by reading, a restart from it does a full, safe re-sync (:189-191). - In every case a commit right after succeeds, and B recovers at P2's next point (version 15, generation 2).
Exposure and likelihood318 words
- Who is hit. Applications that use
lucene-replicator's NRT segment replication (PrimaryNode/ReplicaNode/CopyJobsubclasses, all marked@lucene.experimental). Every release since 6.0.0, any index size, assertions on or off. - What it takes. A fail-over during which one notification (or merge pre-copy request) from the old primary reaches a replica after it started following the new primary, plus a merged segment pre-copied from the new primary that no published point uses yet. Lucene's reference transport does not rule this out: the primary sends each notification on its own connection (
SimplePrimaryNode.java:358-366, test code), the server runs one handler thread per connection (TestSimpleServer.java:344-356), and the replica hands the received generation tonewNRTPointunchecked (SimpleReplicaNode.java:205-218;:331-339for pre-copy). Only timing keeps it rare. Likelihood: rare; nothing has been reported since 2016. - What goes wrong. (1) The cut-over to the new primary's merged point fails with a misleading "may be missing or corrupt" exception and the replica keeps serving its previous point until the primary publishes again; the reference job runner logs and ignores it (
Jobs.java:105-111, test code), so it shows as a failed refresh. (2) Or the merged segment is copied again, the cost the pre-copy exists to avoid ("NRT reopen time on replicas is only in proportion to flushed segment sizes, not merged segments",PreCopyMergedSegmentWarmer.java:33-36); for a large merge that is a full extra transfer and a slow refresh. (3) With assertions off the replica reports the old generation, and a commit in that window is tagged with it. - What does not go wrong. No wrong documents are searched: the replica serves its previous point or the new primary's, the failure comes before the searcher switch, a commit right after succeeds, and nothing hangs.
- Impact class: crash (an unexpected exception on a state the API accepts); priority low (opt-in experimental module, rare trigger, recovers at the next point).
One JUnit test in the replicator module's test package: a primary and a replica in memory, two empty documents, one sync, forceMerge(1), then the late call newNRTPoint(1, 10). Single thread, deterministic, no sockets or reflection. It accepts the late call being ignored or rejected and checks only that the pre-copied files still exist. 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.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
import java.util.concurrent.atomic.AtomicBoolean;
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.index.SerialMergeScheduler;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.store.IOContext;
import org.apache.lucene.tests.util.LuceneTestCase;
public class TestReplicaLateOldPrimaryNotification extends LuceneTestCase {
public void testLateNotificationFromOlderPrimaryGenKeepsPreCopiedMergedFiles() throws Exception {
long oldPrimaryGen = 1, newPrimaryGen = 2, oldPrimaryLastVersion = 10;
IndexWriterConfig iwc = new IndexWriterConfig().setMergeScheduler(new SerialMergeScheduler());
IndexWriter writer = new IndexWriter(new ByteBuffersDirectory(), iwc);
try (Primary primary = new Primary(writer, newPrimaryGen, oldPrimaryLastVersion + 1);
Replica replica = new Replica(new ByteBuffersDirectory(), primary)) {
writer.addDocument(new Document());
writer.flush();
writer.addDocument(new Document());
primary.flushAndRefresh();
replica.newNRTPoint(newPrimaryGen, primary.getCopyStateVersion());
writer.forceMerge(1);
Set<String> preCopied = new TreeSet<>(replica.pendingMergeFiles);
assertFalse("the merged segment was not pre-copied", preCopied.isEmpty());
try {
replica.newNRTPoint(oldPrimaryGen, oldPrimaryLastVersion);
} catch (AssertionError | IllegalStateException _) {
}
List<String> files = List.of(replica.getDirectory().listAll());
// expected: the stale call is ignored or rejected, pre-copied files stay; actual: deleted
assertTrue(
"pre-copied " + preCopied + " but after the late call the replica has " + files,
files.containsAll(preCopied));
}
}
private static final class Primary extends PrimaryNode {
Replica replica;
Primary(IndexWriter writer, long primaryGen, long forcePrimaryVersion) throws IOException {
super(writer, 0, primaryGen, forcePrimaryVersion, null, null);
}
@Override
protected void preCopyMergedSegmentFiles(
SegmentCommitInfo info, Map<String, FileMetaData> files) throws IOException {
replica.launchPreCopyMerge(new AtomicBoolean(), primaryGen, files);
}
}
private static final class Replica extends ReplicaNode {
final Primary primary;
Replica(Directory dir, Primary primary) throws IOException {
super(1, dir, null, null);
this.primary = primary;
primary.replica = this;
start(primary.getPrimaryGen());
}
@Override
protected CopyJob newCopyJob(
String reason,
Map<String, FileMetaData> files,
Map<String, FileMetaData> prevFiles,
boolean highPriority,
CopyJob.OnceDone onceDone)
throws IOException {
CopyState copyState = files == null ? primary.getCopyState() : null;
if (copyState != null) {
files = copyState.files();
}
return new DirectoryCopyJob(reason, files, this, highPriority, onceDone, copyState);
}
@Override
protected void launch(CopyJob job) {
try {
job.runBlocking();
job.onceDone.run(job);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
@Override
protected void sendNewReplica() {}
}
/** Copies the missing files straight from the primary's directory, on the calling thread. */
private static final class DirectoryCopyJob extends CopyJob {
private final CopyState copyState;
DirectoryCopyJob(
String reason,
Map<String, FileMetaData> files,
Replica dest,
boolean highPriority,
OnceDone onceDone,
CopyState copyState)
throws IOException {
super(reason, files, dest, highPriority, onceDone);
this.copyState = copyState;
}
private Primary primary() {
return ((Replica) dest).primary;
}
@Override
public void start() {}
@Override
public void runBlocking() throws IOException {
for (Map.Entry<String, FileMetaData> ent : toCopy) {
dest.getDirectory()
.copyFrom(primary().getDirectory(), ent.getKey(), ent.getKey(), IOContext.DEFAULT);
}
}
@Override
public void finish() throws IOException {
if (copyState != null) {
primary().releaseCopyState(copyState);
}
}
@Override
protected CopyOneFile newCopyOneFile(CopyOneFile current) {
throw new UnsupportedOperationException();
}
@Override
public boolean conflicts(CopyJob other) {
return false;
}
@Override
public boolean getFailed() {
return exc != null;
}
@Override
public Set<String> getFileNamesToCopy() {
Set<String> names = new HashSet<>();
for (Map.Entry<String, FileMetaData> ent : toCopy) {
names.add(ent.getKey());
}
return names;
}
@Override
public Set<String> getFileNames() {
return files.keySet();
}
@Override
public CopyState getCopyState() {
return copyState;
}
@Override
public long getTotalBytesCopied() {
return 0;
}
@Override
public int compareTo(CopyJob other) {
return Long.compare(ord, other.ord);
}
}
}
# $REPRO holds TestReplicaLateOldPrimaryNotification.java; $LUCENE is a Lucene checkout
cp $REPRO/TestReplicaLateOldPrimaryNotification.java $LUCENE/lucene/replicator/src/test/org/apache/lucene/replicator/nrt/
cd $LUCENE
./gradlew :lucene:replicator:test --tests TestReplicaLateOldPrimaryNotification -Ptests.seed=DEADBEEF
# assertions off: add -Ptests.asserts=false (same failure)
- Expected
- The late call is ignored or rejected, and the pre-copied
[_2.cfe, _2.cfs, _2.si]stay in the replica's directory. - Observed
- The three files are gone; the replica has
[_0.cfe, _0.cfs, _0.si, _1.cfe, _1.cfs, _1.si], with assertions on and off.
More files, environment, full output444 words
The ~120 lines below the test method are the minimum PrimaryNode, ReplicaNode and CopyJob subclasses that run the protocol on one thread, copying files straight from the primary's directory. The merged segment is pre-copied through the real path (PreCopyMergedSegmentWarmer -> preCopyMergedSegmentFiles -> ReplicaNode.launchPreCopyMerge). It lives in the module's package because launchPreCopyMerge is protected and pendingMergeFiles package-private, like the module's own tests. assertFalse(preCopied.isEmpty()) guards against a vacuous pass. The late call carries (1, 10): 10 is below the replica's version, so the replica never contacts the old primary and the only work the call does is maybeNewPrimary.
The seed only drives the test framework's randomization: 10 of 10 iterations under another seed fail with the same message, with assertions on and off. The consequences (the failed cut-over and the re-copy) are shown by a longer probe, not by this test; see the full explanation above. Use this one test as the regression test too. It uses an unnamed catch variable (_, Java 22+): in a 10.x checkout, whose source level is Java 21, rename it to e.
| lucene | main 645d4fe14a20, 10.5.2 and 10.6.0: reproduced, assertions on and off; 6.0.0 to 6.2.0: reproduced (same test, 6.x API); 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 message)
TestReplicaLateOldPrimaryNotification > testLateNotificationFromOlderPrimaryGenKeepsPreCopiedMergedFiles FAILED
java.lang.AssertionError: pre-copied [_2.cfe, _2.cfs, _2.si] but after the late call the replica has [_0.cfe, _0.cfs, _0.si, _1.cfe, _1.cfs, _1.si]
at org.apache.lucene.replicator.nrt.TestReplicaLateOldPrimaryNotification.testLateNotificationFromOlderPrimaryGenKeepsPreCopiedMergedFiles(TestReplicaLateOldPrimaryNotification.java:59)
# repro harness, assertions on: the same message, plus (suppressed, from ReplicaNode.close at ReplicaNode.java:694)
# java.nio.file.NoSuchFileException: _2.si
# 10.5.2 (no compound file): pre-copied [_2.fdm, _2.fdt, _2.fdx, _2.fnm, _2.si] but after the late call the replica has [_0.cfe, _0.cfs, _0.si, _1.cfe, _1.cfs, _1.si]
# control (the late call carries generation 2): passes, assertions on and off
# with fix A: passes, assertions on and off
# consequence probe (main 645d4fe14a20, assertions off; on: the same plus AssertionError newPrimaryGen=1 vs lastPrimaryGen=2)
late=DURING_COPY: B followed P2 at version 11, pre-copied merged files [_3.cfe, _3.cfs, _3.si]; late P1 notification: ...
lastPrimaryGen after it=1 pre-copied files then missing=[_3.cfe, _3.cfs, _3.si]; P2's merged point (version 13): files to copy [] ...;
after that copy B serves version 11 ...; copy failure=java.lang.RuntimeException: org.apache.lucene.index.CorruptSegmentInfoException:
segment info file: _3.si cannot be read - it may be missing or corrupt ...; commit right after: failure=null;
P2's next point (version 15 ...): B then serves version 15 ..., lastPrimaryGen=2
late=BEFORE_MERGED_POINT: ... files to copy [_3.si, _3.cfs, _3.cfe] (pre-copied files copied again: [_3.cfe, _3.cfs, _3.si], bytes copied 2167) ...
late=NONE (control): ... pre-copied files then missing=[] ... bytes copied 0 ...- The generation exists "so replicas can detect when the primary they were talking to is changed" (
Node.java:50-54); an older one is never a new primary. - The same method skips older versions because reordering "can happen"; a late older generation is the same reordering.
- The assert shows an older generation was not expected, but it runs after the deletion.
The full argument345 words
- The generation exists for this comparison.
Node.java:50-54: the primary gen "increments every time we promote a new primary, so replicas can detect when the primary they were talking to is changed";PrimaryNode.java:136-139: "incremented each time a new primary is started for this index";ReplicaNode.java:85:lastPrimaryGenis the "Primary gen last time we successfully replicated". - The discard rationale covers only a new primary. The comment above the call (
ReplicaNode.java:536-544) explains discarding pre-copied segments for "the same primary (just e.g. rebooted), or a promoted replica that had a newer NRT point". Nothing about an older generation. - Reordering is already expected. An older version "can happen, if two syncs happen close together, and due to thread scheduling, the incoming older version runs after the newer version" (
:564-566), and is skipped. - The pre-copied files are the current primary's.
:82-83: "Merged segment files that we pre-copied, but have not yet made visible in a new NRT point". - No caller precondition is documented in the
newNRTPointjavadoc (:525-529),maybeNewPrimary(:705), the package docs,CHANGES.txtorMIGRATE.md. The module author's 2017 design post: "once you pick the new primary, Lucene will take care of having all replicas cutover to it, removing stale partially copied files from the old primary"; it leaves leader election, failure detection, load balancing, cluster state and "the low level mechanics of how to copy bytes from primary to replica" to the application, and says nothing about late or reordered messages. - History. Before
9afa560054aa(2016-02-08)maybeNewPrimaryran after the copy job was created, with the generation of the primary actually serving the copy, and deleted nothing. That commit moved the call to the top and added the deletion ahead of the older assert-only check, with a comment about new primaries only. No module test sends an older generation (the three explicitnewNRTPointcalls inTestNRTReplicationcarry the generation-0 primary's own generation). - The control. The same test with generation 2 in the late call passes, with assertions on and off: only the older-generation path deletes the files.
The devil's-advocate check247 words
Strongest case for "intended" (an independent reviewer from another model family, OpenAI GPT-5.6, refute lens, reading only): the newNRTPoint javadoc speaks of a new infos "on the primary", and a deposed generation-1 sender is no longer the primary; the assert states that callers present generations in increasing order; the module is @lucene.experimental; the design post leaves tracking which node is the primary to the application, so filtering stale senders is its job.
Why it fails:
- No javadoc states the precondition; an assert that fires after the damage is no contract, and with assertions off it is not checked at all.
- The trigger is a clean fail-over: the old primary is already closed, so there is no live sender to fence, only a message in flight. Nothing documents that the caller must filter it: the application's
ReplicaNodesubclass could compare against the protectedlastPrimaryGen(ReplicaNode.java:85-86), but no javadoc asks it to, the module's own test transport does not (SimpleReplicaNode.java:205-218passes the received generation on unchecked), and the replica already holds that value and drops older versions the same way. @lucene.experimentalis about API stability, not about which inputs may damage the replica's state.- The reviewer agreed with the root cause, could not run Java, and rated its argument moderate; the tie-breaker, which ran the test, the control and the fix, confirmed the bug. The split-brain reading applies only to the study's dropped first test (a deposed primary that keeps publishing), which is not claimed.
Recommended A. Ignore an older generation in newNRTPoint, reject it in maybeNewPrimary before deleting
In newNRTPoint, ignore and log a notification whose generation is older than lastPrimaryGen, before maybeNewPrimary, like an older version; in maybeNewPrimary, throw IllegalStateException for an older generation before deleting anything, which also covers launchPreCopyMerge. The repro passes with assertions on and off; with assertions on, the module's nightly TestNRTReplication (13 tests) and one TestStressNRTReplication run pass too.
All options and trade-offs3 options ยท 638 words
One fix was run through gradle (A): the study's patch without its optional third hunk in start(). A check placed where the assert is now does not work, because the files are already deleted by then.
A. Ignore an older generation in newNRTPoint, reject it in maybeNewPrimary before deleting (validated by the checks) (recommended)
A notification from an older generation is logged and ignored before anything is touched, exactly like an older version (return null is what the version skips already return). maybeNewPrimary refuses an older generation before its deletion loop, which also covers a late launchPreCopyMerge from the old primary. The existing assert stays and is now always true.
--- a/lucene/replicator/src/java/org/apache/lucene/replicator/nrt/ReplicaNode.java
+++ b/lucene/replicator/src/java/org/apache/lucene/replicator/nrt/ReplicaNode.java
@@ -533,6 +533,17 @@ public abstract class ReplicaNode extends Node {
throw new AlreadyClosedException("this replica is closed: state=" + state);
}
+ if (newPrimaryGen < lastPrimaryGen) {
+ // A notification from a primary that has since been replaced: ignore it
+ message(
+ "top: ignoring NRT point from stale primaryGen="
+ + newPrimaryGen
+ + " (current primaryGen="
+ + lastPrimaryGen
+ + ")");
+ return null;
+ }
+
// Cutover (possibly) to new primary first, so we discard any pre-copied merged segments up
// front, before checking for which files need
// copying. While it's possible the pre-copied merged segments could still be useful to us, in
@@ -704,6 +715,10 @@ public abstract class ReplicaNode extends Node {
/** Called when the primary changed */
protected synchronized void maybeNewPrimary(long newPrimaryGen) throws IOException {
+ if (newPrimaryGen < lastPrimaryGen) {
+ throw new IllegalStateException(
+ "primaryGen=" + newPrimaryGen + " is older than current primaryGen=" + lastPrimaryGen);
+ }
if (newPrimaryGen != lastPrimaryGen) {
message(
"top: now change lastPrimaryGen from "Trade-offs
- A stale
launchPreCopyMergecaller now gets an exception instead of a started job. Returning silently there instead would leave the reference transport's handler waiting on itsfinishedflag until it notices the dead connection (SimpleReplicaNode.java:339-362), so the exception is the cleaner signal (read, not run). - The ignore in
newNRTPointis silent apart from the log line; a maintainer may prefer to throw there too and let the transport decide. The repro accepts either. - A check at the assert's current position does not fix it: the study's tie-breaker ran such a patch and its probe still failed 2 of 3 with assertions off.
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 ran; the 14 nightly tests andTestSimpleServer, which runs only as a child process, were skipped). - With A and nightly tests enabled:
TestNRTReplication13 tests, 0 failures (51 s);TestStressNRTReplication1 test, 0 failures (one run of 326 s, one seed only). - Earlier, with the study's three-hunk patch shadowed into the stock jars: the repro passes with assertions on and off (also 10 of 10 iterations), the consequence probe passes 3 of 3, and the two hunks backported to 6.0.0 pass there.
Throw for newPrimaryGen < lastPrimaryGen at the top of maybeNewPrimary and leave newNRTPoint alone.
Trade-offs
Smaller, and it protects the pre-copied files on both paths, but a late notification then throws to the transport instead of being skipped like an older version.
ReplicaNode.java:191, assert myPrimaryGen < curPrimaryGen, guards a replica restarting from a commit tagged with a newer generation than the primary it is given; the study's three-hunk patch throws IllegalStateException there.
Trade-offs
That value comes from the caller of start(), not from a late message; a separate hardening, not needed for this bug.
Downstream, the open-source nrtsearch server forwards the primary generation it receives to ReplicaNode unchecked, as Lucene's reference test transport does (code read, not run).
TLA+ model checking of the NRT replication protocol: TLC showed an older generation getting past the assert-only check with assertions off, in a split-brain trace that is not claimed. JUnit probes then narrowed it to a late message after a clean fail-over and showed the deletion of the pre-copied merge with assertions on and off.
The method in full164 words
- Method (semi-formal). The hunt plan for the replicator listed primary-generation fencing ("no copy accepted from a deposed primary") as a property to model. The finder wrote a TLA+ model of the NRT replication protocol with
maybeNewPrimaryin both assertion modes. TLC found the fencing property violated with assertions off (2076 distinct states); with assertions on the "no assert fires" property is violated instead (23 states). That trace needs a deposed primary that keeps publishing, and the property holds under the module's controller discipline (220 states), so the finder's first JUnit (a split brain) was dropped from the claim. - Narrowing (non-formal). The devil's-advocate verifier ran a probe with the old primary closed before the promotion: with assertions off the generation went back to 1 and the next commit recorded it. The tie-breaker's probe added the merge pre-copy and showed the deletion, the failed cut-over and the re-copy, with assertions on and off.
- Reduction. The one-test repro used here was then written from the narrowed scenario.
No prior report in GitHub issues and PRs, the file lists of all 410 open PRs, four mailing lists, the JIRA archive, downstream repositories or the web (2026-10-26, repeated 2026-10-31). Closest: #13044 / #13045, about a higher-generation primary, the opposite direction.
Searches3 searches
Nearest items, none this bug: #13044 / #13045 (open) call primaryGen the main primary/replica check, but concern a higher-generation primary with an unrelated index, and the PR only adds a commitData hook. The java-user thread of 2025-02-21: a replica moves to a higher generation and then skips the new primary's points by the intended version check. #14362 / #14417 and nrtsearch #425 / #961: segment-name reuse after a promotion. #6501 (LUCENE-5438): the origin, no discussion of older-generation notifications. Open PRs #16838, #13142, #16847 and #16846 change other parts of the module. CI mails that name these identifiers are other failures. Limits: GitHub search matches tokens and caps results per query; private reports cannot be searched.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene), downstream repositories | 2026-10-26 and 2026-10-31 | the scripted prior-report search with three query specs (72 issue/PR, 9 commit, 6 downstream queries) and 95 GitHub GraphQL searches over apache/lucene, nrtsearch, OpenSearch, Elasticsearch, Solr and all of GitHub | no report; 0 high-overlap hits; every query naming maybeNewPrimary, lastPrimaryGen, newPrimaryGen, launchPreCopyMerge or the assert text found nothing on point |
| Open PRs and git history | 2026-10-31 | the changed files of all 410 open PRs; gh pr list / issue list for ReplicaNode, primaryGen, newNRTPoint; upstream commits to ReplicaNode.java | #16838, #13142, #16847 and #16846 touch other code; newest commit to the file 27d1272984f9 (2025-07-14) |
| Mailing lists (dev@, java-user@, issues@, commits@, builds@), JIRA archive, web | 2026-10-31 | 60 list queries through the tool and 44 direct ones; 12 JIRA queries including the replicator component; 17 web queries | no report; CI mails naming the identifiers are other failures |
Introduced by 9afa560054aa (2016-02-08, LUCENE-5438 branch), which moved maybeNewPrimary before the version checks and added the deletion before the older assert-only check; first released in 6.0.0. Every release run from 6.0.0 to 10.6.0, and main, fails; unchanged on upstream main 2f424fd428da.
Versions tested6 rows
Introduced by 9afa560054aa ("fix some nocommits; fix one stress test failure", mikemccand, 2016-02-08) on the LUCENE-5438 feature branch: it changed newNRTPoint(long version) to newNRTPoint(long newPrimaryGen, long version), moved the maybeNewPrimary call to the top, ahead of the version skips, and added the deleteIfNoRef loop ahead of the generation check. The assert-only check is older, from 1ae7291429ba (2016-01-24, the first commit of ReplicaNode.java). Both reached the main development branch in the merge 12b8721a44db (2016-02-11). LUCENE-5438 "add near-real-time replication" is now #6501, with no PR and no CHANGES entry. All 84 release tags in the apache/lucene repository from 6.0.0 to 10.6.0 have the same maybeNewPrimary body and call order (8.8.2 to 8.11.x were tagged in the old lucene-solr repository and are not among them; 8.11.4 was run). Not fixed upstream.
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | the one test fails (gradle and harness, assertions on and off); passes with fix A; the consequence probe fails 2 of 3 |
| 10.6.0, 10.5.2 | affected | the one test fails (assertions on and off) |
| 6.6.6, 7.7.3, 8.11.4, 9.12.3 | affected | the consequence probe fails 2 of 3 (assertions on and off); 9.0.0 and 10.0.0 with assertions on |
| 6.2.0 | affected | the one test fails (assertions on and off); first release with no workaround needed |
| 6.0.0 | first release | fails with assertions on and off (with a test-side guard for a separate constructor defect of 6.0.0 and 6.1.0); passes with the fix |
| 5.5.0, 5.5.5 | no API | no NRT replication package; the test does not compile |
The study's finder, an independent reproducer, a devil's advocate, cross-model reviews (OpenAI GPT-5.6), an adjudication (disputed) and a tie-breaker (confirmed, narrowed to the clean fail-over), then a credibility audit and a re-verification with its own review and tie-breaker on 2026-10-31 (real).
Checks and verdicts328 words
The split-brain headline of the finder was refuted by the adjudication and dropped; the narrowed bug was confirmed by both tie-breakers, which ran the test, a control and the fix.
| Check | Verdict | What it found |
|---|---|---|
| finder (TLA+, JUnit) | split-brain headline, dropped | TLC trace and a JUnit with a deposed primary that keeps publishing; fails with assertions off on main, 10.5.2 and 9.12.3. |
| independent reproducer | real, medium | Own test of the split-brain variant fails with assertions off; an assert-to-check patch fixes that test. |
| cross-model review, intended lens | real, medium | Reading only; root cause agrees; intended argument moderate. |
| cross-model review, refute lens | not real, medium | Reading only; argued that fencing a live deposed primary is the application's job (the reason the split-brain headline was dropped); root cause agrees. |
| intended-behaviour devil's advocate | real, medium | Probe with the old primary closed before the promotion: with assertions off the generation goes back to 1 and the next commit records it. |
| adjudication | disputed, medium | Split-brain headline refuted; the narrow residue (assert-only check on peer input, generation regression, deletion before the assert) not refuted. |
| tie-breaker (study) | confirmed, medium, narrowed | New probe with the merge pre-copy: fails 2 of 3 with assertions on and off on main and 10.5.2 (control passes), 20 of 30 over random seeds; the fix passes 3 of 3. |
| credibility audit | keep | Re-ran the probe at another seed (fails with assertions on and off; passes with the fix); noted no corruption and no wrong results (the replica recovers at the next point). |
| re-verification and its review | real | 2026-10-31: the one test fails on main and 10.5.2 (assertions on and off), passes with the fix; a new refute-lens review could not run anything and voted not real on procedure only. |
| tie-breaker (2026-10-31) | real, medium | Ran the test at a fresh seed, a same-generation control (passes), 10.5.2, the fix and the consequence probe; checked the shipped bytecode order. |
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 466 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,655 words
- Registry title
- An NRT replica acts on a late notification from an older primary generation: ReplicaNode.maybeNewPrimary deletes the current primary's pre-copied merged segment before its only check, an assert, so the replica's cut-over to the merged point fails or the segment is copied again (with or without assertions), and with assertions off lastPrimaryGen also moves back
- Area
- replicator / NRT segment replication (ReplicaNode.newNRTPoint, maybeNewPrimary, launchPreCopyMerge: primaryGen fencing)
- Priority
- Low, rank 943: In experimental NRT replication, a late notification from the old primary gives a loud, transient failed cut-over or a redundant copy.
- Impact class
- crash
- Impact note
- Applications that use lucene-replicator's NRT segment replication (PrimaryNode/ReplicaNode/CopyJob subclasses, opt-in and @lucene.experimental), in every release since 6.0.0 and at any index size, are hit when, during a clean fail-over (old primary closed before the new one is promoted, no split brain), a notification from the old primary reaches a replica after it has started following the new primary: with -ea and with -da the replica deletes the new primary's pre-copied merged segment, so its cut-over to that merged point fails with a misleading RuntimeException wrapping CorruptSegmentInfoException 'segment info file: _3.si cannot be read - it may be missing or corrupt' (NoSuchFileException for _3.si on the executed releases) and it keeps serving its previous point until the primary publishes again, or, if the late call lands before the merged point, the whole merged segment is copied again (defeating the pre-copy); with -da lastPrimaryGen and the next commit's __primaryGen also go back to the dead primary's generation until the next point; no wrong search results, no corrupt commit and no hang were observed.
- Summary
- You run Lucene's NRT segment replication and fail over cleanly: the old primary (generation 1) is closed, then replica A is promoted to primary (generation 2), and replica B follows A and pre-copies A's merged segment. A last notification from the old primary, delayed in transit, then reaches B; you expect B to ignore it, as it already ignores out-of-order older NRT versions, because the primary generation only ever grows. Instead ReplicaNode.maybeNewPrimary treats the older generation as a primary change and deletes A's pre-copied merged files before its only check (an assert), so B's cut-over to A's merged point fails with '_3.si cannot be read - it may be missing or corrupt' or B copies the merged segment again, and with assertions off B also sets lastPrimaryGen back to 1.
- Introduced
- 9afa560054aad72b8cc623b130745bc8778d1d5c (mikemccand, 2016-02-08, 'fix some nocommits; fix one stress test failure', on the LUCENE-5438 feature branch jira/lucene-5438-nrt-replication): it changed newNRTPoint(long version) to newNRTPoint(long newPrimaryGen, long version), moved the maybeNewPrimary call to the top of the method, before the same-version and older-version skips, and made maybeNewPrimary delete every pendingMergeFiles entry with deleteIfNoRef before its only generation check; that assert-only check ('assert newPrimaryGen > lastPrimaryGen') is older, from 1ae7291429bad742715344f86cfa5200229b3698 ('current patch', 2016-01-24, the first commit of ReplicaNode.java); both reached master in the merge 12b8721a44dbd1fbc7878fa37186c16cf6045401 (2016-02-11), which the author named as the LUCENE-5438 commit ('add near-real-time replication', now #6501); no pull request, no CHANGES entry [read: git log -L, git blame -w, git show, code-shape scan of every branch commit that touched ReplicaNode.java] (2016-02-08 (9afa560054aa, feature branch); 2016-02-11 (merge 12b8721a44db into master); the assert-only generation check dates from 2016-01-24 (1ae7291429ba))
- First affected version
- 6.0.0 [exec] (released 2016-04-08, the first release with the org.apache.lucene.replicator.nrt package): the probe fails 2 of 3 under -ea and -da with the control passing, and passes 3 of 3 with the candidate fix's two ReplicaNode hunks shadowed; the 6.0.0 run needs a test-side close() guard because of a separate 6.0.0-6.1.0 constructor defect (no 'success = true'), which the module's SimpleReplicaNode test node avoids and which is not part of this bug; 6.2.0 is the first release that reproduces with no workaround [exec]; 5.5.0 and 5.5.5 have no nrt package (the repro does not compile) [exec]
- Affected versions
- every release from 6.0.0 through 10.6.0, and main ea57360912ed. [exec] 10.6.0 and main 645d4fe14a20 with -ea and -da: the one-test repro fails, the pre-copied merged files deleted (version-history runs, 2026-10-31). [exec] -ea and -da: 6.0.0 (with the close() guard), 6.2.0, 6.6.6, 7.7.3, 8.11.4, 9.12.3, 10.5.2 and main; -ea only: 9.0.0 and 10.0.0; the same 2 of 3 failure and the same per-scenario effects in every run (follow-up history sweep, plus the tie-breaker's runs on main, 10.5.2 and 9.12.3). [read] the same maybeNewPrimary body, the call before the version skips in newNRTPoint, the call in launchPreCopyMerge and a deleteIfNoRef that deletes files with no ref entry in every release tag 6.0.0-8.8.1 and 9.0.0-10.5.2, branch_8_8, branch_10x, branch_10_6 and main (8.8.2-8.11.3 are not in the local clones; bracketed by 8.8.1 [read] and 8.11.4 [exec]). Not fixed upstream (apache/lucene main and branch_10x re-read 2026-10-26; no open PR changes this logic). Not affected: 5.x (API absent) [exec]. All runs on aarch64 with JDK 25.0.3.
- Status
- documented (brief, checks and a per-finding doc exist)
- Verification
- Confirmed by AI-run checks: an independent repro, a cross-model review that disputed it, an adjudication that rated it disputed and a tie-breaker that confirmed it (medium confidence), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
- Found by
- semi-formal; also reached by TLA+/TLC
- Upstream and prior reports
- none found (2026-10-26, read-only; the prior-report record: three scripted prior-report searches (core terms, error messages and symptoms, concept terms) with GitHub issue/PR, commit, downstream, mailing-list (dev@, java-user@, issues@, commits@), JIRA-archive and web queries (65, 59 and 101 distinct hits, 0 high overlap in each); the finder's 35-query run, 23 hits, 0 high overlap; 73 GitHub GraphQL searches over apache/lucene, apache/lucene-solr, Yelp/nrtsearch, OpenSearch, Elasticsearch and Solr (every query naming newPrimaryGen, lastPrimaryGen, maybeNewPrimary, newNRTPoint, launchPreCopyMerge or the assertion text returned 0 or only unrelated items); every module:replicator issue and PR (32) listed and the plausible ones read in full; the changed-file lists of all 412 open apache/lucene PRs (6 touch lucene/replicator, none changes this logic); direct lists.apache.org searches (54 queries) and JIRA-archive searches; local git history; the module author's 2017 design post); not fixed upstream (apache/lucene main, last change to ReplicaNode.java 27d1272984f9 of 2025-07-14, and branch_10x still delete pendingMergeFiles and then only assert newPrimaryGen > lastPrimaryGen). Related context, not reports of this bug: #6501 / LUCENE-5438 (origin; no discussion of older-generation notifications), #13044 / #13045 (open: primaryGen as the only primary/replica check, opposite direction, adds a commitData hook), the java-user thread of 2025-02-21 on resetting the NRT version (forward generation change, then the intended version check), #14362 / #14417 (segment-name reuse after promotion), #16838 and #13142 (open PRs touching ReplicaNode, other logic)
- 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 10 iterations each); a control with the current generation in the late call (passes); 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 study's consequence probe (assertions on and off, two seeds). The 10.5.2 and 10.6.0 release jars with the one-test repro (assertions on and off); the same test in the 6.x API on 6.0.0, 6.1.0 and 6.2.0; the consequence probe on 6.6.6, 7.7.3, 8.11.4, 9.0.0, 9.12.3 and 10.0.0. Upstream main2f424fd428da(2026-10-30) hasReplicaNode.javabyte-identical to645d4fe14a20. OpenJDK 25.0.3, Linux aarch64 (the only platform run).
- Finding record
lucene/replicator/src/java/org/apache/lucene/replicator/nrt/ReplicaNode.java:61 (@lucene.experimental), :82-83 (pendingMergeFiles javadoc 'Merged segment files that we pre-copied, but have not yet made visible in a new NRT point'), :85-86 (lastPrimaryGen javadoc 'Primary gen last time we successfully replicated'), :188-191 (start(): 'assert myPrimaryGen < curPrimaryGen', assert-only), :368 (commit writes PRIMARY_GEN_KEY from lastPrimaryGen), :433-434 and :440 (finishNRTCopy: SegmentInfos.readCommit before setCurrentInfos, so a failed cut-over leaves the old point in place), :525-529 (newNRTPoint javadoc), :536-545 (the 'Cutover (possibly) to new primary first ... defensively discard' comment and maybeNewPrimary(newPrimaryGen) before any check), :547-548 (mgr assert with 'Caller should not "publish" us'), :558-574 (same-version and older-version skips; 'This can happen ... due to thread scheduling'), :607 (assert newPrimaryGen == job.getCopyState().primaryGen()), :705-728 (maybeNewPrimary: deleteIfNoRef on every pendingMergeFiles entry at :716-719, then 'assert newPrimaryGen > lastPrimaryGen' at :721-722, lastPrimaryGen = newPrimaryGen at :723, pendingMergeFiles.clear() at :724), :730-736 (launchPreCopyMerge calls maybeNewPrimary(newPrimaryGen))lucene/replicator/src/java/org/apache/lucene/replicator/nrt/CopyJob.java:85 (toCopy = dest.getFilesToCopy(files): identical local files, including pre-copied merged files, are not copied again)lucene/replicator/src/java/org/apache/lucene/replicator/nrt/Node.java:42 (@lucene.experimental), :50-54 (PRIMARY_GEN_KEY javadoc: 'increments every time we promote a new primary, so replicas can detect when the primary they were talking to is changed')lucene/replicator/src/java/org/apache/lucene/replicator/nrt/PrimaryNode.java:136-139 (getPrimaryGen javadoc: 'incremented each time a new primary is started for this index')lucene/replicator/src/java/org/apache/lucene/replicator/nrt/PreCopyMergedSegmentWarmer.java:33-36 (purpose of the pre-copy: replica reopen time 'only in proportion to flushed segment sizes, not merged segments')lucene/replicator/src/test/org/apache/lucene/replicator/nrt/SimpleReplicaNode.java:205-218 (reference transport: CMD_NEW_NRT_POINT reads the generation and sender port from the message and calls newNRTPoint unchecked), :331-339 (CMD_PRE_COPY_MERGE, same for launchPreCopyMerge); lucene/replicator/src/test/org/apache/lucene/replicator/nrt/SimplePrimaryNode.java:360-362 (each notification on a new connection); lucene/replicator/src/test/org/apache/lucene/replicator/nrt/TestSimpleServer.java:68, :348-354 (one handler thread per connection); lucene/replicator/src/test/org/apache/lucene/replicator/nrt/TestStressNRTReplication.java:820-825 (nodeClosed: primaryGen++ only after the old primary's process finished)lucene/CHANGES.txt:332 (GITHUB#15325: tests run with assertions disabled ~25% of the time)
- The finding in 30 seconds
a Lucene main checkout lucene/replicator/src/java/org/apache/lucene/replicator/nrt/ReplicaNode.java:82-86, 189-191, 368, 433-450, 525-574, 692-694, 705-736 (read)a Lucene main checkout lucene/replicator/src/java/org/apache/lucene/replicator/nrt/CopyJob.java:85; ReplicaNode.java:834-873 (getFilesToCopy) (read)
- Who is affected
a Lucene main checkout lucene/replicator/src/test/org/apache/lucene/replicator/nrt/SimplePrimaryNode.java:358-366, SimpleReplicaNode.java:205-218, 331-339, TestSimpleServer.java:344-356, Jobs.java:105-111 (read)a Lucene main checkout lucene/replicator/src/java/org/apache/lucene/replicator/nrt/PreCopyMergedSegmentWarmer.java:33-36; ReplicaNode.java:61, Node.java:42, PrimaryNode.java:50, CopyJob.java:35 (read)
- Why it looks unintended
a Lucene main checkout lucene/replicator/src/java/org/apache/lucene/replicator/nrt/Node.java:50-54; PrimaryNode.java:136-139; ReplicaNode.java:82-86, 525-574, 705 (read)a Lucene main checkout lucene/replicator/src/test/org/apache/lucene/replicator/nrt/TestNRTReplication.java:56-84, 388, 496, 792 (explicit newNRTPoint calls carry the generation-0 primary's own generation) (read)
- Suggested fix
a Lucene main checkout lucene/replicator/src/test/org/apache/lucene/replicator/nrt/SimpleReplicaNode.java:331-362 (pre-copy handler keep-alive loop) (read)
- TestReplicaLateOldPrimaryNotification.java (one test):
repro/LFV-270/src/org/apache/lucene/replicator/nrt/TestReplicaLateOldPrimaryNotification.java(200 lines)
assets/LFV-270/bug.png(466 KB)
