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 b1f30e94f5 Move long running TCM operations to a longer timout
b1f30e94f5 is described below

commit b1f30e94f523f8481d7695da863b8389b217449c
Author: Jon Meredith <[email protected]>
AuthorDate: Mon Mar 30 17:20:58 2026 -0600

    Move long running TCM operations to a longer timout
    
    Replaces the fixed-retry commit loop with deadline-based exponential
    backoff for long running CMS commit operations (cms_commit_timeout=1h, 
5s-60s jitter)
    to allow heavily contended CMS nodes time to commit transforms.
    
    Patch by Jon Meredith and Sam Tunnicliffe; reviewed by Jon Meredith and
    Sam Tunnicliffe for CASSANDRA-21453
    
    Co-authored-by: Sam Tunnicliffe <[email protected]>
---
 CHANGES.txt                                        |   1 +
 src/java/org/apache/cassandra/config/Config.java   |  45 ++++
 .../cassandra/config/DatabaseDescriptor.java       |  92 ++++++++
 src/java/org/apache/cassandra/net/Verb.java        |   1 +
 .../apache/cassandra/service/RetryStrategy.java    |   2 +
 .../apache/cassandra/service/StorageService.java   |  13 --
 .../cassandra/service/StorageServiceMBean.java     |  12 -
 .../cassandra/tcm/AbstractLocalProcessor.java      |  44 +++-
 .../org/apache/cassandra/tcm/CMSOperations.java    |  62 ++++++
 .../apache/cassandra/tcm/CMSOperationsMBean.java   |  26 ++-
 .../org/apache/cassandra/tcm/ClusterMetadata.java  |  13 ++
 .../cassandra/tcm/ClusterMetadataService.java      |  66 +++++-
 src/java/org/apache/cassandra/tcm/Commit.java      |  27 ++-
 .../apache/cassandra/tcm/PaxosBackedProcessor.java |   9 +
 src/java/org/apache/cassandra/tcm/Processor.java   |  40 ----
 .../org/apache/cassandra/tcm/RemoteProcessor.java  |  22 +-
 src/java/org/apache/cassandra/tcm/Retry.java       |  23 ++
 .../cassandra/tcm/sequences/BootstrapAndJoin.java  |   4 +-
 .../tcm/sequences/BootstrapAndReplace.java         |   4 +-
 .../org/apache/cassandra/tcm/sequences/Move.java   |   3 +
 .../tcm/sequences/UnbootstrapAndLeave.java         |   4 +-
 .../log/InProgressSequenceCoordinationTest.java    |   7 +-
 .../test/repair/AutoRepairSchedulerTest.java       |   5 +-
 .../distributed/test/tcm/LostCommitReqResTest.java |  33 ++-
 .../RemoteCmsCommitTimeoutDifferentiationTest.java | 240 ++++++++++++++++++++
 .../cassandra/tcm/CommitTimeoutPathsTest.java      | 242 +++++++++++++++++++++
 test/unit/org/apache/cassandra/tcm/RetryTest.java  | 111 ++++++++--
 27 files changed, 1031 insertions(+), 120 deletions(-)

diff --git a/CHANGES.txt b/CHANGES.txt
index f208e0db29..c9b14d569d 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -1,4 +1,5 @@
 6.0-alpha2
+ * Move long running TCM operations to a longer timout (CASSANDRA-21453)
  * Offline nodetool commands should not print network options in help 
(CASSANDRA-20876)
  * Defer creation of the system_cluster_metadata keyspace until CMS 
initialization (CASSANDRA-21477)
  * Support direct I/O for background SSTable writes (CASSANDRA-21134)
diff --git a/src/java/org/apache/cassandra/config/Config.java 
b/src/java/org/apache/cassandra/config/Config.java
index 82ee064802..5a45073ac5 100644
--- a/src/java/org/apache/cassandra/config/Config.java
+++ b/src/java/org/apache/cassandra/config/Config.java
@@ -183,6 +183,18 @@ public class Config
 
     public volatile DurationSpec.LongMillisecondsBound 
stream_transfer_task_timeout = new DurationSpec.LongMillisecondsBound("12h");
 
+    /**
+     * Timeout for the per-message window when a non-CMS node sends a 
TCM_COMMIT_REQ to a CMS node.
+     * The CMS will attempt Paxos retries for (cms_await_timeout - 
write_request_timeout) before
+     * returning an explicit failure, giving the sender time to reschedule 
before its message callback fires.
+     *
+     * WARNING: cms_await_timeout should be substantially larger than 
write_request_timeout.
+     * A single Paxos CAS attempt can take up to (cas_contention_timeout + 
write_request_timeout) to
+     * complete. If cms_await_timeout is set close to write_request_timeout 
the deadline reduction has
+     * no effect and many concurrent CMS operations timing out at the same 
time may create a thundering herd,
+     * all retrying against the CMS.
+     * Default 2 minutes to match "nodetool cms initialize".
+     */
     public volatile DurationSpec.LongMillisecondsBound cms_await_timeout = new 
DurationSpec.LongMillisecondsBound("120000ms");
     public volatile int cms_default_max_retries = 10;
     @Deprecated(since="6.0")
@@ -192,6 +204,39 @@ public class Config
     public String cms_retry_delay = "50ms*attempts <= 500ms ... 100ms*attempts 
<= 1s,retries=10";
 
     public volatile CMSCommitMemberPreferencePolicy 
cms_commit_member_preference_policy = CMSCommitMemberPreferencePolicy.random;
+    /**
+     * Controls the sender-side retry behavior for CMS commits (topology 
changes,
+     * CMS reconfiguration, node registration — everything except STARTUP and 
schema DDL
+     * with an explicit client deadline).
+     *
+     * cms_commit_timeout: Overall deadline for the commit to succeed. The 
sender retries
+     *   with exponential backoff until this deadline expires. Each retry 
sends a fresh
+     *   TCM_COMMIT_REQ to a (possibly different) CMS node. Set this longer 
than the
+     *   expected total time for all concurrent operations to drain through 
the Paxos log.
+     *
+     * cms_commit_retry_initial_delay: Base delay for Full Jitter exponential 
backoff.
+     *   Actual delay per retry = uniform_random(0, min(max_delay, 
initial_delay * 2^attempt)).
+     *   Higher values reduce Paxos contention at the cost of slower progress 
when the log
+     *   is lightly loaded. 5s is a good default — it spaces retries enough to 
avoid
+     *   thundering herd while still making progress within minutes.
+     *
+     * cms_commit_retry_max_delay: Cap on the exponential backoff. Once 
2^attempt growth
+     *   exceeds this, all subsequent retries draw from uniform_random(0, 
max_delay).
+     *   60s keeps retries frequent enough that a freed Paxos slot is filled 
within
+     *   ~30s on average, while avoiding retry storms.
+     *
+     * When to change:
+     *   - If bulk topology ops complete but take too long: reduce max_delay 
(e.g. 30s)
+     *     to retry more aggressively. Monitor CommitRetries rate for 
contention impact.
+     *   - If bulk topology ops fail (timeout): increase cms_commit_timeout.
+     *   - If Paxos contention is extremely high (>100 concurrent commits): 
increase
+     *     initial_delay to 10-15s to spread the retry wavefront.
+     *   - All three are hot-settable via JMX without restart.
+     */
+    public volatile DurationSpec.LongMillisecondsBound cms_commit_timeout = 
new DurationSpec.LongMillisecondsBound("1h");
+    public volatile DurationSpec.LongMillisecondsBound 
cms_commit_retry_initial_delay = new DurationSpec.LongMillisecondsBound("5s");
+    public volatile DurationSpec.LongMillisecondsBound 
cms_commit_retry_max_delay = new DurationSpec.LongMillisecondsBound("60s");
+
     public volatile int epoch_aware_debounce_inflight_tracker_max_size = 100;
 
     /**
diff --git a/src/java/org/apache/cassandra/config/DatabaseDescriptor.java 
b/src/java/org/apache/cassandra/config/DatabaseDescriptor.java
index 1f818a8276..981604736c 100644
--- a/src/java/org/apache/cassandra/config/DatabaseDescriptor.java
+++ b/src/java/org/apache/cassandra/config/DatabaseDescriptor.java
@@ -124,8 +124,10 @@ import org.apache.cassandra.security.JREProvider;
 import org.apache.cassandra.security.SSLFactory;
 import org.apache.cassandra.service.CacheService.CacheType;
 import org.apache.cassandra.service.FileSystemOwnershipCheck;
+import org.apache.cassandra.service.RetryStrategy;
 import org.apache.cassandra.service.StartupChecks;
 import org.apache.cassandra.service.StorageService;
+import org.apache.cassandra.service.TimeoutStrategy;
 import org.apache.cassandra.service.accord.AccordService;
 import org.apache.cassandra.service.accord.api.AccordWaitStrategies;
 import org.apache.cassandra.service.consensus.TransactionalMode;
@@ -270,6 +272,15 @@ public class DatabaseDescriptor
 
     public static volatile boolean allowUnlimitedConcurrentValidations = 
ALLOW_UNLIMITED_CONCURRENT_VALIDATIONS.getBoolean();
 
+    /**
+     * RetryStrategy which provides exponential backoff with full jitter, for 
use by both CMS and non-CMS members
+     * when submitting a Commit request. The range and increments of the 
backoff times are defined by
+     * conf.cms_commit_retry_initial_delay and 
conf.cms_commit_retry_max_delay. Both are hot properties and so
+     * changing either one causes this retry strategy to be reconstructed.
+     */
+    private static volatile RetryStrategy cms_commit_retry_strategy;
+
+
     /**
      * The configuration for guardrails.
      */
@@ -580,6 +591,8 @@ public class DatabaseDescriptor
 
         applyAccord();
 
+        applyCMS();
+
         applyStartupChecks();
     }
 
@@ -1388,6 +1401,25 @@ public class DatabaseDescriptor
         AccordService.applyProtocolModifiers(getAccord());
     }
 
