You are viewing a plain text version of this content. The canonical link for it is here.
Posted to pr@cassandra.apache.org by "aweisberg (via GitHub)" <gi...@apache.org> on 2026/06/15 21:21:52 UTC

Re: [PR] CEP-58: Add failover states and enforce relevant quorum requirements [cassandra]

aweisberg commented on code in PR #4708:
URL: https://github.com/apache/cassandra/pull/4708#discussion_r3398324401


##########
src/java/org/apache/cassandra/locator/ResponseTracker.java:
##########
@@ -0,0 +1,126 @@
+/*
+ * 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.locator;
+
+import org.apache.cassandra.exceptions.RequestFailureReason;
+
+/**
+ * Black-box response tracker encapsulating coordination completion logic.
+ *
+ * Created by replication strategy for each operation, this interface allows
+ * strategies to customize how quorum requirements are calculated and enforced.
+ * Different implementations can provide different semantics.
+ *
+ * The tracker is responsible for:
+ * 1. Recording responses and failures from replicas
+ * 2. Determining when the operation has completed (success or definite failure)
+ * 3. Providing metrics for error messages and monitoring
+ *
+ * Thread safety: Implementations must be thread-safe as onResponse/onFailure
+ * can be called concurrently from multiple network threads.
+ */
+public interface ResponseTracker
+{
+    // TODO: review replica plan members and move here as appropriate
+
+    /**
+     * Record a successful response from a replica.
+     *
+     * @param from endpoint that responded successfully
+     */
+    void onResponse(InetAddressAndPort from);
+
+    /**
+     * Record a failed response from a replica.
+     *
+     * @param from endpoint that failed
+     * @param reason failure reason for metrics and error messages
+     */
+    void onFailure(InetAddressAndPort from, RequestFailureReason reason);
+
+    // TODO: consider having an outcome method that returns an enum (PENDING, SUCCESS, FAILURE)
+    /**
+     * Has the operation completed (either success or definite failure)?
+     *
+     * An operation is complete when:
+     * - Success: Required quorum has been achieved
+     * - Definite failure: Not enough replicas remain to achieve quorum
+     *
+     * @return true if no more responses are needed to make a decision
+     */
+    boolean isComplete();
+
+    /**
+     * Did the operation succeed (quorum achieved)?
+     *
+     * Only meaningful if isComplete() returns true.
+     *
+     * @return true if required quorum was met
+     */
+    boolean isSuccessful();
+
+    /**
+     * How many responses are required for success?
+     *
+     * Used for error messages, metrics, and UnavailableException construction.
+     * For complex trackers (e.g., EACH_QUORUM), this may be a sum or other
+     * aggregate value rather than the actual completion criteria.
+     *
+     * @return number of responses required
+     */
+    int required();
+
+    /**
+     * How many successful responses have been received so far?
+     *
+     * @return number of successful responses
+     */
+    int received();
+
+    /**
+     * How many failures have been recorded so far?
+     *
+     * @return number of failures
+     */
+    int failures();
+
+    /**
+     * Should responses from this endpoint be counted toward quorum?
+     *
+     * Allows filtering of responses based on datacenter, state, or other criteria.
+     * For example, LOCAL_QUORUM trackers would return false for remote DC replicas.
+     *
+     * @param from endpoint to check
+     * @return true if responses from this endpoint count toward quorum
+     */
+    boolean countsTowardQuorum(InetAddressAndPort from);

Review Comment:
   Not really important but since this is test ONLY in practice maybe create a section for test only methods and add VisibleForTesting?



##########
src/java/org/apache/cassandra/locator/ResponseTracker.java:
##########
@@ -0,0 +1,126 @@
+/*
+ * 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.locator;
+
+import org.apache.cassandra.exceptions.RequestFailureReason;
+
+/**
+ * Black-box response tracker encapsulating coordination completion logic.
+ *
+ * Created by replication strategy for each operation, this interface allows
+ * strategies to customize how quorum requirements are calculated and enforced.
+ * Different implementations can provide different semantics.
+ *
+ * The tracker is responsible for:
+ * 1. Recording responses and failures from replicas
+ * 2. Determining when the operation has completed (success or definite failure)
+ * 3. Providing metrics for error messages and monitoring
+ *
+ * Thread safety: Implementations must be thread-safe as onResponse/onFailure
+ * can be called concurrently from multiple network threads.
+ */
+public interface ResponseTracker
+{
+    // TODO: review replica plan members and move here as appropriate
+
+    /**
+     * Record a successful response from a replica.
+     *
+     * @param from endpoint that responded successfully
+     */
+    void onResponse(InetAddressAndPort from);
+
+    /**
+     * Record a failed response from a replica.
+     *
+     * @param from endpoint that failed
+     * @param reason failure reason for metrics and error messages
+     */
+    void onFailure(InetAddressAndPort from, RequestFailureReason reason);
+
+    // TODO: consider having an outcome method that returns an enum (PENDING, SUCCESS, FAILURE)
+    /**
+     * Has the operation completed (either success or definite failure)?
+     *
+     * An operation is complete when:
+     * - Success: Required quorum has been achieved
+     * - Definite failure: Not enough replicas remain to achieve quorum
+     *
+     * @return true if no more responses are needed to make a decision
+     */
+    boolean isComplete();
+
+    /**
+     * Did the operation succeed (quorum achieved)?
+     *
+     * Only meaningful if isComplete() returns true.
+     *
+     * @return true if required quorum was met
+     */
+    boolean isSuccessful();
+
+    /**
+     * How many responses are required for success?
+     *
+     * Used for error messages, metrics, and UnavailableException construction.
+     * For complex trackers (e.g., EACH_QUORUM), this may be a sum or other
+     * aggregate value rather than the actual completion criteria.
+     *
+     * @return number of responses required
+     */
+    int required();
+
+    /**
+     * How many successful responses have been received so far?
+     *
+     * @return number of successful responses
+     */
+    int received();
+
+    /**
+     * How many failures have been recorded so far?
+     *
+     * @return number of failures
+     */
+    int failures();
+
+    /**
+     * Should responses from this endpoint be counted toward quorum?
+     *
+     * Allows filtering of responses based on datacenter, state, or other criteria.
+     * For example, LOCAL_QUORUM trackers would return false for remote DC replicas.
+     *
+     * @param from endpoint to check
+     * @return true if responses from this endpoint count toward quorum
+     */
+    boolean countsTowardQuorum(InetAddressAndPort from);
+
+    boolean isPending(InetAddressAndPort from);

Review Comment:
   Needs documentation explaining what it does. Pending in what way?



##########
src/java/org/apache/cassandra/locator/ReplicaPlans.java:
##########
@@ -287,6 +278,35 @@ public static ReplicaPlan.ForWrite forLocalBatchlogWrite()
         return forWrite(systemKeyspace, ConsistencyLevel.ONE, (cm) -> liveAndDown, (cm) -> true, writeAll);
     }
 
+    /**

Review Comment:
   I think there is some cleanup to do with ReplicaPlans. Maybe code that isn't used anywhere outside of test code now and code that is only called from inside the locator package and should be package private? Feels like AbstractReplicationStrategy maybe wrapped a lot of this.



##########
src/java/org/apache/cassandra/locator/WriteResponseTracker.java:
##########
@@ -0,0 +1,237 @@
+/*
+ * 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.locator;
+
+import java.util.function.Predicate;
+
+import org.apache.cassandra.exceptions.RequestFailureReason;
+
+/**
+ * Response tracker for writes with pending replicas using the double count model.
+ * <p>
+ * Two requirements must be satisfied for success:
+ * <ol>
+ *   <li>Committed replicas must satisfy base CL: {@code committedSuccesses >= baseBlockFor}</li>
+ *   <li>Total replicas (committed + pending) must satisfy: {@code totalSuccesses >= totalBlockFor}</li>
+ * </ol>
+ * <p>
+ * This ensures both consistency (committed replicas have the data) and bootstrap safety
+ * (pending replicas also receive the write).
+ * <p>
+ * Implemented by composing two {@link SimpleResponseTracker}s:
+ * <ul>
+ *   <li>committedTracker: tracks responses from committed replicas only</li>
+ *   <li>totalTracker: tracks responses from all replicas (committed + pending)</li>
+ * </ul>
+ * <p>
+ * Thread-safe through delegation to thread-safe SimpleResponseTrackers.
+ */
+public class WriteResponseTracker implements ResponseTracker
+{
+    private final SimpleResponseTracker natural;
+    private final SimpleResponseTracker total;

Review Comment:
   It seems like you could only construct total and that would be sufficient? Am I wrong here?



##########
src/java/org/apache/cassandra/locator/AbstractReplicationStrategy.java:
##########
@@ -437,4 +461,372 @@ public RangesAtEndpoint getLocalRanges(ClusterMetadata cm)
                 return newRanges;
         }
     }
+
+    protected CoordinationPlan.ForWrite planForWriteInternal(ClusterMetadata metadata,
+                                                                          Keyspace keyspace,

Review Comment:
   Indentation



##########
src/java/org/apache/cassandra/locator/AbstractReplicationStrategy.java:
##########
@@ -437,4 +461,372 @@ public RangesAtEndpoint getLocalRanges(ClusterMetadata cm)
                 return newRanges;
         }
     }
+
+    protected CoordinationPlan.ForWrite planForWriteInternal(ClusterMetadata metadata,
+                                                                          Keyspace keyspace,
+                                                                          ConsistencyLevel consistencyLevel,
+                                                                          Function<ClusterMetadata, ReplicaLayout.ForTokenWrite> liveAndDown,
+                                                                          ReplicaPlans.Selector selector)
+    {
+        ReplicaPlan.ForWrite plan = ReplicaPlans.forWrite(metadata, keyspace, consistencyLevel, liveAndDown, selector);
+        ResponseTracker tracker = createTrackerForWrite(consistencyLevel, plan, plan.pending, metadata);
+        return new CoordinationPlan.ForWrite(plan, tracker);
+    }
+
+    public CoordinationPlan.ForWriteWithIdeal planForWrite(ClusterMetadata metadata,
+                                                           Keyspace keyspace,
+                                                           ConsistencyLevel consistencyLevel,
+                                                           Function<ClusterMetadata, ReplicaLayout.ForTokenWrite> liveAndDown,
+                                                           ReplicaPlans.Selector selector)
+    {
+        CoordinationPlan.ForWrite actual = planForWriteInternal(metadata, keyspace, consistencyLevel, liveAndDown, selector);
+
+        CoordinationPlan.ForWrite ideal = null;
+        ConsistencyLevel idealCL = DatabaseDescriptor.getIdealConsistencyLevel();
+        if (idealCL != null)
+        {
+            if (idealCL == consistencyLevel)
+            {
+                ideal = actual;
+            }
+            else
+            {
+                ideal = planForWriteInternal(metadata, keyspace, idealCL, liveAndDown, selector);
+            }
+        }
+
+        return new CoordinationPlan.ForWriteWithIdeal(metadata, actual.replicas(), actual.responses(), ideal);
+    }
+
+    public CoordinationPlan.ForWriteWithIdeal planForWrite(ClusterMetadata metadata,
+                                                           Keyspace keyspace,
+                                                           ConsistencyLevel consistencyLevel,
+                                                           Token token,
+                                                           ReplicaPlans.Selector selector)
+    {
+        return planForWrite(metadata, keyspace, consistencyLevel,
+                            (newClusterMetadata) -> ReplicaLayout.forTokenWriteLiveAndDown(newClusterMetadata, keyspace, token), selector);
+    }
+
+    /**
+     * Create coordination plan for forwarding a counter write to the leader replica.
+     *
+     * In cases where the original coordinator is not a replica of the counter key, the counter
+     * mutation is forwarded to a leader replica that will coordinate the actual counter update.
+     */
+    public CoordinationPlan.ForWrite planForForwardingCounterWrite(ClusterMetadata metadata,
+                                                                   Keyspace keyspace,
+                                                                   Token token,
+                                                                   Function<ClusterMetadata, Replica> replicaSupplier)
+    {
+        ReplicaPlan.ForWrite plan = ReplicaPlans.forSingleReplicaWrite(metadata, keyspace, token, replicaSupplier);
+        ResponseTracker tracker = createTrackerForWrite(plan.consistencyLevel(), plan, plan.pending, metadata);
+
+        return new CoordinationPlan.ForWriteWithIdeal(metadata, plan, tracker, null);
+    }
+
+    /**
+     * Create coordination plan for replaying a mutation from the batchlog.
+     *
+     * When recovering failed batches, mutations are replayed to remote replicas only
+     * (local replica is handled separately). This method creates a coordination plan
+     * targeting live remote replicas with CL.ONE.
+     */
+    public CoordinationPlan.ForWriteWithIdeal planForReplayMutation(ClusterMetadata metadata,
+                                                                    Keyspace keyspace,
+                                                                    Token token)
+    {
+        Preconditions.checkState(!replicationType.isTracked(), "Batch replay not supported with tracked keyspaces");
+
+        ReplicaPlan.ForWrite plan = ReplicaPlans.forReplayMutation(metadata, keyspace, token);
+        ResponseTracker tracker = createTrackerForWrite(plan.consistencyLevel(), plan, plan.pending, metadata);
+
+        return new CoordinationPlan.ForWriteWithIdeal(metadata, plan, tracker, null);
+    }
+
+    /**
+     * Create coordination plan for a single-partition token read.
+     */
+    public CoordinationPlan.ForTokenRead planForTokenRead(ClusterMetadata metadata,
+                                                          Keyspace keyspace,
+                                                          TableId tableId,
+                                                          Token token,
+                                                          @Nullable Index.QueryPlan indexQueryPlan,
+                                                          ConsistencyLevel consistencyLevel,
+                                                          SpeculativeRetryPolicy retry,
+                                                          ReadCoordinator coordinator)
+    {
+        ReplicaPlan.ForTokenRead plan = ReplicaPlans.forRead(metadata, keyspace, tableId, token, indexQueryPlan, consistencyLevel, retry, coordinator);
+        ReplicaPlan.SharedForTokenRead shared = ReplicaPlan.shared(plan);
+        ResponseTracker tracker = createTrackerForRead(plan);
+        return new CoordinationPlan.ForTokenRead(shared, tracker);
+    }
+
+    /**
+     * Create coordination plan for a range read.
+     */
+    public CoordinationPlan.ForRangeRead planForRangeRead(ClusterMetadata metadata,
+                                                          Keyspace keyspace,
+                                                          TableId tableId,
+                                                          @Nullable Index.QueryPlan indexQueryPlan,
+                                                          ConsistencyLevel consistencyLevel,
+                                                          AbstractBounds<PartitionPosition> range,
+                                                          int vnodeCount)
+    {
+        ReplicaPlan.ForRangeRead plan = ReplicaPlans.forRangeRead(metadata, keyspace, tableId, indexQueryPlan, consistencyLevel, range, vnodeCount, true);
+        ReplicaPlan.SharedForRangeRead shared = ReplicaPlan.shared(plan);
+        ResponseTracker tracker = createTrackerForRead(plan);
+        return new CoordinationPlan.ForRangeRead(shared, tracker);
+    }
+
+    /**
+     * Attempt to merge two adjacent range read coordination plans into one.
+     *
+     * If the two plans share enough live endpoints to satisfy the consistency level
+     * and the merge is worthwhile returns a merged plan otherwise returns null.
+     */
+    public CoordinationPlan.ForRangeRead maybeMergeRangeReads(ClusterMetadata metadata,
+                                                               Keyspace keyspace,
+                                                               TableId tableId,
+                                                               ConsistencyLevel consistencyLevel,
+                                                               ReplicaPlan.ForRangeRead left,
+                                                               ReplicaPlan.ForRangeRead right)
+    {
+        ReplicaPlan.ForRangeRead merged = ReplicaPlans.maybeMerge(metadata, keyspace, tableId, consistencyLevel, left, right);
+        if (merged == null)
+            return null;
+
+        ReplicaPlan.SharedForRangeRead shared = ReplicaPlan.shared(merged);
+        ResponseTracker tracker = createTrackerForRead(merged);
+        return new CoordinationPlan.ForRangeRead(shared, tracker);
+    }
+
+    /**
+     * Create coordination plan for a full range read
+     */
+    public CoordinationPlan.ForRangeRead planForFullRangeRead(Keyspace keyspace,
+                                                              ConsistencyLevel consistencyLevel,
+                                                              AbstractBounds<PartitionPosition> range,
+                                                              Set<InetAddressAndPort> endpointsToContact,
+                                                              int vnodeCount)
+    {
+        ReplicaPlan.ForRangeRead plan = ReplicaPlans.forFullRangeRead(keyspace, consistencyLevel, range, endpointsToContact, vnodeCount);
+        ReplicaPlan.SharedForRangeRead shared = ReplicaPlan.shared(plan);
+        ResponseTracker tracker = createTrackerForRead(plan);
+        return new CoordinationPlan.ForRangeRead(shared, tracker);
+    }
+
+    /**
+     * Create coordination plan for a single-replica token read.
+     */
+    public CoordinationPlan.ForTokenRead planForSingleReplicaTokenRead(Keyspace keyspace, Token token, Replica replica)
+    {
+        ReplicaPlan.ForTokenRead plan = ReplicaPlans.forSingleReplicaRead(keyspace, token, replica);
+        ReplicaPlan.SharedForTokenRead shared = ReplicaPlan.shared(plan);
+        ResponseTracker tracker = createTrackerForRead(plan);
+        return new CoordinationPlan.ForTokenRead(shared, tracker);
+    }
+
+    /**
+     * Create coordination plan for a single-replica range read.
+     *
+     * Used by short read protection to fetch additional partitions from a
+     * specific replica. blockFor=1, totalReplicas=1.
+     */
+    public CoordinationPlan.ForRangeRead planForSingleReplicaRangeRead(Keyspace keyspace,
+                                                                       AbstractBounds<PartitionPosition> range,
+                                                                       Replica replica,
+                                                                       int vnodeCount)
+    {
+        ReplicaPlan.ForRangeRead plan = ReplicaPlans.forSingleReplicaRead(keyspace, range, replica, vnodeCount);
+        ReplicaPlan.SharedForRangeRead shared = ReplicaPlan.shared(plan);
+        ResponseTracker tracker = createTrackerForRead(plan);
+        return new CoordinationPlan.ForRangeRead(shared, tracker);
+    }
+
+    /**
+     * Create ResponseTracker for read operation.
+     */
+    public <E extends Endpoints<E>, P extends ReplicaPlan.ForRead<E, P>> ResponseTracker createTrackerForRead(P plan)
+    {
+        int blockFor = plan.readQuorum();
+
+        // Use candidates.size() for totalReplicas to allow for speculation
+        // (speculation can contact additional candidates beyond initial contacts)
+        int totalReplicas = plan.readCandidates().size();
+
+        return new SimpleResponseTracker(blockFor, totalReplicas);
+    }
+
+    public Paxos.Participants paxosParticipants(ClusterMetadata metadata,
+                                                TableMetadata table,
+                                                Token token,
+                                                ConsistencyLevel consistencyForConsensus,
+                                                Predicate<Replica> isReplicaAlive)
+    {
+
+        KeyspaceMetadata keyspaceMetadata = metadata.schema.getKeyspaceMetadata(table.keyspace);
+        // MetaStrategy distributes the entire keyspace to all replicas. In addition, its tables (currently only
+        // the dist log table) don't use the globally configured partitioner. For these reasons we don't lookup the
+        // replicas using the supplied token as this can actually be of the incorrect type (for example when
+        // performing Paxos repair).
+        final Token actualToken = table.partitioner == MetaStrategy.partitioner ? MetaStrategy.entireRange.right : token;
+        ReplicaLayout.ForTokenWrite all = forTokenWriteLiveAndDown(keyspaceMetadata, actualToken);
+        ReplicaLayout.ForTokenWrite electorate = consistencyForConsensus.isDatacenterLocal()
+                                                 ? all.filter(InOurDc.replicas()) : all;
+
+        EndpointsForToken live = all.all().filter(isReplicaAlive);
+        return new Paxos.Participants(metadata.epoch, Keyspace.open(table.keyspace), consistencyForConsensus, all, electorate, live,
+                                      (cm) -> Paxos.Participants.get(cm, table, actualToken, consistencyForConsensus));
+    }
+
+    /**
+     * Hook for replication strategies to send additional mutations alongside a paxos commit.
+     * Called from PaxosCommit.start() after local synchronous execution for tracked keyspaces.
+     *
+     * If the method doesn't return null, the returned future is composed with the paxos consensus:
+     * onDone fires only after both the paxos quorum decision AND this future complete, or after
+     * one of them fails.
+     */
+    public Future<Void> sendPaxosCommitMutations(Agreed commit, boolean isUrgent)

Review Comment:
   This is called before executeOnSelf and I have had that result in remote replicas sending responses for mutations that the local node doesn't know about yet causing failures.



##########
src/java/org/apache/cassandra/locator/AbstractReplicationStrategy.java:
##########
@@ -437,4 +461,372 @@ public RangesAtEndpoint getLocalRanges(ClusterMetadata cm)
                 return newRanges;
         }
     }
+
+    protected CoordinationPlan.ForWrite planForWriteInternal(ClusterMetadata metadata,
+                                                                          Keyspace keyspace,
+                                                                          ConsistencyLevel consistencyLevel,
+                                                                          Function<ClusterMetadata, ReplicaLayout.ForTokenWrite> liveAndDown,
+                                                                          ReplicaPlans.Selector selector)
+    {
+        ReplicaPlan.ForWrite plan = ReplicaPlans.forWrite(metadata, keyspace, consistencyLevel, liveAndDown, selector);
+        ResponseTracker tracker = createTrackerForWrite(consistencyLevel, plan, plan.pending, metadata);
+        return new CoordinationPlan.ForWrite(plan, tracker);
+    }
+
+    public CoordinationPlan.ForWriteWithIdeal planForWrite(ClusterMetadata metadata,
+                                                           Keyspace keyspace,
+                                                           ConsistencyLevel consistencyLevel,
+                                                           Function<ClusterMetadata, ReplicaLayout.ForTokenWrite> liveAndDown,
+                                                           ReplicaPlans.Selector selector)
+    {
+        CoordinationPlan.ForWrite actual = planForWriteInternal(metadata, keyspace, consistencyLevel, liveAndDown, selector);
+
+        CoordinationPlan.ForWrite ideal = null;
+        ConsistencyLevel idealCL = DatabaseDescriptor.getIdealConsistencyLevel();
+        if (idealCL != null)
+        {
+            if (idealCL == consistencyLevel)
+            {
+                ideal = actual;
+            }
+            else
+            {
+                ideal = planForWriteInternal(metadata, keyspace, idealCL, liveAndDown, selector);
+            }
+        }
+
+        return new CoordinationPlan.ForWriteWithIdeal(metadata, actual.replicas(), actual.responses(), ideal);
+    }
+
+    public CoordinationPlan.ForWriteWithIdeal planForWrite(ClusterMetadata metadata,
+                                                           Keyspace keyspace,
+                                                           ConsistencyLevel consistencyLevel,
+                                                           Token token,
+                                                           ReplicaPlans.Selector selector)
+    {
+        return planForWrite(metadata, keyspace, consistencyLevel,
+                            (newClusterMetadata) -> ReplicaLayout.forTokenWriteLiveAndDown(newClusterMetadata, keyspace, token), selector);
+    }
+
+    /**
+     * Create coordination plan for forwarding a counter write to the leader replica.
+     *
+     * In cases where the original coordinator is not a replica of the counter key, the counter
+     * mutation is forwarded to a leader replica that will coordinate the actual counter update.
+     */
+    public CoordinationPlan.ForWrite planForForwardingCounterWrite(ClusterMetadata metadata,
+                                                                   Keyspace keyspace,
+                                                                   Token token,
+                                                                   Function<ClusterMetadata, Replica> replicaSupplier)
+    {
+        ReplicaPlan.ForWrite plan = ReplicaPlans.forSingleReplicaWrite(metadata, keyspace, token, replicaSupplier);
+        ResponseTracker tracker = createTrackerForWrite(plan.consistencyLevel(), plan, plan.pending, metadata);
+
+        return new CoordinationPlan.ForWriteWithIdeal(metadata, plan, tracker, null);
+    }
+
+    /**
+     * Create coordination plan for replaying a mutation from the batchlog.
+     *
+     * When recovering failed batches, mutations are replayed to remote replicas only
+     * (local replica is handled separately). This method creates a coordination plan
+     * targeting live remote replicas with CL.ONE.
+     */
+    public CoordinationPlan.ForWriteWithIdeal planForReplayMutation(ClusterMetadata metadata,
+                                                                    Keyspace keyspace,
+                                                                    Token token)
+    {
+        Preconditions.checkState(!replicationType.isTracked(), "Batch replay not supported with tracked keyspaces");
+
+        ReplicaPlan.ForWrite plan = ReplicaPlans.forReplayMutation(metadata, keyspace, token);
+        ResponseTracker tracker = createTrackerForWrite(plan.consistencyLevel(), plan, plan.pending, metadata);
+
+        return new CoordinationPlan.ForWriteWithIdeal(metadata, plan, tracker, null);
+    }
+
+    /**
+     * Create coordination plan for a single-partition token read.
+     */
+    public CoordinationPlan.ForTokenRead planForTokenRead(ClusterMetadata metadata,
+                                                          Keyspace keyspace,
+                                                          TableId tableId,
+                                                          Token token,
+                                                          @Nullable Index.QueryPlan indexQueryPlan,
+                                                          ConsistencyLevel consistencyLevel,
+                                                          SpeculativeRetryPolicy retry,
+                                                          ReadCoordinator coordinator)
+    {
+        ReplicaPlan.ForTokenRead plan = ReplicaPlans.forRead(metadata, keyspace, tableId, token, indexQueryPlan, consistencyLevel, retry, coordinator);
+        ReplicaPlan.SharedForTokenRead shared = ReplicaPlan.shared(plan);
+        ResponseTracker tracker = createTrackerForRead(plan);
+        return new CoordinationPlan.ForTokenRead(shared, tracker);
+    }
+
+    /**
+     * Create coordination plan for a range read.
+     */
+    public CoordinationPlan.ForRangeRead planForRangeRead(ClusterMetadata metadata,
+                                                          Keyspace keyspace,
+                                                          TableId tableId,
+                                                          @Nullable Index.QueryPlan indexQueryPlan,
+                                                          ConsistencyLevel consistencyLevel,
+                                                          AbstractBounds<PartitionPosition> range,
+                                                          int vnodeCount)
+    {
+        ReplicaPlan.ForRangeRead plan = ReplicaPlans.forRangeRead(metadata, keyspace, tableId, indexQueryPlan, consistencyLevel, range, vnodeCount, true);
+        ReplicaPlan.SharedForRangeRead shared = ReplicaPlan.shared(plan);
+        ResponseTracker tracker = createTrackerForRead(plan);
+        return new CoordinationPlan.ForRangeRead(shared, tracker);
+    }
+
+    /**
+     * Attempt to merge two adjacent range read coordination plans into one.
+     *
+     * If the two plans share enough live endpoints to satisfy the consistency level
+     * and the merge is worthwhile returns a merged plan otherwise returns null.
+     */
+    public CoordinationPlan.ForRangeRead maybeMergeRangeReads(ClusterMetadata metadata,
+                                                               Keyspace keyspace,
+                                                               TableId tableId,
+                                                               ConsistencyLevel consistencyLevel,
+                                                               ReplicaPlan.ForRangeRead left,
+                                                               ReplicaPlan.ForRangeRead right)
+    {
+        ReplicaPlan.ForRangeRead merged = ReplicaPlans.maybeMerge(metadata, keyspace, tableId, consistencyLevel, left, right);
+        if (merged == null)
+            return null;
+
+        ReplicaPlan.SharedForRangeRead shared = ReplicaPlan.shared(merged);
+        ResponseTracker tracker = createTrackerForRead(merged);
+        return new CoordinationPlan.ForRangeRead(shared, tracker);
+    }
+
+    /**
+     * Create coordination plan for a full range read
+     */
+    public CoordinationPlan.ForRangeRead planForFullRangeRead(Keyspace keyspace,
+                                                              ConsistencyLevel consistencyLevel,
+                                                              AbstractBounds<PartitionPosition> range,
+                                                              Set<InetAddressAndPort> endpointsToContact,
+                                                              int vnodeCount)
+    {
+        ReplicaPlan.ForRangeRead plan = ReplicaPlans.forFullRangeRead(keyspace, consistencyLevel, range, endpointsToContact, vnodeCount);
+        ReplicaPlan.SharedForRangeRead shared = ReplicaPlan.shared(plan);
+        ResponseTracker tracker = createTrackerForRead(plan);
+        return new CoordinationPlan.ForRangeRead(shared, tracker);
+    }
+
+    /**
+     * Create coordination plan for a single-replica token read.
+     */
+    public CoordinationPlan.ForTokenRead planForSingleReplicaTokenRead(Keyspace keyspace, Token token, Replica replica)
+    {
+        ReplicaPlan.ForTokenRead plan = ReplicaPlans.forSingleReplicaRead(keyspace, token, replica);
+        ReplicaPlan.SharedForTokenRead shared = ReplicaPlan.shared(plan);
+        ResponseTracker tracker = createTrackerForRead(plan);
+        return new CoordinationPlan.ForTokenRead(shared, tracker);
+    }
+
+    /**
+     * Create coordination plan for a single-replica range read.
+     *
+     * Used by short read protection to fetch additional partitions from a
+     * specific replica. blockFor=1, totalReplicas=1.
+     */
+    public CoordinationPlan.ForRangeRead planForSingleReplicaRangeRead(Keyspace keyspace,
+                                                                       AbstractBounds<PartitionPosition> range,
+                                                                       Replica replica,
+                                                                       int vnodeCount)
+    {
+        ReplicaPlan.ForRangeRead plan = ReplicaPlans.forSingleReplicaRead(keyspace, range, replica, vnodeCount);
+        ReplicaPlan.SharedForRangeRead shared = ReplicaPlan.shared(plan);
+        ResponseTracker tracker = createTrackerForRead(plan);
+        return new CoordinationPlan.ForRangeRead(shared, tracker);
+    }
+
+    /**
+     * Create ResponseTracker for read operation.
+     */
+    public <E extends Endpoints<E>, P extends ReplicaPlan.ForRead<E, P>> ResponseTracker createTrackerForRead(P plan)
+    {
+        int blockFor = plan.readQuorum();
+
+        // Use candidates.size() for totalReplicas to allow for speculation
+        // (speculation can contact additional candidates beyond initial contacts)
+        int totalReplicas = plan.readCandidates().size();
+
+        return new SimpleResponseTracker(blockFor, totalReplicas);
+    }
+
+    public Paxos.Participants paxosParticipants(ClusterMetadata metadata,
+                                                TableMetadata table,
+                                                Token token,
+                                                ConsistencyLevel consistencyForConsensus,
+                                                Predicate<Replica> isReplicaAlive)
+    {
+
+        KeyspaceMetadata keyspaceMetadata = metadata.schema.getKeyspaceMetadata(table.keyspace);
+        // MetaStrategy distributes the entire keyspace to all replicas. In addition, its tables (currently only
+        // the dist log table) don't use the globally configured partitioner. For these reasons we don't lookup the
+        // replicas using the supplied token as this can actually be of the incorrect type (for example when
+        // performing Paxos repair).
+        final Token actualToken = table.partitioner == MetaStrategy.partitioner ? MetaStrategy.entireRange.right : token;
+        ReplicaLayout.ForTokenWrite all = forTokenWriteLiveAndDown(keyspaceMetadata, actualToken);
+        ReplicaLayout.ForTokenWrite electorate = consistencyForConsensus.isDatacenterLocal()
+                                                 ? all.filter(InOurDc.replicas()) : all;
+
+        EndpointsForToken live = all.all().filter(isReplicaAlive);
+        return new Paxos.Participants(metadata.epoch, Keyspace.open(table.keyspace), consistencyForConsensus, all, electorate, live,
+                                      (cm) -> Paxos.Participants.get(cm, table, actualToken, consistencyForConsensus));
+    }
+
+    /**
+     * Hook for replication strategies to send additional mutations alongside a paxos commit.
+     * Called from PaxosCommit.start() after local synchronous execution for tracked keyspaces.
+     *
+     * If the method doesn't return null, the returned future is composed with the paxos consensus:
+     * onDone fires only after both the paxos quorum decision AND this future complete, or after
+     * one of them fails.
+     */
+    public Future<Void> sendPaxosCommitMutations(Agreed commit, boolean isUrgent)
+    {
+        return null;
+    }
+
+    /**
+     * Check whether paxos operations should be rejected for the given token.
+     */
+    public boolean shouldRejectPaxos(Token token)
+    {
+        return false;
+    }
+
+    /**
+     * Create ResponseTracker for write operation based on consistency level.
+     */
+    @VisibleForTesting
+    public ResponseTracker createTrackerForWrite(ConsistencyLevel cl, ReplicaPlan.ForWrite plan, Endpoints<?> pending, ClusterMetadata metadata)
+    {
+        switch (cl)
+        {
+            case ANY:
+            case ONE:
+            case TWO:
+            case THREE:
+            case QUORUM:
+            case ALL:
+                int totalContacts = plan.contacts().size();
+                if (pending.isEmpty())
+                {
+                    int blockFor = cl.blockFor(this);
+                    return new SimpleResponseTracker(blockFor, totalContacts);
+                }
+                else
+                {
+                    // Check if double count model applies (some CLs like ANY don't add pending)
+                    int baseBlockFor = cl.blockFor(this);
+                    int totalBlockFor = cl.blockForWrite(this, pending);
+
+                    // If totalBlockFor == baseBlockFor, no double-count needed (e.g., ANY)
+                    if (totalBlockFor == baseBlockFor)
+                        return new SimpleResponseTracker(baseBlockFor, totalContacts);
+
+                    // Double count model: committed must satisfy base CL, total must include pending
+                    int pendingReplicas = pending.size();
+                    // contacts() includes both committed and pending replicas
+                    int committedReplicas = totalContacts - pendingReplicas;
+                    return new WriteResponseTracker(baseBlockFor, totalBlockFor,
+                                                    committedReplicas, pendingReplicas,
+                                                    endpoint -> pending.endpoints().contains(endpoint));
+                }
+
+            case LOCAL_ONE:
+            case LOCAL_QUORUM:
+                int localContacts = plan.contacts().filter(InOurDc.replicas()).size();

Review Comment:
   Maybe reduce copy pasta between these two by creating a 3 arg method?



##########
src/java/org/apache/cassandra/locator/SatelliteReplicationStrategy.java:
##########
@@ -513,4 +631,1268 @@ public boolean hasSameSettings(AbstractReplicationStrategy other)
                primaryDC.equals(otherStrategy.primaryDC) &&
                disabledDCs.equals(otherStrategy.disabledDCs);
     }
+
+    private AbstractReplicationStrategy strategy(ClusterMetadata metadata)
+    {
+        return metadata.schema.getKeyspaceMetadata(keyspaceName).replicationStrategy;
+    }
+
+    static abstract class CoordinationPlanner<E extends Endpoints<E>, L extends ReplicaLayout<E>, P extends ReplicaPlan<E, P>>
+    {
+        final ClusterMetadata metadata;
+        final Keyspace keyspace;
+        final ConsistencyLevel cl;
+        final SatelliteReplicationStrategy strategy;
+        final String primary;
+        final String satellite;
+        final List<String> dcs;
+        final L fullLayout;
+        final L liveLayout;
+        final Map<String, L> fullLayouts;
+        final Map<String, L> liveLayouts;
+        final Map<String, E> fullEndpoints;
+        final Map<String, E> liveEndpoints;
+
+        public CoordinationPlanner(ClusterMetadata metadata, Keyspace keyspace, ConsistencyLevel cl, SatelliteReplicationStrategy strategy, String primary, L fullLayout)
+        {
+            this.metadata = metadata;
+            this.keyspace = keyspace;
+            this.cl = cl;
+            this.strategy = strategy;
+            this.primary = primary;
+            this.satellite = strategy.getSatelliteForDC(primary);
+
+            this.dcs = createDcList();
+            Preconditions.checkState(!dcs.isEmpty(), "No DCs available for request (primary=%s, all disabled?)", primary);
+
+            Set<String> dcSet = new HashSet<>(dcs);
+            this.fullLayout = filterLayout(fullLayout, rp -> dcSet.contains(metadata.locator.location(rp.endpoint()).datacenter));
+            this.liveLayout = filterLayout(this.fullLayout, FailureDetector.isReplicaAlive);
+
+            this.fullLayouts = Maps.newHashMapWithExpectedSize(dcs.size());
+            this.liveLayouts = Maps.newHashMapWithExpectedSize(dcs.size());
+
+            this.fullEndpoints = Maps.newHashMapWithExpectedSize(dcs.size());
+            this.liveEndpoints = Maps.newHashMapWithExpectedSize(dcs.size());
+
+            populateDcLayouts();
+        }
+
+        AbstractReplicationStrategy strategy(ClusterMetadata metadata)
+        {
+            return metadata.schema.getKeyspaceMetadata(keyspace.getName()).replicationStrategy;
+        }
+
+        List<String> createDcList()
+        {
+            List<String> results = new ArrayList<>();
+            if (!strategy.disabledDCs.contains(primary))
+                results.add(primary);
+
+            if (satellite != null && !strategy.disabledDCs.contains(satellite))
+                results.add(satellite);
+
+            for (String dc : strategy.fullDCs.keySet())
+            {
+                if (dc.equals(primary) || dc.equals(satellite) || strategy.disabledDCs.contains(dc))
+                    continue;
+                results.add(dc);
+            }
+
+            Preconditions.checkState(!results.isEmpty());
+            return results;
+        }
+
+        void populateDcLayouts()
+        {
+            for (String dc : dcs)
+            {
+                Predicate<Replica> dcFilter = rp -> metadata.locator.location(rp.endpoint()).datacenter.equals(dc);
+                L full = filterLayout(fullLayout, dcFilter);
+                L live = filterLayout(liveLayout, dcFilter);
+
+                fullLayouts.put(dc, full);
+                liveLayouts.put(dc, live);
+
+                fullEndpoints.put(dc, full.natural());
+                liveEndpoints.put(dc, live.natural());
+            }
+
+            if (ADDL_CHECKS_ENABLED)
+                Preconditions.checkState(fullLayouts.size() == dcs.size(), "populateDcLayouts: fullLayouts.size()=%s != dcs.size()=%s", fullLayouts.size(), dcs.size());
+        }
+
+        ResponseTrackerBuilder<E, L, P> createResponseTrackerBuilder()
+        {
+            switch (cl)
+            {
+                case ONE:
+                case LOCAL_ONE:
+                case NODE_LOCAL:
+                case ANY:
+                case TWO:
+                case THREE:
+                case ALL:
+                    return new ResponseTrackerBuilder.Simple<>(this);
+
+                case QUORUM:
+                case LOCAL_QUORUM:
+                    // LOCAL_QUORUM an QUORUM mean the same thing in SRS
+                    return new ResponseTrackerBuilder.Quorum<>(this);
+
+                case EACH_QUORUM:
+                    return new ResponseTrackerBuilder.EachQuorum<>(this);
+
+                default:
+                    throw new IllegalStateException("Unsupported consistency level: " + cl);
+            }
+        }
+
+        ResponseTracker createResponseTracker()
+        {
+            ResponseTrackerBuilder<E, L, P> builder = createResponseTrackerBuilder();
+            return builder.build();
+        }
+
+        abstract L filterLayout(L layout, Predicate<Replica> predicate);
+        abstract ResponseTracker createDcResponseTracker(String dc);
+
+        abstract P recomputePlan(ClusterMetadata metadata);
+        abstract P createReplicaPlan();
+
+        interface IndexReadExceptionCheck<E extends Endpoints<E>>
+        {
+            void maybeThrowIndexReadException(int initial, int filtered, Map<String, E> candidates, Map<InetAddressAndPort, Index.Status> indexStatusMap);
+        }
+
+        static class ForWrite extends CoordinationPlanner<EndpointsForToken, ReplicaLayout.ForTokenWrite, ReplicaPlan.ForWrite>
+        {
+            final ReplicaPlans.Selector selector;
+
+            public ForWrite(ClusterMetadata metadata, Keyspace keyspace, ConsistencyLevel cl, SatelliteReplicationStrategy strategy, String primary, ReplicaLayout.ForTokenWrite fullLayout, ReplicaPlans.Selector selector)
+            {
+                super(metadata, keyspace, cl, strategy, primary, fullLayout);
+                this.selector = selector;
+                if (ADDL_CHECKS_ENABLED)
+                {
+                    Preconditions.checkState(!strategy.disabledDCs.contains(primary));
+                }
+            }
+
+            @Override
+            ResponseTracker createDcResponseTracker(String dc)
+            {
+                // this basically hardcodes ReplicaPlans.writeAll
+                Predicate<InetAddressAndPort> dcFilter = ep -> metadata.locator.location(ep).datacenter.equals(dc);
+
+                ReplicaLayout.ForTokenWrite dcLayout = fullLayouts.get(dc);
+                Preconditions.checkNotNull(dcLayout);
+
+                int blockFor = strategy.calculateQuorum(dc);
+                int totalContacts = dcLayout.all().size();
+
+                // Only count pending replicas that are actually in the selected contacts
+                int pendingInDc = dcLayout.pending().size();
+
+                if (pendingInDc == 0)
+                {
+                    return new SimpleResponseTracker(blockFor, totalContacts, dcFilter);
+                }
+                else
+                {
+                    int committedContacts = totalContacts - pendingInDc;
+                    int totalBlockFor = blockFor + pendingInDc;
+                    Predicate<InetAddressAndPort> isPending = ep -> dcLayout.pending.endpoints().contains(ep);
+                    return new WriteResponseTracker(blockFor, totalBlockFor,
+                                                    committedContacts, pendingInDc,
+                                                    isPending, dcFilter);
+                }
+            }
+
+            @Override
+            ReplicaPlan.ForWrite recomputePlan(ClusterMetadata metadata)
+            {
+                Token token = fullLayout.token();
+                return strategy(metadata).planForWrite(metadata, keyspace, cl,
+                                                              cm -> ReplicaLayout.forTokenWriteLiveAndDown(cm, keyspace, token),
+                                                       selector).replicas();
+            }
+
+            @Override
+            ReplicaLayout.ForTokenWrite filterLayout(ReplicaLayout.ForTokenWrite layout, Predicate<Replica> predicate)
+            {
+                return layout.filter(predicate);
+            }
+
+            private ReplicaLayout.ForTokenWrite mergeLayouts(Collection<ReplicaLayout.ForTokenWrite> layouts)
+            {
+                ReplicaCollection.Builder<EndpointsForToken> naturalBuilder = null;
+                ReplicaCollection.Builder<EndpointsForToken> pendingBuilder = null;
+
+                for (ReplicaLayout.ForTokenWrite layout : layouts)
+                {
+                    EndpointsForToken natural = layout.natural();
+                    EndpointsForToken pending = layout.pending();
+                    if (naturalBuilder == null)
+                    {
+                        naturalBuilder = natural.newBuilder(natural.size());
+                        pendingBuilder = pending.newBuilder(pending.size());
+                    }
+
+                    naturalBuilder.addAll(natural);
+                    pendingBuilder.addAll(pending);
+                }
+
+                return new ReplicaLayout.ForTokenWrite(strategy, naturalBuilder.build(), pendingBuilder.build());
+            }
+
+            @Override
+            ReplicaPlan.ForWrite createReplicaPlan()
+            {
+
+                ReplicaLayout.ForTokenWrite fullLayout = mergeLayouts(fullLayouts.values());
+                ReplicaLayout.ForTokenWrite liveLayout = mergeLayouts(liveLayouts.values());
+                EndpointsForToken contacts = selector.select(cl, fullLayout, liveLayout);
+
+                return new ReplicaPlan.ForWrite(keyspace,
+                                                strategy,
+                                                cl,
+                                                fullLayout.pending(),
+                                                fullLayout.all(),
+                                                liveLayout.all(),
+                                                contacts,
+                                                this::recomputePlan,
+                                                metadata.epoch);
+            }
+        }
+
+        static abstract class ForRead<E extends Endpoints<E>, L extends ReplicaLayout<E>, P extends ReplicaPlan.ForRead<E, P>> extends CoordinationPlanner<E, L, P>
+        {
+            final @Nullable Index.QueryPlan indexQueryPlan;
+            final boolean alwaysSpeculate;
+            final boolean throwOnInsufficientLiveReplicas;
+
+            public ForRead(ClusterMetadata metadata, Keyspace keyspace, ConsistencyLevel cl, SatelliteReplicationStrategy strategy, String primary, L fullLayout, @Nullable Index.QueryPlan indexQueryPlan, boolean alwaysSpeculate, boolean throwOnInsufficientLiveReplicas)
+            {
+                super(metadata, keyspace, cl, strategy, primary, fullLayout);
+                this.indexQueryPlan = indexQueryPlan;
+                this.alwaysSpeculate = alwaysSpeculate;
+                this.throwOnInsufficientLiveReplicas = throwOnInsufficientLiveReplicas;
+            }
+
+            @Override
+            ResponseTracker createDcResponseTracker(String dc)
+            {
+                Predicate<InetAddressAndPort> dcFilter = ep -> metadata.locator.location(ep).datacenter.equals(dc);
+
+                L dcLayout = fullLayouts.get(dc);
+                int blockFor = strategy.calculateQuorum(dc);
+                int totalContacts = dcLayout.all().size();
+
+                return new SimpleResponseTracker(blockFor, totalContacts, dcFilter);
+            }
+
+            Map<String, E> candidates(IndexReadExceptionCheck<E> check)
+            {
+                if (indexQueryPlan == null)
+                    return liveEndpoints;
+
+                Map<String, E> candidates = new HashMap<>();
+                Map<InetAddressAndPort, Index.Status> indexStatusMap = new HashMap<>();
+                int initial = 0;
+                int filtered = 0;
+                for (Map.Entry<String, E> entry : liveEndpoints.entrySet())
+                {
+                    E liveEndpoints = entry.getValue();
+                    E queryableEndpoints = IndexStatusManager.instance.filterForQuery(liveEndpoints, keyspace.getName(), indexQueryPlan, indexStatusMap);
+                    initial += liveEndpoints.size();
+                    filtered += queryableEndpoints.size();
+                    candidates.put(entry.getKey(), queryableEndpoints);
+                }
+
+                if (initial != filtered)
+                    check.maybeThrowIndexReadException(initial, filtered, candidates, indexStatusMap);
+
+                return candidates;
+            }
+
+            @Override
+            ResponseTracker createResponseTracker()
+            {
+                ResponseTracker tracker = super.createResponseTracker();
+
+                // read trackers don't wait on pending nodes
+                Preconditions.checkState(tracker.pendingContacts() == 0);
+                return tracker;
+            }
+
+
+            abstract P replicaPlanBuilder(E candidates, E contacts, E liveAndDown, int quorumSize);
+
+            ReadReplicaPlanBuilder<E, L, P> replicaPlanBuilder()
+            {
+                switch (cl)
+                {
+                    case ONE:
+                    case LOCAL_ONE:
+                    case NODE_LOCAL:
+                    case ANY:
+                    case TWO:
+                    case THREE:
+                    case ALL:
+                        return new ReadReplicaPlanBuilder.Simple<>(this);
+
+                    case QUORUM:
+                    case LOCAL_QUORUM:
+                    case EACH_QUORUM:
+                        return new ReadReplicaPlanBuilder.DcQuorum<>(this);
+
+                    default:
+                        throw new IllegalStateException("Unsupported consistency level: " + cl);
+                }
+            }
+
+            @Override
+            P createReplicaPlan()
+            {
+                ReadReplicaPlanBuilder<E, L, P> builder = replicaPlanBuilder();
+                return builder.build();
+            }
+        }
+
+        static class ForTokenRead extends ForRead<EndpointsForToken, ReplicaLayout.ForTokenRead, ReplicaPlan.ForTokenRead>
+        {
+            static final Function<ReplicaPlan<?, ?>, ReplicaPlan.ForWrite> READ_REPAIR_PLAN = (ReplicaPlan<?, ?> self) -> {throw new IllegalStateException("Cannot create read repair plans for SatelliteDatacenterReplicationStrategy");};
+            final Token token;
+            final TableId tableId;
+            final SpeculativeRetryPolicy retry;
+            final ReadCoordinator coordinator;
+
+            public ForTokenRead(ClusterMetadata metadata, Keyspace keyspace, Token token, ConsistencyLevel cl, SatelliteReplicationStrategy strategy, String primary, ReplicaLayout.ForTokenRead fullLayout, @Nullable Index.QueryPlan indexQueryPlan, SpeculativeRetryPolicy retry, boolean throwOnInsufficientLiveReplicas, TableId tableId, ReadCoordinator coordinator)
+            {
+                super(metadata, keyspace, cl, strategy, primary, fullLayout, indexQueryPlan, retry == AlwaysSpeculativeRetryPolicy.INSTANCE, throwOnInsufficientLiveReplicas);
+                this.token = token;
+                this.tableId = tableId;
+                this.retry = retry;
+                this.coordinator = coordinator;
+            }
+
+            @Override
+            ReplicaLayout.ForTokenRead filterLayout(ReplicaLayout.ForTokenRead layout, Predicate<Replica> predicate)
+            {
+                return layout.filter(predicate);
+            }
+
+            @Override
+            ReplicaPlan.ForTokenRead recomputePlan(ClusterMetadata metadata)
+            {
+                return strategy(metadata).planForTokenRead(metadata, keyspace, tableId, token,
+                                                           indexQueryPlan, cl, retry, coordinator).replicas();
+            }
+
+            @Override
+            ReplicaPlan.ForTokenRead replicaPlanBuilder(EndpointsForToken candidates, EndpointsForToken contacts, EndpointsForToken liveAndDown, int quorumSize)
+            {
+                return new ReplicaPlan.ForTokenRead(keyspace, strategy, cl,
+                                                    candidates, contacts, liveAndDown,
+                                                    this::recomputePlan,
+                                                    READ_REPAIR_PLAN,
+                                                    metadata.epoch,
+                                                    quorumSize);
+            }
+        }
+
+        static class ForRangeRead extends ForRead<EndpointsForRange, ReplicaLayout.ForRangeRead, ReplicaPlan.ForRangeRead>
+        {
+            static final BiFunction<ReplicaPlan<?, ?>, Token, ReplicaPlan.ForWrite> READ_REPAIR_PLAN = (ReplicaPlan<?, ?> self, Token token) -> {throw new IllegalStateException("Cannot create read repair plans for SatelliteDatacenterReplicationStrategy");};
+            final AbstractBounds<PartitionPosition> range;
+            final int vnodeCount;
+            final TableId tableId;
+
+            public ForRangeRead(ClusterMetadata metadata, Keyspace keyspace, AbstractBounds<PartitionPosition> range, int vnodeCount, ConsistencyLevel cl, SatelliteReplicationStrategy strategy, String primary, ReplicaLayout.ForRangeRead fullLayout, @Nullable Index.QueryPlan indexQueryPlan, boolean throwOnInsufficientLiveReplicas, TableId tableId)
+            {
+                super(metadata, keyspace, cl, strategy, primary, fullLayout, indexQueryPlan, false, throwOnInsufficientLiveReplicas);
+                this.range = range;
+                this.vnodeCount = vnodeCount;
+                this.tableId = tableId;
+            }
+
+            @Override
+            ReplicaLayout.ForRangeRead filterLayout(ReplicaLayout.ForRangeRead layout, Predicate<Replica> predicate)
+            {
+                return layout.filter(predicate);
+            }
+
+            @Override
+            ReplicaPlan.ForRangeRead recomputePlan(ClusterMetadata metadata)
+            {
+                return strategy(metadata).planForRangeRead(metadata, keyspace, tableId,
+                                                           indexQueryPlan, cl, range, vnodeCount).replicas();
+            }
+
+            @Override
+            ReplicaPlan.ForRangeRead replicaPlanBuilder(EndpointsForRange candidates, EndpointsForRange contacts, EndpointsForRange liveAndDown, int quorumSize)
+            {
+                return new ReplicaPlan.ForRangeRead(keyspace, strategy, cl, range,
+                                                    candidates, contacts, liveAndDown, vnodeCount,
+                                                    this::recomputePlan,
+                                                    READ_REPAIR_PLAN,
+                                                    metadata.epoch,
+                                                    quorumSize);
+            }
+        }
+    }
+
+    static abstract class ResponseTrackerBuilder<E extends Endpoints<E>, L extends ReplicaLayout<E>, P extends ReplicaPlan<E, P>>
+    {
+        final CoordinationPlanner<E, L, P> planner;
+
+        public ResponseTrackerBuilder(CoordinationPlanner<E, L, P> planner)
+        {
+            this.planner = planner;
+        }
+
+        Map<String, ResponseTracker> responseTrackersForPrimary()
+        {
+            Map<String, ResponseTracker> result = Maps.newHashMapWithExpectedSize(planner.dcs.size());
+
+            for (String dc : planner.dcs)
+                result.put(dc, planner.createDcResponseTracker(dc));
+
+            return result;
+        }
+
+        abstract ResponseTracker aggregateTrackers(Map<String, ResponseTracker> dcTrackers);
+
+        ResponseTracker build()
+        {
+            Map<String, ResponseTracker> dcTrackers = responseTrackersForPrimary();
+            return aggregateTrackers(dcTrackers);
+        }
+
+        static class Simple<E extends Endpoints<E>, L extends ReplicaLayout<E>, P extends ReplicaPlan<E, P>> extends ResponseTrackerBuilder<E, L, P>
+        {
+            public Simple(CoordinationPlanner<E, L, P> planner)
+            {
+                super(planner);
+            }
+
+            @Override
+            ResponseTracker aggregateTrackers(Map<String, ResponseTracker> dcTrackers)
+            {
+                int totalContacts = 0;
+                int totalPending = 0;
+
+
+                List<ResponseTracker> trackers = Lists.newArrayList();
+                Set<String> blockingDatacenters = Sets.newHashSet();
+
+                for (Map.Entry<String, ResponseTracker> entry : dcTrackers.entrySet())
+                {
+                    String dc = entry.getKey();
+                    ResponseTracker tracker = entry.getValue();
+                    if (planner.cl == ConsistencyLevel.ALL || dc.equals(planner.primary) || dc.equals(planner.satellite))

Review Comment:
   For non-QUORUM CLs does it make sense to force specific blocking data centers with Satellite replication?



##########
src/java/org/apache/cassandra/locator/SatelliteReplicationStrategy.java:
##########
@@ -513,4 +631,1268 @@ public boolean hasSameSettings(AbstractReplicationStrategy other)
                primaryDC.equals(otherStrategy.primaryDC) &&
                disabledDCs.equals(otherStrategy.disabledDCs);
     }
+
+    private AbstractReplicationStrategy strategy(ClusterMetadata metadata)
+    {
+        return metadata.schema.getKeyspaceMetadata(keyspaceName).replicationStrategy;
+    }
+
+    static abstract class CoordinationPlanner<E extends Endpoints<E>, L extends ReplicaLayout<E>, P extends ReplicaPlan<E, P>>
+    {
+        final ClusterMetadata metadata;
+        final Keyspace keyspace;
+        final ConsistencyLevel cl;
+        final SatelliteReplicationStrategy strategy;
+        final String primary;
+        final String satellite;
+        final List<String> dcs;
+        final L fullLayout;
+        final L liveLayout;
+        final Map<String, L> fullLayouts;
+        final Map<String, L> liveLayouts;
+        final Map<String, E> fullEndpoints;
+        final Map<String, E> liveEndpoints;
+
+        public CoordinationPlanner(ClusterMetadata metadata, Keyspace keyspace, ConsistencyLevel cl, SatelliteReplicationStrategy strategy, String primary, L fullLayout)
+        {
+            this.metadata = metadata;
+            this.keyspace = keyspace;
+            this.cl = cl;
+            this.strategy = strategy;
+            this.primary = primary;
+            this.satellite = strategy.getSatelliteForDC(primary);
+
+            this.dcs = createDcList();
+            Preconditions.checkState(!dcs.isEmpty(), "No DCs available for request (primary=%s, all disabled?)", primary);
+
+            Set<String> dcSet = new HashSet<>(dcs);
+            this.fullLayout = filterLayout(fullLayout, rp -> dcSet.contains(metadata.locator.location(rp.endpoint()).datacenter));
+            this.liveLayout = filterLayout(this.fullLayout, FailureDetector.isReplicaAlive);
+
+            this.fullLayouts = Maps.newHashMapWithExpectedSize(dcs.size());
+            this.liveLayouts = Maps.newHashMapWithExpectedSize(dcs.size());
+
+            this.fullEndpoints = Maps.newHashMapWithExpectedSize(dcs.size());
+            this.liveEndpoints = Maps.newHashMapWithExpectedSize(dcs.size());
+
+            populateDcLayouts();
+        }
+
+        AbstractReplicationStrategy strategy(ClusterMetadata metadata)
+        {
+            return metadata.schema.getKeyspaceMetadata(keyspace.getName()).replicationStrategy;
+        }
+
+        List<String> createDcList()
+        {
+            List<String> results = new ArrayList<>();
+            if (!strategy.disabledDCs.contains(primary))
+                results.add(primary);
+
+            if (satellite != null && !strategy.disabledDCs.contains(satellite))
+                results.add(satellite);
+
+            for (String dc : strategy.fullDCs.keySet())
+            {
+                if (dc.equals(primary) || dc.equals(satellite) || strategy.disabledDCs.contains(dc))
+                    continue;
+                results.add(dc);
+            }
+
+            Preconditions.checkState(!results.isEmpty());
+            return results;
+        }
+
+        void populateDcLayouts()
+        {
+            for (String dc : dcs)
+            {
+                Predicate<Replica> dcFilter = rp -> metadata.locator.location(rp.endpoint()).datacenter.equals(dc);
+                L full = filterLayout(fullLayout, dcFilter);
+                L live = filterLayout(liveLayout, dcFilter);
+
+                fullLayouts.put(dc, full);
+                liveLayouts.put(dc, live);
+
+                fullEndpoints.put(dc, full.natural());
+                liveEndpoints.put(dc, live.natural());
+            }
+
+            if (ADDL_CHECKS_ENABLED)
+                Preconditions.checkState(fullLayouts.size() == dcs.size(), "populateDcLayouts: fullLayouts.size()=%s != dcs.size()=%s", fullLayouts.size(), dcs.size());
+        }
+
+        ResponseTrackerBuilder<E, L, P> createResponseTrackerBuilder()
+        {
+            switch (cl)
+            {
+                case ONE:
+                case LOCAL_ONE:
+                case NODE_LOCAL:
+                case ANY:
+                case TWO:
+                case THREE:
+                case ALL:
+                    return new ResponseTrackerBuilder.Simple<>(this);
+
+                case QUORUM:
+                case LOCAL_QUORUM:
+                    // LOCAL_QUORUM an QUORUM mean the same thing in SRS
+                    return new ResponseTrackerBuilder.Quorum<>(this);
+
+                case EACH_QUORUM:
+                    return new ResponseTrackerBuilder.EachQuorum<>(this);
+
+                default:
+                    throw new IllegalStateException("Unsupported consistency level: " + cl);
+            }
+        }
+
+        ResponseTracker createResponseTracker()
+        {
+            ResponseTrackerBuilder<E, L, P> builder = createResponseTrackerBuilder();
+            return builder.build();
+        }
+
+        abstract L filterLayout(L layout, Predicate<Replica> predicate);
+        abstract ResponseTracker createDcResponseTracker(String dc);
+
+        abstract P recomputePlan(ClusterMetadata metadata);
+        abstract P createReplicaPlan();
+
+        interface IndexReadExceptionCheck<E extends Endpoints<E>>
+        {
+            void maybeThrowIndexReadException(int initial, int filtered, Map<String, E> candidates, Map<InetAddressAndPort, Index.Status> indexStatusMap);
+        }
+
+        static class ForWrite extends CoordinationPlanner<EndpointsForToken, ReplicaLayout.ForTokenWrite, ReplicaPlan.ForWrite>
+        {
+            final ReplicaPlans.Selector selector;
+
+            public ForWrite(ClusterMetadata metadata, Keyspace keyspace, ConsistencyLevel cl, SatelliteReplicationStrategy strategy, String primary, ReplicaLayout.ForTokenWrite fullLayout, ReplicaPlans.Selector selector)
+            {
+                super(metadata, keyspace, cl, strategy, primary, fullLayout);
+                this.selector = selector;
+                if (ADDL_CHECKS_ENABLED)
+                {
+                    Preconditions.checkState(!strategy.disabledDCs.contains(primary));
+                }
+            }
+
+            @Override
+            ResponseTracker createDcResponseTracker(String dc)
+            {
+                // this basically hardcodes ReplicaPlans.writeAll
+                Predicate<InetAddressAndPort> dcFilter = ep -> metadata.locator.location(ep).datacenter.equals(dc);
+
+                ReplicaLayout.ForTokenWrite dcLayout = fullLayouts.get(dc);
+                Preconditions.checkNotNull(dcLayout);
+
+                int blockFor = strategy.calculateQuorum(dc);
+                int totalContacts = dcLayout.all().size();
+
+                // Only count pending replicas that are actually in the selected contacts
+                int pendingInDc = dcLayout.pending().size();
+
+                if (pendingInDc == 0)
+                {
+                    return new SimpleResponseTracker(blockFor, totalContacts, dcFilter);
+                }
+                else
+                {
+                    int committedContacts = totalContacts - pendingInDc;
+                    int totalBlockFor = blockFor + pendingInDc;
+                    Predicate<InetAddressAndPort> isPending = ep -> dcLayout.pending.endpoints().contains(ep);
+                    return new WriteResponseTracker(blockFor, totalBlockFor,
+                                                    committedContacts, pendingInDc,
+                                                    isPending, dcFilter);
+                }
+            }
+
+            @Override
+            ReplicaPlan.ForWrite recomputePlan(ClusterMetadata metadata)
+            {
+                Token token = fullLayout.token();
+                return strategy(metadata).planForWrite(metadata, keyspace, cl,
+                                                              cm -> ReplicaLayout.forTokenWriteLiveAndDown(cm, keyspace, token),
+                                                       selector).replicas();
+            }
+
+            @Override
+            ReplicaLayout.ForTokenWrite filterLayout(ReplicaLayout.ForTokenWrite layout, Predicate<Replica> predicate)
+            {
+                return layout.filter(predicate);
+            }
+
+            private ReplicaLayout.ForTokenWrite mergeLayouts(Collection<ReplicaLayout.ForTokenWrite> layouts)
+            {
+                ReplicaCollection.Builder<EndpointsForToken> naturalBuilder = null;
+                ReplicaCollection.Builder<EndpointsForToken> pendingBuilder = null;
+
+                for (ReplicaLayout.ForTokenWrite layout : layouts)
+                {
+                    EndpointsForToken natural = layout.natural();
+                    EndpointsForToken pending = layout.pending();
+                    if (naturalBuilder == null)
+                    {
+                        naturalBuilder = natural.newBuilder(natural.size());
+                        pendingBuilder = pending.newBuilder(pending.size());
+                    }
+
+                    naturalBuilder.addAll(natural);
+                    pendingBuilder.addAll(pending);
+                }
+
+                return new ReplicaLayout.ForTokenWrite(strategy, naturalBuilder.build(), pendingBuilder.build());
+            }
+
+            @Override
+            ReplicaPlan.ForWrite createReplicaPlan()
+            {
+
+                ReplicaLayout.ForTokenWrite fullLayout = mergeLayouts(fullLayouts.values());
+                ReplicaLayout.ForTokenWrite liveLayout = mergeLayouts(liveLayouts.values());
+                EndpointsForToken contacts = selector.select(cl, fullLayout, liveLayout);
+
+                return new ReplicaPlan.ForWrite(keyspace,
+                                                strategy,
+                                                cl,
+                                                fullLayout.pending(),
+                                                fullLayout.all(),
+                                                liveLayout.all(),
+                                                contacts,
+                                                this::recomputePlan,
+                                                metadata.epoch);
+            }
+        }
+
+        static abstract class ForRead<E extends Endpoints<E>, L extends ReplicaLayout<E>, P extends ReplicaPlan.ForRead<E, P>> extends CoordinationPlanner<E, L, P>
+        {
+            final @Nullable Index.QueryPlan indexQueryPlan;
+            final boolean alwaysSpeculate;
+            final boolean throwOnInsufficientLiveReplicas;
+
+            public ForRead(ClusterMetadata metadata, Keyspace keyspace, ConsistencyLevel cl, SatelliteReplicationStrategy strategy, String primary, L fullLayout, @Nullable Index.QueryPlan indexQueryPlan, boolean alwaysSpeculate, boolean throwOnInsufficientLiveReplicas)

Review Comment:
   Is throwOnInsufficientLiveReplicas always true?



##########
src/java/org/apache/cassandra/locator/SatelliteReplicationStrategy.java:
##########
@@ -95,6 +143,17 @@ public class SatelliteReplicationStrategy extends AbstractReplicationStrategy
 
     private final ReplicationFactor aggregateRf;
 
+    /**
+     * Per-range failover state.
+     * Volatile for visibility across threads.
+     *
+     * NOTE: In-memory for initial implementation. Future work will pull this
+     * from TCM (Transactional Cluster Metadata) for persistence and coordination.
+     *
+     * Initialized to NORMAL state for all ranges.
+     */
+    private volatile SatelliteFailoverState.FailoverStateMap failoverState;
+
     private static class SatelliteInfo

Review Comment:
   SatelliteInfo missing equals and hashCode and is used for comparison.
   
   This also means we are missing tests of the functionality that leverages this behavior.



##########
src/java/org/apache/cassandra/locator/SatelliteReplicationStrategy.java:
##########
@@ -497,6 +567,54 @@ public boolean isDisabled(String dc)
         return disabledDCs.contains(dc);
     }
 
+    @Override
+    public Paxos.Participants paxosParticipants(ClusterMetadata metadata,
+                                                 TableMetadata table,
+                                                 Token token,
+                                                 ConsistencyLevel consistencyForConsensus,
+                                                 Predicate<Replica> isReplicaAlive)
+    {
+        // Reject paxos operations during TRANSITION_ACK to prevent conflicting paxos operations
+        // across different full DCs. The temporary gap in paxos availability ensures that the old
+        // primary has no in-flight proposals before the new primary begins serving paxos operations.
+        SatelliteFailoverState.FailoverInfo failoverInfo = getFailoverInfo(token, metadata);
+        if (failoverInfo.getState() == SatelliteFailoverState.State.TRANSITION_ACK)
+            throw UnavailableException.create(consistencyForConsensus, 1, 0);
+
+        KeyspaceMetadata keyspaceMetadata = metadata.schema.getKeyspaceMetadata(table.keyspace);
+        ReplicaLayout.ForTokenWrite fullLayout = ReplicaLayout.forTokenWriteLiveAndDown(metadata, keyspaceMetadata, token);
+
+        // Paxos consensus operates entirely within the primary DC
+        Predicate<Replica> inPrimaryDC = rp -> metadata.locator.location(rp.endpoint()).datacenter.equals(primaryDC);
+        ReplicaLayout.ForTokenWrite primaryAll = fullLayout.filter(inPrimaryDC);
+        ReplicaLayout.ForTokenWrite primaryElectorate = primaryAll;
+
+        // Satellite/secondary DC endpoints for reads during prepare and writes during commit.
+        // Only include the primary DC's satellite and other full DCs — not satellites of other DCs.
+        String primarySatellite = getSatelliteForDC(primaryDC);
+        Predicate<Replica> isParticipatingNonPrimary = rp -> {

Review Comment:
   Should disabled data centers be filtered here? Elsewhere we do exclude them.



##########
src/java/org/apache/cassandra/locator/AbstractReplicationStrategy.java:
##########
@@ -437,4 +461,376 @@ public RangesAtEndpoint getLocalRanges(ClusterMetadata cm)
                 return newRanges;
         }
     }
+
+    protected CoordinationPlan.ForWrite planForWriteInternal(ClusterMetadata metadata,
+                                                                          Keyspace keyspace,
+                                                                          ConsistencyLevel consistencyLevel,
+                                                                          Function<ClusterMetadata, ReplicaLayout.ForTokenWrite> liveAndDown,
+                                                                          ReplicaPlans.Selector selector)
+    {
+        ReplicaPlan.ForWrite plan = ReplicaPlans.forWrite(metadata, keyspace, consistencyLevel, liveAndDown, selector);
+        ResponseTracker tracker = createTrackerForWrite(consistencyLevel, plan, plan.pending, metadata);
+        return new CoordinationPlan.ForWrite(plan, tracker);
+    }
+
+    public CoordinationPlan.ForWriteWithIdeal planForWrite(ClusterMetadata metadata,
+                                                           Keyspace keyspace,
+                                                           ConsistencyLevel consistencyLevel,
+                                                           Function<ClusterMetadata, ReplicaLayout.ForTokenWrite> liveAndDown,
+                                                           ReplicaPlans.Selector selector)
+    {
+        CoordinationPlan.ForWrite actual = planForWriteInternal(metadata, keyspace, consistencyLevel, liveAndDown, selector);
+
+        CoordinationPlan.ForWrite ideal = null;
+        ConsistencyLevel idealCL = DatabaseDescriptor.getIdealConsistencyLevel();
+        if (idealCL != null)
+        {
+            if (idealCL == consistencyLevel)
+            {
+                ideal = actual;
+            }
+            else
+            {
+                ideal = planForWriteInternal(metadata, keyspace, idealCL, liveAndDown, selector);
+            }
+        }
+
+        return new CoordinationPlan.ForWriteWithIdeal(metadata, actual.replicas(), actual.responses(), ideal);
+    }
+
+    public CoordinationPlan.ForWriteWithIdeal planForWrite(ClusterMetadata metadata,
+                                                           Keyspace keyspace,
+                                                           ConsistencyLevel consistencyLevel,
+                                                           Token token,
+                                                           ReplicaPlans.Selector selector)
+    {
+        return planForWrite(metadata, keyspace, consistencyLevel,
+                            (newClusterMetadata) -> ReplicaLayout.forTokenWriteLiveAndDown(newClusterMetadata, keyspace, token), selector);
+    }
+
+    /**
+     * Create coordination plan for forwarding a counter write to the leader replica.
+     *
+     * In cases where the original coordinator is not a replica of the counter key, the counter
+     * mutation is forwarded to a leader replica that will coordinate the actual counter update.
+     */
+    public CoordinationPlan.ForWrite planForForwardingCounterWrite(ClusterMetadata metadata,
+                                                                   Keyspace keyspace,
+                                                                   Token token,
+                                                                   Function<ClusterMetadata, Replica> replicaSupplier)
+    {
+        ReplicaPlan.ForWrite plan = ReplicaPlans.forSingleReplicaWrite(metadata, keyspace, token, replicaSupplier);
+        ResponseTracker tracker = createTrackerForWrite(plan.consistencyLevel(), plan, plan.pending, metadata);
+
+        return new CoordinationPlan.ForWriteWithIdeal(metadata, plan, tracker, null);
+    }
+
+    /**
+     * Create coordination plan for replaying a mutation from the batchlog.
+     *
+     * When recovering failed batches, mutations are replayed to remote replicas only
+     * (local replica is handled separately). This method creates a replica plan
+     * targeting live remote replicas with CL.ONE, and a response tracker that waits on
+     * all contacts
+     */
+    public CoordinationPlan.ForWriteWithIdeal planForReplayMutation(ClusterMetadata metadata,
+                                                                    Keyspace keyspace,
+                                                                    Token token)
+    {
+        Preconditions.checkState(!replicationType.isTracked(), "Batch replay not supported with tracked keyspaces");
+
+        ReplicaPlan.ForWrite plan = ReplicaPlans.forReplayMutation(metadata, keyspace, token);
+
+        // wait until all contacts respond
+        int blockFor = plan.contacts().size();
+        ResponseTracker tracker = new SimpleResponseTracker(blockFor, blockFor);
+
+        return new CoordinationPlan.ForWriteWithIdeal(metadata, plan, tracker, null);
+    }
+
+    /**
+     * Create coordination plan for a single-partition token read.
+     */
+    public CoordinationPlan.ForTokenRead planForTokenRead(ClusterMetadata metadata,
+                                                          Keyspace keyspace,
+                                                          TableId tableId,
+                                                          Token token,
+                                                          @Nullable Index.QueryPlan indexQueryPlan,
+                                                          ConsistencyLevel consistencyLevel,
+                                                          SpeculativeRetryPolicy retry,
+                                                          ReadCoordinator coordinator)
+    {
+        ReplicaPlan.ForTokenRead plan = ReplicaPlans.forRead(metadata, keyspace, tableId, token, indexQueryPlan, consistencyLevel, retry, coordinator);
+        ReplicaPlan.SharedForTokenRead shared = ReplicaPlan.shared(plan);
+        ResponseTracker tracker = createTrackerForRead(plan);
+        return new CoordinationPlan.ForTokenRead(shared, tracker);
+    }
+
+    /**
+     * Create coordination plan for a range read.
+     */
+    public CoordinationPlan.ForRangeRead planForRangeRead(ClusterMetadata metadata,
+                                                          Keyspace keyspace,
+                                                          TableId tableId,
+                                                          @Nullable Index.QueryPlan indexQueryPlan,
+                                                          ConsistencyLevel consistencyLevel,
+                                                          AbstractBounds<PartitionPosition> range,
+                                                          int vnodeCount)
+    {
+        ReplicaPlan.ForRangeRead plan = ReplicaPlans.forRangeRead(metadata, keyspace, tableId, indexQueryPlan, consistencyLevel, range, vnodeCount, true);
+        ReplicaPlan.SharedForRangeRead shared = ReplicaPlan.shared(plan);
+        ResponseTracker tracker = createTrackerForRead(plan);
+        return new CoordinationPlan.ForRangeRead(shared, tracker);
+    }
+
+    /**
+     * Attempt to merge two adjacent range read coordination plans into one.
+     *
+     * If the two plans share enough live endpoints to satisfy the consistency level
+     * and the merge is worthwhile returns a merged plan otherwise returns null.
+     */
+    public CoordinationPlan.ForRangeRead maybeMergeRangeReads(ClusterMetadata metadata,
+                                                               Keyspace keyspace,
+                                                               TableId tableId,
+                                                               ConsistencyLevel consistencyLevel,
+                                                               ReplicaPlan.ForRangeRead left,
+                                                               ReplicaPlan.ForRangeRead right)
+    {
+        ReplicaPlan.ForRangeRead merged = ReplicaPlans.maybeMerge(metadata, keyspace, tableId, consistencyLevel, left, right);
+        if (merged == null)
+            return null;
+
+        ReplicaPlan.SharedForRangeRead shared = ReplicaPlan.shared(merged);
+        ResponseTracker tracker = createTrackerForRead(merged);
+        return new CoordinationPlan.ForRangeRead(shared, tracker);
+    }
+
+    /**
+     * Create coordination plan for a full range read
+     */
+    public CoordinationPlan.ForRangeRead planForFullRangeRead(Keyspace keyspace,
+                                                              ConsistencyLevel consistencyLevel,
+                                                              AbstractBounds<PartitionPosition> range,
+                                                              Set<InetAddressAndPort> endpointsToContact,
+                                                              int vnodeCount)
+    {
+        ReplicaPlan.ForRangeRead plan = ReplicaPlans.forFullRangeRead(keyspace, consistencyLevel, range, endpointsToContact, vnodeCount);
+        ReplicaPlan.SharedForRangeRead shared = ReplicaPlan.shared(plan);
+        ResponseTracker tracker = createTrackerForRead(plan);
+        return new CoordinationPlan.ForRangeRead(shared, tracker);
+    }
+
+    /**
+     * Create coordination plan for a single-replica token read.
+     */
+    public CoordinationPlan.ForTokenRead planForSingleReplicaTokenRead(Keyspace keyspace, Token token, Replica replica)
+    {
+        ReplicaPlan.ForTokenRead plan = ReplicaPlans.forSingleReplicaRead(keyspace, token, replica);
+        ReplicaPlan.SharedForTokenRead shared = ReplicaPlan.shared(plan);
+        ResponseTracker tracker = createTrackerForRead(plan);
+        return new CoordinationPlan.ForTokenRead(shared, tracker);
+    }
+
+    /**
+     * Create coordination plan for a single-replica range read.
+     *
+     * Used by short read protection to fetch additional partitions from a
+     * specific replica. blockFor=1, totalReplicas=1.
+     */
+    public CoordinationPlan.ForRangeRead planForSingleReplicaRangeRead(Keyspace keyspace,
+                                                                       AbstractBounds<PartitionPosition> range,
+                                                                       Replica replica,
+                                                                       int vnodeCount)
+    {
+        ReplicaPlan.ForRangeRead plan = ReplicaPlans.forSingleReplicaRead(keyspace, range, replica, vnodeCount);
+        ReplicaPlan.SharedForRangeRead shared = ReplicaPlan.shared(plan);
+        ResponseTracker tracker = createTrackerForRead(plan);
+        return new CoordinationPlan.ForRangeRead(shared, tracker);
+    }
+
+    /**
+     * Create ResponseTracker for read operation.
+     */
+    public <E extends Endpoints<E>, P extends ReplicaPlan.ForRead<E, P>> ResponseTracker createTrackerForRead(P plan)
+    {
+        int blockFor = plan.readQuorum();
+
+        // Use candidates.size() for totalReplicas to allow for speculation
+        // (speculation can contact additional candidates beyond initial contacts)
+        int totalReplicas = plan.readCandidates().size();
+
+        return new SimpleResponseTracker(blockFor, totalReplicas);
+    }
+
+    public Paxos.Participants paxosParticipants(ClusterMetadata metadata,
+                                                TableMetadata table,
+                                                Token token,
+                                                ConsistencyLevel consistencyForConsensus,
+                                                Predicate<Replica> isReplicaAlive)
+    {
+
+        KeyspaceMetadata keyspaceMetadata = metadata.schema.getKeyspaceMetadata(table.keyspace);
+        // MetaStrategy distributes the entire keyspace to all replicas. In addition, its tables (currently only
+        // the dist log table) don't use the globally configured partitioner. For these reasons we don't lookup the
+        // replicas using the supplied token as this can actually be of the incorrect type (for example when
+        // performing Paxos repair).
+        final Token actualToken = table.partitioner == MetaStrategy.partitioner ? MetaStrategy.entireRange.right : token;
+        ReplicaLayout.ForTokenWrite all = forTokenWriteLiveAndDown(keyspaceMetadata, actualToken);

Review Comment:
   This doesn't use the metadata that is passed in, is that intentional?



##########
src/java/org/apache/cassandra/locator/SatelliteReplicationStrategy.java:
##########
@@ -513,4 +631,1268 @@ public boolean hasSameSettings(AbstractReplicationStrategy other)
                primaryDC.equals(otherStrategy.primaryDC) &&
                disabledDCs.equals(otherStrategy.disabledDCs);
     }
+
+    private AbstractReplicationStrategy strategy(ClusterMetadata metadata)
+    {
+        return metadata.schema.getKeyspaceMetadata(keyspaceName).replicationStrategy;
+    }
+
+    static abstract class CoordinationPlanner<E extends Endpoints<E>, L extends ReplicaLayout<E>, P extends ReplicaPlan<E, P>>
+    {
+        final ClusterMetadata metadata;
+        final Keyspace keyspace;
+        final ConsistencyLevel cl;
+        final SatelliteReplicationStrategy strategy;
+        final String primary;
+        final String satellite;
+        final List<String> dcs;
+        final L fullLayout;
+        final L liveLayout;
+        final Map<String, L> fullLayouts;
+        final Map<String, L> liveLayouts;
+        final Map<String, E> fullEndpoints;
+        final Map<String, E> liveEndpoints;
+
+        public CoordinationPlanner(ClusterMetadata metadata, Keyspace keyspace, ConsistencyLevel cl, SatelliteReplicationStrategy strategy, String primary, L fullLayout)
+        {
+            this.metadata = metadata;
+            this.keyspace = keyspace;
+            this.cl = cl;
+            this.strategy = strategy;
+            this.primary = primary;
+            this.satellite = strategy.getSatelliteForDC(primary);
+
+            this.dcs = createDcList();
+            Preconditions.checkState(!dcs.isEmpty(), "No DCs available for request (primary=%s, all disabled?)", primary);
+
+            Set<String> dcSet = new HashSet<>(dcs);
+            this.fullLayout = filterLayout(fullLayout, rp -> dcSet.contains(metadata.locator.location(rp.endpoint()).datacenter));
+            this.liveLayout = filterLayout(this.fullLayout, FailureDetector.isReplicaAlive);
+
+            this.fullLayouts = Maps.newHashMapWithExpectedSize(dcs.size());
+            this.liveLayouts = Maps.newHashMapWithExpectedSize(dcs.size());
+
+            this.fullEndpoints = Maps.newHashMapWithExpectedSize(dcs.size());
+            this.liveEndpoints = Maps.newHashMapWithExpectedSize(dcs.size());
+
+            populateDcLayouts();
+        }
+
+        AbstractReplicationStrategy strategy(ClusterMetadata metadata)
+        {
+            return metadata.schema.getKeyspaceMetadata(keyspace.getName()).replicationStrategy;
+        }
+
+        List<String> createDcList()
+        {
+            List<String> results = new ArrayList<>();
+            if (!strategy.disabledDCs.contains(primary))
+                results.add(primary);
+
+            if (satellite != null && !strategy.disabledDCs.contains(satellite))
+                results.add(satellite);
+
+            for (String dc : strategy.fullDCs.keySet())
+            {
+                if (dc.equals(primary) || dc.equals(satellite) || strategy.disabledDCs.contains(dc))
+                    continue;
+                results.add(dc);
+            }
+
+            Preconditions.checkState(!results.isEmpty());
+            return results;
+        }
+
+        void populateDcLayouts()
+        {
+            for (String dc : dcs)
+            {
+                Predicate<Replica> dcFilter = rp -> metadata.locator.location(rp.endpoint()).datacenter.equals(dc);
+                L full = filterLayout(fullLayout, dcFilter);
+                L live = filterLayout(liveLayout, dcFilter);
+
+                fullLayouts.put(dc, full);
+                liveLayouts.put(dc, live);
+
+                fullEndpoints.put(dc, full.natural());
+                liveEndpoints.put(dc, live.natural());
+            }
+
+            if (ADDL_CHECKS_ENABLED)
+                Preconditions.checkState(fullLayouts.size() == dcs.size(), "populateDcLayouts: fullLayouts.size()=%s != dcs.size()=%s", fullLayouts.size(), dcs.size());
+        }
+
+        ResponseTrackerBuilder<E, L, P> createResponseTrackerBuilder()
+        {
+            switch (cl)
+            {
+                case ONE:
+                case LOCAL_ONE:
+                case NODE_LOCAL:
+                case ANY:
+                case TWO:
+                case THREE:
+                case ALL:
+                    return new ResponseTrackerBuilder.Simple<>(this);
+
+                case QUORUM:
+                case LOCAL_QUORUM:
+                    // LOCAL_QUORUM an QUORUM mean the same thing in SRS
+                    return new ResponseTrackerBuilder.Quorum<>(this);
+
+                case EACH_QUORUM:
+                    return new ResponseTrackerBuilder.EachQuorum<>(this);
+
+                default:
+                    throw new IllegalStateException("Unsupported consistency level: " + cl);
+            }
+        }
+
+        ResponseTracker createResponseTracker()
+        {
+            ResponseTrackerBuilder<E, L, P> builder = createResponseTrackerBuilder();
+            return builder.build();
+        }
+
+        abstract L filterLayout(L layout, Predicate<Replica> predicate);
+        abstract ResponseTracker createDcResponseTracker(String dc);
+
+        abstract P recomputePlan(ClusterMetadata metadata);
+        abstract P createReplicaPlan();
+
+        interface IndexReadExceptionCheck<E extends Endpoints<E>>
+        {
+            void maybeThrowIndexReadException(int initial, int filtered, Map<String, E> candidates, Map<InetAddressAndPort, Index.Status> indexStatusMap);
+        }
+
+        static class ForWrite extends CoordinationPlanner<EndpointsForToken, ReplicaLayout.ForTokenWrite, ReplicaPlan.ForWrite>
+        {
+            final ReplicaPlans.Selector selector;
+
+            public ForWrite(ClusterMetadata metadata, Keyspace keyspace, ConsistencyLevel cl, SatelliteReplicationStrategy strategy, String primary, ReplicaLayout.ForTokenWrite fullLayout, ReplicaPlans.Selector selector)
+            {
+                super(metadata, keyspace, cl, strategy, primary, fullLayout);
+                this.selector = selector;
+                if (ADDL_CHECKS_ENABLED)
+                {
+                    Preconditions.checkState(!strategy.disabledDCs.contains(primary));
+                }
+            }
+
+            @Override
+            ResponseTracker createDcResponseTracker(String dc)
+            {
+                // this basically hardcodes ReplicaPlans.writeAll
+                Predicate<InetAddressAndPort> dcFilter = ep -> metadata.locator.location(ep).datacenter.equals(dc);
+
+                ReplicaLayout.ForTokenWrite dcLayout = fullLayouts.get(dc);
+                Preconditions.checkNotNull(dcLayout);
+
+                int blockFor = strategy.calculateQuorum(dc);
+                int totalContacts = dcLayout.all().size();
+
+                // Only count pending replicas that are actually in the selected contacts
+                int pendingInDc = dcLayout.pending().size();
+
+                if (pendingInDc == 0)
+                {
+                    return new SimpleResponseTracker(blockFor, totalContacts, dcFilter);
+                }
+                else
+                {
+                    int committedContacts = totalContacts - pendingInDc;
+                    int totalBlockFor = blockFor + pendingInDc;
+                    Predicate<InetAddressAndPort> isPending = ep -> dcLayout.pending.endpoints().contains(ep);
+                    return new WriteResponseTracker(blockFor, totalBlockFor,
+                                                    committedContacts, pendingInDc,
+                                                    isPending, dcFilter);
+                }
+            }
+
+            @Override
+            ReplicaPlan.ForWrite recomputePlan(ClusterMetadata metadata)
+            {
+                Token token = fullLayout.token();
+                return strategy(metadata).planForWrite(metadata, keyspace, cl,
+                                                              cm -> ReplicaLayout.forTokenWriteLiveAndDown(cm, keyspace, token),
+                                                       selector).replicas();
+            }
+
+            @Override
+            ReplicaLayout.ForTokenWrite filterLayout(ReplicaLayout.ForTokenWrite layout, Predicate<Replica> predicate)
+            {
+                return layout.filter(predicate);
+            }
+
+            private ReplicaLayout.ForTokenWrite mergeLayouts(Collection<ReplicaLayout.ForTokenWrite> layouts)
+            {
+                ReplicaCollection.Builder<EndpointsForToken> naturalBuilder = null;
+                ReplicaCollection.Builder<EndpointsForToken> pendingBuilder = null;
+
+                for (ReplicaLayout.ForTokenWrite layout : layouts)
+                {
+                    EndpointsForToken natural = layout.natural();
+                    EndpointsForToken pending = layout.pending();
+                    if (naturalBuilder == null)
+                    {
+                        naturalBuilder = natural.newBuilder(natural.size());
+                        pendingBuilder = pending.newBuilder(pending.size());
+                    }
+
+                    naturalBuilder.addAll(natural);
+                    pendingBuilder.addAll(pending);
+                }
+
+                return new ReplicaLayout.ForTokenWrite(strategy, naturalBuilder.build(), pendingBuilder.build());
+            }
+
+            @Override
+            ReplicaPlan.ForWrite createReplicaPlan()
+            {
+
+                ReplicaLayout.ForTokenWrite fullLayout = mergeLayouts(fullLayouts.values());
+                ReplicaLayout.ForTokenWrite liveLayout = mergeLayouts(liveLayouts.values());
+                EndpointsForToken contacts = selector.select(cl, fullLayout, liveLayout);
+
+                return new ReplicaPlan.ForWrite(keyspace,
+                                                strategy,
+                                                cl,
+                                                fullLayout.pending(),
+                                                fullLayout.all(),
+                                                liveLayout.all(),
+                                                contacts,
+                                                this::recomputePlan,
+                                                metadata.epoch);
+            }
+        }
+
+        static abstract class ForRead<E extends Endpoints<E>, L extends ReplicaLayout<E>, P extends ReplicaPlan.ForRead<E, P>> extends CoordinationPlanner<E, L, P>
+        {
+            final @Nullable Index.QueryPlan indexQueryPlan;
+            final boolean alwaysSpeculate;
+            final boolean throwOnInsufficientLiveReplicas;
+
+            public ForRead(ClusterMetadata metadata, Keyspace keyspace, ConsistencyLevel cl, SatelliteReplicationStrategy strategy, String primary, L fullLayout, @Nullable Index.QueryPlan indexQueryPlan, boolean alwaysSpeculate, boolean throwOnInsufficientLiveReplicas)
+            {
+                super(metadata, keyspace, cl, strategy, primary, fullLayout);
+                this.indexQueryPlan = indexQueryPlan;
+                this.alwaysSpeculate = alwaysSpeculate;
+                this.throwOnInsufficientLiveReplicas = throwOnInsufficientLiveReplicas;
+            }
+
+            @Override
+            ResponseTracker createDcResponseTracker(String dc)
+            {
+                Predicate<InetAddressAndPort> dcFilter = ep -> metadata.locator.location(ep).datacenter.equals(dc);
+
+                L dcLayout = fullLayouts.get(dc);
+                int blockFor = strategy.calculateQuorum(dc);
+                int totalContacts = dcLayout.all().size();
+
+                return new SimpleResponseTracker(blockFor, totalContacts, dcFilter);
+            }
+
+            Map<String, E> candidates(IndexReadExceptionCheck<E> check)
+            {
+                if (indexQueryPlan == null)
+                    return liveEndpoints;
+
+                Map<String, E> candidates = new HashMap<>();
+                Map<InetAddressAndPort, Index.Status> indexStatusMap = new HashMap<>();
+                int initial = 0;
+                int filtered = 0;
+                for (Map.Entry<String, E> entry : liveEndpoints.entrySet())
+                {
+                    E liveEndpoints = entry.getValue();
+                    E queryableEndpoints = IndexStatusManager.instance.filterForQuery(liveEndpoints, keyspace.getName(), indexQueryPlan, indexStatusMap);
+                    initial += liveEndpoints.size();
+                    filtered += queryableEndpoints.size();
+                    candidates.put(entry.getKey(), queryableEndpoints);
+                }
+
+                if (initial != filtered)
+                    check.maybeThrowIndexReadException(initial, filtered, candidates, indexStatusMap);
+
+                return candidates;
+            }
+
+            @Override
+            ResponseTracker createResponseTracker()
+            {
+                ResponseTracker tracker = super.createResponseTracker();
+
+                // read trackers don't wait on pending nodes
+                Preconditions.checkState(tracker.pendingContacts() == 0);
+                return tracker;
+            }
+
+
+            abstract P replicaPlanBuilder(E candidates, E contacts, E liveAndDown, int quorumSize);
+
+            ReadReplicaPlanBuilder<E, L, P> replicaPlanBuilder()
+            {
+                switch (cl)
+                {
+                    case ONE:
+                    case LOCAL_ONE:
+                    case NODE_LOCAL:
+                    case ANY:
+                    case TWO:
+                    case THREE:
+                    case ALL:
+                        return new ReadReplicaPlanBuilder.Simple<>(this);
+
+                    case QUORUM:
+                    case LOCAL_QUORUM:
+                    case EACH_QUORUM:
+                        return new ReadReplicaPlanBuilder.DcQuorum<>(this);
+
+                    default:
+                        throw new IllegalStateException("Unsupported consistency level: " + cl);
+                }
+            }
+
+            @Override
+            P createReplicaPlan()
+            {
+                ReadReplicaPlanBuilder<E, L, P> builder = replicaPlanBuilder();
+                return builder.build();
+            }
+        }
+
+        static class ForTokenRead extends ForRead<EndpointsForToken, ReplicaLayout.ForTokenRead, ReplicaPlan.ForTokenRead>
+        {
+            static final Function<ReplicaPlan<?, ?>, ReplicaPlan.ForWrite> READ_REPAIR_PLAN = (ReplicaPlan<?, ?> self) -> {throw new IllegalStateException("Cannot create read repair plans for SatelliteDatacenterReplicationStrategy");};
+            final Token token;
+            final TableId tableId;
+            final SpeculativeRetryPolicy retry;
+            final ReadCoordinator coordinator;
+
+            public ForTokenRead(ClusterMetadata metadata, Keyspace keyspace, Token token, ConsistencyLevel cl, SatelliteReplicationStrategy strategy, String primary, ReplicaLayout.ForTokenRead fullLayout, @Nullable Index.QueryPlan indexQueryPlan, SpeculativeRetryPolicy retry, boolean throwOnInsufficientLiveReplicas, TableId tableId, ReadCoordinator coordinator)
+            {
+                super(metadata, keyspace, cl, strategy, primary, fullLayout, indexQueryPlan, retry == AlwaysSpeculativeRetryPolicy.INSTANCE, throwOnInsufficientLiveReplicas);
+                this.token = token;
+                this.tableId = tableId;
+                this.retry = retry;
+                this.coordinator = coordinator;
+            }
+
+            @Override
+            ReplicaLayout.ForTokenRead filterLayout(ReplicaLayout.ForTokenRead layout, Predicate<Replica> predicate)
+            {
+                return layout.filter(predicate);
+            }
+
+            @Override
+            ReplicaPlan.ForTokenRead recomputePlan(ClusterMetadata metadata)
+            {
+                return strategy(metadata).planForTokenRead(metadata, keyspace, tableId, token,
+                                                           indexQueryPlan, cl, retry, coordinator).replicas();
+            }
+
+            @Override
+            ReplicaPlan.ForTokenRead replicaPlanBuilder(EndpointsForToken candidates, EndpointsForToken contacts, EndpointsForToken liveAndDown, int quorumSize)
+            {
+                return new ReplicaPlan.ForTokenRead(keyspace, strategy, cl,
+                                                    candidates, contacts, liveAndDown,
+                                                    this::recomputePlan,
+                                                    READ_REPAIR_PLAN,
+                                                    metadata.epoch,
+                                                    quorumSize);
+            }
+        }
+
+        static class ForRangeRead extends ForRead<EndpointsForRange, ReplicaLayout.ForRangeRead, ReplicaPlan.ForRangeRead>
+        {
+            static final BiFunction<ReplicaPlan<?, ?>, Token, ReplicaPlan.ForWrite> READ_REPAIR_PLAN = (ReplicaPlan<?, ?> self, Token token) -> {throw new IllegalStateException("Cannot create read repair plans for SatelliteDatacenterReplicationStrategy");};
+            final AbstractBounds<PartitionPosition> range;
+            final int vnodeCount;
+            final TableId tableId;
+
+            public ForRangeRead(ClusterMetadata metadata, Keyspace keyspace, AbstractBounds<PartitionPosition> range, int vnodeCount, ConsistencyLevel cl, SatelliteReplicationStrategy strategy, String primary, ReplicaLayout.ForRangeRead fullLayout, @Nullable Index.QueryPlan indexQueryPlan, boolean throwOnInsufficientLiveReplicas, TableId tableId)
+            {
+                super(metadata, keyspace, cl, strategy, primary, fullLayout, indexQueryPlan, false, throwOnInsufficientLiveReplicas);
+                this.range = range;
+                this.vnodeCount = vnodeCount;
+                this.tableId = tableId;
+            }
+
+            @Override
+            ReplicaLayout.ForRangeRead filterLayout(ReplicaLayout.ForRangeRead layout, Predicate<Replica> predicate)
+            {
+                return layout.filter(predicate);
+            }
+
+            @Override
+            ReplicaPlan.ForRangeRead recomputePlan(ClusterMetadata metadata)
+            {
+                return strategy(metadata).planForRangeRead(metadata, keyspace, tableId,
+                                                           indexQueryPlan, cl, range, vnodeCount).replicas();
+            }
+
+            @Override
+            ReplicaPlan.ForRangeRead replicaPlanBuilder(EndpointsForRange candidates, EndpointsForRange contacts, EndpointsForRange liveAndDown, int quorumSize)
+            {
+                return new ReplicaPlan.ForRangeRead(keyspace, strategy, cl, range,
+                                                    candidates, contacts, liveAndDown, vnodeCount,
+                                                    this::recomputePlan,
+                                                    READ_REPAIR_PLAN,
+                                                    metadata.epoch,
+                                                    quorumSize);
+            }
+        }
+    }
+
+    static abstract class ResponseTrackerBuilder<E extends Endpoints<E>, L extends ReplicaLayout<E>, P extends ReplicaPlan<E, P>>
+    {
+        final CoordinationPlanner<E, L, P> planner;
+
+        public ResponseTrackerBuilder(CoordinationPlanner<E, L, P> planner)
+        {
+            this.planner = planner;
+        }
+
+        Map<String, ResponseTracker> responseTrackersForPrimary()
+        {
+            Map<String, ResponseTracker> result = Maps.newHashMapWithExpectedSize(planner.dcs.size());
+
+            for (String dc : planner.dcs)
+                result.put(dc, planner.createDcResponseTracker(dc));
+
+            return result;
+        }
+
+        abstract ResponseTracker aggregateTrackers(Map<String, ResponseTracker> dcTrackers);
+
+        ResponseTracker build()
+        {
+            Map<String, ResponseTracker> dcTrackers = responseTrackersForPrimary();
+            return aggregateTrackers(dcTrackers);
+        }
+
+        static class Simple<E extends Endpoints<E>, L extends ReplicaLayout<E>, P extends ReplicaPlan<E, P>> extends ResponseTrackerBuilder<E, L, P>
+        {
+            public Simple(CoordinationPlanner<E, L, P> planner)
+            {
+                super(planner);
+            }
+
+            @Override
+            ResponseTracker aggregateTrackers(Map<String, ResponseTracker> dcTrackers)
+            {
+                int totalContacts = 0;
+                int totalPending = 0;
+
+
+                List<ResponseTracker> trackers = Lists.newArrayList();
+                Set<String> blockingDatacenters = Sets.newHashSet();
+
+                for (Map.Entry<String, ResponseTracker> entry : dcTrackers.entrySet())
+                {
+                    String dc = entry.getKey();
+                    ResponseTracker tracker = entry.getValue();
+                    if (planner.cl == ConsistencyLevel.ALL || dc.equals(planner.primary) || dc.equals(planner.satellite))
+                    {
+                        totalContacts += tracker.totalContacts();
+                        totalPending += tracker.pendingContacts();
+                        trackers.add(tracker);
+                        blockingDatacenters.add(dc);
+                    }
+                }
+
+                Predicate<InetAddressAndPort> filter = i -> {
+                    String dc = planner.metadata.locator.location(i).datacenter;
+                    return dc != null && blockingDatacenters.contains(dc);
+                };
+
+                Preconditions.checkState(!trackers.isEmpty(), "No blocking DCs for CL %s (primary=%s, satellite=%s)", planner.cl, planner.primary, planner.satellite);
+
+
+                if (planner.cl == ConsistencyLevel.ALL)
+                    return new SimpleResponseTracker(totalContacts, totalContacts, filter);
+
+                int blockFor = planner.cl.blockFor(planner.strategy);
+                int totalBlockFor = blockFor + totalPending;
+
+                Preconditions.checkState(blockFor <= totalContacts, "Simple tracker: blockFor=%s > totalContacts=%s for CL %s", blockFor, totalContacts, planner.cl);

Review Comment:
   This should be `UnavailableException` for cases where we don't have enough replicas and we should seek to match the error message that we throw with NTS.



##########
src/java/org/apache/cassandra/locator/SatelliteReplicationStrategy.java:
##########
@@ -513,4 +631,1286 @@ public boolean hasSameSettings(AbstractReplicationStrategy other)
                primaryDC.equals(otherStrategy.primaryDC) &&
                disabledDCs.equals(otherStrategy.disabledDCs);
     }
+
+    private AbstractReplicationStrategy strategy(ClusterMetadata metadata)
+    {
+        return metadata.schema.getKeyspaceMetadata(keyspaceName).replicationStrategy;
+    }
+
+    static abstract class CoordinationPlanner<E extends Endpoints<E>, L extends ReplicaLayout<E>, P extends ReplicaPlan<E, P>>
+    {
+        final ClusterMetadata metadata;
+        final Keyspace keyspace;
+        final ConsistencyLevel cl;
+        final SatelliteReplicationStrategy strategy;
+        final String primary;
+        final String satellite;
+        final List<String> dcs;
+        final L fullLayout;
+        final L liveLayout;
+        final Map<String, L> fullLayouts;
+        final Map<String, L> liveLayouts;
+        final Map<String, E> fullEndpoints;
+        final Map<String, E> liveEndpoints;
+
+        public CoordinationPlanner(ClusterMetadata metadata, Keyspace keyspace, ConsistencyLevel cl, SatelliteReplicationStrategy strategy, String primary, L fullLayout)
+        {
+            this.metadata = metadata;
+            this.keyspace = keyspace;
+            this.cl = cl;
+            this.strategy = strategy;
+            this.primary = primary;
+            this.satellite = strategy.getSatelliteForDC(primary);
+
+            this.dcs = createDcList();
+            Preconditions.checkState(!dcs.isEmpty(), "No DCs available for request (primary=%s, all disabled?)", primary);
+
+            Set<String> dcSet = new HashSet<>(dcs);
+            this.fullLayout = filterLayout(fullLayout, rp -> dcSet.contains(metadata.locator.location(rp.endpoint()).datacenter));
+            this.liveLayout = filterLayout(this.fullLayout, FailureDetector.isReplicaAlive);
+
+            this.fullLayouts = Maps.newHashMapWithExpectedSize(dcs.size());
+            this.liveLayouts = Maps.newHashMapWithExpectedSize(dcs.size());
+
+            this.fullEndpoints = Maps.newHashMapWithExpectedSize(dcs.size());
+            this.liveEndpoints = Maps.newHashMapWithExpectedSize(dcs.size());
+
+            populateDcLayouts();
+        }
+
+        AbstractReplicationStrategy strategy(ClusterMetadata metadata)
+        {
+            return metadata.schema.getKeyspaceMetadata(keyspace.getName()).replicationStrategy;
+        }
+
+        List<String> createDcList()
+        {
+            List<String> results = new ArrayList<>();
+            if (!strategy.disabledDCs.contains(primary))
+                results.add(primary);
+
+            if (satellite != null && !strategy.disabledDCs.contains(satellite))
+                results.add(satellite);
+
+            for (String dc : strategy.fullDCs.keySet())
+            {
+                if (dc.equals(primary) || dc.equals(satellite) || strategy.disabledDCs.contains(dc))
+                    continue;
+                results.add(dc);
+            }
+
+            Preconditions.checkState(!results.isEmpty());
+            return results;
+        }
+
+        void populateDcLayouts()
+        {
+            for (String dc : dcs)
+            {
+                Predicate<Replica> dcFilter = rp -> metadata.locator.location(rp.endpoint()).datacenter.equals(dc);
+                L full = filterLayout(fullLayout, dcFilter);
+                L live = filterLayout(liveLayout, dcFilter);
+
+                fullLayouts.put(dc, full);
+                liveLayouts.put(dc, live);
+
+                fullEndpoints.put(dc, full.natural());
+                liveEndpoints.put(dc, live.natural());
+            }
+
+            if (ADDL_CHECKS_ENABLED)
+                Preconditions.checkState(fullLayouts.size() == dcs.size(), "populateDcLayouts: fullLayouts.size()=%s != dcs.size()=%s", fullLayouts.size(), dcs.size());
+        }
+
+        ResponseTrackerBuilder<E, L, P> createResponseTrackerBuilder()
+        {
+            switch (cl)
+            {
+                case ONE:
+                case LOCAL_ONE:
+                case NODE_LOCAL:
+                case ANY:
+                case TWO:
+                case THREE:
+                case ALL:
+                    return new ResponseTrackerBuilder.Simple<>(this);
+
+                case QUORUM:
+                case LOCAL_QUORUM:
+                    // LOCAL_QUORUM an QUORUM mean the same thing in SRS
+                    return new ResponseTrackerBuilder.Quorum<>(this);
+
+                case EACH_QUORUM:
+                    return new ResponseTrackerBuilder.EachQuorum<>(this);
+
+                default:
+                    throw new IllegalStateException("Unsupported consistency level: " + cl);
+            }
+        }
+
+        ResponseTracker createResponseTracker()
+        {
+            ResponseTrackerBuilder<E, L, P> builder = createResponseTrackerBuilder();
+            return builder.build();
+        }
+
+        abstract L filterLayout(L layout, Predicate<Replica> predicate);
+        abstract ResponseTracker createDcResponseTracker(String dc);
+
+        abstract P recomputePlan(ClusterMetadata metadata);
+        abstract P createReplicaPlan();
+
+        interface IndexReadExceptionCheck<E extends Endpoints<E>>
+        {
+            void maybeThrowIndexReadException(int initial, int filtered, Map<String, E> candidates, Map<InetAddressAndPort, Index.Status> indexStatusMap);
+        }
+
+        static class ForWrite extends CoordinationPlanner<EndpointsForToken, ReplicaLayout.ForTokenWrite, ReplicaPlan.ForWrite>
+        {
+            final ReplicaPlans.Selector selector;
+
+            public ForWrite(ClusterMetadata metadata, Keyspace keyspace, ConsistencyLevel cl, SatelliteReplicationStrategy strategy, String primary, ReplicaLayout.ForTokenWrite fullLayout, ReplicaPlans.Selector selector)
+            {
+                super(metadata, keyspace, cl, strategy, primary, fullLayout);
+                this.selector = selector;
+                if (ADDL_CHECKS_ENABLED)
+                {
+                    Preconditions.checkState(!strategy.disabledDCs.contains(primary));
+                }
+            }
+
+            @Override
+            ResponseTracker createDcResponseTracker(String dc)
+            {
+                // this basically hardcodes ReplicaPlans.writeAll
+                Predicate<InetAddressAndPort> dcFilter = ep -> metadata.locator.location(ep).datacenter.equals(dc);
+
+                ReplicaLayout.ForTokenWrite dcLayout = fullLayouts.get(dc);
+                Preconditions.checkNotNull(dcLayout);
+
+                int blockFor = strategy.calculateQuorum(dc);
+                int totalContacts = dcLayout.all().size();
+
+                // Only count pending replicas that are actually in the selected contacts
+                int pendingInDc = dcLayout.pending().size();
+
+                if (pendingInDc == 0)
+                {
+                    return new SimpleResponseTracker(blockFor, totalContacts, dcFilter);
+                }
+                else
+                {
+                    int committedContacts = totalContacts - pendingInDc;
+                    int totalBlockFor = blockFor + pendingInDc;
+                    Predicate<InetAddressAndPort> isPending = ep -> dcLayout.pending.endpoints().contains(ep);
+                    return new WriteResponseTracker(blockFor, totalBlockFor,
+                                                    committedContacts, pendingInDc,
+                                                    isPending, dcFilter);
+                }
+            }
+
+            @Override
+            ReplicaPlan.ForWrite recomputePlan(ClusterMetadata metadata)
+            {
+                Token token = fullLayout.token();
+                return strategy(metadata).planForWrite(metadata, keyspace, cl,
+                                                              cm -> ReplicaLayout.forTokenWriteLiveAndDown(cm, keyspace, token),
+                                                       selector).replicas();
+            }
+
+            @Override
+            ReplicaLayout.ForTokenWrite filterLayout(ReplicaLayout.ForTokenWrite layout, Predicate<Replica> predicate)
+            {
+                return layout.filter(predicate);
+            }
+
+            private ReplicaLayout.ForTokenWrite mergeLayouts(Collection<ReplicaLayout.ForTokenWrite> layouts)
+            {
+                ReplicaCollection.Builder<EndpointsForToken> naturalBuilder = null;
+                ReplicaCollection.Builder<EndpointsForToken> pendingBuilder = null;
+
+                for (ReplicaLayout.ForTokenWrite layout : layouts)
+                {
+                    EndpointsForToken natural = layout.natural();
+                    EndpointsForToken pending = layout.pending();
+                    if (naturalBuilder == null)
+                    {
+                        naturalBuilder = natural.newBuilder(natural.size());
+                        pendingBuilder = pending.newBuilder(pending.size());
+                    }
+
+                    naturalBuilder.addAll(natural);
+                    pendingBuilder.addAll(pending);
+                }
+
+                return new ReplicaLayout.ForTokenWrite(strategy, naturalBuilder.build(), pendingBuilder.build());
+            }
+
+            @Override
+            ReplicaPlan.ForWrite createReplicaPlan()
+            {
+
+                ReplicaLayout.ForTokenWrite fullLayout = mergeLayouts(fullLayouts.values());
+                ReplicaLayout.ForTokenWrite liveLayout = mergeLayouts(liveLayouts.values());
+                EndpointsForToken contacts = selector.select(cl, fullLayout, liveLayout);
+
+                return new ReplicaPlan.ForWrite(keyspace,
+                                                strategy,
+                                                cl,
+                                                fullLayout.pending(),
+                                                fullLayout.all(),
+                                                liveLayout.all(),
+                                                contacts,
+                                                this::recomputePlan,
+                                                metadata.epoch);
+            }
+        }
+
+        static abstract class ForRead<E extends Endpoints<E>, L extends ReplicaLayout<E>, P extends ReplicaPlan.ForRead<E, P>> extends CoordinationPlanner<E, L, P>
+        {
+            final @Nullable Index.QueryPlan indexQueryPlan;
+            final boolean alwaysSpeculate;
+            final boolean throwOnInsufficientLiveReplicas;
+
+            public ForRead(ClusterMetadata metadata, Keyspace keyspace, ConsistencyLevel cl, SatelliteReplicationStrategy strategy, String primary, L fullLayout, @Nullable Index.QueryPlan indexQueryPlan, boolean alwaysSpeculate, boolean throwOnInsufficientLiveReplicas)
+            {
+                super(metadata, keyspace, cl, strategy, primary, fullLayout);
+                this.indexQueryPlan = indexQueryPlan;
+                this.alwaysSpeculate = alwaysSpeculate;
+                this.throwOnInsufficientLiveReplicas = throwOnInsufficientLiveReplicas;
+            }
+
+            @Override
+            ResponseTracker createDcResponseTracker(String dc)
+            {
+                Predicate<InetAddressAndPort> dcFilter = ep -> metadata.locator.location(ep).datacenter.equals(dc);
+
+                L dcLayout = fullLayouts.get(dc);
+                int blockFor = strategy.calculateQuorum(dc);
+                int totalContacts = dcLayout.all().size();
+
+                return new SimpleResponseTracker(blockFor, totalContacts, dcFilter);
+            }
+
+            Map<String, E> candidates(IndexReadExceptionCheck<E> check)
+            {
+                if (indexQueryPlan == null)
+                    return liveEndpoints;
+
+                Map<String, E> candidates = new HashMap<>();
+                Map<InetAddressAndPort, Index.Status> indexStatusMap = new HashMap<>();
+                int initial = 0;
+                int filtered = 0;
+                for (Map.Entry<String, E> entry : liveEndpoints.entrySet())
+                {
+                    E liveEndpoints = entry.getValue();
+                    E queryableEndpoints = IndexStatusManager.instance.filterForQuery(liveEndpoints, keyspace.getName(), indexQueryPlan, indexStatusMap);
+                    initial += liveEndpoints.size();
+                    filtered += queryableEndpoints.size();
+                    candidates.put(entry.getKey(), queryableEndpoints);
+                }
+
+                if (initial != filtered)
+                    check.maybeThrowIndexReadException(initial, filtered, candidates, indexStatusMap);
+
+                return candidates;
+            }
+
+            @Override
+            ResponseTracker createResponseTracker()
+            {
+                ResponseTracker tracker = super.createResponseTracker();
+
+                // read trackers don't wait on pending nodes
+                Preconditions.checkState(tracker.pendingContacts() == 0);
+                return tracker;
+            }
+
+
+            abstract P replicaPlanBuilder(E candidates, E contacts, E liveAndDown, int quorumSize);
+
+            ReadReplicaPlanBuilder<E, L, P> replicaPlanBuilder()
+            {
+                switch (cl)
+                {
+                    case ONE:
+                    case LOCAL_ONE:
+                    case NODE_LOCAL:
+                    case ANY:
+                    case TWO:
+                    case THREE:
+                    case ALL:
+                        return new ReadReplicaPlanBuilder.Simple<>(this);
+
+                    case QUORUM:
+                    case LOCAL_QUORUM:
+                    case EACH_QUORUM:
+                        return new ReadReplicaPlanBuilder.DcQuorum<>(this);
+
+                    default:
+                        throw new IllegalStateException("Unsupported consistency level: " + cl);
+                }
+            }
+
+            @Override
+            P createReplicaPlan()
+            {
+                ReadReplicaPlanBuilder<E, L, P> builder = replicaPlanBuilder();
+                return builder.build();
+            }
+        }
+
+        static class ForTokenRead extends ForRead<EndpointsForToken, ReplicaLayout.ForTokenRead, ReplicaPlan.ForTokenRead>
+        {
+            static final Function<ReplicaPlan<?, ?>, ReplicaPlan.ForWrite> READ_REPAIR_PLAN = (ReplicaPlan<?, ?> self) -> {throw new IllegalStateException("Cannot create read repair plans for SatelliteDatacenterReplicationStrategy");};
+            final Token token;
+            final TableId tableId;
+            final SpeculativeRetryPolicy retry;
+            final ReadCoordinator coordinator;
+
+            public ForTokenRead(ClusterMetadata metadata, Keyspace keyspace, Token token, ConsistencyLevel cl, SatelliteReplicationStrategy strategy, String primary, ReplicaLayout.ForTokenRead fullLayout, @Nullable Index.QueryPlan indexQueryPlan, SpeculativeRetryPolicy retry, boolean throwOnInsufficientLiveReplicas, TableId tableId, ReadCoordinator coordinator)
+            {
+                super(metadata, keyspace, cl, strategy, primary, fullLayout, indexQueryPlan, retry == AlwaysSpeculativeRetryPolicy.INSTANCE, throwOnInsufficientLiveReplicas);
+                this.token = token;
+                this.tableId = tableId;
+                this.retry = retry;
+                this.coordinator = coordinator;
+            }
+
+            @Override
+            ReplicaLayout.ForTokenRead filterLayout(ReplicaLayout.ForTokenRead layout, Predicate<Replica> predicate)
+            {
+                return layout.filter(predicate);
+            }
+
+            @Override
+            ReplicaPlan.ForTokenRead recomputePlan(ClusterMetadata metadata)
+            {
+                return strategy(metadata).planForTokenRead(metadata, keyspace, tableId, token,
+                                                           indexQueryPlan, cl, retry, coordinator).replicas();
+            }
+
+            @Override
+            ReplicaPlan.ForTokenRead replicaPlanBuilder(EndpointsForToken candidates, EndpointsForToken contacts, EndpointsForToken liveAndDown, int quorumSize)
+            {
+                return new ReplicaPlan.ForTokenRead(keyspace, strategy, cl,
+                                                    candidates, contacts, liveAndDown,
+                                                    this::recomputePlan,
+                                                    READ_REPAIR_PLAN,
+                                                    metadata.epoch,
+                                                    quorumSize);
+            }
+        }
+
+        static class ForRangeRead extends ForRead<EndpointsForRange, ReplicaLayout.ForRangeRead, ReplicaPlan.ForRangeRead>
+        {
+            static final BiFunction<ReplicaPlan<?, ?>, Token, ReplicaPlan.ForWrite> READ_REPAIR_PLAN = (ReplicaPlan<?, ?> self, Token token) -> {throw new IllegalStateException("Cannot create read repair plans for SatelliteDatacenterReplicationStrategy");};
+            final AbstractBounds<PartitionPosition> range;
+            final int vnodeCount;
+            final TableId tableId;
+
+            public ForRangeRead(ClusterMetadata metadata, Keyspace keyspace, AbstractBounds<PartitionPosition> range, int vnodeCount, ConsistencyLevel cl, SatelliteReplicationStrategy strategy, String primary, ReplicaLayout.ForRangeRead fullLayout, @Nullable Index.QueryPlan indexQueryPlan, boolean throwOnInsufficientLiveReplicas, TableId tableId)
+            {
+                super(metadata, keyspace, cl, strategy, primary, fullLayout, indexQueryPlan, false, throwOnInsufficientLiveReplicas);
+                this.range = range;
+                this.vnodeCount = vnodeCount;
+                this.tableId = tableId;
+            }
+
+            @Override
+            ReplicaLayout.ForRangeRead filterLayout(ReplicaLayout.ForRangeRead layout, Predicate<Replica> predicate)
+            {
+                return layout.filter(predicate);
+            }
+
+            @Override
+            ReplicaPlan.ForRangeRead recomputePlan(ClusterMetadata metadata)
+            {
+                return strategy(metadata).planForRangeRead(metadata, keyspace, tableId,
+                                                           indexQueryPlan, cl, range, vnodeCount).replicas();
+            }
+
+            @Override
+            ReplicaPlan.ForRangeRead replicaPlanBuilder(EndpointsForRange candidates, EndpointsForRange contacts, EndpointsForRange liveAndDown, int quorumSize)
+            {
+                return new ReplicaPlan.ForRangeRead(keyspace, strategy, cl, range,
+                                                    candidates, contacts, liveAndDown, vnodeCount,
+                                                    this::recomputePlan,
+                                                    READ_REPAIR_PLAN,
+                                                    metadata.epoch,
+                                                    quorumSize);
+            }
+        }
+    }
+
+    static abstract class ResponseTrackerBuilder<E extends Endpoints<E>, L extends ReplicaLayout<E>, P extends ReplicaPlan<E, P>>
+    {
+        final CoordinationPlanner<E, L, P> planner;
+
+        public ResponseTrackerBuilder(CoordinationPlanner<E, L, P> planner)
+        {
+            this.planner = planner;
+        }
+
+        Map<String, ResponseTracker> responseTrackersForPrimary()
+        {
+            Map<String, ResponseTracker> result = Maps.newHashMapWithExpectedSize(planner.dcs.size());
+
+            for (String dc : planner.dcs)
+                result.put(dc, planner.createDcResponseTracker(dc));
+
+            return result;
+        }
+
+        abstract ResponseTracker aggregateTrackers(Map<String, ResponseTracker> dcTrackers);
+
+        ResponseTracker build()
+        {
+            Map<String, ResponseTracker> dcTrackers = responseTrackersForPrimary();
+            return aggregateTrackers(dcTrackers);
+        }
+
+        static class Simple<E extends Endpoints<E>, L extends ReplicaLayout<E>, P extends ReplicaPlan<E, P>> extends ResponseTrackerBuilder<E, L, P>
+        {
+            public Simple(CoordinationPlanner<E, L, P> planner)
+            {
+                super(planner);
+            }
+
+            @Override
+            ResponseTracker aggregateTrackers(Map<String, ResponseTracker> dcTrackers)
+            {
+                int totalContacts = 0;
+                int totalPending = 0;
+
+
+                List<ResponseTracker> trackers = Lists.newArrayList();
+                Set<String> blockingDatacenters = Sets.newHashSet();
+
+                for (Map.Entry<String, ResponseTracker> entry : dcTrackers.entrySet())
+                {
+                    String dc = entry.getKey();
+                    ResponseTracker tracker = entry.getValue();
+                    if (planner.cl == ConsistencyLevel.ALL || dc.equals(planner.primary) || dc.equals(planner.satellite))
+                    {
+                        totalContacts += tracker.totalContacts();
+                        totalPending += tracker.pendingContacts();
+                        trackers.add(tracker);
+                        blockingDatacenters.add(dc);
+                    }
+                }
+
+                Predicate<InetAddressAndPort> filter = i -> {
+                    String dc = planner.metadata.locator.location(i).datacenter;
+                    return dc != null && blockingDatacenters.contains(dc);
+                };
+
+                Preconditions.checkState(!trackers.isEmpty(), "No blocking DCs for CL %s (primary=%s, satellite=%s)", planner.cl, planner.primary, planner.satellite);
+
+
+                if (planner.cl == ConsistencyLevel.ALL)
+                    return new SimpleResponseTracker(totalContacts, totalContacts, filter);
+
+                int blockFor = planner.cl.blockFor(planner.strategy);
+                int totalBlockFor = blockFor + totalPending;
+
+                Preconditions.checkState(blockFor <= totalContacts, "Simple tracker: blockFor=%s > totalContacts=%s for CL %s", blockFor, totalContacts, planner.cl);
+
+
+                if (totalPending == 0)
+                    return new SimpleResponseTracker(blockFor, totalContacts, filter);
+
+                Predicate<InetAddressAndPort> pending = i -> {
+                    for (ResponseTracker tracker : trackers)
+                    {
+                        if (tracker.isPending(i))
+                            return true;
+                    }
+                    return false;
+                };
+                return new WriteResponseTracker(blockFor, totalBlockFor, totalContacts, totalPending, pending, filter);
+            }
+        }
+
+        static class Quorum<E extends Endpoints<E>, L extends ReplicaLayout<E>, P extends ReplicaPlan<E, P>> extends ResponseTrackerBuilder<E, L, P>
+        {
+            public Quorum(CoordinationPlanner<E, L, P> planner)
+            {
+                super(planner);
+            }
+
+            @Override
+            ResponseTracker aggregateTrackers(Map<String, ResponseTracker> dcTrackers)
+            {
+                return new CompositeTracker(CompositeTracker.quorum(dcTrackers.size()), new ArrayList<>(dcTrackers.values()));
+            }
+        }
+
+        static class EachQuorum<E extends Endpoints<E>, L extends ReplicaLayout<E>, P extends ReplicaPlan<E, P>> extends ResponseTrackerBuilder<E, L, P>
+        {
+            public EachQuorum(CoordinationPlanner<E, L, P> planner)
+            {
+                super(planner);
+            }
+
+            @Override
+            ResponseTracker aggregateTrackers(Map<String, ResponseTracker> dcTrackers)
+            {
+                return new CompositeTracker(CompositeTracker.all(dcTrackers.size()), new ArrayList<>(dcTrackers.values()));
+            }
+        }
+    }
+
+    static abstract class ReadReplicaPlanBuilder<E extends Endpoints<E>, L extends ReplicaLayout<E>, P extends ReplicaPlan.ForRead<E, P>>
+            implements CoordinationPlanner.IndexReadExceptionCheck<E>
+    {
+
+        private static <E extends Endpoints<E>> Iterable<Replica> removedSupplier(Map<String, E> initial, Map<String, E> filtered)
+        {
+            return () -> initial.keySet().stream().map(dc -> {
+                Set<InetAddressAndPort> f =  filtered.containsKey(dc) ? filtered.get(dc).endpoints() : Collections.emptySet();
+                return initial.get(dc).without(f);
+            }).flatMap(AbstractReplicaCollection::stream).iterator();
+        }
+
+        final CoordinationPlanner.ForRead<E, L, P> planner;
+
+        public ReadReplicaPlanBuilder(CoordinationPlanner.ForRead<E, L, P> planner)
+        {
+            this.planner = planner;
+        }
+
+        abstract boolean canSelectReplicasFromDc(String dc, int liveNodes);
+        abstract int minBlockFor();
+        abstract int eligibleCandidates(Map<String, E> candidates);
+
+        abstract boolean haveSufficientContacts(int contactedReplicas, int contactedDcs);
+        abstract boolean haveSufficientContactsForDc(String dc, int contactedReplicas, int contactedDcs, int dcContacts);
+        abstract boolean haveSufficientLiveNodes(int contactedReplicas, int contactedDcs);
+
+        private E allLiveAndDown()
+        {
+            E fullEndpoints = planner.fullLayout.all();
+            ReplicaCollection.Builder<E> fullBuilder = fullEndpoints.newBuilder(fullEndpoints.size());
+
+            for (String dc : planner.dcs)
+            {
+                E endpoints = planner.fullEndpoints.get(dc);
+                if (endpoints == null)
+                    continue;
+
+                fullBuilder.addAll(endpoints);
+            }
+
+            return fullBuilder.build();
+        }
+
+        private E allCandidates(Map<String, E> candidates)
+        {
+            E liveEndpoints = planner.liveLayout.all();
+            ReplicaCollection.Builder<E> candidateBuilder = liveEndpoints.newBuilder(liveEndpoints.size());
+
+            for (String dc : planner.dcs)
+            {
+                // TODO: make sure the full replica is added if this is the dc it's from
+                E dcCandidates = candidates.get(dc);
+                if (dcCandidates == null || !canSelectReplicasFromDc(dc, dcCandidates.size()))
+                    continue;
+
+                candidateBuilder.addAll(dcCandidates);
+            }
+
+            return candidateBuilder.build();
+        }
+
+        private E contacts(Map<String, E> candidates)
+        {
+            E liveEndpoints = planner.liveLayout.all();
+
+            ReplicaCollection.Builder<E> contactBuilder = liveEndpoints.newBuilder(liveEndpoints.size());
+
+            // find full replica
+            Replica fullReplica = null;
+            String fullDc = null;
+            for (String dc : planner.dcs)
+            {
+                if (fullReplica != null)
+                    break;
+
+                E dcCandidates = candidates.get(dc);
+                if (dcCandidates == null || !canSelectReplicasFromDc(dc, dcCandidates.size()))
+                    continue;
+
+                for (Replica replica : dcCandidates)
+                {
+                    if (replica.isFull())
+                    {
+                        fullReplica = replica;
+                        fullDc = dc;
+                        break;
+                    }
+                }
+            }
+
+            if (fullReplica == null)
+            {
+                // No full replicas available - throw error similar to assureSufficientLiveReplicas
+                throw UnavailableException.create(ConsistencyLevel.ONE, minBlockFor(), 1, eligibleCandidates(candidates), 0);
+            }
+
+            int totalContacts = 0;
+            int totalContactedDcs = 0;
+
+            // add dc with full replicas first
+            {
+                E dcCandidates = candidates.get(fullDc);
+                Preconditions.checkNotNull(dcCandidates);
+
+                contactBuilder.add(fullReplica);
+                totalContacts++;
+                int dcContacts = 1;
+
+                if (!haveSufficientContacts(totalContacts, totalContactedDcs))
+                {
+                    for (Replica replica : dcCandidates)
+                    {
+                        if (replica.equals(fullReplica))
+                            continue;
+
+                        if (haveSufficientContactsForDc(fullDc, totalContacts, totalContactedDcs, dcContacts))
+                            break;
+
+                        contactBuilder.add(replica);
+                        totalContacts++;
+                        dcContacts++;
+                    }
+                }
+                totalContactedDcs++;
+            }
+
+            for (String dc : planner.dcs)
+            {
+                if (dc.equals(fullDc))
+                    continue;
+
+                E dcCandidates = candidates.get(dc);
+                if (dcCandidates == null || dcCandidates.isEmpty())
+                    continue;
+
+                if (haveSufficientContacts(totalContacts, totalContactedDcs))
+                    break;
+
+                int dcContacts = 0;
+                for (Replica replica : dcCandidates)
+                {
+                    if (haveSufficientContactsForDc(dc, totalContacts, totalContactedDcs, dcContacts))
+                        break;
+
+                    contactBuilder.add(replica);
+                    totalContacts++;
+                    dcContacts++;
+                }
+
+                totalContactedDcs++;
+            }
+
+            if (planner.throwOnInsufficientLiveReplicas && !haveSufficientLiveNodes(totalContacts, totalContactedDcs))
+            {
+                int fullReplicas = 0;
+                for (String dc : planner.dcs)
+                {
+                    E dcCandidates = candidates.get(dc);
+                    if (dcCandidates == null || !canSelectReplicasFromDc(dc, dcCandidates.size()))
+                        continue;
+                    for (Replica replica : dcCandidates)
+                        if (replica.isFull())
+                            fullReplicas++;
+                }
+
+                throw UnavailableException.create(planner.cl, minBlockFor(), 1, eligibleCandidates(candidates), fullReplicas);

Review Comment:
   This doesn't really communicate that we failed to achieve Quorum of Quorums vs QUORUM



##########
src/java/org/apache/cassandra/locator/AbstractReplicationStrategy.java:
##########
@@ -437,4 +461,376 @@ public RangesAtEndpoint getLocalRanges(ClusterMetadata cm)
                 return newRanges;
         }
     }
+
+    protected CoordinationPlan.ForWrite planForWriteInternal(ClusterMetadata metadata,
+                                                                          Keyspace keyspace,
+                                                                          ConsistencyLevel consistencyLevel,
+                                                                          Function<ClusterMetadata, ReplicaLayout.ForTokenWrite> liveAndDown,
+                                                                          ReplicaPlans.Selector selector)
+    {
+        ReplicaPlan.ForWrite plan = ReplicaPlans.forWrite(metadata, keyspace, consistencyLevel, liveAndDown, selector);
+        ResponseTracker tracker = createTrackerForWrite(consistencyLevel, plan, plan.pending, metadata);
+        return new CoordinationPlan.ForWrite(plan, tracker);
+    }
+
+    public CoordinationPlan.ForWriteWithIdeal planForWrite(ClusterMetadata metadata,
+                                                           Keyspace keyspace,
+                                                           ConsistencyLevel consistencyLevel,
+                                                           Function<ClusterMetadata, ReplicaLayout.ForTokenWrite> liveAndDown,
+                                                           ReplicaPlans.Selector selector)
+    {
+        CoordinationPlan.ForWrite actual = planForWriteInternal(metadata, keyspace, consistencyLevel, liveAndDown, selector);
+
+        CoordinationPlan.ForWrite ideal = null;
+        ConsistencyLevel idealCL = DatabaseDescriptor.getIdealConsistencyLevel();
+        if (idealCL != null)
+        {
+            if (idealCL == consistencyLevel)
+            {
+                ideal = actual;
+            }
+            else
+            {
+                ideal = planForWriteInternal(metadata, keyspace, idealCL, liveAndDown, selector);
+            }
+        }
+
+        return new CoordinationPlan.ForWriteWithIdeal(metadata, actual.replicas(), actual.responses(), ideal);
+    }
+
+    public CoordinationPlan.ForWriteWithIdeal planForWrite(ClusterMetadata metadata,
+                                                           Keyspace keyspace,
+                                                           ConsistencyLevel consistencyLevel,
+                                                           Token token,
+                                                           ReplicaPlans.Selector selector)
+    {
+        return planForWrite(metadata, keyspace, consistencyLevel,
+                            (newClusterMetadata) -> ReplicaLayout.forTokenWriteLiveAndDown(newClusterMetadata, keyspace, token), selector);
+    }
+
+    /**
+     * Create coordination plan for forwarding a counter write to the leader replica.
+     *
+     * In cases where the original coordinator is not a replica of the counter key, the counter
+     * mutation is forwarded to a leader replica that will coordinate the actual counter update.
+     */
+    public CoordinationPlan.ForWrite planForForwardingCounterWrite(ClusterMetadata metadata,
+                                                                   Keyspace keyspace,
+                                                                   Token token,
+                                                                   Function<ClusterMetadata, Replica> replicaSupplier)
+    {
+        ReplicaPlan.ForWrite plan = ReplicaPlans.forSingleReplicaWrite(metadata, keyspace, token, replicaSupplier);
+        ResponseTracker tracker = createTrackerForWrite(plan.consistencyLevel(), plan, plan.pending, metadata);
+
+        return new CoordinationPlan.ForWriteWithIdeal(metadata, plan, tracker, null);
+    }
+
+    /**
+     * Create coordination plan for replaying a mutation from the batchlog.
+     *
+     * When recovering failed batches, mutations are replayed to remote replicas only
+     * (local replica is handled separately). This method creates a replica plan
+     * targeting live remote replicas with CL.ONE, and a response tracker that waits on
+     * all contacts
+     */
+    public CoordinationPlan.ForWriteWithIdeal planForReplayMutation(ClusterMetadata metadata,
+                                                                    Keyspace keyspace,
+                                                                    Token token)
+    {
+        Preconditions.checkState(!replicationType.isTracked(), "Batch replay not supported with tracked keyspaces");
+
+        ReplicaPlan.ForWrite plan = ReplicaPlans.forReplayMutation(metadata, keyspace, token);
+
+        // wait until all contacts respond
+        int blockFor = plan.contacts().size();
+        ResponseTracker tracker = new SimpleResponseTracker(blockFor, blockFor);
+
+        return new CoordinationPlan.ForWriteWithIdeal(metadata, plan, tracker, null);
+    }
+
+    /**
+     * Create coordination plan for a single-partition token read.
+     */
+    public CoordinationPlan.ForTokenRead planForTokenRead(ClusterMetadata metadata,
+                                                          Keyspace keyspace,
+                                                          TableId tableId,
+                                                          Token token,
+                                                          @Nullable Index.QueryPlan indexQueryPlan,
+                                                          ConsistencyLevel consistencyLevel,
+                                                          SpeculativeRetryPolicy retry,
+                                                          ReadCoordinator coordinator)
+    {
+        ReplicaPlan.ForTokenRead plan = ReplicaPlans.forRead(metadata, keyspace, tableId, token, indexQueryPlan, consistencyLevel, retry, coordinator);
+        ReplicaPlan.SharedForTokenRead shared = ReplicaPlan.shared(plan);
+        ResponseTracker tracker = createTrackerForRead(plan);
+        return new CoordinationPlan.ForTokenRead(shared, tracker);
+    }
+
+    /**
+     * Create coordination plan for a range read.
+     */
+    public CoordinationPlan.ForRangeRead planForRangeRead(ClusterMetadata metadata,
+                                                          Keyspace keyspace,
+                                                          TableId tableId,
+                                                          @Nullable Index.QueryPlan indexQueryPlan,
+                                                          ConsistencyLevel consistencyLevel,
+                                                          AbstractBounds<PartitionPosition> range,
+                                                          int vnodeCount)
+    {
+        ReplicaPlan.ForRangeRead plan = ReplicaPlans.forRangeRead(metadata, keyspace, tableId, indexQueryPlan, consistencyLevel, range, vnodeCount, true);
+        ReplicaPlan.SharedForRangeRead shared = ReplicaPlan.shared(plan);
+        ResponseTracker tracker = createTrackerForRead(plan);
+        return new CoordinationPlan.ForRangeRead(shared, tracker);
+    }
+
+    /**
+     * Attempt to merge two adjacent range read coordination plans into one.
+     *
+     * If the two plans share enough live endpoints to satisfy the consistency level
+     * and the merge is worthwhile returns a merged plan otherwise returns null.
+     */
+    public CoordinationPlan.ForRangeRead maybeMergeRangeReads(ClusterMetadata metadata,
+                                                               Keyspace keyspace,
+                                                               TableId tableId,
+                                                               ConsistencyLevel consistencyLevel,
+                                                               ReplicaPlan.ForRangeRead left,
+                                                               ReplicaPlan.ForRangeRead right)
+    {
+        ReplicaPlan.ForRangeRead merged = ReplicaPlans.maybeMerge(metadata, keyspace, tableId, consistencyLevel, left, right);
+        if (merged == null)
+            return null;
+
+        ReplicaPlan.SharedForRangeRead shared = ReplicaPlan.shared(merged);
+        ResponseTracker tracker = createTrackerForRead(merged);
+        return new CoordinationPlan.ForRangeRead(shared, tracker);
+    }
+
+    /**
+     * Create coordination plan for a full range read
+     */
+    public CoordinationPlan.ForRangeRead planForFullRangeRead(Keyspace keyspace,
+                                                              ConsistencyLevel consistencyLevel,
+                                                              AbstractBounds<PartitionPosition> range,
+                                                              Set<InetAddressAndPort> endpointsToContact,
+                                                              int vnodeCount)
+    {
+        ReplicaPlan.ForRangeRead plan = ReplicaPlans.forFullRangeRead(keyspace, consistencyLevel, range, endpointsToContact, vnodeCount);
+        ReplicaPlan.SharedForRangeRead shared = ReplicaPlan.shared(plan);
+        ResponseTracker tracker = createTrackerForRead(plan);
+        return new CoordinationPlan.ForRangeRead(shared, tracker);
+    }
+
+    /**
+     * Create coordination plan for a single-replica token read.
+     */
+    public CoordinationPlan.ForTokenRead planForSingleReplicaTokenRead(Keyspace keyspace, Token token, Replica replica)
+    {
+        ReplicaPlan.ForTokenRead plan = ReplicaPlans.forSingleReplicaRead(keyspace, token, replica);
+        ReplicaPlan.SharedForTokenRead shared = ReplicaPlan.shared(plan);
+        ResponseTracker tracker = createTrackerForRead(plan);
+        return new CoordinationPlan.ForTokenRead(shared, tracker);
+    }
+
+    /**
+     * Create coordination plan for a single-replica range read.
+     *
+     * Used by short read protection to fetch additional partitions from a
+     * specific replica. blockFor=1, totalReplicas=1.
+     */
+    public CoordinationPlan.ForRangeRead planForSingleReplicaRangeRead(Keyspace keyspace,
+                                                                       AbstractBounds<PartitionPosition> range,
+                                                                       Replica replica,
+                                                                       int vnodeCount)
+    {
+        ReplicaPlan.ForRangeRead plan = ReplicaPlans.forSingleReplicaRead(keyspace, range, replica, vnodeCount);
+        ReplicaPlan.SharedForRangeRead shared = ReplicaPlan.shared(plan);
+        ResponseTracker tracker = createTrackerForRead(plan);
+        return new CoordinationPlan.ForRangeRead(shared, tracker);
+    }
+
+    /**
+     * Create ResponseTracker for read operation.
+     */
+    public <E extends Endpoints<E>, P extends ReplicaPlan.ForRead<E, P>> ResponseTracker createTrackerForRead(P plan)
+    {
+        int blockFor = plan.readQuorum();
+
+        // Use candidates.size() for totalReplicas to allow for speculation
+        // (speculation can contact additional candidates beyond initial contacts)
+        int totalReplicas = plan.readCandidates().size();
+
+        return new SimpleResponseTracker(blockFor, totalReplicas);
+    }
+
+    public Paxos.Participants paxosParticipants(ClusterMetadata metadata,
+                                                TableMetadata table,
+                                                Token token,
+                                                ConsistencyLevel consistencyForConsensus,
+                                                Predicate<Replica> isReplicaAlive)
+    {
+
+        KeyspaceMetadata keyspaceMetadata = metadata.schema.getKeyspaceMetadata(table.keyspace);
+        // MetaStrategy distributes the entire keyspace to all replicas. In addition, its tables (currently only
+        // the dist log table) don't use the globally configured partitioner. For these reasons we don't lookup the
+        // replicas using the supplied token as this can actually be of the incorrect type (for example when
+        // performing Paxos repair).
+        final Token actualToken = table.partitioner == MetaStrategy.partitioner ? MetaStrategy.entireRange.right : token;
+        ReplicaLayout.ForTokenWrite all = forTokenWriteLiveAndDown(keyspaceMetadata, actualToken);
+        ReplicaLayout.ForTokenWrite electorate = consistencyForConsensus.isDatacenterLocal()
+                                                 ? all.filter(InOurDc.replicas()) : all;
+
+        EndpointsForToken live = all.all().filter(isReplicaAlive);
+        return new Paxos.Participants(metadata.epoch, Keyspace.open(table.keyspace), consistencyForConsensus, all, electorate, live,
+                                      (cm) -> Paxos.Participants.get(cm, table, actualToken, consistencyForConsensus));
+    }
+
+    /**
+     * Hook for replication strategies to send additional mutations alongside a paxos commit.
+     * Called from PaxosCommit.start() after local synchronous execution for tracked keyspaces.
+     *
+     * If the method doesn't return null, the returned future is composed with the paxos consensus:
+     * onDone fires only after both the paxos quorum decision AND this future complete, or after
+     * one of them fails.
+     */
+    public Future<Void> sendPaxosCommitMutations(Agreed commit, boolean isUrgent)
+    {
+        return null;
+    }
+
+    /**
+     * Check whether paxos operations should be rejected for the given token.
+     */
+    public boolean shouldRejectPaxos(Token token)
+    {
+        return false;
+    }
+
+    /**
+     * Create ResponseTracker for write operation based on consistency level.
+     */
+    @VisibleForTesting
+    public ResponseTracker createTrackerForWrite(ConsistencyLevel cl, ReplicaPlan.ForWrite plan, Endpoints<?> pending, ClusterMetadata metadata)
+    {
+        switch (cl)
+        {
+            case ANY:
+            case ONE:
+            case TWO:
+            case THREE:
+            case QUORUM:
+            case ALL:
+                int totalContacts = plan.contacts().size();
+                if (pending.isEmpty())
+                {
+                    int blockFor = cl.blockFor(this);
+                    return new SimpleResponseTracker(blockFor, totalContacts);
+                }
+                else
+                {
+                    // Check if double count model applies (some CLs like ANY don't add pending)
+                    int baseBlockFor = cl.blockFor(this);
+                    int totalBlockFor = cl.blockForWrite(this, pending);
+
+                    // If totalBlockFor == baseBlockFor, no double-count needed (e.g., ANY)
+                    if (totalBlockFor == baseBlockFor)
+                        return new SimpleResponseTracker(baseBlockFor, totalContacts);
+
+                    // Double count model: committed must satisfy base CL, total must include pending
+                    int pendingReplicas = pending.size();
+                    // contacts() includes both committed and pending replicas
+                    int committedReplicas = totalContacts - pendingReplicas;
+                    return new WriteResponseTracker(baseBlockFor, totalBlockFor,
+                                                    committedReplicas, pendingReplicas,
+                                                    endpoint -> pending.endpoints().contains(endpoint));
+                }
+
+            case LOCAL_ONE:
+            case LOCAL_QUORUM:
+                int localContacts = plan.contacts().filter(InOurDc.replicas()).size();
+                if (pending.isEmpty())
+                {
+                    int localBlockFor = cl.blockFor(this);
+                    return new SimpleResponseTracker(localBlockFor, localContacts, InOurDc.endpoints());
+                }
+                else
+                {
+                    // Check if double count model applies (depends on local pending)
+                    int baseBlockFor = cl.blockFor(this);
+                    int totalBlockFor = cl.blockForWrite(this, pending);
+
+                    // If totalBlockFor == baseBlockFor, no local pending so no double-count needed
+                    if (totalBlockFor == baseBlockFor)
+                        return new SimpleResponseTracker(baseBlockFor, localContacts, InOurDc.endpoints());
+
+                    // Double count model for local DC
+                    int localPending = pending.count(InOurDc.replicas());
+                    // localContacts includes both committed and pending in local DC
+                    int localCommitted = localContacts - localPending;
+                    return new WriteResponseTracker(baseBlockFor, totalBlockFor,
+                                                    localCommitted, localPending,
+                                                    endpoint -> pending.endpoints().contains(endpoint),
+                                                    InOurDc.endpoints());
+                }
+
+            case EACH_QUORUM:
+                return createPerDcTracker(plan, pending, metadata);
+
+            default:
+                throw new UnsupportedOperationException("Unsupported consistency level for writes: " + cl);
+        }
+    }
+
+    /**
+     * Create per-datacenter tracker for EACH_QUORUM.
+     */
+    private ResponseTracker createPerDcTracker(ReplicaPlan.ForWrite plan, Endpoints<?> pending, ClusterMetadata metadata)
+    {
+        Map<String, ResponseTracker> trackerPerDc = new HashMap<>();
+        Locator locator = metadata.locator;
+
+        // Group replicas by datacenter
+        Map<String, List<Replica>> replicasByDc = new HashMap<>();
+        for (Replica replica : plan.contacts())
+        {
+            String dc = locator.location(replica.endpoint()).datacenter;
+            replicasByDc.computeIfAbsent(dc, k -> new ArrayList<>()).add(replica);
+        }
+
+        // Group pending replicas by datacenter
+        Map<String, List<Replica>> pendingByDc = new HashMap<>();
+        for (Replica replica : pending)
+        {
+            String dc = locator.location(replica.endpoint()).datacenter;
+            pendingByDc.computeIfAbsent(dc, k -> new ArrayList<>()).add(replica);
+        }
+
+        // Create tracker for each DC
+        for (Map.Entry<String, List<Replica>> entry : replicasByDc.entrySet())
+        {
+            String dc = entry.getKey();
+            int dcContacts = entry.getValue().size();
+            List<Replica> dcPending = pendingByDc.getOrDefault(dc, Collections.emptyList());
+            int dcPendingCount = dcPending.size();
+            int dcCommitted = dcContacts - dcPendingCount;
+            int dcBlockFor = dcCommitted / 2 + 1;

Review Comment:
   If a DC has all replicas pending (such as bootstrapping a DC) I think it would cause things to fail like WriteResponseTracker throwing IAE. This also indicates a missing test.



##########
src/java/org/apache/cassandra/locator/ResponseTracker.java:
##########
@@ -0,0 +1,126 @@
+/*
+ * 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.locator;
+
+import org.apache.cassandra.exceptions.RequestFailureReason;
+
+/**
+ * Black-box response tracker encapsulating coordination completion logic.
+ *
+ * Created by replication strategy for each operation, this interface allows
+ * strategies to customize how quorum requirements are calculated and enforced.
+ * Different implementations can provide different semantics.
+ *
+ * The tracker is responsible for:
+ * 1. Recording responses and failures from replicas
+ * 2. Determining when the operation has completed (success or definite failure)
+ * 3. Providing metrics for error messages and monitoring
+ *
+ * Thread safety: Implementations must be thread-safe as onResponse/onFailure
+ * can be called concurrently from multiple network threads.
+ */
+public interface ResponseTracker
+{
+    // TODO: review replica plan members and move here as appropriate
+
+    /**
+     * Record a successful response from a replica.
+     *
+     * @param from endpoint that responded successfully
+     */
+    void onResponse(InetAddressAndPort from);
+
+    /**
+     * Record a failed response from a replica.
+     *
+     * @param from endpoint that failed
+     * @param reason failure reason for metrics and error messages
+     */
+    void onFailure(InetAddressAndPort from, RequestFailureReason reason);
+
+    // TODO: consider having an outcome method that returns an enum (PENDING, SUCCESS, FAILURE)
+    /**
+     * Has the operation completed (either success or definite failure)?
+     *
+     * An operation is complete when:
+     * - Success: Required quorum has been achieved
+     * - Definite failure: Not enough replicas remain to achieve quorum
+     *
+     * @return true if no more responses are needed to make a decision
+     */
+    boolean isComplete();
+
+    /**
+     * Did the operation succeed (quorum achieved)?
+     *
+     * Only meaningful if isComplete() returns true.
+     *
+     * @return true if required quorum was met
+     */
+    boolean isSuccessful();
+
+    /**
+     * How many responses are required for success?
+     *
+     * Used for error messages, metrics, and UnavailableException construction.
+     * For complex trackers (e.g., EACH_QUORUM), this may be a sum or other
+     * aggregate value rather than the actual completion criteria.
+     *
+     * @return number of responses required
+     */
+    int required();
+
+    /**
+     * How many successful responses have been received so far?
+     *
+     * @return number of successful responses
+     */
+    int received();
+
+    /**
+     * How many failures have been recorded so far?
+     *
+     * @return number of failures
+     */
+    int failures();
+
+    /**
+     * Should responses from this endpoint be counted toward quorum?
+     *
+     * Allows filtering of responses based on datacenter, state, or other criteria.
+     * For example, LOCAL_QUORUM trackers would return false for remote DC replicas.
+     *
+     * @param from endpoint to check
+     * @return true if responses from this endpoint count toward quorum
+     */
+    boolean countsTowardQuorum(InetAddressAndPort from);
+
+    boolean isPending(InetAddressAndPort from);
+
+    int totalRequired();

Review Comment:
   Also test only? Never used and maybe only tested to show that it is correct so not even really test only?



##########
src/java/org/apache/cassandra/locator/CompositeTracker.java:
##########
@@ -0,0 +1,181 @@
+/*
+ * 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.locator;
+
+import java.util.Collection;
+import java.util.function.ToIntFunction;
+
+import org.apache.cassandra.exceptions.RequestFailureReason;
+
+/**
+ * Composite response tracker: broadcasts responses to all child trackers and succeeds when at least
+ * `blockFor` children are individually successful, or fails when `failures > count - blockFor`
+ *
+ * <ul>
+ *   <li>{@code blockFor == children.length} gives AND semantics (all must succeed)</li>
+ *   <li>{@code blockFor == quorum(children.length)} gives majority-quorum semantics</li>
+ * </ul>
+ */
+public class CompositeTracker implements ResponseTracker
+{
+    private final ResponseTracker[] children;
+    private final int blockFor;
+
+    public CompositeTracker(int blockFor, ResponseTracker... children)
+    {
+        if (children == null || children.length == 0)
+            throw new IllegalArgumentException("children cannot be null or empty");
+        if (blockFor < 1 || blockFor > children.length)
+            throw new IllegalArgumentException("blockFor must be between 1 and " + children.length);
+
+        this.children = children;
+        this.blockFor = blockFor;
+    }
+
+    public CompositeTracker(int blockFor, Collection<ResponseTracker> children)
+    {
+        this(blockFor, children.toArray(ResponseTracker[]::new));
+    }
+
+    public static int all(int count)

Review Comment:
   A bit odd to have a method for this, but sure. It could just be a local variable if what you want is to name this value in the context where it is used.



##########
src/java/org/apache/cassandra/locator/SatelliteReplicationStrategy.java:
##########
@@ -497,6 +567,54 @@ public boolean isDisabled(String dc)
         return disabledDCs.contains(dc);
     }
 
+    @Override
+    public Paxos.Participants paxosParticipants(ClusterMetadata metadata,
+                                                 TableMetadata table,
+                                                 Token token,
+                                                 ConsistencyLevel consistencyForConsensus,
+                                                 Predicate<Replica> isReplicaAlive)
+    {
+        // Reject paxos operations during TRANSITION_ACK to prevent conflicting paxos operations
+        // across different full DCs. The temporary gap in paxos availability ensures that the old
+        // primary has no in-flight proposals before the new primary begins serving paxos operations.
+        SatelliteFailoverState.FailoverInfo failoverInfo = getFailoverInfo(token, metadata);
+        if (failoverInfo.getState() == SatelliteFailoverState.State.TRANSITION_ACK)
+            throw UnavailableException.create(consistencyForConsensus, 1, 0);
+
+        KeyspaceMetadata keyspaceMetadata = metadata.schema.getKeyspaceMetadata(table.keyspace);
+        ReplicaLayout.ForTokenWrite fullLayout = ReplicaLayout.forTokenWriteLiveAndDown(metadata, keyspaceMetadata, token);
+
+        // Paxos consensus operates entirely within the primary DC
+        Predicate<Replica> inPrimaryDC = rp -> metadata.locator.location(rp.endpoint()).datacenter.equals(primaryDC);
+        ReplicaLayout.ForTokenWrite primaryAll = fullLayout.filter(inPrimaryDC);
+        ReplicaLayout.ForTokenWrite primaryElectorate = primaryAll;

Review Comment:
   Redundant?



##########
src/java/org/apache/cassandra/locator/SatelliteReplicationStrategy.java:
##########
@@ -497,6 +567,54 @@ public boolean isDisabled(String dc)
         return disabledDCs.contains(dc);
     }
 
+    @Override
+    public Paxos.Participants paxosParticipants(ClusterMetadata metadata,
+                                                 TableMetadata table,
+                                                 Token token,
+                                                 ConsistencyLevel consistencyForConsensus,
+                                                 Predicate<Replica> isReplicaAlive)
+    {
+        // Reject paxos operations during TRANSITION_ACK to prevent conflicting paxos operations
+        // across different full DCs. The temporary gap in paxos availability ensures that the old
+        // primary has no in-flight proposals before the new primary begins serving paxos operations.
+        SatelliteFailoverState.FailoverInfo failoverInfo = getFailoverInfo(token, metadata);
+        if (failoverInfo.getState() == SatelliteFailoverState.State.TRANSITION_ACK)
+            throw UnavailableException.create(consistencyForConsensus, 1, 0);
+
+        KeyspaceMetadata keyspaceMetadata = metadata.schema.getKeyspaceMetadata(table.keyspace);
+        ReplicaLayout.ForTokenWrite fullLayout = ReplicaLayout.forTokenWriteLiveAndDown(metadata, keyspaceMetadata, token);
+
+        // Paxos consensus operates entirely within the primary DC
+        Predicate<Replica> inPrimaryDC = rp -> metadata.locator.location(rp.endpoint()).datacenter.equals(primaryDC);
+        ReplicaLayout.ForTokenWrite primaryAll = fullLayout.filter(inPrimaryDC);
+        ReplicaLayout.ForTokenWrite primaryElectorate = primaryAll;
+
+        // Satellite/secondary DC endpoints for reads during prepare and writes during commit.
+        // Only include the primary DC's satellite and other full DCs — not satellites of other DCs.
+        String primarySatellite = getSatelliteForDC(primaryDC);
+        Predicate<Replica> isParticipatingNonPrimary = rp -> {
+            String dc = metadata.locator.location(rp.endpoint()).datacenter;
+            if (dc.equals(primaryDC))
+                return false;
+            if (dc.equals(primarySatellite))
+                return true;
+            return fullDCs.containsKey(dc);
+        };
+        EndpointsForToken satelliteEndpoints = fullLayout.all().filter(isParticipatingNonPrimary);
+
+        EndpointsForToken live = fullLayout.all().filter(isReplicaAlive);
+
+        Token actualToken = token;

Review Comment:
   Redundant?



##########
src/java/org/apache/cassandra/locator/AbstractReplicationStrategy.java:
##########
@@ -437,4 +461,372 @@ public RangesAtEndpoint getLocalRanges(ClusterMetadata cm)
                 return newRanges;
         }
     }
+
+    protected CoordinationPlan.ForWrite planForWriteInternal(ClusterMetadata metadata,
+                                                                          Keyspace keyspace,
+                                                                          ConsistencyLevel consistencyLevel,
+                                                                          Function<ClusterMetadata, ReplicaLayout.ForTokenWrite> liveAndDown,
+                                                                          ReplicaPlans.Selector selector)
+    {
+        ReplicaPlan.ForWrite plan = ReplicaPlans.forWrite(metadata, keyspace, consistencyLevel, liveAndDown, selector);
+        ResponseTracker tracker = createTrackerForWrite(consistencyLevel, plan, plan.pending, metadata);
+        return new CoordinationPlan.ForWrite(plan, tracker);
+    }
+
+    public CoordinationPlan.ForWriteWithIdeal planForWrite(ClusterMetadata metadata,
+                                                           Keyspace keyspace,
+                                                           ConsistencyLevel consistencyLevel,
+                                                           Function<ClusterMetadata, ReplicaLayout.ForTokenWrite> liveAndDown,
+                                                           ReplicaPlans.Selector selector)
+    {
+        CoordinationPlan.ForWrite actual = planForWriteInternal(metadata, keyspace, consistencyLevel, liveAndDown, selector);
+
+        CoordinationPlan.ForWrite ideal = null;
+        ConsistencyLevel idealCL = DatabaseDescriptor.getIdealConsistencyLevel();
+        if (idealCL != null)
+        {
+            if (idealCL == consistencyLevel)
+            {
+                ideal = actual;
+            }
+            else
+            {
+                ideal = planForWriteInternal(metadata, keyspace, idealCL, liveAndDown, selector);
+            }
+        }
+
+        return new CoordinationPlan.ForWriteWithIdeal(metadata, actual.replicas(), actual.responses(), ideal);
+    }
+
+    public CoordinationPlan.ForWriteWithIdeal planForWrite(ClusterMetadata metadata,
+                                                           Keyspace keyspace,
+                                                           ConsistencyLevel consistencyLevel,
+                                                           Token token,
+                                                           ReplicaPlans.Selector selector)
+    {
+        return planForWrite(metadata, keyspace, consistencyLevel,
+                            (newClusterMetadata) -> ReplicaLayout.forTokenWriteLiveAndDown(newClusterMetadata, keyspace, token), selector);
+    }
+
+    /**
+     * Create coordination plan for forwarding a counter write to the leader replica.
+     *
+     * In cases where the original coordinator is not a replica of the counter key, the counter
+     * mutation is forwarded to a leader replica that will coordinate the actual counter update.
+     */
+    public CoordinationPlan.ForWrite planForForwardingCounterWrite(ClusterMetadata metadata,
+                                                                   Keyspace keyspace,
+                                                                   Token token,
+                                                                   Function<ClusterMetadata, Replica> replicaSupplier)
+    {
+        ReplicaPlan.ForWrite plan = ReplicaPlans.forSingleReplicaWrite(metadata, keyspace, token, replicaSupplier);
+        ResponseTracker tracker = createTrackerForWrite(plan.consistencyLevel(), plan, plan.pending, metadata);
+
+        return new CoordinationPlan.ForWriteWithIdeal(metadata, plan, tracker, null);
+    }
+
+    /**
+     * Create coordination plan for replaying a mutation from the batchlog.
+     *
+     * When recovering failed batches, mutations are replayed to remote replicas only
+     * (local replica is handled separately). This method creates a coordination plan
+     * targeting live remote replicas with CL.ONE.
+     */
+    public CoordinationPlan.ForWriteWithIdeal planForReplayMutation(ClusterMetadata metadata,
+                                                                    Keyspace keyspace,
+                                                                    Token token)
+    {
+        Preconditions.checkState(!replicationType.isTracked(), "Batch replay not supported with tracked keyspaces");
+
+        ReplicaPlan.ForWrite plan = ReplicaPlans.forReplayMutation(metadata, keyspace, token);
+        ResponseTracker tracker = createTrackerForWrite(plan.consistencyLevel(), plan, plan.pending, metadata);
+
+        return new CoordinationPlan.ForWriteWithIdeal(metadata, plan, tracker, null);
+    }
+
+    /**
+     * Create coordination plan for a single-partition token read.
+     */
+    public CoordinationPlan.ForTokenRead planForTokenRead(ClusterMetadata metadata,
+                                                          Keyspace keyspace,
+                                                          TableId tableId,
+                                                          Token token,
+                                                          @Nullable Index.QueryPlan indexQueryPlan,
+                                                          ConsistencyLevel consistencyLevel,
+                                                          SpeculativeRetryPolicy retry,
+                                                          ReadCoordinator coordinator)
+    {
+        ReplicaPlan.ForTokenRead plan = ReplicaPlans.forRead(metadata, keyspace, tableId, token, indexQueryPlan, consistencyLevel, retry, coordinator);
+        ReplicaPlan.SharedForTokenRead shared = ReplicaPlan.shared(plan);
+        ResponseTracker tracker = createTrackerForRead(plan);
+        return new CoordinationPlan.ForTokenRead(shared, tracker);
+    }
+
+    /**
+     * Create coordination plan for a range read.
+     */
+    public CoordinationPlan.ForRangeRead planForRangeRead(ClusterMetadata metadata,
+                                                          Keyspace keyspace,
+                                                          TableId tableId,
+                                                          @Nullable Index.QueryPlan indexQueryPlan,
+                                                          ConsistencyLevel consistencyLevel,
+                                                          AbstractBounds<PartitionPosition> range,
+                                                          int vnodeCount)
+    {
+        ReplicaPlan.ForRangeRead plan = ReplicaPlans.forRangeRead(metadata, keyspace, tableId, indexQueryPlan, consistencyLevel, range, vnodeCount, true);
+        ReplicaPlan.SharedForRangeRead shared = ReplicaPlan.shared(plan);
+        ResponseTracker tracker = createTrackerForRead(plan);
+        return new CoordinationPlan.ForRangeRead(shared, tracker);
+    }
+
+    /**
+     * Attempt to merge two adjacent range read coordination plans into one.
+     *
+     * If the two plans share enough live endpoints to satisfy the consistency level
+     * and the merge is worthwhile returns a merged plan otherwise returns null.
+     */
+    public CoordinationPlan.ForRangeRead maybeMergeRangeReads(ClusterMetadata metadata,
+                                                               Keyspace keyspace,
+                                                               TableId tableId,
+                                                               ConsistencyLevel consistencyLevel,
+                                                               ReplicaPlan.ForRangeRead left,
+                                                               ReplicaPlan.ForRangeRead right)
+    {
+        ReplicaPlan.ForRangeRead merged = ReplicaPlans.maybeMerge(metadata, keyspace, tableId, consistencyLevel, left, right);
+        if (merged == null)
+            return null;
+
+        ReplicaPlan.SharedForRangeRead shared = ReplicaPlan.shared(merged);
+        ResponseTracker tracker = createTrackerForRead(merged);
+        return new CoordinationPlan.ForRangeRead(shared, tracker);
+    }
+
+    /**
+     * Create coordination plan for a full range read
+     */
+    public CoordinationPlan.ForRangeRead planForFullRangeRead(Keyspace keyspace,
+                                                              ConsistencyLevel consistencyLevel,
+                                                              AbstractBounds<PartitionPosition> range,
+                                                              Set<InetAddressAndPort> endpointsToContact,
+                                                              int vnodeCount)
+    {
+        ReplicaPlan.ForRangeRead plan = ReplicaPlans.forFullRangeRead(keyspace, consistencyLevel, range, endpointsToContact, vnodeCount);
+        ReplicaPlan.SharedForRangeRead shared = ReplicaPlan.shared(plan);
+        ResponseTracker tracker = createTrackerForRead(plan);
+        return new CoordinationPlan.ForRangeRead(shared, tracker);
+    }
+
+    /**
+     * Create coordination plan for a single-replica token read.
+     */
+    public CoordinationPlan.ForTokenRead planForSingleReplicaTokenRead(Keyspace keyspace, Token token, Replica replica)
+    {
+        ReplicaPlan.ForTokenRead plan = ReplicaPlans.forSingleReplicaRead(keyspace, token, replica);
+        ReplicaPlan.SharedForTokenRead shared = ReplicaPlan.shared(plan);
+        ResponseTracker tracker = createTrackerForRead(plan);
+        return new CoordinationPlan.ForTokenRead(shared, tracker);
+    }
+
+    /**
+     * Create coordination plan for a single-replica range read.
+     *
+     * Used by short read protection to fetch additional partitions from a
+     * specific replica. blockFor=1, totalReplicas=1.
+     */
+    public CoordinationPlan.ForRangeRead planForSingleReplicaRangeRead(Keyspace keyspace,
+                                                                       AbstractBounds<PartitionPosition> range,
+                                                                       Replica replica,
+                                                                       int vnodeCount)
+    {
+        ReplicaPlan.ForRangeRead plan = ReplicaPlans.forSingleReplicaRead(keyspace, range, replica, vnodeCount);
+        ReplicaPlan.SharedForRangeRead shared = ReplicaPlan.shared(plan);
+        ResponseTracker tracker = createTrackerForRead(plan);
+        return new CoordinationPlan.ForRangeRead(shared, tracker);
+    }
+
+    /**
+     * Create ResponseTracker for read operation.
+     */
+    public <E extends Endpoints<E>, P extends ReplicaPlan.ForRead<E, P>> ResponseTracker createTrackerForRead(P plan)

Review Comment:
   Could be private? Any additional visibility reduction possible in this class inside the locator package?



##########
src/java/org/apache/cassandra/locator/SatelliteReplicationStrategy.java:
##########
@@ -513,4 +631,1268 @@ public boolean hasSameSettings(AbstractReplicationStrategy other)
                primaryDC.equals(otherStrategy.primaryDC) &&
                disabledDCs.equals(otherStrategy.disabledDCs);
     }
+
+    private AbstractReplicationStrategy strategy(ClusterMetadata metadata)
+    {
+        return metadata.schema.getKeyspaceMetadata(keyspaceName).replicationStrategy;
+    }
+
+    static abstract class CoordinationPlanner<E extends Endpoints<E>, L extends ReplicaLayout<E>, P extends ReplicaPlan<E, P>>
+    {
+        final ClusterMetadata metadata;
+        final Keyspace keyspace;
+        final ConsistencyLevel cl;
+        final SatelliteReplicationStrategy strategy;
+        final String primary;
+        final String satellite;
+        final List<String> dcs;
+        final L fullLayout;
+        final L liveLayout;
+        final Map<String, L> fullLayouts;
+        final Map<String, L> liveLayouts;
+        final Map<String, E> fullEndpoints;
+        final Map<String, E> liveEndpoints;
+
+        public CoordinationPlanner(ClusterMetadata metadata, Keyspace keyspace, ConsistencyLevel cl, SatelliteReplicationStrategy strategy, String primary, L fullLayout)
+        {
+            this.metadata = metadata;
+            this.keyspace = keyspace;
+            this.cl = cl;
+            this.strategy = strategy;
+            this.primary = primary;
+            this.satellite = strategy.getSatelliteForDC(primary);
+
+            this.dcs = createDcList();
+            Preconditions.checkState(!dcs.isEmpty(), "No DCs available for request (primary=%s, all disabled?)", primary);
+
+            Set<String> dcSet = new HashSet<>(dcs);
+            this.fullLayout = filterLayout(fullLayout, rp -> dcSet.contains(metadata.locator.location(rp.endpoint()).datacenter));
+            this.liveLayout = filterLayout(this.fullLayout, FailureDetector.isReplicaAlive);
+
+            this.fullLayouts = Maps.newHashMapWithExpectedSize(dcs.size());
+            this.liveLayouts = Maps.newHashMapWithExpectedSize(dcs.size());
+
+            this.fullEndpoints = Maps.newHashMapWithExpectedSize(dcs.size());
+            this.liveEndpoints = Maps.newHashMapWithExpectedSize(dcs.size());
+
+            populateDcLayouts();
+        }
+
+        AbstractReplicationStrategy strategy(ClusterMetadata metadata)
+        {
+            return metadata.schema.getKeyspaceMetadata(keyspace.getName()).replicationStrategy;
+        }
+
+        List<String> createDcList()
+        {
+            List<String> results = new ArrayList<>();
+            if (!strategy.disabledDCs.contains(primary))
+                results.add(primary);
+
+            if (satellite != null && !strategy.disabledDCs.contains(satellite))
+                results.add(satellite);
+
+            for (String dc : strategy.fullDCs.keySet())
+            {
+                if (dc.equals(primary) || dc.equals(satellite) || strategy.disabledDCs.contains(dc))
+                    continue;
+                results.add(dc);
+            }
+
+            Preconditions.checkState(!results.isEmpty());
+            return results;
+        }
+
+        void populateDcLayouts()
+        {
+            for (String dc : dcs)
+            {
+                Predicate<Replica> dcFilter = rp -> metadata.locator.location(rp.endpoint()).datacenter.equals(dc);
+                L full = filterLayout(fullLayout, dcFilter);
+                L live = filterLayout(liveLayout, dcFilter);
+
+                fullLayouts.put(dc, full);
+                liveLayouts.put(dc, live);
+
+                fullEndpoints.put(dc, full.natural());
+                liveEndpoints.put(dc, live.natural());
+            }
+
+            if (ADDL_CHECKS_ENABLED)
+                Preconditions.checkState(fullLayouts.size() == dcs.size(), "populateDcLayouts: fullLayouts.size()=%s != dcs.size()=%s", fullLayouts.size(), dcs.size());
+        }
+
+        ResponseTrackerBuilder<E, L, P> createResponseTrackerBuilder()
+        {
+            switch (cl)
+            {
+                case ONE:
+                case LOCAL_ONE:
+                case NODE_LOCAL:
+                case ANY:
+                case TWO:
+                case THREE:
+                case ALL:
+                    return new ResponseTrackerBuilder.Simple<>(this);
+
+                case QUORUM:
+                case LOCAL_QUORUM:
+                    // LOCAL_QUORUM an QUORUM mean the same thing in SRS
+                    return new ResponseTrackerBuilder.Quorum<>(this);
+
+                case EACH_QUORUM:
+                    return new ResponseTrackerBuilder.EachQuorum<>(this);
+
+                default:
+                    throw new IllegalStateException("Unsupported consistency level: " + cl);
+            }
+        }
+
+        ResponseTracker createResponseTracker()
+        {
+            ResponseTrackerBuilder<E, L, P> builder = createResponseTrackerBuilder();
+            return builder.build();
+        }
+
+        abstract L filterLayout(L layout, Predicate<Replica> predicate);
+        abstract ResponseTracker createDcResponseTracker(String dc);
+
+        abstract P recomputePlan(ClusterMetadata metadata);
+        abstract P createReplicaPlan();
+
+        interface IndexReadExceptionCheck<E extends Endpoints<E>>
+        {
+            void maybeThrowIndexReadException(int initial, int filtered, Map<String, E> candidates, Map<InetAddressAndPort, Index.Status> indexStatusMap);
+        }
+
+        static class ForWrite extends CoordinationPlanner<EndpointsForToken, ReplicaLayout.ForTokenWrite, ReplicaPlan.ForWrite>
+        {
+            final ReplicaPlans.Selector selector;
+
+            public ForWrite(ClusterMetadata metadata, Keyspace keyspace, ConsistencyLevel cl, SatelliteReplicationStrategy strategy, String primary, ReplicaLayout.ForTokenWrite fullLayout, ReplicaPlans.Selector selector)
+            {
+                super(metadata, keyspace, cl, strategy, primary, fullLayout);
+                this.selector = selector;
+                if (ADDL_CHECKS_ENABLED)
+                {

Review Comment:
   Remove braces?



##########
src/java/org/apache/cassandra/locator/SatelliteReplicationStrategy.java:
##########
@@ -513,4 +631,1268 @@ public boolean hasSameSettings(AbstractReplicationStrategy other)
                primaryDC.equals(otherStrategy.primaryDC) &&
                disabledDCs.equals(otherStrategy.disabledDCs);
     }
+
+    private AbstractReplicationStrategy strategy(ClusterMetadata metadata)
+    {
+        return metadata.schema.getKeyspaceMetadata(keyspaceName).replicationStrategy;
+    }
+
+    static abstract class CoordinationPlanner<E extends Endpoints<E>, L extends ReplicaLayout<E>, P extends ReplicaPlan<E, P>>
+    {
+        final ClusterMetadata metadata;
+        final Keyspace keyspace;
+        final ConsistencyLevel cl;
+        final SatelliteReplicationStrategy strategy;
+        final String primary;
+        final String satellite;
+        final List<String> dcs;
+        final L fullLayout;
+        final L liveLayout;
+        final Map<String, L> fullLayouts;
+        final Map<String, L> liveLayouts;
+        final Map<String, E> fullEndpoints;
+        final Map<String, E> liveEndpoints;
+
+        public CoordinationPlanner(ClusterMetadata metadata, Keyspace keyspace, ConsistencyLevel cl, SatelliteReplicationStrategy strategy, String primary, L fullLayout)
+        {
+            this.metadata = metadata;
+            this.keyspace = keyspace;
+            this.cl = cl;
+            this.strategy = strategy;
+            this.primary = primary;
+            this.satellite = strategy.getSatelliteForDC(primary);
+
+            this.dcs = createDcList();
+            Preconditions.checkState(!dcs.isEmpty(), "No DCs available for request (primary=%s, all disabled?)", primary);
+
+            Set<String> dcSet = new HashSet<>(dcs);
+            this.fullLayout = filterLayout(fullLayout, rp -> dcSet.contains(metadata.locator.location(rp.endpoint()).datacenter));
+            this.liveLayout = filterLayout(this.fullLayout, FailureDetector.isReplicaAlive);
+
+            this.fullLayouts = Maps.newHashMapWithExpectedSize(dcs.size());
+            this.liveLayouts = Maps.newHashMapWithExpectedSize(dcs.size());
+
+            this.fullEndpoints = Maps.newHashMapWithExpectedSize(dcs.size());
+            this.liveEndpoints = Maps.newHashMapWithExpectedSize(dcs.size());
+
+            populateDcLayouts();
+        }
+
+        AbstractReplicationStrategy strategy(ClusterMetadata metadata)
+        {
+            return metadata.schema.getKeyspaceMetadata(keyspace.getName()).replicationStrategy;
+        }
+
+        List<String> createDcList()
+        {
+            List<String> results = new ArrayList<>();
+            if (!strategy.disabledDCs.contains(primary))

Review Comment:
   What exactly is supposed to happen here when this returns with a DC list with no primary?



##########
src/java/org/apache/cassandra/locator/SatelliteReplicationStrategy.java:
##########
@@ -513,4 +631,1268 @@ public boolean hasSameSettings(AbstractReplicationStrategy other)
                primaryDC.equals(otherStrategy.primaryDC) &&
                disabledDCs.equals(otherStrategy.disabledDCs);
     }
+
+    private AbstractReplicationStrategy strategy(ClusterMetadata metadata)
+    {
+        return metadata.schema.getKeyspaceMetadata(keyspaceName).replicationStrategy;
+    }
+
+    static abstract class CoordinationPlanner<E extends Endpoints<E>, L extends ReplicaLayout<E>, P extends ReplicaPlan<E, P>>
+    {
+        final ClusterMetadata metadata;
+        final Keyspace keyspace;
+        final ConsistencyLevel cl;
+        final SatelliteReplicationStrategy strategy;
+        final String primary;
+        final String satellite;
+        final List<String> dcs;
+        final L fullLayout;
+        final L liveLayout;
+        final Map<String, L> fullLayouts;
+        final Map<String, L> liveLayouts;
+        final Map<String, E> fullEndpoints;
+        final Map<String, E> liveEndpoints;
+
+        public CoordinationPlanner(ClusterMetadata metadata, Keyspace keyspace, ConsistencyLevel cl, SatelliteReplicationStrategy strategy, String primary, L fullLayout)
+        {
+            this.metadata = metadata;
+            this.keyspace = keyspace;
+            this.cl = cl;
+            this.strategy = strategy;
+            this.primary = primary;
+            this.satellite = strategy.getSatelliteForDC(primary);
+
+            this.dcs = createDcList();
+            Preconditions.checkState(!dcs.isEmpty(), "No DCs available for request (primary=%s, all disabled?)", primary);
+
+            Set<String> dcSet = new HashSet<>(dcs);
+            this.fullLayout = filterLayout(fullLayout, rp -> dcSet.contains(metadata.locator.location(rp.endpoint()).datacenter));
+            this.liveLayout = filterLayout(this.fullLayout, FailureDetector.isReplicaAlive);
+
+            this.fullLayouts = Maps.newHashMapWithExpectedSize(dcs.size());
+            this.liveLayouts = Maps.newHashMapWithExpectedSize(dcs.size());
+
+            this.fullEndpoints = Maps.newHashMapWithExpectedSize(dcs.size());
+            this.liveEndpoints = Maps.newHashMapWithExpectedSize(dcs.size());
+
+            populateDcLayouts();
+        }
+
+        AbstractReplicationStrategy strategy(ClusterMetadata metadata)
+        {
+            return metadata.schema.getKeyspaceMetadata(keyspace.getName()).replicationStrategy;
+        }
+
+        List<String> createDcList()
+        {
+            List<String> results = new ArrayList<>();
+            if (!strategy.disabledDCs.contains(primary))
+                results.add(primary);
+
+            if (satellite != null && !strategy.disabledDCs.contains(satellite))
+                results.add(satellite);
+
+            for (String dc : strategy.fullDCs.keySet())
+            {
+                if (dc.equals(primary) || dc.equals(satellite) || strategy.disabledDCs.contains(dc))
+                    continue;
+                results.add(dc);
+            }
+
+            Preconditions.checkState(!results.isEmpty());
+            return results;
+        }
+
+        void populateDcLayouts()
+        {
+            for (String dc : dcs)
+            {
+                Predicate<Replica> dcFilter = rp -> metadata.locator.location(rp.endpoint()).datacenter.equals(dc);
+                L full = filterLayout(fullLayout, dcFilter);
+                L live = filterLayout(liveLayout, dcFilter);
+
+                fullLayouts.put(dc, full);
+                liveLayouts.put(dc, live);
+
+                fullEndpoints.put(dc, full.natural());
+                liveEndpoints.put(dc, live.natural());
+            }
+
+            if (ADDL_CHECKS_ENABLED)
+                Preconditions.checkState(fullLayouts.size() == dcs.size(), "populateDcLayouts: fullLayouts.size()=%s != dcs.size()=%s", fullLayouts.size(), dcs.size());
+        }
+
+        ResponseTrackerBuilder<E, L, P> createResponseTrackerBuilder()
+        {
+            switch (cl)
+            {
+                case ONE:
+                case LOCAL_ONE:
+                case NODE_LOCAL:
+                case ANY:
+                case TWO:
+                case THREE:
+                case ALL:
+                    return new ResponseTrackerBuilder.Simple<>(this);
+
+                case QUORUM:
+                case LOCAL_QUORUM:
+                    // LOCAL_QUORUM an QUORUM mean the same thing in SRS
+                    return new ResponseTrackerBuilder.Quorum<>(this);
+
+                case EACH_QUORUM:
+                    return new ResponseTrackerBuilder.EachQuorum<>(this);
+
+                default:
+                    throw new IllegalStateException("Unsupported consistency level: " + cl);
+            }
+        }
+
+        ResponseTracker createResponseTracker()
+        {
+            ResponseTrackerBuilder<E, L, P> builder = createResponseTrackerBuilder();
+            return builder.build();
+        }
+
+        abstract L filterLayout(L layout, Predicate<Replica> predicate);
+        abstract ResponseTracker createDcResponseTracker(String dc);
+
+        abstract P recomputePlan(ClusterMetadata metadata);
+        abstract P createReplicaPlan();

Review Comment:
   Are the two above unused?



##########
src/java/org/apache/cassandra/locator/SatelliteReplicationStrategy.java:
##########
@@ -513,4 +631,1268 @@ public boolean hasSameSettings(AbstractReplicationStrategy other)
                primaryDC.equals(otherStrategy.primaryDC) &&
                disabledDCs.equals(otherStrategy.disabledDCs);
     }
+
+    private AbstractReplicationStrategy strategy(ClusterMetadata metadata)
+    {
+        return metadata.schema.getKeyspaceMetadata(keyspaceName).replicationStrategy;
+    }
+
+    static abstract class CoordinationPlanner<E extends Endpoints<E>, L extends ReplicaLayout<E>, P extends ReplicaPlan<E, P>>
+    {
+        final ClusterMetadata metadata;
+        final Keyspace keyspace;
+        final ConsistencyLevel cl;
+        final SatelliteReplicationStrategy strategy;
+        final String primary;
+        final String satellite;
+        final List<String> dcs;
+        final L fullLayout;
+        final L liveLayout;
+        final Map<String, L> fullLayouts;
+        final Map<String, L> liveLayouts;
+        final Map<String, E> fullEndpoints;
+        final Map<String, E> liveEndpoints;
+
+        public CoordinationPlanner(ClusterMetadata metadata, Keyspace keyspace, ConsistencyLevel cl, SatelliteReplicationStrategy strategy, String primary, L fullLayout)
+        {
+            this.metadata = metadata;
+            this.keyspace = keyspace;
+            this.cl = cl;
+            this.strategy = strategy;
+            this.primary = primary;
+            this.satellite = strategy.getSatelliteForDC(primary);
+
+            this.dcs = createDcList();
+            Preconditions.checkState(!dcs.isEmpty(), "No DCs available for request (primary=%s, all disabled?)", primary);
+
+            Set<String> dcSet = new HashSet<>(dcs);
+            this.fullLayout = filterLayout(fullLayout, rp -> dcSet.contains(metadata.locator.location(rp.endpoint()).datacenter));
+            this.liveLayout = filterLayout(this.fullLayout, FailureDetector.isReplicaAlive);
+
+            this.fullLayouts = Maps.newHashMapWithExpectedSize(dcs.size());
+            this.liveLayouts = Maps.newHashMapWithExpectedSize(dcs.size());
+
+            this.fullEndpoints = Maps.newHashMapWithExpectedSize(dcs.size());
+            this.liveEndpoints = Maps.newHashMapWithExpectedSize(dcs.size());
+
+            populateDcLayouts();
+        }
+
+        AbstractReplicationStrategy strategy(ClusterMetadata metadata)
+        {
+            return metadata.schema.getKeyspaceMetadata(keyspace.getName()).replicationStrategy;
+        }
+
+        List<String> createDcList()
+        {
+            List<String> results = new ArrayList<>();
+            if (!strategy.disabledDCs.contains(primary))
+                results.add(primary);
+
+            if (satellite != null && !strategy.disabledDCs.contains(satellite))
+                results.add(satellite);
+
+            for (String dc : strategy.fullDCs.keySet())
+            {
+                if (dc.equals(primary) || dc.equals(satellite) || strategy.disabledDCs.contains(dc))

Review Comment:
   Why check for satellite of satellite is not a full DC?



##########
src/java/org/apache/cassandra/locator/SatelliteReplicationStrategy.java:
##########
@@ -513,4 +631,1286 @@ public boolean hasSameSettings(AbstractReplicationStrategy other)
                primaryDC.equals(otherStrategy.primaryDC) &&
                disabledDCs.equals(otherStrategy.disabledDCs);
     }
+
+    private AbstractReplicationStrategy strategy(ClusterMetadata metadata)
+    {
+        return metadata.schema.getKeyspaceMetadata(keyspaceName).replicationStrategy;
+    }
+
+    static abstract class CoordinationPlanner<E extends Endpoints<E>, L extends ReplicaLayout<E>, P extends ReplicaPlan<E, P>>
+    {
+        final ClusterMetadata metadata;
+        final Keyspace keyspace;
+        final ConsistencyLevel cl;
+        final SatelliteReplicationStrategy strategy;
+        final String primary;
+        final String satellite;
+        final List<String> dcs;
+        final L fullLayout;
+        final L liveLayout;
+        final Map<String, L> fullLayouts;
+        final Map<String, L> liveLayouts;
+        final Map<String, E> fullEndpoints;
+        final Map<String, E> liveEndpoints;
+
+        public CoordinationPlanner(ClusterMetadata metadata, Keyspace keyspace, ConsistencyLevel cl, SatelliteReplicationStrategy strategy, String primary, L fullLayout)
+        {
+            this.metadata = metadata;
+            this.keyspace = keyspace;
+            this.cl = cl;
+            this.strategy = strategy;
+            this.primary = primary;
+            this.satellite = strategy.getSatelliteForDC(primary);
+
+            this.dcs = createDcList();
+            Preconditions.checkState(!dcs.isEmpty(), "No DCs available for request (primary=%s, all disabled?)", primary);
+
+            Set<String> dcSet = new HashSet<>(dcs);
+            this.fullLayout = filterLayout(fullLayout, rp -> dcSet.contains(metadata.locator.location(rp.endpoint()).datacenter));
+            this.liveLayout = filterLayout(this.fullLayout, FailureDetector.isReplicaAlive);
+
+            this.fullLayouts = Maps.newHashMapWithExpectedSize(dcs.size());
+            this.liveLayouts = Maps.newHashMapWithExpectedSize(dcs.size());
+
+            this.fullEndpoints = Maps.newHashMapWithExpectedSize(dcs.size());
+            this.liveEndpoints = Maps.newHashMapWithExpectedSize(dcs.size());
+
+            populateDcLayouts();
+        }
+
+        AbstractReplicationStrategy strategy(ClusterMetadata metadata)
+        {
+            return metadata.schema.getKeyspaceMetadata(keyspace.getName()).replicationStrategy;
+        }
+
+        List<String> createDcList()
+        {
+            List<String> results = new ArrayList<>();
+            if (!strategy.disabledDCs.contains(primary))
+                results.add(primary);
+
+            if (satellite != null && !strategy.disabledDCs.contains(satellite))
+                results.add(satellite);
+
+            for (String dc : strategy.fullDCs.keySet())
+            {
+                if (dc.equals(primary) || dc.equals(satellite) || strategy.disabledDCs.contains(dc))
+                    continue;
+                results.add(dc);
+            }
+
+            Preconditions.checkState(!results.isEmpty());
+            return results;
+        }
+
+        void populateDcLayouts()
+        {
+            for (String dc : dcs)
+            {
+                Predicate<Replica> dcFilter = rp -> metadata.locator.location(rp.endpoint()).datacenter.equals(dc);
+                L full = filterLayout(fullLayout, dcFilter);
+                L live = filterLayout(liveLayout, dcFilter);
+
+                fullLayouts.put(dc, full);
+                liveLayouts.put(dc, live);
+
+                fullEndpoints.put(dc, full.natural());
+                liveEndpoints.put(dc, live.natural());
+            }
+
+            if (ADDL_CHECKS_ENABLED)
+                Preconditions.checkState(fullLayouts.size() == dcs.size(), "populateDcLayouts: fullLayouts.size()=%s != dcs.size()=%s", fullLayouts.size(), dcs.size());
+        }
+
+        ResponseTrackerBuilder<E, L, P> createResponseTrackerBuilder()
+        {
+            switch (cl)
+            {
+                case ONE:
+                case LOCAL_ONE:
+                case NODE_LOCAL:
+                case ANY:
+                case TWO:
+                case THREE:
+                case ALL:
+                    return new ResponseTrackerBuilder.Simple<>(this);
+
+                case QUORUM:
+                case LOCAL_QUORUM:
+                    // LOCAL_QUORUM an QUORUM mean the same thing in SRS
+                    return new ResponseTrackerBuilder.Quorum<>(this);
+
+                case EACH_QUORUM:
+                    return new ResponseTrackerBuilder.EachQuorum<>(this);
+
+                default:
+                    throw new IllegalStateException("Unsupported consistency level: " + cl);
+            }
+        }
+
+        ResponseTracker createResponseTracker()
+        {
+            ResponseTrackerBuilder<E, L, P> builder = createResponseTrackerBuilder();
+            return builder.build();
+        }
+
+        abstract L filterLayout(L layout, Predicate<Replica> predicate);
+        abstract ResponseTracker createDcResponseTracker(String dc);
+
+        abstract P recomputePlan(ClusterMetadata metadata);
+        abstract P createReplicaPlan();
+
+        interface IndexReadExceptionCheck<E extends Endpoints<E>>
+        {
+            void maybeThrowIndexReadException(int initial, int filtered, Map<String, E> candidates, Map<InetAddressAndPort, Index.Status> indexStatusMap);
+        }
+
+        static class ForWrite extends CoordinationPlanner<EndpointsForToken, ReplicaLayout.ForTokenWrite, ReplicaPlan.ForWrite>
+        {
+            final ReplicaPlans.Selector selector;
+
+            public ForWrite(ClusterMetadata metadata, Keyspace keyspace, ConsistencyLevel cl, SatelliteReplicationStrategy strategy, String primary, ReplicaLayout.ForTokenWrite fullLayout, ReplicaPlans.Selector selector)
+            {
+                super(metadata, keyspace, cl, strategy, primary, fullLayout);
+                this.selector = selector;
+                if (ADDL_CHECKS_ENABLED)
+                {
+                    Preconditions.checkState(!strategy.disabledDCs.contains(primary));
+                }
+            }
+
+            @Override
+            ResponseTracker createDcResponseTracker(String dc)
+            {
+                // this basically hardcodes ReplicaPlans.writeAll
+                Predicate<InetAddressAndPort> dcFilter = ep -> metadata.locator.location(ep).datacenter.equals(dc);
+
+                ReplicaLayout.ForTokenWrite dcLayout = fullLayouts.get(dc);
+                Preconditions.checkNotNull(dcLayout);
+
+                int blockFor = strategy.calculateQuorum(dc);
+                int totalContacts = dcLayout.all().size();
+
+                // Only count pending replicas that are actually in the selected contacts
+                int pendingInDc = dcLayout.pending().size();
+
+                if (pendingInDc == 0)
+                {
+                    return new SimpleResponseTracker(blockFor, totalContacts, dcFilter);
+                }
+                else
+                {
+                    int committedContacts = totalContacts - pendingInDc;
+                    int totalBlockFor = blockFor + pendingInDc;
+                    Predicate<InetAddressAndPort> isPending = ep -> dcLayout.pending.endpoints().contains(ep);
+                    return new WriteResponseTracker(blockFor, totalBlockFor,
+                                                    committedContacts, pendingInDc,
+                                                    isPending, dcFilter);
+                }
+            }
+
+            @Override
+            ReplicaPlan.ForWrite recomputePlan(ClusterMetadata metadata)
+            {
+                Token token = fullLayout.token();
+                return strategy(metadata).planForWrite(metadata, keyspace, cl,
+                                                              cm -> ReplicaLayout.forTokenWriteLiveAndDown(cm, keyspace, token),
+                                                       selector).replicas();
+            }
+
+            @Override
+            ReplicaLayout.ForTokenWrite filterLayout(ReplicaLayout.ForTokenWrite layout, Predicate<Replica> predicate)
+            {
+                return layout.filter(predicate);
+            }
+
+            private ReplicaLayout.ForTokenWrite mergeLayouts(Collection<ReplicaLayout.ForTokenWrite> layouts)
+            {
+                ReplicaCollection.Builder<EndpointsForToken> naturalBuilder = null;
+                ReplicaCollection.Builder<EndpointsForToken> pendingBuilder = null;
+
+                for (ReplicaLayout.ForTokenWrite layout : layouts)
+                {
+                    EndpointsForToken natural = layout.natural();
+                    EndpointsForToken pending = layout.pending();
+                    if (naturalBuilder == null)
+                    {
+                        naturalBuilder = natural.newBuilder(natural.size());
+                        pendingBuilder = pending.newBuilder(pending.size());
+                    }
+
+                    naturalBuilder.addAll(natural);
+                    pendingBuilder.addAll(pending);
+                }
+
+                return new ReplicaLayout.ForTokenWrite(strategy, naturalBuilder.build(), pendingBuilder.build());
+            }
+
+            @Override
+            ReplicaPlan.ForWrite createReplicaPlan()
+            {
+
+                ReplicaLayout.ForTokenWrite fullLayout = mergeLayouts(fullLayouts.values());
+                ReplicaLayout.ForTokenWrite liveLayout = mergeLayouts(liveLayouts.values());
+                EndpointsForToken contacts = selector.select(cl, fullLayout, liveLayout);
+
+                return new ReplicaPlan.ForWrite(keyspace,
+                                                strategy,
+                                                cl,
+                                                fullLayout.pending(),
+                                                fullLayout.all(),
+                                                liveLayout.all(),
+                                                contacts,
+                                                this::recomputePlan,
+                                                metadata.epoch);
+            }
+        }
+
+        static abstract class ForRead<E extends Endpoints<E>, L extends ReplicaLayout<E>, P extends ReplicaPlan.ForRead<E, P>> extends CoordinationPlanner<E, L, P>
+        {
+            final @Nullable Index.QueryPlan indexQueryPlan;
+            final boolean alwaysSpeculate;
+            final boolean throwOnInsufficientLiveReplicas;
+
+            public ForRead(ClusterMetadata metadata, Keyspace keyspace, ConsistencyLevel cl, SatelliteReplicationStrategy strategy, String primary, L fullLayout, @Nullable Index.QueryPlan indexQueryPlan, boolean alwaysSpeculate, boolean throwOnInsufficientLiveReplicas)
+            {
+                super(metadata, keyspace, cl, strategy, primary, fullLayout);
+                this.indexQueryPlan = indexQueryPlan;
+                this.alwaysSpeculate = alwaysSpeculate;
+                this.throwOnInsufficientLiveReplicas = throwOnInsufficientLiveReplicas;
+            }
+
+            @Override
+            ResponseTracker createDcResponseTracker(String dc)
+            {
+                Predicate<InetAddressAndPort> dcFilter = ep -> metadata.locator.location(ep).datacenter.equals(dc);
+
+                L dcLayout = fullLayouts.get(dc);
+                int blockFor = strategy.calculateQuorum(dc);
+                int totalContacts = dcLayout.all().size();
+
+                return new SimpleResponseTracker(blockFor, totalContacts, dcFilter);
+            }
+
+            Map<String, E> candidates(IndexReadExceptionCheck<E> check)
+            {
+                if (indexQueryPlan == null)
+                    return liveEndpoints;
+
+                Map<String, E> candidates = new HashMap<>();
+                Map<InetAddressAndPort, Index.Status> indexStatusMap = new HashMap<>();
+                int initial = 0;
+                int filtered = 0;
+                for (Map.Entry<String, E> entry : liveEndpoints.entrySet())
+                {
+                    E liveEndpoints = entry.getValue();
+                    E queryableEndpoints = IndexStatusManager.instance.filterForQuery(liveEndpoints, keyspace.getName(), indexQueryPlan, indexStatusMap);
+                    initial += liveEndpoints.size();
+                    filtered += queryableEndpoints.size();
+                    candidates.put(entry.getKey(), queryableEndpoints);
+                }
+
+                if (initial != filtered)
+                    check.maybeThrowIndexReadException(initial, filtered, candidates, indexStatusMap);
+
+                return candidates;
+            }
+
+            @Override
+            ResponseTracker createResponseTracker()
+            {
+                ResponseTracker tracker = super.createResponseTracker();
+
+                // read trackers don't wait on pending nodes
+                Preconditions.checkState(tracker.pendingContacts() == 0);
+                return tracker;
+            }
+
+
+            abstract P replicaPlanBuilder(E candidates, E contacts, E liveAndDown, int quorumSize);
+
+            ReadReplicaPlanBuilder<E, L, P> replicaPlanBuilder()
+            {
+                switch (cl)
+                {
+                    case ONE:
+                    case LOCAL_ONE:
+                    case NODE_LOCAL:
+                    case ANY:
+                    case TWO:
+                    case THREE:
+                    case ALL:
+                        return new ReadReplicaPlanBuilder.Simple<>(this);
+
+                    case QUORUM:
+                    case LOCAL_QUORUM:
+                    case EACH_QUORUM:
+                        return new ReadReplicaPlanBuilder.DcQuorum<>(this);
+
+                    default:
+                        throw new IllegalStateException("Unsupported consistency level: " + cl);
+                }
+            }
+
+            @Override
+            P createReplicaPlan()
+            {
+                ReadReplicaPlanBuilder<E, L, P> builder = replicaPlanBuilder();
+                return builder.build();
+            }
+        }
+
+        static class ForTokenRead extends ForRead<EndpointsForToken, ReplicaLayout.ForTokenRead, ReplicaPlan.ForTokenRead>
+        {
+            static final Function<ReplicaPlan<?, ?>, ReplicaPlan.ForWrite> READ_REPAIR_PLAN = (ReplicaPlan<?, ?> self) -> {throw new IllegalStateException("Cannot create read repair plans for SatelliteDatacenterReplicationStrategy");};
+            final Token token;
+            final TableId tableId;
+            final SpeculativeRetryPolicy retry;
+            final ReadCoordinator coordinator;
+
+            public ForTokenRead(ClusterMetadata metadata, Keyspace keyspace, Token token, ConsistencyLevel cl, SatelliteReplicationStrategy strategy, String primary, ReplicaLayout.ForTokenRead fullLayout, @Nullable Index.QueryPlan indexQueryPlan, SpeculativeRetryPolicy retry, boolean throwOnInsufficientLiveReplicas, TableId tableId, ReadCoordinator coordinator)
+            {
+                super(metadata, keyspace, cl, strategy, primary, fullLayout, indexQueryPlan, retry == AlwaysSpeculativeRetryPolicy.INSTANCE, throwOnInsufficientLiveReplicas);
+                this.token = token;
+                this.tableId = tableId;
+                this.retry = retry;
+                this.coordinator = coordinator;
+            }
+
+            @Override
+            ReplicaLayout.ForTokenRead filterLayout(ReplicaLayout.ForTokenRead layout, Predicate<Replica> predicate)
+            {
+                return layout.filter(predicate);
+            }
+
+            @Override
+            ReplicaPlan.ForTokenRead recomputePlan(ClusterMetadata metadata)
+            {
+                return strategy(metadata).planForTokenRead(metadata, keyspace, tableId, token,
+                                                           indexQueryPlan, cl, retry, coordinator).replicas();
+            }
+
+            @Override
+            ReplicaPlan.ForTokenRead replicaPlanBuilder(EndpointsForToken candidates, EndpointsForToken contacts, EndpointsForToken liveAndDown, int quorumSize)
+            {
+                return new ReplicaPlan.ForTokenRead(keyspace, strategy, cl,
+                                                    candidates, contacts, liveAndDown,
+                                                    this::recomputePlan,
+                                                    READ_REPAIR_PLAN,
+                                                    metadata.epoch,
+                                                    quorumSize);
+            }
+        }
+
+        static class ForRangeRead extends ForRead<EndpointsForRange, ReplicaLayout.ForRangeRead, ReplicaPlan.ForRangeRead>
+        {
+            static final BiFunction<ReplicaPlan<?, ?>, Token, ReplicaPlan.ForWrite> READ_REPAIR_PLAN = (ReplicaPlan<?, ?> self, Token token) -> {throw new IllegalStateException("Cannot create read repair plans for SatelliteDatacenterReplicationStrategy");};
+            final AbstractBounds<PartitionPosition> range;
+            final int vnodeCount;
+            final TableId tableId;
+
+            public ForRangeRead(ClusterMetadata metadata, Keyspace keyspace, AbstractBounds<PartitionPosition> range, int vnodeCount, ConsistencyLevel cl, SatelliteReplicationStrategy strategy, String primary, ReplicaLayout.ForRangeRead fullLayout, @Nullable Index.QueryPlan indexQueryPlan, boolean throwOnInsufficientLiveReplicas, TableId tableId)
+            {
+                super(metadata, keyspace, cl, strategy, primary, fullLayout, indexQueryPlan, false, throwOnInsufficientLiveReplicas);
+                this.range = range;
+                this.vnodeCount = vnodeCount;
+                this.tableId = tableId;
+            }
+
+            @Override
+            ReplicaLayout.ForRangeRead filterLayout(ReplicaLayout.ForRangeRead layout, Predicate<Replica> predicate)
+            {
+                return layout.filter(predicate);
+            }
+
+            @Override
+            ReplicaPlan.ForRangeRead recomputePlan(ClusterMetadata metadata)
+            {
+                return strategy(metadata).planForRangeRead(metadata, keyspace, tableId,
+                                                           indexQueryPlan, cl, range, vnodeCount).replicas();
+            }
+
+            @Override
+            ReplicaPlan.ForRangeRead replicaPlanBuilder(EndpointsForRange candidates, EndpointsForRange contacts, EndpointsForRange liveAndDown, int quorumSize)
+            {
+                return new ReplicaPlan.ForRangeRead(keyspace, strategy, cl, range,
+                                                    candidates, contacts, liveAndDown, vnodeCount,
+                                                    this::recomputePlan,
+                                                    READ_REPAIR_PLAN,
+                                                    metadata.epoch,
+                                                    quorumSize);
+            }
+        }
+    }
+
+    static abstract class ResponseTrackerBuilder<E extends Endpoints<E>, L extends ReplicaLayout<E>, P extends ReplicaPlan<E, P>>
+    {
+        final CoordinationPlanner<E, L, P> planner;
+
+        public ResponseTrackerBuilder(CoordinationPlanner<E, L, P> planner)
+        {
+            this.planner = planner;
+        }
+
+        Map<String, ResponseTracker> responseTrackersForPrimary()
+        {
+            Map<String, ResponseTracker> result = Maps.newHashMapWithExpectedSize(planner.dcs.size());
+
+            for (String dc : planner.dcs)
+                result.put(dc, planner.createDcResponseTracker(dc));
+
+            return result;
+        }
+
+        abstract ResponseTracker aggregateTrackers(Map<String, ResponseTracker> dcTrackers);
+
+        ResponseTracker build()
+        {
+            Map<String, ResponseTracker> dcTrackers = responseTrackersForPrimary();
+            return aggregateTrackers(dcTrackers);
+        }
+
+        static class Simple<E extends Endpoints<E>, L extends ReplicaLayout<E>, P extends ReplicaPlan<E, P>> extends ResponseTrackerBuilder<E, L, P>
+        {
+            public Simple(CoordinationPlanner<E, L, P> planner)
+            {
+                super(planner);
+            }
+
+            @Override
+            ResponseTracker aggregateTrackers(Map<String, ResponseTracker> dcTrackers)
+            {
+                int totalContacts = 0;
+                int totalPending = 0;
+
+
+                List<ResponseTracker> trackers = Lists.newArrayList();
+                Set<String> blockingDatacenters = Sets.newHashSet();
+
+                for (Map.Entry<String, ResponseTracker> entry : dcTrackers.entrySet())
+                {
+                    String dc = entry.getKey();
+                    ResponseTracker tracker = entry.getValue();
+                    if (planner.cl == ConsistencyLevel.ALL || dc.equals(planner.primary) || dc.equals(planner.satellite))
+                    {
+                        totalContacts += tracker.totalContacts();
+                        totalPending += tracker.pendingContacts();
+                        trackers.add(tracker);
+                        blockingDatacenters.add(dc);
+                    }
+                }
+
+                Predicate<InetAddressAndPort> filter = i -> {
+                    String dc = planner.metadata.locator.location(i).datacenter;
+                    return dc != null && blockingDatacenters.contains(dc);
+                };
+
+                Preconditions.checkState(!trackers.isEmpty(), "No blocking DCs for CL %s (primary=%s, satellite=%s)", planner.cl, planner.primary, planner.satellite);
+
+
+                if (planner.cl == ConsistencyLevel.ALL)
+                    return new SimpleResponseTracker(totalContacts, totalContacts, filter);
+
+                int blockFor = planner.cl.blockFor(planner.strategy);
+                int totalBlockFor = blockFor + totalPending;
+
+                Preconditions.checkState(blockFor <= totalContacts, "Simple tracker: blockFor=%s > totalContacts=%s for CL %s", blockFor, totalContacts, planner.cl);
+
+
+                if (totalPending == 0)
+                    return new SimpleResponseTracker(blockFor, totalContacts, filter);
+
+                Predicate<InetAddressAndPort> pending = i -> {
+                    for (ResponseTracker tracker : trackers)
+                    {
+                        if (tracker.isPending(i))
+                            return true;
+                    }
+                    return false;
+                };
+                return new WriteResponseTracker(blockFor, totalBlockFor, totalContacts, totalPending, pending, filter);
+            }
+        }
+
+        static class Quorum<E extends Endpoints<E>, L extends ReplicaLayout<E>, P extends ReplicaPlan<E, P>> extends ResponseTrackerBuilder<E, L, P>
+        {
+            public Quorum(CoordinationPlanner<E, L, P> planner)
+            {
+                super(planner);
+            }
+
+            @Override
+            ResponseTracker aggregateTrackers(Map<String, ResponseTracker> dcTrackers)
+            {
+                return new CompositeTracker(CompositeTracker.quorum(dcTrackers.size()), new ArrayList<>(dcTrackers.values()));
+            }
+        }
+
+        static class EachQuorum<E extends Endpoints<E>, L extends ReplicaLayout<E>, P extends ReplicaPlan<E, P>> extends ResponseTrackerBuilder<E, L, P>
+        {
+            public EachQuorum(CoordinationPlanner<E, L, P> planner)
+            {
+                super(planner);
+            }
+
+            @Override
+            ResponseTracker aggregateTrackers(Map<String, ResponseTracker> dcTrackers)
+            {
+                return new CompositeTracker(CompositeTracker.all(dcTrackers.size()), new ArrayList<>(dcTrackers.values()));
+            }
+        }
+    }
+
+    static abstract class ReadReplicaPlanBuilder<E extends Endpoints<E>, L extends ReplicaLayout<E>, P extends ReplicaPlan.ForRead<E, P>>
+            implements CoordinationPlanner.IndexReadExceptionCheck<E>
+    {
+
+        private static <E extends Endpoints<E>> Iterable<Replica> removedSupplier(Map<String, E> initial, Map<String, E> filtered)
+        {
+            return () -> initial.keySet().stream().map(dc -> {
+                Set<InetAddressAndPort> f =  filtered.containsKey(dc) ? filtered.get(dc).endpoints() : Collections.emptySet();
+                return initial.get(dc).without(f);
+            }).flatMap(AbstractReplicaCollection::stream).iterator();
+        }
+
+        final CoordinationPlanner.ForRead<E, L, P> planner;
+
+        public ReadReplicaPlanBuilder(CoordinationPlanner.ForRead<E, L, P> planner)
+        {
+            this.planner = planner;
+        }
+
+        abstract boolean canSelectReplicasFromDc(String dc, int liveNodes);
+        abstract int minBlockFor();
+        abstract int eligibleCandidates(Map<String, E> candidates);
+
+        abstract boolean haveSufficientContacts(int contactedReplicas, int contactedDcs);
+        abstract boolean haveSufficientContactsForDc(String dc, int contactedReplicas, int contactedDcs, int dcContacts);
+        abstract boolean haveSufficientLiveNodes(int contactedReplicas, int contactedDcs);
+
+        private E allLiveAndDown()
+        {
+            E fullEndpoints = planner.fullLayout.all();
+            ReplicaCollection.Builder<E> fullBuilder = fullEndpoints.newBuilder(fullEndpoints.size());
+
+            for (String dc : planner.dcs)
+            {
+                E endpoints = planner.fullEndpoints.get(dc);
+                if (endpoints == null)
+                    continue;
+
+                fullBuilder.addAll(endpoints);
+            }
+
+            return fullBuilder.build();
+        }
+
+        private E allCandidates(Map<String, E> candidates)
+        {
+            E liveEndpoints = planner.liveLayout.all();
+            ReplicaCollection.Builder<E> candidateBuilder = liveEndpoints.newBuilder(liveEndpoints.size());
+
+            for (String dc : planner.dcs)
+            {
+                // TODO: make sure the full replica is added if this is the dc it's from
+                E dcCandidates = candidates.get(dc);
+                if (dcCandidates == null || !canSelectReplicasFromDc(dc, dcCandidates.size()))
+                    continue;
+
+                candidateBuilder.addAll(dcCandidates);
+            }
+
+            return candidateBuilder.build();
+        }
+
+        private E contacts(Map<String, E> candidates)
+        {
+            E liveEndpoints = planner.liveLayout.all();
+
+            ReplicaCollection.Builder<E> contactBuilder = liveEndpoints.newBuilder(liveEndpoints.size());
+
+            // find full replica
+            Replica fullReplica = null;
+            String fullDc = null;
+            for (String dc : planner.dcs)
+            {
+                if (fullReplica != null)
+                    break;
+
+                E dcCandidates = candidates.get(dc);
+                if (dcCandidates == null || !canSelectReplicasFromDc(dc, dcCandidates.size()))
+                    continue;
+
+                for (Replica replica : dcCandidates)
+                {
+                    if (replica.isFull())
+                    {
+                        fullReplica = replica;
+                        fullDc = dc;
+                        break;
+                    }
+                }
+            }
+
+            if (fullReplica == null)
+            {
+                // No full replicas available - throw error similar to assureSufficientLiveReplicas
+                throw UnavailableException.create(ConsistencyLevel.ONE, minBlockFor(), 1, eligibleCandidates(candidates), 0);

Review Comment:
   This is the wrong CL, we shouldn't regress errors. It also doesn't communicate correctly the issue is missing a full replica.



##########
src/java/org/apache/cassandra/locator/SatelliteReplicationStrategy.java:
##########
@@ -513,4 +631,1286 @@ public boolean hasSameSettings(AbstractReplicationStrategy other)
                primaryDC.equals(otherStrategy.primaryDC) &&
                disabledDCs.equals(otherStrategy.disabledDCs);
     }
+
+    private AbstractReplicationStrategy strategy(ClusterMetadata metadata)
+    {
+        return metadata.schema.getKeyspaceMetadata(keyspaceName).replicationStrategy;
+    }
+
+    static abstract class CoordinationPlanner<E extends Endpoints<E>, L extends ReplicaLayout<E>, P extends ReplicaPlan<E, P>>
+    {
+        final ClusterMetadata metadata;
+        final Keyspace keyspace;
+        final ConsistencyLevel cl;
+        final SatelliteReplicationStrategy strategy;
+        final String primary;
+        final String satellite;
+        final List<String> dcs;
+        final L fullLayout;
+        final L liveLayout;
+        final Map<String, L> fullLayouts;
+        final Map<String, L> liveLayouts;
+        final Map<String, E> fullEndpoints;
+        final Map<String, E> liveEndpoints;
+
+        public CoordinationPlanner(ClusterMetadata metadata, Keyspace keyspace, ConsistencyLevel cl, SatelliteReplicationStrategy strategy, String primary, L fullLayout)
+        {
+            this.metadata = metadata;
+            this.keyspace = keyspace;
+            this.cl = cl;
+            this.strategy = strategy;
+            this.primary = primary;
+            this.satellite = strategy.getSatelliteForDC(primary);
+
+            this.dcs = createDcList();
+            Preconditions.checkState(!dcs.isEmpty(), "No DCs available for request (primary=%s, all disabled?)", primary);
+
+            Set<String> dcSet = new HashSet<>(dcs);
+            this.fullLayout = filterLayout(fullLayout, rp -> dcSet.contains(metadata.locator.location(rp.endpoint()).datacenter));
+            this.liveLayout = filterLayout(this.fullLayout, FailureDetector.isReplicaAlive);
+
+            this.fullLayouts = Maps.newHashMapWithExpectedSize(dcs.size());
+            this.liveLayouts = Maps.newHashMapWithExpectedSize(dcs.size());
+
+            this.fullEndpoints = Maps.newHashMapWithExpectedSize(dcs.size());
+            this.liveEndpoints = Maps.newHashMapWithExpectedSize(dcs.size());
+
+            populateDcLayouts();
+        }
+
+        AbstractReplicationStrategy strategy(ClusterMetadata metadata)
+        {
+            return metadata.schema.getKeyspaceMetadata(keyspace.getName()).replicationStrategy;
+        }
+
+        List<String> createDcList()
+        {
+            List<String> results = new ArrayList<>();
+            if (!strategy.disabledDCs.contains(primary))
+                results.add(primary);
+
+            if (satellite != null && !strategy.disabledDCs.contains(satellite))
+                results.add(satellite);
+
+            for (String dc : strategy.fullDCs.keySet())
+            {
+                if (dc.equals(primary) || dc.equals(satellite) || strategy.disabledDCs.contains(dc))
+                    continue;
+                results.add(dc);
+            }
+
+            Preconditions.checkState(!results.isEmpty());
+            return results;
+        }
+
+        void populateDcLayouts()
+        {
+            for (String dc : dcs)
+            {
+                Predicate<Replica> dcFilter = rp -> metadata.locator.location(rp.endpoint()).datacenter.equals(dc);
+                L full = filterLayout(fullLayout, dcFilter);
+                L live = filterLayout(liveLayout, dcFilter);
+
+                fullLayouts.put(dc, full);
+                liveLayouts.put(dc, live);
+
+                fullEndpoints.put(dc, full.natural());
+                liveEndpoints.put(dc, live.natural());
+            }
+
+            if (ADDL_CHECKS_ENABLED)
+                Preconditions.checkState(fullLayouts.size() == dcs.size(), "populateDcLayouts: fullLayouts.size()=%s != dcs.size()=%s", fullLayouts.size(), dcs.size());
+        }
+
+        ResponseTrackerBuilder<E, L, P> createResponseTrackerBuilder()
+        {
+            switch (cl)
+            {
+                case ONE:
+                case LOCAL_ONE:
+                case NODE_LOCAL:
+                case ANY:
+                case TWO:
+                case THREE:
+                case ALL:
+                    return new ResponseTrackerBuilder.Simple<>(this);
+
+                case QUORUM:
+                case LOCAL_QUORUM:
+                    // LOCAL_QUORUM an QUORUM mean the same thing in SRS
+                    return new ResponseTrackerBuilder.Quorum<>(this);
+
+                case EACH_QUORUM:
+                    return new ResponseTrackerBuilder.EachQuorum<>(this);
+
+                default:
+                    throw new IllegalStateException("Unsupported consistency level: " + cl);
+            }
+        }
+
+        ResponseTracker createResponseTracker()
+        {
+            ResponseTrackerBuilder<E, L, P> builder = createResponseTrackerBuilder();
+            return builder.build();
+        }
+
+        abstract L filterLayout(L layout, Predicate<Replica> predicate);
+        abstract ResponseTracker createDcResponseTracker(String dc);
+
+        abstract P recomputePlan(ClusterMetadata metadata);
+        abstract P createReplicaPlan();
+
+        interface IndexReadExceptionCheck<E extends Endpoints<E>>
+        {
+            void maybeThrowIndexReadException(int initial, int filtered, Map<String, E> candidates, Map<InetAddressAndPort, Index.Status> indexStatusMap);
+        }
+
+        static class ForWrite extends CoordinationPlanner<EndpointsForToken, ReplicaLayout.ForTokenWrite, ReplicaPlan.ForWrite>
+        {
+            final ReplicaPlans.Selector selector;
+
+            public ForWrite(ClusterMetadata metadata, Keyspace keyspace, ConsistencyLevel cl, SatelliteReplicationStrategy strategy, String primary, ReplicaLayout.ForTokenWrite fullLayout, ReplicaPlans.Selector selector)
+            {
+                super(metadata, keyspace, cl, strategy, primary, fullLayout);
+                this.selector = selector;
+                if (ADDL_CHECKS_ENABLED)
+                {
+                    Preconditions.checkState(!strategy.disabledDCs.contains(primary));
+                }
+            }
+
+            @Override
+            ResponseTracker createDcResponseTracker(String dc)
+            {
+                // this basically hardcodes ReplicaPlans.writeAll
+                Predicate<InetAddressAndPort> dcFilter = ep -> metadata.locator.location(ep).datacenter.equals(dc);
+
+                ReplicaLayout.ForTokenWrite dcLayout = fullLayouts.get(dc);
+                Preconditions.checkNotNull(dcLayout);
+
+                int blockFor = strategy.calculateQuorum(dc);
+                int totalContacts = dcLayout.all().size();
+
+                // Only count pending replicas that are actually in the selected contacts
+                int pendingInDc = dcLayout.pending().size();
+
+                if (pendingInDc == 0)
+                {
+                    return new SimpleResponseTracker(blockFor, totalContacts, dcFilter);
+                }
+                else
+                {
+                    int committedContacts = totalContacts - pendingInDc;
+                    int totalBlockFor = blockFor + pendingInDc;
+                    Predicate<InetAddressAndPort> isPending = ep -> dcLayout.pending.endpoints().contains(ep);
+                    return new WriteResponseTracker(blockFor, totalBlockFor,
+                                                    committedContacts, pendingInDc,
+                                                    isPending, dcFilter);
+                }
+            }
+
+            @Override
+            ReplicaPlan.ForWrite recomputePlan(ClusterMetadata metadata)
+            {
+                Token token = fullLayout.token();
+                return strategy(metadata).planForWrite(metadata, keyspace, cl,
+                                                              cm -> ReplicaLayout.forTokenWriteLiveAndDown(cm, keyspace, token),
+                                                       selector).replicas();
+            }
+
+            @Override
+            ReplicaLayout.ForTokenWrite filterLayout(ReplicaLayout.ForTokenWrite layout, Predicate<Replica> predicate)
+            {
+                return layout.filter(predicate);
+            }
+
+            private ReplicaLayout.ForTokenWrite mergeLayouts(Collection<ReplicaLayout.ForTokenWrite> layouts)
+            {
+                ReplicaCollection.Builder<EndpointsForToken> naturalBuilder = null;
+                ReplicaCollection.Builder<EndpointsForToken> pendingBuilder = null;
+
+                for (ReplicaLayout.ForTokenWrite layout : layouts)
+                {
+                    EndpointsForToken natural = layout.natural();
+                    EndpointsForToken pending = layout.pending();
+                    if (naturalBuilder == null)
+                    {
+                        naturalBuilder = natural.newBuilder(natural.size());
+                        pendingBuilder = pending.newBuilder(pending.size());
+                    }
+
+                    naturalBuilder.addAll(natural);
+                    pendingBuilder.addAll(pending);
+                }
+
+                return new ReplicaLayout.ForTokenWrite(strategy, naturalBuilder.build(), pendingBuilder.build());
+            }
+
+            @Override
+            ReplicaPlan.ForWrite createReplicaPlan()
+            {
+
+                ReplicaLayout.ForTokenWrite fullLayout = mergeLayouts(fullLayouts.values());
+                ReplicaLayout.ForTokenWrite liveLayout = mergeLayouts(liveLayouts.values());
+                EndpointsForToken contacts = selector.select(cl, fullLayout, liveLayout);
+
+                return new ReplicaPlan.ForWrite(keyspace,
+                                                strategy,
+                                                cl,
+                                                fullLayout.pending(),
+                                                fullLayout.all(),
+                                                liveLayout.all(),
+                                                contacts,
+                                                this::recomputePlan,
+                                                metadata.epoch);
+            }
+        }
+
+        static abstract class ForRead<E extends Endpoints<E>, L extends ReplicaLayout<E>, P extends ReplicaPlan.ForRead<E, P>> extends CoordinationPlanner<E, L, P>
+        {
+            final @Nullable Index.QueryPlan indexQueryPlan;
+            final boolean alwaysSpeculate;
+            final boolean throwOnInsufficientLiveReplicas;
+
+            public ForRead(ClusterMetadata metadata, Keyspace keyspace, ConsistencyLevel cl, SatelliteReplicationStrategy strategy, String primary, L fullLayout, @Nullable Index.QueryPlan indexQueryPlan, boolean alwaysSpeculate, boolean throwOnInsufficientLiveReplicas)
+            {
+                super(metadata, keyspace, cl, strategy, primary, fullLayout);
+                this.indexQueryPlan = indexQueryPlan;
+                this.alwaysSpeculate = alwaysSpeculate;
+                this.throwOnInsufficientLiveReplicas = throwOnInsufficientLiveReplicas;
+            }
+
+            @Override
+            ResponseTracker createDcResponseTracker(String dc)
+            {
+                Predicate<InetAddressAndPort> dcFilter = ep -> metadata.locator.location(ep).datacenter.equals(dc);
+
+                L dcLayout = fullLayouts.get(dc);
+                int blockFor = strategy.calculateQuorum(dc);
+                int totalContacts = dcLayout.all().size();
+
+                return new SimpleResponseTracker(blockFor, totalContacts, dcFilter);
+            }
+
+            Map<String, E> candidates(IndexReadExceptionCheck<E> check)
+            {
+                if (indexQueryPlan == null)
+                    return liveEndpoints;
+
+                Map<String, E> candidates = new HashMap<>();
+                Map<InetAddressAndPort, Index.Status> indexStatusMap = new HashMap<>();
+                int initial = 0;
+                int filtered = 0;
+                for (Map.Entry<String, E> entry : liveEndpoints.entrySet())
+                {
+                    E liveEndpoints = entry.getValue();
+                    E queryableEndpoints = IndexStatusManager.instance.filterForQuery(liveEndpoints, keyspace.getName(), indexQueryPlan, indexStatusMap);
+                    initial += liveEndpoints.size();
+                    filtered += queryableEndpoints.size();
+                    candidates.put(entry.getKey(), queryableEndpoints);
+                }
+
+                if (initial != filtered)
+                    check.maybeThrowIndexReadException(initial, filtered, candidates, indexStatusMap);
+
+                return candidates;
+            }
+
+            @Override
+            ResponseTracker createResponseTracker()
+            {
+                ResponseTracker tracker = super.createResponseTracker();
+
+                // read trackers don't wait on pending nodes
+                Preconditions.checkState(tracker.pendingContacts() == 0);
+                return tracker;
+            }
+
+
+            abstract P replicaPlanBuilder(E candidates, E contacts, E liveAndDown, int quorumSize);
+
+            ReadReplicaPlanBuilder<E, L, P> replicaPlanBuilder()
+            {
+                switch (cl)
+                {
+                    case ONE:
+                    case LOCAL_ONE:
+                    case NODE_LOCAL:
+                    case ANY:
+                    case TWO:
+                    case THREE:
+                    case ALL:
+                        return new ReadReplicaPlanBuilder.Simple<>(this);
+
+                    case QUORUM:
+                    case LOCAL_QUORUM:
+                    case EACH_QUORUM:
+                        return new ReadReplicaPlanBuilder.DcQuorum<>(this);
+
+                    default:
+                        throw new IllegalStateException("Unsupported consistency level: " + cl);
+                }
+            }
+
+            @Override
+            P createReplicaPlan()
+            {
+                ReadReplicaPlanBuilder<E, L, P> builder = replicaPlanBuilder();
+                return builder.build();
+            }
+        }
+
+        static class ForTokenRead extends ForRead<EndpointsForToken, ReplicaLayout.ForTokenRead, ReplicaPlan.ForTokenRead>
+        {
+            static final Function<ReplicaPlan<?, ?>, ReplicaPlan.ForWrite> READ_REPAIR_PLAN = (ReplicaPlan<?, ?> self) -> {throw new IllegalStateException("Cannot create read repair plans for SatelliteDatacenterReplicationStrategy");};
+            final Token token;
+            final TableId tableId;
+            final SpeculativeRetryPolicy retry;
+            final ReadCoordinator coordinator;
+
+            public ForTokenRead(ClusterMetadata metadata, Keyspace keyspace, Token token, ConsistencyLevel cl, SatelliteReplicationStrategy strategy, String primary, ReplicaLayout.ForTokenRead fullLayout, @Nullable Index.QueryPlan indexQueryPlan, SpeculativeRetryPolicy retry, boolean throwOnInsufficientLiveReplicas, TableId tableId, ReadCoordinator coordinator)
+            {
+                super(metadata, keyspace, cl, strategy, primary, fullLayout, indexQueryPlan, retry == AlwaysSpeculativeRetryPolicy.INSTANCE, throwOnInsufficientLiveReplicas);
+                this.token = token;
+                this.tableId = tableId;
+                this.retry = retry;
+                this.coordinator = coordinator;
+            }
+
+            @Override
+            ReplicaLayout.ForTokenRead filterLayout(ReplicaLayout.ForTokenRead layout, Predicate<Replica> predicate)
+            {
+                return layout.filter(predicate);
+            }
+
+            @Override
+            ReplicaPlan.ForTokenRead recomputePlan(ClusterMetadata metadata)
+            {
+                return strategy(metadata).planForTokenRead(metadata, keyspace, tableId, token,
+                                                           indexQueryPlan, cl, retry, coordinator).replicas();
+            }
+
+            @Override
+            ReplicaPlan.ForTokenRead replicaPlanBuilder(EndpointsForToken candidates, EndpointsForToken contacts, EndpointsForToken liveAndDown, int quorumSize)
+            {
+                return new ReplicaPlan.ForTokenRead(keyspace, strategy, cl,
+                                                    candidates, contacts, liveAndDown,
+                                                    this::recomputePlan,
+                                                    READ_REPAIR_PLAN,
+                                                    metadata.epoch,
+                                                    quorumSize);
+            }
+        }
+
+        static class ForRangeRead extends ForRead<EndpointsForRange, ReplicaLayout.ForRangeRead, ReplicaPlan.ForRangeRead>
+        {
+            static final BiFunction<ReplicaPlan<?, ?>, Token, ReplicaPlan.ForWrite> READ_REPAIR_PLAN = (ReplicaPlan<?, ?> self, Token token) -> {throw new IllegalStateException("Cannot create read repair plans for SatelliteDatacenterReplicationStrategy");};
+            final AbstractBounds<PartitionPosition> range;
+            final int vnodeCount;
+            final TableId tableId;
+
+            public ForRangeRead(ClusterMetadata metadata, Keyspace keyspace, AbstractBounds<PartitionPosition> range, int vnodeCount, ConsistencyLevel cl, SatelliteReplicationStrategy strategy, String primary, ReplicaLayout.ForRangeRead fullLayout, @Nullable Index.QueryPlan indexQueryPlan, boolean throwOnInsufficientLiveReplicas, TableId tableId)
+            {
+                super(metadata, keyspace, cl, strategy, primary, fullLayout, indexQueryPlan, false, throwOnInsufficientLiveReplicas);
+                this.range = range;
+                this.vnodeCount = vnodeCount;
+                this.tableId = tableId;
+            }
+
+            @Override
+            ReplicaLayout.ForRangeRead filterLayout(ReplicaLayout.ForRangeRead layout, Predicate<Replica> predicate)
+            {
+                return layout.filter(predicate);
+            }
+
+            @Override
+            ReplicaPlan.ForRangeRead recomputePlan(ClusterMetadata metadata)
+            {
+                return strategy(metadata).planForRangeRead(metadata, keyspace, tableId,
+                                                           indexQueryPlan, cl, range, vnodeCount).replicas();
+            }
+
+            @Override
+            ReplicaPlan.ForRangeRead replicaPlanBuilder(EndpointsForRange candidates, EndpointsForRange contacts, EndpointsForRange liveAndDown, int quorumSize)
+            {
+                return new ReplicaPlan.ForRangeRead(keyspace, strategy, cl, range,
+                                                    candidates, contacts, liveAndDown, vnodeCount,
+                                                    this::recomputePlan,
+                                                    READ_REPAIR_PLAN,
+                                                    metadata.epoch,
+                                                    quorumSize);
+            }
+        }
+    }
+
+    static abstract class ResponseTrackerBuilder<E extends Endpoints<E>, L extends ReplicaLayout<E>, P extends ReplicaPlan<E, P>>
+    {
+        final CoordinationPlanner<E, L, P> planner;
+
+        public ResponseTrackerBuilder(CoordinationPlanner<E, L, P> planner)
+        {
+            this.planner = planner;
+        }
+
+        Map<String, ResponseTracker> responseTrackersForPrimary()
+        {
+            Map<String, ResponseTracker> result = Maps.newHashMapWithExpectedSize(planner.dcs.size());
+
+            for (String dc : planner.dcs)
+                result.put(dc, planner.createDcResponseTracker(dc));
+
+            return result;
+        }
+
+        abstract ResponseTracker aggregateTrackers(Map<String, ResponseTracker> dcTrackers);
+
+        ResponseTracker build()
+        {
+            Map<String, ResponseTracker> dcTrackers = responseTrackersForPrimary();
+            return aggregateTrackers(dcTrackers);
+        }
+
+        static class Simple<E extends Endpoints<E>, L extends ReplicaLayout<E>, P extends ReplicaPlan<E, P>> extends ResponseTrackerBuilder<E, L, P>
+        {
+            public Simple(CoordinationPlanner<E, L, P> planner)
+            {
+                super(planner);
+            }
+
+            @Override
+            ResponseTracker aggregateTrackers(Map<String, ResponseTracker> dcTrackers)
+            {
+                int totalContacts = 0;
+                int totalPending = 0;
+
+
+                List<ResponseTracker> trackers = Lists.newArrayList();
+                Set<String> blockingDatacenters = Sets.newHashSet();
+
+                for (Map.Entry<String, ResponseTracker> entry : dcTrackers.entrySet())
+                {
+                    String dc = entry.getKey();
+                    ResponseTracker tracker = entry.getValue();
+                    if (planner.cl == ConsistencyLevel.ALL || dc.equals(planner.primary) || dc.equals(planner.satellite))
+                    {
+                        totalContacts += tracker.totalContacts();
+                        totalPending += tracker.pendingContacts();
+                        trackers.add(tracker);
+                        blockingDatacenters.add(dc);
+                    }
+                }
+
+                Predicate<InetAddressAndPort> filter = i -> {
+                    String dc = planner.metadata.locator.location(i).datacenter;
+                    return dc != null && blockingDatacenters.contains(dc);
+                };
+
+                Preconditions.checkState(!trackers.isEmpty(), "No blocking DCs for CL %s (primary=%s, satellite=%s)", planner.cl, planner.primary, planner.satellite);
+
+
+                if (planner.cl == ConsistencyLevel.ALL)
+                    return new SimpleResponseTracker(totalContacts, totalContacts, filter);
+
+                int blockFor = planner.cl.blockFor(planner.strategy);
+                int totalBlockFor = blockFor + totalPending;
+
+                Preconditions.checkState(blockFor <= totalContacts, "Simple tracker: blockFor=%s > totalContacts=%s for CL %s", blockFor, totalContacts, planner.cl);
+
+
+                if (totalPending == 0)
+                    return new SimpleResponseTracker(blockFor, totalContacts, filter);
+
+                Predicate<InetAddressAndPort> pending = i -> {
+                    for (ResponseTracker tracker : trackers)
+                    {
+                        if (tracker.isPending(i))
+                            return true;
+                    }
+                    return false;
+                };
+                return new WriteResponseTracker(blockFor, totalBlockFor, totalContacts, totalPending, pending, filter);
+            }
+        }
+
+        static class Quorum<E extends Endpoints<E>, L extends ReplicaLayout<E>, P extends ReplicaPlan<E, P>> extends ResponseTrackerBuilder<E, L, P>
+        {
+            public Quorum(CoordinationPlanner<E, L, P> planner)
+            {
+                super(planner);
+            }
+
+            @Override
+            ResponseTracker aggregateTrackers(Map<String, ResponseTracker> dcTrackers)
+            {
+                return new CompositeTracker(CompositeTracker.quorum(dcTrackers.size()), new ArrayList<>(dcTrackers.values()));
+            }
+        }
+
+        static class EachQuorum<E extends Endpoints<E>, L extends ReplicaLayout<E>, P extends ReplicaPlan<E, P>> extends ResponseTrackerBuilder<E, L, P>
+        {
+            public EachQuorum(CoordinationPlanner<E, L, P> planner)
+            {
+                super(planner);
+            }
+
+            @Override
+            ResponseTracker aggregateTrackers(Map<String, ResponseTracker> dcTrackers)
+            {
+                return new CompositeTracker(CompositeTracker.all(dcTrackers.size()), new ArrayList<>(dcTrackers.values()));
+            }
+        }
+    }
+
+    static abstract class ReadReplicaPlanBuilder<E extends Endpoints<E>, L extends ReplicaLayout<E>, P extends ReplicaPlan.ForRead<E, P>>
+            implements CoordinationPlanner.IndexReadExceptionCheck<E>
+    {
+
+        private static <E extends Endpoints<E>> Iterable<Replica> removedSupplier(Map<String, E> initial, Map<String, E> filtered)
+        {
+            return () -> initial.keySet().stream().map(dc -> {
+                Set<InetAddressAndPort> f =  filtered.containsKey(dc) ? filtered.get(dc).endpoints() : Collections.emptySet();
+                return initial.get(dc).without(f);
+            }).flatMap(AbstractReplicaCollection::stream).iterator();
+        }
+
+        final CoordinationPlanner.ForRead<E, L, P> planner;
+
+        public ReadReplicaPlanBuilder(CoordinationPlanner.ForRead<E, L, P> planner)
+        {
+            this.planner = planner;
+        }
+
+        abstract boolean canSelectReplicasFromDc(String dc, int liveNodes);
+        abstract int minBlockFor();
+        abstract int eligibleCandidates(Map<String, E> candidates);
+
+        abstract boolean haveSufficientContacts(int contactedReplicas, int contactedDcs);
+        abstract boolean haveSufficientContactsForDc(String dc, int contactedReplicas, int contactedDcs, int dcContacts);
+        abstract boolean haveSufficientLiveNodes(int contactedReplicas, int contactedDcs);
+
+        private E allLiveAndDown()
+        {
+            E fullEndpoints = planner.fullLayout.all();
+            ReplicaCollection.Builder<E> fullBuilder = fullEndpoints.newBuilder(fullEndpoints.size());
+
+            for (String dc : planner.dcs)
+            {
+                E endpoints = planner.fullEndpoints.get(dc);
+                if (endpoints == null)
+                    continue;
+
+                fullBuilder.addAll(endpoints);
+            }
+
+            return fullBuilder.build();
+        }
+
+        private E allCandidates(Map<String, E> candidates)
+        {
+            E liveEndpoints = planner.liveLayout.all();
+            ReplicaCollection.Builder<E> candidateBuilder = liveEndpoints.newBuilder(liveEndpoints.size());
+
+            for (String dc : planner.dcs)
+            {
+                // TODO: make sure the full replica is added if this is the dc it's from
+                E dcCandidates = candidates.get(dc);
+                if (dcCandidates == null || !canSelectReplicasFromDc(dc, dcCandidates.size()))
+                    continue;
+
+                candidateBuilder.addAll(dcCandidates);
+            }
+
+            return candidateBuilder.build();
+        }
+
+        private E contacts(Map<String, E> candidates)
+        {
+            E liveEndpoints = planner.liveLayout.all();
+
+            ReplicaCollection.Builder<E> contactBuilder = liveEndpoints.newBuilder(liveEndpoints.size());
+
+            // find full replica
+            Replica fullReplica = null;
+            String fullDc = null;
+            for (String dc : planner.dcs)
+            {
+                if (fullReplica != null)
+                    break;
+
+                E dcCandidates = candidates.get(dc);
+                if (dcCandidates == null || !canSelectReplicasFromDc(dc, dcCandidates.size()))
+                    continue;
+
+                for (Replica replica : dcCandidates)
+                {
+                    if (replica.isFull())
+                    {
+                        fullReplica = replica;
+                        fullDc = dc;
+                        break;
+                    }
+                }
+            }
+
+            if (fullReplica == null)
+            {
+                // No full replicas available - throw error similar to assureSufficientLiveReplicas
+                throw UnavailableException.create(ConsistencyLevel.ONE, minBlockFor(), 1, eligibleCandidates(candidates), 0);
+            }
+
+            int totalContacts = 0;
+            int totalContactedDcs = 0;
+
+            // add dc with full replicas first
+            {
+                E dcCandidates = candidates.get(fullDc);
+                Preconditions.checkNotNull(dcCandidates);
+
+                contactBuilder.add(fullReplica);
+                totalContacts++;
+                int dcContacts = 1;
+
+                if (!haveSufficientContacts(totalContacts, totalContactedDcs))
+                {
+                    for (Replica replica : dcCandidates)
+                    {
+                        if (replica.equals(fullReplica))
+                            continue;
+
+                        if (haveSufficientContactsForDc(fullDc, totalContacts, totalContactedDcs, dcContacts))
+                            break;
+
+                        contactBuilder.add(replica);
+                        totalContacts++;
+                        dcContacts++;
+                    }
+                }
+                totalContactedDcs++;
+            }
+
+            for (String dc : planner.dcs)
+            {
+                if (dc.equals(fullDc))
+                    continue;
+
+                E dcCandidates = candidates.get(dc);
+                if (dcCandidates == null || dcCandidates.isEmpty())
+                    continue;
+
+                if (haveSufficientContacts(totalContacts, totalContactedDcs))
+                    break;
+
+                int dcContacts = 0;
+                for (Replica replica : dcCandidates)
+                {
+                    if (haveSufficientContactsForDc(dc, totalContacts, totalContactedDcs, dcContacts))
+                        break;
+
+                    contactBuilder.add(replica);
+                    totalContacts++;
+                    dcContacts++;
+                }
+
+                totalContactedDcs++;
+            }
+
+            if (planner.throwOnInsufficientLiveReplicas && !haveSufficientLiveNodes(totalContacts, totalContactedDcs))
+            {
+                int fullReplicas = 0;
+                for (String dc : planner.dcs)
+                {
+                    E dcCandidates = candidates.get(dc);
+                    if (dcCandidates == null || !canSelectReplicasFromDc(dc, dcCandidates.size()))
+                        continue;
+                    for (Replica replica : dcCandidates)
+                        if (replica.isFull())
+                            fullReplicas++;
+                }
+
+                throw UnavailableException.create(planner.cl, minBlockFor(), 1, eligibleCandidates(candidates), fullReplicas);
+            }
+
+            E result = contactBuilder.build();
+
+            if (ADDL_CHECKS_ENABLED)
+            {
+                // check for duplicate contacts
+                Set<InetAddressAndPort> seen = new HashSet<>();
+                for (Replica r : result)
+                    Preconditions.checkState(seen.add(r.endpoint()), "Duplicate contact: %s", r.endpoint());
+            }
+
+            return result;
+        }
+
+        public P build()
+        {
+            Map<String, E> candidates = planner.candidates(this);
+
+            E allEndpoints = allLiveAndDown();
+            E allCandidates = allCandidates(candidates);
+            E allContacts = contacts(candidates);
+
+            if (ADDL_CHECKS_ENABLED)
+            {
+                // contacts must be a subset of candidates
+                Set<InetAddressAndPort> candidateSet = allCandidates.endpoints();
+                for (Replica contact : allContacts)
+                    Preconditions.checkState(candidateSet.contains(contact.endpoint()), "Contact %s not in candidates", contact.endpoint());
+            }
+
+            return planner.replicaPlanBuilder(allCandidates, allContacts, allEndpoints, minBlockFor());
+        }
+
+        static class Simple<E extends Endpoints<E>, L extends ReplicaLayout<E>, P extends ReplicaPlan.ForRead<E, P>> extends ReadReplicaPlanBuilder<E, L, P>
+        {
+            private final int blockFor;
+            private final int targetContacts;
+
+            public Simple(CoordinationPlanner.ForRead<E, L, P> planner)
+            {
+                super(planner);
+
+                if (planner.cl == ConsistencyLevel.ALL)
+                {
+                    int required = 0;
+                    for (E replicas : planner.fullEndpoints.values())
+                        required += replicas.size();
+
+                    this.blockFor = required;
+                }
+                else
+                {
+                    this.blockFor = planner.cl.blockFor(planner.strategy);
+                }
+
+                this.targetContacts = planner.alwaysSpeculate ? blockFor + 1 : blockFor;
+            }
+
+            @Override
+            public void maybeThrowIndexReadException(int initial, int filtered, Map<String, E> candidates, Map<InetAddressAndPort, Index.Status> indexStatusMap)
+            {
+                if (blockFor <= initial && blockFor > filtered)
+                {
+                    IndexStatusManager.instance.readFailureException(filtered, removedSupplier(planner.fullEndpoints, candidates), indexStatusMap, planner.cl, blockFor);
+                }
+            }
+
+            @Override
+            boolean canSelectReplicasFromDc(String dc, int liveNodes)
+            {
+                return true;
+            }
+
+            @Override
+            int minBlockFor()
+            {
+                return blockFor;
+            }
+
+            @Override
+            int eligibleCandidates(Map<String, E> candidates)
+            {
+                int eligible = 0;
+                for (Map.Entry<String, E> entry : candidates.entrySet())
+                {
+                    eligible += entry.getValue().size();
+                }
+                return eligible;
+            }
+
+            @Override
+            boolean haveSufficientContacts(int contactedReplicas, int contactedDcs)
+            {
+                return contactedReplicas >= targetContacts;
+            }
+
+            @Override
+            boolean haveSufficientContactsForDc(String dc, int contactedReplicas, int contactedDcs, int dcContacts)
+            {
+                return haveSufficientContacts(contactedReplicas, contactedDcs);
+            }
+
+            @Override
+            boolean haveSufficientLiveNodes(int contactedReplicas, int contactedDcs)
+            {
+                return contactedReplicas >= blockFor;
+            }
+        }
+
+        static class DcQuorum<E extends Endpoints<E>, L extends ReplicaLayout<E>, P extends ReplicaPlan.ForRead<E, P>> extends ReadReplicaPlanBuilder<E, L, P>
+        {
+            private final int blockForDcs;
+            private final Object2IntHashMap<String> dcQuorums = new Object2IntHashMap<>(-1);
+            private boolean approvedSpeculativeReplica = false;
+
+            public DcQuorum(CoordinationPlanner.ForRead<E, L, P> planner)
+            {
+                super(planner);
+                this.blockForDcs = planner.cl != ConsistencyLevel.EACH_QUORUM ? (planner.dcs.size() / 2) + 1 : planner.dcs.size();
+                for (String dc : planner.dcs)
+                    dcQuorums.put(dc, planner.strategy.calculateQuorum(dc));
+            }
+            private int dcQuorum(String dc)
+            {
+                int v = dcQuorums.get(dc);
+                Preconditions.checkArgument(v >= 0, "dcQuorum must be >= 0");
+                return v;
+            }
+
+            @Override
+            public void maybeThrowIndexReadException(int initial, int filtered, Map<String, E> candidates, Map<InetAddressAndPort, Index.Status> indexStatusMap)
+            {
+                int required = 0;
+                int available = 0;
+                int liveDcs = 0;
+                for (Map.Entry<String, E> entry : candidates.entrySet())
+                {
+                    int dcQuorum = dcQuorum(entry.getKey());
+                    int dcLive = entry.getValue().size();
+                    if (dcLive >= dcQuorum)
+                        liveDcs++;
+
+                    required += dcQuorum;
+                    available += entry.getValue().size();
+                }
+
+                if (liveDcs <= blockForDcs)
+                {
+                    IndexStatusManager.instance.readFailureException(available, removedSupplier(planner.fullEndpoints, candidates), indexStatusMap, planner.cl, required);
+                }
+            }
+
+            @Override
+            boolean canSelectReplicasFromDc(String dc, int liveNodes)
+            {
+                int dcQuorum = dcQuorum(dc);
+                return liveNodes >= dcQuorum;
+            }
+
+            @Override
+            int minBlockFor()
+            {
+                int[] quorums = new int[planner.dcs.size()];
+                for (int i = 0; i < quorums.length; i++)
+                    quorums[i] = dcQuorum(planner.dcs.get(i));
+
+                Arrays.sort(quorums);
+
+                int blockFor = 0;
+                for (int i=0; i<blockForDcs; i++)
+                    blockFor += quorums[i];
+                return blockFor;
+            }
+
+            @Override
+            int eligibleCandidates(Map<String, E> candidates)
+            {
+                int eligible = 0;
+                for (Map.Entry<String, E> entry : candidates.entrySet())
+                {
+                    int dcLive = entry.getValue().size();
+                    int dcQuorum = dcQuorum(entry.getKey());
+                    if (dcLive >= dcQuorum)
+                        eligible += dcLive;
+                }
+
+                return eligible;
+            }
+
+            @Override
+            boolean haveSufficientContacts(int contactedReplicas, int contactedDcs)
+            {
+                return contactedDcs >= blockForDcs;
+            }
+
+            @Override
+            boolean haveSufficientContactsForDc(String dc, int contactedReplicas, int contactedDcs, int dcContacts)
+            {
+                int dcQuorum = dcQuorum(dc);
+
+                if (dcContacts < dcQuorum)
+                    return false;
+
+                if (dcContacts == dcQuorum && planner.alwaysSpeculate && !approvedSpeculativeReplica)
+                {
+                    approvedSpeculativeReplica = true;
+                    return false;
+                }
+
+                return true;
+            }
+
+            @Override
+            boolean haveSufficientLiveNodes(int contactedReplicas, int contactedDcs)
+            {
+                return contactedDcs >= blockForDcs;
+            }
+        }
+
+    }
+
+    @Override
+    protected CoordinationPlan.ForWrite planForWriteInternal(ClusterMetadata metadata,
+                                                             Keyspace keyspace,
+                                                             ConsistencyLevel consistencyLevel,
+                                                             Function<ClusterMetadata, ReplicaLayout.ForTokenWrite> liveAndDown,
+                                                             ReplicaPlans.Selector selector)
+    {
+        // writes don't need any special handling during primary failover. We just write to the current
+        // primary DC, regardless of any other failover status
+        ReplicaLayout.ForTokenWrite fullLayout = liveAndDown.apply(metadata);
+
+        CoordinationPlanner.ForWrite planner = new CoordinationPlanner.ForWrite(metadata, keyspace, consistencyLevel, this, primaryDC, fullLayout, selector);
+
+        return new CoordinationPlan.ForWrite(planner.createReplicaPlan(), planner.createResponseTracker());
+    }
+
+    /**
+     * Holds the information needed to send satellite commit mutations alongside a paxos commit.
+     * The tracker has the primary DC pre-completed, so only satellite/secondary DC quorums
+     * need actual responses.
+     */
+    static class SatelliteCommitPlan
+    {
+        final EndpointsForToken liveEndpoints;
+        final EndpointsForToken downEndpoints;
+        final ResponseTracker tracker;
+
+        SatelliteCommitPlan(EndpointsForToken liveEndpoints, EndpointsForToken downEndpoints, ResponseTracker tracker)
+        {
+            this.liveEndpoints = liveEndpoints;
+            this.downEndpoints = downEndpoints;
+            this.tracker = tracker;
+        }
+    }
+
+    SatelliteCommitPlan createSatelliteCommitPlan(ClusterMetadata metadata, Keyspace keyspace, Token token)
+    {
+        ReplicaLayout.ForTokenWrite fullLayout = ReplicaLayout.forTokenWriteLiveAndDown(metadata, keyspace, token);
+        CoordinationPlanner.ForWrite planner = new CoordinationPlanner.ForWrite(metadata, keyspace, ConsistencyLevel.QUORUM,
+                                                                             this, primaryDC, fullLayout, ReplicaPlans.writeAll);
+        ResponseTracker tracker = planner.createResponseTracker();
+
+        // paxos is handling the consensus/commit acks in the primary DC, we just need to worry about the witness DCs
+        EndpointsForToken primaryEndpoints = planner.fullEndpoints.get(primaryDC);
+        if (primaryEndpoints != null)
+        {
+            for (int i = 0; i < primaryEndpoints.size(); i++)
+                tracker.onResponse(primaryEndpoints.endpoint(i));
+        }
+
+        // Collect satellite/secondary DC endpoints from the filtered layout.
+        // planner.fullLayout is already filtered to only include DCs in the coordination plan
+        // (primary DC + its satellite + other full DCs), excluding satellites of non-primary DCs.
+        Predicate<Replica> notPrimary = rp -> !metadata.locator.location(rp.endpoint()).datacenter.equals(primaryDC);
+        EndpointsForToken allSatellite = planner.fullLayout.all().filter(notPrimary);
+        EndpointsForToken liveSatellite = allSatellite.filter(FailureDetector.isReplicaAlive);
+        EndpointsForToken downSatellite = allSatellite.filter(rp -> !FailureDetector.isReplicaAlive.test(rp));
+
+        return new SatelliteCommitPlan(liveSatellite, downSatellite, tracker);
+    }
+
+    /**
+     * Sends plain mutations to satellite/secondary DCs alongside a paxos commit.
+     *
+     * Paxos consensus handles the primary DC writes. This method sends the committed mutation to satellite DCs using
+     * the standard SRS write coordination. The primary DC group is pre-completed in the tracker so only satellite DC
+     * quorums need actual responses.
+     *
+     * @return a future that completes when satellite DC quorum requirements are met (or failed)
+     */
+    @Override
+    public Future<Void> sendPaxosCommitMutations(Agreed commit, boolean isUrgent)
+    {
+        ClusterMetadata metadata = ClusterMetadata.current();
+        Keyspace keyspace = Keyspace.open(commit.metadata().keyspace);
+        Token token = commit.partitionKey().getToken();
+
+        // Reject satellite commit writes during TRANSITION_ACK. Paxos operations should not be
+        // in progress during this state, but check defensively to avoid propagating stale commits.
+        SatelliteFailoverState.FailoverInfo failoverInfo = getFailoverInfo(token, metadata);
+        if (failoverInfo.getState() == SatelliteFailoverState.State.TRANSITION_ACK)
+            throw new UnavailableException("Paxos commit rejected during TRANSITION_ACK failover state",
+                                           ConsistencyLevel.SERIAL, 1, 0);
+
+        SatelliteCommitPlan plan = createSatelliteCommitPlan(metadata, keyspace, token);
+        ResponseTracker tracker = plan.tracker;
+        MutationId mutationId = commit.mutation.id();
+        Preconditions.checkState(!mutationId.isNone());
+
+        // Register satellite replicas with mutation tracking service
+        if (!plan.liveEndpoints.isEmpty() || !plan.downEndpoints.isEmpty())
+        {
+            IntHashSet satelliteHostIds = new IntHashSet();
+            for (int i = 0; i < plan.liveEndpoints.size(); i++)
+                satelliteHostIds.add(metadata.directory.peerId(plan.liveEndpoints.endpoint(i)).id());
+            for (int i = 0; i < plan.downEndpoints.size(); i++)
+                satelliteHostIds.add(metadata.directory.peerId(plan.downEndpoints.endpoint(i)).id());
+            if (!satelliteHostIds.isEmpty())
+                MutationTrackingService.instance().sentWriteRequest(commit.makeMutation(), satelliteHostIds);
+        }
+
+        // Create promise that resolves when satellite tracker completes
+        AsyncPromise<Void> promise = new AsyncPromise<>();
+
+        // Send plain mutations to live satellite endpoints
+        Message<Mutation> satelliteMessage = Message.out(Verb.PAXOS2_COMMIT_REMOTE_REQ, commit.makeMutation(), isUrgent);
+        for (int i = 0, mi = plan.liveEndpoints.size(); i < mi; ++i)
+        {
+            InetAddressAndPort endpoint = plan.liveEndpoints.endpoint(i);
+            logger.trace("Sending satellite commit mutation for {} to {}", commit.partitionKey(), endpoint);
+            MessagingService.instance().sendWithCallback(satelliteMessage, endpoint, new RequestCallback<NoPayload>()

Review Comment:
   This doesn't set invokeOnFailure, will onFailure ever get called?
   
   This also means there is no failure testing here.



##########
src/java/org/apache/cassandra/locator/SatelliteReplicationStrategy.java:
##########
@@ -513,4 +631,1286 @@ public boolean hasSameSettings(AbstractReplicationStrategy other)
                primaryDC.equals(otherStrategy.primaryDC) &&
                disabledDCs.equals(otherStrategy.disabledDCs);
     }
+
+    private AbstractReplicationStrategy strategy(ClusterMetadata metadata)
+    {
+        return metadata.schema.getKeyspaceMetadata(keyspaceName).replicationStrategy;
+    }
+
+    static abstract class CoordinationPlanner<E extends Endpoints<E>, L extends ReplicaLayout<E>, P extends ReplicaPlan<E, P>>
+    {
+        final ClusterMetadata metadata;
+        final Keyspace keyspace;
+        final ConsistencyLevel cl;
+        final SatelliteReplicationStrategy strategy;
+        final String primary;
+        final String satellite;
+        final List<String> dcs;
+        final L fullLayout;
+        final L liveLayout;
+        final Map<String, L> fullLayouts;
+        final Map<String, L> liveLayouts;
+        final Map<String, E> fullEndpoints;
+        final Map<String, E> liveEndpoints;
+
+        public CoordinationPlanner(ClusterMetadata metadata, Keyspace keyspace, ConsistencyLevel cl, SatelliteReplicationStrategy strategy, String primary, L fullLayout)
+        {
+            this.metadata = metadata;
+            this.keyspace = keyspace;
+            this.cl = cl;
+            this.strategy = strategy;
+            this.primary = primary;
+            this.satellite = strategy.getSatelliteForDC(primary);
+
+            this.dcs = createDcList();
+            Preconditions.checkState(!dcs.isEmpty(), "No DCs available for request (primary=%s, all disabled?)", primary);
+
+            Set<String> dcSet = new HashSet<>(dcs);
+            this.fullLayout = filterLayout(fullLayout, rp -> dcSet.contains(metadata.locator.location(rp.endpoint()).datacenter));
+            this.liveLayout = filterLayout(this.fullLayout, FailureDetector.isReplicaAlive);
+
+            this.fullLayouts = Maps.newHashMapWithExpectedSize(dcs.size());
+            this.liveLayouts = Maps.newHashMapWithExpectedSize(dcs.size());
+
+            this.fullEndpoints = Maps.newHashMapWithExpectedSize(dcs.size());
+            this.liveEndpoints = Maps.newHashMapWithExpectedSize(dcs.size());
+
+            populateDcLayouts();
+        }
+
+        AbstractReplicationStrategy strategy(ClusterMetadata metadata)
+        {
+            return metadata.schema.getKeyspaceMetadata(keyspace.getName()).replicationStrategy;
+        }
+
+        List<String> createDcList()
+        {
+            List<String> results = new ArrayList<>();
+            if (!strategy.disabledDCs.contains(primary))
+                results.add(primary);
+
+            if (satellite != null && !strategy.disabledDCs.contains(satellite))
+                results.add(satellite);
+
+            for (String dc : strategy.fullDCs.keySet())
+            {
+                if (dc.equals(primary) || dc.equals(satellite) || strategy.disabledDCs.contains(dc))
+                    continue;
+                results.add(dc);
+            }
+
+            Preconditions.checkState(!results.isEmpty());
+            return results;
+        }
+
+        void populateDcLayouts()
+        {
+            for (String dc : dcs)
+            {
+                Predicate<Replica> dcFilter = rp -> metadata.locator.location(rp.endpoint()).datacenter.equals(dc);
+                L full = filterLayout(fullLayout, dcFilter);
+                L live = filterLayout(liveLayout, dcFilter);
+
+                fullLayouts.put(dc, full);
+                liveLayouts.put(dc, live);
+
+                fullEndpoints.put(dc, full.natural());
+                liveEndpoints.put(dc, live.natural());
+            }
+
+            if (ADDL_CHECKS_ENABLED)
+                Preconditions.checkState(fullLayouts.size() == dcs.size(), "populateDcLayouts: fullLayouts.size()=%s != dcs.size()=%s", fullLayouts.size(), dcs.size());
+        }
+
+        ResponseTrackerBuilder<E, L, P> createResponseTrackerBuilder()
+        {
+            switch (cl)
+            {
+                case ONE:
+                case LOCAL_ONE:
+                case NODE_LOCAL:
+                case ANY:
+                case TWO:
+                case THREE:
+                case ALL:
+                    return new ResponseTrackerBuilder.Simple<>(this);
+
+                case QUORUM:
+                case LOCAL_QUORUM:
+                    // LOCAL_QUORUM an QUORUM mean the same thing in SRS
+                    return new ResponseTrackerBuilder.Quorum<>(this);
+
+                case EACH_QUORUM:
+                    return new ResponseTrackerBuilder.EachQuorum<>(this);
+
+                default:
+                    throw new IllegalStateException("Unsupported consistency level: " + cl);
+            }
+        }
+
+        ResponseTracker createResponseTracker()
+        {
+            ResponseTrackerBuilder<E, L, P> builder = createResponseTrackerBuilder();
+            return builder.build();
+        }
+
+        abstract L filterLayout(L layout, Predicate<Replica> predicate);
+        abstract ResponseTracker createDcResponseTracker(String dc);
+
+        abstract P recomputePlan(ClusterMetadata metadata);
+        abstract P createReplicaPlan();
+
+        interface IndexReadExceptionCheck<E extends Endpoints<E>>
+        {
+            void maybeThrowIndexReadException(int initial, int filtered, Map<String, E> candidates, Map<InetAddressAndPort, Index.Status> indexStatusMap);
+        }
+
+        static class ForWrite extends CoordinationPlanner<EndpointsForToken, ReplicaLayout.ForTokenWrite, ReplicaPlan.ForWrite>
+        {
+            final ReplicaPlans.Selector selector;
+
+            public ForWrite(ClusterMetadata metadata, Keyspace keyspace, ConsistencyLevel cl, SatelliteReplicationStrategy strategy, String primary, ReplicaLayout.ForTokenWrite fullLayout, ReplicaPlans.Selector selector)
+            {
+                super(metadata, keyspace, cl, strategy, primary, fullLayout);
+                this.selector = selector;
+                if (ADDL_CHECKS_ENABLED)
+                {
+                    Preconditions.checkState(!strategy.disabledDCs.contains(primary));
+                }
+            }
+
+            @Override
+            ResponseTracker createDcResponseTracker(String dc)
+            {
+                // this basically hardcodes ReplicaPlans.writeAll
+                Predicate<InetAddressAndPort> dcFilter = ep -> metadata.locator.location(ep).datacenter.equals(dc);
+
+                ReplicaLayout.ForTokenWrite dcLayout = fullLayouts.get(dc);
+                Preconditions.checkNotNull(dcLayout);
+
+                int blockFor = strategy.calculateQuorum(dc);
+                int totalContacts = dcLayout.all().size();
+
+                // Only count pending replicas that are actually in the selected contacts
+                int pendingInDc = dcLayout.pending().size();
+
+                if (pendingInDc == 0)
+                {
+                    return new SimpleResponseTracker(blockFor, totalContacts, dcFilter);
+                }
+                else
+                {
+                    int committedContacts = totalContacts - pendingInDc;
+                    int totalBlockFor = blockFor + pendingInDc;
+                    Predicate<InetAddressAndPort> isPending = ep -> dcLayout.pending.endpoints().contains(ep);
+                    return new WriteResponseTracker(blockFor, totalBlockFor,
+                                                    committedContacts, pendingInDc,
+                                                    isPending, dcFilter);
+                }
+            }
+
+            @Override
+            ReplicaPlan.ForWrite recomputePlan(ClusterMetadata metadata)
+            {
+                Token token = fullLayout.token();
+                return strategy(metadata).planForWrite(metadata, keyspace, cl,
+                                                              cm -> ReplicaLayout.forTokenWriteLiveAndDown(cm, keyspace, token),
+                                                       selector).replicas();
+            }
+
+            @Override
+            ReplicaLayout.ForTokenWrite filterLayout(ReplicaLayout.ForTokenWrite layout, Predicate<Replica> predicate)
+            {
+                return layout.filter(predicate);
+            }
+
+            private ReplicaLayout.ForTokenWrite mergeLayouts(Collection<ReplicaLayout.ForTokenWrite> layouts)
+            {
+                ReplicaCollection.Builder<EndpointsForToken> naturalBuilder = null;
+                ReplicaCollection.Builder<EndpointsForToken> pendingBuilder = null;
+
+                for (ReplicaLayout.ForTokenWrite layout : layouts)
+                {
+                    EndpointsForToken natural = layout.natural();
+                    EndpointsForToken pending = layout.pending();
+                    if (naturalBuilder == null)
+                    {
+                        naturalBuilder = natural.newBuilder(natural.size());
+                        pendingBuilder = pending.newBuilder(pending.size());
+                    }
+
+                    naturalBuilder.addAll(natural);
+                    pendingBuilder.addAll(pending);
+                }
+
+                return new ReplicaLayout.ForTokenWrite(strategy, naturalBuilder.build(), pendingBuilder.build());
+            }
+
+            @Override
+            ReplicaPlan.ForWrite createReplicaPlan()
+            {
+
+                ReplicaLayout.ForTokenWrite fullLayout = mergeLayouts(fullLayouts.values());
+                ReplicaLayout.ForTokenWrite liveLayout = mergeLayouts(liveLayouts.values());
+                EndpointsForToken contacts = selector.select(cl, fullLayout, liveLayout);
+
+                return new ReplicaPlan.ForWrite(keyspace,
+                                                strategy,
+                                                cl,
+                                                fullLayout.pending(),
+                                                fullLayout.all(),
+                                                liveLayout.all(),
+                                                contacts,
+                                                this::recomputePlan,
+                                                metadata.epoch);
+            }
+        }
+
+        static abstract class ForRead<E extends Endpoints<E>, L extends ReplicaLayout<E>, P extends ReplicaPlan.ForRead<E, P>> extends CoordinationPlanner<E, L, P>
+        {
+            final @Nullable Index.QueryPlan indexQueryPlan;
+            final boolean alwaysSpeculate;
+            final boolean throwOnInsufficientLiveReplicas;
+
+            public ForRead(ClusterMetadata metadata, Keyspace keyspace, ConsistencyLevel cl, SatelliteReplicationStrategy strategy, String primary, L fullLayout, @Nullable Index.QueryPlan indexQueryPlan, boolean alwaysSpeculate, boolean throwOnInsufficientLiveReplicas)
+            {
+                super(metadata, keyspace, cl, strategy, primary, fullLayout);
+                this.indexQueryPlan = indexQueryPlan;
+                this.alwaysSpeculate = alwaysSpeculate;
+                this.throwOnInsufficientLiveReplicas = throwOnInsufficientLiveReplicas;
+            }
+
+            @Override
+            ResponseTracker createDcResponseTracker(String dc)
+            {
+                Predicate<InetAddressAndPort> dcFilter = ep -> metadata.locator.location(ep).datacenter.equals(dc);
+
+                L dcLayout = fullLayouts.get(dc);
+                int blockFor = strategy.calculateQuorum(dc);
+                int totalContacts = dcLayout.all().size();
+
+                return new SimpleResponseTracker(blockFor, totalContacts, dcFilter);
+            }
+
+            Map<String, E> candidates(IndexReadExceptionCheck<E> check)
+            {
+                if (indexQueryPlan == null)
+                    return liveEndpoints;
+
+                Map<String, E> candidates = new HashMap<>();
+                Map<InetAddressAndPort, Index.Status> indexStatusMap = new HashMap<>();
+                int initial = 0;
+                int filtered = 0;
+                for (Map.Entry<String, E> entry : liveEndpoints.entrySet())
+                {
+                    E liveEndpoints = entry.getValue();
+                    E queryableEndpoints = IndexStatusManager.instance.filterForQuery(liveEndpoints, keyspace.getName(), indexQueryPlan, indexStatusMap);
+                    initial += liveEndpoints.size();
+                    filtered += queryableEndpoints.size();
+                    candidates.put(entry.getKey(), queryableEndpoints);
+                }
+
+                if (initial != filtered)
+                    check.maybeThrowIndexReadException(initial, filtered, candidates, indexStatusMap);
+
+                return candidates;
+            }
+
+            @Override
+            ResponseTracker createResponseTracker()
+            {
+                ResponseTracker tracker = super.createResponseTracker();
+
+                // read trackers don't wait on pending nodes
+                Preconditions.checkState(tracker.pendingContacts() == 0);
+                return tracker;
+            }
+
+
+            abstract P replicaPlanBuilder(E candidates, E contacts, E liveAndDown, int quorumSize);
+
+            ReadReplicaPlanBuilder<E, L, P> replicaPlanBuilder()
+            {
+                switch (cl)
+                {
+                    case ONE:
+                    case LOCAL_ONE:
+                    case NODE_LOCAL:
+                    case ANY:
+                    case TWO:
+                    case THREE:
+                    case ALL:
+                        return new ReadReplicaPlanBuilder.Simple<>(this);
+
+                    case QUORUM:
+                    case LOCAL_QUORUM:
+                    case EACH_QUORUM:
+                        return new ReadReplicaPlanBuilder.DcQuorum<>(this);
+
+                    default:
+                        throw new IllegalStateException("Unsupported consistency level: " + cl);
+                }
+            }
+
+            @Override
+            P createReplicaPlan()
+            {
+                ReadReplicaPlanBuilder<E, L, P> builder = replicaPlanBuilder();
+                return builder.build();
+            }
+        }
+
+        static class ForTokenRead extends ForRead<EndpointsForToken, ReplicaLayout.ForTokenRead, ReplicaPlan.ForTokenRead>
+        {
+            static final Function<ReplicaPlan<?, ?>, ReplicaPlan.ForWrite> READ_REPAIR_PLAN = (ReplicaPlan<?, ?> self) -> {throw new IllegalStateException("Cannot create read repair plans for SatelliteDatacenterReplicationStrategy");};
+            final Token token;
+            final TableId tableId;
+            final SpeculativeRetryPolicy retry;
+            final ReadCoordinator coordinator;
+
+            public ForTokenRead(ClusterMetadata metadata, Keyspace keyspace, Token token, ConsistencyLevel cl, SatelliteReplicationStrategy strategy, String primary, ReplicaLayout.ForTokenRead fullLayout, @Nullable Index.QueryPlan indexQueryPlan, SpeculativeRetryPolicy retry, boolean throwOnInsufficientLiveReplicas, TableId tableId, ReadCoordinator coordinator)
+            {
+                super(metadata, keyspace, cl, strategy, primary, fullLayout, indexQueryPlan, retry == AlwaysSpeculativeRetryPolicy.INSTANCE, throwOnInsufficientLiveReplicas);
+                this.token = token;
+                this.tableId = tableId;
+                this.retry = retry;
+                this.coordinator = coordinator;
+            }
+
+            @Override
+            ReplicaLayout.ForTokenRead filterLayout(ReplicaLayout.ForTokenRead layout, Predicate<Replica> predicate)
+            {
+                return layout.filter(predicate);
+            }
+
+            @Override
+            ReplicaPlan.ForTokenRead recomputePlan(ClusterMetadata metadata)
+            {
+                return strategy(metadata).planForTokenRead(metadata, keyspace, tableId, token,
+                                                           indexQueryPlan, cl, retry, coordinator).replicas();
+            }
+
+            @Override
+            ReplicaPlan.ForTokenRead replicaPlanBuilder(EndpointsForToken candidates, EndpointsForToken contacts, EndpointsForToken liveAndDown, int quorumSize)
+            {
+                return new ReplicaPlan.ForTokenRead(keyspace, strategy, cl,
+                                                    candidates, contacts, liveAndDown,
+                                                    this::recomputePlan,
+                                                    READ_REPAIR_PLAN,
+                                                    metadata.epoch,
+                                                    quorumSize);
+            }
+        }
+
+        static class ForRangeRead extends ForRead<EndpointsForRange, ReplicaLayout.ForRangeRead, ReplicaPlan.ForRangeRead>
+        {
+            static final BiFunction<ReplicaPlan<?, ?>, Token, ReplicaPlan.ForWrite> READ_REPAIR_PLAN = (ReplicaPlan<?, ?> self, Token token) -> {throw new IllegalStateException("Cannot create read repair plans for SatelliteDatacenterReplicationStrategy");};
+            final AbstractBounds<PartitionPosition> range;
+            final int vnodeCount;
+            final TableId tableId;
+
+            public ForRangeRead(ClusterMetadata metadata, Keyspace keyspace, AbstractBounds<PartitionPosition> range, int vnodeCount, ConsistencyLevel cl, SatelliteReplicationStrategy strategy, String primary, ReplicaLayout.ForRangeRead fullLayout, @Nullable Index.QueryPlan indexQueryPlan, boolean throwOnInsufficientLiveReplicas, TableId tableId)
+            {
+                super(metadata, keyspace, cl, strategy, primary, fullLayout, indexQueryPlan, false, throwOnInsufficientLiveReplicas);
+                this.range = range;
+                this.vnodeCount = vnodeCount;
+                this.tableId = tableId;
+            }
+
+            @Override
+            ReplicaLayout.ForRangeRead filterLayout(ReplicaLayout.ForRangeRead layout, Predicate<Replica> predicate)
+            {
+                return layout.filter(predicate);
+            }
+
+            @Override
+            ReplicaPlan.ForRangeRead recomputePlan(ClusterMetadata metadata)
+            {
+                return strategy(metadata).planForRangeRead(metadata, keyspace, tableId,
+                                                           indexQueryPlan, cl, range, vnodeCount).replicas();
+            }
+
+            @Override
+            ReplicaPlan.ForRangeRead replicaPlanBuilder(EndpointsForRange candidates, EndpointsForRange contacts, EndpointsForRange liveAndDown, int quorumSize)
+            {
+                return new ReplicaPlan.ForRangeRead(keyspace, strategy, cl, range,
+                                                    candidates, contacts, liveAndDown, vnodeCount,
+                                                    this::recomputePlan,
+                                                    READ_REPAIR_PLAN,
+                                                    metadata.epoch,
+                                                    quorumSize);
+            }
+        }
+    }
+
+    static abstract class ResponseTrackerBuilder<E extends Endpoints<E>, L extends ReplicaLayout<E>, P extends ReplicaPlan<E, P>>
+    {
+        final CoordinationPlanner<E, L, P> planner;
+
+        public ResponseTrackerBuilder(CoordinationPlanner<E, L, P> planner)
+        {
+            this.planner = planner;
+        }
+
+        Map<String, ResponseTracker> responseTrackersForPrimary()
+        {
+            Map<String, ResponseTracker> result = Maps.newHashMapWithExpectedSize(planner.dcs.size());
+
+            for (String dc : planner.dcs)
+                result.put(dc, planner.createDcResponseTracker(dc));
+
+            return result;
+        }
+
+        abstract ResponseTracker aggregateTrackers(Map<String, ResponseTracker> dcTrackers);
+
+        ResponseTracker build()
+        {
+            Map<String, ResponseTracker> dcTrackers = responseTrackersForPrimary();
+            return aggregateTrackers(dcTrackers);
+        }
+
+        static class Simple<E extends Endpoints<E>, L extends ReplicaLayout<E>, P extends ReplicaPlan<E, P>> extends ResponseTrackerBuilder<E, L, P>
+        {
+            public Simple(CoordinationPlanner<E, L, P> planner)
+            {
+                super(planner);
+            }
+
+            @Override
+            ResponseTracker aggregateTrackers(Map<String, ResponseTracker> dcTrackers)
+            {
+                int totalContacts = 0;
+                int totalPending = 0;
+
+
+                List<ResponseTracker> trackers = Lists.newArrayList();
+                Set<String> blockingDatacenters = Sets.newHashSet();
+
+                for (Map.Entry<String, ResponseTracker> entry : dcTrackers.entrySet())
+                {
+                    String dc = entry.getKey();
+                    ResponseTracker tracker = entry.getValue();
+                    if (planner.cl == ConsistencyLevel.ALL || dc.equals(planner.primary) || dc.equals(planner.satellite))
+                    {
+                        totalContacts += tracker.totalContacts();
+                        totalPending += tracker.pendingContacts();
+                        trackers.add(tracker);
+                        blockingDatacenters.add(dc);
+                    }
+                }
+
+                Predicate<InetAddressAndPort> filter = i -> {
+                    String dc = planner.metadata.locator.location(i).datacenter;
+                    return dc != null && blockingDatacenters.contains(dc);
+                };
+
+                Preconditions.checkState(!trackers.isEmpty(), "No blocking DCs for CL %s (primary=%s, satellite=%s)", planner.cl, planner.primary, planner.satellite);
+
+
+                if (planner.cl == ConsistencyLevel.ALL)
+                    return new SimpleResponseTracker(totalContacts, totalContacts, filter);
+
+                int blockFor = planner.cl.blockFor(planner.strategy);
+                int totalBlockFor = blockFor + totalPending;
+
+                Preconditions.checkState(blockFor <= totalContacts, "Simple tracker: blockFor=%s > totalContacts=%s for CL %s", blockFor, totalContacts, planner.cl);
+
+
+                if (totalPending == 0)
+                    return new SimpleResponseTracker(blockFor, totalContacts, filter);
+
+                Predicate<InetAddressAndPort> pending = i -> {
+                    for (ResponseTracker tracker : trackers)
+                    {
+                        if (tracker.isPending(i))
+                            return true;
+                    }
+                    return false;
+                };
+                return new WriteResponseTracker(blockFor, totalBlockFor, totalContacts, totalPending, pending, filter);
+            }
+        }
+
+        static class Quorum<E extends Endpoints<E>, L extends ReplicaLayout<E>, P extends ReplicaPlan<E, P>> extends ResponseTrackerBuilder<E, L, P>
+        {
+            public Quorum(CoordinationPlanner<E, L, P> planner)
+            {
+                super(planner);
+            }
+
+            @Override
+            ResponseTracker aggregateTrackers(Map<String, ResponseTracker> dcTrackers)
+            {
+                return new CompositeTracker(CompositeTracker.quorum(dcTrackers.size()), new ArrayList<>(dcTrackers.values()));
+            }
+        }
+
+        static class EachQuorum<E extends Endpoints<E>, L extends ReplicaLayout<E>, P extends ReplicaPlan<E, P>> extends ResponseTrackerBuilder<E, L, P>
+        {
+            public EachQuorum(CoordinationPlanner<E, L, P> planner)
+            {
+                super(planner);
+            }
+
+            @Override
+            ResponseTracker aggregateTrackers(Map<String, ResponseTracker> dcTrackers)
+            {
+                return new CompositeTracker(CompositeTracker.all(dcTrackers.size()), new ArrayList<>(dcTrackers.values()));
+            }
+        }
+    }
+
+    static abstract class ReadReplicaPlanBuilder<E extends Endpoints<E>, L extends ReplicaLayout<E>, P extends ReplicaPlan.ForRead<E, P>>
+            implements CoordinationPlanner.IndexReadExceptionCheck<E>
+    {
+
+        private static <E extends Endpoints<E>> Iterable<Replica> removedSupplier(Map<String, E> initial, Map<String, E> filtered)
+        {
+            return () -> initial.keySet().stream().map(dc -> {
+                Set<InetAddressAndPort> f =  filtered.containsKey(dc) ? filtered.get(dc).endpoints() : Collections.emptySet();
+                return initial.get(dc).without(f);
+            }).flatMap(AbstractReplicaCollection::stream).iterator();
+        }
+
+        final CoordinationPlanner.ForRead<E, L, P> planner;
+
+        public ReadReplicaPlanBuilder(CoordinationPlanner.ForRead<E, L, P> planner)
+        {
+            this.planner = planner;
+        }
+
+        abstract boolean canSelectReplicasFromDc(String dc, int liveNodes);
+        abstract int minBlockFor();
+        abstract int eligibleCandidates(Map<String, E> candidates);
+
+        abstract boolean haveSufficientContacts(int contactedReplicas, int contactedDcs);
+        abstract boolean haveSufficientContactsForDc(String dc, int contactedReplicas, int contactedDcs, int dcContacts);
+        abstract boolean haveSufficientLiveNodes(int contactedReplicas, int contactedDcs);
+
+        private E allLiveAndDown()
+        {
+            E fullEndpoints = planner.fullLayout.all();
+            ReplicaCollection.Builder<E> fullBuilder = fullEndpoints.newBuilder(fullEndpoints.size());
+
+            for (String dc : planner.dcs)
+            {
+                E endpoints = planner.fullEndpoints.get(dc);
+                if (endpoints == null)
+                    continue;
+
+                fullBuilder.addAll(endpoints);
+            }
+
+            return fullBuilder.build();
+        }
+
+        private E allCandidates(Map<String, E> candidates)
+        {
+            E liveEndpoints = planner.liveLayout.all();
+            ReplicaCollection.Builder<E> candidateBuilder = liveEndpoints.newBuilder(liveEndpoints.size());
+
+            for (String dc : planner.dcs)
+            {
+                // TODO: make sure the full replica is added if this is the dc it's from
+                E dcCandidates = candidates.get(dc);
+                if (dcCandidates == null || !canSelectReplicasFromDc(dc, dcCandidates.size()))
+                    continue;
+
+                candidateBuilder.addAll(dcCandidates);
+            }
+
+            return candidateBuilder.build();
+        }
+
+        private E contacts(Map<String, E> candidates)
+        {
+            E liveEndpoints = planner.liveLayout.all();
+
+            ReplicaCollection.Builder<E> contactBuilder = liveEndpoints.newBuilder(liveEndpoints.size());
+
+            // find full replica
+            Replica fullReplica = null;
+            String fullDc = null;
+            for (String dc : planner.dcs)
+            {
+                if (fullReplica != null)
+                    break;
+
+                E dcCandidates = candidates.get(dc);
+                if (dcCandidates == null || !canSelectReplicasFromDc(dc, dcCandidates.size()))
+                    continue;
+
+                for (Replica replica : dcCandidates)
+                {
+                    if (replica.isFull())
+                    {
+                        fullReplica = replica;
+                        fullDc = dc;
+                        break;
+                    }
+                }
+            }
+
+            if (fullReplica == null)
+            {
+                // No full replicas available - throw error similar to assureSufficientLiveReplicas
+                throw UnavailableException.create(ConsistencyLevel.ONE, minBlockFor(), 1, eligibleCandidates(candidates), 0);
+            }
+
+            int totalContacts = 0;
+            int totalContactedDcs = 0;
+
+            // add dc with full replicas first
+            {
+                E dcCandidates = candidates.get(fullDc);
+                Preconditions.checkNotNull(dcCandidates);
+
+                contactBuilder.add(fullReplica);
+                totalContacts++;
+                int dcContacts = 1;
+
+                if (!haveSufficientContacts(totalContacts, totalContactedDcs))
+                {
+                    for (Replica replica : dcCandidates)
+                    {
+                        if (replica.equals(fullReplica))
+                            continue;
+
+                        if (haveSufficientContactsForDc(fullDc, totalContacts, totalContactedDcs, dcContacts))
+                            break;
+
+                        contactBuilder.add(replica);
+                        totalContacts++;
+                        dcContacts++;
+                    }
+                }
+                totalContactedDcs++;
+            }
+
+            for (String dc : planner.dcs)
+            {
+                if (dc.equals(fullDc))
+                    continue;
+
+                E dcCandidates = candidates.get(dc);
+                if (dcCandidates == null || dcCandidates.isEmpty())
+                    continue;
+
+                if (haveSufficientContacts(totalContacts, totalContactedDcs))
+                    break;
+
+                int dcContacts = 0;
+                for (Replica replica : dcCandidates)
+                {
+                    if (haveSufficientContactsForDc(dc, totalContacts, totalContactedDcs, dcContacts))
+                        break;
+
+                    contactBuilder.add(replica);
+                    totalContacts++;
+                    dcContacts++;
+                }
+
+                totalContactedDcs++;
+            }
+
+            if (planner.throwOnInsufficientLiveReplicas && !haveSufficientLiveNodes(totalContacts, totalContactedDcs))
+            {
+                int fullReplicas = 0;
+                for (String dc : planner.dcs)
+                {
+                    E dcCandidates = candidates.get(dc);
+                    if (dcCandidates == null || !canSelectReplicasFromDc(dc, dcCandidates.size()))
+                        continue;
+                    for (Replica replica : dcCandidates)
+                        if (replica.isFull())
+                            fullReplicas++;
+                }
+
+                throw UnavailableException.create(planner.cl, minBlockFor(), 1, eligibleCandidates(candidates), fullReplicas);
+            }
+
+            E result = contactBuilder.build();
+
+            if (ADDL_CHECKS_ENABLED)
+            {
+                // check for duplicate contacts
+                Set<InetAddressAndPort> seen = new HashSet<>();
+                for (Replica r : result)
+                    Preconditions.checkState(seen.add(r.endpoint()), "Duplicate contact: %s", r.endpoint());
+            }
+
+            return result;
+        }
+
+        public P build()
+        {
+            Map<String, E> candidates = planner.candidates(this);
+
+            E allEndpoints = allLiveAndDown();
+            E allCandidates = allCandidates(candidates);
+            E allContacts = contacts(candidates);
+
+            if (ADDL_CHECKS_ENABLED)
+            {
+                // contacts must be a subset of candidates
+                Set<InetAddressAndPort> candidateSet = allCandidates.endpoints();
+                for (Replica contact : allContacts)
+                    Preconditions.checkState(candidateSet.contains(contact.endpoint()), "Contact %s not in candidates", contact.endpoint());
+            }
+
+            return planner.replicaPlanBuilder(allCandidates, allContacts, allEndpoints, minBlockFor());
+        }
+
+        static class Simple<E extends Endpoints<E>, L extends ReplicaLayout<E>, P extends ReplicaPlan.ForRead<E, P>> extends ReadReplicaPlanBuilder<E, L, P>
+        {
+            private final int blockFor;
+            private final int targetContacts;
+
+            public Simple(CoordinationPlanner.ForRead<E, L, P> planner)
+            {
+                super(planner);
+
+                if (planner.cl == ConsistencyLevel.ALL)
+                {
+                    int required = 0;
+                    for (E replicas : planner.fullEndpoints.values())
+                        required += replicas.size();
+
+                    this.blockFor = required;
+                }
+                else
+                {
+                    this.blockFor = planner.cl.blockFor(planner.strategy);
+                }
+
+                this.targetContacts = planner.alwaysSpeculate ? blockFor + 1 : blockFor;
+            }
+
+            @Override
+            public void maybeThrowIndexReadException(int initial, int filtered, Map<String, E> candidates, Map<InetAddressAndPort, Index.Status> indexStatusMap)
+            {
+                if (blockFor <= initial && blockFor > filtered)
+                {
+                    IndexStatusManager.instance.readFailureException(filtered, removedSupplier(planner.fullEndpoints, candidates), indexStatusMap, planner.cl, blockFor);
+                }
+            }
+
+            @Override
+            boolean canSelectReplicasFromDc(String dc, int liveNodes)
+            {
+                return true;
+            }
+
+            @Override
+            int minBlockFor()
+            {
+                return blockFor;
+            }
+
+            @Override
+            int eligibleCandidates(Map<String, E> candidates)
+            {
+                int eligible = 0;
+                for (Map.Entry<String, E> entry : candidates.entrySet())
+                {
+                    eligible += entry.getValue().size();
+                }
+                return eligible;
+            }
+
+            @Override
+            boolean haveSufficientContacts(int contactedReplicas, int contactedDcs)
+            {
+                return contactedReplicas >= targetContacts;
+            }
+
+            @Override
+            boolean haveSufficientContactsForDc(String dc, int contactedReplicas, int contactedDcs, int dcContacts)
+            {
+                return haveSufficientContacts(contactedReplicas, contactedDcs);
+            }
+
+            @Override
+            boolean haveSufficientLiveNodes(int contactedReplicas, int contactedDcs)
+            {
+                return contactedReplicas >= blockFor;
+            }
+        }
+
+        static class DcQuorum<E extends Endpoints<E>, L extends ReplicaLayout<E>, P extends ReplicaPlan.ForRead<E, P>> extends ReadReplicaPlanBuilder<E, L, P>
+        {
+            private final int blockForDcs;
+            private final Object2IntHashMap<String> dcQuorums = new Object2IntHashMap<>(-1);
+            private boolean approvedSpeculativeReplica = false;
+
+            public DcQuorum(CoordinationPlanner.ForRead<E, L, P> planner)
+            {
+                super(planner);
+                this.blockForDcs = planner.cl != ConsistencyLevel.EACH_QUORUM ? (planner.dcs.size() / 2) + 1 : planner.dcs.size();
+                for (String dc : planner.dcs)
+                    dcQuorums.put(dc, planner.strategy.calculateQuorum(dc));
+            }
+            private int dcQuorum(String dc)
+            {
+                int v = dcQuorums.get(dc);
+                Preconditions.checkArgument(v >= 0, "dcQuorum must be >= 0");
+                return v;
+            }
+
+            @Override
+            public void maybeThrowIndexReadException(int initial, int filtered, Map<String, E> candidates, Map<InetAddressAndPort, Index.Status> indexStatusMap)
+            {
+                int required = 0;
+                int available = 0;
+                int liveDcs = 0;
+                for (Map.Entry<String, E> entry : candidates.entrySet())
+                {
+                    int dcQuorum = dcQuorum(entry.getKey());
+                    int dcLive = entry.getValue().size();
+                    if (dcLive >= dcQuorum)
+                        liveDcs++;
+
+                    required += dcQuorum;
+                    available += entry.getValue().size();
+                }
+
+                if (liveDcs <= blockForDcs)

Review Comment:
   Should this be <? Is there missing test code for this?



##########
src/java/org/apache/cassandra/locator/SatelliteReplicationStrategy.java:
##########
@@ -513,4 +631,1286 @@ public boolean hasSameSettings(AbstractReplicationStrategy other)
                primaryDC.equals(otherStrategy.primaryDC) &&
                disabledDCs.equals(otherStrategy.disabledDCs);
     }
+
+    private AbstractReplicationStrategy strategy(ClusterMetadata metadata)
+    {
+        return metadata.schema.getKeyspaceMetadata(keyspaceName).replicationStrategy;
+    }
+
+    static abstract class CoordinationPlanner<E extends Endpoints<E>, L extends ReplicaLayout<E>, P extends ReplicaPlan<E, P>>
+    {
+        final ClusterMetadata metadata;
+        final Keyspace keyspace;
+        final ConsistencyLevel cl;
+        final SatelliteReplicationStrategy strategy;
+        final String primary;
+        final String satellite;
+        final List<String> dcs;
+        final L fullLayout;
+        final L liveLayout;
+        final Map<String, L> fullLayouts;
+        final Map<String, L> liveLayouts;
+        final Map<String, E> fullEndpoints;
+        final Map<String, E> liveEndpoints;
+
+        public CoordinationPlanner(ClusterMetadata metadata, Keyspace keyspace, ConsistencyLevel cl, SatelliteReplicationStrategy strategy, String primary, L fullLayout)
+        {
+            this.metadata = metadata;
+            this.keyspace = keyspace;
+            this.cl = cl;
+            this.strategy = strategy;
+            this.primary = primary;
+            this.satellite = strategy.getSatelliteForDC(primary);
+
+            this.dcs = createDcList();
+            Preconditions.checkState(!dcs.isEmpty(), "No DCs available for request (primary=%s, all disabled?)", primary);
+
+            Set<String> dcSet = new HashSet<>(dcs);
+            this.fullLayout = filterLayout(fullLayout, rp -> dcSet.contains(metadata.locator.location(rp.endpoint()).datacenter));
+            this.liveLayout = filterLayout(this.fullLayout, FailureDetector.isReplicaAlive);
+
+            this.fullLayouts = Maps.newHashMapWithExpectedSize(dcs.size());
+            this.liveLayouts = Maps.newHashMapWithExpectedSize(dcs.size());
+
+            this.fullEndpoints = Maps.newHashMapWithExpectedSize(dcs.size());
+            this.liveEndpoints = Maps.newHashMapWithExpectedSize(dcs.size());
+
+            populateDcLayouts();
+        }
+
+        AbstractReplicationStrategy strategy(ClusterMetadata metadata)
+        {
+            return metadata.schema.getKeyspaceMetadata(keyspace.getName()).replicationStrategy;
+        }
+
+        List<String> createDcList()
+        {
+            List<String> results = new ArrayList<>();
+            if (!strategy.disabledDCs.contains(primary))
+                results.add(primary);
+
+            if (satellite != null && !strategy.disabledDCs.contains(satellite))
+                results.add(satellite);
+
+            for (String dc : strategy.fullDCs.keySet())
+            {
+                if (dc.equals(primary) || dc.equals(satellite) || strategy.disabledDCs.contains(dc))
+                    continue;
+                results.add(dc);
+            }
+
+            Preconditions.checkState(!results.isEmpty());
+            return results;
+        }
+
+        void populateDcLayouts()
+        {
+            for (String dc : dcs)
+            {
+                Predicate<Replica> dcFilter = rp -> metadata.locator.location(rp.endpoint()).datacenter.equals(dc);
+                L full = filterLayout(fullLayout, dcFilter);
+                L live = filterLayout(liveLayout, dcFilter);
+
+                fullLayouts.put(dc, full);
+                liveLayouts.put(dc, live);
+
+                fullEndpoints.put(dc, full.natural());
+                liveEndpoints.put(dc, live.natural());
+            }
+
+            if (ADDL_CHECKS_ENABLED)
+                Preconditions.checkState(fullLayouts.size() == dcs.size(), "populateDcLayouts: fullLayouts.size()=%s != dcs.size()=%s", fullLayouts.size(), dcs.size());
+        }
+
+        ResponseTrackerBuilder<E, L, P> createResponseTrackerBuilder()
+        {
+            switch (cl)
+            {
+                case ONE:
+                case LOCAL_ONE:
+                case NODE_LOCAL:
+                case ANY:
+                case TWO:
+                case THREE:
+                case ALL:
+                    return new ResponseTrackerBuilder.Simple<>(this);
+
+                case QUORUM:
+                case LOCAL_QUORUM:
+                    // LOCAL_QUORUM an QUORUM mean the same thing in SRS
+                    return new ResponseTrackerBuilder.Quorum<>(this);
+
+                case EACH_QUORUM:
+                    return new ResponseTrackerBuilder.EachQuorum<>(this);
+
+                default:
+                    throw new IllegalStateException("Unsupported consistency level: " + cl);
+            }
+        }
+
+        ResponseTracker createResponseTracker()
+        {
+            ResponseTrackerBuilder<E, L, P> builder = createResponseTrackerBuilder();
+            return builder.build();
+        }
+
+        abstract L filterLayout(L layout, Predicate<Replica> predicate);
+        abstract ResponseTracker createDcResponseTracker(String dc);
+
+        abstract P recomputePlan(ClusterMetadata metadata);
+        abstract P createReplicaPlan();
+
+        interface IndexReadExceptionCheck<E extends Endpoints<E>>
+        {
+            void maybeThrowIndexReadException(int initial, int filtered, Map<String, E> candidates, Map<InetAddressAndPort, Index.Status> indexStatusMap);
+        }
+
+        static class ForWrite extends CoordinationPlanner<EndpointsForToken, ReplicaLayout.ForTokenWrite, ReplicaPlan.ForWrite>
+        {
+            final ReplicaPlans.Selector selector;
+
+            public ForWrite(ClusterMetadata metadata, Keyspace keyspace, ConsistencyLevel cl, SatelliteReplicationStrategy strategy, String primary, ReplicaLayout.ForTokenWrite fullLayout, ReplicaPlans.Selector selector)
+            {
+                super(metadata, keyspace, cl, strategy, primary, fullLayout);
+                this.selector = selector;
+                if (ADDL_CHECKS_ENABLED)
+                {
+                    Preconditions.checkState(!strategy.disabledDCs.contains(primary));
+                }
+            }
+
+            @Override
+            ResponseTracker createDcResponseTracker(String dc)
+            {
+                // this basically hardcodes ReplicaPlans.writeAll
+                Predicate<InetAddressAndPort> dcFilter = ep -> metadata.locator.location(ep).datacenter.equals(dc);
+
+                ReplicaLayout.ForTokenWrite dcLayout = fullLayouts.get(dc);
+                Preconditions.checkNotNull(dcLayout);
+
+                int blockFor = strategy.calculateQuorum(dc);
+                int totalContacts = dcLayout.all().size();
+
+                // Only count pending replicas that are actually in the selected contacts
+                int pendingInDc = dcLayout.pending().size();
+
+                if (pendingInDc == 0)
+                {
+                    return new SimpleResponseTracker(blockFor, totalContacts, dcFilter);
+                }
+                else
+                {
+                    int committedContacts = totalContacts - pendingInDc;
+                    int totalBlockFor = blockFor + pendingInDc;
+                    Predicate<InetAddressAndPort> isPending = ep -> dcLayout.pending.endpoints().contains(ep);
+                    return new WriteResponseTracker(blockFor, totalBlockFor,
+                                                    committedContacts, pendingInDc,
+                                                    isPending, dcFilter);
+                }
+            }
+
+            @Override
+            ReplicaPlan.ForWrite recomputePlan(ClusterMetadata metadata)
+            {
+                Token token = fullLayout.token();
+                return strategy(metadata).planForWrite(metadata, keyspace, cl,
+                                                              cm -> ReplicaLayout.forTokenWriteLiveAndDown(cm, keyspace, token),
+                                                       selector).replicas();
+            }
+
+            @Override
+            ReplicaLayout.ForTokenWrite filterLayout(ReplicaLayout.ForTokenWrite layout, Predicate<Replica> predicate)
+            {
+                return layout.filter(predicate);
+            }
+
+            private ReplicaLayout.ForTokenWrite mergeLayouts(Collection<ReplicaLayout.ForTokenWrite> layouts)
+            {
+                ReplicaCollection.Builder<EndpointsForToken> naturalBuilder = null;
+                ReplicaCollection.Builder<EndpointsForToken> pendingBuilder = null;
+
+                for (ReplicaLayout.ForTokenWrite layout : layouts)
+                {
+                    EndpointsForToken natural = layout.natural();
+                    EndpointsForToken pending = layout.pending();
+                    if (naturalBuilder == null)
+                    {
+                        naturalBuilder = natural.newBuilder(natural.size());
+                        pendingBuilder = pending.newBuilder(pending.size());
+                    }
+
+                    naturalBuilder.addAll(natural);
+                    pendingBuilder.addAll(pending);
+                }
+
+                return new ReplicaLayout.ForTokenWrite(strategy, naturalBuilder.build(), pendingBuilder.build());
+            }
+
+            @Override
+            ReplicaPlan.ForWrite createReplicaPlan()
+            {
+
+                ReplicaLayout.ForTokenWrite fullLayout = mergeLayouts(fullLayouts.values());
+                ReplicaLayout.ForTokenWrite liveLayout = mergeLayouts(liveLayouts.values());
+                EndpointsForToken contacts = selector.select(cl, fullLayout, liveLayout);
+
+                return new ReplicaPlan.ForWrite(keyspace,
+                                                strategy,
+                                                cl,
+                                                fullLayout.pending(),
+                                                fullLayout.all(),
+                                                liveLayout.all(),
+                                                contacts,
+                                                this::recomputePlan,
+                                                metadata.epoch);
+            }
+        }
+
+        static abstract class ForRead<E extends Endpoints<E>, L extends ReplicaLayout<E>, P extends ReplicaPlan.ForRead<E, P>> extends CoordinationPlanner<E, L, P>
+        {
+            final @Nullable Index.QueryPlan indexQueryPlan;
+            final boolean alwaysSpeculate;
+            final boolean throwOnInsufficientLiveReplicas;
+
+            public ForRead(ClusterMetadata metadata, Keyspace keyspace, ConsistencyLevel cl, SatelliteReplicationStrategy strategy, String primary, L fullLayout, @Nullable Index.QueryPlan indexQueryPlan, boolean alwaysSpeculate, boolean throwOnInsufficientLiveReplicas)
+            {
+                super(metadata, keyspace, cl, strategy, primary, fullLayout);
+                this.indexQueryPlan = indexQueryPlan;
+                this.alwaysSpeculate = alwaysSpeculate;
+                this.throwOnInsufficientLiveReplicas = throwOnInsufficientLiveReplicas;
+            }
+
+            @Override
+            ResponseTracker createDcResponseTracker(String dc)
+            {
+                Predicate<InetAddressAndPort> dcFilter = ep -> metadata.locator.location(ep).datacenter.equals(dc);
+
+                L dcLayout = fullLayouts.get(dc);
+                int blockFor = strategy.calculateQuorum(dc);
+                int totalContacts = dcLayout.all().size();
+
+                return new SimpleResponseTracker(blockFor, totalContacts, dcFilter);
+            }
+
+            Map<String, E> candidates(IndexReadExceptionCheck<E> check)
+            {
+                if (indexQueryPlan == null)
+                    return liveEndpoints;
+
+                Map<String, E> candidates = new HashMap<>();
+                Map<InetAddressAndPort, Index.Status> indexStatusMap = new HashMap<>();
+                int initial = 0;
+                int filtered = 0;
+                for (Map.Entry<String, E> entry : liveEndpoints.entrySet())
+                {
+                    E liveEndpoints = entry.getValue();
+                    E queryableEndpoints = IndexStatusManager.instance.filterForQuery(liveEndpoints, keyspace.getName(), indexQueryPlan, indexStatusMap);
+                    initial += liveEndpoints.size();
+                    filtered += queryableEndpoints.size();
+                    candidates.put(entry.getKey(), queryableEndpoints);
+                }
+
+                if (initial != filtered)
+                    check.maybeThrowIndexReadException(initial, filtered, candidates, indexStatusMap);
+
+                return candidates;
+            }
+
+            @Override
+            ResponseTracker createResponseTracker()
+            {
+                ResponseTracker tracker = super.createResponseTracker();
+
+                // read trackers don't wait on pending nodes
+                Preconditions.checkState(tracker.pendingContacts() == 0);
+                return tracker;
+            }
+
+
+            abstract P replicaPlanBuilder(E candidates, E contacts, E liveAndDown, int quorumSize);
+
+            ReadReplicaPlanBuilder<E, L, P> replicaPlanBuilder()
+            {
+                switch (cl)
+                {
+                    case ONE:
+                    case LOCAL_ONE:
+                    case NODE_LOCAL:
+                    case ANY:
+                    case TWO:
+                    case THREE:
+                    case ALL:
+                        return new ReadReplicaPlanBuilder.Simple<>(this);
+
+                    case QUORUM:
+                    case LOCAL_QUORUM:
+                    case EACH_QUORUM:
+                        return new ReadReplicaPlanBuilder.DcQuorum<>(this);
+
+                    default:
+                        throw new IllegalStateException("Unsupported consistency level: " + cl);
+                }
+            }
+
+            @Override
+            P createReplicaPlan()
+            {
+                ReadReplicaPlanBuilder<E, L, P> builder = replicaPlanBuilder();
+                return builder.build();
+            }
+        }
+
+        static class ForTokenRead extends ForRead<EndpointsForToken, ReplicaLayout.ForTokenRead, ReplicaPlan.ForTokenRead>
+        {
+            static final Function<ReplicaPlan<?, ?>, ReplicaPlan.ForWrite> READ_REPAIR_PLAN = (ReplicaPlan<?, ?> self) -> {throw new IllegalStateException("Cannot create read repair plans for SatelliteDatacenterReplicationStrategy");};
+            final Token token;
+            final TableId tableId;
+            final SpeculativeRetryPolicy retry;
+            final ReadCoordinator coordinator;
+
+            public ForTokenRead(ClusterMetadata metadata, Keyspace keyspace, Token token, ConsistencyLevel cl, SatelliteReplicationStrategy strategy, String primary, ReplicaLayout.ForTokenRead fullLayout, @Nullable Index.QueryPlan indexQueryPlan, SpeculativeRetryPolicy retry, boolean throwOnInsufficientLiveReplicas, TableId tableId, ReadCoordinator coordinator)
+            {
+                super(metadata, keyspace, cl, strategy, primary, fullLayout, indexQueryPlan, retry == AlwaysSpeculativeRetryPolicy.INSTANCE, throwOnInsufficientLiveReplicas);
+                this.token = token;
+                this.tableId = tableId;
+                this.retry = retry;
+                this.coordinator = coordinator;
+            }
+
+            @Override
+            ReplicaLayout.ForTokenRead filterLayout(ReplicaLayout.ForTokenRead layout, Predicate<Replica> predicate)
+            {
+                return layout.filter(predicate);
+            }
+
+            @Override
+            ReplicaPlan.ForTokenRead recomputePlan(ClusterMetadata metadata)
+            {
+                return strategy(metadata).planForTokenRead(metadata, keyspace, tableId, token,
+                                                           indexQueryPlan, cl, retry, coordinator).replicas();
+            }
+
+            @Override
+            ReplicaPlan.ForTokenRead replicaPlanBuilder(EndpointsForToken candidates, EndpointsForToken contacts, EndpointsForToken liveAndDown, int quorumSize)
+            {
+                return new ReplicaPlan.ForTokenRead(keyspace, strategy, cl,
+                                                    candidates, contacts, liveAndDown,
+                                                    this::recomputePlan,
+                                                    READ_REPAIR_PLAN,
+                                                    metadata.epoch,
+                                                    quorumSize);
+            }
+        }
+
+        static class ForRangeRead extends ForRead<EndpointsForRange, ReplicaLayout.ForRangeRead, ReplicaPlan.ForRangeRead>
+        {
+            static final BiFunction<ReplicaPlan<?, ?>, Token, ReplicaPlan.ForWrite> READ_REPAIR_PLAN = (ReplicaPlan<?, ?> self, Token token) -> {throw new IllegalStateException("Cannot create read repair plans for SatelliteDatacenterReplicationStrategy");};
+            final AbstractBounds<PartitionPosition> range;
+            final int vnodeCount;
+            final TableId tableId;
+
+            public ForRangeRead(ClusterMetadata metadata, Keyspace keyspace, AbstractBounds<PartitionPosition> range, int vnodeCount, ConsistencyLevel cl, SatelliteReplicationStrategy strategy, String primary, ReplicaLayout.ForRangeRead fullLayout, @Nullable Index.QueryPlan indexQueryPlan, boolean throwOnInsufficientLiveReplicas, TableId tableId)
+            {
+                super(metadata, keyspace, cl, strategy, primary, fullLayout, indexQueryPlan, false, throwOnInsufficientLiveReplicas);
+                this.range = range;
+                this.vnodeCount = vnodeCount;
+                this.tableId = tableId;
+            }
+
+            @Override
+            ReplicaLayout.ForRangeRead filterLayout(ReplicaLayout.ForRangeRead layout, Predicate<Replica> predicate)
+            {
+                return layout.filter(predicate);
+            }
+
+            @Override
+            ReplicaPlan.ForRangeRead recomputePlan(ClusterMetadata metadata)
+            {
+                return strategy(metadata).planForRangeRead(metadata, keyspace, tableId,
+                                                           indexQueryPlan, cl, range, vnodeCount).replicas();
+            }
+
+            @Override
+            ReplicaPlan.ForRangeRead replicaPlanBuilder(EndpointsForRange candidates, EndpointsForRange contacts, EndpointsForRange liveAndDown, int quorumSize)
+            {
+                return new ReplicaPlan.ForRangeRead(keyspace, strategy, cl, range,
+                                                    candidates, contacts, liveAndDown, vnodeCount,
+                                                    this::recomputePlan,
+                                                    READ_REPAIR_PLAN,
+                                                    metadata.epoch,
+                                                    quorumSize);
+            }
+        }
+    }
+
+    static abstract class ResponseTrackerBuilder<E extends Endpoints<E>, L extends ReplicaLayout<E>, P extends ReplicaPlan<E, P>>
+    {
+        final CoordinationPlanner<E, L, P> planner;
+
+        public ResponseTrackerBuilder(CoordinationPlanner<E, L, P> planner)
+        {
+            this.planner = planner;
+        }
+
+        Map<String, ResponseTracker> responseTrackersForPrimary()
+        {
+            Map<String, ResponseTracker> result = Maps.newHashMapWithExpectedSize(planner.dcs.size());
+
+            for (String dc : planner.dcs)
+                result.put(dc, planner.createDcResponseTracker(dc));
+
+            return result;
+        }
+
+        abstract ResponseTracker aggregateTrackers(Map<String, ResponseTracker> dcTrackers);
+
+        ResponseTracker build()
+        {
+            Map<String, ResponseTracker> dcTrackers = responseTrackersForPrimary();
+            return aggregateTrackers(dcTrackers);
+        }
+
+        static class Simple<E extends Endpoints<E>, L extends ReplicaLayout<E>, P extends ReplicaPlan<E, P>> extends ResponseTrackerBuilder<E, L, P>
+        {
+            public Simple(CoordinationPlanner<E, L, P> planner)
+            {
+                super(planner);
+            }
+
+            @Override
+            ResponseTracker aggregateTrackers(Map<String, ResponseTracker> dcTrackers)
+            {
+                int totalContacts = 0;
+                int totalPending = 0;
+
+
+                List<ResponseTracker> trackers = Lists.newArrayList();
+                Set<String> blockingDatacenters = Sets.newHashSet();
+
+                for (Map.Entry<String, ResponseTracker> entry : dcTrackers.entrySet())
+                {
+                    String dc = entry.getKey();
+                    ResponseTracker tracker = entry.getValue();
+                    if (planner.cl == ConsistencyLevel.ALL || dc.equals(planner.primary) || dc.equals(planner.satellite))
+                    {
+                        totalContacts += tracker.totalContacts();

Review Comment:
   Does this include pending replicas? It's passed as natural replicas param to WriteResponseTracker, but it also seems like maybe it's not actually natural replicas anymore? What is a committed replica vs a natural replica?



##########
src/java/org/apache/cassandra/locator/AbstractReplicationStrategy.java:
##########
@@ -437,4 +461,376 @@ public RangesAtEndpoint getLocalRanges(ClusterMetadata cm)
                 return newRanges;
         }
     }
+
+    protected CoordinationPlan.ForWrite planForWriteInternal(ClusterMetadata metadata,
+                                                                          Keyspace keyspace,
+                                                                          ConsistencyLevel consistencyLevel,
+                                                                          Function<ClusterMetadata, ReplicaLayout.ForTokenWrite> liveAndDown,
+                                                                          ReplicaPlans.Selector selector)
+    {
+        ReplicaPlan.ForWrite plan = ReplicaPlans.forWrite(metadata, keyspace, consistencyLevel, liveAndDown, selector);
+        ResponseTracker tracker = createTrackerForWrite(consistencyLevel, plan, plan.pending, metadata);
+        return new CoordinationPlan.ForWrite(plan, tracker);
+    }
+
+    public CoordinationPlan.ForWriteWithIdeal planForWrite(ClusterMetadata metadata,
+                                                           Keyspace keyspace,
+                                                           ConsistencyLevel consistencyLevel,
+                                                           Function<ClusterMetadata, ReplicaLayout.ForTokenWrite> liveAndDown,
+                                                           ReplicaPlans.Selector selector)
+    {
+        CoordinationPlan.ForWrite actual = planForWriteInternal(metadata, keyspace, consistencyLevel, liveAndDown, selector);
+
+        CoordinationPlan.ForWrite ideal = null;
+        ConsistencyLevel idealCL = DatabaseDescriptor.getIdealConsistencyLevel();
+        if (idealCL != null)
+        {
+            if (idealCL == consistencyLevel)
+            {
+                ideal = actual;
+            }
+            else
+            {
+                ideal = planForWriteInternal(metadata, keyspace, idealCL, liveAndDown, selector);
+            }
+        }
+
+        return new CoordinationPlan.ForWriteWithIdeal(metadata, actual.replicas(), actual.responses(), ideal);
+    }
+
+    public CoordinationPlan.ForWriteWithIdeal planForWrite(ClusterMetadata metadata,
+                                                           Keyspace keyspace,
+                                                           ConsistencyLevel consistencyLevel,
+                                                           Token token,
+                                                           ReplicaPlans.Selector selector)
+    {
+        return planForWrite(metadata, keyspace, consistencyLevel,
+                            (newClusterMetadata) -> ReplicaLayout.forTokenWriteLiveAndDown(newClusterMetadata, keyspace, token), selector);
+    }
+
+    /**
+     * Create coordination plan for forwarding a counter write to the leader replica.
+     *
+     * In cases where the original coordinator is not a replica of the counter key, the counter
+     * mutation is forwarded to a leader replica that will coordinate the actual counter update.
+     */
+    public CoordinationPlan.ForWrite planForForwardingCounterWrite(ClusterMetadata metadata,
+                                                                   Keyspace keyspace,
+                                                                   Token token,
+                                                                   Function<ClusterMetadata, Replica> replicaSupplier)
+    {
+        ReplicaPlan.ForWrite plan = ReplicaPlans.forSingleReplicaWrite(metadata, keyspace, token, replicaSupplier);
+        ResponseTracker tracker = createTrackerForWrite(plan.consistencyLevel(), plan, plan.pending, metadata);
+
+        return new CoordinationPlan.ForWriteWithIdeal(metadata, plan, tracker, null);
+    }
+
+    /**
+     * Create coordination plan for replaying a mutation from the batchlog.
+     *
+     * When recovering failed batches, mutations are replayed to remote replicas only
+     * (local replica is handled separately). This method creates a replica plan
+     * targeting live remote replicas with CL.ONE, and a response tracker that waits on
+     * all contacts
+     */
+    public CoordinationPlan.ForWriteWithIdeal planForReplayMutation(ClusterMetadata metadata,
+                                                                    Keyspace keyspace,
+                                                                    Token token)
+    {
+        Preconditions.checkState(!replicationType.isTracked(), "Batch replay not supported with tracked keyspaces");
+
+        ReplicaPlan.ForWrite plan = ReplicaPlans.forReplayMutation(metadata, keyspace, token);
+
+        // wait until all contacts respond
+        int blockFor = plan.contacts().size();
+        ResponseTracker tracker = new SimpleResponseTracker(blockFor, blockFor);
+
+        return new CoordinationPlan.ForWriteWithIdeal(metadata, plan, tracker, null);
+    }
+
+    /**
+     * Create coordination plan for a single-partition token read.
+     */
+    public CoordinationPlan.ForTokenRead planForTokenRead(ClusterMetadata metadata,
+                                                          Keyspace keyspace,
+                                                          TableId tableId,
+                                                          Token token,
+                                                          @Nullable Index.QueryPlan indexQueryPlan,
+                                                          ConsistencyLevel consistencyLevel,
+                                                          SpeculativeRetryPolicy retry,
+                                                          ReadCoordinator coordinator)
+    {
+        ReplicaPlan.ForTokenRead plan = ReplicaPlans.forRead(metadata, keyspace, tableId, token, indexQueryPlan, consistencyLevel, retry, coordinator);
+        ReplicaPlan.SharedForTokenRead shared = ReplicaPlan.shared(plan);
+        ResponseTracker tracker = createTrackerForRead(plan);
+        return new CoordinationPlan.ForTokenRead(shared, tracker);
+    }
+
+    /**
+     * Create coordination plan for a range read.
+     */
+    public CoordinationPlan.ForRangeRead planForRangeRead(ClusterMetadata metadata,
+                                                          Keyspace keyspace,
+                                                          TableId tableId,
+                                                          @Nullable Index.QueryPlan indexQueryPlan,
+                                                          ConsistencyLevel consistencyLevel,
+                                                          AbstractBounds<PartitionPosition> range,
+                                                          int vnodeCount)
+    {
+        ReplicaPlan.ForRangeRead plan = ReplicaPlans.forRangeRead(metadata, keyspace, tableId, indexQueryPlan, consistencyLevel, range, vnodeCount, true);
+        ReplicaPlan.SharedForRangeRead shared = ReplicaPlan.shared(plan);
+        ResponseTracker tracker = createTrackerForRead(plan);
+        return new CoordinationPlan.ForRangeRead(shared, tracker);
+    }
+
+    /**
+     * Attempt to merge two adjacent range read coordination plans into one.
+     *
+     * If the two plans share enough live endpoints to satisfy the consistency level
+     * and the merge is worthwhile returns a merged plan otherwise returns null.
+     */
+    public CoordinationPlan.ForRangeRead maybeMergeRangeReads(ClusterMetadata metadata,
+                                                               Keyspace keyspace,
+                                                               TableId tableId,
+                                                               ConsistencyLevel consistencyLevel,
+                                                               ReplicaPlan.ForRangeRead left,
+                                                               ReplicaPlan.ForRangeRead right)
+    {
+        ReplicaPlan.ForRangeRead merged = ReplicaPlans.maybeMerge(metadata, keyspace, tableId, consistencyLevel, left, right);
+        if (merged == null)
+            return null;
+
+        ReplicaPlan.SharedForRangeRead shared = ReplicaPlan.shared(merged);
+        ResponseTracker tracker = createTrackerForRead(merged);
+        return new CoordinationPlan.ForRangeRead(shared, tracker);
+    }
+
+    /**
+     * Create coordination plan for a full range read
+     */
+    public CoordinationPlan.ForRangeRead planForFullRangeRead(Keyspace keyspace,
+                                                              ConsistencyLevel consistencyLevel,
+                                                              AbstractBounds<PartitionPosition> range,
+                                                              Set<InetAddressAndPort> endpointsToContact,
+                                                              int vnodeCount)
+    {
+        ReplicaPlan.ForRangeRead plan = ReplicaPlans.forFullRangeRead(keyspace, consistencyLevel, range, endpointsToContact, vnodeCount);
+        ReplicaPlan.SharedForRangeRead shared = ReplicaPlan.shared(plan);
+        ResponseTracker tracker = createTrackerForRead(plan);
+        return new CoordinationPlan.ForRangeRead(shared, tracker);
+    }
+
+    /**
+     * Create coordination plan for a single-replica token read.
+     */
+    public CoordinationPlan.ForTokenRead planForSingleReplicaTokenRead(Keyspace keyspace, Token token, Replica replica)
+    {
+        ReplicaPlan.ForTokenRead plan = ReplicaPlans.forSingleReplicaRead(keyspace, token, replica);
+        ReplicaPlan.SharedForTokenRead shared = ReplicaPlan.shared(plan);
+        ResponseTracker tracker = createTrackerForRead(plan);
+        return new CoordinationPlan.ForTokenRead(shared, tracker);
+    }
+
+    /**
+     * Create coordination plan for a single-replica range read.
+     *
+     * Used by short read protection to fetch additional partitions from a
+     * specific replica. blockFor=1, totalReplicas=1.
+     */
+    public CoordinationPlan.ForRangeRead planForSingleReplicaRangeRead(Keyspace keyspace,
+                                                                       AbstractBounds<PartitionPosition> range,
+                                                                       Replica replica,
+                                                                       int vnodeCount)
+    {
+        ReplicaPlan.ForRangeRead plan = ReplicaPlans.forSingleReplicaRead(keyspace, range, replica, vnodeCount);
+        ReplicaPlan.SharedForRangeRead shared = ReplicaPlan.shared(plan);
+        ResponseTracker tracker = createTrackerForRead(plan);
+        return new CoordinationPlan.ForRangeRead(shared, tracker);
+    }
+
+    /**
+     * Create ResponseTracker for read operation.
+     */
+    public <E extends Endpoints<E>, P extends ReplicaPlan.ForRead<E, P>> ResponseTracker createTrackerForRead(P plan)
+    {
+        int blockFor = plan.readQuorum();
+
+        // Use candidates.size() for totalReplicas to allow for speculation
+        // (speculation can contact additional candidates beyond initial contacts)
+        int totalReplicas = plan.readCandidates().size();
+
+        return new SimpleResponseTracker(blockFor, totalReplicas);
+    }
+
+    public Paxos.Participants paxosParticipants(ClusterMetadata metadata,
+                                                TableMetadata table,
+                                                Token token,
+                                                ConsistencyLevel consistencyForConsensus,
+                                                Predicate<Replica> isReplicaAlive)
+    {
+
+        KeyspaceMetadata keyspaceMetadata = metadata.schema.getKeyspaceMetadata(table.keyspace);
+        // MetaStrategy distributes the entire keyspace to all replicas. In addition, its tables (currently only
+        // the dist log table) don't use the globally configured partitioner. For these reasons we don't lookup the
+        // replicas using the supplied token as this can actually be of the incorrect type (for example when
+        // performing Paxos repair).
+        final Token actualToken = table.partitioner == MetaStrategy.partitioner ? MetaStrategy.entireRange.right : token;
+        ReplicaLayout.ForTokenWrite all = forTokenWriteLiveAndDown(keyspaceMetadata, actualToken);
+        ReplicaLayout.ForTokenWrite electorate = consistencyForConsensus.isDatacenterLocal()
+                                                 ? all.filter(InOurDc.replicas()) : all;
+
+        EndpointsForToken live = all.all().filter(isReplicaAlive);
+        return new Paxos.Participants(metadata.epoch, Keyspace.open(table.keyspace), consistencyForConsensus, all, electorate, live,
+                                      (cm) -> Paxos.Participants.get(cm, table, actualToken, consistencyForConsensus));
+    }
+
+    /**
+     * Hook for replication strategies to send additional mutations alongside a paxos commit.
+     * Called from PaxosCommit.start() after local synchronous execution for tracked keyspaces.
+     *
+     * If the method doesn't return null, the returned future is composed with the paxos consensus:
+     * onDone fires only after both the paxos quorum decision AND this future complete, or after
+     * one of them fails.
+     */
+    public Future<Void> sendPaxosCommitMutations(Agreed commit, boolean isUrgent)
+    {
+        return null;
+    }
+
+    /**
+     * Check whether paxos operations should be rejected for the given token.
+     */
+    public boolean shouldRejectPaxos(Token token)
+    {
+        return false;
+    }
+
+    /**
+     * Create ResponseTracker for write operation based on consistency level.
+     */
+    @VisibleForTesting
+    public ResponseTracker createTrackerForWrite(ConsistencyLevel cl, ReplicaPlan.ForWrite plan, Endpoints<?> pending, ClusterMetadata metadata)
+    {
+        switch (cl)
+        {
+            case ANY:
+            case ONE:
+            case TWO:
+            case THREE:
+            case QUORUM:
+            case ALL:
+                int totalContacts = plan.contacts().size();
+                if (pending.isEmpty())
+                {
+                    int blockFor = cl.blockFor(this);
+                    return new SimpleResponseTracker(blockFor, totalContacts);
+                }
+                else
+                {
+                    // Check if double count model applies (some CLs like ANY don't add pending)
+                    int baseBlockFor = cl.blockFor(this);
+                    int totalBlockFor = cl.blockForWrite(this, pending);
+
+                    // If totalBlockFor == baseBlockFor, no double-count needed (e.g., ANY)
+                    if (totalBlockFor == baseBlockFor)
+                        return new SimpleResponseTracker(baseBlockFor, totalContacts);
+
+                    // Double count model: committed must satisfy base CL, total must include pending
+                    int pendingReplicas = pending.size();
+                    // contacts() includes both committed and pending replicas
+                    int committedReplicas = totalContacts - pendingReplicas;
+                    return new WriteResponseTracker(baseBlockFor, totalBlockFor,
+                                                    committedReplicas, pendingReplicas,
+                                                    endpoint -> pending.endpoints().contains(endpoint));
+                }
+
+            case LOCAL_ONE:
+            case LOCAL_QUORUM:
+                int localContacts = plan.contacts().filter(InOurDc.replicas()).size();
+                if (pending.isEmpty())
+                {
+                    int localBlockFor = cl.blockFor(this);
+                    return new SimpleResponseTracker(localBlockFor, localContacts, InOurDc.endpoints());
+                }
+                else
+                {
+                    // Check if double count model applies (depends on local pending)
+                    int baseBlockFor = cl.blockFor(this);
+                    int totalBlockFor = cl.blockForWrite(this, pending);
+
+                    // If totalBlockFor == baseBlockFor, no local pending so no double-count needed
+                    if (totalBlockFor == baseBlockFor)
+                        return new SimpleResponseTracker(baseBlockFor, localContacts, InOurDc.endpoints());
+
+                    // Double count model for local DC
+                    int localPending = pending.count(InOurDc.replicas());
+                    // localContacts includes both committed and pending in local DC
+                    int localCommitted = localContacts - localPending;
+                    return new WriteResponseTracker(baseBlockFor, totalBlockFor,
+                                                    localCommitted, localPending,
+                                                    endpoint -> pending.endpoints().contains(endpoint),
+                                                    InOurDc.endpoints());
+                }
+
+            case EACH_QUORUM:
+                return createPerDcTracker(plan, pending, metadata);
+
+            default:
+                throw new UnsupportedOperationException("Unsupported consistency level for writes: " + cl);
+        }
+    }
+
+    /**
+     * Create per-datacenter tracker for EACH_QUORUM.
+     */
+    private ResponseTracker createPerDcTracker(ReplicaPlan.ForWrite plan, Endpoints<?> pending, ClusterMetadata metadata)
+    {
+        Map<String, ResponseTracker> trackerPerDc = new HashMap<>();
+        Locator locator = metadata.locator;
+
+        // Group replicas by datacenter
+        Map<String, List<Replica>> replicasByDc = new HashMap<>();
+        for (Replica replica : plan.contacts())
+        {
+            String dc = locator.location(replica.endpoint()).datacenter;
+            replicasByDc.computeIfAbsent(dc, k -> new ArrayList<>()).add(replica);
+        }
+
+        // Group pending replicas by datacenter
+        Map<String, List<Replica>> pendingByDc = new HashMap<>();
+        for (Replica replica : pending)
+        {
+            String dc = locator.location(replica.endpoint()).datacenter;
+            pendingByDc.computeIfAbsent(dc, k -> new ArrayList<>()).add(replica);
+        }
+
+        // Create tracker for each DC
+        for (Map.Entry<String, List<Replica>> entry : replicasByDc.entrySet())
+        {
+            String dc = entry.getKey();
+            int dcContacts = entry.getValue().size();
+            List<Replica> dcPending = pendingByDc.getOrDefault(dc, Collections.emptyList());
+            int dcPendingCount = dcPending.size();
+            int dcCommitted = dcContacts - dcPendingCount;
+            int dcBlockFor = dcCommitted / 2 + 1;

Review Comment:
   Is it also possible this quorum computation is incorrect since replicasByDc is computed from plan.contacts() which has already done some filtering? This would also indicate missing tests.



##########
src/java/org/apache/cassandra/locator/SatelliteReplicationStrategy.java:
##########
@@ -513,4 +631,1286 @@ public boolean hasSameSettings(AbstractReplicationStrategy other)
                primaryDC.equals(otherStrategy.primaryDC) &&
                disabledDCs.equals(otherStrategy.disabledDCs);
     }
+
+    private AbstractReplicationStrategy strategy(ClusterMetadata metadata)
+    {
+        return metadata.schema.getKeyspaceMetadata(keyspaceName).replicationStrategy;
+    }
+
+    static abstract class CoordinationPlanner<E extends Endpoints<E>, L extends ReplicaLayout<E>, P extends ReplicaPlan<E, P>>
+    {
+        final ClusterMetadata metadata;
+        final Keyspace keyspace;
+        final ConsistencyLevel cl;
+        final SatelliteReplicationStrategy strategy;
+        final String primary;
+        final String satellite;
+        final List<String> dcs;
+        final L fullLayout;
+        final L liveLayout;
+        final Map<String, L> fullLayouts;
+        final Map<String, L> liveLayouts;
+        final Map<String, E> fullEndpoints;
+        final Map<String, E> liveEndpoints;
+
+        public CoordinationPlanner(ClusterMetadata metadata, Keyspace keyspace, ConsistencyLevel cl, SatelliteReplicationStrategy strategy, String primary, L fullLayout)
+        {
+            this.metadata = metadata;
+            this.keyspace = keyspace;
+            this.cl = cl;
+            this.strategy = strategy;
+            this.primary = primary;
+            this.satellite = strategy.getSatelliteForDC(primary);
+
+            this.dcs = createDcList();
+            Preconditions.checkState(!dcs.isEmpty(), "No DCs available for request (primary=%s, all disabled?)", primary);
+
+            Set<String> dcSet = new HashSet<>(dcs);
+            this.fullLayout = filterLayout(fullLayout, rp -> dcSet.contains(metadata.locator.location(rp.endpoint()).datacenter));
+            this.liveLayout = filterLayout(this.fullLayout, FailureDetector.isReplicaAlive);
+
+            this.fullLayouts = Maps.newHashMapWithExpectedSize(dcs.size());
+            this.liveLayouts = Maps.newHashMapWithExpectedSize(dcs.size());
+
+            this.fullEndpoints = Maps.newHashMapWithExpectedSize(dcs.size());
+            this.liveEndpoints = Maps.newHashMapWithExpectedSize(dcs.size());
+
+            populateDcLayouts();
+        }
+
+        AbstractReplicationStrategy strategy(ClusterMetadata metadata)
+        {
+            return metadata.schema.getKeyspaceMetadata(keyspace.getName()).replicationStrategy;
+        }
+
+        List<String> createDcList()
+        {
+            List<String> results = new ArrayList<>();
+            if (!strategy.disabledDCs.contains(primary))
+                results.add(primary);
+
+            if (satellite != null && !strategy.disabledDCs.contains(satellite))
+                results.add(satellite);
+
+            for (String dc : strategy.fullDCs.keySet())
+            {
+                if (dc.equals(primary) || dc.equals(satellite) || strategy.disabledDCs.contains(dc))
+                    continue;
+                results.add(dc);
+            }
+
+            Preconditions.checkState(!results.isEmpty());
+            return results;
+        }
+
+        void populateDcLayouts()
+        {
+            for (String dc : dcs)
+            {
+                Predicate<Replica> dcFilter = rp -> metadata.locator.location(rp.endpoint()).datacenter.equals(dc);
+                L full = filterLayout(fullLayout, dcFilter);
+                L live = filterLayout(liveLayout, dcFilter);
+
+                fullLayouts.put(dc, full);
+                liveLayouts.put(dc, live);
+
+                fullEndpoints.put(dc, full.natural());
+                liveEndpoints.put(dc, live.natural());
+            }
+
+            if (ADDL_CHECKS_ENABLED)
+                Preconditions.checkState(fullLayouts.size() == dcs.size(), "populateDcLayouts: fullLayouts.size()=%s != dcs.size()=%s", fullLayouts.size(), dcs.size());
+        }
+
+        ResponseTrackerBuilder<E, L, P> createResponseTrackerBuilder()
+        {
+            switch (cl)
+            {
+                case ONE:
+                case LOCAL_ONE:
+                case NODE_LOCAL:
+                case ANY:
+                case TWO:
+                case THREE:
+                case ALL:
+                    return new ResponseTrackerBuilder.Simple<>(this);
+
+                case QUORUM:
+                case LOCAL_QUORUM:
+                    // LOCAL_QUORUM an QUORUM mean the same thing in SRS
+                    return new ResponseTrackerBuilder.Quorum<>(this);
+
+                case EACH_QUORUM:
+                    return new ResponseTrackerBuilder.EachQuorum<>(this);
+
+                default:
+                    throw new IllegalStateException("Unsupported consistency level: " + cl);
+            }
+        }
+
+        ResponseTracker createResponseTracker()
+        {
+            ResponseTrackerBuilder<E, L, P> builder = createResponseTrackerBuilder();
+            return builder.build();
+        }
+
+        abstract L filterLayout(L layout, Predicate<Replica> predicate);
+        abstract ResponseTracker createDcResponseTracker(String dc);
+
+        abstract P recomputePlan(ClusterMetadata metadata);
+        abstract P createReplicaPlan();
+
+        interface IndexReadExceptionCheck<E extends Endpoints<E>>
+        {
+            void maybeThrowIndexReadException(int initial, int filtered, Map<String, E> candidates, Map<InetAddressAndPort, Index.Status> indexStatusMap);
+        }
+
+        static class ForWrite extends CoordinationPlanner<EndpointsForToken, ReplicaLayout.ForTokenWrite, ReplicaPlan.ForWrite>
+        {
+            final ReplicaPlans.Selector selector;
+
+            public ForWrite(ClusterMetadata metadata, Keyspace keyspace, ConsistencyLevel cl, SatelliteReplicationStrategy strategy, String primary, ReplicaLayout.ForTokenWrite fullLayout, ReplicaPlans.Selector selector)
+            {
+                super(metadata, keyspace, cl, strategy, primary, fullLayout);
+                this.selector = selector;
+                if (ADDL_CHECKS_ENABLED)
+                {
+                    Preconditions.checkState(!strategy.disabledDCs.contains(primary));
+                }
+            }
+
+            @Override
+            ResponseTracker createDcResponseTracker(String dc)
+            {
+                // this basically hardcodes ReplicaPlans.writeAll
+                Predicate<InetAddressAndPort> dcFilter = ep -> metadata.locator.location(ep).datacenter.equals(dc);
+
+                ReplicaLayout.ForTokenWrite dcLayout = fullLayouts.get(dc);
+                Preconditions.checkNotNull(dcLayout);
+
+                int blockFor = strategy.calculateQuorum(dc);
+                int totalContacts = dcLayout.all().size();
+
+                // Only count pending replicas that are actually in the selected contacts
+                int pendingInDc = dcLayout.pending().size();
+
+                if (pendingInDc == 0)
+                {
+                    return new SimpleResponseTracker(blockFor, totalContacts, dcFilter);
+                }
+                else
+                {
+                    int committedContacts = totalContacts - pendingInDc;
+                    int totalBlockFor = blockFor + pendingInDc;
+                    Predicate<InetAddressAndPort> isPending = ep -> dcLayout.pending.endpoints().contains(ep);
+                    return new WriteResponseTracker(blockFor, totalBlockFor,
+                                                    committedContacts, pendingInDc,
+                                                    isPending, dcFilter);
+                }
+            }
+
+            @Override
+            ReplicaPlan.ForWrite recomputePlan(ClusterMetadata metadata)
+            {
+                Token token = fullLayout.token();
+                return strategy(metadata).planForWrite(metadata, keyspace, cl,
+                                                              cm -> ReplicaLayout.forTokenWriteLiveAndDown(cm, keyspace, token),
+                                                       selector).replicas();
+            }
+
+            @Override
+            ReplicaLayout.ForTokenWrite filterLayout(ReplicaLayout.ForTokenWrite layout, Predicate<Replica> predicate)
+            {
+                return layout.filter(predicate);
+            }
+
+            private ReplicaLayout.ForTokenWrite mergeLayouts(Collection<ReplicaLayout.ForTokenWrite> layouts)
+            {
+                ReplicaCollection.Builder<EndpointsForToken> naturalBuilder = null;
+                ReplicaCollection.Builder<EndpointsForToken> pendingBuilder = null;
+
+                for (ReplicaLayout.ForTokenWrite layout : layouts)
+                {
+                    EndpointsForToken natural = layout.natural();
+                    EndpointsForToken pending = layout.pending();
+                    if (naturalBuilder == null)
+                    {
+                        naturalBuilder = natural.newBuilder(natural.size());
+                        pendingBuilder = pending.newBuilder(pending.size());
+                    }
+
+                    naturalBuilder.addAll(natural);
+                    pendingBuilder.addAll(pending);
+                }
+
+                return new ReplicaLayout.ForTokenWrite(strategy, naturalBuilder.build(), pendingBuilder.build());
+            }
+
+            @Override
+            ReplicaPlan.ForWrite createReplicaPlan()
+            {
+
+                ReplicaLayout.ForTokenWrite fullLayout = mergeLayouts(fullLayouts.values());
+                ReplicaLayout.ForTokenWrite liveLayout = mergeLayouts(liveLayouts.values());
+                EndpointsForToken contacts = selector.select(cl, fullLayout, liveLayout);
+
+                return new ReplicaPlan.ForWrite(keyspace,
+                                                strategy,
+                                                cl,
+                                                fullLayout.pending(),
+                                                fullLayout.all(),
+                                                liveLayout.all(),
+                                                contacts,
+                                                this::recomputePlan,
+                                                metadata.epoch);
+            }
+        }
+
+        static abstract class ForRead<E extends Endpoints<E>, L extends ReplicaLayout<E>, P extends ReplicaPlan.ForRead<E, P>> extends CoordinationPlanner<E, L, P>
+        {
+            final @Nullable Index.QueryPlan indexQueryPlan;
+            final boolean alwaysSpeculate;
+            final boolean throwOnInsufficientLiveReplicas;
+
+            public ForRead(ClusterMetadata metadata, Keyspace keyspace, ConsistencyLevel cl, SatelliteReplicationStrategy strategy, String primary, L fullLayout, @Nullable Index.QueryPlan indexQueryPlan, boolean alwaysSpeculate, boolean throwOnInsufficientLiveReplicas)
+            {
+                super(metadata, keyspace, cl, strategy, primary, fullLayout);
+                this.indexQueryPlan = indexQueryPlan;
+                this.alwaysSpeculate = alwaysSpeculate;
+                this.throwOnInsufficientLiveReplicas = throwOnInsufficientLiveReplicas;
+            }
+
+            @Override
+            ResponseTracker createDcResponseTracker(String dc)
+            {
+                Predicate<InetAddressAndPort> dcFilter = ep -> metadata.locator.location(ep).datacenter.equals(dc);
+
+                L dcLayout = fullLayouts.get(dc);
+                int blockFor = strategy.calculateQuorum(dc);
+                int totalContacts = dcLayout.all().size();
+
+                return new SimpleResponseTracker(blockFor, totalContacts, dcFilter);
+            }
+
+            Map<String, E> candidates(IndexReadExceptionCheck<E> check)
+            {
+                if (indexQueryPlan == null)
+                    return liveEndpoints;
+
+                Map<String, E> candidates = new HashMap<>();
+                Map<InetAddressAndPort, Index.Status> indexStatusMap = new HashMap<>();
+                int initial = 0;
+                int filtered = 0;
+                for (Map.Entry<String, E> entry : liveEndpoints.entrySet())
+                {
+                    E liveEndpoints = entry.getValue();
+                    E queryableEndpoints = IndexStatusManager.instance.filterForQuery(liveEndpoints, keyspace.getName(), indexQueryPlan, indexStatusMap);
+                    initial += liveEndpoints.size();
+                    filtered += queryableEndpoints.size();
+                    candidates.put(entry.getKey(), queryableEndpoints);
+                }
+
+                if (initial != filtered)
+                    check.maybeThrowIndexReadException(initial, filtered, candidates, indexStatusMap);
+
+                return candidates;
+            }
+
+            @Override
+            ResponseTracker createResponseTracker()
+            {
+                ResponseTracker tracker = super.createResponseTracker();
+
+                // read trackers don't wait on pending nodes
+                Preconditions.checkState(tracker.pendingContacts() == 0);
+                return tracker;
+            }
+
+
+            abstract P replicaPlanBuilder(E candidates, E contacts, E liveAndDown, int quorumSize);
+
+            ReadReplicaPlanBuilder<E, L, P> replicaPlanBuilder()
+            {
+                switch (cl)
+                {
+                    case ONE:
+                    case LOCAL_ONE:
+                    case NODE_LOCAL:
+                    case ANY:
+                    case TWO:
+                    case THREE:
+                    case ALL:
+                        return new ReadReplicaPlanBuilder.Simple<>(this);
+
+                    case QUORUM:
+                    case LOCAL_QUORUM:
+                    case EACH_QUORUM:
+                        return new ReadReplicaPlanBuilder.DcQuorum<>(this);
+
+                    default:
+                        throw new IllegalStateException("Unsupported consistency level: " + cl);
+                }
+            }
+
+            @Override
+            P createReplicaPlan()
+            {
+                ReadReplicaPlanBuilder<E, L, P> builder = replicaPlanBuilder();
+                return builder.build();
+            }
+        }
+
+        static class ForTokenRead extends ForRead<EndpointsForToken, ReplicaLayout.ForTokenRead, ReplicaPlan.ForTokenRead>
+        {
+            static final Function<ReplicaPlan<?, ?>, ReplicaPlan.ForWrite> READ_REPAIR_PLAN = (ReplicaPlan<?, ?> self) -> {throw new IllegalStateException("Cannot create read repair plans for SatelliteDatacenterReplicationStrategy");};
+            final Token token;
+            final TableId tableId;
+            final SpeculativeRetryPolicy retry;
+            final ReadCoordinator coordinator;
+
+            public ForTokenRead(ClusterMetadata metadata, Keyspace keyspace, Token token, ConsistencyLevel cl, SatelliteReplicationStrategy strategy, String primary, ReplicaLayout.ForTokenRead fullLayout, @Nullable Index.QueryPlan indexQueryPlan, SpeculativeRetryPolicy retry, boolean throwOnInsufficientLiveReplicas, TableId tableId, ReadCoordinator coordinator)
+            {
+                super(metadata, keyspace, cl, strategy, primary, fullLayout, indexQueryPlan, retry == AlwaysSpeculativeRetryPolicy.INSTANCE, throwOnInsufficientLiveReplicas);
+                this.token = token;
+                this.tableId = tableId;
+                this.retry = retry;
+                this.coordinator = coordinator;
+            }
+
+            @Override
+            ReplicaLayout.ForTokenRead filterLayout(ReplicaLayout.ForTokenRead layout, Predicate<Replica> predicate)
+            {
+                return layout.filter(predicate);
+            }
+
+            @Override
+            ReplicaPlan.ForTokenRead recomputePlan(ClusterMetadata metadata)
+            {
+                return strategy(metadata).planForTokenRead(metadata, keyspace, tableId, token,
+                                                           indexQueryPlan, cl, retry, coordinator).replicas();
+            }
+
+            @Override
+            ReplicaPlan.ForTokenRead replicaPlanBuilder(EndpointsForToken candidates, EndpointsForToken contacts, EndpointsForToken liveAndDown, int quorumSize)
+            {
+                return new ReplicaPlan.ForTokenRead(keyspace, strategy, cl,
+                                                    candidates, contacts, liveAndDown,
+                                                    this::recomputePlan,
+                                                    READ_REPAIR_PLAN,
+                                                    metadata.epoch,
+                                                    quorumSize);
+            }
+        }
+
+        static class ForRangeRead extends ForRead<EndpointsForRange, ReplicaLayout.ForRangeRead, ReplicaPlan.ForRangeRead>
+        {
+            static final BiFunction<ReplicaPlan<?, ?>, Token, ReplicaPlan.ForWrite> READ_REPAIR_PLAN = (ReplicaPlan<?, ?> self, Token token) -> {throw new IllegalStateException("Cannot create read repair plans for SatelliteDatacenterReplicationStrategy");};
+            final AbstractBounds<PartitionPosition> range;
+            final int vnodeCount;
+            final TableId tableId;
+
+            public ForRangeRead(ClusterMetadata metadata, Keyspace keyspace, AbstractBounds<PartitionPosition> range, int vnodeCount, ConsistencyLevel cl, SatelliteReplicationStrategy strategy, String primary, ReplicaLayout.ForRangeRead fullLayout, @Nullable Index.QueryPlan indexQueryPlan, boolean throwOnInsufficientLiveReplicas, TableId tableId)
+            {
+                super(metadata, keyspace, cl, strategy, primary, fullLayout, indexQueryPlan, false, throwOnInsufficientLiveReplicas);
+                this.range = range;
+                this.vnodeCount = vnodeCount;
+                this.tableId = tableId;
+            }
+
+            @Override
+            ReplicaLayout.ForRangeRead filterLayout(ReplicaLayout.ForRangeRead layout, Predicate<Replica> predicate)
+            {
+                return layout.filter(predicate);
+            }
+
+            @Override
+            ReplicaPlan.ForRangeRead recomputePlan(ClusterMetadata metadata)
+            {
+                return strategy(metadata).planForRangeRead(metadata, keyspace, tableId,
+                                                           indexQueryPlan, cl, range, vnodeCount).replicas();
+            }
+
+            @Override
+            ReplicaPlan.ForRangeRead replicaPlanBuilder(EndpointsForRange candidates, EndpointsForRange contacts, EndpointsForRange liveAndDown, int quorumSize)
+            {
+                return new ReplicaPlan.ForRangeRead(keyspace, strategy, cl, range,
+                                                    candidates, contacts, liveAndDown, vnodeCount,
+                                                    this::recomputePlan,
+                                                    READ_REPAIR_PLAN,
+                                                    metadata.epoch,
+                                                    quorumSize);
+            }
+        }
+    }
+
+    static abstract class ResponseTrackerBuilder<E extends Endpoints<E>, L extends ReplicaLayout<E>, P extends ReplicaPlan<E, P>>
+    {
+        final CoordinationPlanner<E, L, P> planner;
+
+        public ResponseTrackerBuilder(CoordinationPlanner<E, L, P> planner)
+        {
+            this.planner = planner;
+        }
+
+        Map<String, ResponseTracker> responseTrackersForPrimary()
+        {
+            Map<String, ResponseTracker> result = Maps.newHashMapWithExpectedSize(planner.dcs.size());
+
+            for (String dc : planner.dcs)
+                result.put(dc, planner.createDcResponseTracker(dc));
+
+            return result;
+        }
+
+        abstract ResponseTracker aggregateTrackers(Map<String, ResponseTracker> dcTrackers);
+
+        ResponseTracker build()
+        {
+            Map<String, ResponseTracker> dcTrackers = responseTrackersForPrimary();
+            return aggregateTrackers(dcTrackers);
+        }
+
+        static class Simple<E extends Endpoints<E>, L extends ReplicaLayout<E>, P extends ReplicaPlan<E, P>> extends ResponseTrackerBuilder<E, L, P>
+        {
+            public Simple(CoordinationPlanner<E, L, P> planner)
+            {
+                super(planner);
+            }
+
+            @Override
+            ResponseTracker aggregateTrackers(Map<String, ResponseTracker> dcTrackers)
+            {
+                int totalContacts = 0;
+                int totalPending = 0;
+
+
+                List<ResponseTracker> trackers = Lists.newArrayList();
+                Set<String> blockingDatacenters = Sets.newHashSet();
+
+                for (Map.Entry<String, ResponseTracker> entry : dcTrackers.entrySet())
+                {
+                    String dc = entry.getKey();
+                    ResponseTracker tracker = entry.getValue();
+                    if (planner.cl == ConsistencyLevel.ALL || dc.equals(planner.primary) || dc.equals(planner.satellite))
+                    {
+                        totalContacts += tracker.totalContacts();
+                        totalPending += tracker.pendingContacts();
+                        trackers.add(tracker);
+                        blockingDatacenters.add(dc);
+                    }
+                }
+
+                Predicate<InetAddressAndPort> filter = i -> {
+                    String dc = planner.metadata.locator.location(i).datacenter;
+                    return dc != null && blockingDatacenters.contains(dc);
+                };
+
+                Preconditions.checkState(!trackers.isEmpty(), "No blocking DCs for CL %s (primary=%s, satellite=%s)", planner.cl, planner.primary, planner.satellite);
+
+
+                if (planner.cl == ConsistencyLevel.ALL)
+                    return new SimpleResponseTracker(totalContacts, totalContacts, filter);
+
+                int blockFor = planner.cl.blockFor(planner.strategy);
+                int totalBlockFor = blockFor + totalPending;
+
+                Preconditions.checkState(blockFor <= totalContacts, "Simple tracker: blockFor=%s > totalContacts=%s for CL %s", blockFor, totalContacts, planner.cl);
+
+
+                if (totalPending == 0)
+                    return new SimpleResponseTracker(blockFor, totalContacts, filter);
+
+                Predicate<InetAddressAndPort> pending = i -> {
+                    for (ResponseTracker tracker : trackers)
+                    {
+                        if (tracker.isPending(i))
+                            return true;
+                    }
+                    return false;
+                };
+                return new WriteResponseTracker(blockFor, totalBlockFor, totalContacts, totalPending, pending, filter);
+            }
+        }
+
+        static class Quorum<E extends Endpoints<E>, L extends ReplicaLayout<E>, P extends ReplicaPlan<E, P>> extends ResponseTrackerBuilder<E, L, P>
+        {
+            public Quorum(CoordinationPlanner<E, L, P> planner)
+            {
+                super(planner);
+            }
+
+            @Override
+            ResponseTracker aggregateTrackers(Map<String, ResponseTracker> dcTrackers)
+            {
+                return new CompositeTracker(CompositeTracker.quorum(dcTrackers.size()), new ArrayList<>(dcTrackers.values()));
+            }
+        }
+
+        static class EachQuorum<E extends Endpoints<E>, L extends ReplicaLayout<E>, P extends ReplicaPlan<E, P>> extends ResponseTrackerBuilder<E, L, P>
+        {
+            public EachQuorum(CoordinationPlanner<E, L, P> planner)
+            {
+                super(planner);
+            }
+
+            @Override
+            ResponseTracker aggregateTrackers(Map<String, ResponseTracker> dcTrackers)
+            {
+                return new CompositeTracker(CompositeTracker.all(dcTrackers.size()), new ArrayList<>(dcTrackers.values()));
+            }
+        }
+    }
+
+    static abstract class ReadReplicaPlanBuilder<E extends Endpoints<E>, L extends ReplicaLayout<E>, P extends ReplicaPlan.ForRead<E, P>>
+            implements CoordinationPlanner.IndexReadExceptionCheck<E>
+    {
+
+        private static <E extends Endpoints<E>> Iterable<Replica> removedSupplier(Map<String, E> initial, Map<String, E> filtered)
+        {
+            return () -> initial.keySet().stream().map(dc -> {
+                Set<InetAddressAndPort> f =  filtered.containsKey(dc) ? filtered.get(dc).endpoints() : Collections.emptySet();
+                return initial.get(dc).without(f);
+            }).flatMap(AbstractReplicaCollection::stream).iterator();
+        }
+
+        final CoordinationPlanner.ForRead<E, L, P> planner;
+
+        public ReadReplicaPlanBuilder(CoordinationPlanner.ForRead<E, L, P> planner)
+        {
+            this.planner = planner;
+        }
+
+        abstract boolean canSelectReplicasFromDc(String dc, int liveNodes);
+        abstract int minBlockFor();
+        abstract int eligibleCandidates(Map<String, E> candidates);
+
+        abstract boolean haveSufficientContacts(int contactedReplicas, int contactedDcs);
+        abstract boolean haveSufficientContactsForDc(String dc, int contactedReplicas, int contactedDcs, int dcContacts);
+        abstract boolean haveSufficientLiveNodes(int contactedReplicas, int contactedDcs);
+
+        private E allLiveAndDown()
+        {
+            E fullEndpoints = planner.fullLayout.all();
+            ReplicaCollection.Builder<E> fullBuilder = fullEndpoints.newBuilder(fullEndpoints.size());
+
+            for (String dc : planner.dcs)
+            {
+                E endpoints = planner.fullEndpoints.get(dc);
+                if (endpoints == null)
+                    continue;
+
+                fullBuilder.addAll(endpoints);
+            }
+
+            return fullBuilder.build();
+        }
+
+        private E allCandidates(Map<String, E> candidates)
+        {
+            E liveEndpoints = planner.liveLayout.all();
+            ReplicaCollection.Builder<E> candidateBuilder = liveEndpoints.newBuilder(liveEndpoints.size());
+
+            for (String dc : planner.dcs)
+            {
+                // TODO: make sure the full replica is added if this is the dc it's from
+                E dcCandidates = candidates.get(dc);
+                if (dcCandidates == null || !canSelectReplicasFromDc(dc, dcCandidates.size()))
+                    continue;
+
+                candidateBuilder.addAll(dcCandidates);
+            }
+
+            return candidateBuilder.build();
+        }
+
+        private E contacts(Map<String, E> candidates)
+        {
+            E liveEndpoints = planner.liveLayout.all();
+
+            ReplicaCollection.Builder<E> contactBuilder = liveEndpoints.newBuilder(liveEndpoints.size());
+
+            // find full replica
+            Replica fullReplica = null;
+            String fullDc = null;
+            for (String dc : planner.dcs)
+            {
+                if (fullReplica != null)
+                    break;
+
+                E dcCandidates = candidates.get(dc);
+                if (dcCandidates == null || !canSelectReplicasFromDc(dc, dcCandidates.size()))
+                    continue;
+
+                for (Replica replica : dcCandidates)
+                {
+                    if (replica.isFull())
+                    {
+                        fullReplica = replica;
+                        fullDc = dc;
+                        break;
+                    }
+                }
+            }
+
+            if (fullReplica == null)
+            {
+                // No full replicas available - throw error similar to assureSufficientLiveReplicas
+                throw UnavailableException.create(ConsistencyLevel.ONE, minBlockFor(), 1, eligibleCandidates(candidates), 0);
+            }
+
+            int totalContacts = 0;
+            int totalContactedDcs = 0;
+
+            // add dc with full replicas first
+            {
+                E dcCandidates = candidates.get(fullDc);
+                Preconditions.checkNotNull(dcCandidates);
+
+                contactBuilder.add(fullReplica);
+                totalContacts++;
+                int dcContacts = 1;
+
+                if (!haveSufficientContacts(totalContacts, totalContactedDcs))
+                {
+                    for (Replica replica : dcCandidates)
+                    {
+                        if (replica.equals(fullReplica))
+                            continue;
+
+                        if (haveSufficientContactsForDc(fullDc, totalContacts, totalContactedDcs, dcContacts))
+                            break;
+
+                        contactBuilder.add(replica);
+                        totalContacts++;
+                        dcContacts++;
+                    }
+                }
+                totalContactedDcs++;
+            }
+
+            for (String dc : planner.dcs)
+            {
+                if (dc.equals(fullDc))
+                    continue;
+
+                E dcCandidates = candidates.get(dc);
+                if (dcCandidates == null || dcCandidates.isEmpty())
+                    continue;
+
+                if (haveSufficientContacts(totalContacts, totalContactedDcs))
+                    break;
+
+                int dcContacts = 0;
+                for (Replica replica : dcCandidates)
+                {
+                    if (haveSufficientContactsForDc(dc, totalContacts, totalContactedDcs, dcContacts))
+                        break;
+
+                    contactBuilder.add(replica);
+                    totalContacts++;
+                    dcContacts++;
+                }
+
+                totalContactedDcs++;
+            }
+
+            if (planner.throwOnInsufficientLiveReplicas && !haveSufficientLiveNodes(totalContacts, totalContactedDcs))
+            {
+                int fullReplicas = 0;
+                for (String dc : planner.dcs)
+                {
+                    E dcCandidates = candidates.get(dc);
+                    if (dcCandidates == null || !canSelectReplicasFromDc(dc, dcCandidates.size()))
+                        continue;
+                    for (Replica replica : dcCandidates)
+                        if (replica.isFull())
+                            fullReplicas++;
+                }
+
+                throw UnavailableException.create(planner.cl, minBlockFor(), 1, eligibleCandidates(candidates), fullReplicas);
+            }
+
+            E result = contactBuilder.build();
+
+            if (ADDL_CHECKS_ENABLED)
+            {
+                // check for duplicate contacts
+                Set<InetAddressAndPort> seen = new HashSet<>();
+                for (Replica r : result)
+                    Preconditions.checkState(seen.add(r.endpoint()), "Duplicate contact: %s", r.endpoint());
+            }
+
+            return result;
+        }
+
+        public P build()
+        {
+            Map<String, E> candidates = planner.candidates(this);
+
+            E allEndpoints = allLiveAndDown();
+            E allCandidates = allCandidates(candidates);
+            E allContacts = contacts(candidates);
+
+            if (ADDL_CHECKS_ENABLED)
+            {
+                // contacts must be a subset of candidates
+                Set<InetAddressAndPort> candidateSet = allCandidates.endpoints();
+                for (Replica contact : allContacts)
+                    Preconditions.checkState(candidateSet.contains(contact.endpoint()), "Contact %s not in candidates", contact.endpoint());
+            }
+
+            return planner.replicaPlanBuilder(allCandidates, allContacts, allEndpoints, minBlockFor());
+        }
+
+        static class Simple<E extends Endpoints<E>, L extends ReplicaLayout<E>, P extends ReplicaPlan.ForRead<E, P>> extends ReadReplicaPlanBuilder<E, L, P>
+        {
+            private final int blockFor;
+            private final int targetContacts;
+
+            public Simple(CoordinationPlanner.ForRead<E, L, P> planner)
+            {
+                super(planner);
+
+                if (planner.cl == ConsistencyLevel.ALL)
+                {
+                    int required = 0;
+                    for (E replicas : planner.fullEndpoints.values())
+                        required += replicas.size();
+
+                    this.blockFor = required;
+                }
+                else
+                {
+                    this.blockFor = planner.cl.blockFor(planner.strategy);
+                }
+
+                this.targetContacts = planner.alwaysSpeculate ? blockFor + 1 : blockFor;
+            }
+
+            @Override
+            public void maybeThrowIndexReadException(int initial, int filtered, Map<String, E> candidates, Map<InetAddressAndPort, Index.Status> indexStatusMap)
+            {
+                if (blockFor <= initial && blockFor > filtered)
+                {
+                    IndexStatusManager.instance.readFailureException(filtered, removedSupplier(planner.fullEndpoints, candidates), indexStatusMap, planner.cl, blockFor);
+                }
+            }
+
+            @Override
+            boolean canSelectReplicasFromDc(String dc, int liveNodes)
+            {
+                return true;
+            }
+
+            @Override
+            int minBlockFor()
+            {
+                return blockFor;
+            }
+
+            @Override
+            int eligibleCandidates(Map<String, E> candidates)
+            {
+                int eligible = 0;
+                for (Map.Entry<String, E> entry : candidates.entrySet())
+                {
+                    eligible += entry.getValue().size();
+                }
+                return eligible;
+            }
+
+            @Override
+            boolean haveSufficientContacts(int contactedReplicas, int contactedDcs)
+            {
+                return contactedReplicas >= targetContacts;
+            }
+
+            @Override
+            boolean haveSufficientContactsForDc(String dc, int contactedReplicas, int contactedDcs, int dcContacts)
+            {
+                return haveSufficientContacts(contactedReplicas, contactedDcs);
+            }
+
+            @Override
+            boolean haveSufficientLiveNodes(int contactedReplicas, int contactedDcs)
+            {
+                return contactedReplicas >= blockFor;
+            }
+        }
+
+        static class DcQuorum<E extends Endpoints<E>, L extends ReplicaLayout<E>, P extends ReplicaPlan.ForRead<E, P>> extends ReadReplicaPlanBuilder<E, L, P>
+        {
+            private final int blockForDcs;
+            private final Object2IntHashMap<String> dcQuorums = new Object2IntHashMap<>(-1);
+            private boolean approvedSpeculativeReplica = false;
+
+            public DcQuorum(CoordinationPlanner.ForRead<E, L, P> planner)
+            {
+                super(planner);
+                this.blockForDcs = planner.cl != ConsistencyLevel.EACH_QUORUM ? (planner.dcs.size() / 2) + 1 : planner.dcs.size();
+                for (String dc : planner.dcs)
+                    dcQuorums.put(dc, planner.strategy.calculateQuorum(dc));
+            }
+            private int dcQuorum(String dc)
+            {
+                int v = dcQuorums.get(dc);
+                Preconditions.checkArgument(v >= 0, "dcQuorum must be >= 0");
+                return v;
+            }
+
+            @Override
+            public void maybeThrowIndexReadException(int initial, int filtered, Map<String, E> candidates, Map<InetAddressAndPort, Index.Status> indexStatusMap)
+            {
+                int required = 0;
+                int available = 0;
+                int liveDcs = 0;
+                for (Map.Entry<String, E> entry : candidates.entrySet())
+                {
+                    int dcQuorum = dcQuorum(entry.getKey());
+                    int dcLive = entry.getValue().size();
+                    if (dcLive >= dcQuorum)
+                        liveDcs++;
+
+                    required += dcQuorum;
+                    available += entry.getValue().size();
+                }
+
+                if (liveDcs <= blockForDcs)
+                {
+                    IndexStatusManager.instance.readFailureException(available, removedSupplier(planner.fullEndpoints, candidates), indexStatusMap, planner.cl, required);
+                }
+            }
+
+            @Override
+            boolean canSelectReplicasFromDc(String dc, int liveNodes)
+            {
+                int dcQuorum = dcQuorum(dc);
+                return liveNodes >= dcQuorum;
+            }
+
+            @Override
+            int minBlockFor()
+            {
+                int[] quorums = new int[planner.dcs.size()];
+                for (int i = 0; i < quorums.length; i++)
+                    quorums[i] = dcQuorum(planner.dcs.get(i));
+
+                Arrays.sort(quorums);
+
+                int blockFor = 0;
+                for (int i=0; i<blockForDcs; i++)
+                    blockFor += quorums[i];
+                return blockFor;
+            }
+
+            @Override
+            int eligibleCandidates(Map<String, E> candidates)
+            {
+                int eligible = 0;
+                for (Map.Entry<String, E> entry : candidates.entrySet())
+                {
+                    int dcLive = entry.getValue().size();
+                    int dcQuorum = dcQuorum(entry.getKey());
+                    if (dcLive >= dcQuorum)
+                        eligible += dcLive;
+                }
+
+                return eligible;
+            }
+
+            @Override
+            boolean haveSufficientContacts(int contactedReplicas, int contactedDcs)
+            {
+                return contactedDcs >= blockForDcs;
+            }
+
+            @Override
+            boolean haveSufficientContactsForDc(String dc, int contactedReplicas, int contactedDcs, int dcContacts)
+            {
+                int dcQuorum = dcQuorum(dc);
+
+                if (dcContacts < dcQuorum)
+                    return false;
+
+                if (dcContacts == dcQuorum && planner.alwaysSpeculate && !approvedSpeculativeReplica)
+                {
+                    approvedSpeculativeReplica = true;
+                    return false;
+                }
+
+                return true;
+            }
+
+            @Override
+            boolean haveSufficientLiveNodes(int contactedReplicas, int contactedDcs)
+            {
+                return contactedDcs >= blockForDcs;
+            }
+        }
+
+    }
+
+    @Override
+    protected CoordinationPlan.ForWrite planForWriteInternal(ClusterMetadata metadata,
+                                                             Keyspace keyspace,
+                                                             ConsistencyLevel consistencyLevel,
+                                                             Function<ClusterMetadata, ReplicaLayout.ForTokenWrite> liveAndDown,
+                                                             ReplicaPlans.Selector selector)
+    {
+        // writes don't need any special handling during primary failover. We just write to the current
+        // primary DC, regardless of any other failover status
+        ReplicaLayout.ForTokenWrite fullLayout = liveAndDown.apply(metadata);
+
+        CoordinationPlanner.ForWrite planner = new CoordinationPlanner.ForWrite(metadata, keyspace, consistencyLevel, this, primaryDC, fullLayout, selector);
+
+        return new CoordinationPlan.ForWrite(planner.createReplicaPlan(), planner.createResponseTracker());
+    }
+
+    /**
+     * Holds the information needed to send satellite commit mutations alongside a paxos commit.
+     * The tracker has the primary DC pre-completed, so only satellite/secondary DC quorums
+     * need actual responses.
+     */
+    static class SatelliteCommitPlan
+    {
+        final EndpointsForToken liveEndpoints;
+        final EndpointsForToken downEndpoints;
+        final ResponseTracker tracker;
+
+        SatelliteCommitPlan(EndpointsForToken liveEndpoints, EndpointsForToken downEndpoints, ResponseTracker tracker)
+        {
+            this.liveEndpoints = liveEndpoints;
+            this.downEndpoints = downEndpoints;
+            this.tracker = tracker;
+        }
+    }
+
+    SatelliteCommitPlan createSatelliteCommitPlan(ClusterMetadata metadata, Keyspace keyspace, Token token)
+    {
+        ReplicaLayout.ForTokenWrite fullLayout = ReplicaLayout.forTokenWriteLiveAndDown(metadata, keyspace, token);
+        CoordinationPlanner.ForWrite planner = new CoordinationPlanner.ForWrite(metadata, keyspace, ConsistencyLevel.QUORUM,
+                                                                             this, primaryDC, fullLayout, ReplicaPlans.writeAll);
+        ResponseTracker tracker = planner.createResponseTracker();
+
+        // paxos is handling the consensus/commit acks in the primary DC, we just need to worry about the witness DCs
+        EndpointsForToken primaryEndpoints = planner.fullEndpoints.get(primaryDC);
+        if (primaryEndpoints != null)
+        {
+            for (int i = 0; i < primaryEndpoints.size(); i++)
+                tracker.onResponse(primaryEndpoints.endpoint(i));
+        }
+
+        // Collect satellite/secondary DC endpoints from the filtered layout.
+        // planner.fullLayout is already filtered to only include DCs in the coordination plan
+        // (primary DC + its satellite + other full DCs), excluding satellites of non-primary DCs.
+        Predicate<Replica> notPrimary = rp -> !metadata.locator.location(rp.endpoint()).datacenter.equals(primaryDC);
+        EndpointsForToken allSatellite = planner.fullLayout.all().filter(notPrimary);
+        EndpointsForToken liveSatellite = allSatellite.filter(FailureDetector.isReplicaAlive);
+        EndpointsForToken downSatellite = allSatellite.filter(rp -> !FailureDetector.isReplicaAlive.test(rp));
+
+        return new SatelliteCommitPlan(liveSatellite, downSatellite, tracker);
+    }
+
+    /**
+     * Sends plain mutations to satellite/secondary DCs alongside a paxos commit.
+     *
+     * Paxos consensus handles the primary DC writes. This method sends the committed mutation to satellite DCs using
+     * the standard SRS write coordination. The primary DC group is pre-completed in the tracker so only satellite DC
+     * quorums need actual responses.
+     *
+     * @return a future that completes when satellite DC quorum requirements are met (or failed)
+     */
+    @Override
+    public Future<Void> sendPaxosCommitMutations(Agreed commit, boolean isUrgent)
+    {
+        ClusterMetadata metadata = ClusterMetadata.current();
+        Keyspace keyspace = Keyspace.open(commit.metadata().keyspace);
+        Token token = commit.partitionKey().getToken();
+
+        // Reject satellite commit writes during TRANSITION_ACK. Paxos operations should not be
+        // in progress during this state, but check defensively to avoid propagating stale commits.
+        SatelliteFailoverState.FailoverInfo failoverInfo = getFailoverInfo(token, metadata);
+        if (failoverInfo.getState() == SatelliteFailoverState.State.TRANSITION_ACK)
+            throw new UnavailableException("Paxos commit rejected during TRANSITION_ACK failover state",
+                                           ConsistencyLevel.SERIAL, 1, 0);
+
+        SatelliteCommitPlan plan = createSatelliteCommitPlan(metadata, keyspace, token);
+        ResponseTracker tracker = plan.tracker;
+        MutationId mutationId = commit.mutation.id();
+        Preconditions.checkState(!mutationId.isNone());
+
+        // Register satellite replicas with mutation tracking service
+        if (!plan.liveEndpoints.isEmpty() || !plan.downEndpoints.isEmpty())
+        {
+            IntHashSet satelliteHostIds = new IntHashSet();
+            for (int i = 0; i < plan.liveEndpoints.size(); i++)
+                satelliteHostIds.add(metadata.directory.peerId(plan.liveEndpoints.endpoint(i)).id());
+            for (int i = 0; i < plan.downEndpoints.size(); i++)
+                satelliteHostIds.add(metadata.directory.peerId(plan.downEndpoints.endpoint(i)).id());
+            if (!satelliteHostIds.isEmpty())
+                MutationTrackingService.instance().sentWriteRequest(commit.makeMutation(), satelliteHostIds);
+        }
+
+        // Create promise that resolves when satellite tracker completes
+        AsyncPromise<Void> promise = new AsyncPromise<>();
+
+        // Send plain mutations to live satellite endpoints
+        Message<Mutation> satelliteMessage = Message.out(Verb.PAXOS2_COMMIT_REMOTE_REQ, commit.makeMutation(), isUrgent);
+        for (int i = 0, mi = plan.liveEndpoints.size(); i < mi; ++i)
+        {
+            InetAddressAndPort endpoint = plan.liveEndpoints.endpoint(i);
+            logger.trace("Sending satellite commit mutation for {} to {}", commit.partitionKey(), endpoint);
+            MessagingService.instance().sendWithCallback(satelliteMessage, endpoint, new RequestCallback<NoPayload>()
+            {
+                @Override
+                public void onResponse(Message<NoPayload> msg)
+                {
+                    MutationTrackingService.instance().receivedWriteResponse(mutationId, msg.from());
+
+                    tracker.onResponse(msg.from());
+                    if (tracker.isComplete())
+                        resolvePromise(promise, tracker);
+                }
+
+                @Override
+                public void onFailure(InetAddressAndPort from, RequestFailure failure)
+                {
+                    MutationTrackingService.instance().retryFailedWrite(mutationId, from, failure);
+
+                    tracker.onFailure(from, failure.reason);
+                    if (tracker.isComplete())
+                        resolvePromise(promise, tracker);
+                }
+            });
+        }
+
+        // Mark down satellite endpoints as failed
+        for (int i = 0, mi = plan.downEndpoints.size(); i < mi; ++i)
+        {
+            InetAddressAndPort endpoint = plan.downEndpoints.endpoint(i);
+            MutationTrackingService.instance().retryFailedWrite(mutationId, endpoint, RequestFailure.NODE_DOWN);
+            tracker.onFailure(endpoint, UNKNOWN);
+        }
+
+        if (tracker.isComplete())
+            resolvePromise(promise, tracker);
+
+        return promise;
+    }
+
+    private static void resolvePromise(AsyncPromise<Void> promise, ResponseTracker tracker)
+    {
+        if (tracker.isSuccessful())
+            promise.trySuccess(null);
+        else
+            promise.tryFailure(new RuntimeException("Satellite DC quorum not met"));
+    }
+
+    private CoordinationPlan.ForTokenRead planForTokenReadPrimary(ClusterMetadata metadata,
+                                                                  String primary,
+                                                                  Keyspace keyspace,
+                                                                  TableId tableId,
+                                                                  Token token,
+                                                                  @Nullable Index.QueryPlan indexQueryPlan,
+                                                                  ConsistencyLevel consistencyLevel,
+                                                                  SpeculativeRetryPolicy retry,
+                                                                  ReadCoordinator coordinator)
+    {
+        ReplicaLayout.ForTokenRead fullLayout = ReplicaLayout.forTokenReadSorted(metadata, keyspace, this, tableId, token, coordinator);
+
+        CoordinationPlanner.ForTokenRead planner = new CoordinationPlanner.ForTokenRead(metadata, keyspace, token, consistencyLevel, this, primary, fullLayout, indexQueryPlan, retry, true, tableId, coordinator);
+
+        return new CoordinationPlan.ForTokenRead(ReplicaPlan.shared(planner.createReplicaPlan()), planner.createResponseTracker());
+    }
+
+    @Override
+    public CoordinationPlan.ForTokenRead planForTokenRead(ClusterMetadata metadata, Keyspace keyspace, TableId tableId, Token token, @Nullable Index.QueryPlan indexQueryPlan, ConsistencyLevel consistencyLevel, SpeculativeRetryPolicy retry, ReadCoordinator coordinator)
+    {
+        CoordinationPlan.ForTokenRead primaryPlan = planForTokenReadPrimary(metadata, primaryDC, keyspace, tableId, token, indexQueryPlan, consistencyLevel, retry, coordinator);
+
+        SatelliteFailoverState.FailoverInfo failoverInfo = getFailoverInfo(token, metadata);
+        if (!failoverInfo.isTransitioning())
+            return primaryPlan;
+
+        CoordinationPlan.ForTokenRead previousPlan = planForTokenReadPrimary(metadata, failoverInfo.getFromDC(), keyspace, tableId, token, indexQueryPlan, consistencyLevel, retry, coordinator);
+
+        return mergeTokenReadPlans(primaryPlan, previousPlan, cm -> strategy(cm).planForTokenRead(cm, keyspace, tableId, token, indexQueryPlan, consistencyLevel, retry, coordinator).replicas());
+    }
+
+    /**
+     * Merge two ForTokenRead coordination plans during failover transition. The primary plan's replicas are added
+     * first to preserve proximity ordering for data vs digest request selection. Replicas from the previous plan
+     * are appended if not already present in the primary plan.
+     */
+    private static CoordinationPlan.ForTokenRead mergeTokenReadPlans(CoordinationPlan.ForTokenRead primaryPlan,
+                                                                     CoordinationPlan.ForTokenRead previousPlan,
+                                                                     Function<ClusterMetadata, ReplicaPlan.ForTokenRead> recompute)
+    {
+        ReplicaPlan.ForTokenRead primary = primaryPlan.replicas();
+        ReplicaPlan.ForTokenRead previous = previousPlan.replicas();
+
+        EndpointsForToken candidates = mergeEndpoints(primary.readCandidates(), previous.readCandidates());
+        EndpointsForToken contacts = mergeEndpoints(primary.contacts(), previous.contacts());
+        EndpointsForToken liveAndDown = mergeEndpoints(primary.liveAndDown(), previous.liveAndDown());
+        int readQuorum = Math.max(primary.readQuorum(), previous.readQuorum());
+
+        ReplicaPlan.ForTokenRead merged = new ReplicaPlan.ForTokenRead(primary.keyspace(),
+                                                                       primary.replicationStrategy(),
+                                                                       primary.consistencyLevel(),
+                                                                       candidates,
+                                                                       contacts,
+                                                                       liveAndDown,
+                                                                       recompute,
+                                                                       CoordinationPlanner.ForTokenRead.READ_REPAIR_PLAN,
+                                                                       primary.epoch(),
+                                                                       readQuorum);
+
+        ResponseTracker tracker = new CompositeTracker(CompositeTracker.all(2), primaryPlan.responses(), previousPlan.responses());
+
+        return new CoordinationPlan.ForTokenRead(ReplicaPlan.shared(merged), tracker);
+    }
+
+    private static <E extends Endpoints<E>> E mergeEndpoints(E first, E second)
+    {
+        ReplicaCollection.Builder<E> builder = first.newBuilder(first.size() + second.size());
+        builder.addAll(first);
+        builder.addAll(second, ReplicaCollection.Builder.Conflict.DUPLICATE);
+        return builder.build();
+    }
+
+    private CoordinationPlan.ForRangeRead planForRangeReadPrimary(ClusterMetadata metadata,
+                                                                  String primary,
+                                                                  Keyspace keyspace,
+                                                                  TableId tableId,
+                                                                  AbstractBounds<PartitionPosition> range,
+                                                                  int vnodeCount,
+                                                                  @Nullable Index.QueryPlan indexQueryPlan,
+                                                                  ConsistencyLevel consistencyLevel)
+    {
+
+        ReplicaLayout.ForRangeRead fullLayout = ReplicaLayout.forRangeReadSorted(metadata, keyspace, this, range);
+
+        CoordinationPlanner.ForRangeRead planner = new CoordinationPlanner.ForRangeRead(metadata, keyspace, range, vnodeCount, consistencyLevel, this, primary, fullLayout, indexQueryPlan, true, tableId);
+
+        return new CoordinationPlan.ForRangeRead(ReplicaPlan.shared(planner.createReplicaPlan()), planner.createResponseTracker());
+    }
+
+
+    @Override
+    public CoordinationPlan.ForRangeRead planForRangeRead(ClusterMetadata metadata, Keyspace keyspace, TableId tableId, @Nullable Index.QueryPlan indexQueryPlan, ConsistencyLevel consistencyLevel, AbstractBounds<PartitionPosition> range, int vnodeCount)
+    {
+        CoordinationPlan.ForRangeRead primaryPlan = planForRangeReadPrimary(metadata, primaryDC, keyspace, tableId, range, vnodeCount, indexQueryPlan, consistencyLevel);
+
+        SatelliteFailoverState.FailoverInfo failoverInfo = getFailoverInfo(range, metadata);
+        if (!failoverInfo.isTransitioning())
+            return primaryPlan;
+
+        CoordinationPlan.ForRangeRead previousPlan = planForRangeReadPrimary(metadata, failoverInfo.getFromDC(), keyspace, tableId, range, vnodeCount, indexQueryPlan, consistencyLevel);
+
+        return mergeRangeReadPlans(primaryPlan, previousPlan, cm -> strategy(cm).planForRangeRead(cm, keyspace, tableId, indexQueryPlan, consistencyLevel, range, vnodeCount).replicas());
+    }
+
+    private static CoordinationPlan.ForRangeRead mergeRangeReadPlans(CoordinationPlan.ForRangeRead primaryPlan,
+                                                                     CoordinationPlan.ForRangeRead previousPlan,
+                                                                     Function<ClusterMetadata, ReplicaPlan.ForRangeRead> recompute)
+    {
+        ReplicaPlan.ForRangeRead primary = primaryPlan.replicas();
+        ReplicaPlan.ForRangeRead previous = previousPlan.replicas();
+
+        EndpointsForRange candidates = mergeEndpoints(primary.readCandidates(), previous.readCandidates());
+        EndpointsForRange contacts = mergeEndpoints(primary.contacts(), previous.contacts());
+        EndpointsForRange liveAndDown = mergeEndpoints(primary.liveAndDown(), previous.liveAndDown());
+        int readQuorum = Math.max(primary.readQuorum(), previous.readQuorum());
+
+        ReplicaPlan.ForRangeRead merged = new ReplicaPlan.ForRangeRead(primary.keyspace(),
+                                                                       primary.replicationStrategy(),
+                                                                       primary.consistencyLevel(),
+                                                                       primary.range(),
+                                                                       candidates,
+                                                                       contacts,
+                                                                       liveAndDown,
+                                                                       primary.vnodeCount(),
+                                                                       recompute,
+                                                                       CoordinationPlanner.ForRangeRead.READ_REPAIR_PLAN,
+                                                                       primary.epoch(),
+                                                                       readQuorum);
+
+        ResponseTracker tracker = new CompositeTracker(CompositeTracker.all(2), primaryPlan.responses(), previousPlan.responses());
+
+        return new CoordinationPlan.ForRangeRead(ReplicaPlan.shared(merged), tracker);
+    }
+
+    @Override
+    public CoordinationPlan.ForRangeRead planForSingleReplicaRangeRead(Keyspace keyspace, AbstractBounds<PartitionPosition> range, Replica replica, int vnodeCount)
+    {
+        throw new IllegalStateException("planForSingleReplicaRangeRead not supported by SatelliteReplicationStrategy");
+    }
+
+    @Override
+    public CoordinationPlan.ForTokenRead planForSingleReplicaTokenRead(Keyspace keyspace, Token token, Replica replica)
+    {
+        throw new IllegalStateException("planForSingleReplicaTokenRead not supported by SatelliteReplicationStrategy");
+    }
+
+    @Override
+    public CoordinationPlan.ForRangeRead maybeMergeRangeReads(ClusterMetadata metadata, Keyspace keyspace, TableId tableId, ConsistencyLevel consistencyLevel, ReplicaPlan.ForRangeRead left, ReplicaPlan.ForRangeRead right)
+    {
+        return null;
+    }
+
+    private String getSatelliteForDC(String dc)
+    {
+        for (SatelliteInfo sat : satellites.values())

Review Comment:
   Are multiple satellites allowed per parent? We will always only return the same one from this method, the first in iteration order, and the iteration order could change unexpectedly.



##########
src/java/org/apache/cassandra/locator/SatelliteReplicationStrategy.java:
##########
@@ -513,4 +631,1286 @@ public boolean hasSameSettings(AbstractReplicationStrategy other)
                primaryDC.equals(otherStrategy.primaryDC) &&
                disabledDCs.equals(otherStrategy.disabledDCs);
     }
+
+    private AbstractReplicationStrategy strategy(ClusterMetadata metadata)
+    {
+        return metadata.schema.getKeyspaceMetadata(keyspaceName).replicationStrategy;
+    }
+
+    static abstract class CoordinationPlanner<E extends Endpoints<E>, L extends ReplicaLayout<E>, P extends ReplicaPlan<E, P>>
+    {
+        final ClusterMetadata metadata;
+        final Keyspace keyspace;
+        final ConsistencyLevel cl;
+        final SatelliteReplicationStrategy strategy;
+        final String primary;
+        final String satellite;
+        final List<String> dcs;
+        final L fullLayout;
+        final L liveLayout;
+        final Map<String, L> fullLayouts;
+        final Map<String, L> liveLayouts;
+        final Map<String, E> fullEndpoints;
+        final Map<String, E> liveEndpoints;
+
+        public CoordinationPlanner(ClusterMetadata metadata, Keyspace keyspace, ConsistencyLevel cl, SatelliteReplicationStrategy strategy, String primary, L fullLayout)
+        {
+            this.metadata = metadata;
+            this.keyspace = keyspace;
+            this.cl = cl;
+            this.strategy = strategy;
+            this.primary = primary;
+            this.satellite = strategy.getSatelliteForDC(primary);
+
+            this.dcs = createDcList();
+            Preconditions.checkState(!dcs.isEmpty(), "No DCs available for request (primary=%s, all disabled?)", primary);
+
+            Set<String> dcSet = new HashSet<>(dcs);
+            this.fullLayout = filterLayout(fullLayout, rp -> dcSet.contains(metadata.locator.location(rp.endpoint()).datacenter));
+            this.liveLayout = filterLayout(this.fullLayout, FailureDetector.isReplicaAlive);
+
+            this.fullLayouts = Maps.newHashMapWithExpectedSize(dcs.size());
+            this.liveLayouts = Maps.newHashMapWithExpectedSize(dcs.size());
+
+            this.fullEndpoints = Maps.newHashMapWithExpectedSize(dcs.size());
+            this.liveEndpoints = Maps.newHashMapWithExpectedSize(dcs.size());
+
+            populateDcLayouts();
+        }
+
+        AbstractReplicationStrategy strategy(ClusterMetadata metadata)
+        {
+            return metadata.schema.getKeyspaceMetadata(keyspace.getName()).replicationStrategy;
+        }
+
+        List<String> createDcList()
+        {
+            List<String> results = new ArrayList<>();
+            if (!strategy.disabledDCs.contains(primary))
+                results.add(primary);
+
+            if (satellite != null && !strategy.disabledDCs.contains(satellite))
+                results.add(satellite);
+
+            for (String dc : strategy.fullDCs.keySet())
+            {
+                if (dc.equals(primary) || dc.equals(satellite) || strategy.disabledDCs.contains(dc))
+                    continue;
+                results.add(dc);
+            }
+
+            Preconditions.checkState(!results.isEmpty());
+            return results;
+        }
+
+        void populateDcLayouts()
+        {
+            for (String dc : dcs)
+            {
+                Predicate<Replica> dcFilter = rp -> metadata.locator.location(rp.endpoint()).datacenter.equals(dc);
+                L full = filterLayout(fullLayout, dcFilter);
+                L live = filterLayout(liveLayout, dcFilter);
+
+                fullLayouts.put(dc, full);
+                liveLayouts.put(dc, live);
+
+                fullEndpoints.put(dc, full.natural());
+                liveEndpoints.put(dc, live.natural());
+            }
+
+            if (ADDL_CHECKS_ENABLED)
+                Preconditions.checkState(fullLayouts.size() == dcs.size(), "populateDcLayouts: fullLayouts.size()=%s != dcs.size()=%s", fullLayouts.size(), dcs.size());
+        }
+
+        ResponseTrackerBuilder<E, L, P> createResponseTrackerBuilder()
+        {
+            switch (cl)
+            {
+                case ONE:
+                case LOCAL_ONE:
+                case NODE_LOCAL:
+                case ANY:
+                case TWO:
+                case THREE:
+                case ALL:
+                    return new ResponseTrackerBuilder.Simple<>(this);
+
+                case QUORUM:
+                case LOCAL_QUORUM:
+                    // LOCAL_QUORUM an QUORUM mean the same thing in SRS
+                    return new ResponseTrackerBuilder.Quorum<>(this);
+
+                case EACH_QUORUM:
+                    return new ResponseTrackerBuilder.EachQuorum<>(this);
+
+                default:
+                    throw new IllegalStateException("Unsupported consistency level: " + cl);
+            }
+        }
+
+        ResponseTracker createResponseTracker()
+        {
+            ResponseTrackerBuilder<E, L, P> builder = createResponseTrackerBuilder();
+            return builder.build();
+        }
+
+        abstract L filterLayout(L layout, Predicate<Replica> predicate);
+        abstract ResponseTracker createDcResponseTracker(String dc);
+
+        abstract P recomputePlan(ClusterMetadata metadata);
+        abstract P createReplicaPlan();
+
+        interface IndexReadExceptionCheck<E extends Endpoints<E>>
+        {
+            void maybeThrowIndexReadException(int initial, int filtered, Map<String, E> candidates, Map<InetAddressAndPort, Index.Status> indexStatusMap);
+        }
+
+        static class ForWrite extends CoordinationPlanner<EndpointsForToken, ReplicaLayout.ForTokenWrite, ReplicaPlan.ForWrite>
+        {
+            final ReplicaPlans.Selector selector;
+
+            public ForWrite(ClusterMetadata metadata, Keyspace keyspace, ConsistencyLevel cl, SatelliteReplicationStrategy strategy, String primary, ReplicaLayout.ForTokenWrite fullLayout, ReplicaPlans.Selector selector)
+            {
+                super(metadata, keyspace, cl, strategy, primary, fullLayout);
+                this.selector = selector;
+                if (ADDL_CHECKS_ENABLED)
+                {
+                    Preconditions.checkState(!strategy.disabledDCs.contains(primary));
+                }
+            }
+
+            @Override
+            ResponseTracker createDcResponseTracker(String dc)
+            {
+                // this basically hardcodes ReplicaPlans.writeAll
+                Predicate<InetAddressAndPort> dcFilter = ep -> metadata.locator.location(ep).datacenter.equals(dc);
+
+                ReplicaLayout.ForTokenWrite dcLayout = fullLayouts.get(dc);
+                Preconditions.checkNotNull(dcLayout);
+
+                int blockFor = strategy.calculateQuorum(dc);
+                int totalContacts = dcLayout.all().size();
+
+                // Only count pending replicas that are actually in the selected contacts
+                int pendingInDc = dcLayout.pending().size();
+
+                if (pendingInDc == 0)
+                {
+                    return new SimpleResponseTracker(blockFor, totalContacts, dcFilter);
+                }
+                else
+                {
+                    int committedContacts = totalContacts - pendingInDc;
+                    int totalBlockFor = blockFor + pendingInDc;
+                    Predicate<InetAddressAndPort> isPending = ep -> dcLayout.pending.endpoints().contains(ep);
+                    return new WriteResponseTracker(blockFor, totalBlockFor,
+                                                    committedContacts, pendingInDc,
+                                                    isPending, dcFilter);
+                }
+            }
+
+            @Override
+            ReplicaPlan.ForWrite recomputePlan(ClusterMetadata metadata)
+            {
+                Token token = fullLayout.token();
+                return strategy(metadata).planForWrite(metadata, keyspace, cl,
+                                                              cm -> ReplicaLayout.forTokenWriteLiveAndDown(cm, keyspace, token),
+                                                       selector).replicas();
+            }
+
+            @Override
+            ReplicaLayout.ForTokenWrite filterLayout(ReplicaLayout.ForTokenWrite layout, Predicate<Replica> predicate)
+            {
+                return layout.filter(predicate);
+            }
+
+            private ReplicaLayout.ForTokenWrite mergeLayouts(Collection<ReplicaLayout.ForTokenWrite> layouts)
+            {
+                ReplicaCollection.Builder<EndpointsForToken> naturalBuilder = null;
+                ReplicaCollection.Builder<EndpointsForToken> pendingBuilder = null;
+
+                for (ReplicaLayout.ForTokenWrite layout : layouts)
+                {
+                    EndpointsForToken natural = layout.natural();
+                    EndpointsForToken pending = layout.pending();
+                    if (naturalBuilder == null)
+                    {
+                        naturalBuilder = natural.newBuilder(natural.size());
+                        pendingBuilder = pending.newBuilder(pending.size());
+                    }
+
+                    naturalBuilder.addAll(natural);
+                    pendingBuilder.addAll(pending);
+                }
+
+                return new ReplicaLayout.ForTokenWrite(strategy, naturalBuilder.build(), pendingBuilder.build());
+            }
+
+            @Override
+            ReplicaPlan.ForWrite createReplicaPlan()
+            {
+
+                ReplicaLayout.ForTokenWrite fullLayout = mergeLayouts(fullLayouts.values());
+                ReplicaLayout.ForTokenWrite liveLayout = mergeLayouts(liveLayouts.values());
+                EndpointsForToken contacts = selector.select(cl, fullLayout, liveLayout);
+
+                return new ReplicaPlan.ForWrite(keyspace,
+                                                strategy,
+                                                cl,
+                                                fullLayout.pending(),
+                                                fullLayout.all(),
+                                                liveLayout.all(),
+                                                contacts,
+                                                this::recomputePlan,
+                                                metadata.epoch);
+            }
+        }
+
+        static abstract class ForRead<E extends Endpoints<E>, L extends ReplicaLayout<E>, P extends ReplicaPlan.ForRead<E, P>> extends CoordinationPlanner<E, L, P>
+        {
+            final @Nullable Index.QueryPlan indexQueryPlan;
+            final boolean alwaysSpeculate;
+            final boolean throwOnInsufficientLiveReplicas;
+
+            public ForRead(ClusterMetadata metadata, Keyspace keyspace, ConsistencyLevel cl, SatelliteReplicationStrategy strategy, String primary, L fullLayout, @Nullable Index.QueryPlan indexQueryPlan, boolean alwaysSpeculate, boolean throwOnInsufficientLiveReplicas)
+            {
+                super(metadata, keyspace, cl, strategy, primary, fullLayout);
+                this.indexQueryPlan = indexQueryPlan;
+                this.alwaysSpeculate = alwaysSpeculate;
+                this.throwOnInsufficientLiveReplicas = throwOnInsufficientLiveReplicas;
+            }
+
+            @Override
+            ResponseTracker createDcResponseTracker(String dc)
+            {
+                Predicate<InetAddressAndPort> dcFilter = ep -> metadata.locator.location(ep).datacenter.equals(dc);
+
+                L dcLayout = fullLayouts.get(dc);
+                int blockFor = strategy.calculateQuorum(dc);
+                int totalContacts = dcLayout.all().size();
+
+                return new SimpleResponseTracker(blockFor, totalContacts, dcFilter);
+            }
+
+            Map<String, E> candidates(IndexReadExceptionCheck<E> check)
+            {
+                if (indexQueryPlan == null)
+                    return liveEndpoints;
+
+                Map<String, E> candidates = new HashMap<>();
+                Map<InetAddressAndPort, Index.Status> indexStatusMap = new HashMap<>();
+                int initial = 0;
+                int filtered = 0;
+                for (Map.Entry<String, E> entry : liveEndpoints.entrySet())
+                {
+                    E liveEndpoints = entry.getValue();
+                    E queryableEndpoints = IndexStatusManager.instance.filterForQuery(liveEndpoints, keyspace.getName(), indexQueryPlan, indexStatusMap);
+                    initial += liveEndpoints.size();
+                    filtered += queryableEndpoints.size();
+                    candidates.put(entry.getKey(), queryableEndpoints);
+                }
+
+                if (initial != filtered)
+                    check.maybeThrowIndexReadException(initial, filtered, candidates, indexStatusMap);
+
+                return candidates;
+            }
+
+            @Override
+            ResponseTracker createResponseTracker()
+            {
+                ResponseTracker tracker = super.createResponseTracker();
+
+                // read trackers don't wait on pending nodes
+                Preconditions.checkState(tracker.pendingContacts() == 0);
+                return tracker;
+            }
+
+
+            abstract P replicaPlanBuilder(E candidates, E contacts, E liveAndDown, int quorumSize);
+
+            ReadReplicaPlanBuilder<E, L, P> replicaPlanBuilder()
+            {
+                switch (cl)
+                {
+                    case ONE:
+                    case LOCAL_ONE:
+                    case NODE_LOCAL:
+                    case ANY:
+                    case TWO:
+                    case THREE:
+                    case ALL:
+                        return new ReadReplicaPlanBuilder.Simple<>(this);
+
+                    case QUORUM:
+                    case LOCAL_QUORUM:
+                    case EACH_QUORUM:
+                        return new ReadReplicaPlanBuilder.DcQuorum<>(this);
+
+                    default:
+                        throw new IllegalStateException("Unsupported consistency level: " + cl);
+                }
+            }
+
+            @Override
+            P createReplicaPlan()
+            {
+                ReadReplicaPlanBuilder<E, L, P> builder = replicaPlanBuilder();
+                return builder.build();
+            }
+        }
+
+        static class ForTokenRead extends ForRead<EndpointsForToken, ReplicaLayout.ForTokenRead, ReplicaPlan.ForTokenRead>
+        {
+            static final Function<ReplicaPlan<?, ?>, ReplicaPlan.ForWrite> READ_REPAIR_PLAN = (ReplicaPlan<?, ?> self) -> {throw new IllegalStateException("Cannot create read repair plans for SatelliteDatacenterReplicationStrategy");};
+            final Token token;
+            final TableId tableId;
+            final SpeculativeRetryPolicy retry;
+            final ReadCoordinator coordinator;
+
+            public ForTokenRead(ClusterMetadata metadata, Keyspace keyspace, Token token, ConsistencyLevel cl, SatelliteReplicationStrategy strategy, String primary, ReplicaLayout.ForTokenRead fullLayout, @Nullable Index.QueryPlan indexQueryPlan, SpeculativeRetryPolicy retry, boolean throwOnInsufficientLiveReplicas, TableId tableId, ReadCoordinator coordinator)
+            {
+                super(metadata, keyspace, cl, strategy, primary, fullLayout, indexQueryPlan, retry == AlwaysSpeculativeRetryPolicy.INSTANCE, throwOnInsufficientLiveReplicas);
+                this.token = token;
+                this.tableId = tableId;
+                this.retry = retry;
+                this.coordinator = coordinator;
+            }
+
+            @Override
+            ReplicaLayout.ForTokenRead filterLayout(ReplicaLayout.ForTokenRead layout, Predicate<Replica> predicate)
+            {
+                return layout.filter(predicate);
+            }
+
+            @Override
+            ReplicaPlan.ForTokenRead recomputePlan(ClusterMetadata metadata)
+            {
+                return strategy(metadata).planForTokenRead(metadata, keyspace, tableId, token,
+                                                           indexQueryPlan, cl, retry, coordinator).replicas();
+            }
+
+            @Override
+            ReplicaPlan.ForTokenRead replicaPlanBuilder(EndpointsForToken candidates, EndpointsForToken contacts, EndpointsForToken liveAndDown, int quorumSize)
+            {
+                return new ReplicaPlan.ForTokenRead(keyspace, strategy, cl,
+                                                    candidates, contacts, liveAndDown,
+                                                    this::recomputePlan,
+                                                    READ_REPAIR_PLAN,
+                                                    metadata.epoch,
+                                                    quorumSize);
+            }
+        }
+
+        static class ForRangeRead extends ForRead<EndpointsForRange, ReplicaLayout.ForRangeRead, ReplicaPlan.ForRangeRead>
+        {
+            static final BiFunction<ReplicaPlan<?, ?>, Token, ReplicaPlan.ForWrite> READ_REPAIR_PLAN = (ReplicaPlan<?, ?> self, Token token) -> {throw new IllegalStateException("Cannot create read repair plans for SatelliteDatacenterReplicationStrategy");};
+            final AbstractBounds<PartitionPosition> range;
+            final int vnodeCount;
+            final TableId tableId;
+
+            public ForRangeRead(ClusterMetadata metadata, Keyspace keyspace, AbstractBounds<PartitionPosition> range, int vnodeCount, ConsistencyLevel cl, SatelliteReplicationStrategy strategy, String primary, ReplicaLayout.ForRangeRead fullLayout, @Nullable Index.QueryPlan indexQueryPlan, boolean throwOnInsufficientLiveReplicas, TableId tableId)
+            {
+                super(metadata, keyspace, cl, strategy, primary, fullLayout, indexQueryPlan, false, throwOnInsufficientLiveReplicas);
+                this.range = range;
+                this.vnodeCount = vnodeCount;
+                this.tableId = tableId;
+            }
+
+            @Override
+            ReplicaLayout.ForRangeRead filterLayout(ReplicaLayout.ForRangeRead layout, Predicate<Replica> predicate)
+            {
+                return layout.filter(predicate);
+            }
+
+            @Override
+            ReplicaPlan.ForRangeRead recomputePlan(ClusterMetadata metadata)
+            {
+                return strategy(metadata).planForRangeRead(metadata, keyspace, tableId,
+                                                           indexQueryPlan, cl, range, vnodeCount).replicas();
+            }
+
+            @Override
+            ReplicaPlan.ForRangeRead replicaPlanBuilder(EndpointsForRange candidates, EndpointsForRange contacts, EndpointsForRange liveAndDown, int quorumSize)
+            {
+                return new ReplicaPlan.ForRangeRead(keyspace, strategy, cl, range,
+                                                    candidates, contacts, liveAndDown, vnodeCount,
+                                                    this::recomputePlan,
+                                                    READ_REPAIR_PLAN,
+                                                    metadata.epoch,
+                                                    quorumSize);
+            }
+        }
+    }
+
+    static abstract class ResponseTrackerBuilder<E extends Endpoints<E>, L extends ReplicaLayout<E>, P extends ReplicaPlan<E, P>>
+    {
+        final CoordinationPlanner<E, L, P> planner;
+
+        public ResponseTrackerBuilder(CoordinationPlanner<E, L, P> planner)
+        {
+            this.planner = planner;
+        }
+
+        Map<String, ResponseTracker> responseTrackersForPrimary()
+        {
+            Map<String, ResponseTracker> result = Maps.newHashMapWithExpectedSize(planner.dcs.size());
+
+            for (String dc : planner.dcs)
+                result.put(dc, planner.createDcResponseTracker(dc));
+
+            return result;
+        }
+
+        abstract ResponseTracker aggregateTrackers(Map<String, ResponseTracker> dcTrackers);
+
+        ResponseTracker build()
+        {
+            Map<String, ResponseTracker> dcTrackers = responseTrackersForPrimary();
+            return aggregateTrackers(dcTrackers);
+        }
+
+        static class Simple<E extends Endpoints<E>, L extends ReplicaLayout<E>, P extends ReplicaPlan<E, P>> extends ResponseTrackerBuilder<E, L, P>
+        {
+            public Simple(CoordinationPlanner<E, L, P> planner)
+            {
+                super(planner);
+            }
+
+            @Override
+            ResponseTracker aggregateTrackers(Map<String, ResponseTracker> dcTrackers)
+            {
+                int totalContacts = 0;
+                int totalPending = 0;
+
+
+                List<ResponseTracker> trackers = Lists.newArrayList();
+                Set<String> blockingDatacenters = Sets.newHashSet();
+
+                for (Map.Entry<String, ResponseTracker> entry : dcTrackers.entrySet())
+                {
+                    String dc = entry.getKey();
+                    ResponseTracker tracker = entry.getValue();
+                    if (planner.cl == ConsistencyLevel.ALL || dc.equals(planner.primary) || dc.equals(planner.satellite))
+                    {
+                        totalContacts += tracker.totalContacts();
+                        totalPending += tracker.pendingContacts();
+                        trackers.add(tracker);
+                        blockingDatacenters.add(dc);
+                    }
+                }
+
+                Predicate<InetAddressAndPort> filter = i -> {
+                    String dc = planner.metadata.locator.location(i).datacenter;
+                    return dc != null && blockingDatacenters.contains(dc);
+                };
+
+                Preconditions.checkState(!trackers.isEmpty(), "No blocking DCs for CL %s (primary=%s, satellite=%s)", planner.cl, planner.primary, planner.satellite);
+
+
+                if (planner.cl == ConsistencyLevel.ALL)
+                    return new SimpleResponseTracker(totalContacts, totalContacts, filter);
+
+                int blockFor = planner.cl.blockFor(planner.strategy);
+                int totalBlockFor = blockFor + totalPending;
+
+                Preconditions.checkState(blockFor <= totalContacts, "Simple tracker: blockFor=%s > totalContacts=%s for CL %s", blockFor, totalContacts, planner.cl);
+
+
+                if (totalPending == 0)
+                    return new SimpleResponseTracker(blockFor, totalContacts, filter);
+
+                Predicate<InetAddressAndPort> pending = i -> {
+                    for (ResponseTracker tracker : trackers)
+                    {
+                        if (tracker.isPending(i))
+                            return true;
+                    }
+                    return false;
+                };
+                return new WriteResponseTracker(blockFor, totalBlockFor, totalContacts, totalPending, pending, filter);
+            }
+        }
+
+        static class Quorum<E extends Endpoints<E>, L extends ReplicaLayout<E>, P extends ReplicaPlan<E, P>> extends ResponseTrackerBuilder<E, L, P>
+        {
+            public Quorum(CoordinationPlanner<E, L, P> planner)
+            {
+                super(planner);
+            }
+
+            @Override
+            ResponseTracker aggregateTrackers(Map<String, ResponseTracker> dcTrackers)
+            {
+                return new CompositeTracker(CompositeTracker.quorum(dcTrackers.size()), new ArrayList<>(dcTrackers.values()));
+            }
+        }
+
+        static class EachQuorum<E extends Endpoints<E>, L extends ReplicaLayout<E>, P extends ReplicaPlan<E, P>> extends ResponseTrackerBuilder<E, L, P>
+        {
+            public EachQuorum(CoordinationPlanner<E, L, P> planner)
+            {
+                super(planner);
+            }
+
+            @Override
+            ResponseTracker aggregateTrackers(Map<String, ResponseTracker> dcTrackers)
+            {
+                return new CompositeTracker(CompositeTracker.all(dcTrackers.size()), new ArrayList<>(dcTrackers.values()));
+            }
+        }
+    }
+
+    static abstract class ReadReplicaPlanBuilder<E extends Endpoints<E>, L extends ReplicaLayout<E>, P extends ReplicaPlan.ForRead<E, P>>
+            implements CoordinationPlanner.IndexReadExceptionCheck<E>
+    {
+
+        private static <E extends Endpoints<E>> Iterable<Replica> removedSupplier(Map<String, E> initial, Map<String, E> filtered)
+        {
+            return () -> initial.keySet().stream().map(dc -> {
+                Set<InetAddressAndPort> f =  filtered.containsKey(dc) ? filtered.get(dc).endpoints() : Collections.emptySet();
+                return initial.get(dc).without(f);
+            }).flatMap(AbstractReplicaCollection::stream).iterator();
+        }
+
+        final CoordinationPlanner.ForRead<E, L, P> planner;
+
+        public ReadReplicaPlanBuilder(CoordinationPlanner.ForRead<E, L, P> planner)
+        {
+            this.planner = planner;
+        }
+
+        abstract boolean canSelectReplicasFromDc(String dc, int liveNodes);
+        abstract int minBlockFor();
+        abstract int eligibleCandidates(Map<String, E> candidates);
+
+        abstract boolean haveSufficientContacts(int contactedReplicas, int contactedDcs);
+        abstract boolean haveSufficientContactsForDc(String dc, int contactedReplicas, int contactedDcs, int dcContacts);
+        abstract boolean haveSufficientLiveNodes(int contactedReplicas, int contactedDcs);
+
+        private E allLiveAndDown()
+        {
+            E fullEndpoints = planner.fullLayout.all();
+            ReplicaCollection.Builder<E> fullBuilder = fullEndpoints.newBuilder(fullEndpoints.size());
+
+            for (String dc : planner.dcs)
+            {
+                E endpoints = planner.fullEndpoints.get(dc);
+                if (endpoints == null)
+                    continue;
+
+                fullBuilder.addAll(endpoints);
+            }
+
+            return fullBuilder.build();
+        }
+
+        private E allCandidates(Map<String, E> candidates)
+        {
+            E liveEndpoints = planner.liveLayout.all();
+            ReplicaCollection.Builder<E> candidateBuilder = liveEndpoints.newBuilder(liveEndpoints.size());
+
+            for (String dc : planner.dcs)
+            {
+                // TODO: make sure the full replica is added if this is the dc it's from
+                E dcCandidates = candidates.get(dc);
+                if (dcCandidates == null || !canSelectReplicasFromDc(dc, dcCandidates.size()))
+                    continue;
+
+                candidateBuilder.addAll(dcCandidates);
+            }
+
+            return candidateBuilder.build();
+        }
+
+        private E contacts(Map<String, E> candidates)
+        {
+            E liveEndpoints = planner.liveLayout.all();
+
+            ReplicaCollection.Builder<E> contactBuilder = liveEndpoints.newBuilder(liveEndpoints.size());
+
+            // find full replica
+            Replica fullReplica = null;
+            String fullDc = null;
+            for (String dc : planner.dcs)
+            {
+                if (fullReplica != null)
+                    break;
+
+                E dcCandidates = candidates.get(dc);
+                if (dcCandidates == null || !canSelectReplicasFromDc(dc, dcCandidates.size()))
+                    continue;
+
+                for (Replica replica : dcCandidates)
+                {
+                    if (replica.isFull())
+                    {
+                        fullReplica = replica;
+                        fullDc = dc;
+                        break;
+                    }
+                }
+            }
+
+            if (fullReplica == null)
+            {
+                // No full replicas available - throw error similar to assureSufficientLiveReplicas
+                throw UnavailableException.create(ConsistencyLevel.ONE, minBlockFor(), 1, eligibleCandidates(candidates), 0);
+            }
+
+            int totalContacts = 0;
+            int totalContactedDcs = 0;
+
+            // add dc with full replicas first
+            {
+                E dcCandidates = candidates.get(fullDc);
+                Preconditions.checkNotNull(dcCandidates);
+
+                contactBuilder.add(fullReplica);
+                totalContacts++;
+                int dcContacts = 1;
+
+                if (!haveSufficientContacts(totalContacts, totalContactedDcs))
+                {
+                    for (Replica replica : dcCandidates)
+                    {
+                        if (replica.equals(fullReplica))
+                            continue;
+
+                        if (haveSufficientContactsForDc(fullDc, totalContacts, totalContactedDcs, dcContacts))
+                            break;
+
+                        contactBuilder.add(replica);
+                        totalContacts++;
+                        dcContacts++;
+                    }
+                }
+                totalContactedDcs++;
+            }
+
+            for (String dc : planner.dcs)
+            {
+                if (dc.equals(fullDc))
+                    continue;
+
+                E dcCandidates = candidates.get(dc);
+                if (dcCandidates == null || dcCandidates.isEmpty())
+                    continue;
+
+                if (haveSufficientContacts(totalContacts, totalContactedDcs))
+                    break;
+
+                int dcContacts = 0;
+                for (Replica replica : dcCandidates)
+                {
+                    if (haveSufficientContactsForDc(dc, totalContacts, totalContactedDcs, dcContacts))
+                        break;
+
+                    contactBuilder.add(replica);
+                    totalContacts++;
+                    dcContacts++;
+                }
+
+                totalContactedDcs++;
+            }
+
+            if (planner.throwOnInsufficientLiveReplicas && !haveSufficientLiveNodes(totalContacts, totalContactedDcs))
+            {
+                int fullReplicas = 0;
+                for (String dc : planner.dcs)
+                {
+                    E dcCandidates = candidates.get(dc);
+                    if (dcCandidates == null || !canSelectReplicasFromDc(dc, dcCandidates.size()))
+                        continue;
+                    for (Replica replica : dcCandidates)
+                        if (replica.isFull())
+                            fullReplicas++;
+                }
+
+                throw UnavailableException.create(planner.cl, minBlockFor(), 1, eligibleCandidates(candidates), fullReplicas);
+            }
+
+            E result = contactBuilder.build();
+
+            if (ADDL_CHECKS_ENABLED)
+            {
+                // check for duplicate contacts
+                Set<InetAddressAndPort> seen = new HashSet<>();
+                for (Replica r : result)
+                    Preconditions.checkState(seen.add(r.endpoint()), "Duplicate contact: %s", r.endpoint());
+            }
+
+            return result;
+        }
+
+        public P build()
+        {
+            Map<String, E> candidates = planner.candidates(this);
+
+            E allEndpoints = allLiveAndDown();
+            E allCandidates = allCandidates(candidates);
+            E allContacts = contacts(candidates);
+
+            if (ADDL_CHECKS_ENABLED)
+            {
+                // contacts must be a subset of candidates
+                Set<InetAddressAndPort> candidateSet = allCandidates.endpoints();
+                for (Replica contact : allContacts)
+                    Preconditions.checkState(candidateSet.contains(contact.endpoint()), "Contact %s not in candidates", contact.endpoint());
+            }
+
+            return planner.replicaPlanBuilder(allCandidates, allContacts, allEndpoints, minBlockFor());
+        }
+
+        static class Simple<E extends Endpoints<E>, L extends ReplicaLayout<E>, P extends ReplicaPlan.ForRead<E, P>> extends ReadReplicaPlanBuilder<E, L, P>
+        {
+            private final int blockFor;
+            private final int targetContacts;
+
+            public Simple(CoordinationPlanner.ForRead<E, L, P> planner)
+            {
+                super(planner);
+
+                if (planner.cl == ConsistencyLevel.ALL)
+                {
+                    int required = 0;
+                    for (E replicas : planner.fullEndpoints.values())
+                        required += replicas.size();
+
+                    this.blockFor = required;
+                }
+                else
+                {
+                    this.blockFor = planner.cl.blockFor(planner.strategy);
+                }
+
+                this.targetContacts = planner.alwaysSpeculate ? blockFor + 1 : blockFor;
+            }
+
+            @Override
+            public void maybeThrowIndexReadException(int initial, int filtered, Map<String, E> candidates, Map<InetAddressAndPort, Index.Status> indexStatusMap)
+            {
+                if (blockFor <= initial && blockFor > filtered)
+                {
+                    IndexStatusManager.instance.readFailureException(filtered, removedSupplier(planner.fullEndpoints, candidates), indexStatusMap, planner.cl, blockFor);
+                }
+            }
+
+            @Override
+            boolean canSelectReplicasFromDc(String dc, int liveNodes)
+            {
+                return true;
+            }
+
+            @Override
+            int minBlockFor()
+            {
+                return blockFor;
+            }
+
+            @Override
+            int eligibleCandidates(Map<String, E> candidates)
+            {
+                int eligible = 0;
+                for (Map.Entry<String, E> entry : candidates.entrySet())
+                {
+                    eligible += entry.getValue().size();
+                }
+                return eligible;
+            }
+
+            @Override
+            boolean haveSufficientContacts(int contactedReplicas, int contactedDcs)
+            {
+                return contactedReplicas >= targetContacts;
+            }
+
+            @Override
+            boolean haveSufficientContactsForDc(String dc, int contactedReplicas, int contactedDcs, int dcContacts)
+            {
+                return haveSufficientContacts(contactedReplicas, contactedDcs);
+            }
+
+            @Override
+            boolean haveSufficientLiveNodes(int contactedReplicas, int contactedDcs)
+            {
+                return contactedReplicas >= blockFor;
+            }
+        }
+
+        static class DcQuorum<E extends Endpoints<E>, L extends ReplicaLayout<E>, P extends ReplicaPlan.ForRead<E, P>> extends ReadReplicaPlanBuilder<E, L, P>
+        {
+            private final int blockForDcs;
+            private final Object2IntHashMap<String> dcQuorums = new Object2IntHashMap<>(-1);
+            private boolean approvedSpeculativeReplica = false;
+
+            public DcQuorum(CoordinationPlanner.ForRead<E, L, P> planner)
+            {
+                super(planner);
+                this.blockForDcs = planner.cl != ConsistencyLevel.EACH_QUORUM ? (planner.dcs.size() / 2) + 1 : planner.dcs.size();
+                for (String dc : planner.dcs)
+                    dcQuorums.put(dc, planner.strategy.calculateQuorum(dc));
+            }
+            private int dcQuorum(String dc)
+            {
+                int v = dcQuorums.get(dc);
+                Preconditions.checkArgument(v >= 0, "dcQuorum must be >= 0");
+                return v;
+            }
+
+            @Override
+            public void maybeThrowIndexReadException(int initial, int filtered, Map<String, E> candidates, Map<InetAddressAndPort, Index.Status> indexStatusMap)
+            {
+                int required = 0;
+                int available = 0;
+                int liveDcs = 0;
+                for (Map.Entry<String, E> entry : candidates.entrySet())
+                {
+                    int dcQuorum = dcQuorum(entry.getKey());
+                    int dcLive = entry.getValue().size();
+                    if (dcLive >= dcQuorum)
+                        liveDcs++;
+
+                    required += dcQuorum;
+                    available += entry.getValue().size();
+                }
+
+                if (liveDcs <= blockForDcs)
+                {
+                    IndexStatusManager.instance.readFailureException(available, removedSupplier(planner.fullEndpoints, candidates), indexStatusMap, planner.cl, required);
+                }
+            }
+
+            @Override
+            boolean canSelectReplicasFromDc(String dc, int liveNodes)
+            {
+                int dcQuorum = dcQuorum(dc);
+                return liveNodes >= dcQuorum;
+            }
+
+            @Override
+            int minBlockFor()
+            {
+                int[] quorums = new int[planner.dcs.size()];
+                for (int i = 0; i < quorums.length; i++)
+                    quorums[i] = dcQuorum(planner.dcs.get(i));
+
+                Arrays.sort(quorums);
+
+                int blockFor = 0;
+                for (int i=0; i<blockForDcs; i++)
+                    blockFor += quorums[i];
+                return blockFor;
+            }
+
+            @Override
+            int eligibleCandidates(Map<String, E> candidates)
+            {
+                int eligible = 0;
+                for (Map.Entry<String, E> entry : candidates.entrySet())
+                {
+                    int dcLive = entry.getValue().size();
+                    int dcQuorum = dcQuorum(entry.getKey());
+                    if (dcLive >= dcQuorum)
+                        eligible += dcLive;
+                }
+
+                return eligible;
+            }
+
+            @Override
+            boolean haveSufficientContacts(int contactedReplicas, int contactedDcs)
+            {
+                return contactedDcs >= blockForDcs;
+            }
+
+            @Override
+            boolean haveSufficientContactsForDc(String dc, int contactedReplicas, int contactedDcs, int dcContacts)
+            {
+                int dcQuorum = dcQuorum(dc);
+
+                if (dcContacts < dcQuorum)
+                    return false;
+
+                if (dcContacts == dcQuorum && planner.alwaysSpeculate && !approvedSpeculativeReplica)
+                {
+                    approvedSpeculativeReplica = true;
+                    return false;
+                }
+
+                return true;
+            }
+
+            @Override
+            boolean haveSufficientLiveNodes(int contactedReplicas, int contactedDcs)
+            {
+                return contactedDcs >= blockForDcs;
+            }
+        }
+
+    }
+
+    @Override
+    protected CoordinationPlan.ForWrite planForWriteInternal(ClusterMetadata metadata,
+                                                             Keyspace keyspace,
+                                                             ConsistencyLevel consistencyLevel,
+                                                             Function<ClusterMetadata, ReplicaLayout.ForTokenWrite> liveAndDown,
+                                                             ReplicaPlans.Selector selector)
+    {
+        // writes don't need any special handling during primary failover. We just write to the current
+        // primary DC, regardless of any other failover status
+        ReplicaLayout.ForTokenWrite fullLayout = liveAndDown.apply(metadata);
+
+        CoordinationPlanner.ForWrite planner = new CoordinationPlanner.ForWrite(metadata, keyspace, consistencyLevel, this, primaryDC, fullLayout, selector);
+
+        return new CoordinationPlan.ForWrite(planner.createReplicaPlan(), planner.createResponseTracker());
+    }
+
+    /**
+     * Holds the information needed to send satellite commit mutations alongside a paxos commit.
+     * The tracker has the primary DC pre-completed, so only satellite/secondary DC quorums
+     * need actual responses.
+     */
+    static class SatelliteCommitPlan
+    {
+        final EndpointsForToken liveEndpoints;
+        final EndpointsForToken downEndpoints;
+        final ResponseTracker tracker;
+
+        SatelliteCommitPlan(EndpointsForToken liveEndpoints, EndpointsForToken downEndpoints, ResponseTracker tracker)
+        {
+            this.liveEndpoints = liveEndpoints;
+            this.downEndpoints = downEndpoints;
+            this.tracker = tracker;
+        }
+    }
+
+    SatelliteCommitPlan createSatelliteCommitPlan(ClusterMetadata metadata, Keyspace keyspace, Token token)
+    {
+        ReplicaLayout.ForTokenWrite fullLayout = ReplicaLayout.forTokenWriteLiveAndDown(metadata, keyspace, token);
+        CoordinationPlanner.ForWrite planner = new CoordinationPlanner.ForWrite(metadata, keyspace, ConsistencyLevel.QUORUM,
+                                                                             this, primaryDC, fullLayout, ReplicaPlans.writeAll);
+        ResponseTracker tracker = planner.createResponseTracker();
+
+        // paxos is handling the consensus/commit acks in the primary DC, we just need to worry about the witness DCs
+        EndpointsForToken primaryEndpoints = planner.fullEndpoints.get(primaryDC);
+        if (primaryEndpoints != null)
+        {
+            for (int i = 0; i < primaryEndpoints.size(); i++)
+                tracker.onResponse(primaryEndpoints.endpoint(i));
+        }
+
+        // Collect satellite/secondary DC endpoints from the filtered layout.
+        // planner.fullLayout is already filtered to only include DCs in the coordination plan
+        // (primary DC + its satellite + other full DCs), excluding satellites of non-primary DCs.
+        Predicate<Replica> notPrimary = rp -> !metadata.locator.location(rp.endpoint()).datacenter.equals(primaryDC);
+        EndpointsForToken allSatellite = planner.fullLayout.all().filter(notPrimary);
+        EndpointsForToken liveSatellite = allSatellite.filter(FailureDetector.isReplicaAlive);
+        EndpointsForToken downSatellite = allSatellite.filter(rp -> !FailureDetector.isReplicaAlive.test(rp));
+
+        return new SatelliteCommitPlan(liveSatellite, downSatellite, tracker);
+    }
+
+    /**
+     * Sends plain mutations to satellite/secondary DCs alongside a paxos commit.
+     *
+     * Paxos consensus handles the primary DC writes. This method sends the committed mutation to satellite DCs using
+     * the standard SRS write coordination. The primary DC group is pre-completed in the tracker so only satellite DC
+     * quorums need actual responses.
+     *
+     * @return a future that completes when satellite DC quorum requirements are met (or failed)
+     */
+    @Override
+    public Future<Void> sendPaxosCommitMutations(Agreed commit, boolean isUrgent)
+    {
+        ClusterMetadata metadata = ClusterMetadata.current();
+        Keyspace keyspace = Keyspace.open(commit.metadata().keyspace);
+        Token token = commit.partitionKey().getToken();
+
+        // Reject satellite commit writes during TRANSITION_ACK. Paxos operations should not be
+        // in progress during this state, but check defensively to avoid propagating stale commits.
+        SatelliteFailoverState.FailoverInfo failoverInfo = getFailoverInfo(token, metadata);
+        if (failoverInfo.getState() == SatelliteFailoverState.State.TRANSITION_ACK)
+            throw new UnavailableException("Paxos commit rejected during TRANSITION_ACK failover state",
+                                           ConsistencyLevel.SERIAL, 1, 0);
+
+        SatelliteCommitPlan plan = createSatelliteCommitPlan(metadata, keyspace, token);
+        ResponseTracker tracker = plan.tracker;
+        MutationId mutationId = commit.mutation.id();
+        Preconditions.checkState(!mutationId.isNone());
+
+        // Register satellite replicas with mutation tracking service
+        if (!plan.liveEndpoints.isEmpty() || !plan.downEndpoints.isEmpty())
+        {
+            IntHashSet satelliteHostIds = new IntHashSet();
+            for (int i = 0; i < plan.liveEndpoints.size(); i++)
+                satelliteHostIds.add(metadata.directory.peerId(plan.liveEndpoints.endpoint(i)).id());
+            for (int i = 0; i < plan.downEndpoints.size(); i++)
+                satelliteHostIds.add(metadata.directory.peerId(plan.downEndpoints.endpoint(i)).id());
+            if (!satelliteHostIds.isEmpty())
+                MutationTrackingService.instance().sentWriteRequest(commit.makeMutation(), satelliteHostIds);
+        }
+
+        // Create promise that resolves when satellite tracker completes
+        AsyncPromise<Void> promise = new AsyncPromise<>();
+
+        // Send plain mutations to live satellite endpoints
+        Message<Mutation> satelliteMessage = Message.out(Verb.PAXOS2_COMMIT_REMOTE_REQ, commit.makeMutation(), isUrgent);
+        for (int i = 0, mi = plan.liveEndpoints.size(); i < mi; ++i)
+        {
+            InetAddressAndPort endpoint = plan.liveEndpoints.endpoint(i);
+            logger.trace("Sending satellite commit mutation for {} to {}", commit.partitionKey(), endpoint);
+            MessagingService.instance().sendWithCallback(satelliteMessage, endpoint, new RequestCallback<NoPayload>()
+            {
+                @Override
+                public void onResponse(Message<NoPayload> msg)
+                {
+                    MutationTrackingService.instance().receivedWriteResponse(mutationId, msg.from());
+
+                    tracker.onResponse(msg.from());
+                    if (tracker.isComplete())
+                        resolvePromise(promise, tracker);
+                }
+
+                @Override
+                public void onFailure(InetAddressAndPort from, RequestFailure failure)
+                {
+                    MutationTrackingService.instance().retryFailedWrite(mutationId, from, failure);
+
+                    tracker.onFailure(from, failure.reason);
+                    if (tracker.isComplete())
+                        resolvePromise(promise, tracker);
+                }
+            });
+        }
+
+        // Mark down satellite endpoints as failed
+        for (int i = 0, mi = plan.downEndpoints.size(); i < mi; ++i)
+        {
+            InetAddressAndPort endpoint = plan.downEndpoints.endpoint(i);
+            MutationTrackingService.instance().retryFailedWrite(mutationId, endpoint, RequestFailure.NODE_DOWN);
+            tracker.onFailure(endpoint, UNKNOWN);
+        }
+
+        if (tracker.isComplete())
+            resolvePromise(promise, tracker);
+
+        return promise;
+    }
+
+    private static void resolvePromise(AsyncPromise<Void> promise, ResponseTracker tracker)
+    {
+        if (tracker.isSuccessful())
+            promise.trySuccess(null);
+        else
+            promise.tryFailure(new RuntimeException("Satellite DC quorum not met"));
+    }
+
+    private CoordinationPlan.ForTokenRead planForTokenReadPrimary(ClusterMetadata metadata,
+                                                                  String primary,
+                                                                  Keyspace keyspace,
+                                                                  TableId tableId,
+                                                                  Token token,
+                                                                  @Nullable Index.QueryPlan indexQueryPlan,
+                                                                  ConsistencyLevel consistencyLevel,
+                                                                  SpeculativeRetryPolicy retry,
+                                                                  ReadCoordinator coordinator)
+    {
+        ReplicaLayout.ForTokenRead fullLayout = ReplicaLayout.forTokenReadSorted(metadata, keyspace, this, tableId, token, coordinator);
+
+        CoordinationPlanner.ForTokenRead planner = new CoordinationPlanner.ForTokenRead(metadata, keyspace, token, consistencyLevel, this, primary, fullLayout, indexQueryPlan, retry, true, tableId, coordinator);
+
+        return new CoordinationPlan.ForTokenRead(ReplicaPlan.shared(planner.createReplicaPlan()), planner.createResponseTracker());
+    }
+
+    @Override
+    public CoordinationPlan.ForTokenRead planForTokenRead(ClusterMetadata metadata, Keyspace keyspace, TableId tableId, Token token, @Nullable Index.QueryPlan indexQueryPlan, ConsistencyLevel consistencyLevel, SpeculativeRetryPolicy retry, ReadCoordinator coordinator)
+    {
+        CoordinationPlan.ForTokenRead primaryPlan = planForTokenReadPrimary(metadata, primaryDC, keyspace, tableId, token, indexQueryPlan, consistencyLevel, retry, coordinator);
+
+        SatelliteFailoverState.FailoverInfo failoverInfo = getFailoverInfo(token, metadata);
+        if (!failoverInfo.isTransitioning())
+            return primaryPlan;
+
+        CoordinationPlan.ForTokenRead previousPlan = planForTokenReadPrimary(metadata, failoverInfo.getFromDC(), keyspace, tableId, token, indexQueryPlan, consistencyLevel, retry, coordinator);
+
+        return mergeTokenReadPlans(primaryPlan, previousPlan, cm -> strategy(cm).planForTokenRead(cm, keyspace, tableId, token, indexQueryPlan, consistencyLevel, retry, coordinator).replicas());
+    }
+
+    /**
+     * Merge two ForTokenRead coordination plans during failover transition. The primary plan's replicas are added
+     * first to preserve proximity ordering for data vs digest request selection. Replicas from the previous plan
+     * are appended if not already present in the primary plan.
+     */
+    private static CoordinationPlan.ForTokenRead mergeTokenReadPlans(CoordinationPlan.ForTokenRead primaryPlan,
+                                                                     CoordinationPlan.ForTokenRead previousPlan,
+                                                                     Function<ClusterMetadata, ReplicaPlan.ForTokenRead> recompute)
+    {
+        ReplicaPlan.ForTokenRead primary = primaryPlan.replicas();
+        ReplicaPlan.ForTokenRead previous = previousPlan.replicas();
+
+        EndpointsForToken candidates = mergeEndpoints(primary.readCandidates(), previous.readCandidates());
+        EndpointsForToken contacts = mergeEndpoints(primary.contacts(), previous.contacts());
+        EndpointsForToken liveAndDown = mergeEndpoints(primary.liveAndDown(), previous.liveAndDown());
+        int readQuorum = Math.max(primary.readQuorum(), previous.readQuorum());
+
+        ReplicaPlan.ForTokenRead merged = new ReplicaPlan.ForTokenRead(primary.keyspace(),
+                                                                       primary.replicationStrategy(),
+                                                                       primary.consistencyLevel(),
+                                                                       candidates,
+                                                                       contacts,
+                                                                       liveAndDown,
+                                                                       recompute,
+                                                                       CoordinationPlanner.ForTokenRead.READ_REPAIR_PLAN,
+                                                                       primary.epoch(),
+                                                                       readQuorum);
+
+        ResponseTracker tracker = new CompositeTracker(CompositeTracker.all(2), primaryPlan.responses(), previousPlan.responses());
+
+        return new CoordinationPlan.ForTokenRead(ReplicaPlan.shared(merged), tracker);
+    }
+
+    private static <E extends Endpoints<E>> E mergeEndpoints(E first, E second)
+    {
+        ReplicaCollection.Builder<E> builder = first.newBuilder(first.size() + second.size());
+        builder.addAll(first);
+        builder.addAll(second, ReplicaCollection.Builder.Conflict.DUPLICATE);
+        return builder.build();
+    }
+
+    private CoordinationPlan.ForRangeRead planForRangeReadPrimary(ClusterMetadata metadata,
+                                                                  String primary,
+                                                                  Keyspace keyspace,
+                                                                  TableId tableId,
+                                                                  AbstractBounds<PartitionPosition> range,
+                                                                  int vnodeCount,
+                                                                  @Nullable Index.QueryPlan indexQueryPlan,
+                                                                  ConsistencyLevel consistencyLevel)
+    {
+
+        ReplicaLayout.ForRangeRead fullLayout = ReplicaLayout.forRangeReadSorted(metadata, keyspace, this, range);
+
+        CoordinationPlanner.ForRangeRead planner = new CoordinationPlanner.ForRangeRead(metadata, keyspace, range, vnodeCount, consistencyLevel, this, primary, fullLayout, indexQueryPlan, true, tableId);
+
+        return new CoordinationPlan.ForRangeRead(ReplicaPlan.shared(planner.createReplicaPlan()), planner.createResponseTracker());
+    }
+
+
+    @Override
+    public CoordinationPlan.ForRangeRead planForRangeRead(ClusterMetadata metadata, Keyspace keyspace, TableId tableId, @Nullable Index.QueryPlan indexQueryPlan, ConsistencyLevel consistencyLevel, AbstractBounds<PartitionPosition> range, int vnodeCount)
+    {
+        CoordinationPlan.ForRangeRead primaryPlan = planForRangeReadPrimary(metadata, primaryDC, keyspace, tableId, range, vnodeCount, indexQueryPlan, consistencyLevel);
+
+        SatelliteFailoverState.FailoverInfo failoverInfo = getFailoverInfo(range, metadata);
+        if (!failoverInfo.isTransitioning())
+            return primaryPlan;
+
+        CoordinationPlan.ForRangeRead previousPlan = planForRangeReadPrimary(metadata, failoverInfo.getFromDC(), keyspace, tableId, range, vnodeCount, indexQueryPlan, consistencyLevel);
+
+        return mergeRangeReadPlans(primaryPlan, previousPlan, cm -> strategy(cm).planForRangeRead(cm, keyspace, tableId, indexQueryPlan, consistencyLevel, range, vnodeCount).replicas());
+    }
+
+    private static CoordinationPlan.ForRangeRead mergeRangeReadPlans(CoordinationPlan.ForRangeRead primaryPlan,
+                                                                     CoordinationPlan.ForRangeRead previousPlan,
+                                                                     Function<ClusterMetadata, ReplicaPlan.ForRangeRead> recompute)
+    {
+        ReplicaPlan.ForRangeRead primary = primaryPlan.replicas();
+        ReplicaPlan.ForRangeRead previous = previousPlan.replicas();
+
+        EndpointsForRange candidates = mergeEndpoints(primary.readCandidates(), previous.readCandidates());
+        EndpointsForRange contacts = mergeEndpoints(primary.contacts(), previous.contacts());
+        EndpointsForRange liveAndDown = mergeEndpoints(primary.liveAndDown(), previous.liveAndDown());
+        int readQuorum = Math.max(primary.readQuorum(), previous.readQuorum());
+
+        ReplicaPlan.ForRangeRead merged = new ReplicaPlan.ForRangeRead(primary.keyspace(),
+                                                                       primary.replicationStrategy(),
+                                                                       primary.consistencyLevel(),
+                                                                       primary.range(),
+                                                                       candidates,
+                                                                       contacts,
+                                                                       liveAndDown,
+                                                                       primary.vnodeCount(),
+                                                                       recompute,
+                                                                       CoordinationPlanner.ForRangeRead.READ_REPAIR_PLAN,
+                                                                       primary.epoch(),
+                                                                       readQuorum);
+
+        ResponseTracker tracker = new CompositeTracker(CompositeTracker.all(2), primaryPlan.responses(), previousPlan.responses());
+
+        return new CoordinationPlan.ForRangeRead(ReplicaPlan.shared(merged), tracker);
+    }
+
+    @Override
+    public CoordinationPlan.ForRangeRead planForSingleReplicaRangeRead(Keyspace keyspace, AbstractBounds<PartitionPosition> range, Replica replica, int vnodeCount)
+    {
+        throw new IllegalStateException("planForSingleReplicaRangeRead not supported by SatelliteReplicationStrategy");
+    }
+
+    @Override
+    public CoordinationPlan.ForTokenRead planForSingleReplicaTokenRead(Keyspace keyspace, Token token, Replica replica)
+    {
+        throw new IllegalStateException("planForSingleReplicaTokenRead not supported by SatelliteReplicationStrategy");
+    }
+
+    @Override
+    public CoordinationPlan.ForRangeRead maybeMergeRangeReads(ClusterMetadata metadata, Keyspace keyspace, TableId tableId, ConsistencyLevel consistencyLevel, ReplicaPlan.ForRangeRead left, ReplicaPlan.ForRangeRead right)
+    {
+        return null;
+    }
+
+    private String getSatelliteForDC(String dc)
+    {
+        for (SatelliteInfo sat : satellites.values())
+            if (sat.parentDC.equals(dc))
+                return sat.name;
+        return null;
+    }
+
+    private int calculateQuorum(String dc)
+    {
+        ReplicationFactor rf = fullDCs.get(dc);
+        if (rf != null)
+            return rf.fullReplicas / 2 + 1;
+
+        SatelliteInfo sat = satellites.get(dc);
+        return sat != null ? sat.rf.allReplicas / 2 + 1 : 0;
+    }
+
+    public SatelliteFailoverState.FailoverInfo getFailoverInfo(Token token, ClusterMetadata metadata)
+    {
+        Range<Token> range = TokenRingUtils.getRange(
+            metadata.tokenMap.tokens(), token);
+        return failoverState.getFailoverInfo(range);
+    }
+
+    public SatelliteFailoverState.FailoverInfo getFailoverInfo(AbstractBounds<PartitionPosition> range, ClusterMetadata metadata)
+    {
+        return failoverState.getFailoverInfo(range.right.getToken());

Review Comment:
   Returns a single failover info, but should return a list of pairs of ranges and failover info?



##########
src/java/org/apache/cassandra/locator/SatelliteReplicationStrategy.java:
##########
@@ -513,4 +631,1286 @@ public boolean hasSameSettings(AbstractReplicationStrategy other)
                primaryDC.equals(otherStrategy.primaryDC) &&
                disabledDCs.equals(otherStrategy.disabledDCs);
     }
+
+    private AbstractReplicationStrategy strategy(ClusterMetadata metadata)
+    {
+        return metadata.schema.getKeyspaceMetadata(keyspaceName).replicationStrategy;
+    }
+
+    static abstract class CoordinationPlanner<E extends Endpoints<E>, L extends ReplicaLayout<E>, P extends ReplicaPlan<E, P>>
+    {
+        final ClusterMetadata metadata;
+        final Keyspace keyspace;
+        final ConsistencyLevel cl;
+        final SatelliteReplicationStrategy strategy;
+        final String primary;
+        final String satellite;
+        final List<String> dcs;
+        final L fullLayout;
+        final L liveLayout;
+        final Map<String, L> fullLayouts;
+        final Map<String, L> liveLayouts;
+        final Map<String, E> fullEndpoints;
+        final Map<String, E> liveEndpoints;
+
+        public CoordinationPlanner(ClusterMetadata metadata, Keyspace keyspace, ConsistencyLevel cl, SatelliteReplicationStrategy strategy, String primary, L fullLayout)
+        {
+            this.metadata = metadata;
+            this.keyspace = keyspace;
+            this.cl = cl;
+            this.strategy = strategy;
+            this.primary = primary;
+            this.satellite = strategy.getSatelliteForDC(primary);
+
+            this.dcs = createDcList();
+            Preconditions.checkState(!dcs.isEmpty(), "No DCs available for request (primary=%s, all disabled?)", primary);
+
+            Set<String> dcSet = new HashSet<>(dcs);
+            this.fullLayout = filterLayout(fullLayout, rp -> dcSet.contains(metadata.locator.location(rp.endpoint()).datacenter));
+            this.liveLayout = filterLayout(this.fullLayout, FailureDetector.isReplicaAlive);
+
+            this.fullLayouts = Maps.newHashMapWithExpectedSize(dcs.size());
+            this.liveLayouts = Maps.newHashMapWithExpectedSize(dcs.size());
+
+            this.fullEndpoints = Maps.newHashMapWithExpectedSize(dcs.size());
+            this.liveEndpoints = Maps.newHashMapWithExpectedSize(dcs.size());
+
+            populateDcLayouts();
+        }
+
+        AbstractReplicationStrategy strategy(ClusterMetadata metadata)
+        {
+            return metadata.schema.getKeyspaceMetadata(keyspace.getName()).replicationStrategy;
+        }
+
+        List<String> createDcList()
+        {
+            List<String> results = new ArrayList<>();
+            if (!strategy.disabledDCs.contains(primary))
+                results.add(primary);
+
+            if (satellite != null && !strategy.disabledDCs.contains(satellite))
+                results.add(satellite);
+
+            for (String dc : strategy.fullDCs.keySet())
+            {
+                if (dc.equals(primary) || dc.equals(satellite) || strategy.disabledDCs.contains(dc))
+                    continue;
+                results.add(dc);
+            }
+
+            Preconditions.checkState(!results.isEmpty());
+            return results;
+        }
+
+        void populateDcLayouts()
+        {
+            for (String dc : dcs)
+            {
+                Predicate<Replica> dcFilter = rp -> metadata.locator.location(rp.endpoint()).datacenter.equals(dc);
+                L full = filterLayout(fullLayout, dcFilter);
+                L live = filterLayout(liveLayout, dcFilter);
+
+                fullLayouts.put(dc, full);
+                liveLayouts.put(dc, live);
+
+                fullEndpoints.put(dc, full.natural());
+                liveEndpoints.put(dc, live.natural());
+            }
+
+            if (ADDL_CHECKS_ENABLED)
+                Preconditions.checkState(fullLayouts.size() == dcs.size(), "populateDcLayouts: fullLayouts.size()=%s != dcs.size()=%s", fullLayouts.size(), dcs.size());
+        }
+
+        ResponseTrackerBuilder<E, L, P> createResponseTrackerBuilder()
+        {
+            switch (cl)
+            {
+                case ONE:
+                case LOCAL_ONE:
+                case NODE_LOCAL:
+                case ANY:
+                case TWO:
+                case THREE:
+                case ALL:
+                    return new ResponseTrackerBuilder.Simple<>(this);
+
+                case QUORUM:
+                case LOCAL_QUORUM:
+                    // LOCAL_QUORUM an QUORUM mean the same thing in SRS
+                    return new ResponseTrackerBuilder.Quorum<>(this);
+
+                case EACH_QUORUM:
+                    return new ResponseTrackerBuilder.EachQuorum<>(this);
+
+                default:
+                    throw new IllegalStateException("Unsupported consistency level: " + cl);
+            }
+        }
+
+        ResponseTracker createResponseTracker()
+        {
+            ResponseTrackerBuilder<E, L, P> builder = createResponseTrackerBuilder();
+            return builder.build();
+        }
+
+        abstract L filterLayout(L layout, Predicate<Replica> predicate);
+        abstract ResponseTracker createDcResponseTracker(String dc);
+
+        abstract P recomputePlan(ClusterMetadata metadata);
+        abstract P createReplicaPlan();
+
+        interface IndexReadExceptionCheck<E extends Endpoints<E>>
+        {
+            void maybeThrowIndexReadException(int initial, int filtered, Map<String, E> candidates, Map<InetAddressAndPort, Index.Status> indexStatusMap);
+        }
+
+        static class ForWrite extends CoordinationPlanner<EndpointsForToken, ReplicaLayout.ForTokenWrite, ReplicaPlan.ForWrite>
+        {
+            final ReplicaPlans.Selector selector;
+
+            public ForWrite(ClusterMetadata metadata, Keyspace keyspace, ConsistencyLevel cl, SatelliteReplicationStrategy strategy, String primary, ReplicaLayout.ForTokenWrite fullLayout, ReplicaPlans.Selector selector)
+            {
+                super(metadata, keyspace, cl, strategy, primary, fullLayout);
+                this.selector = selector;
+                if (ADDL_CHECKS_ENABLED)
+                {
+                    Preconditions.checkState(!strategy.disabledDCs.contains(primary));
+                }
+            }
+
+            @Override
+            ResponseTracker createDcResponseTracker(String dc)
+            {
+                // this basically hardcodes ReplicaPlans.writeAll
+                Predicate<InetAddressAndPort> dcFilter = ep -> metadata.locator.location(ep).datacenter.equals(dc);
+
+                ReplicaLayout.ForTokenWrite dcLayout = fullLayouts.get(dc);
+                Preconditions.checkNotNull(dcLayout);
+
+                int blockFor = strategy.calculateQuorum(dc);
+                int totalContacts = dcLayout.all().size();
+
+                // Only count pending replicas that are actually in the selected contacts
+                int pendingInDc = dcLayout.pending().size();
+
+                if (pendingInDc == 0)
+                {
+                    return new SimpleResponseTracker(blockFor, totalContacts, dcFilter);
+                }
+                else
+                {
+                    int committedContacts = totalContacts - pendingInDc;
+                    int totalBlockFor = blockFor + pendingInDc;
+                    Predicate<InetAddressAndPort> isPending = ep -> dcLayout.pending.endpoints().contains(ep);
+                    return new WriteResponseTracker(blockFor, totalBlockFor,
+                                                    committedContacts, pendingInDc,
+                                                    isPending, dcFilter);
+                }
+            }
+
+            @Override
+            ReplicaPlan.ForWrite recomputePlan(ClusterMetadata metadata)
+            {
+                Token token = fullLayout.token();
+                return strategy(metadata).planForWrite(metadata, keyspace, cl,
+                                                              cm -> ReplicaLayout.forTokenWriteLiveAndDown(cm, keyspace, token),
+                                                       selector).replicas();
+            }
+
+            @Override
+            ReplicaLayout.ForTokenWrite filterLayout(ReplicaLayout.ForTokenWrite layout, Predicate<Replica> predicate)
+            {
+                return layout.filter(predicate);
+            }
+
+            private ReplicaLayout.ForTokenWrite mergeLayouts(Collection<ReplicaLayout.ForTokenWrite> layouts)
+            {
+                ReplicaCollection.Builder<EndpointsForToken> naturalBuilder = null;
+                ReplicaCollection.Builder<EndpointsForToken> pendingBuilder = null;
+
+                for (ReplicaLayout.ForTokenWrite layout : layouts)
+                {
+                    EndpointsForToken natural = layout.natural();
+                    EndpointsForToken pending = layout.pending();
+                    if (naturalBuilder == null)
+                    {
+                        naturalBuilder = natural.newBuilder(natural.size());
+                        pendingBuilder = pending.newBuilder(pending.size());
+                    }
+
+                    naturalBuilder.addAll(natural);
+                    pendingBuilder.addAll(pending);
+                }
+
+                return new ReplicaLayout.ForTokenWrite(strategy, naturalBuilder.build(), pendingBuilder.build());
+            }
+
+            @Override
+            ReplicaPlan.ForWrite createReplicaPlan()
+            {
+
+                ReplicaLayout.ForTokenWrite fullLayout = mergeLayouts(fullLayouts.values());
+                ReplicaLayout.ForTokenWrite liveLayout = mergeLayouts(liveLayouts.values());
+                EndpointsForToken contacts = selector.select(cl, fullLayout, liveLayout);
+
+                return new ReplicaPlan.ForWrite(keyspace,
+                                                strategy,
+                                                cl,
+                                                fullLayout.pending(),
+                                                fullLayout.all(),
+                                                liveLayout.all(),
+                                                contacts,
+                                                this::recomputePlan,
+                                                metadata.epoch);
+            }
+        }
+
+        static abstract class ForRead<E extends Endpoints<E>, L extends ReplicaLayout<E>, P extends ReplicaPlan.ForRead<E, P>> extends CoordinationPlanner<E, L, P>
+        {
+            final @Nullable Index.QueryPlan indexQueryPlan;
+            final boolean alwaysSpeculate;
+            final boolean throwOnInsufficientLiveReplicas;
+
+            public ForRead(ClusterMetadata metadata, Keyspace keyspace, ConsistencyLevel cl, SatelliteReplicationStrategy strategy, String primary, L fullLayout, @Nullable Index.QueryPlan indexQueryPlan, boolean alwaysSpeculate, boolean throwOnInsufficientLiveReplicas)
+            {
+                super(metadata, keyspace, cl, strategy, primary, fullLayout);
+                this.indexQueryPlan = indexQueryPlan;
+                this.alwaysSpeculate = alwaysSpeculate;
+                this.throwOnInsufficientLiveReplicas = throwOnInsufficientLiveReplicas;
+            }
+
+            @Override
+            ResponseTracker createDcResponseTracker(String dc)
+            {
+                Predicate<InetAddressAndPort> dcFilter = ep -> metadata.locator.location(ep).datacenter.equals(dc);
+
+                L dcLayout = fullLayouts.get(dc);
+                int blockFor = strategy.calculateQuorum(dc);
+                int totalContacts = dcLayout.all().size();
+
+                return new SimpleResponseTracker(blockFor, totalContacts, dcFilter);
+            }
+
+            Map<String, E> candidates(IndexReadExceptionCheck<E> check)
+            {
+                if (indexQueryPlan == null)
+                    return liveEndpoints;
+
+                Map<String, E> candidates = new HashMap<>();
+                Map<InetAddressAndPort, Index.Status> indexStatusMap = new HashMap<>();
+                int initial = 0;
+                int filtered = 0;
+                for (Map.Entry<String, E> entry : liveEndpoints.entrySet())
+                {
+                    E liveEndpoints = entry.getValue();
+                    E queryableEndpoints = IndexStatusManager.instance.filterForQuery(liveEndpoints, keyspace.getName(), indexQueryPlan, indexStatusMap);
+                    initial += liveEndpoints.size();
+                    filtered += queryableEndpoints.size();
+                    candidates.put(entry.getKey(), queryableEndpoints);
+                }
+
+                if (initial != filtered)
+                    check.maybeThrowIndexReadException(initial, filtered, candidates, indexStatusMap);
+
+                return candidates;
+            }
+
+            @Override
+            ResponseTracker createResponseTracker()
+            {
+                ResponseTracker tracker = super.createResponseTracker();
+
+                // read trackers don't wait on pending nodes
+                Preconditions.checkState(tracker.pendingContacts() == 0);
+                return tracker;
+            }
+
+
+            abstract P replicaPlanBuilder(E candidates, E contacts, E liveAndDown, int quorumSize);
+
+            ReadReplicaPlanBuilder<E, L, P> replicaPlanBuilder()
+            {
+                switch (cl)
+                {
+                    case ONE:
+                    case LOCAL_ONE:
+                    case NODE_LOCAL:
+                    case ANY:
+                    case TWO:
+                    case THREE:
+                    case ALL:
+                        return new ReadReplicaPlanBuilder.Simple<>(this);
+
+                    case QUORUM:
+                    case LOCAL_QUORUM:
+                    case EACH_QUORUM:
+                        return new ReadReplicaPlanBuilder.DcQuorum<>(this);
+
+                    default:
+                        throw new IllegalStateException("Unsupported consistency level: " + cl);
+                }
+            }
+
+            @Override
+            P createReplicaPlan()
+            {
+                ReadReplicaPlanBuilder<E, L, P> builder = replicaPlanBuilder();
+                return builder.build();
+            }
+        }
+
+        static class ForTokenRead extends ForRead<EndpointsForToken, ReplicaLayout.ForTokenRead, ReplicaPlan.ForTokenRead>
+        {
+            static final Function<ReplicaPlan<?, ?>, ReplicaPlan.ForWrite> READ_REPAIR_PLAN = (ReplicaPlan<?, ?> self) -> {throw new IllegalStateException("Cannot create read repair plans for SatelliteDatacenterReplicationStrategy");};
+            final Token token;
+            final TableId tableId;
+            final SpeculativeRetryPolicy retry;
+            final ReadCoordinator coordinator;
+
+            public ForTokenRead(ClusterMetadata metadata, Keyspace keyspace, Token token, ConsistencyLevel cl, SatelliteReplicationStrategy strategy, String primary, ReplicaLayout.ForTokenRead fullLayout, @Nullable Index.QueryPlan indexQueryPlan, SpeculativeRetryPolicy retry, boolean throwOnInsufficientLiveReplicas, TableId tableId, ReadCoordinator coordinator)
+            {
+                super(metadata, keyspace, cl, strategy, primary, fullLayout, indexQueryPlan, retry == AlwaysSpeculativeRetryPolicy.INSTANCE, throwOnInsufficientLiveReplicas);
+                this.token = token;
+                this.tableId = tableId;
+                this.retry = retry;
+                this.coordinator = coordinator;
+            }
+
+            @Override
+            ReplicaLayout.ForTokenRead filterLayout(ReplicaLayout.ForTokenRead layout, Predicate<Replica> predicate)
+            {
+                return layout.filter(predicate);
+            }
+
+            @Override
+            ReplicaPlan.ForTokenRead recomputePlan(ClusterMetadata metadata)
+            {
+                return strategy(metadata).planForTokenRead(metadata, keyspace, tableId, token,
+                                                           indexQueryPlan, cl, retry, coordinator).replicas();
+            }
+
+            @Override
+            ReplicaPlan.ForTokenRead replicaPlanBuilder(EndpointsForToken candidates, EndpointsForToken contacts, EndpointsForToken liveAndDown, int quorumSize)
+            {
+                return new ReplicaPlan.ForTokenRead(keyspace, strategy, cl,
+                                                    candidates, contacts, liveAndDown,
+                                                    this::recomputePlan,
+                                                    READ_REPAIR_PLAN,
+                                                    metadata.epoch,
+                                                    quorumSize);
+            }
+        }
+
+        static class ForRangeRead extends ForRead<EndpointsForRange, ReplicaLayout.ForRangeRead, ReplicaPlan.ForRangeRead>
+        {
+            static final BiFunction<ReplicaPlan<?, ?>, Token, ReplicaPlan.ForWrite> READ_REPAIR_PLAN = (ReplicaPlan<?, ?> self, Token token) -> {throw new IllegalStateException("Cannot create read repair plans for SatelliteDatacenterReplicationStrategy");};
+            final AbstractBounds<PartitionPosition> range;
+            final int vnodeCount;
+            final TableId tableId;
+
+            public ForRangeRead(ClusterMetadata metadata, Keyspace keyspace, AbstractBounds<PartitionPosition> range, int vnodeCount, ConsistencyLevel cl, SatelliteReplicationStrategy strategy, String primary, ReplicaLayout.ForRangeRead fullLayout, @Nullable Index.QueryPlan indexQueryPlan, boolean throwOnInsufficientLiveReplicas, TableId tableId)
+            {
+                super(metadata, keyspace, cl, strategy, primary, fullLayout, indexQueryPlan, false, throwOnInsufficientLiveReplicas);
+                this.range = range;
+                this.vnodeCount = vnodeCount;
+                this.tableId = tableId;
+            }
+
+            @Override
+            ReplicaLayout.ForRangeRead filterLayout(ReplicaLayout.ForRangeRead layout, Predicate<Replica> predicate)
+            {
+                return layout.filter(predicate);
+            }
+
+            @Override
+            ReplicaPlan.ForRangeRead recomputePlan(ClusterMetadata metadata)
+            {
+                return strategy(metadata).planForRangeRead(metadata, keyspace, tableId,
+                                                           indexQueryPlan, cl, range, vnodeCount).replicas();
+            }
+
+            @Override
+            ReplicaPlan.ForRangeRead replicaPlanBuilder(EndpointsForRange candidates, EndpointsForRange contacts, EndpointsForRange liveAndDown, int quorumSize)
+            {
+                return new ReplicaPlan.ForRangeRead(keyspace, strategy, cl, range,
+                                                    candidates, contacts, liveAndDown, vnodeCount,
+                                                    this::recomputePlan,
+                                                    READ_REPAIR_PLAN,
+                                                    metadata.epoch,
+                                                    quorumSize);
+            }
+        }
+    }
+
+    static abstract class ResponseTrackerBuilder<E extends Endpoints<E>, L extends ReplicaLayout<E>, P extends ReplicaPlan<E, P>>
+    {
+        final CoordinationPlanner<E, L, P> planner;
+
+        public ResponseTrackerBuilder(CoordinationPlanner<E, L, P> planner)
+        {
+            this.planner = planner;
+        }
+
+        Map<String, ResponseTracker> responseTrackersForPrimary()
+        {
+            Map<String, ResponseTracker> result = Maps.newHashMapWithExpectedSize(planner.dcs.size());
+
+            for (String dc : planner.dcs)
+                result.put(dc, planner.createDcResponseTracker(dc));
+
+            return result;
+        }
+
+        abstract ResponseTracker aggregateTrackers(Map<String, ResponseTracker> dcTrackers);
+
+        ResponseTracker build()
+        {
+            Map<String, ResponseTracker> dcTrackers = responseTrackersForPrimary();
+            return aggregateTrackers(dcTrackers);
+        }
+
+        static class Simple<E extends Endpoints<E>, L extends ReplicaLayout<E>, P extends ReplicaPlan<E, P>> extends ResponseTrackerBuilder<E, L, P>
+        {
+            public Simple(CoordinationPlanner<E, L, P> planner)
+            {
+                super(planner);
+            }
+
+            @Override
+            ResponseTracker aggregateTrackers(Map<String, ResponseTracker> dcTrackers)
+            {
+                int totalContacts = 0;
+                int totalPending = 0;
+
+
+                List<ResponseTracker> trackers = Lists.newArrayList();
+                Set<String> blockingDatacenters = Sets.newHashSet();
+
+                for (Map.Entry<String, ResponseTracker> entry : dcTrackers.entrySet())
+                {
+                    String dc = entry.getKey();
+                    ResponseTracker tracker = entry.getValue();
+                    if (planner.cl == ConsistencyLevel.ALL || dc.equals(planner.primary) || dc.equals(planner.satellite))
+                    {
+                        totalContacts += tracker.totalContacts();
+                        totalPending += tracker.pendingContacts();
+                        trackers.add(tracker);
+                        blockingDatacenters.add(dc);
+                    }
+                }
+
+                Predicate<InetAddressAndPort> filter = i -> {
+                    String dc = planner.metadata.locator.location(i).datacenter;
+                    return dc != null && blockingDatacenters.contains(dc);
+                };
+
+                Preconditions.checkState(!trackers.isEmpty(), "No blocking DCs for CL %s (primary=%s, satellite=%s)", planner.cl, planner.primary, planner.satellite);
+
+
+                if (planner.cl == ConsistencyLevel.ALL)
+                    return new SimpleResponseTracker(totalContacts, totalContacts, filter);
+
+                int blockFor = planner.cl.blockFor(planner.strategy);
+                int totalBlockFor = blockFor + totalPending;
+
+                Preconditions.checkState(blockFor <= totalContacts, "Simple tracker: blockFor=%s > totalContacts=%s for CL %s", blockFor, totalContacts, planner.cl);
+
+
+                if (totalPending == 0)
+                    return new SimpleResponseTracker(blockFor, totalContacts, filter);
+
+                Predicate<InetAddressAndPort> pending = i -> {
+                    for (ResponseTracker tracker : trackers)
+                    {
+                        if (tracker.isPending(i))
+                            return true;
+                    }
+                    return false;
+                };
+                return new WriteResponseTracker(blockFor, totalBlockFor, totalContacts, totalPending, pending, filter);
+            }
+        }
+
+        static class Quorum<E extends Endpoints<E>, L extends ReplicaLayout<E>, P extends ReplicaPlan<E, P>> extends ResponseTrackerBuilder<E, L, P>
+        {
+            public Quorum(CoordinationPlanner<E, L, P> planner)
+            {
+                super(planner);
+            }
+
+            @Override
+            ResponseTracker aggregateTrackers(Map<String, ResponseTracker> dcTrackers)
+            {
+                return new CompositeTracker(CompositeTracker.quorum(dcTrackers.size()), new ArrayList<>(dcTrackers.values()));
+            }
+        }
+
+        static class EachQuorum<E extends Endpoints<E>, L extends ReplicaLayout<E>, P extends ReplicaPlan<E, P>> extends ResponseTrackerBuilder<E, L, P>
+        {
+            public EachQuorum(CoordinationPlanner<E, L, P> planner)
+            {
+                super(planner);
+            }
+
+            @Override
+            ResponseTracker aggregateTrackers(Map<String, ResponseTracker> dcTrackers)
+            {
+                return new CompositeTracker(CompositeTracker.all(dcTrackers.size()), new ArrayList<>(dcTrackers.values()));
+            }
+        }
+    }
+
+    static abstract class ReadReplicaPlanBuilder<E extends Endpoints<E>, L extends ReplicaLayout<E>, P extends ReplicaPlan.ForRead<E, P>>
+            implements CoordinationPlanner.IndexReadExceptionCheck<E>
+    {
+
+        private static <E extends Endpoints<E>> Iterable<Replica> removedSupplier(Map<String, E> initial, Map<String, E> filtered)
+        {
+            return () -> initial.keySet().stream().map(dc -> {
+                Set<InetAddressAndPort> f =  filtered.containsKey(dc) ? filtered.get(dc).endpoints() : Collections.emptySet();
+                return initial.get(dc).without(f);
+            }).flatMap(AbstractReplicaCollection::stream).iterator();
+        }
+
+        final CoordinationPlanner.ForRead<E, L, P> planner;
+
+        public ReadReplicaPlanBuilder(CoordinationPlanner.ForRead<E, L, P> planner)
+        {
+            this.planner = planner;
+        }
+
+        abstract boolean canSelectReplicasFromDc(String dc, int liveNodes);
+        abstract int minBlockFor();
+        abstract int eligibleCandidates(Map<String, E> candidates);
+
+        abstract boolean haveSufficientContacts(int contactedReplicas, int contactedDcs);
+        abstract boolean haveSufficientContactsForDc(String dc, int contactedReplicas, int contactedDcs, int dcContacts);
+        abstract boolean haveSufficientLiveNodes(int contactedReplicas, int contactedDcs);
+
+        private E allLiveAndDown()
+        {
+            E fullEndpoints = planner.fullLayout.all();
+            ReplicaCollection.Builder<E> fullBuilder = fullEndpoints.newBuilder(fullEndpoints.size());
+
+            for (String dc : planner.dcs)
+            {
+                E endpoints = planner.fullEndpoints.get(dc);
+                if (endpoints == null)
+                    continue;
+
+                fullBuilder.addAll(endpoints);
+            }
+
+            return fullBuilder.build();
+        }
+
+        private E allCandidates(Map<String, E> candidates)
+        {
+            E liveEndpoints = planner.liveLayout.all();
+            ReplicaCollection.Builder<E> candidateBuilder = liveEndpoints.newBuilder(liveEndpoints.size());
+
+            for (String dc : planner.dcs)
+            {
+                // TODO: make sure the full replica is added if this is the dc it's from
+                E dcCandidates = candidates.get(dc);
+                if (dcCandidates == null || !canSelectReplicasFromDc(dc, dcCandidates.size()))
+                    continue;
+
+                candidateBuilder.addAll(dcCandidates);
+            }
+
+            return candidateBuilder.build();
+        }
+
+        private E contacts(Map<String, E> candidates)
+        {
+            E liveEndpoints = planner.liveLayout.all();
+
+            ReplicaCollection.Builder<E> contactBuilder = liveEndpoints.newBuilder(liveEndpoints.size());
+
+            // find full replica
+            Replica fullReplica = null;
+            String fullDc = null;
+            for (String dc : planner.dcs)
+            {
+                if (fullReplica != null)
+                    break;
+
+                E dcCandidates = candidates.get(dc);
+                if (dcCandidates == null || !canSelectReplicasFromDc(dc, dcCandidates.size()))
+                    continue;
+
+                for (Replica replica : dcCandidates)
+                {
+                    if (replica.isFull())
+                    {
+                        fullReplica = replica;
+                        fullDc = dc;
+                        break;
+                    }
+                }
+            }
+
+            if (fullReplica == null)
+            {
+                // No full replicas available - throw error similar to assureSufficientLiveReplicas
+                throw UnavailableException.create(ConsistencyLevel.ONE, minBlockFor(), 1, eligibleCandidates(candidates), 0);
+            }
+
+            int totalContacts = 0;
+            int totalContactedDcs = 0;
+
+            // add dc with full replicas first
+            {
+                E dcCandidates = candidates.get(fullDc);
+                Preconditions.checkNotNull(dcCandidates);
+
+                contactBuilder.add(fullReplica);
+                totalContacts++;
+                int dcContacts = 1;
+
+                if (!haveSufficientContacts(totalContacts, totalContactedDcs))
+                {
+                    for (Replica replica : dcCandidates)
+                    {
+                        if (replica.equals(fullReplica))
+                            continue;
+
+                        if (haveSufficientContactsForDc(fullDc, totalContacts, totalContactedDcs, dcContacts))
+                            break;
+
+                        contactBuilder.add(replica);
+                        totalContacts++;
+                        dcContacts++;
+                    }
+                }
+                totalContactedDcs++;
+            }
+
+            for (String dc : planner.dcs)
+            {
+                if (dc.equals(fullDc))
+                    continue;
+
+                E dcCandidates = candidates.get(dc);
+                if (dcCandidates == null || dcCandidates.isEmpty())
+                    continue;
+
+                if (haveSufficientContacts(totalContacts, totalContactedDcs))
+                    break;
+
+                int dcContacts = 0;
+                for (Replica replica : dcCandidates)
+                {
+                    if (haveSufficientContactsForDc(dc, totalContacts, totalContactedDcs, dcContacts))
+                        break;
+
+                    contactBuilder.add(replica);
+                    totalContacts++;
+                    dcContacts++;
+                }
+
+                totalContactedDcs++;
+            }
+
+            if (planner.throwOnInsufficientLiveReplicas && !haveSufficientLiveNodes(totalContacts, totalContactedDcs))
+            {
+                int fullReplicas = 0;
+                for (String dc : planner.dcs)
+                {
+                    E dcCandidates = candidates.get(dc);
+                    if (dcCandidates == null || !canSelectReplicasFromDc(dc, dcCandidates.size()))
+                        continue;
+                    for (Replica replica : dcCandidates)
+                        if (replica.isFull())
+                            fullReplicas++;
+                }
+
+                throw UnavailableException.create(planner.cl, minBlockFor(), 1, eligibleCandidates(candidates), fullReplicas);
+            }
+
+            E result = contactBuilder.build();
+
+            if (ADDL_CHECKS_ENABLED)
+            {
+                // check for duplicate contacts
+                Set<InetAddressAndPort> seen = new HashSet<>();
+                for (Replica r : result)
+                    Preconditions.checkState(seen.add(r.endpoint()), "Duplicate contact: %s", r.endpoint());
+            }
+
+            return result;
+        }
+
+        public P build()
+        {
+            Map<String, E> candidates = planner.candidates(this);
+
+            E allEndpoints = allLiveAndDown();
+            E allCandidates = allCandidates(candidates);
+            E allContacts = contacts(candidates);
+
+            if (ADDL_CHECKS_ENABLED)
+            {
+                // contacts must be a subset of candidates
+                Set<InetAddressAndPort> candidateSet = allCandidates.endpoints();
+                for (Replica contact : allContacts)
+                    Preconditions.checkState(candidateSet.contains(contact.endpoint()), "Contact %s not in candidates", contact.endpoint());
+            }
+
+            return planner.replicaPlanBuilder(allCandidates, allContacts, allEndpoints, minBlockFor());
+        }
+
+        static class Simple<E extends Endpoints<E>, L extends ReplicaLayout<E>, P extends ReplicaPlan.ForRead<E, P>> extends ReadReplicaPlanBuilder<E, L, P>
+        {
+            private final int blockFor;
+            private final int targetContacts;
+
+            public Simple(CoordinationPlanner.ForRead<E, L, P> planner)
+            {
+                super(planner);
+
+                if (planner.cl == ConsistencyLevel.ALL)
+                {
+                    int required = 0;
+                    for (E replicas : planner.fullEndpoints.values())
+                        required += replicas.size();
+
+                    this.blockFor = required;
+                }
+                else
+                {
+                    this.blockFor = planner.cl.blockFor(planner.strategy);
+                }
+
+                this.targetContacts = planner.alwaysSpeculate ? blockFor + 1 : blockFor;
+            }
+
+            @Override
+            public void maybeThrowIndexReadException(int initial, int filtered, Map<String, E> candidates, Map<InetAddressAndPort, Index.Status> indexStatusMap)
+            {
+                if (blockFor <= initial && blockFor > filtered)
+                {
+                    IndexStatusManager.instance.readFailureException(filtered, removedSupplier(planner.fullEndpoints, candidates), indexStatusMap, planner.cl, blockFor);
+                }
+            }
+
+            @Override
+            boolean canSelectReplicasFromDc(String dc, int liveNodes)
+            {
+                return true;
+            }
+
+            @Override
+            int minBlockFor()
+            {
+                return blockFor;
+            }
+
+            @Override
+            int eligibleCandidates(Map<String, E> candidates)
+            {
+                int eligible = 0;
+                for (Map.Entry<String, E> entry : candidates.entrySet())
+                {
+                    eligible += entry.getValue().size();
+                }
+                return eligible;
+            }
+
+            @Override
+            boolean haveSufficientContacts(int contactedReplicas, int contactedDcs)
+            {
+                return contactedReplicas >= targetContacts;
+            }
+
+            @Override
+            boolean haveSufficientContactsForDc(String dc, int contactedReplicas, int contactedDcs, int dcContacts)
+            {
+                return haveSufficientContacts(contactedReplicas, contactedDcs);
+            }
+
+            @Override
+            boolean haveSufficientLiveNodes(int contactedReplicas, int contactedDcs)
+            {
+                return contactedReplicas >= blockFor;
+            }
+        }
+
+        static class DcQuorum<E extends Endpoints<E>, L extends ReplicaLayout<E>, P extends ReplicaPlan.ForRead<E, P>> extends ReadReplicaPlanBuilder<E, L, P>
+        {
+            private final int blockForDcs;
+            private final Object2IntHashMap<String> dcQuorums = new Object2IntHashMap<>(-1);
+            private boolean approvedSpeculativeReplica = false;
+
+            public DcQuorum(CoordinationPlanner.ForRead<E, L, P> planner)
+            {
+                super(planner);
+                this.blockForDcs = planner.cl != ConsistencyLevel.EACH_QUORUM ? (planner.dcs.size() / 2) + 1 : planner.dcs.size();
+                for (String dc : planner.dcs)
+                    dcQuorums.put(dc, planner.strategy.calculateQuorum(dc));
+            }
+            private int dcQuorum(String dc)
+            {
+                int v = dcQuorums.get(dc);
+                Preconditions.checkArgument(v >= 0, "dcQuorum must be >= 0");
+                return v;
+            }
+
+            @Override
+            public void maybeThrowIndexReadException(int initial, int filtered, Map<String, E> candidates, Map<InetAddressAndPort, Index.Status> indexStatusMap)
+            {
+                int required = 0;
+                int available = 0;
+                int liveDcs = 0;
+                for (Map.Entry<String, E> entry : candidates.entrySet())
+                {
+                    int dcQuorum = dcQuorum(entry.getKey());
+                    int dcLive = entry.getValue().size();
+                    if (dcLive >= dcQuorum)
+                        liveDcs++;
+
+                    required += dcQuorum;
+                    available += entry.getValue().size();
+                }
+
+                if (liveDcs <= blockForDcs)
+                {
+                    IndexStatusManager.instance.readFailureException(available, removedSupplier(planner.fullEndpoints, candidates), indexStatusMap, planner.cl, required);
+                }
+            }
+
+            @Override
+            boolean canSelectReplicasFromDc(String dc, int liveNodes)
+            {
+                int dcQuorum = dcQuorum(dc);
+                return liveNodes >= dcQuorum;
+            }
+
+            @Override
+            int minBlockFor()
+            {
+                int[] quorums = new int[planner.dcs.size()];
+                for (int i = 0; i < quorums.length; i++)
+                    quorums[i] = dcQuorum(planner.dcs.get(i));
+
+                Arrays.sort(quorums);
+
+                int blockFor = 0;
+                for (int i=0; i<blockForDcs; i++)
+                    blockFor += quorums[i];
+                return blockFor;
+            }
+
+            @Override
+            int eligibleCandidates(Map<String, E> candidates)
+            {
+                int eligible = 0;
+                for (Map.Entry<String, E> entry : candidates.entrySet())
+                {
+                    int dcLive = entry.getValue().size();
+                    int dcQuorum = dcQuorum(entry.getKey());
+                    if (dcLive >= dcQuorum)
+                        eligible += dcLive;
+                }
+
+                return eligible;
+            }
+
+            @Override
+            boolean haveSufficientContacts(int contactedReplicas, int contactedDcs)
+            {
+                return contactedDcs >= blockForDcs;
+            }
+
+            @Override
+            boolean haveSufficientContactsForDc(String dc, int contactedReplicas, int contactedDcs, int dcContacts)
+            {
+                int dcQuorum = dcQuorum(dc);
+
+                if (dcContacts < dcQuorum)
+                    return false;
+
+                if (dcContacts == dcQuorum && planner.alwaysSpeculate && !approvedSpeculativeReplica)
+                {
+                    approvedSpeculativeReplica = true;
+                    return false;
+                }
+
+                return true;
+            }
+
+            @Override
+            boolean haveSufficientLiveNodes(int contactedReplicas, int contactedDcs)
+            {
+                return contactedDcs >= blockForDcs;
+            }
+        }
+
+    }
+
+    @Override
+    protected CoordinationPlan.ForWrite planForWriteInternal(ClusterMetadata metadata,
+                                                             Keyspace keyspace,
+                                                             ConsistencyLevel consistencyLevel,
+                                                             Function<ClusterMetadata, ReplicaLayout.ForTokenWrite> liveAndDown,
+                                                             ReplicaPlans.Selector selector)
+    {
+        // writes don't need any special handling during primary failover. We just write to the current
+        // primary DC, regardless of any other failover status
+        ReplicaLayout.ForTokenWrite fullLayout = liveAndDown.apply(metadata);
+
+        CoordinationPlanner.ForWrite planner = new CoordinationPlanner.ForWrite(metadata, keyspace, consistencyLevel, this, primaryDC, fullLayout, selector);
+
+        return new CoordinationPlan.ForWrite(planner.createReplicaPlan(), planner.createResponseTracker());
+    }
+
+    /**
+     * Holds the information needed to send satellite commit mutations alongside a paxos commit.
+     * The tracker has the primary DC pre-completed, so only satellite/secondary DC quorums
+     * need actual responses.
+     */
+    static class SatelliteCommitPlan
+    {
+        final EndpointsForToken liveEndpoints;
+        final EndpointsForToken downEndpoints;
+        final ResponseTracker tracker;
+
+        SatelliteCommitPlan(EndpointsForToken liveEndpoints, EndpointsForToken downEndpoints, ResponseTracker tracker)
+        {
+            this.liveEndpoints = liveEndpoints;
+            this.downEndpoints = downEndpoints;
+            this.tracker = tracker;
+        }
+    }
+
+    SatelliteCommitPlan createSatelliteCommitPlan(ClusterMetadata metadata, Keyspace keyspace, Token token)
+    {
+        ReplicaLayout.ForTokenWrite fullLayout = ReplicaLayout.forTokenWriteLiveAndDown(metadata, keyspace, token);
+        CoordinationPlanner.ForWrite planner = new CoordinationPlanner.ForWrite(metadata, keyspace, ConsistencyLevel.QUORUM,
+                                                                             this, primaryDC, fullLayout, ReplicaPlans.writeAll);
+        ResponseTracker tracker = planner.createResponseTracker();
+
+        // paxos is handling the consensus/commit acks in the primary DC, we just need to worry about the witness DCs
+        EndpointsForToken primaryEndpoints = planner.fullEndpoints.get(primaryDC);

Review Comment:
   fullEndpoints has natural replicas, but the tracker is build using `dcLayout.all().size()` which I believe also includes pending? If there are pending replicas this may not pre-complete enough replicas in the primary.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: pr-unsubscribe@cassandra.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: pr-unsubscribe@cassandra.apache.org
For additional commands, e-mail: pr-help@cassandra.apache.org