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

bdeggleston pushed a commit to branch cep-45-mutation-tracking
in repository https://gitbox.apache.org/repos/asf/cassandra.git


The following commit(s) were added to refs/heads/cep-45-mutation-tracking by 
this push:
     new e66fb9eb0d CEP-45: Lost witness marker race
e66fb9eb0d is described below

commit e66fb9eb0d2069f09d87a5bbd08b6cdc24b61f61
Author: Blake Eggleston <[email protected]>
AuthorDate: Thu Jun 11 13:53:53 2026 -0700

    CEP-45: Lost witness marker race
    
    Don't truncate journal segments until witnessed offsets they contain are
    flushed. Also moves MutationTrackingService startup to after the commit
    log is replayed
    
    Patch by Blake Eggleston; Reviewed by Francisco Guerrero for CASSANDRA-21443
---
 src/java/org/apache/cassandra/db/Keyspace.java     |  26 ++-
 .../db/tracked/TrackedKeyspaceWriteHandler.java    |   8 +-
 .../cassandra/metrics/MutationTrackingMetrics.java |   5 +
 .../cassandra/replication/MutationJournal.java     |  85 ++++++-
 .../replication/MutationTrackingService.java       | 117 +++++++++-
 .../apache/cassandra/schema/KeyspaceParams.java    |   4 +
 .../apache/cassandra/service/CassandraDaemon.java  |   4 +
 src/java/org/apache/cassandra/tcm/Startup.java     |  19 +-
 .../cassandra/distributed/impl/Instance.java       |   3 +
 .../test/tracking/MutationTrackingTest.java        | 256 +++++++++++++++++++++
 .../virtual/MutationTrackingShardsTableTest.java   |   3 +-
 .../replication/CoordinatorLogOffsetsTest.java     |   2 +-
 .../AlterSchemaMutationTrackingTest.java           |  66 ++++++
 13 files changed, 555 insertions(+), 43 deletions(-)

diff --git a/src/java/org/apache/cassandra/db/Keyspace.java 
b/src/java/org/apache/cassandra/db/Keyspace.java
index dbf4d7b571..27072f60a6 100644
--- a/src/java/org/apache/cassandra/db/Keyspace.java
+++ b/src/java/org/apache/cassandra/db/Keyspace.java
@@ -410,7 +410,7 @@ public class Keyspace
         if (mutation.id().isNone())
             return applyInternal(mutation, writeCommitLog, updateIndexes, 
true, true, new AsyncPromise<>());
         else
-            return applyInternalTracked(mutation, new AsyncPromise<>());
+            return applyInternalTracked(mutation, false, new AsyncPromise<>());
     }
 
     public void apply(Mutation mutation, boolean writeCommitLog, boolean 
updateIndexes)
@@ -441,7 +441,10 @@ public class Keyspace
                       boolean isDroppable)
     {
         if (MigrationRouter.isFullyTracked(mutation))
-            applyInternalTracked(mutation, null);
+        {
+            // makeDurable is ignored for tracked mutations, the mutation 
journal is required for replication
+            applyInternalTracked(mutation, false, null);
+        }
         else
             applyInternal(mutation, makeDurable, updateIndexes, isDroppable, 
false, null);
     }
@@ -612,10 +615,23 @@ public class Keyspace
         }
     }
 
+    /**
+     * Apply a tracked mutation read from the {@link 
org.apache.cassandra.replication.MutationJournal}
+     * during static-segment replay on startup.
+     *
+     * Compared to the normal write apply path, this skips the journal append 
(since we're replaying from it)
+     * and always writes to the memtable, even when {@link 
MutationTrackingService#startWriting} reports the offset
+     * as already witnessed.
+     */
+    public void applyForReplay(Mutation mutation)
+    {
+        applyInternalTracked(mutation, true, null);
+    }
+
     /**
      * Append the mutation to the mutation journal, then update memtables and 
indexes.
      */
-    private Future<?> applyInternalTracked(Mutation mutation, Promise<?> 
future)
+    private Future<?> applyInternalTracked(Mutation mutation, boolean 
isReplay, Promise<?> future)
     {
         MutationTrackingService.ensureEnabled();
         if (!MigrationRouter.isFullyTracked(mutation) || 
mutation.id().isNone())
@@ -628,11 +644,11 @@ public class Keyspace
             throw new RuntimeException("Testing write failures");
 
         boolean started;
-        try (WriteContext ctx = trackedWriteHandler.beginWrite(mutation, true))
+        try (WriteContext ctx = trackedWriteHandler.beginWrite(mutation, 
!isReplay))
         {
             started = 
MutationTrackingService.instance().startWriting(mutation);
 
-            if (started)
+            if (started || isReplay)
             {
                 for (PartitionUpdate upd : mutation.getPartitionUpdates())
                 {
diff --git 
a/src/java/org/apache/cassandra/db/tracked/TrackedKeyspaceWriteHandler.java 
b/src/java/org/apache/cassandra/db/tracked/TrackedKeyspaceWriteHandler.java
index 6d8ed90149..2be356538c 100644
--- a/src/java/org/apache/cassandra/db/tracked/TrackedKeyspaceWriteHandler.java
+++ b/src/java/org/apache/cassandra/db/tracked/TrackedKeyspaceWriteHandler.java
@@ -41,8 +41,12 @@ public class TrackedKeyspaceWriteHandler implements 
KeyspaceWriteHandler
 
             MigrationRouter.validateTrackedMutation(mutation);
 
-            Tracing.trace("Appending to mutation journal");
-            CommitLogPosition pointer = 
MutationJournal.instance().write(mutation.id(), mutation);
+            CommitLogPosition pointer = null;
+            if (makeDurable)
+            {
+                Tracing.trace("Appending to mutation journal");
+                pointer = MutationJournal.instance().write(mutation.id(), 
mutation);
+            }
 
             return new CassandraWriteContext(group, pointer);
         }
diff --git a/src/java/org/apache/cassandra/metrics/MutationTrackingMetrics.java 
b/src/java/org/apache/cassandra/metrics/MutationTrackingMetrics.java
index 2b7650e36b..9935a42bd4 100644
--- a/src/java/org/apache/cassandra/metrics/MutationTrackingMetrics.java
+++ b/src/java/org/apache/cassandra/metrics/MutationTrackingMetrics.java
@@ -48,6 +48,7 @@ public class MutationTrackingMetrics
     public final Histogram readSummarySize; // Read summary sizes
     public final Gauge<Long> unreconciledMutationCount; // Number of 
unreconciled mutations
     public final Gauge<Long> journalDiskSpaceUsed; // Size of MutationJournal 
on disk
+    public final Gauge<Integer> pendingClearReplaySize; // Static segments 
awaiting clearNeedsReplay
 
     @SuppressWarnings("Convert2MethodRef")
     private MutationTrackingMetrics()
@@ -63,5 +64,9 @@ public class MutationTrackingMetrics
                 factory.createMetricName("JournalDiskSpaceUsed"),
                 () -> MutationJournal.instance().getDiskSpaceUsed()
         );
+        pendingClearReplaySize = Metrics.register(
+                factory.createMetricName("PendingClearReplaySize"),
+                () -> MutationJournal.pendingClearReplaySize()
+        );
     }
 }
