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 1ba23a9cd4 CEP-46: update witness replication validation restrictions
1ba23a9cd4 is described below

commit 1ba23a9cd4263a20a0977ab949f628119a019ce1
Author: Blake Eggleston <[email protected]>
AuthorDate: Tue Sep 8 15:33:12 2026 -0700

    CEP-46: update witness replication validation restrictions
    
    Patch by Blake Eggleston; Reviewed by Francisco Guerrero for CASSANDRA-21674
---
 .../cassandra/pages/architecture/dynamo.adoc       |   8 +-
 .../managing/operating/transientreplication.adoc   |  75 +++++-
 .../config/CassandraRelevantProperties.java        |   1 +
 .../statements/schema/AlterKeyspaceStatement.java  | 128 +++++++++-
 .../statements/schema/AlterTableStatement.java     |   7 -
 .../cql3/statements/schema/CopyTableStatement.java |   7 -
 .../statements/schema/CreateIndexStatement.java    |   1 -
 .../statements/schema/CreateTableStatement.java    |   7 -
 .../org/apache/cassandra/locator/ReplicaPlans.java |   4 +
 .../cassandra/replication/TrackedWriteRequest.java |   9 +-
 .../apache/cassandra/service/StorageService.java   |  14 +
 .../cassandra/service/StorageServiceMBean.java     |   3 +
 .../cassandra/service/paxos/PaxosPrepare.java      |  38 +--
 .../test/TrackedCounterForwardingTest.java         |   2 +
 .../test/TrackedCounterWitnessForwardingTest.java  | 188 ++++++++++++++
 .../service/paxos/PaxosWitnessDataNodeTest.java    | 250 ++++++++++++++++++
 ...eyspaceStatementUnsafeTransientChangesTest.java |  95 +++++++
 .../AlterKeyspaceWitnessTransitionTest.java        | 284 +++++++++++++++++++++
 .../AlterSchemaMutationTrackingTest.java           | 103 ++++++++
 19 files changed, 1162 insertions(+), 62 deletions(-)

diff --git a/doc/modules/cassandra/pages/architecture/dynamo.adoc 
b/doc/modules/cassandra/pages/architecture/dynamo.adoc
index 551d5a3338..5da99becc3 100644
--- a/doc/modules/cassandra/pages/architecture/dynamo.adoc
+++ b/doc/modules/cassandra/pages/architecture/dynamo.adoc
@@ -245,12 +245,8 @@ data. Configure it by specifying replication factor as
 `<total_replicas>/<transient_replicas` Both `SimpleStrategy` and
 `NetworkTopologyStrategy` support configuring transient replication.
 
-Transiently replicated keyspaces only support tables created with
-`read_repair` set to `NONE`; monotonic reads are not currently
-supported. You also can't use `LWT`, logged batches, or counters in 
{40_version}.
-You will possibly never be able to use materialized views with
-transiently replicated keyspaces and probably never be able to use
-secondary indices with them.
+Transient replication requires mutation tracking, so a transiently replicated
+keyspace must set `replication_type = 'tracked'`.
 
 Transient replication is an experimental feature that is not ready
 for production use. The expected audience is experienced users of
diff --git 
a/doc/modules/cassandra/pages/managing/operating/transientreplication.adoc 
b/doc/modules/cassandra/pages/managing/operating/transientreplication.adoc
index 3a0bfaf1cb..c813a3ed9a 100644
--- a/doc/modules/cassandra/pages/managing/operating/transientreplication.adoc
+++ b/doc/modules/cassandra/pages/managing/operating/transientreplication.adoc
@@ -71,8 +71,8 @@ CREATE KEYSPACE some_keysopace WITH replication = {'class': 
'NetworkTopologyStra
 'DC1' : '3/1'', 'DC2' : '5/2'};
 ....
 
-Transiently replicated keyspaces only support tables with `read_repair`
-set to `NONE`.
+Transient replication requires mutation tracking, so a transiently replicated
+keyspace must set `replication_type = 'tracked'`.
 
 Important Restrictions:
 
@@ -83,15 +83,74 @@ must first remove all transient replicas, then change the # 
of full
 replicas, then add back the transient replicas.
 * You can only safely increase number of transients one at a time with
 incremental repair run in between each time.
+* You can't add transient replicas to a keyspace whose mutation tracking
+migration is still in progress, nor enable mutation tracking and add
+transient replicas in the same statement. Set `replication_type = 'tracked'`
+first, wait for the migration to complete, then alter the replication
+factor.
+
+Setting `-Dcassandra.allow_unsafe_transient_changes=true` skips every 
restriction
+above except the first, which is governed by
+`-Dcassandra.allow_alter_rf_during_range_movement`. Any alteration reached that
+way can leave a transient replica counted by reads it cannot answer, and needs 
a
+full repair afterwards to redistribute the data.
+
+Promotion is the one performed deliberately often enough to want it without a
+restart, so it also honours `AllowUnsafeWitnessPromotion` on the
+`StorageService` MBean, seeded from
+`-Dcassandra.allow_unsafe_witness_promotion`.
+
+=== Adding transient replicas to an existing keyspace
+
+A keyspace that is not already using mutation tracking adopts transient
+replication in two steps, because enabling mutation tracking starts a migration
+and reads for a range that has not yet migrated still take the untracked path.
+
+First enable mutation tracking:
+
+....
+ALTER KEYSPACE some_keyspace WITH replication_type = 'tracked';
+....
+
+The migration repairs each range so that a single-replica tracked read reflects
+every write made while the keyspace was untracked. Wait for it to complete, 
then
+set the replication factor:
+
+....
+ALTER KEYSPACE some_keyspace WITH replication = {'class': 'SimpleStrategy',
+'replication_factor' : '3/1'};
+....
+
+A keyspace created with both settings at once needs no migration, because it 
has
+no pre-tracking writes to repair.
+
+=== Removing transient replicas
+
+Dropping the transient replica from the replica set needs no data movement,
+because the remaining full replicas already hold all data:
+
+....
+ALTER KEYSPACE some_keyspace WITH replication = {'class': 'SimpleStrategy',
+'replication_factor' : '2'};
+....
+
+To restore the original full replica count, add a full replica after you drop 
the
+transient replica. Then run a full repair to populate the added replica:
+
+....
+ALTER KEYSPACE some_keyspace WITH replication = {'class': 'SimpleStrategy',
+'replication_factor' : '3'};
+nodetool repair -full some_keyspace
+....
+
+Direct promotion (`3/1` to `3`) is rejected by default. You can enable
+`AllowUnsafeWitnessPromotion` through Java Management Extensions (JMX) to allow
+it, and then run a full repair immediately.
 
 Additionally, transient replication cannot be used for:
 
-* Monotonic Reads
-* Lightweight Transactions (LWTs)
-* Logged Batches
-* Counters
-* Keyspaces using materialized views
-* Secondary indexes (2i)
+* Logged Batches.
+* Keyspaces using materialized views.
 
 == Cheap Quorums
 
diff --git 
a/src/java/org/apache/cassandra/config/CassandraRelevantProperties.java 
b/src/java/org/apache/cassandra/config/CassandraRelevantProperties.java
index b0631d98ac..97dcc8d5ab 100644
--- a/src/java/org/apache/cassandra/config/CassandraRelevantProperties.java
+++ b/src/java/org/apache/cassandra/config/CassandraRelevantProperties.java
@@ -61,6 +61,7 @@ public enum CassandraRelevantProperties
     ALLOW_UNSAFE_JOIN("cassandra.allow_unsafe_join"),
     ALLOW_UNSAFE_REPLACE("cassandra.allow_unsafe_replace"),
     ALLOW_UNSAFE_TRANSIENT_CHANGES("cassandra.allow_unsafe_transient_changes"),
+    ALLOW_UNSAFE_WITNESS_PROMOTION("cassandra.allow_unsafe_witness_promotion"),
     APPROXIMATE_TIME_PRECISION_MS("cassandra.approximate_time_precision_ms", 
"2"),
     ASYNC_PROFILER_ENABLED("cassandra.async_profiler.enabled", "false"),
     ASYNC_PROFILER_UNSAFE_MODE("cassandra.async_profiler.unsafe_mode", 
"false"),
diff --git 
a/src/java/org/apache/cassandra/cql3/statements/schema/AlterKeyspaceStatement.java
 
b/src/java/org/apache/cassandra/cql3/statements/schema/AlterKeyspaceStatement.java
index 30faee655e..bb5c4487d4 100644
--- 
a/src/java/org/apache/cassandra/cql3/statements/schema/AlterKeyspaceStatement.java
+++ 
b/src/java/org/apache/cassandra/cql3/statements/schema/AlterKeyspaceStatement.java
@@ -17,10 +17,15 @@
  */
 package org.apache.cassandra.cql3.statements.schema;
 
+import java.util.Collections;
+import java.util.HashMap;
 import java.util.HashSet;
+import java.util.Map;
 import java.util.Set;
 import java.util.stream.Collectors;
 
+import com.google.common.collect.Sets;
+
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -34,6 +39,7 @@ import org.apache.cassandra.exceptions.ConfigurationException;
 import org.apache.cassandra.locator.AbstractReplicationStrategy;
 import org.apache.cassandra.locator.InetAddressAndPort;
 import org.apache.cassandra.locator.LocalStrategy;
+import org.apache.cassandra.locator.NetworkTopologyStrategy;
 import org.apache.cassandra.locator.ReplicationFactor;
 import org.apache.cassandra.locator.SimpleStrategy;
 import org.apache.cassandra.schema.KeyspaceMetadata;
@@ -42,7 +48,6 @@ import org.apache.cassandra.schema.Keyspaces;
 import org.apache.cassandra.schema.Keyspaces.KeyspacesDiff;
 import org.apache.cassandra.schema.Schema;
 import org.apache.cassandra.schema.SchemaConstants;
-import org.apache.cassandra.schema.TableMetadata;
 import org.apache.cassandra.service.ClientState;
 import org.apache.cassandra.tcm.ClusterMetadata;
 import org.apache.cassandra.tcm.membership.NodeId;
@@ -53,6 +58,7 @@ import org.apache.cassandra.utils.FBUtilities;
 
 import static 
org.apache.cassandra.config.CassandraRelevantProperties.ALLOW_ALTER_RF_DURING_RANGE_MOVEMENT;
 import static 
org.apache.cassandra.config.CassandraRelevantProperties.ALLOW_UNSAFE_TRANSIENT_CHANGES;
+import static 
org.apache.cassandra.config.CassandraRelevantProperties.ALLOW_UNSAFE_WITNESS_PROMOTION;
 import static 
org.apache.cassandra.replication.MutationTrackingService.DISABLED_MESSAGE;
 
 public final class AlterKeyspaceStatement extends AlterSchemaStatement
@@ -102,9 +108,6 @@ public final class AlterKeyspaceStatement extends 
AlterSchemaStatement
         newKeyspace.params.validate(keyspaceName, state, metadata);
         newKeyspace.replicationStrategy.validate(metadata);
 
-        validateNoRangeMovements();
-        validateTransientReplication(keyspace, newKeyspace);
-
         // Because we used to not properly validate unrecognized options, we 
only log a warning if we find one.
         try
         {
@@ -156,6 +159,11 @@ public final class AlterKeyspaceStatement extends 
AlterSchemaStatement
             if (!DatabaseDescriptor.getMutationTrackingEnabled())
                 throw ire(DISABLED_MESSAGE);
         }
+
+        ClusterMetadata metadata = ClusterMetadata.current();
+        validateNoRangeMovements(metadata);
+        validateTransientReplication(keyspace, newKeyspace);
+        validateWitnessTransitions(metadata, keyspace, newKeyspace);
     }
 
     @Override
