All findings · Lucene finding · LFV-245 · replica-merge-precopy-oncedone-locks-wrong-monitor

An NRT replica's merge pre-copy callback locks itself, not the replica, so a primary switch can slip into its install

In NRT segment replication, the callback that installs a pre-copied merged segment on a replica uses synchronized (this) inside an anonymous class, so it locks itself, not the ReplicaNode. A fail-over during the install leaves the old primary's merged files pending and on disk; synchronized (ReplicaNode.this) fixes it.

Summary

Priority
Low, rank 944The 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
minor
Since
6.0.0 (LUCENE-5438, 2016)introduced 2016-01-24 · over 10 years
Affected
6.0.0 to 10.6.0 (the latest release) and main
Status
documented
Found by
semi-formal: TLA+ model checking of the NRT replication protocol, then JUnit

AI-assisted page: this finding was first signalled by a semi-formal check (an exact check of the real jar, or a modelling hypothesis that a tool then witnessed) and then checked with AI tools; the checks it went through, and what each found, are under Prior reports, history and checks. Not yet triaged by a human. Snapshot of 2026-11-06.

The finding in 30 seconds

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.

Background: NRT replication and merge pre-copy

  1. In lucene-replicator's NRT segment replication (opt-in, experimental) one PrimaryNode indexes and ReplicaNodes copy its files. Each newly promoted primary gets a higher primary generation.
  2. 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.
  3. When a replica sees a new primary generation, newNRTPoint first deletes every pending pre-copied file and clears the list: files from the old primary are not reused for the new one.
Timeline of replica B. Copy thread: the last byte of P1's merged segment _2 arrives; the callback checks the primary generation (1, unchanged) and starts renaming _2.cfe, _2.cfs and _2.si into place. NRT-point thread: P2's first point newNRTPoint(2, ...) arrives, clears the still-empty pending list and switches B to generation 2. Copy thread: the callback adds _2.cfe, _2.cfs and _2.si to pendingMergeFiles. End state: B follows generation 2 with the old primary's three files pending and on disk. With synchronized (ReplicaNode.this) the switch waits for the install and then deletes the files.
Replica B's two threads on one timeline: the copy thread checks the generation and starts the install, the switch to generation 2 runs in the middle, and the old primary's files end up pending under the new primary. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. 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.
  2. Right then replica A is promoted to P2 (generation 2), and B receives P2's first point, newNRTPoint(2, v), on another thread.
  3. 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").
  4. 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.
  5. 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 made newNRTPoint throw ConcurrentModificationException; with another pre-copy pending, a re-delivered switch and close() then failed with NoSuchFileException.

Who is affected

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.

The code (main 645d4fe14a20)

// 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();                                    // :724

The 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).

Step by step, on the minimal repro

  1. Pre-copy request. An empty replica starts at generation 1 and gets launchPreCopyMerge(generation 1) for one file, _0.si. The callback records primaryGenStart = 1.
  2. 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.
  3. Switch. Inside the install (job.finish()), the test's job starts a thread that calls the public newNRTPoint(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.
  4. Install. The callback creates _0.si and adds it to pendingMergeFiles. The replica now follows generation 2 with [_0.si] from generation 1 pending.
  5. 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: pendingMergeFiles is empty.

What follows (fuller tests and a probe)

  • Real copy, two replicas (the study's test: a real forceMerge, Lucene's CopyOneFile, temp-file renames; replica A promoted to P2): replica B follows generation 2 with pendingMergeFiles=[_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 _2 is rejected by the assert at :742, file "_2.si" is already being warmed!. With assertions off that pre-copy goes through.
  • Unlocked iteration. pendingMergeFiles is a Collections.synchronizedSet (:83), whose iterator must be used under the set's own lock; maybeNewPrimary (:717) and close() (:693) iterate it holding only the node lock, and the callback's add is 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 calls newNRTPoint after a random 0-200 microsecond spin; 2000 rounds), with one earlier pre-copy pending at each round start, newNRTPoint threw ConcurrentModificationException in 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 threw NoSuchFileException, and so did close(), 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 / CopyJob subclasses, 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 through maybeNewPrimary) reaching a replica while its callback for an old-primary merge pre-copy is between the check and the end of its add loop. Lucene's reference transport allows it: one handler thread per connection (TestSimpleServer.java:344-356, test code) calls newNRTPoint and launchPreCopyMerge (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, newNRTPoint threw ConcurrentModificationException, and when another pre-copy was pending the replica then failed a re-delivered switch and close() with NoSuchFileException and 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).

