This is an automated email from the ASF dual-hosted git repository.

asf-gitbox-commits pushed a commit to branch cassandra-6.0
in repository https://gitbox.apache.org/repos/asf/cassandra.git


The following commit(s) were added to refs/heads/cassandra-6.0 by this push:
     new 1df3a8cef0 Setup async transformation before making internode request
1df3a8cef0 is described below

commit 1df3a8cef04d1bf2a92b4ca48f99b5b5be6bd57d
Author: Sam Tunnicliffe <[email protected]>
AuthorDate: Wed Jul 8 15:35:09 2026 +0100

    Setup async transformation before making internode request
    
    Patch by Sam Tunnicliffe and Dmitry Konstantinov; reviewed by Sam
    Tunnicliffe and Dmitry Konstantinov for CASSANDRA-21384
    
    Co-authored-by: Dmitry Konstantinov <[email protected]>
---
 CHANGES.txt                                        |   1 +
 .../org/apache/cassandra/tcm/PeerLogFetcher.java   |  37 +--
 .../apache/cassandra/tcm/PeerLogFetcherTest.java   | 354 +++++++++++++++++++++
 3 files changed, 374 insertions(+), 18 deletions(-)

diff --git a/CHANGES.txt b/CHANGES.txt
index dcd7972231..51b576d242 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -1,4 +1,5 @@
 6.0-alpha2
+ * Avoid potential deadlock between GlobalLogFollower and GossipStage 
(CASSANDRA-21384)
  * Add CMS membership as a field in ClusterMetadata (CASSANDRA-20736)
  * Fix maven remote publishing of Accord artifacts (CASSANDRA-21261)
  * Move long running TCM operations to a longer timout (CASSANDRA-21453)
diff --git a/src/java/org/apache/cassandra/tcm/PeerLogFetcher.java 
b/src/java/org/apache/cassandra/tcm/PeerLogFetcher.java
index 12cdc18e5d..4a131954cb 100644
--- a/src/java/org/apache/cassandra/tcm/PeerLogFetcher.java
+++ b/src/java/org/apache/cassandra/tcm/PeerLogFetcher.java
@@ -88,34 +88,35 @@ public class PeerLogFetcher
             return res;
         }
 