\ No newline at end of file
diff --git a/src/java/org/apache/cassandra/replication/MutationJournal.java 
b/src/java/org/apache/cassandra/replication/MutationJournal.java
index a5b762e7f0..1fa4909804 100644
--- a/src/java/org/apache/cassandra/replication/MutationJournal.java
+++ b/src/java/org/apache/cassandra/replication/MutationJournal.java
@@ -20,7 +20,10 @@ package org.apache.cassandra.replication;
 import java.io.IOException;
 import java.nio.ByteBuffer;
 import java.util.Collection;
+import java.util.List;
 import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.function.BiConsumer;
@@ -30,10 +33,13 @@ import javax.annotation.Nullable;
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Preconditions;
+import com.google.common.collect.ImmutableSet;
 
 import org.agrona.collections.Long2LongHashMap;
 import org.agrona.collections.Long2ObjectHashMap;
 import org.jctools.maps.NonBlockingHashMapLong;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 import accord.utils.Invariants;
 
@@ -79,11 +85,27 @@ import static 
org.apache.cassandra.utils.FBUtilities.getAvailableProcessors;
 // TODO (required): handle table truncations
 public class MutationJournal
 {
+    private static final Logger logger = 
LoggerFactory.getLogger(MutationJournal.class);
+
+    // opaque / immutable list of segments that we should clear the 
needs-replay flag on
+    public static class PendingClearReplay
+    {
+        private final ImmutableSet<Long> segments;
+
+        public PendingClearReplay(ImmutableSet<Long> segments)
+        {
+            this.segments = segments;
+        }
+    }
+
     private static final MutationJournal instance = 
DatabaseDescriptor.getMutationTrackingEnabled() ? new MutationJournal() : null;
 
     private final Journal<ShortMutationId, Mutation> journal;
     private final Map<Long, SegmentStateTracker> segmentStateTrackers;
 
+    // Static segments awaiting durable cleanup of their needsReplay=false 
metadata.
+    private final Set<Long> pendingClearReplay = ConcurrentHashMap.newKeySet();
+
     // Most of the time during write, we will notify last known segment, so we 
optimistically cache last segment tracker,
     // without imposing any visibility guarantees. If we do not see the right 
segment in this field, we will look it up
     // in NBHM.
@@ -176,20 +198,71 @@ public class MutationJournal
     }
 
     // If all Memtables associated with given segment were flushed by the time 
we have closed active segment
-    // and opened it as static, mark its metadata to indicate it does not need 
replay. It may happen that we
-    // crash before persisting this metadata, in which case we will 
unnecessarily replay the segment, which
-    // has no correctness implications.
+    // and opened it as static, the segment is eligible to be marked as not 
needing replay. The actual durable
+    // recording of needsReplay=false is deferred — we record the segment in 
pendingClearReplay and let the
+    // LogStatePersister drain the queue after it has written witnessed 
offsets to system.coordinator_logs.
+    //
+    // See the comment in LogStatePersister or CASSANDRA-21443 for an 
explanation of why we do this
     private void maybeCleanupStaticSegment(Segment<ShortMutationId, Mutation> 
segment)
     {
         Invariants.require(segment.isStatic());
         SegmentStateTracker tracker = segmentStateTrackers.get(segment.id());
         if (tracker != null && tracker.removeCleanFromDirty())
+            pendingClearReplay.add(segment.id());
+    }
+
+    /**
+     * Snapshot the current set of segments awaiting clearing of their needs 
replay flag.
+     */
+    public PendingClearReplay snapshotPendingClearReplay()
+    {
+        return new PendingClearReplay(ImmutableSet.copyOf(pendingClearReplay));
+    }
+
+    /**
+     * Mark the given PendingClearReplay as not needing replay
+     *
+     * See the comment in LogStatePersister or CASSANDRA-21443 for an 
explanation of why we do this
+     */
+    public void drainCleanup(PendingClearReplay toDrain)
+    {
+        for (long segId : toDrain.segments)
         {
-            segment.metadata().clearNeedsReplay();
-            segment.persistMetadata();
+            List<Segment<ShortMutationId, Mutation>> found = 
journal.getSegments(segId, segId);
+            if (found.isEmpty())
+            {
+                // segment was dropped between enqueue and drain — nothing to 
persist.
+                pendingClearReplay.remove(segId);
+                continue;
+            }
+            Segment<ShortMutationId, Mutation> segment = found.get(0);
+            try
+            {
+                segment.metadata().clearNeedsReplay();
+                segment.persistMetadata();
+                pendingClearReplay.remove(segId);
+            }
+            catch (Throwable t)
+            {
+                logger.warn("Deferred cleanup failed for segment {}; will 
retry next persister tick", segId, t);
+                // leave in live queue
+            }
         }
     }
 
+    @VisibleForTesting
+    public Set<Long> pendingCleanupForTesting()
+    {
+        return pendingClearReplay;
+    }
+
+    public static int pendingClearReplaySize()
+    {
+        if (instance == null)
+            return 0;
+        return instance.pendingClearReplay.size();
+    }
+
     void startInternal()
     {
         journal.start();
@@ -328,7 +401,7 @@ public class MutationJournal
                 if (newPUCollector != null)
                 {
                     assert !newPUCollector.isEmpty();
-                    keyspace.apply(newPUCollector.build(), false, true, false);
+                    keyspace.applyForReplay(newPUCollector.build());
                 }
             }
         }, getAvailableProcessors());
diff --git 
a/src/java/org/apache/cassandra/replication/MutationTrackingService.java 
b/src/java/org/apache/cassandra/replication/MutationTrackingService.java
index 5d1103e69a..db87a88f01 100644
--- a/src/java/org/apache/cassandra/replication/MutationTrackingService.java
+++ b/src/java/org/apache/cassandra/replication/MutationTrackingService.java
@@ -33,6 +33,7 @@ import java.util.concurrent.atomic.AtomicInteger;
 import java.util.concurrent.locks.ReentrantReadWriteLock;
 import java.util.function.BiConsumer;
 import java.util.function.Consumer;