@@ -176,12 +184,11 @@ public final class AlterKeyspaceStatement extends 
AlterSchemaStatement
         return clientWarnings;
     }
 
-    private void validateNoRangeMovements()
+    private void validateNoRangeMovements(ClusterMetadata metadata)
     {
         if (allow_alter_rf_during_range_movement)
             return;
 
-        ClusterMetadata metadata = ClusterMetadata.current();
         NodeId nodeId = 
metadata.directory.peerId(FBUtilities.getBroadcastAddressAndPort());
         Set<InetAddressAndPort> notNormalEndpoints = 
metadata.directory.states.entrySet().stream().filter(e -> 
!e.getKey().equals(nodeId)).filter(e -> {
             switch (e.getValue())
@@ -225,10 +232,6 @@ public final class AlterKeyspaceStatement extends 
AlterSchemaStatement
 
             if (!current.views.isEmpty())
                 throw new ConfigurationException("Cannot use transient 
replication on keyspaces using materialized views");
-
-            for (TableMetadata table : current.tables)
-                if (!table.indexes.isEmpty())
-                    throw new ConfigurationException("Cannot use transient 
replication on keyspaces using secondary indexes");
         }
 
         //This is true right now because the transition from transient -> full 
lacks the pending state
@@ -237,6 +240,12 @@ public final class AlterKeyspaceStatement extends 
AlterSchemaStatement
         if (oldFull > newFull && oldTrans > 0)
             throw new ConfigurationException("Can't add full replicas if there 
are any transient replicas. You must first remove all transient replicas, then 
change the # of full replicas, then add back the transient replicas");
 
+        //Promoting a witness to a full replica leaves it a full replica 
holding no data for the range
+        //it witnessed: tracked writes skip the column family store for tokens 
outside a node's full
+        //ranges, see Keyspace#applyInternalTracked. The promoted replica is 
then counted by quorum
+        //reads. Repair has to redistribute the data, so this is opt-in only.
+        validateNoWitnessPromotion(current.replicationStrategy, 
proposed.replicationStrategy);
+
         //Don't increase transient replication factor by more than one at a 
time if changing number of replicas
         //Just like with changing full replicas it's not safe to do this as 
you could read from too many replicas
         //that don't have the necessary data. W/O transient replication this 
alteration was allowed and it's not clear
@@ -247,6 +256,105 @@ public final class AlterKeyspaceStatement extends 
AlterSchemaStatement
             throw new ConfigurationException("Can only safely increase number 
of transients one at a time with incremental repair run in between each time");
     }
 
+    /**
+     * A witness is promoted when the full count rises while transient 
replicas exist. This promotes witness replicas
+     * to full replicas without sending them the data they need to participate 
in reads.
+     */
+    private void validateNoWitnessPromotion(AbstractReplicationStrategy 
current, AbstractReplicationStrategy proposed)
+    {
+        if (allowUnsafeWitnessPromotion)
+            return;
+
+        if (!current.getClass().equals(proposed.getClass()))
+        {
+            if (promotesWitness(current.getReplicationFactor(), 
proposed.getReplicationFactor()))
+                throw witnessPromotionRejected();
+            return;
+        }
+
+        Map<String, ReplicationFactor> before = 
replicationFactorsByGroup(current);
+        Map<String, ReplicationFactor> after = 
replicationFactorsByGroup(proposed);
+        for (String group : Sets.union(before.keySet(), after.keySet()))
+        {
+            if (promotesWitness(before.getOrDefault(group, 
ReplicationFactor.ZERO),
+                                after.getOrDefault(group, 
ReplicationFactor.ZERO)))
+                throw witnessPromotionRejected();
+        }
+    }
+
+    private static boolean promotesWitness(ReplicationFactor before, 
ReplicationFactor after)
+    {
+        return before.transientReplicas() > 0 && after.fullReplicas > 
before.fullReplicas;
+    }
+
+    /**
+     * Replication factors keyed by the unit the strategy assigns them to 
independently: datacenter for
+     * {@link NetworkTopologyStrategy}, and the keyspace as a whole for every 
other strategy, whose
+     * aggregate factor is already its only group.
+     */
+    private static Map<String, ReplicationFactor> 
replicationFactorsByGroup(AbstractReplicationStrategy strategy)
+    {
+        if (!(strategy instanceof NetworkTopologyStrategy))
+            return Collections.singletonMap("", 
strategy.getReplicationFactor());
+
+        NetworkTopologyStrategy nts = (NetworkTopologyStrategy) strategy;
+        Map<String, ReplicationFactor> byDc = new HashMap<>();
+        for (String dc : nts.getDatacenters())
+            byDc.put(dc, nts.getReplicationFactor(dc));
+        return byDc;
+    }
+
+    private ConfigurationException witnessPromotionRejected()
+    {
+        return new ConfigurationException(String.format("Cannot promote a 
transient replica of %s to a full replica: " +
+                                                        "it holds no data for 
the range it witnessed. Set %s=true over " +
+                                                        "JMX, or %s=true at 
startup, to allow it, then run a full repair " +
+                                                        "to distribute the 
data.",
+                                                        keyspaceName,
+                                                        
ALLOW_UNSAFE_WITNESS_PROMOTION.getKey(),
+                                                        
ALLOW_UNSAFE_TRANSIENT_CHANGES.getKey()));
+    }
+
+    private static volatile boolean allowUnsafeWitnessPromotion = 
ALLOW_UNSAFE_WITNESS_PROMOTION.getBoolean();
+
+    public static void setAllowUnsafeWitnessPromotion(boolean allow)
+    {
+        allowUnsafeWitnessPromotion = allow;
+    }
+
+    public static boolean getAllowUnsafeWitnessPromotion()
+    {
+        return allowUnsafeWitnessPromotion;
+    }
+
+    /**
+     * Alterations that would leave a witness (transient replica) counted by 
reads it cannot answer.
+     * These are unsafe rather than inexpressible, so 
cassandra.allow_unsafe_transient_changes opts out
+     * of them alongside the rules in validateTransientReplication.
+     */
+    private void validateWitnessTransitions(ClusterMetadata metadata,
+                                            KeyspaceMetadata current,
+                                            KeyspaceMetadata proposed)
+    {
+        if (allow_unsafe_transient_changes)
+            return;
+
+        boolean proposesWitnesses = 
proposed.replicationStrategy.getReplicationFactor().hasTransientReplicas();
+
+        if (proposesWitnesses && 
metadata.mutationTrackingMigrationState.isMigrating(keyspaceName))
+            throw new ConfigurationException(String.format("Cannot add 
transient replicas to %s while its mutation " +
+                                                           "tracking migration 
is in progress. Wait for the migration " +
+                                                           "to complete, then 
alter the replication factor.",
+                                                           keyspaceName));
+
+        if (proposesWitnesses && proposed.params.replicationType.isTracked() 
&& !current.params.replicationType.isTracked())
+            throw new ConfigurationException(String.format("Cannot enable 
mutation tracking on %s and add transient " +
+                                                           "replicas in the 
same statement, because doing so starts a " +
+                                                           "migration. Set 
replication_type = 'tracked' first, wait for " +
+                                                           "the migration to 
complete, then alter the replication factor.",
+                                                           keyspaceName));
+    }
+
     @Override
     public AuditLogContext getAuditLogContext()
     {
diff --git 
a/src/java/org/apache/cassandra/cql3/statements/schema/AlterTableStatement.java 
b/src/java/org/apache/cassandra/cql3/statements/schema/AlterTableStatement.java
index cebc174e24..c544ad867e 100644
--- 
a/src/java/org/apache/cassandra/cql3/statements/schema/AlterTableStatement.java
+++ 
b/src/java/org/apache/cassandra/cql3/statements/schema/AlterTableStatement.java
@@ -71,7 +71,6 @@ import org.apache.cassandra.schema.Views;
 import org.apache.cassandra.service.ClientState;
 import org.apache.cassandra.service.consensus.TransactionalMode;
 import 
org.apache.cassandra.service.consensus.migration.TransactionalMigrationFromMode;
-import org.apache.cassandra.service.reads.repair.ReadRepairStrategy;
 import org.apache.cassandra.tcm.ClusterMetadata;
 import org.apache.cassandra.tcm.Epoch;
 import org.apache.cassandra.tcm.membership.Directory;
@@ -700,12 +699,6 @@ public abstract class AlterTableStatement extends 
AlterSchemaStatement
                           "before being replayed.");
             }
 
-            if (keyspace.replicationStrategy.hasTransientReplicas()
-                && params.readRepair != ReadRepairStrategy.NONE)
-            {
-                throw ire("read_repair must be set to 'NONE' for transiently 
replicated keyspaces");
-            }
-
             if (!params.compression.isEnabled())
                 Guardrails.uncompressedTablesEnabled.ensureEnabled(state);
 
diff --git 
a/src/java/org/apache/cassandra/cql3/statements/schema/CopyTableStatement.java 
b/src/java/org/apache/cassandra/cql3/statements/schema/CopyTableStatement.java
index 1250cb79ec..c92dfed4b3 100644
--- 
a/src/java/org/apache/cassandra/cql3/statements/schema/CopyTableStatement.java
+++ 
b/src/java/org/apache/cassandra/cql3/statements/schema/CopyTableStatement.java
@@ -55,7 +55,6 @@ import org.apache.cassandra.schema.Triggers;
 import org.apache.cassandra.schema.UserFunctions;
 import org.apache.cassandra.service.ClientState;
 import org.apache.cassandra.service.ClientWarn;
-import org.apache.cassandra.service.reads.repair.ReadRepairStrategy;
 import org.apache.cassandra.tcm.ClusterMetadata;
 import org.apache.cassandra.tcm.serialization.Version;
 import org.apache.cassandra.transport.Event.SchemaChange;
@@ -194,12 +193,6 @@ public final class CopyTableStatement extends 
AlterSchemaStatement
         if (sourceTableMeta.isCompactTable())
             Guardrails.compactTablesEnabled.ensureEnabled(state);
 
-        if (sourceKeyspaceMeta.replicationStrategy.hasTransientReplicas()
-            && sourceTableMeta.params.readRepair != ReadRepairStrategy.NONE)
-        {
-            throw ire("read_repair must be set to 'NONE' for transiently 
replicated keyspaces");
-        }
-
         if (!sourceTableMeta.params.compression.isEnabled())
             Guardrails.uncompressedTablesEnabled.ensureEnabled(state);
 
diff --git 
a/src/java/org/apache/cassandra/cql3/statements/schema/CreateIndexStatement.java
 
b/src/java/org/apache/cassandra/cql3/statements/schema/CreateIndexStatement.java
index fc1c2343f1..31e9b920a1 100644
--- 
a/src/java/org/apache/cassandra/cql3/statements/schema/CreateIndexStatement.java
+++ 
b/src/java/org/apache/cassandra/cql3/statements/schema/CreateIndexStatement.java
@@ -65,7 +65,6 @@ public final class CreateIndexStatement extends 
AlterSchemaStatement
     public static final String TABLE_DOES_NOT_EXIST = "Table '%s' doesn't 
exist";
     public static final String COUNTER_TABLES_NOT_SUPPORTED = "Secondary 
indexes on counter tables aren't supported";
     public static final String MATERIALIZED_VIEWS_NOT_SUPPORTED = "Secondary 
indexes on materialized views aren't supported";
-    public static final String TRANSIENTLY_REPLICATED_KEYSPACE_NOT_SUPPORTED = 
"Secondary indexes are not supported on transiently replicated keyspaces";
     public static final String CUSTOM_CREATE_WITHOUT_COLUMN = "Only CUSTOM 
indexes can be created without specifying a target column";
     public static final String CUSTOM_MULTIPLE_COLUMNS = "Only CUSTOM indexes 
support multiple columns";
     public static final String DUPLICATE_TARGET_COLUMN = "Duplicate column 
'%s' in index target list";
diff --git 
a/src/java/org/apache/cassandra/cql3/statements/schema/CreateTableStatement.java
 
b/src/java/org/apache/cassandra/cql3/statements/schema/CreateTableStatement.java
index 99eeef4a58..7eca3ab992 100644
--- 
a/src/java/org/apache/cassandra/cql3/statements/schema/CreateTableStatement.java
+++ 
b/src/java/org/apache/cassandra/cql3/statements/schema/CreateTableStatement.java
@@ -175,13 +175,6 @@ public final class CreateTableStatement extends 
AlterSchemaStatement
         TableMetadata table = builder.build();
         table.validate();
 
-        // TODO (review): This can be removed right? ReadRepair is effectively 
not done anymore so the setting doesn't matter
-//        if (keyspace.replicationStrategy.hasTransientReplicas()
-//            && table.params.readRepair != ReadRepairStrategy.NONE)
-//        {
-//            throw ire("read_repair must be set to 'NONE' for transiently 
replicated keyspaces");
-//        }
-
         if (!table.params.compression.isEnabled() && 
!SchemaConstants.isSystemKeyspace(table.keyspace))
             Guardrails.uncompressedTablesEnabled.ensureEnabled(state);
 
diff --git a/src/java/org/apache/cassandra/locator/ReplicaPlans.java 
b/src/java/org/apache/cassandra/locator/ReplicaPlans.java
index 01de6d4a5d..34bca5d18a 100644
--- a/src/java/org/apache/cassandra/locator/ReplicaPlans.java
+++ b/src/java/org/apache/cassandra/locator/ReplicaPlans.java
@@ -234,6 +234,10 @@ public class ReplicaPlans
 
         EndpointsForToken replicas = 
metadata.placements.get(keyspace.getMetadata().params.replication).reads.forToken(key.getToken()).get();
 
+        // A transient replica holds no data for the range it witnesses, so it 
would resolve against nothing
+        // and write a value that discards every prior increment.
+        replicas = replicas.filter(Replica::isFull);
+
         // CASSANDRA-13043: filter out those endpoints not accepting clients 
yet, maybe because still bootstrapping
         replicas = replicas.filter(replica -> 
StorageService.instance.isRpcReady(replica.endpoint()));
 
diff --git a/src/java/org/apache/cassandra/replication/TrackedWriteRequest.java 
b/src/java/org/apache/cassandra/replication/TrackedWriteRequest.java
index 9a5ab64157..1b0bb5856d 100644
--- a/src/java/org/apache/cassandra/replication/TrackedWriteRequest.java
+++ b/src/java/org/apache/cassandra/replication/TrackedWriteRequest.java
@@ -195,7 +195,14 @@ public class TrackedWriteRequest
         ReplicaPlan.ForWrite plan = ReplicaPlans.forWrite(keyspace, 
consistencyLevel, token, ReplicaPlans.writeAll);
         AbstractReplicationStrategy rs = plan.replicationStrategy();
 
-        if (plan.lookup(FBUtilities.getBroadcastAddressAndPort()) == null)
+        Replica localReplica = 
plan.lookup(FBUtilities.getBroadcastAddressAndPort());
+
+        // A counter leader resolves the increment against its local data, so 
we can't use transient replicas as data replicas.
+        boolean witnessForCounter = localReplica != null
+                                    && localReplica.isTransient()
+                                    && mutation instanceof CounterMutation;
+
+        if (localReplica == null || witnessForCounter)
         {
             logger.trace("Remote tracked request {} {}", mutation, plan);
             writeMetrics.remoteRequests.mark();
diff --git a/src/java/org/apache/cassandra/service/StorageService.java 
b/src/java/org/apache/cassandra/service/StorageService.java
index 15512ac981..ca036dbe33 100644
--- a/src/java/org/apache/cassandra/service/StorageService.java
+++ b/src/java/org/apache/cassandra/service/StorageService.java
@@ -98,6 +98,7 @@ import org.apache.cassandra.config.DataStorageSpec;
 import org.apache.cassandra.config.DatabaseDescriptor;
 import org.apache.cassandra.cql3.QueryHandler;
 import org.apache.cassandra.cql3.QueryProcessor;
+import org.apache.cassandra.cql3.statements.schema.AlterKeyspaceStatement;
 import org.apache.cassandra.db.ColumnFamilyStore;
 import org.apache.cassandra.db.DecoratedKey;
 import org.apache.cassandra.db.Keyspace;
@@ -5599,6 +5600,19 @@ public class StorageService extends 
NotificationBroadcasterSupport implements IE
         return PaxosRepair.getSkipPaxosRepairCompatibilityCheck();
     }
 
+    @Override
+    public void setAllowUnsafeWitnessPromotion(boolean allow)
+    {
+        AlterKeyspaceStatement.setAllowUnsafeWitnessPromotion(allow);
+        logger.info("AllowUnsafeWitnessPromotion set to {} via jmx", allow);
+    }
+
+    @Override
+    public boolean getAllowUnsafeWitnessPromotion()
+    {
+        return AlterKeyspaceStatement.getAllowUnsafeWitnessPromotion();
+    }
+
     @Override
     public boolean topPartitionsEnabled()
     {
diff --git a/src/java/org/apache/cassandra/service/StorageServiceMBean.java 
b/src/java/org/apache/cassandra/service/StorageServiceMBean.java
index 1e6b8e6a46..28e44de9a7 100644
--- a/src/java/org/apache/cassandra/service/StorageServiceMBean.java
+++ b/src/java/org/apache/cassandra/service/StorageServiceMBean.java
@@ -1366,6 +1366,9 @@ public interface StorageServiceMBean extends 
NotificationEmitter
     public void setSkipPaxosRepairCompatibilityCheck(boolean v);
     public boolean getSkipPaxosRepairCompatibilityCheck();
 
+    public void setAllowUnsafeWitnessPromotion(boolean allow);
+    public boolean getAllowUnsafeWitnessPromotion();
+
     String getToken(String keyspaceName, String table, String partitionKey);
     public boolean topPartitionsEnabled();
     public int getMaxTopSizePartitionCount();
diff --git a/src/java/org/apache/cassandra/service/paxos/PaxosPrepare.java 
b/src/java/org/apache/cassandra/service/paxos/PaxosPrepare.java
index 97de0841e3..25c41f0c06 100644
--- a/src/java/org/apache/cassandra/service/paxos/PaxosPrepare.java
+++ b/src/java/org/apache/cassandra/service/paxos/PaxosPrepare.java
@@ -20,6 +20,7 @@ package org.apache.cassandra.service.paxos;
 
 import java.io.IOException;
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.Collections;
 import java.util.List;
 import java.util.Map;
@@ -31,6 +32,8 @@ import java.util.stream.Collectors;
 import javax.annotation.Nonnull;
 import javax.annotation.Nullable;
 
+import com.google.common.annotations.VisibleForTesting;
+
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -447,24 +450,10 @@ public class PaxosPrepare extends 
PaxosRequestCallback<PaxosPrepare.Response> im
         Message<R> selfMessage = null;
         Message<R> summaryMessage = null;
         Id readId = Id.nextId();
-        Replica localReplica = 
participants.lookup(FBUtilities.getBroadcastAddressAndPort());
-        Replica dataNode = localReplica != null && localReplica.isFull()
-                           ? localReplica
-                           : null;
+        Replica dataNode = selectDataNode(participants, 
FBUtilities.getBroadcastAddressAndPort());
         int[] summaryHostIds = new int[participants.sizeOfPoll() - 1]; // all 
nodes except data node
         int summaryIndex = 0;
         ClusterMetadata metadata = ClusterMetadata.current();
-        if (dataNode == null)
-        {
-            for (int i = 0, size = participants.sizeOfPoll() ; i < size ; i++)
-            {
-                Replica replica = participants.voterReplica(i);
-                if (!replica.isFull() || 
participants.electorate.isPending(replica.endpoint()))
-                    continue;
-                dataNode = replica;
-                break;
-            }
-        }
 
         checkState(dataNode != null, "Couldn't find a data node to use");
         int dataNodeId  = metadata.directory.peerId(dataNode.endpoint()).id();
@@ -474,6 +463,8 @@ public class PaxosPrepare extends 
PaxosRequestCallback<PaxosPrepare.Response> im
             if (replica != dataNode && 
!participants.electorate.isPending(replica.endpoint()))
                 summaryHostIds[summaryIndex++] = 
metadata.directory.peerId(replica.endpoint()).id();
         }
+        if (summaryIndex < summaryHostIds.length)
+            summaryHostIds = Arrays.copyOf(summaryHostIds, summaryIndex);
 
         for (int i = 0, size = participants.sizeOfPoll() ; i < size ; ++i)
         {
@@ -506,6 +497,23 @@ public class PaxosPrepare extends 
PaxosRequestCallback<PaxosPrepare.Response> im
         }
     }
 
+    @VisibleForTesting
+    static Replica selectDataNode(Participants participants, 
InetAddressAndPort local)
+    {
+        Replica localReplica = participants.lookup(local);
+        if (localReplica != null && localReplica.isFull() && 
!participants.electorate.isPending(local))
+            return localReplica;
+
+        for (int i = 0, size = participants.sizeOfPoll() ; i < size ; i++)
+        {
+            Replica replica = participants.voterReplica(i);
+            if (!replica.isFull() || 
participants.electorate.isPending(replica.endpoint()))
+                continue;
+            return replica;
+        }
+        return null;
+    }
+
     private static <R extends AbstractRequest<R>> void 
startUntracked(PaxosPrepare prepare, Participants participants, Message<R> 
send, BiFunction<R, RequestTime, Future<Response>> selfHandler)
     {
         prepare.haveTrackedDataResponseIfNeeded = true;
diff --git 
a/test/distributed/org/apache/cassandra/distributed/test/TrackedCounterForwardingTest.java
 
b/test/distributed/org/apache/cassandra/distributed/test/TrackedCounterForwardingTest.java
index 656b3be4f5..611491b939 100644
--- 
a/test/distributed/org/apache/cassandra/distributed/test/TrackedCounterForwardingTest.java
+++ 
b/test/distributed/org/apache/cassandra/distributed/test/TrackedCounterForwardingTest.java
@@ -35,6 +35,8 @@ import static 
org.apache.cassandra.distributed.shared.AssertUtils.row;
  *
  * With 6 nodes and RF=3, we can pick a coordinator that is definitely
  * not a replica for a specific partition.
+ *
+ * For a keyspace with witnesses, see {@link 
TrackedCounterWitnessForwardingTest}.
  */
 public class TrackedCounterForwardingTest extends TestBaseImpl
 {
diff --git 
a/test/distributed/org/apache/cassandra/distributed/test/TrackedCounterWitnessForwardingTest.java
 
b/test/distributed/org/apache/cassandra/distributed/test/TrackedCounterWitnessForwardingTest.java
new file mode 100644
index 0000000000..6c9fef22a2
--- /dev/null
+++ 
b/test/distributed/org/apache/cassandra/distributed/test/TrackedCounterWitnessForwardingTest.java
@@ -0,0 +1,188 @@
+/*
+ * 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;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+
+import org.junit.Test;
+
+import org.apache.cassandra.config.DatabaseDescriptor;
+import org.apache.cassandra.db.ConsistencyLevel;
+import org.apache.cassandra.db.DecoratedKey;
+import org.apache.cassandra.dht.Token;
+import org.apache.cassandra.distributed.Cluster;
+import org.apache.cassandra.distributed.api.Feature;
+import org.apache.cassandra.locator.Replica;
+import org.apache.cassandra.locator.ReplicaPlans;
+import org.apache.cassandra.schema.Schema;
+import org.apache.cassandra.tcm.ClusterMetadata;
+import org.apache.cassandra.utils.ByteBufferUtil;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * Counter writes on a keyspace with witnesses (transient replicas). The 
witness-free equivalent is
+ * {@link TrackedCounterForwardingTest}.
+ *
+ * A counter leader resolves the increment against its local data
+ * ({@link org.apache.cassandra.db.CounterMutation#processModifications}) and 
replicates the resolved
+ * absolute value. A witness holds no data for the range it witnesses, so a 
witness elected leader resolves
+ * against nothing and writes a value discarding every prior increment.
+ *
+ * The proximity implementation sorts witnesses first, which is what
+ * {@link ReplicaPlans#findCounterLeaderReplica} falls back on when no replica 
is in the local
+ * datacenter. See {@link 
WitnessAlwaysReadsFullReplicaTest.TransientFirstProximity}.
+ */
+public class TrackedCounterWitnessForwardingTest extends TestBaseImpl
+{
+    private static final String TABLE = "counters";
+    private static final String QUALIFIED_TABLE = KEYSPACE + '.' + TABLE;
+    private static final String KEY = "1";
+
+    private static Cluster witnessCluster() throws IOException
+    {
+        Cluster cluster = Cluster.build(3)
+                                 .withConfig(cfg -> cfg.with(Feature.NETWORK)
+                                                       .with(Feature.GOSSIP)
+                                                       // NATIVE_PROTOCOL so 
Instance startup reaches
+                                                       // 
CassandraDaemon#startNativeTransport, which sets
+                                                       // rpc-readiness. 
findCounterLeaderReplica filters on it.
+                                                       
.with(Feature.NATIVE_PROTOCOL)
+                                                       
.set("transient_replication_enabled", "true")
+                                                       .set("dynamic_snitch", 
false)
+                                                       .set("node_proximity", 
WitnessAlwaysReadsFullReplicaTest.TransientFirstProximity.class.getName()))
+                                 .start();
+        cluster.schemaChange("CREATE KEYSPACE " + KEYSPACE + " WITH 
replication = " +
+                             "{'class': 'SimpleStrategy', 
'replication_factor': '3/1'} AND replication_type='tracked'");
+        cluster.schemaChange("CREATE TABLE " + QUALIFIED_TABLE + " (k text 
PRIMARY KEY, c counter)");
+        return cluster;
+    }
+
+    @Test
+    public void testElectedLeaderIsAlwaysFull() throws IOException
+    {
+        try (Cluster cluster = witnessCluster())
+        {
+            // Election picks at random among candidates, so one election 
proves nothing
+            int elections = 500;
+            int transientElections = cluster.get(1).callOnInstance(() -> {
+                ClusterMetadata metadata = ClusterMetadata.current();
+                DecoratedKey key = Schema.instance.getTableMetadata(KEYSPACE, 
TABLE)
+                                                  
.partitioner.decorateKey(ByteBufferUtil.bytes(KEY));
+                String localDc = 
DatabaseDescriptor.getLocator().local().datacenter;
+
+                int transientCount = 0;
+                for (int i = 0; i < elections; i++)
+                {
+                    if (ReplicaPlans.findCounterLeaderReplica(metadata, 
KEYSPACE, key, localDc, ConsistencyLevel.ONE).isTransient())
+                        transientCount++;
+                }
+                return transientCount;
+            });
+
+            assertThat(transientElections)
+                .describedAs("a witness was elected counter leader; it holds 
no data for the range it witnesses")
+                .isZero();
+        }
+    }
+
+    @Test
+    public void testIncrementsAreNotLost() throws IOException
+    {
+        try (Cluster cluster = witnessCluster())
+        {
+            int increments = 50;
+            for (int i = 0; i < increments; i++)
+                cluster.coordinator(1).execute("UPDATE " + QUALIFIED_TABLE + " 
SET c = c + 1 WHERE k = ?",
+                                               
org.apache.cassandra.distributed.api.ConsistencyLevel.QUORUM, KEY);
+
+            Object[][] result = cluster.coordinator(1).execute("SELECT c FROM 
" + QUALIFIED_TABLE + " WHERE k = ?",
+                                                              
org.apache.cassandra.distributed.api.ConsistencyLevel.QUORUM, KEY);
+            assertThat(result).hasNumberOfRows(1);
+            assertThat((long) result[0][0])
+                .describedAs("an increment resolved against a witness's empty 
data would reset the counter")
+                .isEqualTo(increments);
+        }
+    }
+
+    /**
+     * With every full replica for the token shut down, the write has no 
candidate leader. It must fail
+     * rather than fall back on the witness, which would reset the counter to 
the increment's own value.
+     */
+    @Test
+    public void testUnavailableWhenNoFullReplicaIsUp() throws IOException
+    {
+        try (Cluster cluster = witnessCluster())
+        {
+            cluster.coordinator(1).execute("UPDATE " + QUALIFIED_TABLE + " SET 
c = c + 7 WHERE k = ?",
+                                           
org.apache.cassandra.distributed.api.ConsistencyLevel.QUORUM, KEY);
+
+            List<Integer> fullReplicaNodes = fullReplicaNodesFor(cluster, KEY);
+            assertThat(fullReplicaNodes).describedAs("a 3/1 keyspace has two 
full replicas").hasSize(2);
+            int coordinator = nodeOtherThan(cluster, fullReplicaNodes);
+
+            // Shut down rather than filtering messages: a message filter does 
not convict the node in the
+            // failure detector, so a leader would still be elected and the 
write would time out instead
+            for (int node : fullReplicaNodes)
+                cluster.get(node).shutdown().get();
+
+            assertThatThrownBy(() ->
+                cluster.coordinator(coordinator).execute("UPDATE " + 
QUALIFIED_TABLE + " SET c = c + 1 WHERE k = ?",
+                                                         
org.apache.cassandra.distributed.api.ConsistencyLevel.ONE, KEY))
+                .describedAs("with no full replica up the counter write must 
fail rather than be led by the witness")
+                .isInstanceOf(Throwable.class);
+        }
+        catch (Exception e)
+        {
+            throw new RuntimeException(e);
+        }
+    }
+
+    private static List<Integer> fullReplicaNodesFor(Cluster cluster, String 
key)
+    {
+        String endpoints = cluster.get(1).callOnInstance(() -> {
+            ClusterMetadata metadata = ClusterMetadata.current();
+            Token token = Schema.instance.getTableMetadata(KEYSPACE, TABLE)
+                                         
.partitioner.getToken(ByteBufferUtil.bytes(key));
+            StringBuilder sb = new StringBuilder();
+            
metadata.placements.get(Schema.instance.getKeyspaceMetadata(KEYSPACE).params.replication)
+                    .reads.forToken(token).get()
+                    .stream()
+                    .filter(Replica::isFull)
+                    .forEach(r -> sb.append(sb.length() == 0 ? "" : 
",").append(r.endpoint().getHostAddress(false)));
+            return sb.toString();
+        });
+
+        List<Integer> nodes = new ArrayList<>();
+        for (String endpoint : endpoints.split(","))
+            
nodes.add(Integer.parseInt(endpoint.substring(endpoint.lastIndexOf('.') + 1)));
+        return nodes;
+    }
+
+    private static int nodeOtherThan(Cluster cluster, List<Integer> excluded)
+    {
+        for (int node = 1; node <= cluster.size(); node++)
+            if (!excluded.contains(node))
+                return node;
+        throw new AssertionError("no node available outside " + excluded);
+    }
+}
diff --git 
a/test/unit/org/apache/cassandra/service/paxos/PaxosWitnessDataNodeTest.java 
b/test/unit/org/apache/cassandra/service/paxos/PaxosWitnessDataNodeTest.java
new file mode 100644
index 0000000000..c69cd2be21
--- /dev/null
+++ b/test/unit/org/apache/cassandra/service/paxos/PaxosWitnessDataNodeTest.java
@@ -0,0 +1,250 @@
+/*
+ * 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.service.paxos;
+
+import java.nio.ByteBuffer;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.function.Predicate;
+
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+import org.apache.cassandra.ServerTestUtils;
+import org.apache.cassandra.config.CassandraRelevantProperties;
+import org.apache.cassandra.db.ConsistencyLevel;
+import org.apache.cassandra.db.Keyspace;
+import org.apache.cassandra.db.marshal.Int32Type;
+import org.apache.cassandra.dht.Murmur3Partitioner;
+import org.apache.cassandra.dht.Token;
+import org.apache.cassandra.distributed.test.log.ClusterMetadataTestHelper;
+import org.apache.cassandra.locator.InetAddressAndPort;
+import org.apache.cassandra.locator.Replica;
+import org.apache.cassandra.replication.MutationJournal;
+import org.apache.cassandra.schema.Schema;
+import org.apache.cassandra.schema.SchemaTestUtil;
+import org.apache.cassandra.schema.TableMetadata;
+import org.apache.cassandra.tcm.ClusterMetadata;
+import org.apache.cassandra.utils.ByteBufferUtil;
+import org.apache.cassandra.utils.FBUtilities;
+
+import static org.apache.cassandra.service.paxos.PaxosPrepare.selectDataNode;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+public class PaxosWitnessDataNodeTest
+{
+    private static final String KEYSPACE = "paxos_witness_test";
+    private static final String SINGLE_FULL_KEYSPACE = 
"paxos_witness_single_full_test";
+    private static final String TABLE = "tbl";
+
+    private static InetAddressAndPort node1;
+    private static InetAddressAndPort node2;
+    private static InetAddressAndPort node3;
+
+    @BeforeClass
+    public static void setUpClass() throws Exception
+    {
+        
CassandraRelevantProperties.PARTITIONER.setString(Murmur3Partitioner.class.getName());
+        ServerTestUtils.daemonInitialization();
+        // Not prepareServer(): it registers the local node on a random token, 
and these tests place all
+        // three nodes deterministically so the replica set for the key is 
known
+        ServerTestUtils.prepareServerNoRegister();
+        ServerTestUtils.markCMS();
+        MutationJournal.start();
+
+        node1 = FBUtilities.getBroadcastAddressAndPort();
+        node2 = ClusterMetadataTestHelper.addr(2);
+        node3 = ClusterMetadataTestHelper.addr(3);
+
+        ClusterMetadataTestHelper.register(node1, "datacenter1", "rack1");
+        ClusterMetadataTestHelper.register(node2, "datacenter1", "rack1");
+        ClusterMetadataTestHelper.register(node3, "datacenter1", "rack1");
+        ClusterMetadataTestHelper.join(node1, new 
Murmur3Partitioner.LongToken(Long.MIN_VALUE / 2));
+        ClusterMetadataTestHelper.join(node2, new 
Murmur3Partitioner.LongToken(0L));
+        ClusterMetadataTestHelper.join(node3, new 
Murmur3Partitioner.LongToken(Long.MAX_VALUE / 2));
+
+        ClusterMetadataTestHelper.createKeyspace("CREATE KEYSPACE " + KEYSPACE 
+
+                                                 " WITH replication = 
{'class': 'SimpleStrategy', 'replication_factor': '3/1'}" +
+                                                 " AND replication_type = 
'tracked'");
+        SchemaTestUtil.announceNewTable(TableMetadata.builder(KEYSPACE, TABLE)
+                                                     
.addPartitionKeyColumn("k", Int32Type.instance)
+                                                     .addRegularColumn("v", 
Int32Type.instance)
+                                                     .build());
+
+        // A second keyspace with only one full replica. Killing it leaves a 
quorum of witnesses, which is
+        // the only way to reach a live poll with no full replica in it: 
replica ordering puts full
+        // replicas first, so a first-match selection would otherwise always 
find one.
+        ClusterMetadataTestHelper.createKeyspace("CREATE KEYSPACE " + 
SINGLE_FULL_KEYSPACE +
+                                                 " WITH replication = 
{'class': 'SimpleStrategy', 'replication_factor': '3/2'}" +
+                                                 " AND replication_type = 
'tracked'");
+        
SchemaTestUtil.announceNewTable(TableMetadata.builder(SINGLE_FULL_KEYSPACE, 
TABLE)
+                                                     
.addPartitionKeyColumn("k", Int32Type.instance)
+                                                     .addRegularColumn("v", 
Int32Type.instance)
+                                                     .build());
+    }
+
+    private static Paxos.Participants participants(Predicate<Replica> isAlive)
+    {
+        return participants(KEYSPACE, ByteBufferUtil.bytes(1), isAlive);
+    }
+
+    private static Paxos.Participants participants(String keyspace, 
Predicate<Replica> isAlive)
+    {
+        return participants(keyspace, ByteBufferUtil.bytes(1), isAlive);
+    }
+
+    private static Paxos.Participants participants(String keyspace, ByteBuffer 
key, Predicate<Replica> isAlive)
+    {
+        TableMetadata table = Schema.instance.getTableMetadata(keyspace, 
TABLE);
+        assertNotNull("test schema was not created", table);
+        Token token = table.partitioner.getToken(key);
+        return Paxos.Participants.get(ClusterMetadata.current(), table, token, 
ConsistencyLevel.SERIAL, isAlive);
+    }
+
+
+    /**
+     * Replica ordering puts full replicas first, so a live poll normally 
contains one whether or not the
+     * selection filters for it. With a single full replica and that replica 
down, the surviving quorum is
+     * all witnesses, and the selection has to report that no data node is 
available rather than picking a
+     * witness that cannot answer the read.
+     */
+    @Test
+    public void testNoDataNodeWhenOnlyWitnessesSurvive()
+    {
+        Paxos.Participants all = participants(SINGLE_FULL_KEYSPACE, replica -> 
true);
+        assertEquals(1, fullReplicas(all).size());
+        InetAddressAndPort onlyFull = fullReplicas(all).get(0).endpoint();
+
+        Paxos.Participants participants = participants(SINGLE_FULL_KEYSPACE,
+                                                      replica -> 
!replica.endpoint().equals(onlyFull));
+
+        assertEquals(2, participants.sizeOfPoll());
+        assertEquals(participants.sizeOfPoll(), 
witnesses(participants).size());
+
+        Replica dataNode = selectDataNode(participants, 
participants.voterReplica(0).endpoint());
+        assertNull("startTracked fails the prepare with \"Couldn't find a data 
node to use\" in this state",
+                   dataNode);
+    }
+
+    private static List<Replica> fullReplicas(Paxos.Participants participants)
+    {
+        List<Replica> full = new ArrayList<>();
+        for (int i = 0, size = participants.sizeOfPoll(); i < size; i++)
+            if (participants.voterReplica(i).isFull())
+                full.add(participants.voterReplica(i));
+        return full;
+    }
+
+    private static List<Replica> witnesses(Paxos.Participants participants)
+    {
+        List<Replica> transientReplicas = new ArrayList<>();
+        for (int i = 0, size = participants.sizeOfPoll(); i < size; i++)
+            if (participants.voterReplica(i).isTransient())
+                transientReplicas.add(participants.voterReplica(i));
+        return transientReplicas;
+    }
+
+
+    @Test
+    public void testWitnessVotesButIsNotTheDataNode()
+    {
+        Paxos.Participants participants = participants(replica -> true);
+
+        assertEquals(3, participants.sizeOfPoll());
+        assertEquals(1, witnesses(participants).size());
+
+        Replica dataNode = selectDataNode(participants, node1);
+        assertNotNull(dataNode);
+        assertTrue(dataNode.isFull());
+    }
+
+    /**
+     * The case a cluster test cannot reach: with one full replica down, 
quorum is still met by the
+     * surviving full replica plus the witness, so the prepare proceeds and 
the data node has to be the
+     * surviving full replica rather than the witness.
+     */
+    @Test
+    public void testDataNodeIsTheSurvivingFullReplica()
+    {
+        Paxos.Participants all = participants(replica -> true);
+        Replica witness = witnesses(all).get(0);
+        Replica fullToKill = null;
+        for (int i = 0, size = all.sizeOfPoll(); i < size; i++)
+        {
+            Replica replica = all.voterReplica(i);
+            if (replica.isFull())
+            {
+                fullToKill = replica;
+                break;
+            }
+        }
+        assertNotNull(fullToKill);
+
+        InetAddressAndPort dead = fullToKill.endpoint();
+        Paxos.Participants participants = participants(replica -> 
!replica.endpoint().equals(dead));
+
+        assertEquals(2, participants.sizeOfPoll());
+
+        // The coordinator here is the witness, so the local-replica shortcut 
must not apply
+        Replica dataNode = selectDataNode(participants, witness.endpoint());
+        assertNotNull(dataNode);
+        assertTrue(dataNode.isFull());
+        assertNotEquals(dead, dataNode.endpoint());
+    }
+
+    @Test
+    public void testPendingNodeNotSelectedAsDataNode() throws Exception
+    {
+        InetAddressAndPort node4 = ClusterMetadataTestHelper.addr(4);
+        ClusterMetadataTestHelper.register(node4, "datacenter1", "rack1");
+        ClusterMetadataTestHelper.joinPartially(node4, new 
Murmur3Partitioner.LongToken(Long.MAX_VALUE / 4));
+
+        try
+        {
+            TableMetadata table = Schema.instance.getTableMetadata(KEYSPACE, 
TABLE);
+            Token token = new Murmur3Partitioner.LongToken(Long.MIN_VALUE / 4);
+            Paxos.Participants participants = 
Paxos.Participants.get(ClusterMetadata.current(), table, token, 
ConsistencyLevel.SERIAL, replica -> true);
+            assertTrue(participants.electorate.isPending(node1));
+
+            Replica dataNode = selectDataNode(participants, node1);
+            assertNotNull(dataNode);
+            assertTrue(dataNode.isFull());
+            assertNotEquals(node1, dataNode.endpoint());
+        }
+        finally
+        {
+            
ClusterMetadataTestHelper.commit(ClusterMetadataTestHelper.getBootstrapPlan(node4).finishJoin);
+        }
+    }
+
+    /**
+     * Paxos ballots and promises live in system.paxos, a node-local table, so 
being a witness for a data
+     * range does not affect a node's ability to hold Paxos state for it.
+     */
+    @Test
+    public void testPaxosStateKeyspaceIsNotTransientlyReplicated()
+    {
+        Keyspace systemKeyspace = Keyspace.open("system");
+        
assertFalse(systemKeyspace.getReplicationStrategy().hasTransientReplicas());
+    }
+}
diff --git 
a/test/unit/org/apache/cassandra/tcm/transformations/AlterKeyspaceStatementUnsafeTransientChangesTest.java
 
b/test/unit/org/apache/cassandra/tcm/transformations/AlterKeyspaceStatementUnsafeTransientChangesTest.java
new file mode 100644
index 0000000000..f33c8b37ad
--- /dev/null
+++ 
b/test/unit/org/apache/cassandra/tcm/transformations/AlterKeyspaceStatementUnsafeTransientChangesTest.java
@@ -0,0 +1,95 @@
+/*
+ * 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.transformations;
+
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+import org.apache.cassandra.ServerTestUtils;
+import org.apache.cassandra.config.CassandraRelevantProperties;
+import org.apache.cassandra.dht.Murmur3Partitioner;
+import org.apache.cassandra.replication.MutationJournal;
+import org.apache.cassandra.tcm.ClusterMetadata;
+
+import static 
org.apache.cassandra.config.CassandraRelevantProperties.ALLOW_UNSAFE_TRANSIENT_CHANGES;
+import static org.apache.cassandra.cql3.CQLTester.schemaChange;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * The transient replication validation rules in AlterKeyspaceStatement are 
skipped when
+ * cassandra.allow_unsafe_transient_changes is set, which is how an operator 
opts into an unsafe
+ * alteration. The flag is read into a static final field at class 
initialization, so it has to be set
+ * before AlterKeyspaceStatement loads. That is why these cases need their own 
class rather than
+ * living in AlterSchemaMutationTrackingTest, which asserts the same 
alterations are rejected.
+ */
+public class AlterKeyspaceStatementUnsafeTransientChangesTest
+{
+    private static final AtomicInteger ksCounter = new AtomicInteger();
+
+    @BeforeClass
+    public static void setUpClass() throws Exception
+    {
+        ALLOW_UNSAFE_TRANSIENT_CHANGES.setBoolean(true);
+        
CassandraRelevantProperties.PARTITIONER.setString(Murmur3Partitioner.class.getName());
+        ServerTestUtils.daemonInitialization();
+        ServerTestUtils.prepareServer();
+        MutationJournal.start();
+    }
+
+    private static String nextKsName()
+    {
+        return "ks" + ksCounter.incrementAndGet();
+    }
+
+    @Test
+    public void testAddingWitnessesDuringMigrationAllowedWhenOptedIn()
+    {
+        String ksName = nextKsName();
+        schemaChange("CREATE KEYSPACE " + ksName +
+                     " WITH replication = {'class': 'SimpleStrategy', 
'replication_factor': '3'}" +
+                     " AND replication_type = 'untracked'");
+        schemaChange(String.format("CREATE TABLE %s.tbl (pk int PRIMARY KEY, 
val int)", ksName));
+        schemaChange(String.format("ALTER KEYSPACE %s WITH replication_type = 
'tracked'", ksName));
+        
assertTrue(ClusterMetadata.current().mutationTrackingMigrationState.isMigrating(ksName));
+
+        schemaChange("ALTER KEYSPACE " + ksName +
+                     " WITH replication = {'class': 'SimpleStrategy', 
'replication_factor': '3/1'}");
+
+        assertTrue(ClusterMetadata.current().schema.getKeyspaceMetadata(ksName)
+                                 
.replicationStrategy.getReplicationFactor().hasTransientReplicas());
+    }
+
+    /** Enabling tracking and adding witnesses in a single statement. */
+    @Test
+    public void testEnablingTrackingWithWitnessesAllowedWhenOptedIn()
+    {
+        String ksName = nextKsName();
+        schemaChange("CREATE KEYSPACE " + ksName +
+                     " WITH replication = {'class': 'SimpleStrategy', 
'replication_factor': '3'}" +
+                     " AND replication_type = 'untracked'");
+
+        schemaChange("ALTER KEYSPACE " + ksName +
+                     " WITH replication = {'class': 'SimpleStrategy', 
'replication_factor': '3/1'}" +
+                     " AND replication_type = 'tracked'");
+
+        assertTrue(ClusterMetadata.current().schema.getKeyspaceMetadata(ksName)
+                                 
.replicationStrategy.getReplicationFactor().hasTransientReplicas());
+    }
+}
diff --git 
a/test/unit/org/apache/cassandra/tcm/transformations/AlterKeyspaceWitnessTransitionTest.java
 
b/test/unit/org/apache/cassandra/tcm/transformations/AlterKeyspaceWitnessTransitionTest.java
new file mode 100644
index 0000000000..55e2ad8291
--- /dev/null
+++ 
b/test/unit/org/apache/cassandra/tcm/transformations/AlterKeyspaceWitnessTransitionTest.java
@@ -0,0 +1,284 @@
+/*
+ * 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.transformations;
+
+import java.net.UnknownHostException;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.junit.BeforeClass;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.Parameterized;
+
+import org.apache.cassandra.ServerTestUtils;
+import org.apache.cassandra.config.CassandraRelevantProperties;
+import org.apache.cassandra.dht.Murmur3Partitioner;
+import org.apache.cassandra.distributed.test.log.ClusterMetadataTestHelper;
+import org.apache.cassandra.exceptions.ConfigurationException;
+import org.apache.cassandra.locator.InetAddressAndPort;
+import org.apache.cassandra.replication.MutationJournal;
+import org.apache.cassandra.schema.KeyspaceMetadata;
+import org.apache.cassandra.schema.ReplicationParams;
+import org.apache.cassandra.tcm.ClusterMetadata;
+import org.apache.cassandra.tcm.membership.Location;
+
+import static 
org.apache.cassandra.config.CassandraRelevantProperties.ALLOW_UNSAFE_TRANSIENT_CHANGES;
+import static 
org.apache.cassandra.config.CassandraRelevantProperties.ALLOW_UNSAFE_WITNESS_PROMOTION;
+import static org.apache.cassandra.cql3.CQLTester.schemaChange;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * Which replication changes a keyspace with witnesses (transient replicas) 
permits.
+ *
+ * Each rejected transition asserts the whole rejection message rather than 
just that the statement
+ * failed. Several rules overlap here -- witness promotion, the migration 
guard, the pre-existing
+ * replica-count rules, and the requirement that transient replication implies 
mutation tracking -- so a
+ * test asserting only failure can pass because the wrong rule fired.
+ *
+ * Datacenter-aware rows are present because {@link 
org.apache.cassandra.locator.NetworkTopologyStrategy}
+ * reports the sum of its per-datacenter factors: comparing aggregates both 
misses a promotion in one
+ * datacenter offset by a reduction in another, and rejects two independently 
legal per-datacenter
+ * changes whose aggregates resemble a promotion.
+ */
+@RunWith(Parameterized.class)
+public class AlterKeyspaceWitnessTransitionTest
+{
+    private static final String SIMPLE_3 = "{'class': 'SimpleStrategy', 
'replication_factor': '3'}";
+    private static final String SIMPLE_3_1 = "{'class': 'SimpleStrategy', 
'replication_factor': '3/1'}";
+    private static final String NTS_3_1_AND_3 = "{'class': 
'NetworkTopologyStrategy', 'DC1': '3/1', 'DC2': '3'}";
+    private static final String NTS_3_1_AND_2 = "{'class': 
'NetworkTopologyStrategy', 'DC1': '3/1', 'DC2': '2'}";
+
+    private static final AtomicInteger ksCounter = new AtomicInteger();
+
+    private static final String TRACKED = "tracked";
+    private static final String UNTRACKED = "untracked";
+    private static final boolean MIGRATING = true;
+    private static final boolean NO_MIGRATION = false;
+
+    @Parameterized.Parameter(0)
+    public String startReplication;
+
+    /** The replication type the keyspace is created with. */
+    @Parameterized.Parameter(1)
+    public String startReplicationType;
+
+    /** Whether to leave a mutation tracking migration in flight before the 
alteration under test. */
+    @Parameterized.Parameter(2)
+    public boolean startMigration;
+
+    @Parameterized.Parameter(3)
+    public String proposedReplication;
+
+    /** The replication type to propose, or null to leave it unchanged. */
+    @Parameterized.Parameter(4)
+    public String proposedReplicationType;
+
+    /** The rejection this change must produce, or null when it is permitted. 
*/
+    @Parameterized.Parameter(5)
+    public Rejection expectedRejection;
+
+    @Parameterized.Parameter(6)
+    public String description;
+
+    enum Rejection
+    {
+        WITNESS_PROMOTION("Cannot promote a transient replica of %s to a full 
replica: it holds no data for the " +
+                          "range it witnessed. Set " + 
ALLOW_UNSAFE_WITNESS_PROMOTION.getKey() + "=true over JMX, " +
+                          "or " + ALLOW_UNSAFE_TRANSIENT_CHANGES.getKey() + 
"=true at startup, to allow it, then " +
+                          "run a full repair to distribute the data."),
+        MIGRATION_IN_FLIGHT("Cannot add transient replicas to %s while its 
mutation tracking migration is in " +
+                            "progress. Wait for the migration to complete, 
then alter the replication factor."),
+        WOULD_START_MIGRATION("Cannot enable mutation tracking on %s and add 
transient replicas in the same " +
+                              "statement, because doing so starts a migration. 
Set replication_type = 'tracked' " +
+                              "first, wait for the migration to complete, then 
alter the replication factor."),
+        NEEDS_MUTATION_TRACKING("Transient replication requires mutation 
tracking"),
+        REPLICA_COUNT_RULE("Can't add full replicas if there are any transient 
replicas. You must first remove all " +
+                           "transient replicas, then change the # of full 
replicas, then add back the transient " +
+                           "replicas");
+
+        private final String template;
+
+        Rejection(String template)
+        {
+            this.template = template;
+        }
+
+        String message(String keyspace)
+        {
+            return template.contains("%s") ? String.format(template, keyspace) 
: template;
+        }
+    }
+
+    @Parameterized.Parameters(name = "{6}")
+    public static Collection<Object[]> transitions()
+    {
+        List<Object[]> rows = new ArrayList<>();
+
+        // The migration guard is on the migration, not on transient replicas 
as such
+        rows.add(row(SIMPLE_3, TRACKED, NO_MIGRATION, "3/1", null, null,
+                     "settled tracked keyspace accepts witnesses"));
+        rows.add(row(SIMPLE_3, UNTRACKED, MIGRATING, "3/1", null, 
Rejection.MIGRATION_IN_FLIGHT,
+                     "adding witnesses while a migration is in flight"));
+        rows.add(row(SIMPLE_3, UNTRACKED, NO_MIGRATION, "3/1", TRACKED, 
Rejection.WOULD_START_MIGRATION,
+                     "enabling tracking and adding witnesses at once"));
+
+        // Dropping a witness needs no data movement: replica ordering puts 
the transient replica last, so
+        // lowering the factor removes it from the replica set rather than 
promoting it
+        rows.add(row(SIMPLE_3_1, TRACKED, NO_MIGRATION, "2", null, null, 
"dropping a witness"));
+        rows.add(row(SIMPLE_3_1, TRACKED, NO_MIGRATION, "2", UNTRACKED, null,
+                     "dropping a witness and tracking at once"));
+        rows.add(row(SIMPLE_3, TRACKED, NO_MIGRATION, "3", UNTRACKED, null,
+                     "leaving tracked replication without witnesses"));
+        rows.add(row(SIMPLE_3_1, TRACKED, NO_MIGRATION, "3/1", UNTRACKED, 
Rejection.NEEDS_MUTATION_TRACKING,
+                     "retaining witnesses while leaving tracked replication"));
+
+        // A promoted witness holds no data for the range it witnessed, and 
quorum reads would count it
+        rows.add(row(SIMPLE_3_1, TRACKED, NO_MIGRATION, "3", null, 
Rejection.WITNESS_PROMOTION,
+                     "promoting a witness to a full replica"));
+        rows.add(row(SIMPLE_3_1, TRACKED, NO_MIGRATION, "3", UNTRACKED, 
Rejection.WITNESS_PROMOTION,
+                     "promoting a witness while leaving tracked replication"));
+        rows.add(row(SIMPLE_3_1, TRACKED, NO_MIGRATION, "4/1", null, 
Rejection.WITNESS_PROMOTION,
+                     "increasing full replicas while retaining a witness"));
+
+        // Per-datacenter comparison: the aggregate full replica count is 5 
either side of this change
+        rows.add(row(NTS_3_1_AND_3, TRACKED, NO_MIGRATION,
+                     "{'class': 'NetworkTopologyStrategy', 'DC1': '3', 'DC2': 
'2'}", null,
+                     Rejection.WITNESS_PROMOTION, "promotion masked by a 
reduction in another datacenter"));
+        rows.add(row(NTS_3_1_AND_3, TRACKED, NO_MIGRATION,
+                     "{'class': 'NetworkTopologyStrategy', 'DC1': '3', 'DC2': 
'3'}", null,
+                     Rejection.WITNESS_PROMOTION, "promotion in a single 
datacenter"));
+        // Dropping a witness in one datacenter and adding a full replica in 
another are legal alone; their
+        // aggregates resemble a promotion
+        rows.add(row(NTS_3_1_AND_2, TRACKED, NO_MIGRATION,
+                     "{'class': 'NetworkTopologyStrategy', 'DC1': '2', 'DC2': 
'3'}", null, null,
+                     "legal changes in separate datacenters"));
+        // Not the promotion guard: removing a datacenter drops the aggregate 
full count while a transient
+        // replica remains, which the pre-existing replica-count rule rejects
+        rows.add(row(NTS_3_1_AND_3, TRACKED, NO_MIGRATION,
+                     "{'class': 'NetworkTopologyStrategy', 'DC2': '3'}", null,
+                     Rejection.REPLICA_COUNT_RULE, "removing a witness 
datacenter entirely"));
+
+        return rows;
+    }
+
+    private static Object[] row(String startReplication, String startType, 
boolean startMigration,
+                                String proposedReplication, String 
proposedType, Rejection rejection,
+                                String description)
+    {
+        return new Object[]{ startReplication, startType, startMigration, 
proposedReplication, proposedType,
+                             rejection, description };
+    }
+
+    @BeforeClass
+    public static void setUpClass() throws Exception
+    {
+        
CassandraRelevantProperties.PARTITIONER.setString(Murmur3Partitioner.class.getName());
+        ServerTestUtils.daemonInitialization();
+        ServerTestUtils.prepareServer();
+        MutationJournal.start();
+
+        // Datacenter-aware rows need endpoints in both datacenters for their 
options to be recognised
+        addEndpoints(new byte[]{ 10, 0, 0 }, new Location("DC1", "Rack1"));
+        addEndpoints(new byte[]{ 10, 20, 114 }, new Location("DC2", "Rack1"));
+    }
+
+    private static void addEndpoints(byte[] prefix, Location location) throws 
UnknownHostException
+    {
+        for (byte last = 10; last < 14; last++)
+        {
+            InetAddressAndPort addr = InetAddressAndPort.getByAddress(new 
byte[]{ prefix[0], prefix[1], prefix[2], last });
+            ClusterMetadataTestHelper.addEndpoint(addr, 
Murmur3Partitioner.instance.getRandomToken(), location);
+        }
+    }
+
+    @Test
+    public void testTransition()
+    {
+        String ksName = "ks" + ksCounter.incrementAndGet();
+        schemaChange("CREATE KEYSPACE " + ksName + " WITH replication = " + 
startReplication +
+                     " AND replication_type = '" + startReplicationType + "'");
+
+        if (startMigration)
+        {
+            schemaChange(String.format("CREATE TABLE %s.tbl (pk int PRIMARY 
KEY, val int)", ksName));
+            schemaChange(String.format("ALTER KEYSPACE %s WITH 
replication_type = 'tracked'", ksName));
+            
assertThat(ClusterMetadata.current().mutationTrackingMigrationState.isMigrating(ksName)).isTrue();
+        }
+
+        Map<String, String> before = replicationOf(ksName);
+        String proposed = proposedReplication.startsWith("{")
+                          ? proposedReplication
+                          : "{'class': 'SimpleStrategy', 'replication_factor': 
'" + proposedReplication + "'}";
+        String alter = "ALTER KEYSPACE " + ksName + " WITH replication = " + 
proposed +
+                       (proposedReplicationType == null ? "" : " AND 
replication_type = '" + proposedReplicationType + "'");
+
+        if (expectedRejection != null)
+        {
+            assertThatThrownBy(() -> schemaChange(alter))
+                .hasRootCauseInstanceOf(ConfigurationException.class)
+                .hasRootCauseMessage(expectedRejection.message(ksName));
+            assertThat(replicationOf(ksName)).isEqualTo(before);
+            
assertThat(trackedOf(ksName)).isEqualTo(TRACKED.equals(startReplicationType) || 
startMigration);
+        }
+        else
+        {
+            schemaChange(alter);
+            assertThat(replicationOf(ksName)).isEqualTo(optionsOf(proposed));
+            if (proposedReplicationType != null)
+                
assertThat(trackedOf(ksName)).isEqualTo("tracked".equals(proposedReplicationType));
+        }
+    }
+
+    /**
+     * The options in a CQL replication map, in the form {@link 
ReplicationParams#asMap} returns them:
+     * the strategy class is fully qualified there.
+     */
+    private static Map<String, String> optionsOf(String cqlReplicationMap)
+    {
+        Map<String, String> options = new HashMap<>();
+        for (String entry : cqlReplicationMap.replaceAll("[{}']", 
"").split(","))
+        {
+            String[] keyAndValue = entry.split(":");
+            String key = keyAndValue[0].trim();
+            String value = keyAndValue[1].trim();
+            options.put(key, ReplicationParams.CLASS.equals(key) ? 
"org.apache.cassandra.locator." + value : value);
+        }
+        return options;
+    }
+
+    private static Map<String, String> replicationOf(String ksName)
+    {
+        return keyspace(ksName).params.replication.asMap();
+    }
+
+    private static boolean trackedOf(String ksName)
+    {
+        return keyspace(ksName).params.replicationType.isTracked();
+    }
+
+    private static KeyspaceMetadata keyspace(String ksName)
+    {
+        return ClusterMetadata.current().schema.getKeyspaceMetadata(ksName);
+    }
+}
diff --git 
a/test/unit/org/apache/cassandra/tcm/transformations/AlterSchemaMutationTrackingTest.java
 
b/test/unit/org/apache/cassandra/tcm/transformations/AlterSchemaMutationTrackingTest.java
index 81b8621e99..4192cbffa7 100644
--- 
a/test/unit/org/apache/cassandra/tcm/transformations/AlterSchemaMutationTrackingTest.java
+++ 
b/test/unit/org/apache/cassandra/tcm/transformations/AlterSchemaMutationTrackingTest.java
@@ -36,12 +36,15 @@ 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.StorageService;
+import org.apache.cassandra.service.reads.repair.ReadRepairStrategy;
 import 
org.apache.cassandra.service.replication.migration.KeyspaceMigrationInfo;
 import 
org.apache.cassandra.service.replication.migration.MutationTrackingMigrationState;
 import org.apache.cassandra.tcm.ClusterMetadata;
 import org.apache.cassandra.tcm.Epoch;
 
 import static org.apache.cassandra.cql3.CQLTester.schemaChange;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertTrue;
@@ -375,6 +378,106 @@ public class AlterSchemaMutationTrackingTest
         }
     }
 
+    @Test
+    public void testReadRepairAllowedOnWitnessKeyspace()
+    {
+        String ksName = nextKsName();
+        schemaChange("CREATE KEYSPACE " + ksName +
+                     " WITH replication = {'class': 'SimpleStrategy', 
'replication_factor': '3/1'}" +
+                     " AND replication_type = 'tracked'");
+
+        // CREATE TABLE with a repairing strategy
+        schemaChange(String.format("CREATE TABLE %s.created (pk int PRIMARY 
KEY, val int)" +
+                                   " WITH read_repair = 'BLOCKING'", ksName));
+        assertEquals(ReadRepairStrategy.BLOCKING, readRepairOf(ksName, 
"created"));
+
+        // ALTER TABLE from NONE to a repairing strategy
+        schemaChange(String.format("CREATE TABLE %s.altered (pk int PRIMARY 
KEY, val int)" +
+                                   " WITH read_repair = 'NONE'", ksName));
+        schemaChange(String.format("ALTER TABLE %s.altered WITH read_repair = 
'BLOCKING'", ksName));
+        assertEquals(ReadRepairStrategy.BLOCKING, readRepairOf(ksName, 
"altered"));
+
+        // CREATE TABLE LIKE a source whose strategy is repairing
+        schemaChange(String.format("CREATE TABLE %s.copied LIKE %s.created", 
ksName, ksName));
+        assertEquals(ReadRepairStrategy.BLOCKING, readRepairOf(ksName, 
"copied"));
+    }
+
+    private static ReadRepairStrategy readRepairOf(String keyspace, String 
table)
+    {
+        return ClusterMetadata.current()
+                              .schema
+                              .getKeyspaceMetadata(keyspace)
+                              .getTableOrViewNullable(table)
+                              .params
+                              .readRepair;
+    }
+
+    @Test
+    public void testSecondaryIndexesAllowedWithWitnesses()
+    {
+        // index first, then witnesses
+        String indexFirstKs = nextKsName();
+        schemaChange("CREATE KEYSPACE " + indexFirstKs +
+                     " WITH replication = {'class': 'SimpleStrategy', 
'replication_factor': '3'}" +
+                     " AND replication_type = 'tracked'");
+        schemaChange(String.format("CREATE TABLE %s.tbl (pk int PRIMARY KEY, 
val int)", indexFirstKs));
+        schemaChange(String.format("CREATE INDEX ON %s.tbl (val)", 
indexFirstKs));
+
+        schemaChange("ALTER KEYSPACE " + indexFirstKs +
+                     " WITH replication = {'class': 'SimpleStrategy', 
'replication_factor': '3/1'}");
+
+        
assertTrue(ClusterMetadata.current().schema.getKeyspaceMetadata(indexFirstKs)
+                                 
.replicationStrategy.getReplicationFactor().hasTransientReplicas());
+
+        // witnesses first, then index
+        String witnessFirstKs = nextKsName();
+        schemaChange("CREATE KEYSPACE " + witnessFirstKs +
+                     " WITH replication = {'class': 'SimpleStrategy', 
'replication_factor': '3/1'}" +
+                     " AND replication_type = 'tracked'");
+        schemaChange(String.format("CREATE TABLE %s.tbl (pk int PRIMARY KEY, 
val int)", witnessFirstKs));
+        schemaChange(String.format("CREATE INDEX ON %s.tbl (val)", 
witnessFirstKs));
+
+        
assertFalse(ClusterMetadata.current().schema.getKeyspaceMetadata(witnessFirstKs)
+                                  
.getTableOrViewNullable("tbl").indexes.isEmpty());
+    }
+
+    @Test
+    public void testWitnessPromotionSkippableAtRuntime()
+    {
+        String ksName = nextKsName();
+        schemaChange("CREATE KEYSPACE " + ksName +
+                     " WITH replication = {'class': 'SimpleStrategy', 
'replication_factor': '3/1'}" +
+                     " AND replication_type = 'tracked'");
+        String promote = "ALTER KEYSPACE " + ksName +
+                         " WITH replication = {'class': 'SimpleStrategy', 
'replication_factor': '3'}";
+
+        assertThatThrownBy(() -> schemaChange(promote))
+            .hasRootCauseInstanceOf(ConfigurationException.class);
+
+        StorageService.instance.setAllowUnsafeWitnessPromotion(true);
+        try
+        {
+            
assertTrue(StorageService.instance.getAllowUnsafeWitnessPromotion());
+            schemaChange(promote);
+            
assertFalse(ClusterMetadata.current().schema.getKeyspaceMetadata(ksName)
+                                      
.replicationStrategy.getReplicationFactor().hasTransientReplicas());
+        }
+        finally
+        {
+            StorageService.instance.setAllowUnsafeWitnessPromotion(false);
+        }
+
+        // Cleared again, so a second keyspace is still protected
+        String stillGuarded = nextKsName();
+        schemaChange("CREATE KEYSPACE " + stillGuarded +
+                     " WITH replication = {'class': 'SimpleStrategy', 
'replication_factor': '3/1'}" +
+                     " AND replication_type = 'tracked'");
+        assertThatThrownBy(() ->
+            schemaChange("ALTER KEYSPACE " + stillGuarded +
+                         " WITH replication = {'class': 'SimpleStrategy', 
'replication_factor': '3'}"))
+            .hasRootCauseInstanceOf(ConfigurationException.class);
+    }
+
     /**
      * The mutation journal can't be disabled for tracked replication, so 
attempting to set durable_writes=false
      * on tracked keyspaces needs to fail validation


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

Reply via email to