-        Promise<LogState> fetchRes = new AsyncPromise<>();
+        Promise<LogState> fetchFromRemote = new AsyncPromise<>();
+        Future<ClusterMetadata> appendToLog = fetchFromRemote.map(logState -> {
+            log.append(logState);
+            ClusterMetadata fetched = log.waitForHighestConsecutive();
+            if (fetched.epoch.isEqualOrAfter(awaitAtleast))
+            {
+                TCMMetrics.instance.peerLogEntriesFetched(before, 
logState.latestEpoch());
+                return fetched;
+            }
+            else
+            {
+                throw new IllegalStateException(String.format("Queried for 
epoch %s, but could not catch up. Current epoch: %s", awaitAtleast, 
fetched.epoch));
+            }
+        });
+
         logger.info("Fetching log from {}, at least {}", remote, awaitAtleast);
         try (Timer.Context ctx = 
TCMMetrics.instance.fetchPeerLogLatency.time())
         {
             RemoteProcessor.sendWithRetries(Verb.TCM_FETCH_PEER_LOG_REQ,
                                             new FetchPeerLog(before),
-                                            fetchRes,
+                                            fetchFromRemote,
                                             new 
RemoteProcessor.CandidateIterator(Collections.singletonList(remote), false),
                                             
Retry.untilElapsed(DatabaseDescriptor.getCmsAwaitTimeout().to(TimeUnit.NANOSECONDS),
 TCMMetrics.instance.fetchLogRetries));
-
-            return fetchRes.map((logState) -> {
-                log.append(logState);
-                ClusterMetadata fetched = log.waitForHighestConsecutive();
-                if (fetched.epoch.isEqualOrAfter(awaitAtleast))
-                {
-                    TCMMetrics.instance.peerLogEntriesFetched(before, 
logState.latestEpoch());
-                    return fetched;
-                }
-                else
-                {
-                    throw new IllegalStateException(String.format("Queried for 
epoch %s, but could not catch up. Current epoch: %s", awaitAtleast, 
fetched.epoch));
-                }
-            });
-
+            return appendToLog;
         }
         catch (Throwable t)
         {
-            fetchRes.cancel(true);
+            fetchFromRemote.cancel(true);
+            appendToLog.cancel(true);
             JVMStabilityInspector.inspectThrowable(t);
 
             logger.warn("Unable to fetch log entries from " + remote, t);
diff --git a/test/unit/org/apache/cassandra/tcm/PeerLogFetcherTest.java 
b/test/unit/org/apache/cassandra/tcm/PeerLogFetcherTest.java
new file mode 100644
index 0000000000..b3ee9ef9a8
--- /dev/null
+++ b/test/unit/org/apache/cassandra/tcm/PeerLogFetcherTest.java
@@ -0,0 +1,354 @@
+/*
+ * 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.cassandra.tcm;
+
+import java.util.Map;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Predicate;
+
+import com.google.common.util.concurrent.Uninterruptibles;
+
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+import org.apache.cassandra.concurrent.Stage;
+import org.apache.cassandra.config.DatabaseDescriptor;
+import org.apache.cassandra.dht.Murmur3Partitioner;
+import org.apache.cassandra.gms.Gossiper;
+import org.apache.cassandra.locator.InetAddressAndPort;
+import org.apache.cassandra.net.Message;
+import org.apache.cassandra.net.MessagingService;
+import org.apache.cassandra.net.OutboundSink;
+import org.apache.cassandra.net.Verb;
+import org.apache.cassandra.tcm.listeners.ChangeListener;
+import org.apache.cassandra.tcm.log.Entry;
+import org.apache.cassandra.tcm.log.LocalLog;
+import org.apache.cassandra.tcm.log.LogState;
+import org.apache.cassandra.tcm.ownership.UniformRangePlacement;
+import org.apache.cassandra.utils.concurrent.Future;
+
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNotSame;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+public class PeerLogFetcherTest
+{
+    private static final InetAddressAndPort REMOTE = 
InetAddressAndPort.getByNameUnchecked("127.0.0.2");
+
+    @BeforeClass
+    public static void beforeClass()
+    {
+        DatabaseDescriptor.daemonInitialization();
+    }
+
+    /**
+     * Regression test for CASSANDRA-21384 (deadlock between GlobalLogFollower 
and GossipStage).
+     *
+     * When a node discovers a peer at a higher epoch (e.g. while processing 
gossip), it calls
+     * {@link PeerLogFetcher#asyncFetchLog}. Through {@link 
EpochAwareDebounce} that runs
+     * synchronously on the calling thread (GossipStage). The fetch attaches a 
continuation via
+     * {@code Future.map} that does {@code log.append(logState)} followed by 
the blocking
+     * {@code log.waitForHighestConsecutive()}.
+     *
+     * The bug: the original code attached that continuation *after* 
dispatching the internode
+     * request. If the response completed the request promise before the 
{@code map()} call (a
+     * small but real race, easily won over loopback), {@code map()} on an 
already-completed
+     * promise runs the mapper inline on the caller thread. That means the 
blocking
+     * {@code waitForHighestConsecutive()} ran on GossipStage, which then 
could not service the
+     * {@code Gossiper.runInGossipStageBlocking()} call made by the 
GlobalLogFollower's post-commit
+     * listeners -> circular wait.
+     *
+     * This test forces exactly that "promise already completed before the 
continuation is
+     * attached" ordering: the request promise is completed from a dedicated 
responder thread and
+     * the caller is blocked until that has happened. It then asserts that the 
append/wait
+     * continuation ran on the responder (messaging) thread and never on the 
caller thread.
+     * Against the pre-fix code the continuation runs on the caller thread and 
the assertion fails.
+     */
+    @Test
+    public void continuationNeverRunsOnCallerThread() throws Exception
+    {
+        LocalLog log = LocalLog.logSpec()
+                               .async()
+                               .withInitialState(new 
ClusterMetadata(Murmur3Partitioner.instance))
+                               .createLog();
+        ClusterMetadataService cms = new ClusterMetadataService(new 
UniformRangePlacement(),
+                                                                
MetadataSnapshots.NO_OP,
+                                                                log,
+                                                                new 
AtomicLongBackedProcessor(log),
+                                                                
Commit.Replicator.NO_OP,
+                                                                false);
+        ClusterMetadataService.setInstance(cms);
+        log.readyUnchecked();
+
+        // Records the thread that executes the append/wait continuation. The 
filter fires inside
+        // log.append(...) - the first thing the continuation does - and drops 
the entry so no real
+        // processing is required.
+        AtomicReference<Thread> continuationThread = new AtomicReference<>();
+        Predicate<Entry> captureThenDrop = entry -> {
+            continuationThread.compareAndSet(null, Thread.currentThread());
+            return true;
+        };
+
+        ExecutorService responder = Executors.newSingleThreadExecutor(r -> new 
Thread(r, "test-fetch-responder"));
+        AtomicReference<Thread> responderThread = new AtomicReference<>();
+        CountDownLatch delivered = new CountDownLatch(1);
+        AtomicBoolean intercepted = new AtomicBoolean(false);
+
+        // Intercept the outbound TCM_FETCH_PEER_LOG_REQ. Complete the request 
promise from the
+        // responder thread (mimicking the messaging response callback) and 
block the caller here
+        // until that has happened, so the promise is already done when the 
caller returns from the
+        // send. The message is always dropped (return false) so nothing is 
put on the wire.
+        OutboundSink.Filter interceptor = (message, to, type) -> {
+            if (message.verb() != Verb.TCM_FETCH_PEER_LOG_REQ)
+                return true;
+
+            if (intercepted.compareAndSet(false, true))
+            {
+                long id = message.id();
+                Message<?> request = message;
+                responder.execute(() -> {
+                    responderThread.set(Thread.currentThread());
+                    LogState logState = LogState.make(new 
ClusterMetadata(Murmur3Partitioner.instance));
+                    Message<LogState> response = 
request.responseWith(logState);
+                    MessagingService.instance().callbacks.removeAndRespond(id, 
to, response);
+                    delivered.countDown();
+                });
+                assertTrue("responder did not deliver in time",
+                           Uninterruptibles.awaitUninterruptibly(delivered, 
30, TimeUnit.SECONDS));
+            }
+            return false;
+        };
+        MessagingService.instance().outboundSink.add(interceptor);
+
+        try
+        {
+            log.addFilter(captureThenDrop);
+            Thread callerThread = Thread.currentThread();
+
+            PeerLogFetcher fetcher = new PeerLogFetcher(log);
+            Future<ClusterMetadata> result = fetcher.asyncFetchLog(REMOTE, 
Epoch.FIRST);
+            // The continuation has run by the time asyncFetchLog returns 
(inline on the caller for
+            // the buggy code, on the responder thread otherwise). Wait for 
the future to settle -
+            // it completes exceptionally here since the fetched epoch never 
reaches FIRST, which is
+            // irrelevant: we only care which thread ran the continuation.
+            result.awaitUninterruptibly(30, TimeUnit.SECONDS);
+
+            Thread ran = continuationThread.get();
+            assertNotNull("The append/wait continuation never ran", ran);
+            assertNotSame("CASSANDRA-21384: the peer-log append/wait 
continuation must not run on the " +
+                          "caller (e.g. GossipStage) thread, otherwise it can 
deadlock with GlobalLogFollower",
+                          callerThread, ran);
+            assertSame("The continuation should run on the messaging response 
thread",
+                       responderThread.get(), ran);
+        }
+        finally
+        {
+            MessagingService.instance().outboundSink.remove(interceptor);
+            responder.shutdownNow();
+            log.close();
+            ClusterMetadataService.unsetInstance();
+        }
+    }
+
+    /**
+     * Regression test for CASSANDRA-21384 that reproduces the *actual* 
circular wait from the
+     * ticket end-to-end (not just the thread on which the continuation runs), 
and verifies it with
+     * a thread dump.
+     *
+     * It runs the peer-log fetch on the real, single-threaded {@link 
Stage#GOSSIP} and registers a
+     * post-commit {@link ChangeListener} that - like the production listeners 
- calls
+     * {@link Gossiper#runInGossipStageBlocking} while an epoch is enacted. On 
the pre-fix code the
+     * fetch's append/wait continuation runs inline on the GossipStage thread 
and blocks it, so:
+     *
+     * <pre>
+     *   GossipStage       : blocked in PeerLogFetcher -> 
LocalLog.waitForHighestConsecutive()
+     *                       (waiting for the log to advance)
+     *   GlobalLogFollower : enacting the epoch -> ChangeListener -> 
Gossiper.runInGossipStageBlocking()
+     *                       (waiting for the single GossipStage thread, which 
is stuck above)
+     * </pre>
+     *
+     * The test asserts the fetch returns within a timeout (i.e. no deadlock). 
If it does deadlock
+     * it captures a full thread dump and asserts the two stacks are exactly 
that cycle before
+     * failing. On the fixed code the continuation runs on the messaging 
thread, GossipStage returns
+     * from the fetch immediately, and the follower's task is serviced - the 
test passes.
+     */
+    @Test
+    public void doesNotDeadlockBetweenGossipStageAndLogFollower() throws 
Exception
+    {
+        LocalLog log = LocalLog.logSpec()
+                               .async()
+                               .withInitialState(new 
ClusterMetadata(Murmur3Partitioner.instance))
+                               .createLog();
+        ClusterMetadataService cms = new ClusterMetadataService(new 
UniformRangePlacement(),
+                                                                
MetadataSnapshots.NO_OP,
+                                                                log,
+                                                                new 
AtomicLongBackedProcessor(log),
+                                                                
Commit.Replicator.NO_OP,
+                                                                false);
+        ClusterMetadataService.setInstance(cms);
+        log.readyUnchecked();
+
+        // Models the production post-commit listeners (e.g. 
LegacyStateListener) which block on the
+        // gossip stage while the follower enacts an epoch. Only armed for the 
fetch under test.
+        AtomicBoolean armed = new AtomicBoolean(false);
+        log.addListener(new ChangeListener()
+        {
+            @Override
+            public void notifyPostCommit(ClusterMetadata prev, ClusterMetadata 
next, boolean fromSnapshot)
+            {
+                if (armed.get())
+                    Gossiper.runInGossipStageBlocking(() -> {});
+            }
+        });
+
+        // Releases the caller from the intercepted send once the request 
promise has been completed
+        // (buggy path) or the continuation has begun appending on the 
responder thread (fixed path).
+        // The latter frees the GossipStage thread before the follower needs 
it, so the fixed code
+        // does not deadlock.
+        CountDownLatch releaseSend = new CountDownLatch(1);
+        log.addFilter(entry -> {
+            releaseSend.countDown();
+            return false; // keep the entry so the follower enacts it and 
fires the listener
+        });
+
+        ExecutorService responder = Executors.newSingleThreadExecutor(r -> new 
Thread(r, "test-fetch-responder"));
+
+        // Complete the request promise from the responder thread (mimicking 
the messaging response
+        // callback), holding the caller inside the send until that has 
happened - so the buggy
+        // code's map() sees an already-completed promise and runs the 
continuation inline on the
+        // GossipStage thread. The message is always dropped so nothing hits 
the network.
+        OutboundSink.Filter interceptor = (message, to, type) -> {
+            if (message.verb() != Verb.TCM_FETCH_PEER_LOG_REQ)
+                return true;
+
+            long id = message.id();
+            Message<?> request = message;
+            responder.execute(() -> {
+                // A snapshot at FIRST that the follower will enact, advancing 
the epoch and firing
+                // the post-commit listener above.
+                LogState logState = LogState.make(new 
ClusterMetadata(Murmur3Partitioner.instance).forceEpoch(Epoch.FIRST));
+                Message<LogState> response = request.responseWith(logState);
+                MessagingService.instance().callbacks.removeAndRespond(id, to, 
response);
+                releaseSend.countDown();
+            });
+            Uninterruptibles.awaitUninterruptibly(releaseSend, 30, 
TimeUnit.SECONDS);
+            return false;
+        };
+        MessagingService.instance().outboundSink.add(interceptor);
+
+        AtomicReference<Future<ClusterMetadata>> fetchFuture = new 
AtomicReference<>();
+        CountDownLatch fetchReturned = new CountDownLatch(1);
+        try
+        {
+            PeerLogFetcher fetcher = new PeerLogFetcher(log);
+            armed.set(true);
+            // Run the fetch on the real, single-threaded GossipStage, exactly 
as the gossip path does.
+            Stage.GOSSIP.execute(() -> {
+                try
+                {
+                    fetchFuture.set(fetcher.asyncFetchLog(REMOTE, 
Epoch.FIRST));
+                }
+                finally
+                {
+                    fetchReturned.countDown();
+                }
+            });
+
+            if (!fetchReturned.await(20, TimeUnit.SECONDS))
+            {
+                // GossipStage never returned from the fetch: the deadlock. 
Prove it is exactly the
+                // GlobalLogFollower <-> GossipStage cycle, then fail with the 
dump attached.
+                String dump = fullThreadDump();
+                StackTraceElement[] gossip = stackOf("GossipStage");
+                StackTraceElement[] follower = stackOf("GlobalLogFollower");
+
+                // break the deadlock so we don't leak the shared GossipStage 
thread
+                interruptThreads("GossipStage");
+
+                assertTrue("Expected GossipStage to be blocked advancing the 
log inside the peer-log " +
+                           "fetch continuation, but was:\n" + dump,
+                           stackContains(gossip, "PeerLogFetcher") && 
stackContains(gossip, "LocalLog"));
+                assertTrue("Expected GlobalLogFollower to be blocked handing 
off to the gossip stage, " +
+                           "but was:\n" + dump,
+                           stackContains(follower, 
"runInGossipStageBlocking"));
+                fail("CASSANDRA-21384: reproduced deadlock between 
GlobalLogFollower and GossipStage:\n" + dump);
+            }
+
+            // No deadlock: the fetch returned on the gossip stage. Let it 
settle to be sure the
+            // whole chain (responder -> follower -> gossip stage) completed.
+            Future<ClusterMetadata> f = fetchFuture.get();
+            assertNotNull("peer-log fetch future was not captured", f);
+            f.awaitUninterruptibly(20, TimeUnit.SECONDS);
+        }
+        finally
+        {
+            armed.set(false);
+            MessagingService.instance().outboundSink.remove(interceptor);
+            responder.shutdownNow();
+            log.close();
+            ClusterMetadataService.unsetInstance();
+        }
+    }
+
+    private static StackTraceElement[] stackOf(String namePrefix)
+    {
+        for (Map.Entry<Thread, StackTraceElement[]> e : 
Thread.getAllStackTraces().entrySet())
+            if (e.getKey().getName().startsWith(namePrefix))
+                return e.getValue();
+        return null;
+    }
+
+    private static boolean stackContains(StackTraceElement[] stack, String 
needle)
+    {
+        if (stack == null)
+            return false;
+        for (StackTraceElement frame : stack)
+            if (frame.toString().contains(needle))
+                return true;
+        return false;
+    }
+
+    private static void interruptThreads(String namePrefix)
+    {
+        for (Thread t : Thread.getAllStackTraces().keySet())
+            if (t.getName().startsWith(namePrefix))
+                t.interrupt();
+    }
+
+    private static String fullThreadDump()
+    {
+        StringBuilder sb = new StringBuilder();
+        for (Map.Entry<Thread, StackTraceElement[]> e : 
Thread.getAllStackTraces().entrySet())
+        {
+            Thread t = e.getKey();
+            sb.append('"').append(t.getName()).append("\" 
").append(t.getState()).append('\n');
+            for (StackTraceElement frame : e.getValue())
+                sb.append("\tat ").append(frame).append('\n');
+            sb.append('\n');
+        }
+        return sb.toString();
+    }
+}


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to