+import java.util.function.Function;
 import java.util.function.LongSupplier;
 
 import javax.annotation.Nonnull;
@@ -147,11 +148,22 @@ public class MutationTrackingService implements 
MutationTrackingServiceMBean
             throw new IllegalStateException(DISABLED_MESSAGE);
     }
 
-    public static void start(ClusterMetadata metadata)
+    public static ClusterMetadata register(ChangeListener listener)
+    {
+        ClusterMetadataService.instance().log().addListener(listener);
+        return ClusterMetadata.current();
+    }
+
+    public static void start(Function<ChangeListener, ClusterMetadata> 
register)
     {
         if (!isEnabled())
             return;
-        instance().startInternal(metadata);
+        instance().startInternal(register);
+    }
+
+    public static void start()
+    {
+        start(MutationTrackingService::register);
     }
 
     public static void shutdown() throws InterruptedException
@@ -178,6 +190,11 @@ public class MutationTrackingService implements 
MutationTrackingServiceMBean
     private ConcurrentHashMap<CoordinatorLogId, Shard> log2ShardMap = new 
ConcurrentHashMap<>();
     private final ChangeListener tcmListener;
 
+    // The highest TCM epoch we have applied to keyspaceShards via 
onNewClusterMetadata.
+    // Updates with next.epoch <= this value are skipped. Protects against 
state going
+    // backwards in time when events are delivered out of order
+    private volatile Epoch lastAppliedEpoch = Epoch.EMPTY;
+
     // prevents a race between topology changes (shard recreation) and 
coordinator log creation.
     //
     // coordinator log creation can race with topology updates and be lost if 
shard recreation discards the old
@@ -218,7 +235,7 @@ public class MutationTrackingService implements 
MutationTrackingServiceMBean
         };
     }
 
-    private synchronized void startInternal(ClusterMetadata metadata)
+    private synchronized void startInternal(Function<ChangeListener, 
ClusterMetadata> register)
     {
         if (started)
             return;
@@ -227,6 +244,8 @@ public class MutationTrackingService implements 
MutationTrackingServiceMBean
 
         logger.info("Starting mutation tracking service. Previous host log id: 
{}", prevHostLogId);
 
+        ClusterMetadata metadata = register.apply(tcmListener);
+
         if (metadata.myNodeId() != null)
             for (KeyspaceShards ks : 
KeyspaceShards.loadFromSystemTables(metadata, this::nextLogId, this::onNewLog))
                 keyspaceShards.put(ks.keyspace, ks);
@@ -307,11 +326,6 @@ public class MutationTrackingService implements 
MutationTrackingServiceMBean
         return builder.build();
     }
 
-    public void registerMetadataListener()
-    {
-        ClusterMetadataService.instance().log().addListener(tcmListener);
-    }
-
     public synchronized boolean isStarted()
     {
         return started;
@@ -322,7 +336,13 @@ public class MutationTrackingService implements 
MutationTrackingServiceMBean
         ClusterMetadataService.instance().log().removeListener(tcmListener);
         activeReconciler.shutdownBlocking();
         executor.shutdown();
-        executor.awaitTermination(1, TimeUnit.MINUTES);
+        if (!executor.awaitTermination(1, TimeUnit.MINUTES))
+            logger.warn("Mutation tracking executor did not terminate within 1 
minute; forcing shutdown");
+
+        // attempt to persist offsets and mark segments as
+        // not needing replay one last time before shutdown
+        if (isStarted())
+            offsetsPersister.run(true);
         ExpiredStatePurger.instance.shutdownBlocking();
     }
 
@@ -883,12 +903,15 @@ public class MutationTrackingService implements 
MutationTrackingServiceMBean
         }
     }
 
-    private void onNewClusterMetadata(@Nullable ClusterMetadata prev, 
ClusterMetadata next)
+    private synchronized void onNewClusterMetadata(@Nullable ClusterMetadata 
prev, ClusterMetadata next)
     {
         if (logger.isTraceEnabled())
             logger.trace("Processing cluster metadata change - epoch {} -> {}",
                         prev != null ? prev.epoch : "none", next.epoch);
 
+        if (!next.epoch.isAfter(lastAppliedEpoch))
+            return;
+
         shardLock.readLock().lock();
         try
         {
@@ -905,6 +928,9 @@ public class MutationTrackingService implements 
MutationTrackingServiceMBean
         ConcurrentHashMap<String, KeyspaceShards> originalKeyspaceShards = 
keyspaceShards;
         try
         {
+            if (!next.epoch.isAfter(lastAppliedEpoch))
+                return;
+
             if (!shardUpdateNeeded(keyspaceShards, prev, next))
                 return;
 
@@ -919,6 +945,8 @@ public class MutationTrackingService implements 
MutationTrackingServiceMBean
                 if (!newKeyspaces.isEmpty())
                     logBackgroundReconciliationDisabledWarning(newKeyspaces);
             }
+
+            lastAppliedEpoch = next.epoch;
         }
         catch (Throwable t)
         {
@@ -1576,6 +1604,35 @@ public class MutationTrackingService implements 
MutationTrackingServiceMBean
         }
     }
 