+    private static void applyCMS()
+    {
+        try
+        {
+            long initialDelayMs = 
conf.cms_commit_retry_initial_delay.to(TimeUnit.MILLISECONDS);
+            long maxDelayMs = 
conf.cms_commit_retry_max_delay.to(TimeUnit.MILLISECONDS);
+            // range of backoff wait time starts at 0ms backing off 
exponentially at initialDelayMs * 2^attempts
+            String spec = String.format("0ms ... %dms * 2^attempts <= %dms", 
initialDelayMs, maxDelayMs);
+            logger.debug("Initializing cms_commit_retry_strategy from spec: " 
+ spec);
+            cms_commit_retry_strategy = RetryStrategy.parse(spec,
+                                                            
TimeoutStrategy.LatencySourceFactory.none(),
+                                                            
RetryStrategy.randomizers.uniform());
+        }
+        catch (Exception e)
+        {
+            throw new ConfigurationException("Invalid configuration for 
cms_commit_retry_strategy. " + e.getMessage(), e);
+        }
+    }
+
     public static StartupChecksConfiguration getStartupChecksConfiguration()
     {
         return startupChecksConfiguration;
@@ -6167,6 +6199,15 @@ public class DatabaseDescriptor
         return conf.cms_commit_member_preference_policy;
     }
 
+    public static void setCmsAwaitTimeout(long timeoutInMillis)
+    {
+        if (timeoutInMillis != 
conf.cms_await_timeout.to(TimeUnit.MILLISECONDS))
+        {
+            logger.info("Setting cms_await_timeout to {}ms", timeoutInMillis);
+            conf.cms_await_timeout = new 
DurationSpec.LongMillisecondsBound(timeoutInMillis);
+        }
+    }
+
     public static void 
setCmsCommitMemberPreferencePolicy(Config.CMSCommitMemberPreferencePolicy 
policy)
     {
         conf.cms_commit_member_preference_policy = policy;
@@ -6177,6 +6218,57 @@ public class DatabaseDescriptor
         
setCmsCommitMemberPreferencePolicy(Config.CMSCommitMemberPreferencePolicy.valueOf(toLowerCaseLocalized(policy)));
     }
 
+    public static DurationSpec getCmsCommitTimeout()
+    {
+        return conf.cms_commit_timeout;
+    }
+
+    public static void setCmsCommitTimeout(long timeoutInMillis)
+    {
+        if (timeoutInMillis != 
conf.cms_commit_timeout.to(TimeUnit.MILLISECONDS))
+        {
+            logger.info("Setting cms_commit_timeout to {}ms", timeoutInMillis);
+            conf.cms_commit_timeout = new 
DurationSpec.LongMillisecondsBound(timeoutInMillis);
+        }
+    }
+
+
+
+    public static DurationSpec getCmsCommitRetryInitialDelay()
+    {
+        return conf.cms_commit_retry_initial_delay;
+    }
+
+    public static void setCmsCommitRetryInitialDelay(long delayInMillis)
+    {
+        if (delayInMillis != 
conf.cms_commit_retry_initial_delay.to(TimeUnit.MILLISECONDS))
+        {
+            logger.info("Setting cms_commit_retry_initial_delay to {}ms", 
delayInMillis);
+            conf.cms_commit_retry_initial_delay = new 
DurationSpec.LongMillisecondsBound(delayInMillis);
+            applyCMS();
+        }
+    }
+
+    public static DurationSpec getCmsCommitRetryMaxDelay()
+    {
+        return conf.cms_commit_retry_max_delay;
+    }
+
+    public static void setCmsCommitRetryMaxDelay(long delayInMillis)
+    {
+        if (delayInMillis != 
conf.cms_commit_retry_max_delay.to(TimeUnit.MILLISECONDS))
+        {
+            logger.info("Setting cms_commit_retry_max_delay to {}ms", 
delayInMillis);
+            conf.cms_commit_retry_max_delay = new 
DurationSpec.LongMillisecondsBound(delayInMillis);
+            applyCMS();
+        }
+    }
+
+    public static RetryStrategy getCmsCommitRetryStrategy()
+    {
+        return cms_commit_retry_strategy;
+    }
+
     public static int getEpochAwareDebounceInFlightTrackerMaxSize()
     {
         return conf.epoch_aware_debounce_inflight_tracker_max_size;
diff --git a/src/java/org/apache/cassandra/net/Verb.java 
b/src/java/org/apache/cassandra/net/Verb.java
index 1bb3b7a890..f0721fe997 100644
--- a/src/java/org/apache/cassandra/net/Verb.java
+++ b/src/java/org/apache/cassandra/net/Verb.java
@@ -301,6 +301,7 @@ public enum Verb
 
     // transactional cluster metadata
     TCM_COMMIT_RSP         (801, P0, rpcTimeout,      INTERNAL_METADATA,    
MessageSerializers::commitResultSerializer,         RESPONSE_HANDLER            
                     ),
+    // message timeout is overridden in RemoteProcessor to cmsAwaitTimeout for 
non-client facing commit requests
     TCM_COMMIT_REQ         (802, P0, rpcTimeout,      INTERNAL_METADATA,    
MessageSerializers::commitSerializer,               () -> 
commitRequestHandler(),               TCM_COMMIT_RSP         ),
     TCM_FETCH_CMS_LOG_RSP  (803, P0, shortTimeout,    FETCH_METADATA,       
MessageSerializers::logStateSerializer,             RESPONSE_HANDLER            
                     ),
     TCM_FETCH_CMS_LOG_REQ  (804, P0, rpcTimeout,      FETCH_METADATA,       () 
-> FetchCMSLog.serializer,                       () -> 
fetchLogRequestHandler(),             TCM_FETCH_CMS_LOG_RSP  ),
diff --git a/src/java/org/apache/cassandra/service/RetryStrategy.java 
b/src/java/org/apache/cassandra/service/RetryStrategy.java
index e11c507bf5..19f85a2c5b 100644
--- a/src/java/org/apache/cassandra/service/RetryStrategy.java
+++ b/src/java/org/apache/cassandra/service/RetryStrategy.java
@@ -241,6 +241,8 @@ public class RetryStrategy implements WaitStrategy
             if (min > maxMinMicros)
                 min = maxMinMicros;
             long max = this.max.getMicros(attempt);
+            if (max > maxMaxMicros)
+                max = maxMaxMicros;
             result = min >= max ? min : waitRandomizer.wait(min, max, attempt);
         }
 
diff --git a/src/java/org/apache/cassandra/service/StorageService.java 
b/src/java/org/apache/cassandra/service/StorageService.java
index 2ad2a5bf27..53f0986e0c 100644
--- a/src/java/org/apache/cassandra/service/StorageService.java
+++ b/src/java/org/apache/cassandra/service/StorageService.java
@@ -1761,19 +1761,6 @@ public class StorageService extends 
NotificationBroadcasterSupport implements IE
         return pojoMapToString(snapshotAsMap, format);
     }
 