Minimal repro

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).

TestMergePreCopyPrimaryChange.java (one test)151 lines
/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.apache.lucene.replicator.nrt;

import java.io.IOException;
import java.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);
    }
  }
}
Command
# $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.si is discarded: pendingMergeFiles is empty.
Observed
pendingMergeFiles at primaryGen 2 expected:<[]> but was:<[_0.si]>, with assertions on and off.

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

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.

Environment

lucenemain 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
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux aarch64

Observed output

# 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 rounds

Why it looks unintended

  • newNRTPoint'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 finishNRTCopy runs 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"; maybeNewPrimary deletes and clears pendingMergeFiles (: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 with newNRTPoint ("Even though in newNRTPoint we have similar logic, which cancels any merge copy jobs ...", :787-793). The asserts at :742 and :799-800 say 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 inside synchronized (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:

  1. 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.
  2. 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 CopyOneFile with length and checksum checks, temp-file renames) fails the same way, and so does a variant with the old primary closed before the promotion.
  3. The stale files are on disk, not only in a field, and the probe shows the public newNRTPoint throwing.
  4. 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.
  5. @lucene.experimental is about API stability, not about breaking the contract the module's own comments state.

Suggested fix

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 slow finish() delays newNRTPoint and commit for 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 in finishNRTCopy (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: newNRTPoint calls launch holding the node lock (:654) and its callback needs that lock (:412); launch is 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:test task passes (16 tests: the new one passes; the 14 nightly tests and TestSimpleServer.test, which runs only nested, are skipped).
  • With A and nightly tests enabled: TestNRTReplication 13 tests, 0 failures (48 s); TestStressNRTReplication 1 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", ConcurrentModificationException or NoSuchFileException).
  • 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 TestNRTReplication 13 of 13 and TestStressNRTReplication 1 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.

B. The same lock in a clearer shape (not run)

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.

C. A concurrent set for pendingMergeFiles (hardening only, not run)

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 note

Downstream, the open-source nrtsearch server subclasses ReplicaNode and calls launchPreCopyMerge without replacing the callback, so it inherits the race (code read, not run).

How it was found

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 forceMerge replayed 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 on 645d4fe14a20 and added the timing probe and the nightly suites with the fix.

Prior reports, history and checks

Prior reports

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.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene), downstream repositories2026-10-26 and 2026-10-31the 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.thisno report; 0 high-overlap hits
Open PRs and git history2026-10-31all 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.txtonly #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, web2026-10-26 and 2026-10-3140 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 queriesno report

History

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.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedthe one test fails (gradle and harness, assertions on and off); passes with the one-line fix
10.6.0, 10.5.2 affectedthe one test fails (assertions on and off)
9.12.3 affectedthe one test fails (assertions on)
6.6.6, 7.7.3, 8.11.4 affecteda port of the test fails (assertions on)
6.0.0 first releasethe port fails with assertions on and off; passes with only that line changed
5.5.0, 5.5.5 no APIno NRT replication package; the test does not compile

Independent checks

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.

Each independent check and its verdict
CheckVerdictWhat it found
finder (TLA+, JUnit) bugTLC trace (7 states); the real-copy JUnit fails on main, 10.5.2 and 9.12.3 and passes with the fix.
independent reproducer realOwn 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, highNo intended, documented, misuse or unreachable argument survived; re-ran the test on main, 10.5.2 and 9.12.3.
adjudication confirmed, high; impact minorRe-ran the independent reproducer's test at a fresh seed; the fix passes.
credibility audit keepRe-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) realThe 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, highChecked 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.

Diagram file

The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.

Sources and provenance

Finding record, Lucene and upstream sources, repro files and diagram notes1,662 words

Finding record

LFV-245, snapshot of 2026-11-06

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:test task; with nightly tests enabled TestNRTReplication, 13 tests, and TestStressNRTReplication, 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 main 2f424fd428da (2026-10-30) has ReplicaNode.java byte-identical to 645d4fe14a20. OpenJDK 25.0.3, Linux aarch64 (the only platform run).

Lucene and upstream sources

Finding record
  • lucene/replicator/src/java/org/apache/lucene/replicator/nrt/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)

Other statements trace to the hunt's working files (models, logs, reviews and briefs), which are not published.

Repro files

About the diagrams

Built 2026-11-06 from the hunt's record of this finding and its doc file. Lucene main under test in this hunt: ea57360912ed. Every listed finding was re-run on Lucene main e1faeba08bde on 2026-11-02.