+    /**
+     * Persists per-log witnessed offsets, and durably marks needsReplay=false 
on any segments that have become eligible
+     * for it since the most recent run of this class. These 2 operations need 
to performed in a specific sequence to avoid
+     * correctness problems.
+     *
+     * For background, mutation tracking needs to keep a record of every 
mutation id it's written locally. For correctness
+     * purposes, a nodes view of mutation ids it's written locally needs to 
exactly match the data it has on disk.
+     * Having data on disk you dont have an id for, or thinking you have ids 
on disk that you don't breaks the mutation
+     * tracking consistency mechanism.
+     *
+     * To improve startup, we periodically save our view of mutation ids that 
we've witnessed to disk as part of this
+     * class. Any ids witnessed since the last time this class was run are 
reconstructed by replaying the journal.
+     *
+     * However, if an sstable is flushed after the most recent 
LogStatePersister run, AND it marks a segment as no
+     * longer needing replay, AND the node is stopped before the next 
LogStatePersister, then the offsets witnessed
+     * between the LogStatePersister and sstable flush will be forgotten on 
startup.
+     *
+     * This is a correctness problem for mutation tracking because it means 
that we will be returning data in reads that
+     * are not included in our mutation summaries, which breaks reconciliation 
and read monotonicity.
+     *
+     * To prevent this, witnessed offsets are flushed and segments are marked 
as not needing replay together in 3 steps.
+     *
+     * 1. Snapshot the set of journal segments that have been marked as 
needing their need replay flag set to false (but not yet updated on disk)
+     * 2. Flush per-log witnessed offsets to the system table
+     * 3. Durably mark the snapshotted segments as not needing replay
+     *
+     * This guarantees that, on startup, we will always replay all segments 
that may contain offsets not persisted to
+     * system.coordinator_logs
+     */
     private static class LogStatePersister implements Runnable
     {
         // TODO (expected): consider a different interval
@@ -1583,20 +1640,46 @@ public class MutationTrackingService implements 
MutationTrackingServiceMBean
         // private static final long PERSIST_INTERVAL_MILLIS = 60_000;
         private static final long PERSIST_INTERVAL_MILLIS = 1_000;
 
+        private volatile boolean isPaused = false;
+
         void start()
         {
             executor.scheduleWithFixedDelay(this, PERSIST_INTERVAL_MILLIS, 
PERSIST_INTERVAL_MILLIS, TimeUnit.MILLISECONDS);
         }
 
+        void pauseForTesting(boolean pause)
+        {
+            isPaused = pause;
+        }
+
         @Override
         public void run()
         {
+            if (isPaused)
+                return;
             run(true);
         }
 
         private void run(boolean dropSegments)
         {
-            MutationTrackingService.instance().forEachKeyspace(this::run);
+
+            MutationJournal.PendingClearReplay toDrain = 
MutationJournal.instance().snapshotPendingClearReplay();
+
+            boolean writesOk;
+            try
+            {
+                MutationTrackingService.instance().forEachKeyspace(this::run);
+                writesOk = true;
+            }
+            catch (Throwable t)
+            {
+                writesOk = false;
+                logger.error("LogStatePersister write to 
system.coordinator_logs failed; deferring segment cleanup drain to next tick", 
t);
+            }
+
+            if (writesOk)
+                MutationJournal.instance().drainCleanup(toDrain);
+
             if (dropSegments)
                 MutationTrackingService.instance().truncateMutationJournal();
         }
@@ -1643,6 +1726,18 @@ public class MutationTrackingService implements 
MutationTrackingServiceMBean
         activeReconciler.resumeForTesting();
     }
 