-    @Override
-    public String getCmsCommitMemberPreferencePolicy()
-    {
-        return DatabaseDescriptor.getCmsCommitMemberPreferencePolicy().name();
-    }
-
-    @Override
-    public void setCmsCommitMemberPreferencePolicy(String policy)
-    {
-        DatabaseDescriptor.setCmsCommitMemberPreferencePolicy(policy);
-        logger.info("Set cms_commit_member_preference_policy to {}", policy);
-    }
-
     public Map<String,List<Integer>> getConcurrency(List<String> stageNames)
     {
         Stream<Stage> stageStream = stageNames.isEmpty() ? 
stream(Stage.values()) : stageNames.stream().map(Stage::fromPoolName);
diff --git a/src/java/org/apache/cassandra/service/StorageServiceMBean.java 
b/src/java/org/apache/cassandra/service/StorageServiceMBean.java
index 34db8b4180..b9f47b3b2d 100644
--- a/src/java/org/apache/cassandra/service/StorageServiceMBean.java
+++ b/src/java/org/apache/cassandra/service/StorageServiceMBean.java
@@ -1170,18 +1170,6 @@ public interface StorageServiceMBean extends 
NotificationEmitter
     List<String> getAccordManagedKeyspaces();
     List<String> getAccordManagedTables();
 
-    /** Get the CMS commit member preference policy
-     *
-     * @return how to choose the cms member preference order for commits
-     */
-    public String getCmsCommitMemberPreferencePolicy();
-
-    /** Update the CMS commit member preference policy
-     *
-     * @param policy see Config.CMSCommitMemberPreferencePolicy
-     */
-    public void setCmsCommitMemberPreferencePolicy(String policy);
-
     /** Gets the concurrency settings for processing stages*/
     static class StageConcurrency implements Serializable
     {
diff --git a/src/java/org/apache/cassandra/tcm/AbstractLocalProcessor.java 
b/src/java/org/apache/cassandra/tcm/AbstractLocalProcessor.java
index a245377f91..e545e726ed 100644
--- a/src/java/org/apache/cassandra/tcm/AbstractLocalProcessor.java
+++ b/src/java/org/apache/cassandra/tcm/AbstractLocalProcessor.java
@@ -23,14 +23,17 @@ import java.util.function.Supplier;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
+import org.apache.cassandra.metrics.TCMMetrics;
 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.utils.FBUtilities;
 import org.apache.cassandra.utils.JVMStabilityInspector;
 
+import static java.util.concurrent.TimeUnit.NANOSECONDS;
 import static org.apache.cassandra.exceptions.ExceptionCode.INVALID;
 import static org.apache.cassandra.exceptions.ExceptionCode.SERVER_ERROR;
+import static org.apache.cassandra.utils.Clock.Global.nanoTime;
 
 public abstract class AbstractLocalProcessor implements Processor
 {
@@ -50,6 +53,9 @@ public abstract class AbstractLocalProcessor implements 
Processor
     @Override
     public final Commit.Result commit(Entry.Id entryId, Transformation 
transform, final Epoch lastKnown, Retry retryPolicy)
     {
+        String transformStr = transform.toString(); // convert once as 
idempotent and used in multiple logs
+        logger.debug("Starting local commit of {} with policy {}", 
transformStr, retryPolicy);
+        long commitStart = nanoTime();
         while (!retryPolicy.hasExpired())
         {
             ClusterMetadata previous = log.waitForHighestConsecutive();
@@ -66,7 +72,7 @@ public abstract class AbstractLocalProcessor implements 
Processor
             Transformation.Result result;
             if (!transform.eligibleToCommit(previous))
             {
-                result = new Transformation.Rejected(INVALID, "Transformation 
rejected, can't commit " + transform +
+                result = new Transformation.Rejected(INVALID, "Transformation 
rejected, can't commit " + transformStr +
                                                               " it not 
supported with cluster common serialization version " + 
previous.directory.commonSerializationVersion +
                                                               " and min/max 
serialization versions " + previous.directory.clusterMinVersion + "/" + 
previous.directory.clusterMaxVersion);
             }
@@ -79,16 +85,24 @@ public abstract class AbstractLocalProcessor implements 
Processor
             // Just try to catch up to the latest distributed state.
             if (result.isRejected())
             {
-                ClusterMetadata replayed = fetchLogAndWait(null, retryPolicy);
+                // Use a dedicated retry policy here as the one for the commit 
itself may not be appropriate.
+                // It uses the wrong metric and for STARTUP transformations 
will retry indefinitely, which is not
+                // what we want here.
+                Retry fetchLogRetry = Retry.until(retryPolicy.deadlineNanos, 
TCMMetrics.instance.fetchLogRetries);
+                ClusterMetadata replayed = fetchLogAndWait(null, 
fetchLogRetry);
 
                 // Retry if replay has changed the epoch, return rejection 
otherwise.
                 if (!replayed.epoch.isAfter(previous.epoch))
                 {
+                    logger.info("No epoch change after fetched latest log 
entries, returning rejection response");
                     return maybeFailure(entryId,
                                         lastKnown,
                                         () -> 
Commit.Result.rejected(result.rejected().code, result.rejected().reason, 
toLogState(lastKnown)));
                 }
 
+                logger.info("Fetched latest log entries after transformation 
rejection, re-entering " +
+                            "commit retry loop with {}ms remaining until 
deadline",
+                            
NANOSECONDS.toMillis(retryPolicy.remainingNanos()));
                 continue;
             }
 
@@ -96,12 +110,18 @@ public abstract class AbstractLocalProcessor implements 
Processor
             {
                 Epoch nextEpoch = result.success().metadata.epoch;
                 // If metadata applies, try committing it to the log
+                long casStart = nanoTime();
                 boolean applied = tryCommitOne(entryId, transform, 
previous.epoch, nextEpoch);
+                long casElapsedUs = NANOSECONDS.toMicros(nanoTime() - 
casStart);
+                logger.debug("tryCommitOne for {} epoch {}->{}: applied={}, 
took {}us",
+                             transform.kind(), previous.epoch, nextEpoch, 
applied, casElapsedUs);
 
                 // Application here semantially means "succeeded in committing 
to the distributed log".
                 if (applied)
                 {
-                    logger.info("Committed {}. New epoch is {}", transform, 
nextEpoch);
+                    logger.info("Committed {}. New epoch is {}. Took {} 
attempts in {}us total.",
+                                transformStr, nextEpoch, 
retryPolicy.attempts(),
+                                NANOSECONDS.toMicros(nanoTime() - 
commitStart));
                     log.append(new Entry(entryId, nextEpoch, new 
Transformation.Executed(transform, result)));
                     log.awaitAtLeast(nextEpoch);
 
@@ -112,8 +132,16 @@ public abstract class AbstractLocalProcessor implements 
Processor
                 {
                     if (!retryPolicy.maybeSleep())
                         break;
+
+                    logger.info("Backed off after failure to commit to log, 
fetching latest log entries before retry");
                     // TODO: could also add epoch from mis-application from 
[applied].
-                    fetchLogAndWait(null, retryPolicy);
+                    // Use a dedicated retry policy here as the one for the 
commit itself may not be appropriate.
+                    // It uses the wrong metric and for STARTUP 
transformations will retry indefinitely, which is not
+                    // what we want here.
+                    Retry fetchLogRetry = 
Retry.until(retryPolicy.deadlineNanos, TCMMetrics.instance.fetchLogRetries);
+                    fetchLogAndWait(null, fetchLogRetry);
+                    logger.info("Fetched latest log entries, re-entering 
commit retry loop with {}ms remaining until deadline",
+                                
NANOSECONDS.toMillis(retryPolicy.remainingNanos()));
                 }
             }
             catch (Throwable e)
@@ -124,8 +152,12 @@ public abstract class AbstractLocalProcessor implements 
Processor
                     break;
             }
         }
-        return Commit.Result.failed(SERVER_ERROR,
-                                    String.format("Could not perform commit; 
policy %s gave up", retryPolicy));
+        long remainingMillis = 
NANOSECONDS.toMillis(retryPolicy.remainingNanos());
+        String failureMsg = String.format("Could not perform commit after %d 
attempts. Time remaining: %dms",
+                                          retryPolicy.attempts(), 
remainingMillis);
+        logger.debug("Commit {} failed in {}us total. {}",
+                     transformStr, NANOSECONDS.toMicros(nanoTime() - 
commitStart), failureMsg);
+        return Commit.Result.failed(SERVER_ERROR, failureMsg);
     }
 
     public Commit.Result maybeFailure(Entry.Id entryId, Epoch lastKnown, 
Supplier<Commit.Result.Failure> orElse)
diff --git a/src/java/org/apache/cassandra/tcm/CMSOperations.java 
b/src/java/org/apache/cassandra/tcm/CMSOperations.java
index d2b18ce361..460513fbf5 100644
--- a/src/java/org/apache/cassandra/tcm/CMSOperations.java
+++ b/src/java/org/apache/cassandra/tcm/CMSOperations.java
@@ -27,6 +27,8 @@ import java.util.List;
 import java.util.Map;
 import java.util.stream.Collectors;
 
+import com.google.common.base.Preconditions;
+
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -49,6 +51,7 @@ import 
org.apache.cassandra.tcm.transformations.cms.AdvanceCMSReconfiguration;
 import org.apache.cassandra.utils.FBUtilities;
 import org.apache.cassandra.utils.MBeanWrapper;
 
+import static java.util.concurrent.TimeUnit.MILLISECONDS;
 import static 
org.apache.cassandra.tcm.transformations.cms.PrepareCMSReconfiguration.needsReconfiguration;
 
 public class CMSOperations implements CMSOperationsMBean
@@ -80,6 +83,65 @@ public class CMSOperations implements CMSOperationsMBean
         this.cms = cms;
     }
 
+    // TCM CMS await timeout
+    public long getCmsAwaitTimeoutMillis()
+    {
+        return DatabaseDescriptor.getCmsAwaitTimeout().to(MILLISECONDS);
+    }
+
+    public void setCmsAwaitTimeoutMillis(long timeoutInMillis)
+    {
+        Preconditions.checkState(timeoutInMillis > 0);
+        DatabaseDescriptor.setCmsAwaitTimeout(timeoutInMillis);
+    }
+
+    // CMS commit timeout with exponential backoff
+    public long getCmsCommitTimeoutMillis()
+    {
+        return DatabaseDescriptor.getCmsCommitTimeout().to(MILLISECONDS);
+    }
+
+    public void setCmsCommitTimeoutMillis(long timeoutInMillis)
+    {
+        Preconditions.checkState(timeoutInMillis > 0);
+        DatabaseDescriptor.setCmsCommitTimeout(timeoutInMillis);
+    }
+
+    public long getCmsCommitRetryInitialDelayMillis()
+    {
+        return 
DatabaseDescriptor.getCmsCommitRetryInitialDelay().to(MILLISECONDS);
+    }
+
+    public void setCmsCommitRetryInitialDelayMillis(long delayInMillis)
+    {
+        Preconditions.checkState(delayInMillis > 0);
+        DatabaseDescriptor.setCmsCommitRetryInitialDelay(delayInMillis);
+    }
+
+    public long getCmsCommitRetryMaxDelayMillis()
+    {
+        return DatabaseDescriptor.getCmsCommitRetryMaxDelay().to(MILLISECONDS);
+    }
+
+    public void setCmsCommitRetryMaxDelayMillis(long delayInMillis)
+    {
+        Preconditions.checkState(delayInMillis > 0);
+        DatabaseDescriptor.setCmsCommitRetryMaxDelay(delayInMillis);
+    }
+
+    @Override
+    public String getCmsCommitMemberPreferencePolicy()
+    {
+        return DatabaseDescriptor.getCmsCommitMemberPreferencePolicy().name();
+    }
+
+    @Override
+    public void setCmsCommitMemberPreferencePolicy(String policy)
+    {
+        DatabaseDescriptor.setCmsCommitMemberPreferencePolicy(policy);
+        logger.info("Set cms_commit_member_preference_policy to {}", policy);
+    }
+
     @Override
     public void initializeCMS(List<String> ignoredEndpoints)
     {
diff --git a/src/java/org/apache/cassandra/tcm/CMSOperationsMBean.java 
b/src/java/org/apache/cassandra/tcm/CMSOperationsMBean.java
index efdf28267e..4f53270ab9 100644
--- a/src/java/org/apache/cassandra/tcm/CMSOperationsMBean.java
+++ b/src/java/org/apache/cassandra/tcm/CMSOperationsMBean.java
@@ -24,6 +24,30 @@ import java.util.Map;
 
 public interface CMSOperationsMBean
 {
+    // CMS await timeout
+    public long getCmsAwaitTimeoutMillis();
+    public void setCmsAwaitTimeoutMillis(long timeoutInMillis);
+
+    // CMS commit timeout with exponential backoff
+    public long getCmsCommitTimeoutMillis();
+    public void setCmsCommitTimeoutMillis(long timeoutInMillis);
+    public long getCmsCommitRetryInitialDelayMillis();
+    public void setCmsCommitRetryInitialDelayMillis(long delayInMillis);
+    public long getCmsCommitRetryMaxDelayMillis();
+    public void setCmsCommitRetryMaxDelayMillis(long delayInMillis);
+
+    /** Get the CMS commit member preference policy
+     *
+     * @return how to choose the cms member preference order for commits
+     */
+    public String getCmsCommitMemberPreferencePolicy();
+
+    /** Update the CMS commit member preference policy
+     *
+     * @param policy see Config.CMSCommitMemberPreferencePolicy
+     */
+    public void setCmsCommitMemberPreferencePolicy(String policy);
+
     public void initializeCMS(List<String> ignore);
     public void abortInitialization(String initiator);
     public void resumeReconfigureCms();
@@ -53,4 +77,4 @@ public interface CMSOperationsMBean
 
     public boolean getLegacyStateListenerSyncLocalUpdates();
     public void setLegacyStateListenerSyncLocalUpdates(boolean sync);
-}
+ }
diff --git a/src/java/org/apache/cassandra/tcm/ClusterMetadata.java 
b/src/java/org/apache/cassandra/tcm/ClusterMetadata.java
index 40dcb7fb4c..be6d0bd212 100644
--- a/src/java/org/apache/cassandra/tcm/ClusterMetadata.java
+++ b/src/java/org/apache/cassandra/tcm/ClusterMetadata.java
@@ -62,6 +62,7 @@ import org.apache.cassandra.schema.KeyspaceMetadata;
 import org.apache.cassandra.schema.KeyspaceParams;
 import org.apache.cassandra.schema.Keyspaces;
 import org.apache.cassandra.schema.ReplicationParams;
+import org.apache.cassandra.schema.SchemaConstants;
 import org.apache.cassandra.schema.TableId;
 import org.apache.cassandra.service.accord.topology.AccordFastPath;
 import org.apache.cassandra.service.accord.topology.AccordStaleReplicas;
@@ -221,7 +222,11 @@ public class ClusterMetadata
             return Collections.emptySet();
 
         if (fullCMSEndpoints == null)
+        {
+            if 
(schema.maybeGetKeyspaceMetadata(SchemaConstants.METADATA_KEYSPACE_NAME).isEmpty())
+                return Collections.emptySet();
             this.fullCMSEndpoints = 
ImmutableSet.copyOf(placements.get(ReplicationParams.meta(this)).reads.byEndpoint().keySet());
+        }
         return fullCMSEndpoints;
     }
 
@@ -231,7 +236,11 @@ public class ClusterMetadata
             return Collections.emptySet();
 
         if (fullCMSIds == null)
+        {
+            if 
(schema.maybeGetKeyspaceMetadata(SchemaConstants.METADATA_KEYSPACE_NAME).isEmpty())
+                return Collections.emptySet();
             this.fullCMSIds = 
placements.get(ReplicationParams.meta(this)).reads.byEndpoint().keySet().stream().map(directory::peerId).collect(toImmutableSet());
+        }
         return fullCMSIds;
     }
 
@@ -241,7 +250,11 @@ public class ClusterMetadata
             return EndpointsForRange.empty(MetaStrategy.entireRange);
 
         if (fullCMSReplicas == null)
+        {
+            if 
(schema.maybeGetKeyspaceMetadata(SchemaConstants.METADATA_KEYSPACE_NAME).isEmpty())
+                return EndpointsForRange.empty(MetaStrategy.entireRange);
             fullCMSReplicas = 
placements.get(ReplicationParams.meta(this)).reads.forRange(MetaStrategy.entireRange).get();
+        }
         return fullCMSReplicas;
     }
 
diff --git a/src/java/org/apache/cassandra/tcm/ClusterMetadataService.java 
b/src/java/org/apache/cassandra/tcm/ClusterMetadataService.java
index 6caea0001f..6d1b054bec 100644
--- a/src/java/org/apache/cassandra/tcm/ClusterMetadataService.java
+++ b/src/java/org/apache/cassandra/tcm/ClusterMetadataService.java
@@ -57,6 +57,7 @@ import 
org.apache.cassandra.schema.DistributedMetadataLogKeyspace;
 import org.apache.cassandra.schema.DistributedSchema;
 import org.apache.cassandra.schema.Keyspaces;
 import org.apache.cassandra.schema.ReplicationParams;
+import org.apache.cassandra.service.RetryStrategy;
 import org.apache.cassandra.service.accord.topology.AccordFastPath;
 import org.apache.cassandra.service.accord.topology.AccordStaleReplicas;
 import 
org.apache.cassandra.service.consensus.migration.ConsensusMigrationState;
@@ -562,7 +563,6 @@ public class ClusterMetadataService
 
     /**
      * dumps the cluster metadata at the given epoch, returns path to the 
generated file
-     *
      * if the given Epoch is EMPTY, we dump the current metadata
      *
      * @param epoch dump clustermetadata at this epoch
@@ -637,19 +637,41 @@ public class ClusterMetadataService
 
     /**
      * Attempt to commit the transformation (with retries).
-     *
+     * <p>
      * Since we can not rely on reliability of the network or even the fact 
that the committing node will stay alive
      * for the duration of commit, we have to allow for subsequent discovery 
of the transformation effects, which can
      * be made visible either by replaying the log, or receiving the metadata 
snapshot.
-     *
+     * <p>
      * In other words, there is no reliable way to find out whether _this 
particular_ transformation has been executed
      * while we are allowing replay from snapshot, since even failure response 
from the CMS does not guarantee
      * Paxos re-proposal, which would place the transformation into the log 
during proposal _by some other_ CMS node.
-     *
+     * <p>
      * Protocol does foresee the concept of EntryId that would allow discovery 
of the committed transformations
      * without changes to binary protocol, but this change was left out from 
the initial implementation of TCM.
+     * <p>
+     * TCM Commit Timeouts
+     * <p>
+     * Timeout passed to the TCM Processor
+     *   - STARTUP: retries indefinitely with Jitter(cms_default_retry_backoff 
50ms)
+     *   - SCHEMA_CHANGE: rpc_timeout + Jitter(cms_default_retry_backoff 50ms) 
- fast retry, fail fast for DDL
+     *   - Everything else (LOCAL/CMS & REMOTE/non-CMS): cms_commit_timeout 
(1h) + ExponentialJitter(5s-60s) - full jitter exponential backoff
+     * <p>
+     * The Processor changes behavior based on whether the node is a member of 
the CMS or not.
+     * For non-CMS members (REMOTE state / RemoteProcessor):
+     *   - Sends the commit with a TCM_COMMIT_REQ message
+     *   - Message expires after min(cms_await_timeout, remaining sender 
deadline) to account for CMS node failures.
+     *   - On failure/timeout retries use exponential backoff with full jitter 
to decorrelate cms await timeouts.
+     * For CMS members (LOCAL state / AbstractLocalProcessor) for local TCM 
commits:
+     *   - The main outer retry policy described below for TCM_COMMIT_REQ is 
used without the message expiry,
+     *     as the commit runs locally through Paxos without a remote message 
hop.
+     * For CMS members handling TCM_COMMIT_REQ messages (Commit.Handler):
+     *   - Deadline is max(now + write_rpc_timeout, message.expiresAtNanos() - 
write_rpc_timeout), with exponential
+     *     backoff and full jitter using the TCM admin initial/max delay. The 
floor guarantees at least one attempt
+     *     window even when cms_await_timeout is misconfigured close to 
write_rpc_timeout.
+     *   - The CMS member reduces its own retry deadline by write_rpc_timeout 
before the message expiry so it exhausts
+     *     retries and returns an explicit failure before the sender's 
per-message callback fires (see Commit.Handler).
      *
-     * @param onFailure handler checks if rejection has resulted from a retry 
of the same trasformation.
+     * @param onFailure     handler checks if rejection has resulted from a 
retry of the same trasformation.
      */
     public <T1> T1 commit(Transformation transform, CommitSuccessHandler<T1> 
onSuccess, CommitFailureHandler<T1> onFailure)
     {
@@ -662,7 +684,9 @@ public class ClusterMetadataService
         // discover-own-commits via entry id in case of lost messages (in 
remote case) and Paxos re-proposals (in local case)
         Epoch highestConsecutive = log.waitForHighestConsecutive().epoch;
 
-        Commit.Result result = processor.commit(entryIdGen.get(), transform, 
highestConsecutive);
+        Retry retryPolicy = getRetryPolicy(transform.kind());
+        logger.info("Committing {} with {}", transform.kind(), retryPolicy);
+        Commit.Result result = processor.commit(entryIdGen.get(), transform, 
highestConsecutive, retryPolicy);
 
         try
         {
@@ -674,6 +698,9 @@ public class ClusterMetadataService
             else
             {
                 TCMMetrics.instance.recordCommitFailureLatency(nanoTime() - 
startTime, NANOSECONDS, result.failure().rejected);
+                logger.debug("Failed to commit {} after {} attempts ({}): {} 
{}",
+                             transform.kind(), retryPolicy.attempts(), 
retryPolicy,
+                             result.failure().code, result.failure().message);
                 return onFailure.accept(result.failure().code, 
result.failure().message);
             }
         }
@@ -687,6 +714,30 @@ public class ClusterMetadataService
         }
     }
 
+    private static Retry getRetryPolicy(Transformation.Kind kind)
+    {
+        Retry retryPolicy;
+        if (kind == Transformation.Kind.STARTUP)
+        {
+            retryPolicy = Retry.unsafeRetryIndefinitely();
+        }
+        else if (kind == Transformation.Kind.SCHEMA_CHANGE)
+        {
+            long deadlineNanos = nanoTime() + 
DatabaseDescriptor.getRpcTimeout(TimeUnit.NANOSECONDS);
+            retryPolicy = Retry.until(deadlineNanos, 
TCMMetrics.instance.commitRetries);
+        }
+        else
+        {
+            // On non-CMS members, which send commit requests via messaging to 
the CMS members, the exponential backoff
+            // with jitter works to desynchronize retry waves after a CMS 
await timeout. For CMS members committing
+            // locally, it helps to space Paxos CAS retries in the local 
commit loop.
+            long deadlineNanos = nanoTime() + 
DatabaseDescriptor.getCmsCommitTimeout().to(TimeUnit.NANOSECONDS);
+            RetryStrategy backoffWithJitter = 
DatabaseDescriptor.getCmsCommitRetryStrategy();
+            retryPolicy = Retry.until(deadlineNanos, 
TCMMetrics.instance.commitRetries, backoffWithJitter);
+        }
+        return retryPolicy;
+    }
+
     /**
      * Accessors
      */
@@ -760,7 +811,6 @@ public class ClusterMetadataService
 
     /**
      * Fetches log entries from directly from CMS, at least to the specified 
epoch.
-     *
      * This operation is blocking and also waits for all retrieved log entries 
to be
      * enacted, so on return all transformations to ClusterMetadata will be 
visible.
      * @return metadata with all currently committed entries enacted.
@@ -814,9 +864,7 @@ public class ClusterMetadataService
     }
 
     /**
-     *
      * IMPORTANT: this call can return _without_ catching us up, so should 
only be used privately.
-     *
      * Attempts to synchronously retrieve log entries from a non-CMS peer.
      * Fetches the log state representing the delta between the current local 
epoch and the one supplied.
      * This is to be used when a message from a peer contains an epoch higher 
than the current local epoch. As
diff --git a/src/java/org/apache/cassandra/tcm/Commit.java 
b/src/java/org/apache/cassandra/tcm/Commit.java
index 6baf00b6aa..7eda9b7523 100644
--- a/src/java/org/apache/cassandra/tcm/Commit.java
+++ b/src/java/org/apache/cassandra/tcm/Commit.java
@@ -19,6 +19,7 @@
 package org.apache.cassandra.tcm;
 
 import java.io.IOException;
+import java.util.concurrent.TimeUnit;
 import java.util.function.BiConsumer;
 import java.util.function.Supplier;
 
@@ -29,6 +30,7 @@ import org.slf4j.LoggerFactory;
 
 import accord.utils.Invariants;
 
+import org.apache.cassandra.config.DatabaseDescriptor;
 import org.apache.cassandra.db.TypeSizes;
 import org.apache.cassandra.exceptions.ExceptionCode;
 import org.apache.cassandra.io.IVersionedSerializer;
@@ -40,6 +42,7 @@ import org.apache.cassandra.net.IVerbHandler;
 import org.apache.cassandra.net.Message;
 import org.apache.cassandra.net.MessagingService;
 import org.apache.cassandra.net.Verb;
+import org.apache.cassandra.service.RetryStrategy;
 import org.apache.cassandra.tcm.log.Entry;
 import org.apache.cassandra.tcm.log.LogState;
 import org.apache.cassandra.tcm.membership.Directory;
@@ -47,6 +50,7 @@ import org.apache.cassandra.tcm.membership.NodeId;
 import org.apache.cassandra.tcm.membership.NodeVersion;
 import org.apache.cassandra.tcm.serialization.Version;
 import org.apache.cassandra.utils.FBUtilities;
+import org.apache.cassandra.utils.MonotonicClock;
 import org.apache.cassandra.utils.vint.VIntCoding;
 
 import static org.apache.cassandra.tcm.ClusterMetadataService.State.LOCAL;
@@ -371,7 +375,28 @@ public class Commit
         {
             checkCMSState();
             logger.info("Received commit request {} from {}", message.payload, 
message.from());
-            Retry retryPolicy = Retry.until(message.expiresAtNanos(), 
TCMMetrics.instance.commitRetries);
+            // Reduce our local retry deadline by write_rpc_timeout so we 
exhaust retries and return an
+            // explicit failure response to the sender before their 
per-message callback fires to avoid
+            // all the non-CMS nodes being synchronized on their CMS await 
timeouts expiring at the same time.
+            //
+            // A single tryCommitOne (Paxos CAS) can overshoot its deadline by 
up to
+            // (cas_contention_timeout + write_rpc_timeout) because 
reachedMax() is only checked at the top
+            // of the AbstractLocalProcessor retry loop, not mid-CAS so 
subtracting write_rpc_timeout covers the
+            // overshoot and leaves a window for the failure response to 
propagate back over the network.
+            //
+            // Explicit failures allow non-CMS senders to immediately 
reschedule retries, replacing the
+            // thundering herd pattern (all senders TIMEOUT and retry 
simultaneously every ~cms_await_timeout)
+            // with fast individual retries paced by CMS response speed.
+            //
+            // The floor of casWriteRpcTimeoutNanos ensures at least one 
attempt window when cms_await_timeout is
+            // misconfigured close to write_rpc_timeout.
+            long casWriteRpcTimeoutNanos = 
DatabaseDescriptor.getCasContentionTimeout(TimeUnit.NANOSECONDS) +
+                                           
DatabaseDescriptor.getWriteRpcTimeout(TimeUnit.NANOSECONDS);
+            long now = MonotonicClock.Global.preciseTime.now();
+            long localDeadlineNanos = Math.max(now + casWriteRpcTimeoutNanos,
+                                               message.expiresAtNanos() - 
casWriteRpcTimeoutNanos);
+            RetryStrategy backoffWithJitter = 
DatabaseDescriptor.getCmsCommitRetryStrategy();
+            Retry retryPolicy = Retry.until(localDeadlineNanos, 
TCMMetrics.instance.commitRetries, backoffWithJitter);
             Result result = processor.commit(message.payload.entryId, 
message.payload.transform, message.payload.lastKnown, retryPolicy);
             if (result.isSuccess())
             {
diff --git a/src/java/org/apache/cassandra/tcm/PaxosBackedProcessor.java 
b/src/java/org/apache/cassandra/tcm/PaxosBackedProcessor.java
index 03f9afe053..4e18964e4d 100644
--- a/src/java/org/apache/cassandra/tcm/PaxosBackedProcessor.java
+++ b/src/java/org/apache/cassandra/tcm/PaxosBackedProcessor.java
@@ -214,6 +214,15 @@ public class PaxosBackedProcessor extends 
AbstractLocalProcessor
             condition = new AsyncPromise<>();
             
messagingService.sendWithCallback(Message.out(Verb.TCM_FETCH_CMS_LOG_REQ, 
request), to.endpoint(), this);
         }
+
+        @Override
+        public String toString()
+        {
+            return "FetchLogRequest{" +
+                   "to=" + to.endpoint() +
+                   ", request=" + request +
+                   '}';
+        }
     }
 
 
diff --git a/src/java/org/apache/cassandra/tcm/Processor.java 
b/src/java/org/apache/cassandra/tcm/Processor.java
index b93adfcd5b..3deaa96b3c 100644
--- a/src/java/org/apache/cassandra/tcm/Processor.java
+++ b/src/java/org/apache/cassandra/tcm/Processor.java
@@ -18,14 +18,7 @@
 
 package org.apache.cassandra.tcm;
 
-import com.codahale.metrics.Meter;
-
-import org.apache.cassandra.config.DatabaseDescriptor;
-import org.apache.cassandra.config.DurationSpec;
 import org.apache.cassandra.metrics.TCMMetrics;
-import org.apache.cassandra.service.RetryStrategy;
-import org.apache.cassandra.service.TimeoutStrategy;
-import org.apache.cassandra.service.WaitStrategy;
 import org.apache.cassandra.tcm.log.Entry;
 
 import static java.util.concurrent.TimeUnit.NANOSECONDS;
@@ -33,39 +26,6 @@ import static 
org.apache.cassandra.config.DatabaseDescriptor.getCmsAwaitTimeout;
 
 public interface Processor
 {
-    /**
-     * Method is _only_ responsible to commit the transformation to the 
cluster metadata. Implementers _have to ensure_
-     * local visibility and enactment of the metadata!
-     */
-    default Commit.Result commit(Entry.Id entryId, Transformation transform, 
Epoch lastKnown)
-    {
-        // When the cluster is bounced, it may happen that regular nodes come 
up earlier than CMS nodes, or CMS
-        // nodes come up and fail to finish the startup since other CMS nodes 
are not up yet, and therefore can not
-        // submit the STARTUP message. This allows the bounces affecting 
majority of CMS nodes to finish successfully.
-        if (transform.kind() == Transformation.Kind.STARTUP)
-        {
-            return commit(entryId, transform, lastKnown, 
unsafeRetryIndefinitely());
-        }
-
-        return commit(entryId, transform, lastKnown,
-                      Retry.untilElapsed(getCmsAwaitTimeout().to(NANOSECONDS), 
TCMMetrics.instance.commitRetries));
-    }
-
-    /**
-     * To be used only when submitting a STARTUP transformation when a node is 
restarted with a new set of addresses or
-     * running a new release version.
-     */
-    static Retry unsafeRetryIndefinitely()
-    {
-        Meter retryMeter = TCMMetrics.instance.commitRetries;
-        DurationSpec.IntMillisecondsBound defaultBackoff = 
DatabaseDescriptor.getDefaultRetryBackoff();
-        DurationSpec.IntMillisecondsBound defaultMaxBackoff = 
DatabaseDescriptor.getDefaultMaxRetryBackoff();
-        String spec = (defaultBackoff == null ? "100ms" : 
defaultBackoff.toMilliseconds() + "ms")
-                      + "*attempts <=" + (defaultMaxBackoff == null ? "10s" : 
defaultMaxBackoff.toMilliseconds() + "ms");
-        WaitStrategy wait = RetryStrategy.parse(spec, 
TimeoutStrategy.LatencySourceFactory.none());
-        return Retry.withNoTimeLimit(retryMeter, wait);
-    }
-
     Commit.Result commit(Entry.Id entryId, Transformation transform, Epoch 
lastKnown, Retry retryPolicy);
 
     /**
diff --git a/src/java/org/apache/cassandra/tcm/RemoteProcessor.java 
b/src/java/org/apache/cassandra/tcm/RemoteProcessor.java
index 69830b64da..7cfc1965ac 100644
--- a/src/java/org/apache/cassandra/tcm/RemoteProcessor.java
+++ b/src/java/org/apache/cassandra/tcm/RemoteProcessor.java
@@ -57,8 +57,8 @@ import org.apache.cassandra.tcm.log.LocalLog;
 import org.apache.cassandra.tcm.log.LogState;
 import org.apache.cassandra.tcm.membership.Location;
 import org.apache.cassandra.utils.AbstractIterator;
-import org.apache.cassandra.utils.Clock;
 import org.apache.cassandra.utils.FBUtilities;
+import org.apache.cassandra.utils.MonotonicClock;
 import org.apache.cassandra.utils.concurrent.AsyncPromise;
 import org.apache.cassandra.utils.concurrent.Future;
 import org.apache.cassandra.utils.concurrent.Promise;
@@ -305,8 +305,24 @@ public final class RemoteProcessor implements Processor
         }
 
         InetAddressAndPort candidate = candidates.next();
-        long waitNanos = Math.min(verb.expiresAfterNanos(), Math.max(0, 
retry.remainingNanos()));
-        Message<REQ> msg = Message.outWithFlag(verb, request, 
MessageFlag.CALL_BACK_ON_FAILURE, Clock.Global.nanoTime() + waitNanos);
+        long msgExpiresAfterNanos;
+        if (verb == Verb.TCM_COMMIT_REQ)
+        {
+            long cmsAwaitNanos = 
DatabaseDescriptor.getCmsAwaitTimeout().to(TimeUnit.NANOSECONDS);
+            long remainingNanos = retry.remainingNanos();
+            msgExpiresAfterNanos = Math.min(cmsAwaitNanos, remainingNanos);
+            logger.debug("Sending {} to {}, per-message expiry {}ms 
(cmsAwait={}ms, remaining={}ms)",
+                         verb, candidate,
+                         TimeUnit.NANOSECONDS.toMillis(msgExpiresAfterNanos),
+                         TimeUnit.NANOSECONDS.toMillis(cmsAwaitNanos),
+                         TimeUnit.NANOSECONDS.toMillis(remainingNanos));
+        }
+        else
+        {
+            msgExpiresAfterNanos = verb.expiresAfterNanos();
+        }
+        long msgExpiresAtNanos = MonotonicClock.Global.preciseTime.now() + 
msgExpiresAfterNanos;
+        Message<REQ> msg = Message.outWithFlag(verb, request, 
MessageFlag.CALL_BACK_ON_FAILURE, msgExpiresAtNanos);
         MessagingService.instance().sendWithCallback(msg, candidate, new 
RequestCallbackWithFailure<RSP>()
         {
             @Override
diff --git a/src/java/org/apache/cassandra/tcm/Retry.java 
b/src/java/org/apache/cassandra/tcm/Retry.java
index be79f10f26..7b46029060 100644
--- a/src/java/org/apache/cassandra/tcm/Retry.java
+++ b/src/java/org/apache/cassandra/tcm/Retry.java
@@ -24,7 +24,9 @@ import com.codahale.metrics.Meter;
 
 import org.apache.cassandra.config.DatabaseDescriptor;
 import org.apache.cassandra.config.DurationSpec;
+import org.apache.cassandra.metrics.TCMMetrics;
 import org.apache.cassandra.service.RetryStrategy;
+import org.apache.cassandra.service.TimeoutStrategy;
 import org.apache.cassandra.service.TimeoutStrategy.LatencySourceFactory;
 import org.apache.cassandra.service.WaitStrategy;
 
@@ -35,6 +37,7 @@ import static 
org.apache.cassandra.utils.Clock.Global.nanoTime;
 public class Retry implements WaitStrategy
 {
     private static final WaitStrategy DEFAULT_STRATEGY;
+    private static final Retry RETRY_INDEFINITELY;
     static
     {
         DurationSpec.IntMillisecondsBound defaultBackoff = 
DatabaseDescriptor.getDefaultRetryBackoff();
@@ -47,6 +50,12 @@ public class Retry implements WaitStrategy
                           + ",retries=" + 
DatabaseDescriptor.getCmsDefaultRetryMaxTries();
         }
         DEFAULT_STRATEGY = RetryStrategy.parse(defaultSpec, 
LatencySourceFactory.none());
+
+        Meter retryMeter = TCMMetrics.instance.commitRetries;
+        String spec = (defaultBackoff == null ? "100ms" : 
defaultBackoff.toMilliseconds() + "ms")
+                      + "*attempts <=" + (defaultMaxBackoff == null ? "10s" : 
defaultMaxBackoff.toMilliseconds() + "ms");
+        WaitStrategy wait = RetryStrategy.parse(spec, 
TimeoutStrategy.LatencySourceFactory.none());
+        RETRY_INDEFINITELY = Retry.withNoTimeLimit(retryMeter, wait);
     }
 
     public final long deadlineNanos;
@@ -61,6 +70,15 @@ public class Retry implements WaitStrategy
         this.delegate = delegate;
     }
 
+    /**
+     * To be used only when submitting a STARTUP transformation when a node is 
restarted with a new set of addresses or
+     * running a new release version.
+     */
+    static Retry unsafeRetryIndefinitely()
+    {
+        return RETRY_INDEFINITELY;
+    }
+
     public Retry(long deadlineNanos, Meter retryMeter)
     {
         this(deadlineNanos, retryMeter, DEFAULT_STRATEGY);
@@ -142,6 +160,11 @@ public class Retry implements WaitStrategy
         return new Retry(deadlineNanos, retryMeter, DEFAULT_STRATEGY);
     }
 
+    public static Retry until(long deadlineNanos, Meter retryMeter, 
WaitStrategy waitStrategy)
+    {
+        return new Retry(deadlineNanos, retryMeter, waitStrategy);
+    }
+
     public static Retry untilElapsed(long timeoutNanos, Meter retryMeter)
     {
         return new Retry(nanoTime() + timeoutNanos, retryMeter, 
DEFAULT_STRATEGY);
diff --git a/src/java/org/apache/cassandra/tcm/sequences/BootstrapAndJoin.java 
b/src/java/org/apache/cassandra/tcm/sequences/BootstrapAndJoin.java
index d2909bd873..43891d5abc 100644
--- a/src/java/org/apache/cassandra/tcm/sequences/BootstrapAndJoin.java
+++ b/src/java/org/apache/cassandra/tcm/sequences/BootstrapAndJoin.java
@@ -210,8 +210,8 @@ public class BootstrapAndJoin extends 
MultiStepOperation<Epoch>
                 }
                 catch (Throwable e)
                 {
+                    logger.warn("Exception committing startJoin, will retry", 
e);
                     JVMStabilityInspector.inspectThrowable(e);
-                    logger.warn("Exception committing startJoin", e);
                     return continuable();
                 }
 
@@ -295,8 +295,8 @@ public class BootstrapAndJoin extends 
MultiStepOperation<Epoch>
                 }
                 catch (Throwable e)
                 {
+                    logger.warn("Exception committing finishJoin, will retry", 
e);
                     JVMStabilityInspector.inspectThrowable(e);
-                    logger.warn("Exception committing finishJoin", e);
                     return continuable();
                 }
                 ClusterMetadataService.instance().ensureCMSPlacement(metadata);
diff --git 
a/src/java/org/apache/cassandra/tcm/sequences/BootstrapAndReplace.java 
b/src/java/org/apache/cassandra/tcm/sequences/BootstrapAndReplace.java
index 32afe23491..552818a774 100644
--- a/src/java/org/apache/cassandra/tcm/sequences/BootstrapAndReplace.java
+++ b/src/java/org/apache/cassandra/tcm/sequences/BootstrapAndReplace.java
@@ -204,8 +204,8 @@ public class BootstrapAndReplace extends 
MultiStepOperation<Epoch>
                 }
                 catch (Throwable e)
                 {
+                    logger.warn("Exception committing startReplace, will 
retry", e);
                     JVMStabilityInspector.inspectThrowable(e);
-                    logger.warn("Got exception committing startReplace", e);
                     return continuable();
                 }
                 break;
@@ -284,8 +284,8 @@ public class BootstrapAndReplace extends 
MultiStepOperation<Epoch>
                 }
                 catch (Throwable e)
                 {
+                    logger.warn("Exception committing finishReplace, sequence 
will halt", e);
                     JVMStabilityInspector.inspectThrowable(e);
-                    logger.warn("Got exception committing finishReplace", e);
                     return halted();
                 }
                 ClusterMetadataService.instance().ensureCMSPlacement(metadata);
diff --git a/src/java/org/apache/cassandra/tcm/sequences/Move.java 
b/src/java/org/apache/cassandra/tcm/sequences/Move.java
index b4c55ba348..cf81e5a3ce 100644
--- a/src/java/org/apache/cassandra/tcm/sequences/Move.java
+++ b/src/java/org/apache/cassandra/tcm/sequences/Move.java
@@ -303,6 +303,7 @@ public class Move extends MultiStepOperation<Epoch>
                 }
                 catch (Throwable t)
                 {
+                    logger.warn("Exception committing startMove, will retry on 
next TCM epoch advance", t);
                     JVMStabilityInspector.inspectThrowable(t);
                     return continuable();
                 }
@@ -384,6 +385,7 @@ public class Move extends MultiStepOperation<Epoch>
                 }
                 catch (Throwable t)
                 {
+                    logger.warn("Exception committing midMove, will retry on 
next TCM epoch advance", t);
                     JVMStabilityInspector.inspectThrowable(t);
                     return continuable();
                 }
@@ -397,6 +399,7 @@ public class Move extends MultiStepOperation<Epoch>
                 }
                 catch (Throwable t)
                 {
+                    logger.warn("Exception committing finishMove, will retry 
on next TCM epoch advance", t);
                     JVMStabilityInspector.inspectThrowable(t);
                     return continuable();
                 }
diff --git 
a/src/java/org/apache/cassandra/tcm/sequences/UnbootstrapAndLeave.java 
b/src/java/org/apache/cassandra/tcm/sequences/UnbootstrapAndLeave.java
index deca30ab92..876caa3d96 100644
--- a/src/java/org/apache/cassandra/tcm/sequences/UnbootstrapAndLeave.java
+++ b/src/java/org/apache/cassandra/tcm/sequences/UnbootstrapAndLeave.java
@@ -183,6 +183,7 @@ public class UnbootstrapAndLeave extends 
MultiStepOperation<Epoch>
                 }
                 catch (Throwable t)
                 {
+                    logger.warn("Exception committing startLeave, will retry", 
t);
                     JVMStabilityInspector.inspectThrowable(t);
                     return continuable();
                 }
