launchPreCopyMerge hands the copy job an anonymous CopyJob.OnceDone. Its run() wraps the "primary changed?" check, the renames and pendingMergeFiles.add in synchronized (this), and in an anonymous class this is the callback, which nothing else locks. newNRTPoint and maybeNewPrimary lock the ReplicaNode, so a switch can run between the check and the add.
- In
lucene-replicator's NRT segment replication (opt-in, experimental) onePrimaryNodeindexes andReplicaNodes copy its files. Each newly promoted primary gets a higher primary generation. - Before a merged segment becomes visible, the primary pre-copies it to the replicas. On a replica a copy thread fetches the bytes, then runs a callback that renames them into place and lists them as pending until an NRT point uses them.
- When a replica sees a new primary generation,
newNRTPointfirst deletes every pending pre-copied file and clears the list: files from the old primary are not reused for the new one.
- Primary P1 (generation 1) merges, and replica B pre-copies the merged segment
_2(_2.cfe,_2.cfs,_2.si). Once the bytes are in, B's callback checks "did the primary change?" (no, still 1) and starts renaming the files into place. - Right then replica A is promoted to P2 (generation 2), and B receives P2's first point,
newNRTPoint(2, v), on another thread. - You expect one of two safe orders: the install finishes and the switch then discards the files ("we defensively discard"), or the switch goes first and the callback cancels ("primary changed during merge copy").
- Instead the switch does not wait, because the callback's lock is the callback object. B moves to generation 2 with an empty pending list, then the callback adds P1's files: B follows P2 with
pendingMergeFiles=[_2.cfe, _2.cfs, _2.si]and the files on disk, unreferenced. - With assertions on, P2's later pre-copy of a same-named segment fails:
file "_2.si" is already being warmed!. In a timing probe the same race also madenewNRTPointthrowConcurrentModificationException; with another pre-copy pending, a re-delivered switch andclose()then failed withNoSuchFileException.
Users of the opt-in, experimental NRT segment replication with merge pre-copy, in every release run or read from 6.0.0 to 10.6.0, with assertions on or off. It takes a fail-over that reaches a replica during the short install after an old-primary pre-copy's last byte. Rare; no wrong search results or corruption were shown.
Mechanism, terms and numbers820 words
In lucene-replicator's NRT segment replication, ReplicaNode.launchPreCopyMerge gives each merge pre-copy job an anonymous CopyJob.OnceDone callback. The callback checks whether the primary changed, renames the copied files into place and lists them in pendingMergeFiles, all inside synchronized (this); in an anonymous class this is the callback, which nothing else locks. newNRTPoint and maybeNewPrimary, which switch to a new primary and discard the pending files, lock the ReplicaNode. So a fail-over that reaches a replica during the install runs in the middle of it: the replica follows the new primary with the old primary's merged files pending and on disk, a state no serial order of the two locked blocks can produce, against the discard that newNRTPoint's comment describes. With assertions on, the new primary's later pre-copy of a same-named merged segment then fails; in a timing probe the race also made newNRTPoint throw ConcurrentModificationException. No wrong search results or corruption were shown. Present since 6.0.0 (LUCENE-5438); synchronized (ReplicaNode.this) fixes the repro and passes the module's nightly suites.
// ReplicaNode.launchPreCopyMerge(), abridged
protected synchronized CopyJob launchPreCopyMerge(...) { // :730 node lock, only while launching
maybeNewPrimary(newPrimaryGen); // :736
final long primaryGenStart = lastPrimaryGen;
job = newCopyJob(..., new CopyJob.OnceDone() { // :754 anonymous class
public void run(CopyJob job) throws IOException { // later, on the copy thread
mergeCopyJobs.remove(job);
synchronized (this) { // :761 'this' = the OnceDone
if (lastPrimaryGen != primaryGenStart) { // :763 1. primary changed?
job.cancel("primary changed during merge copy", null);
} else {
job.finish(); // :796 2. renames into place
for (String fileName : fileNames) {
pendingMergeFiles.add(fileName); // :802 3. register as pending
...
launch(job); // :820 "Runs this job async'd"
// ReplicaNode.newNRTPoint() -> maybeNewPrimary(), both synchronized on the ReplicaNode
maybeNewPrimary(newPrimaryGen); // :545 "defensively discard" (:536-544)
if (newPrimaryGen != lastPrimaryGen) {
for (String fileName : pendingMergeFiles) { // :717 deletes every pending file
deleter.deleteIfNoRef(fileName);
}
lastPrimaryGen = newPrimaryGen; // :723
pendingMergeFiles.clear(); // :724The normal NRT-point callback does it right: finishNRTCopy runs its check, job.finish() and bookkeeping inside synchronized (this) in a ReplicaNode method (:412-430), so under the node lock. A lambda would also have bound this to the ReplicaNode. Nothing under lucene/ ever locks a OnceDone, and the shipped bytecode of the callback locks itself (aload_0; dup; astore_2; monitorenter).
- Pre-copy request. An empty replica starts at generation 1 and gets
launchPreCopyMerge(generation 1)for one file,_0.si. The callback recordsprimaryGenStart = 1. - Check. The test thread runs the callback as the copy thread would, without the node lock. It takes the callback's own lock and passes the check: generation is still 1.
- Switch. Inside the install (
job.finish()), the test's job starts a thread that calls the publicnewNRTPoint(2, current version)and waits for it (up to 5 s). That thread gets the node lock at once:maybeNewPrimary(2)deletes and clears the still-empty pending set and sets generation 2. - Install. The callback creates
_0.siand adds it topendingMergeFiles. The replica now follows generation 2 with[_0.si]from generation 1 pending. - With the fix the switch thread blocks on the node lock, the 5 s wait times out, the install finishes, and then the switch deletes
_0.si:pendingMergeFilesis empty.
- Real copy, two replicas (the study's test: a real
forceMerge, Lucene'sCopyOneFile, temp-file renames; replica A promoted to P2): replica B follows generation 2 withpendingMergeFiles=[_2.cfe, _2.cfs, _2.si]and the same files on disk, unreferenced. They stay until the next primary change,close(), a restart (:692-696,:716-724,:173), or an NRT point from the new primary that reuses or retires the same file names (:450,:464-472). - Assertions on: P2's later pre-copy of a same-named merged segment
_2is rejected by the assert at:742,file "_2.si" is already being warmed!. With assertions off that pre-copy goes through. - Unlocked iteration.
pendingMergeFilesis aCollections.synchronizedSet(:83), whose iterator must be used under the set's own lock;maybeNewPrimary(:717) andclose()(:693) iterate it holding only the node lock, and the callback'saddis the one mutation without the node lock. In the tie-breaker's timing probe (no hooks; a copy thread runs the callback while another thread callsnewNRTPointafter a random 0-200 microsecond spin; 2000 rounds), with one earlier pre-copy pending at each round start,newNRTPointthrewConcurrentModificationExceptionin 785 rounds (assertions off). In 781 of them the aborted loop had already deleted that file, which stayed listed, and the generation stayed old: a re-delivered switch then threwNoSuchFileException, and so didclose(), which kept the directory's write lock. With the fix: no stale state and no exception in 8000 rounds. The spin makes the two events nearly simultaneous, so these counts are conditional, not production frequencies.
Exposure and likelihood382 words
- Who is hit. Applications that use
lucene-replicator's NRT segment replication with merge pre-copy (PrimaryNode/ReplicaNode/CopyJobsubclasses, marked@lucene.experimental). Every release run from 6.0.0 to 10.6.0 fails, and the callback is unchanged in every release tag read; assertions on and off both fail where both were run. The repros use small indexes. - What it takes. A switch to a new primary (
newNRTPoint, or a pre-copy request from the new primary, both throughmaybeNewPrimary) reaching a replica while its callback for an old-primary merge pre-copy is between the check and the end of itsaddloop. Lucene's reference transport allows it: one handler thread per connection (TestSimpleServer.java:344-356, test code) callsnewNRTPointandlaunchPreCopyMerge(SimpleReplicaNode.java:218,:339), while the job runner thread calls the callback without the node lock (Jobs.java:107). Each step is an ordinary fail-over event, but the window, the renames and bookkeeping after the last byte, is short (not measured). Likelihood: rare; nothing has been reported since 2016. - What goes wrong (executed). (1) The old primary's merged files stay pending and on disk under the new primary, against the discard that
newNRTPoint's comment describes, until the next primary change, close, restart, or an NRT point from the new primary that reuses or retires those file names; for a large merge that is a whole merged segment's files on disk, unreferenced. (2) With assertions on, the new primary's later pre-copy of a same-named merged segment fails. (3) In the timing probe,newNRTPointthrewConcurrentModificationException, and when another pre-copy was pending the replica then failed a re-delivered switch andclose()withNoSuchFileExceptionand kept its write lock (seen only in that probe variant, not in the minimal or real-copy tests). - What does not go wrong. No reader or commit references the stale files, so no searcher sees them (by reading; no executed test checked searchability after the race); a different file with the same name is never mistaken for the local one, because a local file counts as identical only if its header (with the segment's random id) and footer match (
ReplicaNode.java:849-873); otherwise it is re-copied. No wrong documents, corrupt commit or hang were shown. - Impact class: minor, priority low (opt-in experimental module, rare trigger).
One JUnit test in the replicator module's test package: an empty replica, one pre-copy request for one file from generation 1, and newNRTPoint(2, ...) started inside the copy job's install. One extra thread (the race is the bug), in memory, no sockets or reflection; it failed in every recorded run. 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.io.UncheckedIOException;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.atomic.AtomicBoolean;
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 TestMergePreCopyPrimaryChange extends LuceneTestCase {
public void testPrimaryChangeWhileMergePreCopyInstalls() throws Exception {
try (Replica replica = new Replica(new ByteBuffersDirectory())) {
replica.start(1);
replica.launchPreCopyMerge(
new AtomicBoolean(),
1,
Map.of("_0.si", new FileMetaData(new byte[0], new byte[0], 0, 0)));
replica.job.onceDone.run(replica.job);
replica.newPrimaryThread.join();
// expected: newNRTPoint(2) discards the old primary's pre-copied files; actual: still pending
assertEquals(
"pendingMergeFiles at primaryGen " + replica.lastPrimaryGen,
Set.of(),
replica.pendingMergeFiles);
}
}
private static final class Replica extends ReplicaNode {
CopyJob job;
Thread newPrimaryThread;
Replica(Directory dir) throws IOException {
super(0, dir, null, null);
}
@Override
protected CopyJob newCopyJob(
String reason,
Map<String, FileMetaData> files,
Map<String, FileMetaData> prevFiles,
boolean highPriority,
CopyJob.OnceDone onceDone)
throws IOException {
return new InstallJob(reason, files, this, onceDone);
}
@Override
protected void launch(CopyJob job) {
this.job = job;
}
@Override
protected void sendNewReplica() {}
}
private static final class InstallJob extends CopyJob {
InstallJob(String reason, Map<String, FileMetaData> files, Replica dest, OnceDone onceDone)
throws IOException {
super(reason, files, dest, false, onceDone);
}
@Override
public void finish() throws IOException {
Replica replica = (Replica) dest;
replica.newPrimaryThread =
new Thread(
() -> {
try {
replica.newNRTPoint(2, replica.getCurrentSearchingVersion());
} catch (IOException e) {
throw new UncheckedIOException(e);
}
});
replica.newPrimaryThread.start();
try {
replica.newPrimaryThread.join(5000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
for (String name : files.keySet()) {
dest.dir.createOutput(name, IOContext.DEFAULT).close();
}
}
@Override
protected CopyOneFile newCopyOneFile(CopyOneFile current) {
throw new UnsupportedOperationException();
}
@Override
public void start() {}
@Override
public void runBlocking() {}
@Override
public boolean conflicts(CopyJob other) {
return false;
}
@Override
public boolean getFailed() {
return false;
}
@Override
public Set<String> getFileNamesToCopy() {
return files.keySet();
}
@Override
public Set<String> getFileNames() {
return files.keySet();
}
@Override
public CopyState getCopyState() {
return null;
}
@Override
public long getTotalBytesCopied() {
return 0;
}
@Override
public int compareTo(CopyJob other) {
return Long.compare(ord, other.ord);
}
}
}
# $REPRO holds TestMergePreCopyPrimaryChange.java; $LUCENE is a Lucene checkout
cp $REPRO/TestMergePreCopyPrimaryChange.java $LUCENE/lucene/replicator/src/test/org/apache/lucene/replicator/nrt/
cd $LUCENE
./gradlew :lucene:replicator:test --tests TestMergePreCopyPrimaryChange -Ptests.seed=DEADBEEF
# assertions off: add -Ptests.asserts=false (same failure)
- Expected
- After
newNRTPoint(2, ...)the old primary's pre-copied_0.siis discarded:pendingMergeFilesis empty. - Observed
pendingMergeFiles at primaryGen 2 expected:<[]> but was:<[_0.si]>, with assertions on and off.
More files, environment, full output495 words
The test thread runs the job's OnceDone as the replica's copy thread would, without the node lock. The OnceDone passes its "primary changed?" check and calls the job's finish() (the install, here creating the file), which starts a thread calling the public newNRTPoint(2, current version) and waits for it with join(5000). With the bug that thread gets the node lock at once; with the fix it waits for the install, so the join times out and the passing run takes about 5 s longer. The wait sits in CopyJob.finish(), a method the integrator implements: it stretches the naturally short install window, and with the correct lock it cannot let the switch in. The NRT point carries the replica's current version, so newNRTPoint needs no copy job: it calls maybeNewPrimary before any version check.
It lives in the module's package because launchPreCopyMerge, start and the CopyJob hooks are protected and pendingMergeFiles is package-private, like the module's own tests. The seed does not change the outcome: 20 of 20 iterations under another seed fail. It rests on a 5-second window: only a switch thread stalled for more than 5 s could let the stock code pass by accident; with the fix it always waits out the 5 s and passes. The job is synthetic (no bytes copied, empty metadata); the study's longer test with a real merge and Lucene's CopyOneFile fails the same way (its nodes run in one JVM with test subclasses; the race was not run through the module's socket-based reference transport). Use this one test as the regression test too.
| lucene | main 645d4fe14a20, 10.5.2 and 10.6.0: reproduced, assertions on and off; 9.12.3, and with a port 6.0.0, 6.6.6, 7.7.3 and 8.11.4: reproduced (the other 6.x-8.x releases by reading only); 5.5.x: 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)
TestMergePreCopyPrimaryChange > testPrimaryChangeWhileMergePreCopyInstalls FAILED
java.lang.AssertionError: pendingMergeFiles at primaryGen 2 expected:<[]> but was:<[_0.si]>
at org.apache.lucene.replicator.nrt.TestMergePreCopyPrimaryChange.testPrimaryChangeWhileMergePreCopyInstalls(TestMergePreCopyPrimaryChange.java:41)
# with the one-line fix: passes, assertions on and off (5.03 s: the switch waits for the install)
# 10.5.2 and 10.6.0 release jars: the same message
# the study's real-copy test (real forceMerge, CopyOneFile, temp-file renames; replica A promoted to P2)
AssertionError: newNRTPoint ran while the merge OnceDone was inside its synchronized block: true; replica B now
follows primaryGen 2 but pendingMergeFiles=[_2.cfe, _2.cfs, _2.si] and P1's pre-copied merged files still on
disk: [_2.cfe, _2.cfs, _2.si]
# independent reproducer's follow-up test, assertions on: the new primary's pre-copy of the same-named segment
AssertionError: replica rejected primaryGen 2's pre-copy of [_2.cfe, _2.cfs, _2.si]:
java.lang.AssertionError: file "_2.si" is already being warmed!
# tie-breaker's timing probe (evidence only), assertions off, one earlier pre-copy pending at each round start
PROBE onePending=true rounds=2000 outcomes={CANCELLED=22, DISCARDED=1159, EXCEPTION ConcurrentModificationException=785,
STALE=34} afterException={... pendingButDeletedFromDisk=some, redelivered switch threw NoSuchFileException, close threw
NoSuchFileException (state closing), write lock still held (LockObtainFailedException)=781}
# with the fix: no STALE and no EXCEPTION in 8000 roundsnewNRTPoint's comment says it discards pre-copied merged segments on a primary change, "defensively" (ReplicaNode.java:536-544); the callback itself cancels on "primary changed during merge copy" (:763-767).- The result matches no serial order of the two locked blocks; the NRT-point twin
finishNRTCopyruns its check and renames under the replica's lock (:412). - No javadoc asks callers to hold that lock.
The full argument300 words
- The code states the contract (in an implementation comment, not in javadoc).
ReplicaNode.java:536-544: "Cutover (possibly) to new primary first, so we discard any pre-copied merged segments up front ... we defensively discard here and re-copy";maybeNewPrimarydeletes and clearspendingMergeFiles(:716-724). - The callback guards against this very race. It cancels when "primary changed during merge copy" (
:763-767), and its own comment assumes it coordinates withnewNRTPoint("Even though in newNRTPoint we have similar logic, which cancels any merge copy jobs ...",:787-793). The asserts at:742and:799-800say a file is never pending twice. - No serial order explains the result. Callback first: the switch deletes the files. Switch first: the callback cancels. Observed: generation 2 with generation-1 files pending.
- The twin path holds the node lock (
finishNRTCopy,:412). A contributor read these blocks as the node lock too: in review of #13142 (2024-02-28) its author wrote that "all operations on the 'pendingMergeFiles' in that class are insidesynchronized (this)blocks. But this is rather fragile." - No locking duty on callers is documented.
OnceDone: "Callback invoked by CopyJob once all files have (finally) finished copying" (CopyJob.java:92);launch: "Runs this job async'd" (:516). The module's job runner calls the callback without the node lock (Jobs.java:107, test code). - Design intent. The module author's 2017 design post: replicas that switch to a new primary "must delete any files copied or partially copied from the old primary but not referenced by the new primary".
- History. The lock dates from the commit that added the package (
1ae7291429ba, 2016) and no earlier discussion of which monitor it locks was found (the #13142 review read it as the node lock); the only later change is a 2021 reformat.
The devil's-advocate check231 words
Strongest case for "intended" (an independent reviewer from another model family, OpenAI GPT-5.6, refute lens, reading only): the module is experimental and documents no instantaneous pendingMergeFiles invariant; OnceDone must be invoked only after a genuine copy, but the test calls it directly after a synthetic job with empty metadata that creates the file itself; and pendingMergeFiles is package-private state with best-effort cleanup, not a result a search client sees.
Why it fails:
- The reviewer agreed with the root cause, could not run anything, called its verdict "not a substantive disproof of the race" and rated its own argument moderate.
- Calling the OnceDone from the copy thread is what the module's job runner does. The study's real-copy test (a real merge, Lucene's
CopyOneFilewith length and checksum checks, temp-file renames) fails the same way, and so does a variant with the old primary closed before the promotion. - The stale files are on disk, not only in a field, and the probe shows the public
newNRTPointthrowing. - Two of its three follow-ups were run: a production-like copy path (item 2) and the module's suites with the fix, which pass in nightly mode. After the race the replica still opens P2's NRT point (the independent reproducer's test asserts it); that no searcher sees the stale files is by reading.
@lucene.experimentalis about API stability, not about breaking the contract the module's own comments state.
Recommended A. synchronized (ReplicaNode.this) in the merge pre-copy callback
Lock the replica: synchronized (ReplicaNode.this) at ReplicaNode.java:761. A switch then waits for a running install and discards its files, or the install sees the new generation and cancels. The repro passes with assertions on and off, and so do the module's nightly TestNRTReplication (13 tests) and TestStressNRTReplication.
--- a/lucene/replicator/src/java/org/apache/lucene/replicator/nrt/ReplicaNode.java
+++ b/lucene/replicator/src/java/org/apache/lucene/replicator/nrt/ReplicaNode.java
@@ -758,7 +758,7 @@ public abstract class ReplicaNode extends Node {
// Signals that this replica has finished
mergeCopyJobs.remove(job);
message("done warming merge " + fileNames + " failed?=" + job.getFailed());
- synchronized (this) {
+ synchronized (ReplicaNode.this) {
if (job.getFailed() == false) {
if (lastPrimaryGen != primaryGenStart) {
message(All options and trade-offs3 options · 562 words
One fix was run through gradle (A), the one-line change the study proposed. B is the same lock in a clearer shape; C alone does not fix the bug.
A. synchronized (ReplicaNode.this) in the merge pre-copy callback (validated by the checks) (recommended)
The check, the renames and the add become atomic with newNRTPoint / maybeNewPrimary: a switch that arrives during the install waits and then discards the files, or the install sees the new generation and cancels. It also removes the only mutation of pendingMergeFiles that does not hold the node lock, so the unlocked iteration in maybeNewPrimary and close() can no longer race with an add.
--- a/lucene/replicator/src/java/org/apache/lucene/replicator/nrt/ReplicaNode.java
+++ b/lucene/replicator/src/java/org/apache/lucene/replicator/nrt/ReplicaNode.java
@@ -758,7 +758,7 @@ public abstract class ReplicaNode extends Node {
// Signals that this replica has finished
mergeCopyJobs.remove(job);
message("done warming merge " + fileNames + " failed?=" + job.getFailed());
- synchronized (this) {
+ synchronized (ReplicaNode.this) {
if (job.getFailed() == false) {
if (lastPrimaryGen != primaryGenStart) {
message(Trade-offs
- The renames now run under the node lock, as they already do for NRT points (
finishNRTCopy,ReplicaNode.java:430), so a slowfinish()delaysnewNRTPointandcommitfor that long. The study's devil's advocate notes it adds no new lock-order pair: the same launcher thread already takes the node lock infinishNRTCopy(read). - An integrator whose
launch()blocked until the job finished on another thread would deadlock here, but such an integrator already deadlocks on every NRT point:newNRTPointcallslaunchholding the node lock (:654) and its callback needs that lock (:412);launchis documented as async (:516) (read, 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 (5.03 s each, because the switch waits for the install). - With A, the whole
:lucene:replicator:testtask passes (16 tests: the new one passes; the 14 nightly tests andTestSimpleServer.test, which runs only nested, are skipped). - With A and nightly tests enabled:
TestNRTReplication13 tests, 0 failures (48 s);TestStressNRTReplication1 test, 0 failures (JUnit time 324 s, one seed only; its log has 9 replica-side primary switches ("now change lastPrimaryGen" lines), 127 merge pre-copy callbacks ("done warming merge" lines) and no "already being warmed",ConcurrentModificationExceptionorNoSuchFileException). - Earlier, the 2026-10-31 tie-breaker with the fix shadowed into the stock jars: the real-copy test, the P1-closed variant and the independent reproducer's two tests pass; the timing probe shows no stale state and no exception in 8000 rounds; nightly
TestNRTReplication13 of 13 andTestStressNRTReplication1 of 1 (a randomized run with crashes and closes, planned run time 478 s). The one-line change also fixes the port on 6.0.0.
Move the callback's body into a synchronized ReplicaNode method (for example finishMergeCopy(job, fileNames, primaryGenStart), like finishNRTCopy), or pass a lambda, whose this is the ReplicaNode.
Trade-offs
Same semantics as A and harder to get wrong again; a larger diff.
Open PR #13142 changes pendingMergeFiles and mergeCopyJobs to ConcurrentHashMap.newKeySet().
Trade-offs
Removes the ConcurrentModificationException, but not the check-then-act race: the old primary's files would still be registered after the switch. With A, every mutation already holds the node lock.
Downstream, the open-source nrtsearch server subclasses ReplicaNode and calls launchPreCopyMerge without replacing the callback, so it inherits the race (code read, not run).
TLA+ model checking of the NRT replication protocol. While transcribing the merge pre-copy callback into the model, the finder saw that its lock is the callback, modelled check and install as two steps, and TLC found an old-generation file pending under the new generation. A JUnit then replayed the interleaving on the stock jars.
The method in full174 words
- Method (semi-formal). The replicator hunt wrote a TLA+ model of the NRT replication protocol (
NrtReplication.tla). The hypothesis came from code reading while the merge pre-copy callback was being transcribed: because the lock is the callback, the model splits it into two actions (check, then install). TLC found the invariant "every pending file comes from the current primary generation" violated in a 7-state trace (689 distinct states with assertions off, 673 on; also under the module's test-controller discipline, 337 states). With the lock fixed TLC found no violation in the bounded configurations it checked (10,233 distinct states for the merge model, 82,805 for the full model). - Executed. A JUnit with in-process nodes and a real
forceMergereplayed the interleaving on the stock jars; an independent reproducer wrote its own test and added the assertions-on follow-up; a devil's advocate and an adjudication confirmed it; the one-test repro was then reduced from these tests; the 2026-10-31 re-verification and tie-breaker re-ran everything on645d4fe14a20and added the timing probe and the nightly suites with the fix.
No prior report in GitHub issues and PRs, the open PRs' file lists, four mailing lists, CI failure mails, the JIRA archive, nrtsearch or the web (2026-10-26, repeated 2026-10-31). Closest: open PR #13142, whose author read these blocks as the node lock; it does not touch the callback.
Searches3 searches
Nearest items, none this bug: #13142 (open, stale since 2024-03) changes the set type of pendingMergeFiles and mergeCopyJobs; in review (2024-02-28) its author wrote that all operations on pendingMergeFiles "are inside synchronized (this) blocks. But this is rather fragile." No participant noticed that one of those blocks locks the callback, and the diff does not touch it. #6501 (LUCENE-5438) is the original feature; its comments do not mention this lock. #16838 (open) validates file names only. #11913 is a different PrimaryNode concurrency bug. nrtsearch #135 and related items cover the primary side. Limits: GitHub search is token based and cannot match synchronized (this) or ReplicaNode.this exactly; CI mail bodies are cut near 100 KB; 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 (40 issue/PR, 6 commit, 4 downstream queries; 432 distinct hits), all 32 module:replicator items, manual searches of apache/lucene, all of GitHub and nrtsearch for launchPreCopyMerge, pendingMergeFiles, the assertion texts and ReplicaNode.this | no report; 0 high-overlap hits |
| Open PRs and git history | 2026-10-31 | all 124 apache/lucene items updated since 2026-10-25 and the file lists of their 96 PRs; git log -S and -G on ReplicaNode.java; CHANGES.txt | only #16838 touches the replicator (file-name validation); no commit or CHANGES entry mentions the lock |
| Mailing lists (dev@, java-user@, issues@, commits@, builds@), CI failure mails, JIRA archive, web | 2026-10-26 and 2026-10-31 | 40 list queries through the tool plus direct searches; 123 CI mails naming the NRT replication tests scanned for the assertion texts and ConcurrentModificationException; 8 JIRA queries; 6 web queries | no report |
Introduced by 1ae7291429ba (2016-01-24, LUCENE-5438 branch), the commit that added the NRT replication package; first released in 6.0.0. Every release run from 6.0.0 to 10.6.0, and main, fails; 6.0.0 passes with the one-line fix; unchanged on upstream main 2f424fd428da.
Versions tested6 rows
Introduced by 1ae7291429ba ("current patch", mikemccand, 2016-01-24) on the LUCENE-5438 feature branch: the commit that created the whole org.apache.lucene.replicator.nrt package and the only one that ever added ReplicaNode.java; its launchPreCopyMerge already passed an anonymous OnceDone whose run() does synchronized(this). Merged to the main line by 12b8721a44db (2016-02-11). 9afa560054aa (2016-02-08, same branch) added the "defensively discard" contract to newNRTPoint; it reached the main line in the same merge. The only later change to the block is 2cbf261032dc (2021-01-05, LUCENE-9570 reformat). LUCENE-5438 is now #6501, with no PR and no CHANGES entry. All 84 release tags 6.0.0-8.8.1 and 9.0.0-10.6.0 in the history clone have a token-identical launchPreCopyMerge body. Not fixed upstream.
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | the one test fails (gradle and harness, assertions on and off); passes with the one-line fix |
| 10.6.0, 10.5.2 | affected | the one test fails (assertions on and off) |
| 9.12.3 | affected | the one test fails (assertions on) |
| 6.6.6, 7.7.3, 8.11.4 | affected | a port of the test fails (assertions on) |
| 6.0.0 | first release | the port fails with assertions on and off; passes with only that line changed |
| 5.5.0, 5.5.5 | no API | no NRT replication package; the test does not compile |
The finder (TLA+, then JUnit), an independent reproducer, a cross-model review, a devil's advocate and an adjudication (confirmed, high), then a credibility audit, a re-verification on 2026-10-31 with a second cross-model review, and a tie-breaker (real, high) that also ran the nightly suites with the fix.
Checks and verdicts279 words
The cross-model reviews returned not real because the reviewer could not run code; each time it agreed with the root cause, and its concrete objections (old primary still alive, synthetic copy job, suite not run with the fix) were answered by executed variants.
| Check | Verdict | What it found |
|---|---|---|
| finder (TLA+, JUnit) | bug | TLC trace (7 states); the real-copy JUnit fails on main, 10.5.2 and 9.12.3 and passes with the fix. |
| independent reproducer | real | Own two-test class: stale files pending and on disk, and the assertions-on rejection of the same-named pre-copy; 20 of 20 iterations fail, 0 of 20 with the fix. |
| cross-model review, refute lens (study) | not real, low (procedural) | Reading only; root cause agrees; its objection (old primary still alive) was answered by the P1-closed variant, which fails and passes with the fix. |
| intended-behaviour devil's advocate | real, high | No intended, documented, misuse or unreachable argument survived; re-ran the test on main, 10.5.2 and 9.12.3. |
| adjudication | confirmed, high; impact minor | Re-ran the independent reproducer's test at a fresh seed; the fix passes. |
| credibility audit | keep | Re-read the code, re-ran the finder test (fails; passes with the fix); noted that the repro widens the window and that no wrong results were shown. |
| re-verification and its review (2026-10-31) | real | The one test fails on main, 10.5.2 and 10.6.0 and passes with the fix; a new refute-lens review could not run anything and voted not real on procedure only (medium). |
| tie-breaker (2026-10-31) | real, high | Checked the shipped bytecode, re-ran every test, wrote the hook-free timing probe (stale state and ConcurrentModificationException) and ran the nightly suites with the fix. |
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 468 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,662 words
- Registry title
- An NRT replica's merge pre-copy callback (ReplicaNode.launchPreCopyMerge) synchronizes on itself instead of the ReplicaNode, so a primary change that lands while it installs the merged files leaves the old primary's pre-copied merged files pending and on disk
- Area
- replicator / NRT segment replication (ReplicaNode.launchPreCopyMerge merge pre-copy, ReplicaNode.newNRTPoint)
- Priority
- Low, rank 944: The experimental NRT merge pre-copy callback locks the wrong object, so a primary change in a narrow window leaves pre-copied files pending on disk, which is minor and races-only.
- Impact class
- minor
- Impact note
- Applications that use lucene-replicator's NRT segment replication with merge pre-copy warming (PrimaryNode/ReplicaNode/CopyJob subclasses, opt-in and @lucene.experimental; the one downstream user found, nrtsearch, calls launchPreCopyMerge without overriding the callback, by reading), in every release since 6.0.0 and at any index size, are hit only when a primary change reaches a replica (newNRTPoint or launchPreCopyMerge with a higher primaryGen) while that replica's merge pre-copy callback for the old primary is between its 'primary changed?' check and the end of its renames plus pendingMergeFiles.add: the old primary's merged files then stay in pendingMergeFiles and on disk after the switch, against the discard that newNRTPoint promises, until a later primary change, close, restart or a re-copy overwrites them, and with -ea the new primary's later pre-copy of a same-named merged segment fails with AssertionError 'file "_2.si" is already being warmed!'; no wrong search results, lost documents or index corruption were shown, and by reading none follow, because a local file is reused only if its header (with the random segment id) and footer match.
- Summary
- You run Lucene's NRT segment replication with merge pre-copy: the primary asks replica B to pre-copy a merged segment, and just as B has copied the bytes and is renaming the files into place, another node is promoted to primary and B receives its first NRT point (primaryGen 2). You expect B either to install the merged files and then discard them in the switch, or to notice the switch and cancel the pre-copy, because newNRTPoint says it discards the old primary's pre-copied merged segments and the callback itself checks whether the primary changed. Instead the callback's synchronized (this) locks the anonymous CopyJob.OnceDone, not the ReplicaNode, so the switch runs in the middle of it and B follows primaryGen 2 with the old primary's merged files [_2.cfe, _2.cfs, _2.si] still in pendingMergeFiles and on disk (with -ea, the new primary's later pre-copy of a same-named merged segment then fails with 'file "_2.si" is already being warmed!').
- Introduced
- 1ae7291429bad742715344f86cfa5200229b3698 ('current patch' by mikemccand, 2016-01-24, on the feature branch jira/lucene-5438-nrt-replication; the commit that added the whole org.apache.lucene.replicator.nrt package, including launchPreCopyMerge and its anonymous CopyJob.OnceDone whose run() does 'synchronized(this)'), merged into the main line by 12b8721a44db (2016-02-11; named as the LUCENE-5438 commit in the author's JIRA comment of that day); LUCENE-5438 'add near-real-time replication' (now #6501), no pull request, no CHANGES entry [read: git log --diff-filter=A, git log -L on launchPreCopyMerge and maybeNewPrimary, git blame, git show in the full-history clone]; 9afa560054aa (2016-02-08, same branch) moved maybeNewPrimary to the top of newNRTPoint with the 'defensively discard' comment and made it delete pendingMergeFiles, the contract the repro checks; 2cbf261032dc (2021-01-05, LUCENE-9570 reformat) is the only later change to launchPreCopyMerge, whitespace only (2016-01-24 (branch commit); 2016-02-11 (merged to the main line))
- First affected version
- 6.0.0 [exec] (release tag commit 2016-04-01; the first release with the org.apache.lucene.replicator.nrt package): the 6.0 port of the repro fails, and the same run passes when only that one line is changed to synchronized (ReplicaNode.this); the port needs a close() guard because of a separate 6.0.0-6.1.0 constructor bug (success = true missing, fixed in 6.2.0), which is not part of this bug; 5.5.0 and 5.5.5 have no nrt package (the repro does not compile, 103 errors) [exec]
- Affected versions
- every release from 6.0.0 through 10.6.0 and main ea57360912ed. [exec] fail with the same end state (old-primary merged files in pendingMergeFiles and on disk after the switch to primaryGen 2): 6.0.0 (-ea and -da), 6.6.6, 7.7.3 and 8.11.4 (-ea), 9.12.3 (-ea and -da), 10.5.2 (-ea and -da) and main (-ea and -da); 6.0.0 passes with the one-line fix (-ea and -da); the independent reproduction's independent test also fails on 10.5.2 and 9.12.3 (-ea and -da) and the intended-behaviour check's rerun on 10.5.2 and 9.12.3 (-da). [read] all 83 release tags in the history clone from 6.0.0 on (6.0.0..8.8.1 and 9.0.0..10.5.2) contain the merge commit, and at each of them the merge pre-copy OnceDone is an anonymous class whose run() locks 'this' (scripted check, 83 of 83); 8.8.2..8.11.4 are on a branch not in the clone (8.11.4 executed). Not fixed on main (upstream main re-checked 2026-10-26: line 761 still reads 'synchronized (this) {'); 10.6.0 (released 2026-10-29) fails too [exec: the one-test repro on the 10.6.0 release jars, -ea and -da, 2026-10-31]. 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 could only read the code and voted “not real” while agreeing with the root cause (the adjudication ruled it real), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
- Found by
- semi-formal: TLA+/TLC, code reading
- Upstream and prior reports
- none found (2026-10-26, read-only; the prior-report record: scripted prior-report search with 16 GitHub issue/PR queries, 3 commit queries, 2 downstream queries over Elasticsearch, OpenSearch and Solr, 20 mailing-list queries over dev@, java-user@, issues@ and commits@, 4 JIRA-archive queries and 6 web queries, 130 hits, 0 high overlap; a second run with 12 GitHub queries including Yelp/nrtsearch, 30 hits, 0 high overlap; a lists and JIRA run with 24 queries, 106 hits, 0 high or medium overlap; 17 manual GitHub searches, including all 32 module:replicator items and GitHub-wide searches for launchPreCopyMerge, pendingMergeFiles, 'already being warmed' and 'primary changed during merge copy'; the file lists of all 413 open apache/lucene PRs (5 touch the replicator, their diffs read); direct JIRA-archive and lists.apache.org searches; all 123 dev@ and builds@ CI mails that mention TestStressNRTReplication or TestNRTReplication scanned for the assertion texts and ConcurrentModificationException, 0 matches; git history and CHANGES.txt; all nrtsearch issue and PR titles; the finder's 35-query run and the independent reproduction's and the intended-behaviour check's searches found nothing either); not fixed upstream (upstream main re-checked 2026-10-26: ReplicaNode.java:761 still reads 'synchronized (this) {' inside the OnceDone; the last commit to the file, 27d1272984f9 of 2025-07-14, is unrelated; no open PR changes the block). Closest item, not a report: #13142 (open, stale since 2024-03: synchronizedSet to ConcurrentHashMap.newKeySet for mergeCopyJobs and pendingMergeFiles), whose author wrote in review on 2024-02-28 that all operations on pendingMergeFiles are inside synchronized (this) blocks, that is, they took the OnceDone's lock for the node lock; the PR does not touch the block. Related context: #6501 / LUCENE-5438 (the original feature), the feature author's 2017 design post (after a primary change, replicas must delete files copied from the old primary that the new primary does not reference), #16838, #13045, #16847, #16846 / #16845, #11913 (a different PrimaryNode concurrency bug), #10346, #11006, #14417, #14454, #14664, #11674, nrtsearch #135, #507 and #226 (primary side)
- 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); the one-line fix through gradle (the repro with assertions on and off; the whole:lucene:replicator:testtask; with nightly tests enabledTestNRTReplication, 13 tests, andTestStressNRTReplication, one run). On the same commit, with and without the fix: the study's real-copy test, its P1-closed variant, the independent reproducer's two tests and the tie-breaker's timing probe. The 10.5.2 and 10.6.0 release jars (assertions on and off); 9.12.3 with the same test; 6.0.0, 6.6.6, 7.7.3 and 8.11.4 with a port, and 6.0.0 with the one-line fix. 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), :68 and :71 (lastCommitFiles and lastNRTFiles, plain HashSets), :83 (pendingMergeFiles, a Collections.synchronizedSet), :86 (lastPrimaryGen, plain long), :173 (deleteUnknownFiles at start), :350 and :412 (commit and finishNRTCopy take synchronized (this) inside ReplicaNode methods, so the node monitor), :530-545 (newNRTPoint, synchronized; comment 536-544 'we defensively discard here and re-copy'; maybeNewPrimary at 545), :692-696 (close deletes leftover pendingMergeFiles), :706-728 (maybeNewPrimary, synchronized: deleteIfNoRef on every pending merge file, lastPrimaryGen update, pendingMergeFiles.clear()), :730-742 (launchPreCopyMerge, synchronized; maybeNewPrimary at 736; assert 'is already being warmed!' at 742), :754-812 (anonymous CopyJob.OnceDone: mergeCopyJobs.remove at 759, 'synchronized (this)' at 761, 'primary changed during merge copy' cancel at 763-767, lastNRTFiles/lastCommitFiles reads at 771/778, job.finish() at 796, pendingMergeFiles.add at 802), :849-873 (fileIsIdentical: header with segment id, length and footer must match; 'Segment name was reused!')lucene/replicator/src/java/org/apache/lucene/replicator/nrt/CopyJob.java:35 (@lucene.experimental), :92-95 (OnceDone javadoc: 'Callback invoked by CopyJob once all files have (finally) finished copying'; no locking contract)lucene/replicator/src/test/org/apache/lucene/replicator/nrt/Jobs.java:29, :91, :107 (the module's job launcher is its own thread and runs onceDone.run without the node monitor); lucene/replicator/src/test/org/apache/lucene/replicator/nrt/SimpleReplicaNode.java:218 (newNRTPoint) and :339 (launchPreCopyMerge), called on per-connection handler threads; grep of lucene/replicator/src/test: no test reads pendingMergeFiles
- The finding in 30 seconds
a Lucene main checkout lucene/replicator/src/java/org/apache/lucene/replicator/nrt/ReplicaNode.java:61, 82-83, 173, 412-430, 516-517, 530-545, 692-696, 706-728, 730-822 (read)a Lucene main checkout lucene/replicator/src/java/org/apache/lucene/replicator/nrt/CopyJob.java:35, 92-95 (read)
- Who is affected
a Lucene main checkout lucene/replicator/src/test/org/apache/lucene/replicator/nrt/TestSimpleServer.java:344-356, SimpleReplicaNode.java:99-100, 218, 339, Jobs.java:107 (read)a Lucene main checkout lucene/replicator/src/java/org/apache/lucene/replicator/nrt/ReplicaNode.java:61, 849-873; CopyJob.java:35 (read)
- Why it looks unintended
a Lucene main checkout lucene/replicator/src/java/org/apache/lucene/replicator/nrt/ReplicaNode.java:412, 516, 536-545, 716-724, 742, 763-767, 787-793, 799-800 (read); CopyJob.java:92 (read)a Lucene main checkout lucene/replicator/src/test/org/apache/lucene/replicator/nrt/Jobs.java:107 (read)
- Suggested fix
a Lucene main checkout lucene/replicator/src/java/org/apache/lucene/replicator/nrt/ReplicaNode.java:412, 430, 516, 654 (read)
- TestMergePreCopyPrimaryChange.java (one test):
repro/LFV-245/src/org/apache/lucene/replicator/nrt/TestMergePreCopyPrimaryChange.java(151 lines)
assets/LFV-245/bug.png(468 KB)