+    @VisibleForTesting
+    public void pauseOffsetsPersisterForTesting()
+    {
+        offsetsPersister.pauseForTesting(true);
+    }
+
+    @VisibleForTesting
+    public void resumeOffsetsPersisterForTesting()
+    {
+        offsetsPersister.pauseForTesting(false);
+    }
+
     /**
      * Pause only regular-priority (background write retry) delivery in the 
active reconciler.
      * High-priority tasks (needed by tracked read reconciliation) continue to 
be processed.
diff --git a/src/java/org/apache/cassandra/schema/KeyspaceParams.java 
b/src/java/org/apache/cassandra/schema/KeyspaceParams.java
index 6edd68d401..ab7575c89f 100644
--- a/src/java/org/apache/cassandra/schema/KeyspaceParams.java
+++ b/src/java/org/apache/cassandra/schema/KeyspaceParams.java
@@ -25,6 +25,7 @@ import com.google.common.base.MoreObjects;
 import com.google.common.base.Objects;
 
 import org.apache.cassandra.db.TypeSizes;
+import org.apache.cassandra.exceptions.ConfigurationException;
 import org.apache.cassandra.io.util.DataInputPlus;
 import org.apache.cassandra.io.util.DataOutputPlus;
 import org.apache.cassandra.service.ClientState;
@@ -180,6 +181,9 @@ public final class KeyspaceParams
 
     public void validate(String name, ClientState state, ClusterMetadata 
metadata)
     {
+        if (!durableWrites && replicationType.isTracked())
+            throw new ConfigurationException(String.format("Keyspace %s cannot 
disable durable_writes with replication_type='tracked': " +
+                                                           "the mutation 
journal is required for tracked replication", name));
         replication.validate(name, state, metadata, replicationType);
     }
 
diff --git a/src/java/org/apache/cassandra/service/CassandraDaemon.java 
b/src/java/org/apache/cassandra/service/CassandraDaemon.java
index 4db660fed6..a0c7158a90 100644
--- a/src/java/org/apache/cassandra/service/CassandraDaemon.java
+++ b/src/java/org/apache/cassandra/service/CassandraDaemon.java
@@ -84,6 +84,7 @@ import org.apache.cassandra.metrics.CassandraMetricsRegistry;
 import org.apache.cassandra.metrics.DefaultNameFactory;
 import org.apache.cassandra.net.StartupClusterConnectivityChecker;
 import org.apache.cassandra.replication.MutationJournal;
+import org.apache.cassandra.replication.MutationTrackingService;
 import org.apache.cassandra.schema.Schema;
 import org.apache.cassandra.schema.SchemaConstants;
 import org.apache.cassandra.security.ThreadAwareSecurityManager;
@@ -363,7 +364,10 @@ public class CassandraDaemon
                 AccordService.localStartup(self);
 
             if (DatabaseDescriptor.getMutationTrackingEnabled())
+            {
+                MutationTrackingService.start();
                 MutationJournal.instance().replayStaticSegments();
+            }
         }
         catch (IOException e)
         {
diff --git a/src/java/org/apache/cassandra/tcm/Startup.java 
b/src/java/org/apache/cassandra/tcm/Startup.java
index 5a462665cc..4a95ad29ca 100644
--- a/src/java/org/apache/cassandra/tcm/Startup.java
+++ b/src/java/org/apache/cassandra/tcm/Startup.java
@@ -54,7 +54,6 @@ import org.apache.cassandra.gms.VersionedValue;
 import org.apache.cassandra.locator.InetAddressAndPort;
 import org.apache.cassandra.net.MessagingService;
 import org.apache.cassandra.replication.MutationJournal;
-import org.apache.cassandra.replication.MutationTrackingService;
 import org.apache.cassandra.schema.DistributedSchema;
 import org.apache.cassandra.schema.KeyspaceMetadata;
 import org.apache.cassandra.schema.Keyspaces;
@@ -160,8 +159,7 @@ import static 
org.apache.cassandra.utils.FBUtilities.getBroadcastAddressAndPort;
         LocalLog.LogSpec logSpec = LocalLog.logSpec()
                                            
.withStorage(LogStorage.SystemKeyspace)
                                            
.afterReplay(Startup::scrubDataDirectories,
-                                                        (metadata) -> 
StorageService.instance.registerMBeans(),
-                                                        
MutationTrackingService::start)
+                                                        (metadata) -> 
StorageService.instance.registerMBeans())
                                            .withDefaultListeners();
         ClusterMetadataService.setInstance(new ClusterMetadataService(new 
UniformRangePlacement(),
                                                                       
wrapProcessor,
@@ -169,9 +167,6 @@ import static 
org.apache.cassandra.utils.FBUtilities.getBroadcastAddressAndPort;
                                                                       
logSpec));
         ClusterMetadataService.instance().log().ready();
 
-        if (DatabaseDescriptor.getMutationTrackingEnabled())
-            MutationTrackingService.instance().registerMetadataListener();
-
         NodeId nodeId = ClusterMetadata.current().myNodeId();
         UUID currentHostId = SystemKeyspace.getLocalHostId();
         if (nodeId != null && !Objects.equals(nodeId.toUUID(), currentHostId))
@@ -291,8 +286,7 @@ import static 
org.apache.cassandra.utils.FBUtilities.getBroadcastAddressAndPort;
         LocalLog.LogSpec logSpec = LocalLog.logSpec()
                                            
.withInitialState(emptyFromSystemTables)
                                            
.afterReplay(Startup::scrubDataDirectories,
-                                                        (metadata) -> 
StorageService.instance.registerMBeans(),
-                                                        
MutationTrackingService::start)
+                                                        (metadata) -> 
StorageService.instance.registerMBeans())
                                            
.withStorage(LogStorage.SystemKeyspace)
                                            .withDefaultListeners();
 
@@ -303,9 +297,6 @@ import static 
org.apache.cassandra.utils.FBUtilities.getBroadcastAddressAndPort;
 
         ClusterMetadataService.instance().log().ready();
 
-        if (DatabaseDescriptor.getMutationTrackingEnabled())
-            MutationTrackingService.instance().registerMetadataListener();
-
         initMessaging.run();
         try
         {
@@ -406,8 +397,7 @@ import static 
org.apache.cassandra.utils.FBUtilities.getBroadcastAddressAndPort;
         ClusterMetadataService.unsetInstance();
         LocalLog.LogSpec logSpec = LocalLog.logSpec()
                                            
.afterReplay(Startup::scrubDataDirectories,
-                                                        (_metadata) -> 
StorageService.instance.registerMBeans(),
-                                                        
MutationTrackingService::start)
+                                                        (_metadata) -> 
StorageService.instance.registerMBeans())
                                            .withPreviousState(prev)
                                            .withInitialState(metadata)
                                            
.withStorage(LogStorage.SystemKeyspace)
@@ -421,9 +411,6 @@ import static 
org.apache.cassandra.utils.FBUtilities.getBroadcastAddressAndPort;
 
         ClusterMetadataService.instance().log().ready();
 
-        if (DatabaseDescriptor.getMutationTrackingEnabled())
-            MutationTrackingService.instance().registerMetadataListener();
-
         initMessaging.run();
         
ClusterMetadataService.instance().forceSnapshot(metadata.forceEpoch(metadata.nextEpoch()));
         ClusterMetadataService.instance().triggerSnapshot();
diff --git 
a/test/distributed/org/apache/cassandra/distributed/impl/Instance.java 
b/test/distributed/org/apache/cassandra/distributed/impl/Instance.java
index 6407c461c3..62cf56a22d 100644
--- a/test/distributed/org/apache/cassandra/distributed/impl/Instance.java
+++ b/test/distributed/org/apache/cassandra/distributed/impl/Instance.java
@@ -842,7 +842,10 @@ public class Instance extends IsolatedExecutor implements 
IInvokableInstance
                 AccordService.localStartup(self);
 
             if ((Boolean) config.get("mutation_tracking.enabled"))
+            {
+                MutationTrackingService.start();
                 MutationJournal.instance().replayStaticSegments();
+            }
         }
         catch (IOException e)
         {
diff --git 
a/test/distributed/org/apache/cassandra/distributed/test/tracking/MutationTrackingTest.java
 
b/test/distributed/org/apache/cassandra/distributed/test/tracking/MutationTrackingTest.java
index 85532e744a..b95be6c146 100644
--- 
a/test/distributed/org/apache/cassandra/distributed/test/tracking/MutationTrackingTest.java
+++ 
b/test/distributed/org/apache/cassandra/distributed/test/tracking/MutationTrackingTest.java
@@ -33,6 +33,7 @@ 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.gms.Gossiper;
 import org.apache.cassandra.hints.HintsService;
@@ -40,6 +41,7 @@ import org.apache.cassandra.locator.InetAddressAndPort;
 import org.apache.cassandra.metrics.StorageMetrics;
 import org.apache.cassandra.net.Verb;
 import org.apache.cassandra.replication.CoordinatorLogId;
+import org.apache.cassandra.replication.MutationJournal;
 import org.apache.cassandra.replication.MutationSummary;
 import org.apache.cassandra.replication.MutationTrackingService;
 import org.apache.cassandra.replication.Offsets;
@@ -107,6 +109,260 @@ public class MutationTrackingTest extends TestBaseImpl
         }
     }
 
+    private static int getOffsetCount(IInvokableInstance node, String 
keyspaceName, String tableName, int key)
+    {
+        return node.callOnInstance(() -> {
+            TableMetadata table = 
Schema.instance.getTableMetadata(keyspaceName, tableName);
+            DecoratedKey dk = 
Murmur3Partitioner.instance.decorateKey(ByteBufferUtil.bytes(key));
+            MutationSummary summary = 
MutationTrackingService.instance().createSummaryForKey(dk, table.id, false);
+            if (summary.isEmpty())
+                return 0;
+            CoordinatorLogId logId = getOnlyLogId(summary);
+            return summaryIdSpace(summary.get(logId)).offsetCount();
+        });
+
+    }
+
+    private static int getOffsetCount(IInvokableInstance node, String 
keyspaceName, int key)
+    {
+        return getOffsetCount(node, keyspaceName, "tbl", key);
+    }
+
+    /**
+     * Writes tracked mutations, deliberately doesn't flush so the writes live 
only in the
+     * commit log the node, and asserts MTS witness state on boot reflects the 
unflushed writes. Confirm they're
+     * reconstructed on journal playback
+     */
+    @Test
+    public void testWitnessSurvivesBounceWithoutFlush() throws Throwable
+    {
+        final int key = 1;
+        final int writes = 10;
+
+        try (Cluster cluster = Cluster.build(1)
+                                      .withConfig(cfg -> 
cfg.with(Feature.NETWORK).with(Feature.GOSSIP))
+                                      .start())
+        {
+            cluster.schemaChange(withKeyspace("CREATE KEYSPACE %s WITH 
replication = " +
+                                              "{'class': 'SimpleStrategy', 
'replication_factor': 1} " +
+                                              "AND 
replication_type='tracked';"));
+            cluster.schemaChange(withKeyspace("CREATE TABLE %s.tbl (k int 
PRIMARY KEY, v int);"));
+
+            // Pause the persister so writes never reach 
system.coordinator_logs SSTables —
+            // the only durable record of the witnesses on disk lives in the 
commit log.
+            cluster.get(1).runOnInstance(() -> 
MutationTrackingService.instance().pauseOffsetsPersisterForTesting());
+
+            for (int i = 0; i < writes; i++)
+                cluster.coordinator(1).execute(withKeyspace("INSERT INTO 
%s.tbl (k, v) VALUES (?, ?)"),
+                                                ConsistencyLevel.QUORUM, key, 
i);
+
+            String keyspaceName = KEYSPACE;
+            int preBounceOffsetCount = getOffsetCount(cluster.get(1), 
keyspaceName, key);
+            assertEquals("Pre-bounce witness count must equal write count", 
writes, preBounceOffsetCount);
+
+            // Bounce without flushing. Witnesses live only in the commit log 
+ journal segments
+            // (still active, needsReplay=true). On the way back up, MTS.start 
must run after
+            // CommitLog.recoverSegmentsOnDisk() so the journal replay path 
repopulates witnesses
+            // before any consumer queries MTS state.
+            ClusterUtils.stopUnchecked(cluster.get(1));
+            cluster.get(1).startup();
+
+            cluster.get(1).runOnInstance(() -> 
MutationTrackingService.instance().pauseOffsetsPersisterForTesting());
+
+            int postBounceOffsetCount = getOffsetCount(cluster.get(1), 
keyspaceName, key);
+
+            assertEquals("Witness state must survive bounce-without-flush: 
post-bounce offsets must match pre-bounce",
+                         preBounceOffsetCount, postBounceOffsetCount);
+        }
+    }
+
+    /**
+     * Regression test for the lost-witness-marker race (CASSANDRA-21443).
+     *
+     * When a memtable flush + segment close fires before the periodic 
LogStatePersister
+     * has written witnessed offsets to system.coordinator_logs, the segment 
metadata can be
+     * durably marked needsReplay=false while the witnesses for its mutations 
are still only
+     * in memory. A crash in this window leaves the node with data in SSTables 
but with
+     * witness state missing on restart, breaking mutation summaries and 
journal sync barrier
+     * guarantees.
+     *
+     * The test pauses the persister, writes a known set of mutations, forces 
flush and
+     * segment close (triggering maybeCleanupStaticSegment), bounces the node, 
and asserts
+     * that the post-restart witness state matches the pre-bounce snapshot.
+     */
+    @Test
+    public void testWitnessSurvivesCrashAfterFlushAndSegmentClose() throws 
Throwable
+    {
+        final int key = 1;
+        final int writes = 10;
+
+        try (Cluster cluster = Cluster.build(1)
+                                      .withConfig(cfg -> 
cfg.with(Feature.NETWORK).with(Feature.GOSSIP))
+                                      .start())
+        {
+            cluster.schemaChange(withKeyspace("CREATE KEYSPACE %s WITH 
replication = " +
+                                              "{'class': 'SimpleStrategy', 
'replication_factor': 1} " +
+                                              "AND 
replication_type='tracked';"));
+            cluster.schemaChange(withKeyspace("CREATE TABLE %s.tbl (k int 
PRIMARY KEY, v int);"));
+
+            // Pause the persister so no witness state escapes to 
system.coordinator_logs
+            // for the duration of the test window. This mirrors the 
in-production hazard
+            // between persister ticks (currently 1s, planned 60s).
+            cluster.get(1).runOnInstance(() -> 
MutationTrackingService.instance().pauseOffsetsPersisterForTesting());
+
+            for (int i = 0; i < writes; i++)
+                cluster.coordinator(1).execute(withKeyspace("INSERT INTO 
%s.tbl (k, v) VALUES (?, ?)"),
+                                                ConsistencyLevel.QUORUM, key, 
i);
+
+            String keyspaceName = KEYSPACE;
+            int preBounceOffsetCount = getOffsetCount(cluster.get(1), 
keyspaceName, key);
+            assertEquals("Pre-bounce witness count must equal write count", 
writes, preBounceOffsetCount);
+
+            // Flush so notifyFlushed marks the active segment's interval 
clean.
+            cluster.get(1).nodetoolResult("flush", 
KEYSPACE).asserts().success();
+
+            // Roll the active segment to static. The cleanup callback fires
+            // (maybeCleanupStaticSegment) and — in the broken code — durably 
clears
+            // needsReplay=false even though witnesses are not persisted.
+            cluster.get(1).runOnInstance(() -> 
MutationJournal.instance().closeCurrentSegmentForTestingIfNonEmpty());
+
+            // Bounce without running the persister.
+            ClusterUtils.stopUnchecked(cluster.get(1));
+            cluster.get(1).startup();
+
+            // Re-pause on the freshly-restarted instance so any first 
persister tick
+            // cannot accidentally normalize state before we sample it.
+            cluster.get(1).runOnInstance(() -> 
MutationTrackingService.instance().pauseOffsetsPersisterForTesting());
+
+            int postBounceOffsetCount = getOffsetCount(cluster.get(1), 
keyspaceName, key);
+
+            assertEquals("Witness state must survive crash: post-bounce 
offsets must match pre-bounce",
+                         preBounceOffsetCount, postBounceOffsetCount);
+        }
+    }
+
+    /**
+     * Companion to {@link 
#testWitnessSurvivesCrashAfterFlushAndSegmentClose}: confirms
+     * that the deferred-cleanup fix did not break segment cleanup itself. 
After a flush
+     * and segment close, the segment should sit in {@code pendingCleanup} 
(not yet
+     * needsReplay=false on disk), and the next persister tick should drain it.
+     *
+     * If this test fails while the witness-survival test passes, it means we 
have
+     * accidentally turned segment cleanup into a no-op - segments would never 
be eligible
+     * for journal compaction, and disk would grow unbounded.
+     */
+    @Test
+    public void testPersisterDrainsPendingSegmentCleanup() throws Throwable
+    {
+        try (Cluster cluster = Cluster.build(1)
+                                      .withConfig(cfg -> 
cfg.with(Feature.NETWORK).with(Feature.GOSSIP))
+                                      .start())
+        {
+            cluster.schemaChange(withKeyspace("CREATE KEYSPACE %s WITH 
replication = " +
+                                              "{'class': 'SimpleStrategy', 
'replication_factor': 1} " +
+                                              "AND 
replication_type='tracked';"));
+            cluster.schemaChange(withKeyspace("CREATE TABLE %s.tbl (k int 
PRIMARY KEY, v int);"));
+
+            // Pause the scheduled persister so the only persister run in this 
test is the
+            // explicit one below — otherwise the periodic tick could drain 
mid-assert.
+            cluster.get(1).runOnInstance(() -> 
MutationTrackingService.instance().pauseOffsetsPersisterForTesting());
+
+            for (int i = 0; i < 10; i++)
+                cluster.coordinator(1).execute(withKeyspace("INSERT INTO 
%s.tbl (k, v) VALUES (?, ?)"),
+                                                ConsistencyLevel.QUORUM, 1, i);
+
+            cluster.get(1).nodetoolResult("flush", 
KEYSPACE).asserts().success();
+            cluster.get(1).runOnInstance(() -> 
MutationJournal.instance().closeCurrentSegmentForTestingIfNonEmpty());
+
+            // After flush + close, the segment should be queued for cleanup 
but not yet
+            // marked needsReplay=false on disk.
+            cluster.get(1).runOnInstance(() -> {
+                assertTrue("Expected at least one segment queued for cleanup 
after flush + close",
+                           
!MutationJournal.instance().pendingCleanupForTesting().isEmpty());
+            });
+
+            // Run an explicit persister tick: writes coordinator_logs, drains 
the snapshot.
+            // Use the boolean variant to bypass the isPaused check.
+            cluster.get(1).runOnInstance(() -> 
MutationTrackingService.instance().persistLogStateForTesting(true));
+
+            // After the persister tick, the queue should be drained.
+            cluster.get(1).runOnInstance(() -> {
+                assertTrue("Expected pendingCleanup to be empty after 
persister tick",
+                           
MutationJournal.instance().pendingCleanupForTesting().isEmpty());
+            });
+        }
+    }
+
+    /**
+     * Validates the clean-shutdown drain path (CASSANDRA-21443).
+     *
+     * Test setup pauses the periodic persister so that the only persister run 
is the
+     * shutdown-drain one. After a clean bounce, witnesses must survive and 
there must be no
+     * static segments on disk, (they're truncated on drain).
+     */
+    @Test
+    public void testCleanShutdownDrainsPendingCleanup() throws Throwable
+    {
+        final int key = 1;
+        final int writes = 10;
+
+        try (Cluster cluster = Cluster.build(1)
+                                      .withConfig(cfg -> 
cfg.with(Feature.NETWORK).with(Feature.GOSSIP))
+                                      .start())
+        {
+            cluster.schemaChange(withKeyspace("CREATE KEYSPACE %s WITH 
replication = " +
+                                              "{'class': 'SimpleStrategy', 
'replication_factor': 1} " +
+                                              "AND 
replication_type='tracked';"));
+            cluster.schemaChange(withKeyspace("CREATE TABLE %s.tbl (k int 
PRIMARY KEY, v int);"));
+
+            // Pause the periodic persister so the only run is the shutdown 
final tick.
+            cluster.get(1).runOnInstance(() -> 
MutationTrackingService.instance().pauseOffsetsPersisterForTesting());
+
+            for (int i = 0; i < writes; i++)
+                cluster.coordinator(1).execute(withKeyspace("INSERT INTO 
%s.tbl (k, v) VALUES (?, ?)"),
+                                                ConsistencyLevel.QUORUM, key, 
i);
+
+            String keyspaceName = KEYSPACE;
+            int preBounceOffsetCount = getOffsetCount(cluster.get(1), 
keyspaceName, key);
+            assertEquals("Pre-bounce witness count must equal write count", 
writes, preBounceOffsetCount);
+
+            // Flush and close the active segment so it becomes static and 
enters pendingCleanup.
+            cluster.get(1).nodetoolResult("flush", 
KEYSPACE).asserts().success();
+            cluster.get(1).runOnInstance(() -> 
MutationJournal.instance().closeCurrentSegmentForTestingIfNonEmpty());
+
+            // Confirm pendingCleanup is non-empty before shutdown — this is 
the state the
+            // final tick is supposed to drain.
+            cluster.get(1).runOnInstance(() -> {
+                assertTrue("Expected at least one segment queued for cleanup 
before shutdown",
+                           
!MutationJournal.instance().pendingCleanupForTesting().isEmpty());
+            });
+
+            // Clean (graceful) shutdown — runs 
MutationTrackingService.shutdownBlocking which
+            // performs the final persister tick.
+            ClusterUtils.stopUnchecked(cluster.get(1));
+            cluster.get(1).startup();
+
+            // Re-pause so any first periodic tick post-restart cannot rewrite 
state before
+            // we sample.
+            cluster.get(1).runOnInstance(() -> 
MutationTrackingService.instance().pauseOffsetsPersisterForTesting());
+
+            // Assertion 1: witness count survived. The final tick wrote 
coordinator_logs,
+            // so MTS.start's loadFromSystemTables on the next boot saw the 
witnesses.
+            int postBounceOffsetCount = getOffsetCount(cluster.get(1), 
keyspaceName, key);
+            assertEquals("Witness state must survive clean shutdown via the 
final-tick path",
+                         preBounceOffsetCount, postBounceOffsetCount);
+
+            // Assertion 2: no static segments on disk. The final tick's 
truncation step
+            // dropped the fully-reconciled segments. Without the final tick, 
the segment would
+            // still be present (replay reconstitutes witnesses but doesn't 
drop the segment).
+            cluster.get(1).runOnInstance(() -> {
+                int staticSegments = 
MutationJournal.instance().countStaticSegmentsForTesting();
+                assertEquals("Expected zero static segments after clean 
shutdown final tick",
+                             0, staticSegments);
+            });
+        }
+    }
+
     @Test
     public void testWitnessPaxosV1Reads() throws Throwable
     {
diff --git 
a/test/unit/org/apache/cassandra/db/virtual/MutationTrackingShardsTableTest.java
 
b/test/unit/org/apache/cassandra/db/virtual/MutationTrackingShardsTableTest.java
index 6f34bdba96..cd9900a773 100644
--- 
a/test/unit/org/apache/cassandra/db/virtual/MutationTrackingShardsTableTest.java
+++ 
b/test/unit/org/apache/cassandra/db/virtual/MutationTrackingShardsTableTest.java
@@ -36,7 +36,6 @@ import org.junit.Test;
 import org.apache.cassandra.cql3.CQLTester;
 import org.apache.cassandra.replication.MutationJournal;
 import org.apache.cassandra.replication.MutationTrackingService;
-import org.apache.cassandra.tcm.ClusterMetadata;
 
 import static org.assertj.core.api.Assertions.assertThat;
 
@@ -57,7 +56,7 @@ public class MutationTrackingShardsTableTest extends CQLTester
     {
         // Start required services for mutation tracking
         MutationJournal.start();
-        MutationTrackingService.start(ClusterMetadata.current());
+        MutationTrackingService.start();
 
         // Create a tracked keyspace
         schemaChange("CREATE KEYSPACE IF NOT EXISTS tracked_ks WITH 
replication = " +
diff --git 
a/test/unit/org/apache/cassandra/replication/CoordinatorLogOffsetsTest.java 
b/test/unit/org/apache/cassandra/replication/CoordinatorLogOffsetsTest.java
index fdf57d5b61..b51227904c 100644
--- a/test/unit/org/apache/cassandra/replication/CoordinatorLogOffsetsTest.java
+++ b/test/unit/org/apache/cassandra/replication/CoordinatorLogOffsetsTest.java
@@ -273,7 +273,7 @@ public class CoordinatorLogOffsetsTest
         ClusterMetadataTestHelper.commit(new 
AlterSchema(SchemaTransformations.addTable(tableMetadata, false)));
 
         CommitLog.instance.start();
-        MutationTrackingService.start(metadata);
+        MutationTrackingService.start(unused -> metadata);
 
         // Eventually, will also run perturbations before checking 
isReconciled (like log truncation, durability, etc.)
         // to ensure that we don't prune data required to check what's been 
reconciled
diff --git 
a/test/unit/org/apache/cassandra/tcm/transformations/AlterSchemaMutationTrackingTest.java
 
b/test/unit/org/apache/cassandra/tcm/transformations/AlterSchemaMutationTrackingTest.java
index 5c9581b71d..81b8621e99 100644
--- 
a/test/unit/org/apache/cassandra/tcm/transformations/AlterSchemaMutationTrackingTest.java
+++ 
b/test/unit/org/apache/cassandra/tcm/transformations/AlterSchemaMutationTrackingTest.java
@@ -33,6 +33,7 @@ import org.apache.cassandra.dht.Murmur3Partitioner;
 import org.apache.cassandra.dht.NormalizedRanges;
 import org.apache.cassandra.dht.Range;
 import org.apache.cassandra.dht.Token;
+import org.apache.cassandra.exceptions.ConfigurationException;
 import org.apache.cassandra.replication.MutationJournal;
 import org.apache.cassandra.schema.TableId;
 import 
org.apache.cassandra.service.replication.migration.KeyspaceMigrationInfo;
@@ -44,6 +45,7 @@ import static 
org.apache.cassandra.cql3.CQLTester.schemaChange;
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
 
 /**
  * Tests for AlterSchema auto-starting mutation tracking migration when 
replication type changes.
@@ -372,4 +374,68 @@ public class AlterSchemaMutationTrackingTest
             assertEquals(expectedInfo, actualInfo);
         }
     }
+
+    /**
+     * The mutation journal can't be disabled for tracked replication, so 
attempting to set durable_writes=false
+     * on tracked keyspaces needs to fail validation
+     */
+    @Test
+    public void testRejectDurableWritesFalseOnTrackedKeyspace()
+    {
+        // CREATE: tracked + durable_writes=false should be rejected
+        String createKs = nextKsName();
+        Throwable createFailure = expectFailure(() ->
+            schemaChange("CREATE KEYSPACE " + createKs +
+                         " WITH replication = {'class': 'SimpleStrategy', 
'replication_factor': '1'}" +
+                         " AND replication_type = 'tracked'" +
+                         " AND durable_writes = false")
+        );
+        assertTrue("Expected ConfigurationException root cause, got: " + 
createFailure,
+                   rootCause(createFailure) instanceof ConfigurationException);
+
+        // ALTER existing tracked keyspace to set durable_writes=false should 
be rejected
+        String alterKs = nextKsName();
+        schemaChange("CREATE KEYSPACE " + alterKs +
+                     " WITH replication = {'class': 'SimpleStrategy', 
'replication_factor': '1'}" +
+                     " AND replication_type = 'tracked'");
+        Throwable alterTrackedFailure = expectFailure(() ->
+            schemaChange("ALTER KEYSPACE " + alterKs + " WITH durable_writes = 
false")
+        );
+        assertTrue("Expected ConfigurationException root cause, got: " + 
alterTrackedFailure,
+                   rootCause(alterTrackedFailure) instanceof 
ConfigurationException);
+
+        // ALTER untracked keyspace with durable_writes=false to switch 
replication_type=tracked should be rejected
+        String migratedKs = nextKsName();
+        schemaChange("CREATE KEYSPACE " + migratedKs +
+                     " WITH replication = {'class': 'SimpleStrategy', 
'replication_factor': '1'}" +
+                     " AND replication_type = 'untracked'" +
+                     " AND durable_writes = false");
+        Throwable alterToTrackedFailure = expectFailure(() ->
+            schemaChange("ALTER KEYSPACE " + migratedKs + " WITH 
replication_type = 'tracked'")
+        );
+        assertTrue("Expected ConfigurationException root cause, got: " + 
alterToTrackedFailure,
+                   rootCause(alterToTrackedFailure) instanceof 
ConfigurationException);
+    }
+
+    private static Throwable expectFailure(Runnable r)
+    {
+        try
+        {
+            r.run();
+        }
+        catch (Throwable t)
+        {
+            return t;
+        }
+        fail("Expected exception but none was thrown");
+        return null;
+    }
+
+    private static Throwable rootCause(Throwable t)
+    {
+        Throwable cause = t;
+        while (cause.getCause() != null && cause.getCause() != cause)
+            cause = cause.getCause();
+        return cause;
+    }
 }


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

Reply via email to