@@ -206,7 +207,7 @@ public class UnbootstrapAndLeave extends 
MultiStepOperation<Epoch>
                 }
                 catch (Throwable t)
                 {
-                    logger.warn("Error committing midLeave", t);
+                    logger.warn("Exception committing midLeave, will retry", 
t);
                     JVMStabilityInspector.inspectThrowable(t);
                     return continuable();
                 }
@@ -219,6 +220,7 @@ public class UnbootstrapAndLeave extends 
MultiStepOperation<Epoch>
                 }
                 catch (Throwable t)
                 {
+                    logger.warn("Exception committing finishLeave, will 
retry", t);
                     JVMStabilityInspector.inspectThrowable(t);
                     return continuable();
                 }
diff --git 
a/test/distributed/org/apache/cassandra/distributed/test/log/InProgressSequenceCoordinationTest.java
 
b/test/distributed/org/apache/cassandra/distributed/test/log/InProgressSequenceCoordinationTest.java
index a365d1bc36..ccb720a42d 100644
--- 
a/test/distributed/org/apache/cassandra/distributed/test/log/InProgressSequenceCoordinationTest.java
+++ 
b/test/distributed/org/apache/cassandra/distributed/test/log/InProgressSequenceCoordinationTest.java
@@ -332,10 +332,15 @@ public class InProgressSequenceCoordinationTest extends 
FuzzTestBase
     @Test
     public void inProgressSequenceRetryTest() throws Throwable
     {
+        // This test expects ~50% of TCM_COMMIT_REQ messages to be dropped, so 
s
+        // ignificantly lower the timeout, backoff, and retry params
         try (Cluster cluster = builder().withNodes(1)
                                         
.withTokenSupplier(TokenSupplier.evenlyDistributedTokens(2))
                                         
.withNodeIdTopology(NetworkTopology.singleDcNetworkTopology(2, "dc0", "rack0"))
-                                        .withConfig((config) -> 
config.with(Feature.NETWORK, Feature.GOSSIP).set("request_timeout_in_ms", 
"1000"))
+                                        .withConfig((config) -> 
config.with(Feature.NETWORK, Feature.GOSSIP)
+                                                                      
.set("cms_await_timeout", "100ms")
+                                                                      
.set("cms_commit_retry_initial_delay", "10ms")
+                                                                      
.set("cms_commit_retry_max_delay", "5000ms"))
                                         .start())
         {
             cluster.filters()
diff --git 
a/test/distributed/org/apache/cassandra/distributed/test/repair/AutoRepairSchedulerTest.java
 
b/test/distributed/org/apache/cassandra/distributed/test/repair/AutoRepairSchedulerTest.java
index ac5a764d6d..d8bf7a1a7b 100644
--- 
a/test/distributed/org/apache/cassandra/distributed/test/repair/AutoRepairSchedulerTest.java
+++ 
b/test/distributed/org/apache/cassandra/distributed/test/repair/AutoRepairSchedulerTest.java
@@ -103,7 +103,10 @@ public class AutoRepairSchedulerTest extends TestBaseImpl
                                                
.set("auto_repair.global_settings.repair_by_keyspace", "true")
                                                
.set("auto_repair.global_settings.repair_retry_backoff", "5s")
                                                
.set("auto_repair.repair_task_min_duration", "0s")
-                                               
.set("auto_repair.repair_check_interval", "5s"))
+                                               
.set("auto_repair.repair_check_interval", "5s")
+                                               .set("cms_await_timeout", 
"100ms")
+                                               
.set("cms_commit_retry_initial_delay", "10ms")
+                                               
.set("cms_commit_retry_max_delay", "5000ms"))
                          .start();
 
         cluster.schemaChange("CREATE KEYSPACE IF NOT EXISTS " + KEYSPACE + " 
WITH replication = {'class': 'SimpleStrategy', 'replication_factor': 3};");
diff --git 
a/test/distributed/org/apache/cassandra/distributed/test/tcm/LostCommitReqResTest.java
 
b/test/distributed/org/apache/cassandra/distributed/test/tcm/LostCommitReqResTest.java
index dfa3512d09..a96d6fd93e 100644
--- 
a/test/distributed/org/apache/cassandra/distributed/test/tcm/LostCommitReqResTest.java
+++ 
b/test/distributed/org/apache/cassandra/distributed/test/tcm/LostCommitReqResTest.java
@@ -33,11 +33,18 @@ import static org.junit.Assert.assertEquals;
 
 public class LostCommitReqResTest extends TestBaseImpl
 {
+
     @Test
     public void lostMoveCommitResponseTest() throws IOException
     {
         try (Cluster cluster = init(builder().withNodes(2)
-                                             .withConfig(c -> 
c.with(Feature.NETWORK, Feature.GOSSIP).set("cms_await_timeout", 
"1s").set("cms_default_max_retries", "5"))
+                                             .withConfig(
+                                             c -> c.with(Feature.NETWORK, 
Feature.GOSSIP)
+                                                   .set("cms_await_timeout", 
"1s")
+                                                   
.set("cms_default_max_retries", "5")
+                                                   .set("cms_commit_timeout", 
"3s")
+                                                   
.set("cms_commit_retry_initial_delay", "200ms")
+                                                   
.set("cms_commit_retry_max_delay", "1s"))
                                              .start()))
         {
             // no commit responses
@@ -60,7 +67,13 @@ public class LostCommitReqResTest extends TestBaseImpl
     public void lostMoveCommitRequestTest() throws IOException
     {
         try (Cluster cluster = init(builder().withNodes(2)
-                                             .withConfig(c -> 
c.with(Feature.NETWORK, Feature.GOSSIP).set("cms_await_timeout", 
"1s").set("cms_default_max_retries", "5"))
+                                             .withConfig(
+                                             c -> c.with(Feature.NETWORK, 
Feature.GOSSIP)
+                                                   .set("cms_await_timeout", 
"1s")
+                                                   
.set("cms_default_max_retries", "5")
+                                                   .set("cms_commit_timeout", 
"3s")
+                                                   
.set("cms_commit_retry_initial_delay", "200ms")
+                                                   
.set("cms_commit_retry_max_delay", "1s"))
                                              .start()))
         {
             // no commit requests
@@ -81,7 +94,13 @@ public class LostCommitReqResTest extends TestBaseImpl
     public void lostDecomCommitResponseTest() throws IOException
     {
         try (Cluster cluster = init(builder().withNodes(2)
-                                             .withConfig(c -> 
c.with(Feature.NETWORK, Feature.GOSSIP).set("cms_await_timeout", 
"1s").set("cms_default_max_retries", "5"))
+                                             .withConfig(
+                                             c -> c.with(Feature.NETWORK, 
Feature.GOSSIP)
+                                                   .set("cms_await_timeout", 
"1s")
+                                                   
.set("cms_default_max_retries", "5")
+                                                   .set("cms_commit_timeout", 
"3s")
+                                                   
.set("cms_commit_retry_initial_delay", "200ms")
+                                                   
.set("cms_commit_retry_max_delay", "1s"))
                                              .start()))
         {
             
cluster.filters().verbs(Verb.TCM_COMMIT_RSP.id).from(1).to(2).drop();
@@ -99,7 +118,13 @@ public class LostCommitReqResTest extends TestBaseImpl
     public void lostDecomCommitRequestTest() throws IOException
     {
         try (Cluster cluster = init(builder().withNodes(2)
-                                             .withConfig(c -> 
c.with(Feature.NETWORK, Feature.GOSSIP).set("cms_await_timeout", 
"1s").set("cms_default_max_retries", "5"))
+                                             .withConfig(
+                                             c -> c.with(Feature.NETWORK, 
Feature.GOSSIP)
+                                                   .set("cms_await_timeout", 
"1s")
+                                                   
.set("cms_default_max_retries", "5")
+                                                   .set("cms_commit_timeout", 
"3s")
+                                                   
.set("cms_commit_retry_initial_delay", "200ms")
+                                                   
.set("cms_commit_retry_max_delay", "1s"))
                                              .start()))
         {
             
cluster.filters().verbs(Verb.TCM_COMMIT_REQ.id).from(2).to(1).drop();
diff --git 
a/test/distributed/org/apache/cassandra/distributed/test/tcm/RemoteCmsCommitTimeoutDifferentiationTest.java
 
b/test/distributed/org/apache/cassandra/distributed/test/tcm/RemoteCmsCommitTimeoutDifferentiationTest.java
new file mode 100644
index 0000000000..67b01ef110
--- /dev/null
+++ 
b/test/distributed/org/apache/cassandra/distributed/test/tcm/RemoteCmsCommitTimeoutDifferentiationTest.java
@@ -0,0 +1,240 @@
+/*
+ * 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.distributed.test.tcm;
+
+import java.util.concurrent.TimeUnit;
+
+import org.junit.AfterClass;
+import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+import org.apache.cassandra.distributed.Cluster;
+import org.apache.cassandra.distributed.api.ConsistencyLevel;
+import org.apache.cassandra.distributed.api.Feature;
+import org.apache.cassandra.distributed.api.IInvokableInstance;
+import org.apache.cassandra.distributed.shared.ClusterUtils;
+import org.apache.cassandra.distributed.test.TestBaseImpl;
+import org.apache.cassandra.net.Verb;
+import org.apache.cassandra.service.StorageService;
+import org.apache.cassandra.tcm.CMSOperations;
+import org.apache.cassandra.tcm.ClusterMetadata;
+import org.apache.cassandra.tcm.sequences.InProgressSequences;
+
+import static org.apache.cassandra.utils.Clock.Global.nanoTime;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * Tests for CMS commit timeout differentiation across admin, schema, and 
default commit paths.
+ * Uses a shared cluster to reduce setup time. Each test resets all 
hot-modifiable timeout
+ * properties to defaults and clears message filters before running.
+ */
+public class RemoteCmsCommitTimeoutDifferentiationTest extends TestBaseImpl
+{
+    private static Cluster CLUSTER;
+
+    // Config defaults (matching Config.java field initializers)
+    private static final long DEFAULT_CMS_AWAIT_TIMEOUT_MS = 120_000L;
+    private static final long DEFAULT_CMS_COMMIT_TIMEOUT_MS = 3_600_000L;
+    private static final long DEFAULT_CMS_COMMIT_RETRY_INITIAL_DELAY_MS = 
5_000L;
+    private static final long DEFAULT_CMS_COMMIT_RETRY_MAX_DELAY_MS = 60_000L;
+
+    @BeforeClass
+    public static void beforeClass() throws Throwable
+    {
+        TestBaseImpl.beforeClass();
+        CLUSTER = init(Cluster.build(2)
+                                .withConfig(c -> c.with(Feature.NETWORK, 
Feature.GOSSIP))
+                                .start());
+    }
+
+    @AfterClass
+    public static void afterClass()
+    {
+        if (CLUSTER != null)
+            CLUSTER.close();
+    }
+
+    @Before
+    public void setUp()
+    {
+        // Clear all message filters
+        CLUSTER.filters().reset();
+
+        // Reset all hot-modifiable timeout properties to defaults on ALL 
instances
+        CLUSTER.forEach(i -> i.runOnInstance(() -> {
+            
CMSOperations.instance.setCmsAwaitTimeoutMillis(DEFAULT_CMS_AWAIT_TIMEOUT_MS);
+            
CMSOperations.instance.setCmsCommitTimeoutMillis(DEFAULT_CMS_COMMIT_TIMEOUT_MS);
+            
CMSOperations.instance.setCmsCommitRetryInitialDelayMillis(DEFAULT_CMS_COMMIT_RETRY_INITIAL_DELAY_MS);
+            
CMSOperations.instance.setCmsCommitRetryMaxDelayMillis(DEFAULT_CMS_COMMIT_RETRY_MAX_DELAY_MS);
+        }));
+
+        // Verify all instances are up to date on TCM log
+        ClusterUtils.waitForCMSToQuiesce(CLUSTER, CLUSTER.get(1));
+
+        // Verify no pending topology change operations
+        CLUSTER.forEach(i -> i.runOnInstance(() -> {
+            InProgressSequences seqs = 
ClusterMetadata.current().inProgressSequences;
+            assertTrue("Expected no in-progress sequences on instance, but 
found some", seqs.isEmpty());
+        }));
+    }
+
+    // ==================== Admin timeout tests ====================
+
+    /**
+     * Move with dropped TCM_COMMIT_REQ fails near cms_commit_timeout (3s), 
not cms_await_timeout (120s).
+     * After failure, restores filter and verifies move can be cancelled.
+     */
+    @Test
+    public void moveUsesAdminTimeout()
+    {
+        setOnAllInstances(3_000L, 200L, 1_000L);
+
+        CLUSTER.filters().verbs(Verb.TCM_COMMIT_REQ.id).from(2).to(1).drop();
+
+        long start = nanoTime();
+        CLUSTER.get(2).nodetoolResult("move", "1234").asserts().failure();
+        long elapsedMs = TimeUnit.NANOSECONDS.toMillis(nanoTime() - start);
+
+        assertTrue("Move should fail near cms_commit_timeout (3s), but took " 
+ elapsedMs + "ms",
+                   elapsedMs < 30_000);
+        assertMoveFailed(CLUSTER.get(2));
+
+        // Restore filter and cancel the failed move
+        CLUSTER.filters().reset();
+        cancelInProgressSequencesOnNode(CLUSTER.get(2));
+    }
+
+    /**
+     * Schema DDL is NOT routed through admin timeout — it should fail near 
cms_await_timeout
+     * (set to 3s), well under the admin timeout (60s).
+     */
+    @Test
+    public void schemaNotRoutedThroughAdminTimeout()
+    {
+        // Short cms_await_timeout, long admin timeout
+        CLUSTER.forEach(i -> i.runOnInstance(() -> {
+            CMSOperations.instance.setCmsAwaitTimeoutMillis(3_000L);
+            CMSOperations.instance.setCmsCommitTimeoutMillis(60_000L);
+        }));
+
+        CLUSTER.filters().verbs(Verb.TCM_COMMIT_REQ.id).from(2).to(1).drop();
+
+        long start = nanoTime();
+        try
+        {
+            CLUSTER.coordinator(2).execute(
+                "CREATE KEYSPACE IF NOT EXISTS schema_not_admin_ks WITH 
replication = {'class': 'SimpleStrategy', 'replication_factor': 1}",
+                ConsistencyLevel.ONE);
+        }
+        catch (Exception e)
+        {
+            // Expected timeout
+        }
+        long elapsedMs = TimeUnit.NANOSECONDS.toMillis(nanoTime() - start);
+
+        assertTrue("Schema DDL should fail near cms_await_timeout (3s), NOT 
admin timeout (60s), but took " + elapsedMs + "ms",
+                   elapsedMs < 30_000);
+    }
+
+    /**
+     * JMX runtime modification of cms_commit_timeout is a hot property and 
takes effect immediately.
+     */
+    @Test
+    public void cmsCommitTimeoutIsHotProperty()
+    {
+        // Verify initial default
+        long initial = CLUSTER.get(2).callOnInstance(() -> 
CMSOperations.instance.getCmsCommitTimeoutMillis());
+        assertEquals("Initial cms_commit_timeout should be default (1h)", 
DEFAULT_CMS_COMMIT_TIMEOUT_MS, initial);
+
+        // Update via JMX on the node that will attempt the move
+        setOnAllInstances(3_000L, 200L, 1_000L);
+
+        long updated = CLUSTER.get(2).callOnInstance(() -> 
CMSOperations.instance.getCmsCommitTimeoutMillis());
+        assertEquals("Updated cms_commit_timeout should be 3s", 3_000L, 
updated);
+
+        // Drop commits and verify the move fails with the new shorter timeout
+        CLUSTER.filters().verbs(Verb.TCM_COMMIT_REQ.id).from(2).to(1).drop();
+
+        long start = nanoTime();
+        CLUSTER.get(2).nodetoolResult("move", "2345").asserts().failure();
+        long elapsedMs = TimeUnit.NANOSECONDS.toMillis(nanoTime() - start);
+
+        assertTrue("Move should fail near updated 3s timeout, but took " + 
elapsedMs + "ms",
+                   elapsedMs < 30_000);
+
+        // Restore filter and cancel
+        CLUSTER.filters().reset();
+        cancelInProgressSequencesOnNode(CLUSTER.get(2));
+    }
+
+    /**
+     * cms_await_timeout is settable at runtime and takes effect.
+     */
+    @Test
+    public void cmsAwaitTimeoutIsHotProperty()
+    {
+        long initial = CLUSTER.get(1).callOnInstance(() -> 
CMSOperations.instance.getCmsAwaitTimeoutMillis());
+        assertEquals("Initial cms_await_timeout should be 120s", 
DEFAULT_CMS_AWAIT_TIMEOUT_MS, initial);
+
+        CLUSTER.forEach(i -> i.runOnInstance(() -> 
CMSOperations.instance.setCmsAwaitTimeoutMillis(5_000L)));
+        long updated = CLUSTER.get(1).callOnInstance(() -> 
CMSOperations.instance.getCmsAwaitTimeoutMillis());
+        assertEquals("cms_await_timeout should be 5s", 5_000L, updated);
+    }
+
+    // ==================== Helpers ====================
+
+    /**
+     * Set admin timeout properties on ALL instances.
+     */
+    private void setOnAllInstances(long adminTimeoutMs, long 
retryInitialDelayMs, long retryMaxDelayMs)
+    {
+        CLUSTER.forEach(i -> i.runOnInstance(() -> {
+            CMSOperations.instance.setCmsCommitTimeoutMillis(adminTimeoutMs);
+            
CMSOperations.instance.setCmsCommitRetryInitialDelayMillis(retryInitialDelayMs);
+            
CMSOperations.instance.setCmsCommitRetryMaxDelayMillis(retryMaxDelayMs);
+        }));
+    }
+
+    private static void assertMoveFailed(IInvokableInstance i)
+    {
+        String mode = i.callOnInstance(() -> 
StorageService.instance.operationMode().toString());
+        assertEquals(StorageService.Mode.MOVE_FAILED.toString(), mode);
+    }
+
+    /**
+     * Cancel any in-progress topology sequences for the given node and verify 
cleanup.
+     */
+    private static void cancelInProgressSequencesOnNode(IInvokableInstance 
instance)
+    {
+        instance.runOnInstance(() -> {
+            
StorageService.cancelInProgressSequences(ClusterMetadata.current().myNodeId());
+        });
+
+        // Wait briefly for cancellation to propagate
+        try { Thread.sleep(500); } catch (InterruptedException ignored) {}
+
+        // Verify the cancellation succeeded
+        instance.runOnInstance(() -> {
+            InProgressSequences seqs = 
ClusterMetadata.current().inProgressSequences;
+            assertTrue("Expected no in-progress sequences after cancellation", 
seqs.isEmpty());
+        });
+    }
+}
diff --git a/test/unit/org/apache/cassandra/tcm/CommitTimeoutPathsTest.java 
b/test/unit/org/apache/cassandra/tcm/CommitTimeoutPathsTest.java
new file mode 100644
index 0000000000..835b7d7b6a
--- /dev/null
+++ b/test/unit/org/apache/cassandra/tcm/CommitTimeoutPathsTest.java
@@ -0,0 +1,242 @@
+/*
+ * 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.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicReference;
+
+import org.junit.After;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+import org.apache.cassandra.config.DatabaseDescriptor;
+import org.apache.cassandra.db.guardrails.Guardrails;
+import org.apache.cassandra.dht.Murmur3Partitioner;
+import org.apache.cassandra.exceptions.ExceptionCode;
+import org.apache.cassandra.locator.MetaStrategy;
+import org.apache.cassandra.schema.DistributedSchema;
+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.tcm.transformations.cms.Initialize;
+import org.apache.cassandra.utils.FBUtilities;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+/**
+ * Tests for timeout and exception paths in the TCM commit pipeline that were
+ * identified as uncovered by code coverage analysis:
+ *
+ * 1. handleCommitResult TimeoutException — commit succeeds but follower can't 
enact
+ * 2. handleCommitResult failure callback — commit returns failure
+ */
+public class CommitTimeoutPathsTest
+{
+    @BeforeClass
+    public static void setup()
+    {
+        DatabaseDescriptor.daemonInitialization();
+        DatabaseDescriptor.setDefaultKeyspaceRF(1);
+        Guardrails.instance.setMinimumReplicationFactorThreshold(1, 1);
+        try
+        {
+            
DatabaseDescriptor.setBroadcastAddress(java.net.InetAddress.getByName("127.0.0.1"));
+        }
+        catch (java.net.UnknownHostException e)
+        {
+            throw new RuntimeException(e);
+        }
+    }
+
+    @After
+    public void cleanup()
+    {
+        try { ClusterMetadataService.unsetInstance(); } catch (Exception 
ignored) {}
+    }
+
+    /**
+     * When processor.commit() returns a Failure result, handleCommitResult 
should invoke
+     * the onFailure handler. We inject a Processor that always returns 
failure.
+     */
+    @Test
+    public void testHandleCommitResultFailureCallback() throws Exception
+    {
+        ClusterMetadata initial = new 
ClusterMetadata(Murmur3Partitioner.instance);
+        LocalLog log = LocalLog.logSpec()
+                               .sync()
+                               .withInitialState(initial)
+                               .createLog();
+
+        // Create a Processor that can fail commits
+        AtomicBoolean succeed = new AtomicBoolean(true);
+        AtomicLongBackedProcessor realProcessor = new 
AtomicLongBackedProcessor(log);
+        Processor testProcessor = new Processor()
+        {
+            @Override
+            public Commit.Result commit(Entry.Id entryId, Transformation 
transform, Epoch lastKnown, Retry retryPolicy)
+            {
+                if (succeed.get()) return realProcessor.commit(entryId, 
transform, lastKnown, retryPolicy);
+                else return Commit.Result.failed(ExceptionCode.SERVER_ERROR, 
"injected failure for testing");
+            }
+
+            @Override
+            public ClusterMetadata fetchLogAndWait(Epoch waitFor, Retry 
retryPolicy)
+            {
+                if (succeed.get()) return 
realProcessor.fetchLogAndWait(waitFor, retryPolicy);
+                else return ClusterMetadata.current();
+            }
+        };
+
+        ClusterMetadataService service = new ClusterMetadataService(new 
UniformRangePlacement(),
+                                                                     
MetadataSnapshots.NO_OP,
+                                                                     log,
+                                                                     
testProcessor,
+                                                                     
Commit.Replicator.NO_OP,
+                                                                     true);
+        ClusterMetadataService.setInstance(service);
+        log.readyUnchecked();
+        
log.unsafeBootstrapForTesting(FBUtilities.getBroadcastAddressAndPort());
+        service.commit(new Initialize(ClusterMetadata.current()) {
+            public Result execute(ClusterMetadata prev)
+            {
+                ClusterMetadata next = baseState;
+                DistributedSchema initialSchema = new 
DistributedSchema(prev.schema.getKeyspaces());
+                ClusterMetadata.Transformer transformer = 
next.transformer().with(initialSchema);
+                return Transformation.success(transformer, 
MetaStrategy.affectedRanges(prev));
+            }
+        });
+
+        succeed.set(false);
+
+        // Now test the failure path via commit(transform, onSuccess, 
onFailure)
+        AtomicReference<String> failureMessage = new AtomicReference<>();
+        AtomicReference<ExceptionCode> failureCode = new AtomicReference<>();
+
+        Transformation noopTransform = new Transformation()
+        {
+            public Kind kind() { return Kind.CUSTOM; }
+            public Result execute(ClusterMetadata metadata)
+            {
+                return Transformation.success(metadata.transformer(), 
MetaStrategy.affectedRanges(metadata));
+            }
+        };
+
+        String result = service.commit(noopTransform,
+                                       metadata -> "success", (code, message) 
-> {
+                                           failureCode.set(code);
+                                           failureMessage.set(message);
+                                           return "failure:" + message;
+                                       });
+
+        assertEquals("failure:injected failure for testing", result);
+        assertEquals(ExceptionCode.SERVER_ERROR, failureCode.get());
+        assertNotNull(failureMessage.get());
+        assertTrue(failureMessage.get().contains("injected failure"));
+    }
+
+    /**
+     * When commit succeeds but awaitAtLeast(epoch) fails because the log 
can't catch up
+     * to the committed epoch, handleCommitResult should throw 
IllegalStateException.
+     */
+    @Test
+    public void testHandleCommitResultTimeoutException()
+    {
+        ClusterMetadata initial = new 
ClusterMetadata(Murmur3Partitioner.instance);
+        LocalLog log = LocalLog.logSpec()
+                               .sync()
+                               .withInitialState(initial)
+                               .createLog();
+
+        /* Create a Processor that can returns a Success result with a 
far-future epoch
+         * that the local log will never reach, causing awaitAtLeast to fail.
+         */
+        AtomicBoolean succeed = new AtomicBoolean(true);
+        AtomicLongBackedProcessor realProcessor = new 
AtomicLongBackedProcessor(log);
+        Processor testProcessor = new Processor()
+        {
+            @Override
+            public Commit.Result commit(Entry.Id entryId, Transformation 
transform, Epoch lastKnown, Retry retryPolicy)
+            {
+                if (succeed.get())
+                {
+                    return realProcessor.commit(entryId, transform, lastKnown, 
retryPolicy);
+                }
+                else
+                {
+                    Epoch farFuture = Epoch.create(999999);
+                    return new Commit.Result.Success(farFuture, 
LogState.EMPTY);
+                }
+            }
+
+            @Override
+            public ClusterMetadata fetchLogAndWait(Epoch waitFor, Retry 
retryPolicy)
+            {
+                return ClusterMetadata.current();
+            }
+        };
+        ClusterMetadataService service = new ClusterMetadataService(new 
UniformRangePlacement(),
+                                                                     
MetadataSnapshots.NO_OP,
+                                                                     log,
+                                                                     
testProcessor,
+                                                                     
Commit.Replicator.NO_OP,
+                                                                     true);
+        ClusterMetadataService.setInstance(service);
+        log.readyUnchecked();
+        
log.unsafeBootstrapForTesting(FBUtilities.getBroadcastAddressAndPort());
+        service.commit(new Initialize(ClusterMetadata.current()) {
+            public Result execute(ClusterMetadata prev)
+            {
+                ClusterMetadata next = baseState;
+                DistributedSchema initialSchema = new 
DistributedSchema(prev.schema.getKeyspaces());
+                ClusterMetadata.Transformer transformer = 
next.transformer().with(initialSchema);
+                return Transformation.success(transformer, 
MetaStrategy.affectedRanges(prev));
+            }
+        });
+
+        // Make the processor returns success with a far-future epoch the log 
can never reach
+        succeed.set(false);
+
+        Transformation noopTransform = new Transformation()
+        {
+            public Kind kind() { return Kind.CUSTOM; }
+            public Result execute(ClusterMetadata metadata)
+            {
+                return Transformation.success(metadata.transformer(), 
MetaStrategy.affectedRanges(metadata));
+            }
+        };
+
+        try
+        {
+            service.commit(noopTransform,
+                           metadata -> "success", (code, message) -> { throw 
new RuntimeException("unexpected failure: " + message); });
+            fail("Expected IllegalStateException wrapping TimeoutException");
+        }
+        catch (IllegalStateException e)
+        {
+            assertTrue("Should mention unreachable epoch or timeout: " + 
e.getMessage(),
+                       e.getMessage().contains("Timed out") || 
e.getMessage().contains("Could not reach"));
+            assertTrue("Should mention epoch: " + e.getMessage(),
+                       e.getMessage().contains("999999"));
+        }
+    }
+}
diff --git a/test/unit/org/apache/cassandra/tcm/RetryTest.java 
b/test/unit/org/apache/cassandra/tcm/RetryTest.java
index e6a90903e9..5fe2c7ebbe 100644
--- a/test/unit/org/apache/cassandra/tcm/RetryTest.java
+++ b/test/unit/org/apache/cassandra/tcm/RetryTest.java
@@ -23,6 +23,7 @@ import java.util.concurrent.TimeUnit;
 
 import com.codahale.metrics.Meter;
 
+import org.assertj.core.api.Assertions;
 import org.junit.Before;
 import org.junit.BeforeClass;
 import org.junit.Test;
@@ -30,21 +31,21 @@ import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
 import org.apache.cassandra.config.DatabaseDescriptor;
+import org.apache.cassandra.service.RetryStrategy;
+import org.apache.cassandra.service.TimeoutStrategy;
 import org.apache.cassandra.service.WaitStrategy;
-import org.apache.cassandra.tcm.log.Entry;
-import org.apache.cassandra.tcm.membership.NodeVersion;
-import org.apache.cassandra.tcm.transformations.Startup;
 
 import static java.util.concurrent.TimeUnit.MILLISECONDS;
 import static java.util.concurrent.TimeUnit.NANOSECONDS;
-import static org.apache.cassandra.tcm.membership.MembershipUtils.node;
-import static 
org.apache.cassandra.tcm.membership.MembershipUtils.nodeAddresses;
 import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
 
 public class RetryTest
 {
     private static final Logger logger = 
LoggerFactory.getLogger(RetryTest.class);
     private Random random;
+    private static final Meter testMeter = new Meter();
 
     @BeforeClass
     public static void setupClass()
@@ -68,7 +69,10 @@ public class RetryTest
         WaitStrategy fixed = new WaitStrategy()
         {
             @Override
-            public long computeWaitUntil(int attempts) {throw new 
UnsupportedOperationException();}
+            public long computeWaitUntil(int attempts)
+            {
+                throw new UnsupportedOperationException();
+            }
 
             @Override
             public long computeWait(int attempts, TimeUnit units)
@@ -87,25 +91,84 @@ public class RetryTest
     @Test
     public void testProcessorIndefiniteRetryBehaviour()
     {
-        new Processor()
+        Retry retryPolicy = Retry.unsafeRetryIndefinitely();
+        // Assert the properties of the Retry provided by the private static 
Processor::unsafeRetryIndefinitely
+        for (int i = 1; i < 1000; i++)
         {
-            @Override
-            public ClusterMetadata fetchLogAndWait(Epoch waitFor, Retry 
retryPolicy) {return null;}
+            // backoff increases in 100ms steps, up to a max of 10000ms
+            long waitTime = retryPolicy.computeWait(i, MILLISECONDS);
+            assertEquals(Math.min((i + 1) * 100, 10000), waitTime);
+        }
+        // Retry indefinitely means no explicit deadline is set
+        assertEquals(Long.MAX_VALUE, retryPolicy.deadlineNanos);
+    }
 
-            @Override
-            public Commit.Result commit(Entry.Id entryId, Transformation 
transform, Epoch lastKnown, Retry retryPolicy)
-            {
-                // Assert the properties of the Retry provided by the private 
static Processor::unsafeRetryIndefinitely
-                for (int i = 1; i<1000;i++)
-                {
-                    // backoff increases in 100ms steps, up to a max of 10000ms
-                    long waitTime = retryPolicy.computeWait(i, MILLISECONDS);
-                    assertEquals(Math.min((i+1) * 100, 10000), waitTime);
-                }
-                // Retry indefinitely means no explicit deadline is set
-                assertEquals(Long.MAX_VALUE, retryPolicy.deadlineNanos);
-                return null;
-            }
-        }.commit(new Entry.Id(0L), new Startup(node(random), 
nodeAddresses(random), NodeVersion.CURRENT), Epoch.EMPTY);
+    @Test
+    public void testExponentialJitterValueDistribution()
+    {
+        String spec = String.format("0ms ... %dms * 2^attempts <= %dms", 100, 
10000);
+        WaitStrategy jitter = RetryStrategy.parse(spec,
+                                                  
TimeoutStrategy.LatencySourceFactory.none(),
+                                                  
RetryStrategy.randomizers.uniform());
+        for (int i = 0; i < 1000; i++)
+        {
+            long sleep = jitter.computeWait(i, TimeUnit.MILLISECONDS);
+            
Assertions.assertThat(sleep).isNotNegative().isLessThanOrEqualTo(10000);
+        }
+    }
+
+    @Test
+    public void testExponentialJitterEarlyAttemptsSmall()
+    {
+        // With base=100ms and cap=60000ms, first attempt max = min(60000, 100 
* 2^1) = 200ms
+        String spec = String.format("0ms ... %dms * 2^attempts <= %dms", 100, 
60000);
+        WaitStrategy jitter = RetryStrategy.parse(spec,
+                                                  
TimeoutStrategy.LatencySourceFactory.none(),
+                                                  
RetryStrategy.randomizers.uniform());
+        long firstSleep = jitter.computeWait(1, TimeUnit.MILLISECONDS);
+        // tries is 0 initially, so expBackoff = min(60000, 100 * 2^0) = 100
+        Assertions.assertThat(firstSleep).isNotNegative().isLessThan(100);
+    }
+
+    @Test
+    public void testExponentialJitterWithDeadline()
+    {
+        long deadlineNanos = System.nanoTime() + 100_000_000L; // 100ms from 
now
+        String spec = String.format("0ms ... %dms * 2^attempts <= %dms", 100, 
1000);
+        WaitStrategy jitter = RetryStrategy.parse(spec,
+                                                  
TimeoutStrategy.LatencySourceFactory.none(),
+                                                  
RetryStrategy.randomizers.uniform());
+        Retry deadline = Retry.until(deadlineNanos, testMeter, jitter);
+
+        assertFalse("Should not have reached deadline yet", 
deadline.hasExpired());
+        assertTrue("Remaining should be positive", deadline.remainingNanos() > 
0);
+    }
+
+    @Test
+    public void testExponentialJitterWithExpiredDeadline() throws 
InterruptedException
+    {
+        long deadlineNanos = System.nanoTime() + 1_000_000L; // 1ms from now
+        String spec = String.format("0ms ... %dms * 2^attempts <= %dms", 100, 
1000);
+        WaitStrategy jitter = RetryStrategy.parse(spec,
+                                                  
TimeoutStrategy.LatencySourceFactory.none(),
+                                                  
RetryStrategy.randomizers.uniform());
+        Retry deadline = Retry.until(deadlineNanos, testMeter, jitter);
+
+        Thread.sleep(5); // ensure deadline passes
+        assertTrue("Should have reached deadline", deadline.hasExpired());
+        assertEquals("Remaining should be 0", 0, deadline.remainingNanos());
+    }
+
+    @Test
+    public void testExponentialJitterOverflowProtection()
+    {
+        // Regardless of the number of attemps, the jittered wait should be 
capped at 5s
+        String spec = String.format("0ms ... %dms * 2^attempts <= %dms", 100, 
5000);
+        WaitStrategy jitter = RetryStrategy.parse(spec,
+                                                  
TimeoutStrategy.LatencySourceFactory.none(),
+                                                  
RetryStrategy.randomizers.uniform());
+        // Should not throw or return negative
+        long sleep = jitter.computeWait(500, TimeUnit.MILLISECONDS);
+        assertTrue("Sleep should be within specified min/max even after 500 
tries: " + sleep, sleep >= 0 && sleep <= 5000);
     }
 }


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


Reply via email to