You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@cassandra.apache.org by bd...@apache.org on 2026/04/14 21:46:51 UTC

(cassandra) 02/36: Mutation tracking journal integration, read, and write path

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

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

commit c71f97695503b87d3b70ede9db3b0872389cc448
Author: Blake Eggleston <bl...@ultrablake.com>
AuthorDate: Thu May 15 16:18:41 2025 -0700

    Mutation tracking journal integration, read, and write path
    
    Patch by Blake Eggleston & Aleksey Yeshchenko
    Reviewed by Blake Eggleston, Aleksey Yeshchenko, & Abe Ratnofsky
     for CASSANDRA-20304, CASSANDRA-20305, & CASSANDRA-20308, CASSANDRA-20373
    
    Co-authored-by: Blake Eggleston <bl...@ultrablake.com>
    Co-authored-by: Aleksey Yeschenko <al...@apache.org>
---
 CHANGES.txt                                        |    9 +-
 pylib/cqlshlib/test/test_cqlsh_output.py           |    2 +-
 src/java/org/apache/cassandra/config/Config.java   |    2 +
 .../cassandra/config/DatabaseDescriptor.java       |   14 +
 .../cassandra/cql3/statements/BatchStatement.java  |    2 +-
 .../cql3/statements/BatchUpdatesCollector.java     |    3 +-
 .../cql3/statements/ModificationStatement.java     |   32 +-
 .../cql3/statements/schema/KeyspaceAttributes.java |   13 +-
 ...er.java => AbstractReadCommandVerbHandler.java} |  117 +-
 .../org/apache/cassandra/db/CounterMutation.java   |   21 +-
 src/java/org/apache/cassandra/db/IMutation.java    |    3 +
 src/java/org/apache/cassandra/db/Keyspace.java     |   58 +-
 src/java/org/apache/cassandra/db/Mutation.java     |   80 +-
 .../cassandra/db/PartitionRangeReadCommand.java    |   25 +
 src/java/org/apache/cassandra/db/ReadCommand.java  |  172 ++-
 .../cassandra/db/ReadCommandVerbHandler.java       |  156 +-
 .../cassandra/db/ReadExecutionController.java      |    2 +
 .../org/apache/cassandra/db/SimpleBuilders.java    |    9 +-
 .../cassandra/db/SinglePartitionReadCommand.java   |   20 +
 .../db/commitlog/CommitLogDescriptor.java          |    5 +-
 .../cassandra/db/commitlog/CommitLogReplayer.java  |    2 +-
 .../db/partitions/AbstractPartitionIterator.java   |   26 +
 .../db/partitions/PartitionIterators.java          |   43 +
 .../db/partitions/SimpleBTreePartition.java        |  131 ++
 .../cassandra/db/rows/AbstractRowIterator.java     |   72 +
 .../cassandra/db/rows/ComplexColumnData.java       |    2 +-
 .../org/apache/cassandra/db/rows/RowIterators.java |  281 +++-
 .../cassandra/db/rows/UnfilteredSerializer.java    |    8 +-
 .../db/streaming/CassandraStreamReceiver.java      |    3 +-
 .../db/tracked/TrackedKeyspaceWriteHandler.java    |   82 ++
 .../org/apache/cassandra/db/view/TableViews.java   |    5 +-
 .../cassandra/db/virtual/VirtualMutation.java      |   13 +
 .../apache/cassandra/hints/HintsDescriptor.java    |    5 +-
 src/java/org/apache/cassandra/journal/Journal.java |    7 +-
 .../cassandra/metrics/ReadRepairMetrics.java       |    1 +
 .../org/apache/cassandra/metrics/TableMetrics.java |    3 +-
 src/java/org/apache/cassandra/net/Message.java     |   25 +-
 .../org/apache/cassandra/net/MessagingService.java |    9 +-
 src/java/org/apache/cassandra/net/Verb.java        |   18 +
 .../cassandra/replication/CoordinatorLog.java      |  240 ++++
 .../cassandra/replication/CoordinatorLogId.java    |  127 ++
 .../cassandra/replication/LocalMutationStates.java |  187 +++
 .../cassandra/replication/Log2OffsetsMap.java      |  230 +++
 .../apache/cassandra/replication/MutationId.java   |  135 ++
 .../tracking => replication}/MutationJournal.java  |  114 +-
 .../cassandra/replication/MutationSummary.java     |  351 +++++
 .../replication/MutationTrackingService.java       |  239 ++++
 .../org/apache/cassandra/replication/Offsets.java  | 1496 ++++++++++++++++++++
 .../apache/cassandra/replication/Participants.java |   55 +
 .../cassandra/replication/ReconciliationPlan.java  |  210 +++
 .../org/apache/cassandra/replication/Shard.java    |  117 ++
 .../cassandra/replication/ShortMutationId.java     |  117 ++
 .../cassandra/replication/TrackedWriteRequest.java |  287 ++++
 .../schema/DistributedMetadataLogKeyspace.java     |    2 +-
 .../apache/cassandra/schema/KeyspaceMetadata.java  |   19 +-
 .../apache/cassandra/schema/KeyspaceParams.java    |   71 +-
 .../apache/cassandra/schema/ReplicationType.java   |   92 ++
 .../apache/cassandra/schema/SchemaKeyspace.java    |   25 +-
 .../org/apache/cassandra/schema/TableMetadata.java |   24 +
 src/java/org/apache/cassandra/schema/Tables.java   |    7 +
 .../apache/cassandra/service/CassandraDaemon.java  |    2 +
 .../org/apache/cassandra/service/StorageProxy.java |  145 +-
 .../apache/cassandra/service/StorageService.java   |    4 +
 .../service/TrackedWriteResponseHandler.java       |  117 ++
 .../org/apache/cassandra/service/paxos/Commit.java |   11 +-
 .../service/reads/AbstractReadExecutor.java        |    1 +
 .../service/reads/range/RangeCommandIterator.java  |   58 +-
 .../service/reads/range/SingleRangeResponse.java   |    2 +-
 .../service/reads/repair/BlockingReadRepairs.java  |    3 +-
 .../reads/tracked/AbstractPartialTrackedRead.java  |  154 ++
 .../reads/tracked/PartialTrackedRangeRead.java     |  347 +++++
 .../service/reads/tracked/PartialTrackedRead.java  |   88 ++
 .../tracked/PartialTrackedSinglePartitionRead.java |  108 ++
 .../service/reads/tracked/ReadReconcileNotify.java |   95 ++
 .../reads/tracked/ReadReconcileReceive.java        |  138 ++
 .../service/reads/tracked/ReadReconcileSend.java   |  166 +++
 .../service/reads/tracked/TrackedDataResponse.java |   99 ++
 .../reads/tracked/TrackedLocalReadCoordinator.java |  617 ++++++++
 .../service/reads/tracked/TrackedLocalReads.java   |  182 +++
 .../service/reads/tracked/TrackedRead.java         |  520 +++++++
 .../reads/tracked/TrackedSummaryResponse.java      |   77 +
 .../cassandra/service/tracking/MutationId.java     |   84 --
 src/java/org/apache/cassandra/tcm/Startup.java     |   10 +-
 .../cassandra/tcm/membership/NodeVersion.java      |    2 +-
 .../tcm/sequences/CancelCMSReconfiguration.java    |    2 +-
 .../cassandra/tcm/serialization/Version.java       |    5 +
 .../cms/PrepareCMSReconfiguration.java             |    2 +-
 .../cassandra/distributed/impl/Instance.java       |    5 +
 .../cassandra/distributed/test/GroupByTest.java    |  521 ++++---
 .../ReadRepairEmptyRangeTombstonesTestBase.java    |   17 +-
 .../distributed/test/ReadRepairInQueriesTest.java  |   10 +
 .../distributed/test/ReadRepairQueryTester.java    |   80 +-
 .../test/ReadRepairRangeQueriesTest.java           |   15 +-
 .../test/ReadRepairSliceQueriesTest.java           |    3 +
 ...ReadRepairTest.java => ReadRepairTestBase.java} |  227 +--
 .../distributed/test/ReadRepairTester.java         |   23 +-
 .../distributed/test/ReadRepairTrackedTest.java    |   30 +
 .../distributed/test/ReadRepairUntrackedTest.java  |   43 +
 .../test/ReplicaFilteringProtectionTest.java       |    3 +-
 .../distributed/test/ResourceLeakTest.java         |    2 +-
 .../distributed/test/ShortReadProtectionTest.java  |   31 +-
 .../tracking/MutationTrackingPendingReadTest.java  |  250 ++++
 .../MutationTrackingReadReconciliationTest.java    |  390 +++++
 .../test/tracking/MutationTrackingTest.java        |  127 ++
 .../test/tracking/MutationTrackingUtils.java       |  301 ++++
 .../db/commitlog/CommitLogStressTest.java          |    2 +
 .../cassandra/AbstractSerializationsTester.java    |    3 +-
 .../cql3/statements/DescribeStatementTest.java     |   22 +-
 .../apache/cassandra/db/ColumnFamilyStoreTest.java |    3 +-
 .../apache/cassandra/db/CounterMutationTest.java   |    3 +-
 .../apache/cassandra/db/DeletePartitionTest.java   |    3 +-
 .../db/MutationVerbHandlerOutOfRangeTest.java      |    2 +
 .../db/ReadCommandVerbHandlerOutOfRangeTest.java   |    2 +
 .../cassandra/db/ReadCommandVerbHandlerTest.java   |    2 +
 .../org/apache/cassandra/db/RowUpdateBuilder.java  |    3 +-
 .../db/compaction/CompactionsPurgeTest.java        |    7 +-
 .../org/apache/cassandra/dht/BootStrapperTest.java |    2 +
 test/unit/org/apache/cassandra/hints/HintTest.java |    3 +-
 .../apache/cassandra/hints/HintsCatalogTest.java   |    3 +-
 .../apache/cassandra/index/sasi/SASIIndexTest.java |   67 +-
 .../RepairMessageVerbHandlerOutOfRangeTest.java    |    2 +
 .../cassandra/replication/CoordinatorLogTest.java  |  129 ++
 .../MutationJournalTest.java                       |   10 +-
 .../apache/cassandra/replication/OffsetsTest.java  |  776 ++++++++++
 .../schema/SchemaMetadataSerializationTest.java    |   12 +-
 .../cassandra/service/JoinTokenRingTest.java       |    2 +
 .../apache/cassandra/service/PaxosStateTest.java   |    2 +
 .../cassandra/service/StorageServiceTest.java      |    2 +
 .../cassandra/service/paxos/PaxosStateTest.java    |    2 +
 .../paxos/PaxosVerbHandlerOutOfRangeTest.java      |    2 +
 .../reads/repair/RepairedDataVerifierTest.java     |    2 +
 .../streaming/StreamSessionOwnedRangesTest.java    |    2 +
 .../apache/cassandra/tools/OfflineToolUtils.java   |    3 +-
 .../cassandra/triggers/TriggerExecutorTest.java    |   19 +-
 .../cassandra/utils/CassandraGenerators.java       |   20 +-
 135 files changed, 10877 insertions(+), 978 deletions(-)

diff --git a/CHANGES.txt b/CHANGES.txt
index 83b3bb7d67..4ca05b0360 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -1,3 +1,9 @@
+cep-45-mutation-tracking
+ * Fix mutation tracking startup (CASSANDRA-20540)
+ * Mutation tracking journal integration, read, and write path (CASSANDRA-20304, CASSANDRA-20305, CASSANDRA-20308)
+ * Introduce MutationJournal for coordinator logs (CASSANDRA-20353)
+ * Copy over Journal and dependencies from cep-15-accord (CASSANDRA-20321)
+
 6.0-alpha2
  * Fix a removed TTLed row re-appearance in a materialized view after a cursor compaction (CASSANDRA-21152)
  * Rework ZSTD dictionary compression logic to create a trainer per training (CASSANDRA-21209)
@@ -7,7 +13,6 @@ Merged from 4.1:
 Merged from 4.0:
  * Rate limit password changes (CASSANDRA-21202)
 
-
 6.0-alpha1
  * Improve performance when calculating settled placements during range movements (CASSANDRA-21144)
  * Make shadow gossip round parameters configurable for testing (CASSANDRA-21149)
@@ -107,6 +112,8 @@ Merged from 4.0:
  * Optimize DataPlacement lookup by ReplicationParams (CASSANDRA-20804)
  * Fix ShortPaxosSimulationTest and AccordSimulationRunner do not execute from the cli (CASSANDRA-20805)
  * Allow overriding arbitrary settings via environment variables (CASSANDRA-20749)
+
+5.1
  * Optimize MessagingService.getVersionOrdinal (CASSANDRA-20816)
  * Optimize TrieMemtable#getFlushSet (CASSANDRA-20760)
  * Support manual secondary index selection at the CQL level (CASSANDRA-18112)
diff --git a/pylib/cqlshlib/test/test_cqlsh_output.py b/pylib/cqlshlib/test/test_cqlsh_output.py
index c32690b424..580e302c7a 100644
--- a/pylib/cqlshlib/test/test_cqlsh_output.py
+++ b/pylib/cqlshlib/test/test_cqlsh_output.py
@@ -794,7 +794,7 @@ class TestCqlshOutput(BaseTestCase):
                 self.assertNoHasColors(output)
                 # Since CASSANDRA-7622 'DESC FULL SCHEMA' also shows all VIRTUAL keyspaces
                 self.assertIn('VIRTUAL KEYSPACE system_virtual_schema', output)
-                self.assertIn("\nCREATE KEYSPACE system_auth WITH replication = {'class': 'SimpleStrategy', 'replication_factor': '1'}  AND durable_writes = true  AND fast_path = 'simple';\n",
+                self.assertIn("\nCREATE KEYSPACE system_auth WITH replication = {'class': 'SimpleStrategy', 'replication_factor': '1'}  AND durable_writes = true  AND fast_path = 'simple' AND replication_type = 'untracked';\n",
                               output)
                 self.assertRegex(output, r'.*\s*$')
 
diff --git a/src/java/org/apache/cassandra/config/Config.java b/src/java/org/apache/cassandra/config/Config.java
index c777d0fc89..2269cb2eda 100644
--- a/src/java/org/apache/cassandra/config/Config.java
+++ b/src/java/org/apache/cassandra/config/Config.java
@@ -694,6 +694,8 @@ public class Config
 
     public boolean dynamic_data_masking_enabled = false;
 
+    public boolean mutation_tracking_enabled = false;
+
     /**
      * Time in milliseconds after a warning will be emitted to the log and to the client that a UDF runs too long.
      * (Only valid, if user_defined_functions_threads_enabled==true)
diff --git a/src/java/org/apache/cassandra/config/DatabaseDescriptor.java b/src/java/org/apache/cassandra/config/DatabaseDescriptor.java
index 59ffb9f02a..d7c997ba1e 100644
--- a/src/java/org/apache/cassandra/config/DatabaseDescriptor.java
+++ b/src/java/org/apache/cassandra/config/DatabaseDescriptor.java
@@ -6025,6 +6025,20 @@ public class DatabaseDescriptor
         }
     }
 
+    public static boolean getMutationTrackingEnabled()
+    {
+        return conf.mutation_tracking_enabled;
+    }
+
+    public static void setMutationTrackingEnabled(boolean enabled)
+    {
+        if (enabled != conf.mutation_tracking_enabled)
+        {
+            logger.info("Setting mutation_tracking_enabled to {}", enabled);
+            conf.mutation_tracking_enabled = enabled;
+        }
+    }
+
     public static OptionalDouble getSeverityDuringDecommission()
     {
         return conf.severity_during_decommission > 0 ?
diff --git a/src/java/org/apache/cassandra/cql3/statements/BatchStatement.java b/src/java/org/apache/cassandra/cql3/statements/BatchStatement.java
index 8a38387d88..6fd03204ce 100644
--- a/src/java/org/apache/cassandra/cql3/statements/BatchStatement.java
+++ b/src/java/org/apache/cassandra/cql3/statements/BatchStatement.java
@@ -542,7 +542,7 @@ public class BatchStatement implements CQLStatement.CompositeCQLStatement
         updatePartitionsPerBatchMetrics(mutations.size());
 
         boolean mutateAtomic = (isLogged() && mutations.size() > 1);
-        StorageProxy.mutateWithTriggers(mutations, cl, mutateAtomic, requestTime, preserveTimestamp);
+        StorageProxy.mutateWithoutConditions(mutations, cl, mutateAtomic, requestTime, preserveTimestamp);
         ClientRequestSizeMetrics.recordRowAndColumnCountMetrics(mutations);
     }
 
diff --git a/src/java/org/apache/cassandra/cql3/statements/BatchUpdatesCollector.java b/src/java/org/apache/cassandra/cql3/statements/BatchUpdatesCollector.java
index 4bc0d909d2..1db782f1c5 100644
--- a/src/java/org/apache/cassandra/cql3/statements/BatchUpdatesCollector.java
+++ b/src/java/org/apache/cassandra/cql3/statements/BatchUpdatesCollector.java
@@ -38,6 +38,7 @@ import org.apache.cassandra.db.commitlog.CommitLogSegment;
 import org.apache.cassandra.db.partitions.PartitionUpdate;
 import org.apache.cassandra.db.virtual.VirtualMutation;
 import org.apache.cassandra.net.MessagingService;
+import org.apache.cassandra.replication.MutationId;
 import org.apache.cassandra.schema.TableId;
 import org.apache.cassandra.schema.TableMetadata;
 import org.apache.cassandra.service.ClientState;
@@ -224,7 +225,7 @@ final class BatchUpdatesCollector implements UpdatesCollector
                 PartitionUpdate update = updateEntry.getValue().build();
                 updates.put(updateEntry.getKey(), update);
             }
-            return new Mutation(keyspaceName, key, updates.build(), createdAt, potentialTxnConflicts);
+            return new Mutation(MutationId.none(), keyspaceName, key, updates.build(), createdAt, potentialTxnConflicts);
         }
 
         public PartitionUpdate.Builder get(TableId tableId)
diff --git a/src/java/org/apache/cassandra/cql3/statements/ModificationStatement.java b/src/java/org/apache/cassandra/cql3/statements/ModificationStatement.java
index fd853004be..9e98c43c66 100644
--- a/src/java/org/apache/cassandra/cql3/statements/ModificationStatement.java
+++ b/src/java/org/apache/cassandra/cql3/statements/ModificationStatement.java
@@ -72,9 +72,11 @@ import org.apache.cassandra.cql3.transactions.ReferenceOperation;
 import org.apache.cassandra.db.CBuilder;
 import org.apache.cassandra.db.Clustering;
 import org.apache.cassandra.db.ConsistencyLevel;
+import org.apache.cassandra.db.CounterMutation;
 import org.apache.cassandra.db.DecoratedKey;
 import org.apache.cassandra.db.IMutation;
 import org.apache.cassandra.db.Keyspace;
+import org.apache.cassandra.db.Mutation;
 import org.apache.cassandra.db.ReadCommand.PotentialTxnConflicts;
 import org.apache.cassandra.db.ReadExecutionController;
 import org.apache.cassandra.db.RegularAndStaticColumns;
@@ -107,6 +109,8 @@ import org.apache.cassandra.locator.NetworkTopologyStrategy;
 import org.apache.cassandra.locator.Replica;
 import org.apache.cassandra.locator.ReplicaLayout;
 import org.apache.cassandra.metrics.ClientRequestSizeMetrics;
+import org.apache.cassandra.replication.MutationId;
+import org.apache.cassandra.replication.MutationTrackingService;
 import org.apache.cassandra.schema.ColumnMetadata;
 import org.apache.cassandra.schema.Schema;
 import org.apache.cassandra.schema.SchemaConstants;
@@ -676,11 +680,11 @@ public abstract class ModificationStatement implements CQLStatement.SingleKeyspa
                          false,
                          options.getTimestamp(queryState),
                          options.getNowInSeconds(queryState),
-                         requestTime
-            );
+                         requestTime);
+
         if (!mutations.isEmpty())
         {
-            StorageProxy.mutateWithTriggers(mutations, cl, false, requestTime, attrs.isTimestampSet() ? PreserveTimestamp.yes : PreserveTimestamp.no);
+            StorageProxy.mutateWithoutConditions(mutations, cl, false, requestTime, attrs.isTimestampSet() ? PreserveTimestamp.yes : PreserveTimestamp.no);
 
             if (!SchemaConstants.isSystemKeyspace(metadata.keyspace))
                 ClientRequestSizeMetrics.recordRowAndColumnCountMetrics(mutations);
@@ -842,8 +846,28 @@ public abstract class ModificationStatement implements CQLStatement.SingleKeyspa
     {
         long timestamp = options.getTimestamp(queryState);
         long nowInSeconds = options.getNowInSeconds(queryState);
-        for (IMutation mutation : getMutations(queryState.getClientState(), options, true, timestamp, nowInSeconds, requestTime))
+        List<? extends IMutation> mutations = getMutations(queryState.getClientState(), options, true, timestamp, nowInSeconds, requestTime);
+        boolean isTracked = !mutations.isEmpty() && Schema.instance.getKeyspaceMetadata(mutations.get(0).getKeyspaceName()).params.replicationType.isTracked();
+        if (isTracked)
+        {
+            if (mutations.stream().anyMatch(m -> m instanceof CounterMutation))
+                throw new InvalidRequestException("Mutation tracking is currently unsupported with counters");
+            if (mutations.size() > 1)
+                throw new InvalidRequestException("Mutation tracking is currently unsupported with unlogged batches");
+
+            Mutation mutation = (Mutation) mutations.get(0);
+
+            String keyspaceName = mutation.getKeyspaceName();
+            Token token = mutation.key().getToken();
+            MutationId id = MutationTrackingService.instance.nextMutationId(keyspaceName, token);
+            mutation = mutation.withMutationId(id);
             mutation.apply();
+        }
+        else
+        {
+            for (IMutation mutation : mutations)
+                mutation.apply();
+        }
         return null;
     }
 
diff --git a/src/java/org/apache/cassandra/cql3/statements/schema/KeyspaceAttributes.java b/src/java/org/apache/cassandra/cql3/statements/schema/KeyspaceAttributes.java
index d4ca44cdcf..27ee037ab3 100644
--- a/src/java/org/apache/cassandra/cql3/statements/schema/KeyspaceAttributes.java
+++ b/src/java/org/apache/cassandra/cql3/statements/schema/KeyspaceAttributes.java
@@ -29,6 +29,7 @@ import org.apache.cassandra.exceptions.ConfigurationException;
 import org.apache.cassandra.exceptions.SyntaxException;
 import org.apache.cassandra.schema.KeyspaceParams;
 import org.apache.cassandra.schema.KeyspaceParams.Option;
+import org.apache.cassandra.schema.ReplicationType;
 import org.apache.cassandra.schema.ReplicationParams;
 import org.apache.cassandra.service.accord.fastpath.FastPathStrategy;
 
@@ -103,18 +104,26 @@ public final class KeyspaceAttributes extends PropertyDefinitions
     {
         boolean durableWrites = getBoolean(Option.DURABLE_WRITES.toString(), KeyspaceParams.DEFAULT_DURABLE_WRITES);
         FastPathStrategy fastPath = getFastPathStrategy();
-        return KeyspaceParams.create(durableWrites, getAllReplicationOptions(), fastPath != null ? fastPath : FastPathStrategy.simple());
+
+        String rtypeName = getString(Option.REPLICATION_TYPE.toString());
+        ReplicationType replicationType = rtypeName != null ? ReplicationType.valueOf(rtypeName) : KeyspaceParams.DEFAULT_REPLICATION_TYPE;
+
+        return KeyspaceParams.create(durableWrites, getAllReplicationOptions(), fastPath != null ? fastPath : FastPathStrategy.simple(), replicationType);
     }
 
     KeyspaceParams asAlteredKeyspaceParams(KeyspaceParams previous)
     {
         boolean durableWrites = getBoolean(Option.DURABLE_WRITES.toString(), previous.durableWrites);
+        String rtypeName = getString(Option.REPLICATION_TYPE.toString());
+        ReplicationType replicationType = rtypeName != null ? ReplicationType.valueOf(rtypeName) : previous.replicationType;
+
+
         Map<String, String> previousOptions = previous.replication.options;
         ReplicationParams replication = getReplicationStrategyClass() == null
                                       ? previous.replication
                                       : ReplicationParams.fromMapWithDefaults(getAllReplicationOptions(), previousOptions);
         FastPathStrategy fastPath = getFastPathStrategy();
-        return new KeyspaceParams(durableWrites, replication, fastPath != null ? fastPath : previous.fastPath);
+        return new KeyspaceParams(durableWrites, replication, fastPath != null ? fastPath : previous.fastPath, replicationType);
     }
 
     public boolean hasOption(Option option)
diff --git a/src/java/org/apache/cassandra/db/ReadCommandVerbHandler.java b/src/java/org/apache/cassandra/db/AbstractReadCommandVerbHandler.java
similarity index 63%
copy from src/java/org/apache/cassandra/db/ReadCommandVerbHandler.java
copy to src/java/org/apache/cassandra/db/AbstractReadCommandVerbHandler.java
index 8ff89f4e9b..9647891f8a 100644
--- a/src/java/org/apache/cassandra/db/ReadCommandVerbHandler.java
+++ b/src/java/org/apache/cassandra/db/AbstractReadCommandVerbHandler.java
@@ -15,20 +15,15 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.apache.cassandra.db;
 
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
+package org.apache.cassandra.db;
 
 import org.apache.cassandra.config.DatabaseDescriptor;
-import org.apache.cassandra.db.partitions.UnfilteredPartitionIterator;
 import org.apache.cassandra.dht.AbstractBounds;
 import org.apache.cassandra.dht.Token;
 import org.apache.cassandra.exceptions.CoordinatorBehindException;
 import org.apache.cassandra.exceptions.InvalidRequestException;
 import org.apache.cassandra.exceptions.InvalidRoutingException;
-import org.apache.cassandra.exceptions.QueryCancelledException;
-import org.apache.cassandra.exceptions.RetryOnDifferentSystemException;
 import org.apache.cassandra.locator.Replica;
 import org.apache.cassandra.metrics.TCMMetrics;
 import org.apache.cassandra.net.IVerbHandler;
@@ -39,35 +34,21 @@ import org.apache.cassandra.service.StorageService;
 import org.apache.cassandra.tcm.ClusterMetadata;
 import org.apache.cassandra.tcm.ClusterMetadataService;
 import org.apache.cassandra.tcm.Epoch;
-import org.apache.cassandra.tracing.Tracing;
 import org.apache.cassandra.utils.FBUtilities;
 
 import static java.util.concurrent.TimeUnit.NANOSECONDS;
-import static org.apache.cassandra.exceptions.RequestFailureReason.RETRY_ON_DIFFERENT_TRANSACTION_SYSTEM;
 
-public class ReadCommandVerbHandler implements IVerbHandler<ReadCommand>
+public abstract class AbstractReadCommandVerbHandler<T> implements IVerbHandler<T>
 {
-    public static final ReadCommandVerbHandler instance = new ReadCommandVerbHandler();
-
-    private static final Logger logger = LoggerFactory.getLogger(ReadCommandVerbHandler.class);
-
-    public ReadResponse doRead(ReadCommand command, boolean trackRepairedData)
-    {
-        ReadResponse response;
-        try (ReadExecutionController controller = command.executionController(trackRepairedData);
-             UnfilteredPartitionIterator iterator = command.executeLocally(controller))
-        {
-            response = command.createResponse(iterator, controller.getRepairedDataInfo());
-        }
+    protected abstract void performRead(Message<T> message, ClusterMetadata metadata);
 
-        return response;
-    }
+    protected abstract ReadCommand getCommand(T payload);
 
-    public void doVerb(Message<ReadCommand> message)
+    public final void doVerb(Message<T> message)
     {
+        ClusterMetadata metadata = ClusterMetadata.current();
         if (message.epoch().isAfter(Epoch.EMPTY))
         {
-            ClusterMetadata metadata = ClusterMetadata.current();
             metadata = checkTokenOwnership(metadata, message);
             metadata = checkSchemaVersion(metadata, message);
         }
@@ -75,70 +56,21 @@ public class ReadCommandVerbHandler implements IVerbHandler<ReadCommand>
         MessageParams.reset();
 
         long timeout = message.expiresAtNanos() - message.createdAtNanos();
-        ReadCommand command = message.payload;
+        ReadCommand command = getCommand(message.payload);
         command.setMonitoringTime(message.createdAtNanos(), message.isCrossNode(), timeout, DatabaseDescriptor.getSlowQueryTimeout(NANOSECONDS));
 
         if (message.trackWarnings())
             command.trackWarnings();
 
-        ReadResponse response;
-        try
-        {
-            response = doRead(command, message.trackRepairedData());
-        }
-        catch (RejectException e)
-        {
-            if (!command.isTrackingWarnings())
-                throw e;
-
-            // make sure to log as the exception is swallowed
-            logger.error(e.getMessage());
-
-            response = command.createEmptyResponse();
-            Message<ReadResponse> reply = message.responseWith(response);
-            reply = MessageParams.addToMessage(reply);
-
-            MessagingService.instance().send(reply, message.from());
-            return;
-        }
-        catch (RetryOnDifferentSystemException e)
-        {
-            logger.debug("Responding with retry on different system");
-            MessagingService.instance().respondWithFailure(RETRY_ON_DIFFERENT_TRANSACTION_SYSTEM, message);
-            Tracing.trace("Payload application resulted in RetryOnDifferentSysten");
-            return;
-        }
-        catch (AssertionError t)
-        {
-            throw new AssertionError(String.format("Caught an error while trying to process the command: %s", command.toCQLString()), t);
-        }
-        catch (QueryCancelledException e)
-        {
-            logger.debug("Query cancelled (timeout)", e);
-            response = null;
-            assert !command.isCompleted() : "Read marked as completed despite being aborted by timeout to table " + command.metadata();
-        }
-
-        if (command.complete())
-        {
-            Tracing.trace("Enqueuing response to {}", message.from());
-            Message<ReadResponse> reply = message.responseWith(response);
-            reply = MessageParams.addToMessage(reply);
-            MessagingService.instance().send(reply, message.from());
-        }
-        else
-        {
-            Tracing.trace("Discarding partial response to {} (timed out)", message.from());
-            MessagingService.instance().metrics.recordDroppedMessage(message, message.elapsedSinceCreated(NANOSECONDS), NANOSECONDS);
-        }
+        performRead(message, metadata);
     }
 
-    private ClusterMetadata checkSchemaVersion(ClusterMetadata metadata, Message<ReadCommand> message)
+    private ClusterMetadata checkSchemaVersion(ClusterMetadata metadata, Message<T> message)
     {
-        ReadCommand readCommand = message.payload;
+        ReadCommand readCommand = getCommand(message.payload);
 
         if (SchemaConstants.isSystemKeyspace(readCommand.metadata().keyspace) ||
-            readCommand.serializedAtEpoch() == null) // don't try to catch up with pre-5.0 nodes
+                readCommand.serializedAtEpoch() == null) // don't try to catch up with pre-5.0 nodes
             return metadata;
 
         Keyspace ks = metadata.schema.getKeyspace(readCommand.metadata().keyspace);
@@ -153,8 +85,8 @@ public class ReadCommandVerbHandler implements IVerbHandler<ReadCommand>
         {
             TCMMetrics.instance.coordinatorBehindSchema.mark();
             throw new CoordinatorBehindException(String.format("Coordinator schema for %s.%s with epoch %s is behind our schema %s",
-                                                               message.payload.metadata().keyspace,
-                                                               message.payload.metadata().name,
+                                                               readCommand.metadata().keyspace,
+                                                               readCommand.metadata().name,
                                                                readCommand.serializedAtEpoch(),
                                                                localComparisonEpoch));
         }
@@ -164,9 +96,9 @@ public class ReadCommandVerbHandler implements IVerbHandler<ReadCommand>
         return metadata;
     }
 
-    private ClusterMetadata checkTokenOwnership(ClusterMetadata metadata, Message<ReadCommand> message)
+    private ClusterMetadata checkTokenOwnership(ClusterMetadata metadata, Message<T> message)
     {
-        ReadCommand command = message.payload;
+        ReadCommand command = getCommand(message.payload);
 
         if (command.metadata().isVirtual())
             return metadata;
@@ -181,7 +113,7 @@ public class ReadCommandVerbHandler implements IVerbHandler<ReadCommand>
 
         if (command instanceof SinglePartitionReadCommand)
         {
-            Token token = ((SinglePartitionReadCommand)command).partitionKey().getToken();
+            Token token = ((SinglePartitionReadCommand) command).partitionKey().getToken();
             Replica localReplica = getLocalReplica(metadata, token, command.metadata().keyspace);
             if (localReplica == null)
             {
@@ -192,7 +124,7 @@ public class ReadCommandVerbHandler implements IVerbHandler<ReadCommand>
             {
                 StorageService.instance.incOutOfRangeOperationCount();
                 Keyspace.open(command.metadata().keyspace).metric.outOfRangeTokenReads.inc();
-                throw InvalidRoutingException.forTokenRead(message.from(), token, metadata.epoch, message.payload);
+                throw InvalidRoutingException.forTokenRead(message.from(), token, metadata.epoch, command);
             }
 
             if (!command.acceptsTransient() && localReplica.isTransient())
@@ -206,7 +138,7 @@ public class ReadCommandVerbHandler implements IVerbHandler<ReadCommand>
         }
         else
         {
-            AbstractBounds<PartitionPosition> range = ((PartitionRangeReadCommand) command).dataRange().keyRange();
+            AbstractBounds<PartitionPosition> range = command.dataRange().keyRange();
 
             // TODO: preexisting issue: for the range queries or queries that span multiple replicas, we can only make requests where the right token is owned, but not the left one
             Replica maxTokenLocalReplica = getLocalReplica(metadata, range.right.getToken(), command.metadata().keyspace);
@@ -219,10 +151,9 @@ public class ReadCommandVerbHandler implements IVerbHandler<ReadCommand>
             {
                 StorageService.instance.incOutOfRangeOperationCount();
                 Keyspace.open(command.metadata().keyspace).metric.outOfRangeTokenReads.inc();
-                throw InvalidRoutingException.forRangeRead(message.from(), range, metadata.epoch, message.payload);
+                throw InvalidRoutingException.forRangeRead(message.from(), range, metadata.epoch, command);
             }
 
-
             // TODO: preexisting issue: we should change the whole range for transient-ness, not just the right token
             if (command.acceptsTransient() != maxTokenLocalReplica.isTransient())
             {
@@ -239,10 +170,10 @@ public class ReadCommandVerbHandler implements IVerbHandler<ReadCommand>
     private static Replica getLocalReplica(ClusterMetadata metadata, Token token, String keyspace)
     {
         return metadata.placements
-               .get(metadata.schema.getKeyspaces().getNullable(keyspace).params.replication)
-               .reads
-               .forToken(token)
-               .get()
-               .lookup(FBUtilities.getBroadcastAddressAndPort());
+                .get(metadata.schema.getKeyspaces().getNullable(keyspace).params.replication)
+                .reads
+                .forToken(token)
+                .get()
+                .lookup(FBUtilities.getBroadcastAddressAndPort());
     }
 }
diff --git a/src/java/org/apache/cassandra/db/CounterMutation.java b/src/java/org/apache/cassandra/db/CounterMutation.java
index 52068473e5..be4b9951e7 100644
--- a/src/java/org/apache/cassandra/db/CounterMutation.java
+++ b/src/java/org/apache/cassandra/db/CounterMutation.java
@@ -53,6 +53,7 @@ import org.apache.cassandra.io.IVersionedSerializer;
 import org.apache.cassandra.io.util.DataInputPlus;
 import org.apache.cassandra.io.util.DataOutputPlus;
 import org.apache.cassandra.locator.AbstractReplicationStrategy;
+import org.apache.cassandra.replication.MutationId;
 import org.apache.cassandra.schema.TableId;
 import org.apache.cassandra.service.CacheService;
 import org.apache.cassandra.tracing.Tracing;
@@ -64,6 +65,7 @@ import static java.util.concurrent.TimeUnit.NANOSECONDS;
 import static org.apache.cassandra.net.MessagingService.VERSION_40;
 import static org.apache.cassandra.net.MessagingService.VERSION_50;
 import static org.apache.cassandra.net.MessagingService.VERSION_60;
+import static org.apache.cassandra.net.MessagingService.VERSION_61;
 import static org.apache.cassandra.utils.Clock.Global.nanoTime;
 
 public class CounterMutation implements IMutation
@@ -81,6 +83,18 @@ public class CounterMutation implements IMutation
         this.consistency = consistency;
     }
 
+    @Override
+    public MutationId id()
+    {
+        return mutation.id();
+    }
+
+    @Override
+    public CounterMutation withMutationId(MutationId mutationId)
+    {
+        return new CounterMutation(mutation.withMutationId(mutationId), consistency);
+    }
+
     public String getKeyspaceName()
     {
         return mutation.getKeyspaceName();
@@ -148,7 +162,7 @@ public class CounterMutation implements IMutation
      */
     public Mutation applyCounterMutation() throws WriteTimeoutException
     {
-        Mutation.PartitionUpdateCollector resultBuilder = new Mutation.PartitionUpdateCollector(getKeyspaceName(), key());
+        Mutation.PartitionUpdateCollector resultBuilder = new Mutation.PartitionUpdateCollector(id(), getKeyspaceName(), key());
         Keyspace keyspace = Keyspace.open(getKeyspaceName());
 
         List<Lock> locks = new ArrayList<>();
@@ -374,6 +388,7 @@ public class CounterMutation implements IMutation
     private int serializedSize40;
     private int serializedSize50;
     private int serializedSize51;
+    private int serializedSize52;
 
     public int serializedSize(int version)
     {
@@ -391,6 +406,10 @@ public class CounterMutation implements IMutation
                 if (serializedSize51 == 0)
                     serializedSize51 = (int) serializer.serializedSize(this, VERSION_60);
                 return serializedSize51;
+            case VERSION_61:
+                if (serializedSize52 == 0)
+                    serializedSize52 = (int) serializer.serializedSize(this, VERSION_61);
+                return serializedSize52;
             default:
                 throw new IllegalStateException("Unknown serialization version: " + version);
         }
diff --git a/src/java/org/apache/cassandra/db/IMutation.java b/src/java/org/apache/cassandra/db/IMutation.java
index f497a2aea5..f0116b58ff 100644
--- a/src/java/org/apache/cassandra/db/IMutation.java
+++ b/src/java/org/apache/cassandra/db/IMutation.java
@@ -27,6 +27,7 @@ import javax.annotation.Nullable;
 import org.apache.cassandra.config.DatabaseDescriptor;
 import org.apache.cassandra.db.ReadCommand.PotentialTxnConflicts;
 import org.apache.cassandra.db.partitions.PartitionUpdate;
+import org.apache.cassandra.replication.MutationId;
 import org.apache.cassandra.schema.TableId;
 import org.apache.cassandra.service.ClientState;
 
@@ -34,6 +35,8 @@ public interface IMutation
 {
     long MAX_MUTATION_SIZE = DatabaseDescriptor.getMaxMutationSize();
 
+    MutationId id();
+    IMutation withMutationId(MutationId mutationId);
     void apply();
     String getKeyspaceName();
     Collection<TableId> getTableIds();
diff --git a/src/java/org/apache/cassandra/db/Keyspace.java b/src/java/org/apache/cassandra/db/Keyspace.java
index e0cdf8b5c2..ff9a52fefe 100644
--- a/src/java/org/apache/cassandra/db/Keyspace.java
+++ b/src/java/org/apache/cassandra/db/Keyspace.java
@@ -33,6 +33,7 @@ import java.util.concurrent.locks.Lock;
 import java.util.stream.Stream;
 
 import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Preconditions;
 import com.google.common.collect.Iterables;
 
 import org.slf4j.Logger;
@@ -44,6 +45,7 @@ import org.apache.cassandra.config.DatabaseDescriptor;
 import org.apache.cassandra.db.lifecycle.SSTableSet;
 import org.apache.cassandra.db.partitions.PartitionUpdate;
 import org.apache.cassandra.db.repair.CassandraKeyspaceRepairManager;
+import org.apache.cassandra.db.tracked.TrackedKeyspaceWriteHandler;
 import org.apache.cassandra.db.view.ViewManager;
 import org.apache.cassandra.db.virtual.VirtualKeyspaceRegistry;
 import org.apache.cassandra.exceptions.WriteTimeoutException;
@@ -54,6 +56,7 @@ import org.apache.cassandra.io.util.File;
 import org.apache.cassandra.locator.AbstractReplicationStrategy;
 import org.apache.cassandra.metrics.KeyspaceMetrics;
 import org.apache.cassandra.repair.KeyspaceRepairManager;
+import org.apache.cassandra.replication.MutationTrackingService;
 import org.apache.cassandra.schema.KeyspaceMetadata;
 import org.apache.cassandra.schema.Schema;
 import org.apache.cassandra.schema.SchemaConstants;
@@ -106,6 +109,7 @@ public class Keyspace
 
     public final ViewManager viewManager;
     private final KeyspaceWriteHandler writeHandler;
+    private final TrackedKeyspaceWriteHandler trackedWriteHandler;
     private final KeyspaceRepairManager repairManager;
     private final SchemaProvider schema;
     private final String name;
@@ -285,6 +289,7 @@ public class Keyspace
 
         this.repairManager = new CassandraKeyspaceRepairManager(this);
         this.writeHandler = new CassandraKeyspaceWriteHandler(this);
+        this.trackedWriteHandler = new TrackedKeyspaceWriteHandler();
     }
 
     public Keyspace(KeyspaceMetadata metadata)
@@ -296,6 +301,7 @@ public class Keyspace
         this.viewManager = new ViewManager(this);
         this.repairManager = new CassandraKeyspaceRepairManager(this);
         this.writeHandler = new CassandraKeyspaceWriteHandler(this);
+        this.trackedWriteHandler = new TrackedKeyspaceWriteHandler();
         this.metadataRef = new KeyspaceMetadataRef(metadata, schema);
     }
 
@@ -395,13 +401,9 @@ public class Keyspace
 
     public Future<?> applyFuture(Mutation mutation, boolean writeCommitLog, boolean updateIndexes)
     {
-        return applyInternal(mutation, writeCommitLog, updateIndexes, true, true, new AsyncPromise<>());
-    }
-
-    public Future<?> applyFuture(Mutation mutation, boolean writeCommitLog, boolean updateIndexes, boolean isDroppable,
-                                            boolean isDeferrable)
-    {
-        return applyInternal(mutation, writeCommitLog, updateIndexes, isDroppable, isDeferrable, new AsyncPromise<>());
+        return getMetadata().useMutationTracking()
+             ? applyInternalTracked(mutation, new AsyncPromise<>())
+             : applyInternal(mutation, writeCommitLog, updateIndexes, true, true, new AsyncPromise<>());
     }
 
     public void apply(Mutation mutation, boolean writeCommitLog, boolean updateIndexes)
@@ -431,7 +433,10 @@ public class Keyspace
                       boolean updateIndexes,
                       boolean isDroppable)
     {
-        applyInternal(mutation, makeDurable, updateIndexes, isDroppable, false, null);
+        if (getMetadata().useMutationTracking())
+            applyInternalTracked(mutation, null);
+        else
+            applyInternal(mutation, makeDurable, updateIndexes, isDroppable, false, null);
     }
 
     /**
@@ -451,6 +456,8 @@ public class Keyspace
                                                boolean isDeferrable,
                                                Promise<?> future)
     {
+        Preconditions.checkState(!getMetadata().useMutationTracking() && mutation.id().isNone());
+
         if (TEST_FAIL_WRITES && getMetadata().name.equals(TEST_FAIL_WRITES_KS))
             throw new RuntimeException("Testing write failures");
 
@@ -598,6 +605,41 @@ public class Keyspace
         }
     }
 
+    /**
+     * Append the mutation to the mutation journal, then update memtables and indexes.
+     */
+    private Future<?> applyInternalTracked(Mutation mutation, Promise<?> future)
+    {
+        Preconditions.checkState(getMetadata().useMutationTracking() && !mutation.id().isNone());
+
+        if (TEST_FAIL_WRITES && getMetadata().name.equals(TEST_FAIL_WRITES_KS))
+            throw new RuntimeException("Testing write failures");
+
+        try (WriteContext ctx = trackedWriteHandler.beginWrite(mutation, true))
+        {
+            MutationTrackingService.instance.startWriting(mutation);
+
+            for (PartitionUpdate upd : mutation.getPartitionUpdates())
+            {
+                ColumnFamilyStore cfs = Schema.instance.getColumnFamilyStoreInstance(upd.metadata().id);
+                if (cfs == null)
+                {
+                    logger.error("Attempting to mutate non-existant table {} ({}.{})", upd.metadata().id, upd.metadata().keyspace, upd.metadata().name);
+                    continue;
+                }
+
+                cfs.getWriteHandler().write(upd, ctx, true);
+            }
+        }
+
+        MutationTrackingService.instance.finishWriting(mutation);
+
+        if (future != null)
+            future.trySuccess(null);
+
+        return future;
+    }
+
     public AbstractReplicationStrategy getReplicationStrategy()
     {
         return getMetadata().replicationStrategy;
diff --git a/src/java/org/apache/cassandra/db/Mutation.java b/src/java/org/apache/cassandra/db/Mutation.java
index 9fc49ae15e..e1a01c117f 100644
--- a/src/java/org/apache/cassandra/db/Mutation.java
+++ b/src/java/org/apache/cassandra/db/Mutation.java
@@ -52,6 +52,7 @@ import org.apache.cassandra.io.util.DataOutputPlus;
 import org.apache.cassandra.io.util.TeeDataInputPlus;
 import org.apache.cassandra.locator.ReplicaPlan;
 import org.apache.cassandra.net.MessagingService;
+import org.apache.cassandra.replication.MutationId;
 import org.apache.cassandra.schema.Schema;
 import org.apache.cassandra.schema.TableId;
 import org.apache.cassandra.schema.TableMetadata;
@@ -63,6 +64,7 @@ import static com.google.common.base.Preconditions.checkState;
 import static org.apache.cassandra.net.MessagingService.VERSION_40;
 import static org.apache.cassandra.net.MessagingService.VERSION_50;
 import static org.apache.cassandra.net.MessagingService.VERSION_60;
+import static org.apache.cassandra.net.MessagingService.VERSION_61;
 import static org.apache.cassandra.utils.MonotonicClock.Global.approxTime;
 
 public class Mutation implements IMutation, Supplier<Mutation>
@@ -71,6 +73,7 @@ public class Mutation implements IMutation, Supplier<Mutation>
     public static final int ALLOW_POTENTIAL_TRANSACTION_CONFLICTS = 0x01;
 
 
+    private final MutationId id;
     // todo this is redundant
     // when we remove it, also restore SerializationsTest.testMutationRead to not regenerate new Mutations each test
     private final String keyspaceName;
@@ -103,23 +106,29 @@ public class Mutation implements IMutation, Supplier<Mutation>
     // because it is being applied by one or in a context where transaction conflicts don't occur
     private PotentialTxnConflicts potentialTxnConflicts;
 
+    public Mutation(MutationId id, PartitionUpdate update)
+    {
+        this(id, update.metadata().keyspace, update.partitionKey(), ImmutableMap.of(update.metadata().id, update), approxTime.now(), update.metadata().params.cdc, PotentialTxnConflicts.DISALLOW);
+    }
+
     public Mutation(PartitionUpdate update)
     {
-        this(update, PotentialTxnConflicts.DISALLOW);
+        this(MutationId.none(), update, PotentialTxnConflicts.DISALLOW);
     }
 
-    public Mutation(PartitionUpdate update, PotentialTxnConflicts potentialTxnConflicts)
+    public Mutation(MutationId id, PartitionUpdate update, PotentialTxnConflicts potentialTxnConflicts)
     {
-        this(update.metadata().keyspace, update.partitionKey(), ImmutableMap.of(update.metadata().id, update), approxTime.now(), update.metadata().params.cdc, potentialTxnConflicts);
+        this(id, update.metadata().keyspace, update.partitionKey(), ImmutableMap.of(update.metadata().id, update), approxTime.now(), update.metadata().params.cdc, potentialTxnConflicts);
     }
 
-    public Mutation(String keyspaceName, DecoratedKey key, ImmutableMap<TableId, PartitionUpdate> modifications, long approxCreatedAtNanos, PotentialTxnConflicts potentialTxnConflicts)
+    public Mutation(MutationId id, String keyspaceName, DecoratedKey key, ImmutableMap<TableId, PartitionUpdate> modifications, long approxCreatedAtNanos, PotentialTxnConflicts potentialTxnConflicts)
     {
-        this(keyspaceName, key, modifications, approxCreatedAtNanos, cdcEnabled(modifications.values()), potentialTxnConflicts);
+        this(id, keyspaceName, key, modifications, approxCreatedAtNanos, cdcEnabled(modifications.values()), potentialTxnConflicts);
     }
 
-    public Mutation(String keyspaceName, DecoratedKey key, ImmutableMap<TableId, PartitionUpdate> modifications, long approxCreatedAtNanos, boolean cdcEnabled, PotentialTxnConflicts potentialTxnConflicts)
+    public Mutation(MutationId id, String keyspaceName, DecoratedKey key, ImmutableMap<TableId, PartitionUpdate> modifications, long approxCreatedAtNanos, boolean cdcEnabled, PotentialTxnConflicts potentialTxnConflicts)
     {
+        this.id = id;
         this.keyspaceName = keyspaceName;
         this.key = key;
         this.modifications = modifications;
@@ -128,6 +137,18 @@ public class Mutation implements IMutation, Supplier<Mutation>
         this.potentialTxnConflicts = potentialTxnConflicts;
     }
 
+    @Override
+    public MutationId id()
+    {
+        return id;
+    }
+
+    @Override
+    public Mutation withMutationId(MutationId mutationId)
+    {
+        return new Mutation(mutationId, keyspaceName, key, modifications, approxCreatedAtNanos, cdcEnabled, potentialTxnConflicts);
+    }
+
     private static boolean cdcEnabled(Iterable<PartitionUpdate> modifications)
     {
         boolean cdc = false;
@@ -159,7 +180,7 @@ public class Mutation implements IMutation, Supplier<Mutation>
 
         Map<TableId, PartitionUpdate> updates = builder.build();
         checkState(!updates.isEmpty(), "Updates should not be empty");
-        return new Mutation(keyspaceName, key, builder.build(), approxCreatedAtNanos, potentialTxnConflicts);
+        return new Mutation(id, keyspaceName, key, builder.build(), approxCreatedAtNanos, potentialTxnConflicts);
     }
 
     public @Nullable Mutation without(TableId tableId)
@@ -258,6 +279,8 @@ public class Mutation implements IMutation, Supplier<Mutation>
                 throw new IllegalArgumentException("Can't merge mutations with differing policies on allowing potential transaction conflicts");
             potentialTxnConflicts = mutation.potentialTxnConflicts;
             updatedTables.addAll(mutation.modifications.keySet());
+            if (!mutation.id().isNone())
+                throw new IllegalArgumentException();
             if (ks != null && !ks.equals(mutation.keyspaceName))
                 throw new IllegalArgumentException();
             if (key != null && !key.equals(mutation.key))
@@ -283,7 +306,7 @@ public class Mutation implements IMutation, Supplier<Mutation>
             modifications.put(table, updates.size() == 1 ? updates.get(0) : PartitionUpdate.merge(updates));
             updates.clear();
         }
-        return new Mutation(ks, key, modifications.build(), approxTime.now(), potentialTxnConflicts);
+        return new Mutation(MutationId.none(), ks, key, modifications.build(), approxTime.now(), potentialTxnConflicts);
     }
 
     public Future<?> applyFuture()
@@ -399,6 +422,7 @@ public class Mutation implements IMutation, Supplier<Mutation>
     private int serializedSize40;
     private int serializedSize50;
     private int serializedSize51;
+    private int serializedSize52;
 
     public int serializedSize(int version)
     {
@@ -416,6 +440,10 @@ public class Mutation implements IMutation, Supplier<Mutation>
                 if (serializedSize51 == 0)
                     serializedSize51 = (int) serializer.serializedSize(this, VERSION_60);
                 return serializedSize51;
+            case VERSION_61:
+                if (serializedSize52 == 0)
+                    serializedSize52 = (int) serializer.serializedSize(this, VERSION_61);
+                return serializedSize52;
             default:
                 throw new IllegalStateException("Unknown serialization version: " + version);
         }
@@ -428,9 +456,9 @@ public class Mutation implements IMutation, Supplier<Mutation>
      * @param partitionKey the key of partition this if a mutation for.
      * @return a newly created builder.
      */
-    public static SimpleBuilder simpleBuilder(String keyspaceName, DecoratedKey partitionKey)
+    public static SimpleBuilder simpleBuilder(MutationId mutationId, String keyspaceName, DecoratedKey partitionKey)
     {
-        return new SimpleBuilders.MutationBuilder(keyspaceName, partitionKey);
+        return new SimpleBuilders.MutationBuilder(mutationId, keyspaceName, partitionKey);
     }
 
     /**
@@ -554,9 +582,9 @@ public class Mutation implements IMutation, Supplier<Mutation>
         }
 
         static void serializeInternal(PartitionUpdate.PartitionUpdateSerializer serializer,
-                                         Mutation mutation,
-                                         DataOutputPlus out,
-                                         int version) throws IOException
+                                      Mutation mutation,
+                                      DataOutputPlus out,
+                                      int version) throws IOException
         {
             Map<TableId, PartitionUpdate> modifications = mutation.modifications;
 
@@ -567,6 +595,9 @@ public class Mutation implements IMutation, Supplier<Mutation>
                 out.write(flags);
             }
 
+            if (version >= MessagingService.VERSION_61)
+                MutationId.serializer.serialize(mutation.id, out, version);
+
             /* serialize the modifications in the mutation */
             int size = modifications.size();
             out.writeUnsignedVInt32(size);
@@ -592,13 +623,18 @@ public class Mutation implements IMutation, Supplier<Mutation>
                     int flags = teeIn.readByte();
                     potentialTxnConflicts = potentialTxnConflicts(flags);
                 }
+
+                MutationId id = version >= MessagingService.VERSION_61
+                              ? MutationId.serializer.deserialize(teeIn, version)
+                              : MutationId.none();
+
                 int size = teeIn.readUnsignedVInt32();
                 assert size > 0;
 
                 PartitionUpdate update = PartitionUpdate.serializer.deserialize(teeIn, version, flag);
                 if (size == 1)
                 {
-                    m = new Mutation(update, potentialTxnConflicts);
+                    m = new Mutation(id, update, potentialTxnConflicts);
                 }
                 else
                 {
@@ -611,7 +647,7 @@ public class Mutation implements IMutation, Supplier<Mutation>
                         update = PartitionUpdate.serializer.deserialize(teeIn, version, flag);
                         modifications.put(update.metadata().id, update);
                     }
-                    m = new Mutation(update.metadata().keyspace, dk, modifications.build(), approxTime.now(), potentialTxnConflicts);
+                    m = new Mutation(id, update.metadata().keyspace, dk, modifications.build(), approxTime.now(), potentialTxnConflicts);
                 }
 
                 //Only cache serializations that don't hit the limit
@@ -691,6 +727,8 @@ public class Mutation implements IMutation, Supplier<Mutation>
             {
                 if (version >= VERSION_60)
                     size += TypeSizes.sizeof((byte)ALLOW_POTENTIAL_TRANSACTION_CONFLICTS); // flags
+                if (version >= MessagingService.VERSION_61)
+                    size += MutationId.serializer.serializedSize(mutation.id, version);
                 size += TypeSizes.sizeofUnsignedVInt(mutation.modifications.size());
                 for (PartitionUpdate partitionUpdate : mutation.modifications.values())
                     size += serializer.serializedSize(partitionUpdate, version);
@@ -706,20 +744,22 @@ public class Mutation implements IMutation, Supplier<Mutation>
     public static class PartitionUpdateCollector
     {
         private final ImmutableMap.Builder<TableId, PartitionUpdate> modifications = new ImmutableMap.Builder<>();
+        private final MutationId mutationId;
         private final String keyspaceName;
         private final DecoratedKey key;
         private final long approxCreatedAtNanos = approxTime.now();
         private boolean empty = true;
 
-        private PotentialTxnConflicts potentialTxnConflicts;
+        private final PotentialTxnConflicts potentialTxnConflicts;
 
-        public PartitionUpdateCollector(String keyspaceName, DecoratedKey key)
+        public PartitionUpdateCollector(MutationId mutationId, String keyspaceName, DecoratedKey key)
         {
-            this(keyspaceName, key, PotentialTxnConflicts.DISALLOW);
+            this(mutationId, keyspaceName, key, PotentialTxnConflicts.DISALLOW);
         }
 
-        public PartitionUpdateCollector(String keyspaceName, DecoratedKey key, PotentialTxnConflicts potentialTxnConflicts)
+        public PartitionUpdateCollector(MutationId mutationId, String keyspaceName, DecoratedKey key, PotentialTxnConflicts potentialTxnConflicts)
         {
+            this.mutationId = mutationId;
             this.keyspaceName = keyspaceName;
             this.key = key;
             this.potentialTxnConflicts = potentialTxnConflicts;
@@ -756,7 +796,7 @@ public class Mutation implements IMutation, Supplier<Mutation>
 
         public Mutation build()
         {
-            return new Mutation(keyspaceName, key, modifications.build(), approxCreatedAtNanos, potentialTxnConflicts);
+            return new Mutation(mutationId, keyspaceName, key, modifications.build(), approxCreatedAtNanos, potentialTxnConflicts);
         }
     }
 }
diff --git a/src/java/org/apache/cassandra/db/PartitionRangeReadCommand.java b/src/java/org/apache/cassandra/db/PartitionRangeReadCommand.java
index d49cf986cb..43bb60d0d4 100644
--- a/src/java/org/apache/cassandra/db/PartitionRangeReadCommand.java
+++ b/src/java/org/apache/cassandra/db/PartitionRangeReadCommand.java
@@ -51,10 +51,14 @@ import org.apache.cassandra.io.util.DataInputPlus;
 import org.apache.cassandra.io.util.DataOutputPlus;
 import org.apache.cassandra.metrics.TableMetrics;
 import org.apache.cassandra.net.Verb;
+import org.apache.cassandra.replication.MutationSummary;
+import org.apache.cassandra.replication.MutationTrackingService;
 import org.apache.cassandra.schema.TableMetadata;
 import org.apache.cassandra.service.ClientState;
 import org.apache.cassandra.service.StorageProxy;
 import org.apache.cassandra.service.reads.ReadCoordinator;
+import org.apache.cassandra.service.reads.tracked.PartialTrackedRangeRead;
+import org.apache.cassandra.service.reads.tracked.PartialTrackedRead;
 import org.apache.cassandra.tcm.Epoch;
 import org.apache.cassandra.tracing.Tracing;
 import org.apache.cassandra.transport.Dispatcher;
@@ -89,6 +93,11 @@ public class PartitionRangeReadCommand extends ReadCommand implements PartitionR
         this.requestedSlices = dataRange.clusteringIndexFilter.getSlices(metadata());
     }
 
+    public Slices requestedSlices()
+    {
+        return requestedSlices;
+    }
+
     private static PartitionRangeReadCommand create(Epoch serializedAtEpoch,
                                                     boolean isDigest,
                                                     int digestVersion,
@@ -469,6 +478,22 @@ public class PartitionRangeReadCommand extends ReadCommand implements PartitionR
         }
     }
 
+    @Override
+    protected PartialTrackedRead createInProgressRead(UnfilteredPartitionIterator iterator,
+                                                      ReadExecutionController executionController,
+                                                      Index.Searcher searcher,
+                                                      ColumnFamilyStore cfs,
+                                                      long startTimeNanos)
+    {
+        return PartialTrackedRangeRead.create(executionController, searcher, cfs, startTimeNanos, this, iterator);
+    }
+
+    @Override
+    protected MutationSummary createMutationSummaryInternal(boolean includePending)
+    {
+        return MutationTrackingService.instance.createSummaryForRange(dataRange.keyRange, metadata().id, includePending);
+    }
+
     @Override
     protected boolean intersects(SSTableReader sstable)
     {
diff --git a/src/java/org/apache/cassandra/db/ReadCommand.java b/src/java/org/apache/cassandra/db/ReadCommand.java
index f5f9105602..edd5aad08e 100644
--- a/src/java/org/apache/cassandra/db/ReadCommand.java
+++ b/src/java/org/apache/cassandra/db/ReadCommand.java
@@ -89,6 +89,7 @@ import org.apache.cassandra.net.MessageFlag;
 import org.apache.cassandra.net.MessagingService;
 import org.apache.cassandra.net.ParamType;
 import org.apache.cassandra.net.Verb;
+import org.apache.cassandra.replication.MutationSummary;
 import org.apache.cassandra.schema.IndexMetadata;
 import org.apache.cassandra.schema.Schema;
 import org.apache.cassandra.schema.SchemaConstants;
@@ -99,6 +100,7 @@ import org.apache.cassandra.service.ActiveRepairService;
 import org.apache.cassandra.service.ClientWarn;
 import org.apache.cassandra.service.accord.serializers.TableMetadatas;
 import org.apache.cassandra.service.consensus.migration.ConsensusRequestRouter;
+import org.apache.cassandra.service.reads.tracked.PartialTrackedRead;
 import org.apache.cassandra.tcm.ClusterMetadata;
 import org.apache.cassandra.tcm.Epoch;
 import org.apache.cassandra.tracing.Tracing;
@@ -122,10 +124,15 @@ import static org.apache.cassandra.utils.MonotonicClock.Global.approxTime;
  * General interface for storage-engine read commands (common to both range and
  * single partition commands).
  * <p>
- * This contains all the informations needed to do a local read.
+ * This contains all the information needed to do a local read.
  */
 public abstract class ReadCommand extends AbstractReadQuery
 {
+    private interface ReadCompleter<T>
+    {
+        T complete(UnfilteredPartitionIterator iterator, ReadExecutionController executionController, Index.Searcher searcher, ColumnFamilyStore cfs, long startTimeNanos);
+    }
+
     private static final int TEST_ITERATION_DELAY_MILLIS = CassandraRelevantProperties.TEST_READ_ITERATION_DELAY_MS.getInt();
 
     protected static final Logger logger = LoggerFactory.getLogger(ReadCommand.class);
@@ -428,6 +435,13 @@ public abstract class ReadCommand extends AbstractReadQuery
 
     protected abstract UnfilteredPartitionIterator queryStorage(ColumnFamilyStore cfs, ReadExecutionController executionController);
 
+    protected abstract MutationSummary createMutationSummaryInternal(boolean includePending);
+
+    public MutationSummary createMutationSummary(boolean includePending)
+    {
+        return createMutationSummaryInternal(includePending);
+    }
+
     /**
      * Whether the underlying {@code ClusteringIndexFilter} is reversed or not.
      *
@@ -489,24 +503,10 @@ public abstract class ReadCommand extends AbstractReadQuery
         selectOptions.validate(metadata(), IndexRegistry.obtain(metadata()), indexQueryPlan());
     }
 
-    /**
-     * Executes this command on the local host.
-     *
-     * @param executionController the execution controller spanning this command
-     *
-     * @return an iterator over the result of executing this command locally.
-     */
-                                  // iterators created inside the try as long as we do close the original resultIterator), or by closing the result.
-    public UnfilteredPartitionIterator executeLocally(ReadExecutionController executionController)
-    {
-        return executeLocally(executionController, null);
-    }
-
     // ClusterMetadata is null on startup when there are local reads from system tables before it's initialized
-    public UnfilteredPartitionIterator executeLocally(ReadExecutionController executionController, @Nullable ClusterMetadata cm)
+    private <T> T beginRead(ReadExecutionController executionController, @Nullable ClusterMetadata cm, ReadCompleter<T> completer)
     {
         long startTimeNanos = nanoTime();
-
         COMMAND.set(this);
         try
         {
@@ -531,57 +531,70 @@ public abstract class ReadCommand extends AbstractReadQuery
                                             .collect(Collectors.joining(",")));
             }
 
+            if (searcher != null && metadata().replicationType().isTracked())
+                throw new UnsupportedOperationException("TODO: support tracked index reads");
+
             UnfilteredPartitionIterator iterator = (null == searcher) ? queryStorage(cfs, executionController) : searcher.search(executionController);
+
+            return completer.complete(iterator, executionController, searcher, cfs, startTimeNanos);
+        }
+        finally
+        {
+            COMMAND.set(null);
+        }
+    }
+
+    private UnfilteredPartitionIterator completeRead(UnfilteredPartitionIterator iterator, ReadExecutionController executionController, Index.Searcher searcher, ColumnFamilyStore cfs, long startTimeNanos)
+    {
+        COMMAND.set(this);
+        try
+        {
             iterator = RTBoundValidator.validate(iterator, Stage.MERGED, false);
+            iterator = withQuerySizeTracking(iterator);
+            iterator = maybeSlowDownForTesting(iterator);
+            iterator = withQueryCancellation(iterator);
+            iterator = maybeRecordPurgeableTombstones(iterator, cfs);
+            iterator = RTBoundValidator.validate(withoutPurgeableTombstones(iterator, cfs, executionController), Stage.PURGED, false);
+            iterator = withMetricsRecording(iterator, cfs.metric, startTimeNanos);
 
-            try
-            {
-                iterator = withQuerySizeTracking(iterator);
-                iterator = maybeSlowDownForTesting(iterator);
-                iterator = withQueryCancellation(iterator);
-                iterator = maybeRecordPurgeableTombstones(iterator, cfs);
-                iterator = RTBoundValidator.validate(withoutPurgeableTombstones(iterator, cfs, executionController), Stage.PURGED, false);
-                iterator = withMetricsRecording(iterator, cfs.metric, startTimeNanos);
-
-                // If we've used a 2ndary index, we know the result already satisfy the primary expression used, so
-                // no point in checking it again.
-                RowFilter filter = (null == searcher) ? rowFilter() : indexQueryPlan.postIndexQueryFilter();
-
-                /*
-                 * TODO: We'll currently do filtering by the rowFilter here because it's convenient. However,
-                 * we'll probably want to optimize by pushing it down the layer (like for dropped columns) as it
-                 * would be more efficient (the sooner we discard stuff we know we don't care, the less useless
-                 * processing we do on it).
-                 */
-                iterator = filter.filter(iterator, nowInSec());
-
-                // apply the limits/row counter; this transformation is stopping and would close the iterator as soon
-                // as the count is observed; if that happens in the middle of an open RT, its end bound will not be included.
-                // If tracking repaired data, the counter is needed for overreading repaired data, otherwise we can
-                // optimise the case where this.limit = DataLimits.NONE which skips an unnecessary transform
-                if (executionController.isTrackingRepairedStatus())
-                {
-                    DataLimits.Counter limit =
-                    limits().newCounter(nowInSec(), false, selectsFullPartition(), metadata().enforceStrictLiveness());
-                    iterator = limit.applyTo(iterator);
-                    // ensure that a consistent amount of repaired data is read on each replica. This causes silent
-                    // overreading from the repaired data set, up to limits(). The extra data is not visible to
-                    // the caller, only iterated to produce the repaired data digest.
-                    iterator = executionController.getRepairedDataInfo().extend(iterator, limit);
-                }
-                else
-                {
-                    iterator = limits().filter(iterator, nowInSec(), selectsFullPartition());
-                }
+            // If we've used a 2ndary index, we know the result already satisfy the primary expression used, so
+            // no point in checking it again.
+            RowFilter filter = (null == searcher) ? rowFilter() : indexQueryPlan.postIndexQueryFilter();
 
-                // because of the above, we need to append an aritifical end bound if the source iterator was stopped short by a counter.
-                return RTBoundCloser.close(iterator);
+            /*
+             * TODO: We'll currently do filtering by the rowFilter here because it's convenient. However,
+             * we'll probably want to optimize by pushing it down the layer (like for dropped columns) as it
+             * would be more efficient (the sooner we discard stuff we know we don't care, the less useless
+             * processing we do on it).
+             */
+            iterator = filter.filter(iterator, nowInSec());
+
+            // apply the limits/row counter; this transformation is stopping and would close the iterator as soon
+            // as the count is observed; if that happens in the middle of an open RT, its end bound will not be included.
+            // If tracking repaired data, the counter is needed for overreading repaired data, otherwise we can
+            // optimise the case where this.limit = DataLimits.NONE which skips an unnecessary transform
+            if (executionController.isTrackingRepairedStatus())
+            {
+                DataLimits.Counter limit =
+                        limits().newCounter(nowInSec(), false, selectsFullPartition(), metadata().enforceStrictLiveness());
+                iterator = limit.applyTo(iterator);
+                // ensure that a consistent amount of repaired data is read on each replica. This causes silent
+                // overreading from the repaired data set, up to limits(). The extra data is not visible to
+                // the caller, only iterated to produce the repaired data digest.
+                iterator = executionController.getRepairedDataInfo().extend(iterator, limit);
             }
-            catch (RuntimeException | Error e)
+            else
             {
-                iterator.close();
-                throw e;
+                iterator = limits().filter(iterator, nowInSec(), selectsFullPartition());
             }
+
+            // because of the above, we need to append an aritifical end bound if the source iterator was stopped short by a counter.
+            return RTBoundCloser.close(iterator);
+        }
+        catch (RuntimeException | Error e)
+        {
+            iterator.close();
+            throw e;
         }
         finally
         {
@@ -589,6 +602,45 @@ public abstract class ReadCommand extends AbstractReadQuery
         }
     }
 
+    /**
+     * Convert the initial iterator into a snapshot of data, either by capturing immutable references or materializing
+     * the contents of the iterator into memory. Once this method returns, subsequent writes against this table should
+     * not be reflected in the result of the InProgressRead
+     */
+    protected abstract PartialTrackedRead createInProgressRead(UnfilteredPartitionIterator iterator,
+                                                               ReadExecutionController executionController,
+                                                               Index.Searcher searcher,
+                                                               ColumnFamilyStore cfs,
+                                                               long startTimeNanos);
+
+    public PartialTrackedRead beginTrackedRead(ReadExecutionController executionController)
+    {
+        return beginRead(executionController, null, this::createInProgressRead);
+    }
+
+    public UnfilteredPartitionIterator completeTrackedRead(UnfilteredPartitionIterator iterator, PartialTrackedRead read)
+    {
+        return completeRead(iterator, read.executionController(), read.searcher(), read.cfs(), read.startTimeNanos());
+    }
+
+    /**
+     * Executes this command on the local host.
+     *
+     * @param executionController the execution controller spanning this command
+     *
+     * @return an iterator over the result of executing this command locally.
+     */
+    // iterators created inside the try as long as we do close the original resultIterator), or by closing the result.
+    public UnfilteredPartitionIterator executeLocally(ReadExecutionController executionController)
+    {
+        return beginRead(executionController, null, this::completeRead);
+    }
+
+    public UnfilteredPartitionIterator executeLocally(ReadExecutionController executionController, @Nullable ClusterMetadata cm)
+    {
+        return beginRead(executionController, cm, this::completeRead);
+    }
+
     protected abstract void recordLatency(TableMetrics metric, long latencyNanos);
 
     public ReadExecutionController executionController(boolean trackRepairedStatus)
diff --git a/src/java/org/apache/cassandra/db/ReadCommandVerbHandler.java b/src/java/org/apache/cassandra/db/ReadCommandVerbHandler.java
index 8ff89f4e9b..6fe8e063b2 100644
--- a/src/java/org/apache/cassandra/db/ReadCommandVerbHandler.java
+++ b/src/java/org/apache/cassandra/db/ReadCommandVerbHandler.java
@@ -16,41 +16,32 @@
  * limitations under the License.
  */
 package org.apache.cassandra.db;
-
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import org.apache.cassandra.config.DatabaseDescriptor;
 import org.apache.cassandra.db.partitions.UnfilteredPartitionIterator;
-import org.apache.cassandra.dht.AbstractBounds;
-import org.apache.cassandra.dht.Token;
-import org.apache.cassandra.exceptions.CoordinatorBehindException;
-import org.apache.cassandra.exceptions.InvalidRequestException;
-import org.apache.cassandra.exceptions.InvalidRoutingException;
 import org.apache.cassandra.exceptions.QueryCancelledException;
 import org.apache.cassandra.exceptions.RetryOnDifferentSystemException;
-import org.apache.cassandra.locator.Replica;
-import org.apache.cassandra.metrics.TCMMetrics;
-import org.apache.cassandra.net.IVerbHandler;
 import org.apache.cassandra.net.Message;
 import org.apache.cassandra.net.MessagingService;
-import org.apache.cassandra.schema.SchemaConstants;
-import org.apache.cassandra.service.StorageService;
 import org.apache.cassandra.tcm.ClusterMetadata;
-import org.apache.cassandra.tcm.ClusterMetadataService;
-import org.apache.cassandra.tcm.Epoch;
 import org.apache.cassandra.tracing.Tracing;
-import org.apache.cassandra.utils.FBUtilities;
 
 import static java.util.concurrent.TimeUnit.NANOSECONDS;
 import static org.apache.cassandra.exceptions.RequestFailureReason.RETRY_ON_DIFFERENT_TRANSACTION_SYSTEM;
 
-public class ReadCommandVerbHandler implements IVerbHandler<ReadCommand>
+public class ReadCommandVerbHandler extends AbstractReadCommandVerbHandler<ReadCommand>
 {
     public static final ReadCommandVerbHandler instance = new ReadCommandVerbHandler();
 
     private static final Logger logger = LoggerFactory.getLogger(ReadCommandVerbHandler.class);
 
+    @Override
+    protected ReadCommand getCommand(ReadCommand payload)
+    {
+        return payload;
+    }
+
     public ReadResponse doRead(ReadCommand command, boolean trackRepairedData)
     {
         ReadResponse response;
@@ -63,25 +54,11 @@ public class ReadCommandVerbHandler implements IVerbHandler<ReadCommand>
         return response;
     }
 
-    public void doVerb(Message<ReadCommand> message)
+    @Override
+    protected void performRead(Message<ReadCommand> message, ClusterMetadata metadata)
     {
-        if (message.epoch().isAfter(Epoch.EMPTY))
-        {
-            ClusterMetadata metadata = ClusterMetadata.current();
-            metadata = checkTokenOwnership(metadata, message);
-            metadata = checkSchemaVersion(metadata, message);
-        }
-
-        MessageParams.reset();
-
-        long timeout = message.expiresAtNanos() - message.createdAtNanos();
-        ReadCommand command = message.payload;
-        command.setMonitoringTime(message.createdAtNanos(), message.isCrossNode(), timeout, DatabaseDescriptor.getSlowQueryTimeout(NANOSECONDS));
-
-        if (message.trackWarnings())
-            command.trackWarnings();
-
         ReadResponse response;
+        ReadCommand command = message.payload;
         try
         {
             response = doRead(command, message.trackRepairedData());
@@ -132,117 +109,4 @@ public class ReadCommandVerbHandler implements IVerbHandler<ReadCommand>
             MessagingService.instance().metrics.recordDroppedMessage(message, message.elapsedSinceCreated(NANOSECONDS), NANOSECONDS);
         }
     }
-
-    private ClusterMetadata checkSchemaVersion(ClusterMetadata metadata, Message<ReadCommand> message)
-    {
-        ReadCommand readCommand = message.payload;
-
-        if (SchemaConstants.isSystemKeyspace(readCommand.metadata().keyspace) ||
-            readCommand.serializedAtEpoch() == null) // don't try to catch up with pre-5.0 nodes
-            return metadata;
-
-        Keyspace ks = metadata.schema.getKeyspace(readCommand.metadata().keyspace);
-        ColumnFamilyStore cfs = ks != null ? ks.getColumnFamilyStore(readCommand.metadata().id) : null;
-        Epoch localComparisonEpoch = metadata.epoch;
-        if (cfs != null)
-            localComparisonEpoch = cfs.metadata().epoch;
-
-        if (localComparisonEpoch.isBefore(readCommand.serializedAtEpoch()))
-            metadata = ClusterMetadataService.instance().fetchLogFromPeerOrCMS(metadata, message.from(), message.epoch());
-        else if (localComparisonEpoch.isAfter(readCommand.serializedAtEpoch()))
-        {
-            TCMMetrics.instance.coordinatorBehindSchema.mark();
-            throw new CoordinatorBehindException(String.format("Coordinator schema for %s.%s with epoch %s is behind our schema %s",
-                                                               message.payload.metadata().keyspace,
-                                                               message.payload.metadata().name,
-                                                               readCommand.serializedAtEpoch(),
-                                                               localComparisonEpoch));
-        }
-        ks = metadata.schema.getKeyspace(readCommand.metadata().keyspace);
-        if (ks == null || ks.getColumnFamilyStore(readCommand.metadata().id) == null)
-            throw new IllegalStateException("Unknown table " + readCommand.metadata().id +" after fetching remote log entries");
-        return metadata;
-    }
-
-    private ClusterMetadata checkTokenOwnership(ClusterMetadata metadata, Message<ReadCommand> message)
-    {
-        ReadCommand command = message.payload;
-
-        if (command.metadata().isVirtual())
-            return metadata;
-
-        // Some read commands may be sent using an older Epoch intentionally so validating using the current Epoch
-        // doesn't work
-        if (command.potentialTxnConflicts().allowed)
-            return metadata;
-
-        if (command.isTopK())
-            return metadata;
-
-        if (command instanceof SinglePartitionReadCommand)
-        {
-            Token token = ((SinglePartitionReadCommand)command).partitionKey().getToken();
-            Replica localReplica = getLocalReplica(metadata, token, command.metadata().keyspace);
-            if (localReplica == null)
-            {
-                metadata = ClusterMetadataService.instance().fetchLogFromPeerOrCMS(metadata, message.from(), message.epoch());
-                localReplica = getLocalReplica(metadata, token, command.metadata().keyspace);
-            }
-            if (localReplica == null)
-            {
-                StorageService.instance.incOutOfRangeOperationCount();
-                Keyspace.open(command.metadata().keyspace).metric.outOfRangeTokenReads.inc();
-                throw InvalidRoutingException.forTokenRead(message.from(), token, metadata.epoch, message.payload);
-            }
-
-            if (!command.acceptsTransient() && localReplica.isTransient())
-            {
-                MessagingService.instance().metrics.recordDroppedMessage(message, message.elapsedSinceCreated(NANOSECONDS), NANOSECONDS);
-                throw new InvalidRequestException(String.format("Attempted to serve %s data request from %s node in %s",
-                                                                command.acceptsTransient() ? "transient" : "full",
-                                                                localReplica.isTransient() ? "transient" : "full",
-                                                                this));
-            }
-        }
-        else
-        {
-            AbstractBounds<PartitionPosition> range = ((PartitionRangeReadCommand) command).dataRange().keyRange();
-
-            // TODO: preexisting issue: for the range queries or queries that span multiple replicas, we can only make requests where the right token is owned, but not the left one
-            Replica maxTokenLocalReplica = getLocalReplica(metadata, range.right.getToken(), command.metadata().keyspace);
-            if (maxTokenLocalReplica == null)
-            {
-                metadata = ClusterMetadataService.instance().fetchLogFromPeerOrCMS(metadata, message.from(), message.epoch());
-                maxTokenLocalReplica = getLocalReplica(metadata, range.right.getToken(), command.metadata().keyspace);
-            }
-            if (maxTokenLocalReplica == null)
-            {
-                StorageService.instance.incOutOfRangeOperationCount();
-                Keyspace.open(command.metadata().keyspace).metric.outOfRangeTokenReads.inc();
-                throw InvalidRoutingException.forRangeRead(message.from(), range, metadata.epoch, message.payload);
-            }
-
-
-            // TODO: preexisting issue: we should change the whole range for transient-ness, not just the right token
-            if (command.acceptsTransient() != maxTokenLocalReplica.isTransient())
-            {
-                MessagingService.instance().metrics.recordDroppedMessage(message, message.elapsedSinceCreated(NANOSECONDS), NANOSECONDS);
-                throw new InvalidRequestException(String.format("Attempted to serve %s data request from %s node in %s",
-                                                                command.acceptsTransient() ? "transient" : "full",
-                                                                maxTokenLocalReplica.isTransient() ? "transient" : "full",
-                                                                this));
-            }
-        }
-        return metadata;
-    }
-
-    private static Replica getLocalReplica(ClusterMetadata metadata, Token token, String keyspace)
-    {
-        return metadata.placements
-               .get(metadata.schema.getKeyspaces().getNullable(keyspace).params.replication)
-               .reads
-               .forToken(token)
-               .get()
-               .lookup(FBUtilities.getBroadcastAddressAndPort());
-    }
 }
diff --git a/src/java/org/apache/cassandra/db/ReadExecutionController.java b/src/java/org/apache/cassandra/db/ReadExecutionController.java
index 8a62ea390d..85b17d4690 100644
--- a/src/java/org/apache/cassandra/db/ReadExecutionController.java
+++ b/src/java/org/apache/cassandra/db/ReadExecutionController.java
@@ -21,6 +21,7 @@ import java.nio.ByteBuffer;
 import java.util.concurrent.TimeUnit;
 
 import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Preconditions;
 
 import org.apache.cassandra.db.filter.DataLimits;
 import org.apache.cassandra.index.Index;
@@ -69,6 +70,7 @@ public class ReadExecutionController implements AutoCloseable
 
         if (trackRepairedStatus)
         {
+            Preconditions.checkArgument(!command.metadata().replicationType().isTracked(), "Tracking repaired status is not supported for tracked reads");
             DataLimits.Counter repairedReadCount = command.limits().newCounter(command.nowInSec(),
                                                                                false,
                                                                                command.selectsFullPartition(),
diff --git a/src/java/org/apache/cassandra/db/SimpleBuilders.java b/src/java/org/apache/cassandra/db/SimpleBuilders.java
index 7139b6c8b2..cc8d38ec32 100644
--- a/src/java/org/apache/cassandra/db/SimpleBuilders.java
+++ b/src/java/org/apache/cassandra/db/SimpleBuilders.java
@@ -39,6 +39,7 @@ import org.apache.cassandra.db.rows.BufferCell;
 import org.apache.cassandra.db.rows.Cell;
 import org.apache.cassandra.db.rows.CellPath;
 import org.apache.cassandra.db.rows.Row;
+import org.apache.cassandra.replication.MutationId;
 import org.apache.cassandra.schema.ColumnMetadata;
 import org.apache.cassandra.schema.Schema;
 import org.apache.cassandra.schema.TableId;
@@ -116,6 +117,7 @@ public abstract class SimpleBuilders
 
     public static class MutationBuilder extends AbstractBuilder<Mutation.SimpleBuilder> implements Mutation.SimpleBuilder
     {
+        private final MutationId mutationId;
         private final String keyspaceName;
         private final DecoratedKey key;
 
@@ -123,8 +125,9 @@ public abstract class SimpleBuilders
 
         private PotentialTxnConflicts potentialTxnConflicts = PotentialTxnConflicts.DISALLOW;
 
-        public MutationBuilder(String keyspaceName, DecoratedKey key)
+        public MutationBuilder(MutationId mutationId, String keyspaceName, DecoratedKey key)
         {
+            this.mutationId = mutationId;
             this.keyspaceName = keyspaceName;
             this.key = key;
         }
@@ -163,9 +166,9 @@ public abstract class SimpleBuilders
             assert !updateBuilders.isEmpty() : "Cannot create empty mutation";
 
             if (updateBuilders.size() == 1)
-                return new Mutation(updateBuilders.values().iterator().next().build(), potentialTxnConflicts);
+                return new Mutation(mutationId, updateBuilders.values().iterator().next().build(), potentialTxnConflicts);
 
-            Mutation.PartitionUpdateCollector mutationBuilder = new Mutation.PartitionUpdateCollector(keyspaceName, key, potentialTxnConflicts);
+            Mutation.PartitionUpdateCollector mutationBuilder = new Mutation.PartitionUpdateCollector(mutationId, keyspaceName, key, potentialTxnConflicts);
             for (PartitionUpdateBuilder builder : updateBuilders.values())
                 mutationBuilder.add(builder.build());
             return mutationBuilder.build();
diff --git a/src/java/org/apache/cassandra/db/SinglePartitionReadCommand.java b/src/java/org/apache/cassandra/db/SinglePartitionReadCommand.java
index b487d011b2..667c8349f9 100644
--- a/src/java/org/apache/cassandra/db/SinglePartitionReadCommand.java
+++ b/src/java/org/apache/cassandra/db/SinglePartitionReadCommand.java
@@ -57,6 +57,7 @@ import org.apache.cassandra.db.partitions.PartitionIterators;
 import org.apache.cassandra.db.partitions.SingletonUnfilteredPartitionIterator;
 import org.apache.cassandra.db.partitions.UnfilteredPartitionIterator;
 import org.apache.cassandra.db.rows.BaseRowIterator;
+import org.apache.cassandra.db.partitions.*;
 import org.apache.cassandra.db.rows.Cell;
 import org.apache.cassandra.db.rows.Row;
 import org.apache.cassandra.db.rows.Rows;
@@ -78,12 +79,16 @@ import org.apache.cassandra.io.util.DataInputPlus;
 import org.apache.cassandra.io.util.DataOutputPlus;
 import org.apache.cassandra.metrics.TableMetrics;
 import org.apache.cassandra.net.Verb;
+import org.apache.cassandra.replication.MutationSummary;
+import org.apache.cassandra.replication.MutationTrackingService;
 import org.apache.cassandra.schema.ColumnMetadata;
 import org.apache.cassandra.schema.TableMetadata;
 import org.apache.cassandra.service.CacheService;
 import org.apache.cassandra.service.ClientState;
 import org.apache.cassandra.service.StorageProxy;
 import org.apache.cassandra.service.accord.api.PartitionKey;
+import org.apache.cassandra.service.reads.tracked.PartialTrackedRead;
+import org.apache.cassandra.service.reads.tracked.PartialTrackedSinglePartitionRead;
 import org.apache.cassandra.tcm.Epoch;
 import org.apache.cassandra.tracing.Tracing;
 import org.apache.cassandra.transport.Dispatcher;
@@ -561,6 +566,16 @@ public class SinglePartitionReadCommand extends ReadCommand implements SinglePar
         return new SingletonUnfilteredPartitionIterator(partition);
     }
 
+    @Override
+    protected PartialTrackedRead createInProgressRead(UnfilteredPartitionIterator iterator,
+                                                      ReadExecutionController executionController,
+                                                      Index.Searcher searcher,
+                                                      ColumnFamilyStore cfs,
+                                                      long startTimeNanos)
+    {
+        return PartialTrackedSinglePartitionRead.create(executionController, searcher, cfs, startTimeNanos, this, iterator);
+    }
+
     /**
      * Fetch the rows requested if in cache; if not, read it from disk and cache it.
      * <p>
@@ -926,6 +941,11 @@ public class SinglePartitionReadCommand extends ReadCommand implements SinglePar
         }
     }
 
+    protected MutationSummary createMutationSummaryInternal(boolean includePending)
+    {
+        return MutationTrackingService.instance.createSummaryForKey(partitionKey, metadata().id, includePending);
+    }
+
     @Override
     protected boolean intersects(SSTableReader sstable)
     {
diff --git a/src/java/org/apache/cassandra/db/commitlog/CommitLogDescriptor.java b/src/java/org/apache/cassandra/db/commitlog/CommitLogDescriptor.java
index c7d3d3f16c..a00ba2094d 100644
--- a/src/java/org/apache/cassandra/db/commitlog/CommitLogDescriptor.java
+++ b/src/java/org/apache/cassandra/db/commitlog/CommitLogDescriptor.java
@@ -64,13 +64,14 @@ public class CommitLogDescriptor
     public static final int VERSION_40 = 7;
     public static final int VERSION_50 = 8;
     public static final int VERSION_60 = 9;
+    public static final int VERSION_61 = 10;
 
     /**
      * Increment this number if there is a changes in the commit log disc layout or MessagingVersion changes.
      * Note: make sure to handle {@link #getMessagingVersion()}
      */
     @VisibleForTesting
-    public static final int current_version = DatabaseDescriptor.getStorageCompatibilityMode().isBefore(5) ? VERSION_40 : VERSION_60;
+    public static final int current_version = DatabaseDescriptor.getStorageCompatibilityMode().isBefore(5) ? VERSION_40 : VERSION_61;
 
     final int version;
     public final long id;
@@ -228,6 +229,8 @@ public class CommitLogDescriptor
                 return MessagingService.VERSION_50;
             case VERSION_60:
                 return MessagingService.VERSION_60;
+            case VERSION_61:
+                return MessagingService.VERSION_61;
             default:
                 throw new IllegalStateException("Unknown commitlog version " + version);
         }
diff --git a/src/java/org/apache/cassandra/db/commitlog/CommitLogReplayer.java b/src/java/org/apache/cassandra/db/commitlog/CommitLogReplayer.java
index 427510258c..e6762aee5c 100644
--- a/src/java/org/apache/cassandra/db/commitlog/CommitLogReplayer.java
+++ b/src/java/org/apache/cassandra/db/commitlog/CommitLogReplayer.java
@@ -313,7 +313,7 @@ public class CommitLogReplayer implements CommitLogReadHandler
                         if (commitLogReplayer.shouldReplay(update.metadata().id, new CommitLogPosition(segmentId, entryLocation)))
                         {
                             if (newPUCollector == null)
-                                newPUCollector = new Mutation.PartitionUpdateCollector(mutation.getKeyspaceName(), mutation.key());
+                                newPUCollector = new Mutation.PartitionUpdateCollector(mutation.id(), mutation.getKeyspaceName(), mutation.key());
                             newPUCollector.add(update);
                             commitLogReplayer.replayedCount.incrementAndGet();
                         }
diff --git a/src/java/org/apache/cassandra/db/partitions/AbstractPartitionIterator.java b/src/java/org/apache/cassandra/db/partitions/AbstractPartitionIterator.java
new file mode 100644
index 0000000000..5dc4663bbb
--- /dev/null
+++ b/src/java/org/apache/cassandra/db/partitions/AbstractPartitionIterator.java
@@ -0,0 +1,26 @@
+/*
+ * 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.db.partitions;
+
+import org.apache.cassandra.db.rows.RowIterator;
+import org.apache.cassandra.utils.AbstractIterator;
+
+public abstract class AbstractPartitionIterator extends AbstractIterator<RowIterator> implements PartitionIterator
+{
+}
diff --git a/src/java/org/apache/cassandra/db/partitions/PartitionIterators.java b/src/java/org/apache/cassandra/db/partitions/PartitionIterators.java
index bf65b3392a..569cee34d4 100644
--- a/src/java/org/apache/cassandra/db/partitions/PartitionIterators.java
+++ b/src/java/org/apache/cassandra/db/partitions/PartitionIterators.java
@@ -17,14 +17,20 @@
  */
 package org.apache.cassandra.db.partitions;
 
+import java.io.IOError;
+import java.io.IOException;
 import java.util.List;
 
 import org.apache.cassandra.db.EmptyIterators;
 import org.apache.cassandra.db.SinglePartitionReadQuery;
+import org.apache.cassandra.db.filter.ColumnFilter;
 import org.apache.cassandra.db.rows.RowIterator;
 import org.apache.cassandra.db.rows.RowIterators;
 import org.apache.cassandra.db.transform.MorePartitions;
 import org.apache.cassandra.db.transform.Transformation;
+import org.apache.cassandra.io.util.DataInputPlus;
+import org.apache.cassandra.io.util.DataOutputPlus;
+import org.apache.cassandra.schema.TableMetadata;
 import org.apache.cassandra.utils.AbstractIterator;
 
 public abstract class PartitionIterators
@@ -167,4 +173,41 @@ public abstract class PartitionIterators
             iterator.close();
         }
     }
+
+    public static class Serializer
+    {
+        public static void serialize(PartitionIterator iterator, ColumnFilter selection, DataOutputPlus out, int version) throws IOException
+        {
+            while (iterator.hasNext())
+            {
+                try (RowIterator partition = iterator.next())
+                {
+                    out.writeBoolean(true);
+                    RowIterators.Serializer.serialize(partition, selection, out, version);
+                }
+            }
+            out.writeBoolean(false);
+        }
+
+        public static PartitionIterator deserialize(TableMetadata metadata, ColumnFilter selection, DataInputPlus in, int version) throws IOException
+        {
+            return new AbstractPartitionIterator()
+            {
+                @Override
+                protected RowIterator computeNext()
+                {
+                    try
+                    {
+                        if (in.readBoolean())
+                            return RowIterators.Serializer.deserialize(metadata, selection, in, version);
+                        return endOfData();
+                    }
+                    catch (IOException e)
+                    {
+                        throw new IOError(e);
+                    }
+                }
+            };
+        }
+    };
 }
diff --git a/src/java/org/apache/cassandra/db/partitions/SimpleBTreePartition.java b/src/java/org/apache/cassandra/db/partitions/SimpleBTreePartition.java
new file mode 100644
index 0000000000..1d04c2c592
--- /dev/null
+++ b/src/java/org/apache/cassandra/db/partitions/SimpleBTreePartition.java
@@ -0,0 +1,131 @@
+/*
+ * 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.db.partitions;
+
+import org.apache.cassandra.db.DecoratedKey;
+import org.apache.cassandra.db.DeletionInfo;
+import org.apache.cassandra.db.RegularAndStaticColumns;
+import org.apache.cassandra.db.rows.*;
+import org.apache.cassandra.index.transactions.UpdateTransaction;
+import org.apache.cassandra.schema.TableMetadata;
+import org.apache.cassandra.utils.btree.BTree;
+import org.apache.cassandra.utils.btree.UpdateFunction;
+import org.apache.cassandra.utils.memory.HeapCloner;
+
+/**
+ * Single threaded btree partition, no waste or allocation tracking
+ */
+public class SimpleBTreePartition extends AbstractBTreePartition implements UpdateFunction<Row, Row>
+{
+    private final TableMetadata metadata;
+    private final UpdateTransaction indexer;
+    private BTreePartitionData data = BTreePartitionData.EMPTY;
+
+    public SimpleBTreePartition(DecoratedKey partitionKey, TableMetadata metadata, UpdateTransaction indexer)
+    {
+        super(partitionKey);
+        this.metadata = metadata;
+        this.indexer = indexer;
+    }
+
+    @Override
+    protected BTreePartitionData holder()
+    {
+        return data;
+    }
+
+    @Override
+    public TableMetadata metadata()
+    {
+        return metadata;
+    }
+
+    @Override
+    protected boolean canHaveShadowedData()
+    {
+        return true;
+    }
+
+    @Override
+    public void onAllocatedOnHeap(long heapSize)
+    {
+
+    }
+
+    protected BTreePartitionData makeMergedPartition(BTreePartitionData current, PartitionUpdate update)
+    {
+        DeletionInfo newDeletionInfo = merge(current.deletionInfo, update.deletionInfo());
+
+        RegularAndStaticColumns columns = current.columns;
+        RegularAndStaticColumns newColumns = update.columns().mergeTo(columns);
+        Row newStatic = mergeStatic(current.staticRow, update.staticRow());
+
+        Object[] tree = BTree.update(current.tree, update.holder().tree, update.metadata().comparator, this);
+        EncodingStats newStats = current.stats.mergeWith(update.stats());
+
+        return new BTreePartitionData(newColumns, tree, newDeletionInfo, newStatic, newStats);
+    }
+
+    private Row mergeStatic(Row current, Row update)
+    {
+        if (update.isEmpty())
+            return current;
+        if (current.isEmpty())
+            return insert(update);
+
+        return merge(current, update);
+    }
+
+    private DeletionInfo merge(DeletionInfo existing, DeletionInfo update)
+    {
+        if (update.isLive() || !update.mayModify(existing))
+            return existing;
+
+        if (!update.getPartitionDeletion().isLive())
+            indexer.onPartitionDeletion(update.getPartitionDeletion());
+
+        if (update.hasRanges())
+            update.rangeIterator(false).forEachRemaining(indexer::onRangeTombstone);
+
+        // Like for rows, we have to clone the update in case internal buffers (when it has range tombstones) reference
+        // memory we shouldn't hold into. But we don't ever store this off-heap currently so we just default to the
+        // HeapAllocator (rather than using 'allocator').
+        DeletionInfo newInfo = existing.mutableCopy().add(update.clone(HeapCloner.instance));
+        return newInfo;
+    }
+
+    public Row insert(Row insert)
+    {
+        indexer.onInserted(insert);
+        return insert;
+    }
+
+    public Row merge(Row existing, Row update)
+    {
+        Row reconciled = Rows.merge(existing, update, ColumnData.noOp);
+        indexer.onUpdated(existing, reconciled);
+
+        return reconciled;
+    }
+
+    public void update(PartitionUpdate update)
+    {
+        data = makeMergedPartition(data, update);
+    }
+}
diff --git a/src/java/org/apache/cassandra/db/rows/AbstractRowIterator.java b/src/java/org/apache/cassandra/db/rows/AbstractRowIterator.java
new file mode 100644
index 0000000000..3481063596
--- /dev/null
+++ b/src/java/org/apache/cassandra/db/rows/AbstractRowIterator.java
@@ -0,0 +1,72 @@
+/*
+ * 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.db.rows;
+
+import org.apache.cassandra.db.DecoratedKey;
+import org.apache.cassandra.db.RegularAndStaticColumns;
+import org.apache.cassandra.schema.TableMetadata;
+import org.apache.cassandra.utils.AbstractIterator;
+
+public abstract class AbstractRowIterator extends AbstractIterator<Row> implements RowIterator
+{
+    protected final TableMetadata metadata;
+    protected final DecoratedKey key;
+    protected final RegularAndStaticColumns columns;
+    protected final boolean isReversedOrder;
+    protected final Row staticRow;
+
+    public AbstractRowIterator(TableMetadata metadata, DecoratedKey key, RegularAndStaticColumns columns, boolean isReversedOrder, Row staticRow)
+    {
+        this.metadata = metadata;
+        this.key = key;
+        this.columns = columns;
+        this.isReversedOrder = isReversedOrder;
+        this.staticRow = staticRow;
+    }
+
+    @Override
+    public TableMetadata metadata()
+    {
+        return metadata;
+    }
+
+    @Override
+    public DecoratedKey partitionKey()
+    {
+        return key;
+    }
+
+    @Override
+    public RegularAndStaticColumns columns()
+    {
+        return columns;
+    }
+
+    @Override
+    public Row staticRow()
+    {
+        return staticRow;
+    }
+
+    @Override
+    public boolean isReverseOrder()
+    {
+        return isReversedOrder;
+    }
+}
diff --git a/src/java/org/apache/cassandra/db/rows/ComplexColumnData.java b/src/java/org/apache/cassandra/db/rows/ComplexColumnData.java
index 062b389ad8..c9543a308b 100644
--- a/src/java/org/apache/cassandra/db/rows/ComplexColumnData.java
+++ b/src/java/org/apache/cassandra/db/rows/ComplexColumnData.java
@@ -220,7 +220,7 @@ public class ComplexColumnData extends ColumnData implements Iterable<Cell<?>>
 
     public ComplexColumnData withOnlyQueriedData(ColumnFilter filter)
     {
-        return transformAndFilter(complexDeletion, (cell) -> filter.fetchedCellIsQueried(column, cell.path()) ? null : cell);
+        return transformAndFilter(complexDeletion, (cell) -> filter.fetchedCellIsQueried(column, cell.path()) ? cell : null);
     }
 
     public ComplexColumnData purgeDataOlderThan(long timestamp)
diff --git a/src/java/org/apache/cassandra/db/rows/RowIterators.java b/src/java/org/apache/cassandra/db/rows/RowIterators.java
index 640cbc8d5b..b398b27522 100644
--- a/src/java/org/apache/cassandra/db/rows/RowIterators.java
+++ b/src/java/org/apache/cassandra/db/rows/RowIterators.java
@@ -17,14 +17,25 @@
  */
 package org.apache.cassandra.db.rows;
 
+import com.google.common.base.Preconditions;
+import net.nicoulaj.compilecommand.annotations.Inline;
+import org.apache.cassandra.db.*;
+import org.apache.cassandra.io.util.DataInputPlus;
+import org.apache.cassandra.io.util.DataOutputPlus;
+import org.apache.cassandra.schema.ColumnMetadata;
+import org.apache.cassandra.utils.ByteBufferUtil;
+import org.apache.cassandra.utils.SearchIterator;
+import org.apache.cassandra.utils.WrappedException;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import org.apache.cassandra.db.Digest;
 import org.apache.cassandra.db.filter.ColumnFilter;
 import org.apache.cassandra.db.transform.Transformation;
 import org.apache.cassandra.schema.TableMetadata;
 
+import java.io.IOError;
+import java.io.IOException;
+
 /**
  * Static methods to work with row iterators.
  */
@@ -101,4 +112,272 @@ public abstract class RowIterators
         }
         return Transformation.apply(iterator, new Log());
     }
+
+    private static class RowSerializer
+    {
+        /*
+         * Row flags constants.
+         */
+        private final static int END_OF_PARTITION     = 0x01; // Signal the end of the partition. Nothing follows a <flags> field with that flag.
+        private final static int HAS_ALL_COLUMNS      = 0x02; // Whether the encoded row has all of the columns from the header present.
+        private final static int IS_STATIC            = 0x04; // Whether the encoded row is a static. If there is no extended flag, the row is assumed not static.
+
+
+        public static void writeEndOfPartition(DataOutputPlus out) throws IOException
+        {
+            out.writeByte((byte) END_OF_PARTITION);
+        }
+
+        public static void serializeRow(Row row, SerializationHelper helper, DataOutputPlus out, int version)
+                throws IOException
+        {
+            int flags = 0;
+
+            boolean isStatic = row.isStatic();
+            SerializationHeader header = helper.header;
+            Preconditions.checkArgument(!header.isForSSTable());
+            boolean hasAllColumns = row.columnCount() == header.columns(isStatic).size();
+
+            if (isStatic)
+                flags |= IS_STATIC;
+
+            if (hasAllColumns)
+                flags |= HAS_ALL_COLUMNS;
+
+            out.writeByte((byte)flags);
+
+            if (!isStatic)
+                Clustering.serializer.serialize(row.clustering(), out, version, header.clusteringTypes());
+
+            serializeRowBody(row, flags, helper, out);
+        }
+
+        public static void serializeStaticRow(Row row, SerializationHelper helper, DataOutputPlus out, int version) throws IOException
+        {
+            Preconditions.checkArgument(row.isStatic());
+            serializeRow(row, helper, out, version);
+        }
+
+        @Inline
+        private static void serializeRowBody(Row row, int flags, SerializationHelper helper, DataOutputPlus out)
+                throws IOException
+        {
+            boolean isStatic = row.isStatic();
+
+            SerializationHeader header = helper.header;
+            Preconditions.checkState(!header.isForSSTable());
+            Columns headerColumns = header.columns(isStatic);
+
+            if ((flags & HAS_ALL_COLUMNS) == 0)
+                Columns.serializer.serializeSubset(row.columns(), headerColumns, out);
+
+            SearchIterator<ColumnMetadata, ColumnMetadata> si = helper.iterator(isStatic);
+
+            try
+            {
+                row.apply(cd -> {
+                    // We can obtain the column for data directly from data.column(). However, if the cell/complex data
+                    // originates from a sstable, the column we'll get will have the type used when the sstable was serialized,
+                    // and if that type have been recently altered, that may not be the type we want to serialize the column
+                    // with. So we use the ColumnMetadata from the "header" which is "current". Also see #11810 for what
+                    // happens if we don't do that.
+                    ColumnMetadata column = si.next(cd.column());
+                    assert column != null : cd.column.toString();
+
+                    try
+                    {
+                        if (cd.column.isSimple())
+                            Cell.serializer.serialize((Cell<?>) cd, column, out, LivenessInfo.EMPTY, header);
+                        else
+                            UnfilteredSerializer.writeComplexColumn((ComplexColumnData) cd, column, false, LivenessInfo.EMPTY, header, out);
+                    }
+                    catch (IOException e)
+                    {
+                        throw new WrappedException(e);
+                    }
+                });
+            }
+            catch (WrappedException e)
+            {
+                if (e.getCause() instanceof IOException)
+                    throw (IOException) e.getCause();
+
+                throw e;
+            }
+        }
+
+        public static Row deserializeStaticRow(DataInputPlus in, SerializationHeader header, DeserializationHelper helper)
+                throws IOException
+        {
+            int flags = in.readUnsignedByte();
+            Preconditions.checkState(!isEndOfPartition(flags));
+            Preconditions.checkState(isStatic(flags));
+            Row.Builder builder = BTreeRow.sortedBuilder();
+            builder.newRow(Clustering.STATIC_CLUSTERING);
+            return deserializeRowBody(in, header, helper, flags, builder);
+        }
+
+        public static Row deserializeRow(DataInputPlus in, SerializationHeader header, DeserializationHelper helper, Row.Builder builder) throws IOException
+        {
+            int flags = in.readUnsignedByte();
+            if (isEndOfPartition(flags))
+                return null;
+            builder.newRow(Clustering.serializer.deserialize(in, helper.version, header.clusteringTypes()));
+            return deserializeRowBody(in, header, helper, flags, builder);
+        }
+
+        public static Row deserializeRowBody(DataInputPlus in,
+                                      SerializationHeader header,
+                                      DeserializationHelper helper,
+                                      int flags,
+                                      Row.Builder builder)
+                throws IOException
+        {
+            try
+            {
+                boolean isStatic = isStatic(flags);
+                boolean hasAllColumns = (flags & HAS_ALL_COLUMNS) != 0;
+                Columns headerColumns = header.columns(isStatic);
+
+                Preconditions.checkState(!header.isForSSTable());
+
+                Columns columns = hasAllColumns ? headerColumns : Columns.serializer.deserializeSubset(headerColumns, in);
+
+                try
+                {
+                    DataInputPlus finalIn = in;
+                    columns.apply(column -> {
+                        try
+                        {
+                            if (column.isSimple())
+                                UnfilteredSerializer.readSimpleColumn(column, finalIn, header, helper, builder, LivenessInfo.EMPTY);
+                            else
+                                UnfilteredSerializer.readComplexColumn(column, finalIn, header, helper, false, builder, LivenessInfo.EMPTY);
+                        }
+                        catch (IOException e)
+                        {
+                            throw new WrappedException(e);
+                        }
+                    });
+                }
+                catch (WrappedException e)
+                {
+                    if (e.getCause() instanceof IOException)
+                        throw (IOException) e.getCause();
+
+                    throw e;
+                }
+
+                return builder.build();
+            }
+            catch (RuntimeException | AssertionError e)
+            {
+                // Corrupted data could be such that it triggers an assertion in the row Builder, or break one of its assumption.
+                // Of course, a bug in said builder could also trigger this, but it's impossible a priori to always make the distinction
+                // between a real bug and data corrupted in just the bad way. Besides, re-throwing as an IOException doesn't hide the
+                // exception, it just make we catch it properly and mark the sstable as corrupted.
+                throw new IOException("Error building row with data deserialized from " + in, e);
+            }
+        }
+
+        public static boolean isEndOfPartition(int flags)
+        {
+            return (flags & END_OF_PARTITION) != 0;
+        }
+
+        public static boolean isStatic(int extendedFlags)
+        {
+            return (extendedFlags & IS_STATIC) != 0;
+        }
+    }
+
+    public static class Serializer
+    {
+        public  static final int IS_EMPTY               = 0x01;
+        private static final int IS_REVERSED            = 0x02;
+        private static final int HAS_STATIC_ROW         = 0x04;
+
+        public static void serialize(RowIterator iterator, ColumnFilter selection, DataOutputPlus out, int version) throws IOException
+        {
+            ByteBufferUtil.writeWithVIntLength(iterator.partitionKey().getKey(), out);
+
+            SerializationHeader header = new SerializationHeader(false,
+                                                                 iterator.metadata(),
+                                                                 iterator.columns(),
+                                                                 EncodingStats.NO_STATS);
+
+            int flags = 0;
+            if (iterator.isReverseOrder())
+                flags |= IS_REVERSED;
+
+            if (iterator.isEmpty())
+            {
+                out.writeByte(flags | IS_EMPTY);
+                return;
+            }
+
+            Row staticRow = iterator.staticRow();
+            boolean hasStatic = staticRow != Rows.EMPTY_STATIC_ROW;
+            if (hasStatic)
+                flags |= HAS_STATIC_ROW;
+
+            out.writeByte((byte)flags);
+
+            SerializationHeader.serializer.serializeForMessaging(header, selection, out, hasStatic);
+            SerializationHelper helper = new SerializationHelper(header);
+
+            if (hasStatic)
+                RowSerializer.serializeStaticRow(staticRow, helper, out, version);
+
+            while (iterator.hasNext())
+                RowSerializer.serializeRow(iterator.next(), helper, out, version);
+            RowSerializer.writeEndOfPartition(out);
+        }
+
+        public static RowIterator deserialize(TableMetadata metadata, ColumnFilter selection, DataInputPlus in, int version) throws IOException
+        {
+            DecoratedKey key = metadata.partitioner.decorateKey(ByteBufferUtil.readWithVIntLength(in));
+            int flags = in.readUnsignedByte();
+            boolean isReversed = (flags & IS_REVERSED) != 0;
+            if ((flags & IS_EMPTY) != 0)
+            {
+                return EmptyIterators.row(metadata, key, isReversed);
+            }
+
+            boolean hasStatic = (flags & HAS_STATIC_ROW) != 0;
+
+            SerializationHeader header = SerializationHeader.serializer.deserializeForMessaging(in, metadata, selection, hasStatic);
+
+            DeserializationHelper helper = new DeserializationHelper(metadata, version, DeserializationHelper.Flag.FROM_REMOTE);
+            Row staticRow = hasStatic ? RowSerializer.deserializeStaticRow(in, header, helper) : Rows.EMPTY_STATIC_ROW;
+
+            return new AbstractRowIterator(metadata, key, header.columns(), isReversed, staticRow)
+            {
+                private final Row.Builder builder = BTreeRow.sortedBuilder();
+
+                @Override
+                protected Row computeNext()
+                {
+                    try
+                    {
+                        Row row = RowSerializer.deserializeRow(in, header, helper, builder);
+                        return row != null ? row : endOfData();
+                    }
+                    catch (IOException e)
+                    {
+                        throw new IOError(e);
+                    }
+                }
+
+                @Override
+                public void close()
+                {
+                    while (hasNext())
+                        next();
+
+                    super.close();
+                }
+            };
+        }
+    };
 }
diff --git a/src/java/org/apache/cassandra/db/rows/UnfilteredSerializer.java b/src/java/org/apache/cassandra/db/rows/UnfilteredSerializer.java
index 112c1e4c6b..7c80c463cf 100644
--- a/src/java/org/apache/cassandra/db/rows/UnfilteredSerializer.java
+++ b/src/java/org/apache/cassandra/db/rows/UnfilteredSerializer.java
@@ -279,7 +279,7 @@ public class UnfilteredSerializer
         }
     }
 
-    private static void writeComplexColumn(ComplexColumnData data, ColumnMetadata column, boolean hasComplexDeletion, LivenessInfo rowLiveness, SerializationHeader header, DataOutputPlus out)
+    static void writeComplexColumn(ComplexColumnData data, ColumnMetadata column, boolean hasComplexDeletion, LivenessInfo rowLiveness, SerializationHeader header, DataOutputPlus out)
     throws IOException
     {
         if (hasComplexDeletion)
@@ -661,7 +661,7 @@ public class UnfilteredSerializer
         }
     }
 
-    private void readSimpleColumn(ColumnMetadata column, DataInputPlus in, SerializationHeader header, DeserializationHelper helper, Row.Builder builder, LivenessInfo rowLiveness)
+    static void readSimpleColumn(ColumnMetadata column, DataInputPlus in, SerializationHeader header, DeserializationHelper helper, Row.Builder builder, LivenessInfo rowLiveness)
     throws IOException
     {
         if (helper.includes(column))
@@ -676,7 +676,7 @@ public class UnfilteredSerializer
         }
     }
 
-    private void readComplexColumn(ColumnMetadata column, DataInputPlus in, SerializationHeader header, DeserializationHelper helper, boolean hasComplexDeletion, Row.Builder builder, LivenessInfo rowLiveness)
+    static void readComplexColumn(ColumnMetadata column, DataInputPlus in, SerializationHeader header, DeserializationHelper helper, boolean hasComplexDeletion, Row.Builder builder, LivenessInfo rowLiveness)
     throws IOException
     {
         if (helper.includes(column))
@@ -733,7 +733,7 @@ public class UnfilteredSerializer
         in.skipBytesFully(markerSize);
     }
 
-    private void skipComplexColumn(DataInputPlus in, ColumnMetadata column, SerializationHeader header, boolean hasComplexDeletion)
+    private static void skipComplexColumn(DataInputPlus in, ColumnMetadata column, SerializationHeader header, boolean hasComplexDeletion)
     throws IOException
     {
         if (hasComplexDeletion)
diff --git a/src/java/org/apache/cassandra/db/streaming/CassandraStreamReceiver.java b/src/java/org/apache/cassandra/db/streaming/CassandraStreamReceiver.java
index 40931e4295..c448d5edbe 100644
--- a/src/java/org/apache/cassandra/db/streaming/CassandraStreamReceiver.java
+++ b/src/java/org/apache/cassandra/db/streaming/CassandraStreamReceiver.java
@@ -48,6 +48,7 @@ import org.apache.cassandra.dht.Token;
 import org.apache.cassandra.io.sstable.ISSTableScanner;
 import org.apache.cassandra.io.sstable.SSTableTxnSingleStreamWriter;
 import org.apache.cassandra.io.sstable.format.SSTableReader;
+import org.apache.cassandra.replication.MutationId;
 import org.apache.cassandra.service.accord.AccordService;
 import org.apache.cassandra.service.accord.AccordTopology;
 import org.apache.cassandra.service.accord.IAccordService;
@@ -200,7 +201,7 @@ public class CassandraStreamReceiver implements StreamReceiver
                     //
                     // If the CFS has CDC, however, these updates need to be written to the CommitLog
                     // so they get archived into the cdc_raw folder
-                    ks.apply(new Mutation(PartitionUpdate.fromIterator(throttledPartitions.next(), filter)),
+                    ks.apply(new Mutation(MutationId.fixme(), PartitionUpdate.fromIterator(throttledPartitions.next(), filter)),
                              writeCDCCommitLog,
                              true,
                              false);
diff --git a/src/java/org/apache/cassandra/db/tracked/TrackedKeyspaceWriteHandler.java b/src/java/org/apache/cassandra/db/tracked/TrackedKeyspaceWriteHandler.java
new file mode 100644
index 0000000000..bfd6d593c6
--- /dev/null
+++ b/src/java/org/apache/cassandra/db/tracked/TrackedKeyspaceWriteHandler.java
@@ -0,0 +1,82 @@
+/*
+ * 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.db.tracked;
+
+import org.apache.cassandra.db.CassandraWriteContext;
+import org.apache.cassandra.db.Keyspace;
+import org.apache.cassandra.db.KeyspaceWriteHandler;
+import org.apache.cassandra.db.Mutation;
+import org.apache.cassandra.db.WriteContext;
+import org.apache.cassandra.db.commitlog.CommitLogPosition;
+import org.apache.cassandra.exceptions.RequestExecutionException;
+import org.apache.cassandra.journal.RecordPointer;
+import org.apache.cassandra.replication.MutationJournal;
+import org.apache.cassandra.tracing.Tracing;
+import org.apache.cassandra.utils.concurrent.OpOrder;
+
+public class TrackedKeyspaceWriteHandler implements KeyspaceWriteHandler
+{
+    @Override
+    public WriteContext beginWrite(Mutation mutation, boolean makeDurable) throws RequestExecutionException
+    {
+        OpOrder.Group group = null;
+        try
+        {
+            group = Keyspace.writeOrder.start();
+
+            Tracing.trace("Appending to mutation journal");
+            RecordPointer pointer = MutationJournal.instance.write(mutation.id(), mutation);
+
+            // TODO (preferred): update journal to return CommitLogPosition or otherwise remove requirement to allocate second object here
+            return new CassandraWriteContext(group, new CommitLogPosition(pointer.segment, pointer.position));
+        }
+        catch (Throwable t)
+        {
+            if (group != null)
+                group.close();
+            throw t;
+        }
+    }
+
+    @Override
+    public WriteContext createContextForIndexing()
+    {
+        return createEmptyContext();
+    }
+
+    @Override
+    public WriteContext createContextForRead()
+    {
+        return createEmptyContext();
+    }
+
+    private WriteContext createEmptyContext()
+    {
+        OpOrder.Group group = Keyspace.writeOrder.start();
+        try
+        {
+            return new CassandraWriteContext(group, null);
+        }
+        catch (Throwable t)
+        {
+            if (group != null)
+                group.close();
+            throw t;
+        }
+    }
+}
diff --git a/src/java/org/apache/cassandra/db/view/TableViews.java b/src/java/org/apache/cassandra/db/view/TableViews.java
index 0f6d877e73..d7b31053e1 100644
--- a/src/java/org/apache/cassandra/db/view/TableViews.java
+++ b/src/java/org/apache/cassandra/db/view/TableViews.java
@@ -41,6 +41,7 @@ import org.apache.cassandra.db.DeletionInfo;
 import org.apache.cassandra.db.DeletionTime;
 import org.apache.cassandra.db.Keyspace;
 import org.apache.cassandra.db.Mutation;
+import org.apache.cassandra.replication.MutationId;
 import org.apache.cassandra.db.RangeTombstone;
 import org.apache.cassandra.db.ReadExecutionController;
 import org.apache.cassandra.db.ReadQuery;
@@ -555,7 +556,7 @@ public class TableViews extends AbstractCollection<View>
             Collection<PartitionUpdate> updates = generator.generateViewUpdates();
             List<Mutation> mutations = new ArrayList<>(updates.size());
             for (PartitionUpdate update : updates)
-                mutations.add(new Mutation(update));
+                mutations.add(new Mutation(MutationId.fixme(), update));
 
             generator.clear();
             return mutations;
@@ -570,7 +571,7 @@ public class TableViews extends AbstractCollection<View>
                 Mutation.PartitionUpdateCollector collector = mutations.get(key);
                 if (collector == null)
                 {
-                    collector = new Mutation.PartitionUpdateCollector(baseTableMetadata.keyspace, key);
+                    collector = new Mutation.PartitionUpdateCollector(MutationId.none(), baseTableMetadata.keyspace, key);
                     mutations.put(key, collector);
                 }
                 collector.add(update);
diff --git a/src/java/org/apache/cassandra/db/virtual/VirtualMutation.java b/src/java/org/apache/cassandra/db/virtual/VirtualMutation.java
index 929ead029f..4aa718462c 100644
--- a/src/java/org/apache/cassandra/db/virtual/VirtualMutation.java
+++ b/src/java/org/apache/cassandra/db/virtual/VirtualMutation.java
@@ -43,6 +43,7 @@ import org.apache.cassandra.io.util.DataOutputPlus;
 import org.apache.cassandra.net.IVerbHandler;
 import org.apache.cassandra.net.MessagingService;
 import org.apache.cassandra.net.NoPayload;
+import org.apache.cassandra.replication.MutationId;
 import org.apache.cassandra.schema.TableId;
 import org.apache.cassandra.service.ClientState;
 
@@ -98,6 +99,18 @@ public final class VirtualMutation implements IMutation
         this.modifications = modifications;
     }
 
+    @Override
+    public MutationId id()
+    {
+        return MutationId.none();
+    }
+
+    @Override
+    public IMutation withMutationId(MutationId mutationId)
+    {
+        throw new IllegalArgumentException("MutationId cannot be used for virtual mutations");
+    }
+
     @Override
     public void apply()
     {
diff --git a/src/java/org/apache/cassandra/hints/HintsDescriptor.java b/src/java/org/apache/cassandra/hints/HintsDescriptor.java
index 5b8db34035..a049ec6cdc 100644
--- a/src/java/org/apache/cassandra/hints/HintsDescriptor.java
+++ b/src/java/org/apache/cassandra/hints/HintsDescriptor.java
@@ -71,7 +71,8 @@ final class HintsDescriptor
     static final int VERSION_40 = 2;
     static final int VERSION_50 = 3;
     static final int VERSION_60 = 4;
-    static final int CURRENT_VERSION = DatabaseDescriptor.getStorageCompatibilityMode().isBefore(5) ? VERSION_40 : VERSION_60;
+    static final int VERSION_61 = 5;
+    static final int CURRENT_VERSION = DatabaseDescriptor.getStorageCompatibilityMode().isBefore(5) ? VERSION_40 : VERSION_61;
 
     static final String COMPRESSION = "compression";
     static final String ENCRYPTION = "encryption";
@@ -261,6 +262,8 @@ final class HintsDescriptor
                 return MessagingService.VERSION_50;
             case VERSION_60:
                 return MessagingService.VERSION_60;
+            case VERSION_61:
+                return MessagingService.VERSION_61;
             default:
                 throw new AssertionError();
         }
diff --git a/src/java/org/apache/cassandra/journal/Journal.java b/src/java/org/apache/cassandra/journal/Journal.java
index 4db2f7bfd8..29972bb20d 100644
--- a/src/java/org/apache/cassandra/journal/Journal.java
+++ b/src/java/org/apache/cassandra/journal/Journal.java
@@ -206,8 +206,11 @@ public class Journal<K, V> implements Shutdownable
 
     public void start()
     {
+        if (state.get() == State.NORMAL)
+            return;
+
         Invariants.require(state.compareAndSet(State.UNINITIALIZED, State.INITIALIZING),
-                              "Unexpected journal state during initialization", state);
+                              "Unexpected journal state during initialization: %s", state);
         metrics.register(flusher);
 
         deleteTmpFiles();
@@ -301,7 +304,7 @@ public class Journal<K, V> implements Shutdownable
             releaser.awaitTermination(1, TimeUnit.MINUTES);
             metrics.deregister();
             Invariants.require(state.compareAndSet(State.SHUTDOWN, State.TERMINATED),
-                                  "Unexpected journal state while trying to shut down", state);
+                                  "Unexpected journal state while trying to shut down: %s", state);
         }
         catch (InterruptedException e)
         {
diff --git a/src/java/org/apache/cassandra/metrics/ReadRepairMetrics.java b/src/java/org/apache/cassandra/metrics/ReadRepairMetrics.java
index 67d3a56ca7..28d2a91f82 100644
--- a/src/java/org/apache/cassandra/metrics/ReadRepairMetrics.java
+++ b/src/java/org/apache/cassandra/metrics/ReadRepairMetrics.java
@@ -49,6 +49,7 @@ public class ReadRepairMetrics
      */
     public static final Meter repairedBlockingFromAccord = Metrics.meter(factory.createMetricName("RepairedBlockingFromAccord"));
     public static final Meter reconcileRead = Metrics.meter(factory.createMetricName("ReconcileRead"));
+    public static final Meter trackedReconcile = Metrics.meter(factory.createMetricName("TrackedReconcile"));
 
     /** @deprecated See CASSANDRA-13910 */
     @Deprecated(since = "4.0")
diff --git a/src/java/org/apache/cassandra/metrics/TableMetrics.java b/src/java/org/apache/cassandra/metrics/TableMetrics.java
index 9e00988da7..356e9c8e92 100644
--- a/src/java/org/apache/cassandra/metrics/TableMetrics.java
+++ b/src/java/org/apache/cassandra/metrics/TableMetrics.java
@@ -59,6 +59,7 @@ import org.apache.cassandra.io.sstable.metadata.MetadataCollector;
 import org.apache.cassandra.metrics.Sampler.SamplerType;
 import org.apache.cassandra.schema.Schema;
 import org.apache.cassandra.schema.SchemaConstants;
+import org.apache.cassandra.service.reads.ReplicaFilteringProtection;
 import org.apache.cassandra.utils.EstimatedHistogram;
 import org.apache.cassandra.utils.ExpMovingAverage;
 import org.apache.cassandra.utils.MovingAverage;
@@ -377,7 +378,7 @@ public class TableMetrics
     public final Meter replicaFilteringProtectionRequests;
     
     /**
-     * This histogram records the maximum number of rows {@link org.apache.cassandra.service.reads.ReplicaFilteringProtection}
+     * This histogram records the maximum number of rows {@link ReplicaFilteringProtection}
      * caches at a point in time per query. With no replica divergence, this is equivalent to the maximum number of
      * cached rows in a single partition during a query. It can be helpful when choosing appropriate values for the
      * replica_filtering_protection thresholds in cassandra.yaml.
diff --git a/src/java/org/apache/cassandra/net/Message.java b/src/java/org/apache/cassandra/net/Message.java
index 98ecca9ef5..81001fcf59 100644
--- a/src/java/org/apache/cassandra/net/Message.java
+++ b/src/java/org/apache/cassandra/net/Message.java
@@ -61,6 +61,7 @@ import static org.apache.cassandra.db.TypeSizes.sizeofUnsignedVInt;
 import static org.apache.cassandra.net.MessagingService.VERSION_40;
 import static org.apache.cassandra.net.MessagingService.VERSION_50;
 import static org.apache.cassandra.net.MessagingService.VERSION_60;
+import static org.apache.cassandra.net.MessagingService.VERSION_61;
 import static org.apache.cassandra.utils.FBUtilities.getBroadcastAddressAndPort;
 import static org.apache.cassandra.utils.MonotonicClock.Global.approxTime;
 import static org.apache.cassandra.utils.vint.VIntCoding.computeUnsignedVIntSize;
@@ -246,6 +247,12 @@ public class Message<T> implements ResponseContext
         return outWithParam(nextId(), verb, 0, payload, flag.addTo(0), null, null);
     }
 
+    public static <T> Message<T> outWithParam(Verb verb, T payload, ParamType paramType, Object paramValue)
+    {
+        assert !verb.isResponse() : verb;
+        return outWithParam(nextId(), verb, payload, paramType, paramValue);
+    }
+
     public static <T> Message<T> outWithFlags(Verb verb, T payload, MessageFlag flag1, MessageFlag flag2)
     {
         assert !verb.isResponse();
@@ -1263,6 +1270,7 @@ public class Message<T> implements ResponseContext
     private int serializedSize40;
     private int serializedSize50;
     private int serializedSize51;
+    private int serializedSize52;
 
     /**
      * Serialized size of the entire message, for the provided messaging version. Caches the calculated value.
@@ -1283,6 +1291,10 @@ public class Message<T> implements ResponseContext
                 if (serializedSize51 == 0)
                     serializedSize51 = serializer.serializedSize(this, VERSION_60);
                 return serializedSize51;
+            case VERSION_61:
+                if (serializedSize52 == 0)
+                    serializedSize52 = serializer.serializedSize(this, VERSION_61);
+                return serializedSize52;
             default:
                 throw new IllegalStateException("Unknown serialization version " + version);
         }
@@ -1290,7 +1302,8 @@ public class Message<T> implements ResponseContext
 
     private int payloadSize40 = -1;
     private int payloadSize50 = -1;
-    private int payloadSize51 = -1;
+    private int payloadSize60 = -1;
+    private int payloadSize61 = -1;
 
     private int payloadSize(int version)
     {
@@ -1305,9 +1318,13 @@ public class Message<T> implements ResponseContext
                     payloadSize50 = serializer.payloadSize(this, VERSION_50);
                 return payloadSize50;
             case VERSION_60:
-                if (payloadSize51 < 0)
-                    payloadSize51 = serializer.payloadSize(this, VERSION_60);
-                return payloadSize51;
+                if (payloadSize60 < 0)
+                    payloadSize60 = serializer.payloadSize(this, VERSION_60);
+                return payloadSize60;
+            case VERSION_61:
+                if (payloadSize61 < 0)
+                    payloadSize61 = serializer.payloadSize(this, VERSION_61);
+                return payloadSize61;
 
             default:
                 throw new IllegalStateException("Unkown serialization version " + version);
diff --git a/src/java/org/apache/cassandra/net/MessagingService.java b/src/java/org/apache/cassandra/net/MessagingService.java
index f7208f694e..4d567d4d11 100644
--- a/src/java/org/apache/cassandra/net/MessagingService.java
+++ b/src/java/org/apache/cassandra/net/MessagingService.java
@@ -225,7 +225,9 @@ public class MessagingService extends MessagingServiceMBeanImpl implements Messa
         // c14227 TTL overflow, 'uint' timestamps
         VERSION_50(13),
         // TCM, index hints
-        VERSION_60(14);
+        VERSION_60(14),
+        // Mutation Tracking
+        VERSION_61(15);
 
         public static final Version MIN_ACCORD_VERSION = Version.VERSION_60;
 
@@ -266,8 +268,9 @@ public class MessagingService extends MessagingServiceMBeanImpl implements Messa
     public static final int VERSION_40 = 12;
     public static final int VERSION_50 = 13; // c14227 TTL overflow, 'uint' timestamps
     public static final int VERSION_60 = 14; // TCM, index hints
+    public static final int VERSION_61 = 15; // Mutation Tracking
     public static final int minimum_version = VERSION_40;
-    public static final int maximum_version = VERSION_60;
+    public static final int maximum_version = VERSION_61;
     // we want to use a modified behavior for the tools and clients - that is, since they are not running a server, they
     // should not need to run in a compatibility mode. They should be able to connect to the server regardless whether
     // it uses messaving version 4 or 5
@@ -309,7 +312,7 @@ public class MessagingService extends MessagingServiceMBeanImpl implements Messa
 
     private static Version currentVersion()
     {
-        return DatabaseDescriptor.getStorageCompatibilityMode().isBefore(5) ? Version.VERSION_40 : Version.VERSION_60;
+        return DatabaseDescriptor.getStorageCompatibilityMode().isBefore(5) ? Version.VERSION_40 : Version.VERSION_61;
     }
 
     private static class MSHandle
diff --git a/src/java/org/apache/cassandra/net/Verb.java b/src/java/org/apache/cassandra/net/Verb.java
index ad2317e5ab..3969a27de7 100644
--- a/src/java/org/apache/cassandra/net/Verb.java
+++ b/src/java/org/apache/cassandra/net/Verb.java
@@ -131,6 +131,12 @@ import org.apache.cassandra.service.paxos.cleanup.PaxosStartPrepareCleanup;
 import org.apache.cassandra.service.paxos.cleanup.PaxosUpdateLowBallot;
 import org.apache.cassandra.service.paxos.v1.PrepareVerbHandler;
 import org.apache.cassandra.service.paxos.v1.ProposeVerbHandler;
+import org.apache.cassandra.service.reads.tracked.ReadReconcileNotify;
+import org.apache.cassandra.service.reads.tracked.ReadReconcileReceive;
+import org.apache.cassandra.service.reads.tracked.ReadReconcileSend;
+import org.apache.cassandra.service.reads.tracked.TrackedDataResponse;
+import org.apache.cassandra.service.reads.tracked.TrackedRead;
+import org.apache.cassandra.service.reads.tracked.TrackedSummaryResponse;
 import org.apache.cassandra.streaming.DataMovement;
 import org.apache.cassandra.streaming.DataMovementVerbHandler;
 import org.apache.cassandra.streaming.ReplicationDoneVerbHandler;
@@ -314,6 +320,18 @@ public enum Verb
     TCM_FETCH_PEER_LOG_RSP (818, P0, shortTimeout,    FETCH_METADATA,       MessageSerializers::logStateSerializer,             RESPONSE_HANDLER                                 ),
     TCM_FETCH_PEER_LOG_REQ (819, P0, rpcTimeout,      FETCH_METADATA,       () -> FetchPeerLog.serializer,                      () -> FetchPeerLog.Handler.instance,        TCM_FETCH_PEER_LOG_RSP ),
 
+    // tracked replication
+    READ_RECONCILE_SEND        (901, P0, rpcTimeout, READ, () -> ReadReconcileSend.serializer, () -> ReadReconcileSend.verbHandler                                ),
+    READ_RECONCILE_RCV         (902, P0, rpcTimeout, MUTATION, () -> ReadReconcileReceive.serializer, () -> ReadReconcileReceive.verbHandler                             ),
+    READ_RECONCILE_NOTIFY      (903, P0, rpcTimeout, REQUEST_RESPONSE, () -> ReadReconcileNotify.serializer, () -> ReadReconcileNotify.verbHandler                              ),
+
+    TRACKED_PARTITION_READ_RSP (906, P2, readTimeout, REQUEST_RESPONSE, () -> TrackedDataResponse.serializer, RESPONSE_HANDLER                                 ),
+    TRACKED_PARTITION_READ_REQ (907, P3, readTimeout, READ, () -> TrackedRead.DataRequest.serializer, () -> TrackedRead.verbHandler, TRACKED_PARTITION_READ_RSP),
+    TRACKED_RANGE_READ_RSP     (908, P2, rangeTimeout, REQUEST_RESPONSE, () -> TrackedDataResponse.serializer,        RESPONSE_HANDLER                                 ),
+    TRACKED_RANGE_READ_REQ     (909, P3, rangeTimeout, READ,             () -> TrackedRead.DataRequest.serializer,    () -> TrackedRead.verbHandler,           TRACKED_RANGE_READ_RSP    ),
+    TRACKED_SUMMARY_RSP        (910, P2, readTimeout, REQUEST_RESPONSE, () -> TrackedSummaryResponse.serializer, () -> TrackedSummaryResponse.verbHandler                           ),
+    TRACKED_SUMMARY_REQ        (911, P3, readTimeout,  READ,             () -> TrackedRead.SummaryRequest.serializer, () -> TrackedRead.verbHandler,           TRACKED_SUMMARY_RSP       ),
+
     INITIATE_DATA_MOVEMENTS_RSP (814, P1, rpcTimeout, MISC, () -> NoPayload.serializer,             RESPONSE_HANDLER                                  ),
     INITIATE_DATA_MOVEMENTS_REQ (815, P1, rpcTimeout, MISC, () -> DataMovement.serializer,          () -> DataMovementVerbHandler.instance, INITIATE_DATA_MOVEMENTS_RSP ),
     DATA_MOVEMENT_EXECUTED_RSP  (816, P1, rpcTimeout, MISC, () -> NoPayload.serializer,             RESPONSE_HANDLER                                  ),
diff --git a/src/java/org/apache/cassandra/replication/CoordinatorLog.java b/src/java/org/apache/cassandra/replication/CoordinatorLog.java
new file mode 100644
index 0000000000..0abd0db93e
--- /dev/null
+++ b/src/java/org/apache/cassandra/replication/CoordinatorLog.java
@@ -0,0 +1,240 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.cassandra.replication;
+
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.concurrent.locks.ReadWriteLock;
+import java.util.concurrent.locks.ReentrantReadWriteLock;
+
+import org.apache.cassandra.db.Mutation;
+import org.apache.cassandra.db.PartitionPosition;
+import org.apache.cassandra.dht.AbstractBounds;
+import org.apache.cassandra.dht.Token;
+import org.apache.cassandra.schema.TableId;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import static org.apache.cassandra.utils.Clock.Global.currentTimeMillis;
+
+public abstract class CoordinatorLog
+{
+    private static final Logger logger = LoggerFactory.getLogger(CoordinatorLog.class);
+
+    protected final int localHostId;
+    protected final CoordinatorLogId logId;
+    protected final Participants participants;
+
+    /**
+     * State machines and an Id <-> token index for unreconciled mutation ids that exist oh this host.
+     */
+    private final LocalMutationStates unreconciledMutations;
+
+    protected final Offsets.Mutable[] witnessedIds;
+    protected final Offsets.Mutable reconciledIds;
+    protected final ReadWriteLock lock;
+
+    CoordinatorLog(int localHostId, CoordinatorLogId logId, Participants participants)
+    {
+        this.localHostId = localHostId;
+        this.logId = logId;
+        this.participants = participants;
+        this.unreconciledMutations = new LocalMutationStates();
+        this.lock = new ReentrantReadWriteLock();
+
+        Offsets.Mutable[] ids = new Offsets.Mutable[participants.size()];
+        for (int i = 0; i < participants.size(); i++)
+            ids[i] = new Offsets.Mutable(logId);
+
+        witnessedIds = ids;
+        reconciledIds = new Offsets.Mutable(logId);
+    }
+
+    static CoordinatorLog create(int localHostId, CoordinatorLogId id, Participants participants)
+    {
+        return id.hostId == localHostId ? new CoordinatorLogPrimary(localHostId, id, participants)
+                                        : new CoordinatorLogReplica(localHostId, id, participants);
+    }
+
+    void witnessedRemoteMutation(MutationId mutationId, int onHostId)
+    {
+        logger.trace("witnessed remote mutation {} from {}", mutationId, onHostId);
+        lock.writeLock().lock();
+        try
+        {
+            if (!get(onHostId).add(mutationId.offset()))
+                return; // already witnessed
+
+            if (!getLocal().contains(mutationId.offset()))
+                return; // local host hasn't witnessed -> no cleanup needed
+
+            // see if any other replicas haven't witnessed the id yet
+            boolean allOtherReplicasWitnessed = true;
+            for (int i = 0; i < participants.size() && allOtherReplicasWitnessed; i++)
+            {
+                int hostId = participants.get(i);
+                if (hostId != onHostId && hostId != localHostId && !get(hostId).contains(mutationId.offset()))
+                    allOtherReplicasWitnessed = false;
+            }
+
+            if (allOtherReplicasWitnessed)
+            {
+                logger.trace("marking mutation {} as fully reconciled", mutationId);
+                // if all replicas have now witnessed the id, remove it from the index
+                unreconciledMutations.remove(mutationId.offset());
+                reconciledIds.add(mutationId.offset());
+            }
+        }
+        finally
+        {
+            lock.writeLock().unlock();
+        }
+    }
+
+    void startWriting(Mutation mutation)
+    {
+        lock.writeLock().lock();
+        try
+        {
+            if (getLocal().contains(mutation.id().offset()))
+                return; // already witnessed; shouldn't get to this path often (duplicate mutation)
+
+            unreconciledMutations.startWriting(mutation);
+        }
+        finally
+        {
+            lock.writeLock().unlock();
+        }
+    }
+
+    void finishWriting(Mutation mutation)
+    {
+        logger.trace("witnessed local mutation {}", mutation.id());
+        lock.writeLock().lock();
+        try
+        {
+            if (!getLocal().add(mutation.id().offset()))
+                throw new IllegalStateException("finishWriting() called on a reconciled mutation");
+
+            // see if any other replicas haven't witnessed the id yet
+            boolean allOtherReplicasWitnessed = true;
+            for (int i = 0; i < participants.size() && allOtherReplicasWitnessed; i++)
+            {
+                int hostId = participants.get(i);
+                if (hostId != localHostId && !get(hostId).contains(mutation.id().offset()))
+                    allOtherReplicasWitnessed = false;
+            }
+
+            // if some replicas also haven't witnessed the mutation yet, we should update local mutation state;
+            // otherwise we are the last node to witness this mutation, and can clean it up
+            if (allOtherReplicasWitnessed)
+                reconciledIds.add(mutation.id().offset());
+            else
+                unreconciledMutations.finishWriting(mutation);
+        }
+        finally
+        {
+            lock.writeLock().unlock();
+        }
+    }
+
+    /**
+     * Look up unreconciled sequence ids of mutations witnessed by this host in this coordinataor log.
+     * Adds the ids to the supplied collection, so it can be reused to aggregate lookups for multiple logs.
+     */
+    boolean collectOffsetsFor(Token token, TableId tableId, boolean includePending, Offsets.OffsetReciever unreconciledInto, Offsets.OffsetReciever reconciledInto)
+    {
+        lock.readLock().lock();
+        try
+        {
+            reconciledInto.addAll(reconciledIds);
+            return unreconciledMutations.collect(token, tableId, includePending, unreconciledInto);
+        }
+        finally
+        {
+            lock.readLock().unlock();
+        }
+    }
+
+    /**
+     * Look up unreconciled sequence ids of mutations witnessed by this host in this coordinataor log.
+     * Adds the ids to the supplied collection, so it can be reused to aggregate lookups for multiple logs.
+     */
+    boolean collectOffsetsFor(AbstractBounds<PartitionPosition> range, TableId tableId, boolean includePending, Offsets.OffsetReciever unreconciledInto, Offsets.OffsetReciever reconciledInto)
+    {
+        lock.readLock().lock();
+        try
+        {
+            reconciledInto.addAll(reconciledIds);
+            return unreconciledMutations.collect(range, tableId, includePending, unreconciledInto);
+        }
+        finally
+        {
+            lock.readLock().unlock();
+        }
+    }
+
+    protected Offsets.Mutable get(int hostId)
+    {
+        return witnessedIds[participants.indexOf(hostId)];
+    }
+
+    protected Offsets.Mutable getLocal()
+    {
+        return witnessedIds[participants.indexOf(localHostId)];
+    }
+
+    public static class CoordinatorLogPrimary extends CoordinatorLog
+    {
+        AtomicLong sequenceId = new AtomicLong(-1);
+
+        CoordinatorLogPrimary(int localHostId, CoordinatorLogId logId, Participants participants)
+        {
+            super(localHostId, logId, participants);
+        }
+
+        MutationId nextId()
+        {
+            return new MutationId(logId.asLong(), nextSequenceId());
+        }
+
+        private long nextSequenceId()
+        {
+            while (true)
+            {
+                long prev = sequenceId.get();
+                int prevOffset = MutationId.offset(prev);
+                int prevTimestamp = MutationId.timestamp(prev);
+
+                int nextOffset = prevOffset + 1;
+                int nextTimestamp = Math.max(prevTimestamp + 1, (int) (currentTimeMillis() / 1000L));
+                long next = MutationId.sequenceId(nextOffset, nextTimestamp);
+
+                if (sequenceId.compareAndSet(prev, next))
+                    return next;
+            }
+        }
+    }
+
+    public static class CoordinatorLogReplica extends CoordinatorLog
+    {
+        CoordinatorLogReplica(int localHostId, CoordinatorLogId logId, Participants participants)
+        {
+            super(localHostId, logId, participants);
+        }
+    }
+}
diff --git a/src/java/org/apache/cassandra/replication/CoordinatorLogId.java b/src/java/org/apache/cassandra/replication/CoordinatorLogId.java
new file mode 100644
index 0000000000..55c38485b0
--- /dev/null
+++ b/src/java/org/apache/cassandra/replication/CoordinatorLogId.java
@@ -0,0 +1,127 @@
+/*
+ * 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.replication;
+
+import org.apache.cassandra.db.TypeSizes;
+import org.apache.cassandra.io.IVersionedSerializer;
+import org.apache.cassandra.io.util.DataInputPlus;
+import org.apache.cassandra.io.util.DataOutputPlus;
+
+import java.io.IOException;
+import java.io.Serializable;
+import java.util.Comparator;
+
+public class CoordinatorLogId implements Serializable
+{
+    /** TCM host ID */
+    protected final int hostId;
+
+    /**
+     * Host log ID (unique within the host).
+     * Allocated anew on host restart - one per token range replicated by the host.
+     * Persisted on allocation, unique within the host.
+     */
+    protected final int hostLogId;
+
+    CoordinatorLogId(long id)
+    {
+        this(hostId(id), hostLogId(id));
+    }
+
+    CoordinatorLogId(int hostId, int hostLogId)
+    {
+        this.hostId = hostId;
+        this.hostLogId = hostLogId;
+    }
+
+    public int hostId()
+    {
+        return hostId;
+    }
+
+    public int hostLogId()
+    {
+        return hostLogId;
+    }
+
+    public long asLong()
+    {
+        return asLong(hostId, hostLogId);
+    }
+
+    static long asLong(int hostId, int hostLogId)
+    {
+        return ((long) hostId << 32) | hostLogId;
+    }
+
+    static int hostId(long coordinatorLogId)
+    {
+        return (int) (coordinatorLogId >>> 32);
+    }
+
+    static int hostLogId(long coordinatorLogId)
+    {
+        return (int) coordinatorLogId;
+    }
+
+    @Override
+    public String toString()
+    {
+        return "CoordinatorLogId{" + hostId + ", " + hostLogId + '}';
+    }
+
+    @Override
+    public boolean equals(Object o)
+    {
+        if (o == null || getClass() != o.getClass()) return false;
+        CoordinatorLogId logId = (CoordinatorLogId) o;
+        return hostId == logId.hostId && hostLogId == logId.hostLogId;
+    }
+
+    @Override
+    public int hashCode()
+    {
+        return Integer.hashCode(hostLogId) + 31 * Integer.hashCode(hostId);
+    }
+
+    public static final Comparator<CoordinatorLogId> comparator = (l, r) -> Long.compareUnsigned(l.asLong(), r.asLong());
+
+    public static final IVersionedSerializer<CoordinatorLogId> serializer = new IVersionedSerializer<>()
+    {
+        @Override
+        public void serialize(CoordinatorLogId logId, DataOutputPlus out, int version) throws IOException
+        {
+            out.writeInt(logId.hostId);
+            out.writeInt(logId.hostLogId);
+        }
+
+        @Override
+        public CoordinatorLogId deserialize(DataInputPlus in, int version) throws IOException
+        {
+            int hostId = in.readInt();
+            int hostLogId = in.readInt();
+            return new CoordinatorLogId(hostId, hostLogId);
+        }
+
+        @Override
+        public long serializedSize(CoordinatorLogId logId, int version)
+        {
+            return TypeSizes.sizeof(logId.hostId) + TypeSizes.sizeof(logId.hostLogId);
+        }
+    };
+}
diff --git a/src/java/org/apache/cassandra/replication/LocalMutationStates.java b/src/java/org/apache/cassandra/replication/LocalMutationStates.java
new file mode 100644
index 0000000000..9bb1b4d834
--- /dev/null
+++ b/src/java/org/apache/cassandra/replication/LocalMutationStates.java
@@ -0,0 +1,187 @@
+/*
+ * 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.replication;
+
+import java.util.Collection;
+import java.util.Comparator;
+import java.util.Set;
+import java.util.SortedSet;
+import java.util.TreeSet;
+
+import com.google.common.base.Preconditions;
+import com.google.common.collect.Sets;
+
+import org.agrona.collections.Int2ObjectHashMap;
+import org.apache.cassandra.db.Mutation;
+import org.apache.cassandra.db.PartitionPosition;
+import org.apache.cassandra.dht.AbstractBounds;
+import org.apache.cassandra.dht.Token;
+import org.apache.cassandra.schema.TableId;
+
+/**
+ * Tracks unreconciled local mutations - the subset of all unreconciled mutations
+ * that have been witnessed, or are currently being written to, on the local node.
+ */
+class LocalMutationStates
+{
+    private final Int2ObjectHashMap<Entry> statesMap = new Int2ObjectHashMap<>();
+    private final SortedSet<Entry> statesSet = new TreeSet<>(Entry.comparator);
+
+    enum Visibility
+    {
+        PENDING, // written to the journal, but not yet to LSM
+        VISIBLE, // written to both the journal and LSM
+    }
+
+    private static final class Entry
+    {
+        private static final Comparator<Entry> comparator = (left, right) ->
+        {
+            int cmp = left.token.compareTo(right.token);
+            return (cmp != 0) ? cmp : Integer.compare(left.offset, right.offset);
+        };
+
+        final Token token;
+        final int offset;
+        final Object tableOrTables;
+        private Visibility visibility;
+
+        Entry(Token token, int offset, Object tableOrTables, Visibility visibility)
+        {
+            this.token = token;
+            this.offset = offset;
+            this.tableOrTables = tableOrTables;
+            this.visibility = visibility;
+        }
+
+        static Entry create(Mutation mutation)
+        {
+            Collection<TableId> ids = mutation.getTableIds();
+            Preconditions.checkArgument(!ids.isEmpty());
+            return new Entry(mutation.key().getToken(), mutation.id().offset(), tableOrTables(mutation), Visibility.PENDING);
+        }
+
+        private static Object tableOrTables(Mutation mutation)
+        {
+            Collection<TableId> ids = mutation.getTableIds();
+            Preconditions.checkArgument(!ids.isEmpty());
+            return ids.size() == 1 ? ids.iterator().next() : Sets.newHashSet(mutation.getTableIds());
+        }
+
+        boolean contains(TableId tableId)
+        {
+            return tableOrTables instanceof Set
+                 ? ((Set<?>) tableOrTables).contains(tableId)
+                 : tableId.equals(tableOrTables);
+        }
+
+        boolean isVisible()
+        {
+            return visibility == Visibility.VISIBLE;
+        }
+
+        static Entry start(Token token, boolean isInclusive)
+        {
+            return new Entry(token, isInclusive ? 0 : Integer.MAX_VALUE, null, null);
+        }
+
+        static Entry end(Token token, boolean isInclusive)
+        {
+            return new Entry(token, isInclusive ? Integer.MAX_VALUE : 0, null, null);
+        }
+
+        @Override
+        public boolean equals(Object o)
+        {
+            if (!(o instanceof Entry))
+                return false;
+            Entry that = (Entry) o;
+            return this.offset == that.offset && this.token.equals(that.token);
+        }
+    }
+
+    void startWriting(Mutation mutation)
+    {
+        Entry entry = Entry.create(mutation);
+        statesMap.put(entry.offset, entry);
+        statesSet.add(entry);
+    }
+
+    void finishWriting(Mutation mutation)
+    {
+        Entry entry = statesMap.get(mutation.id().offset());
+        Preconditions.checkNotNull(entry);
+        entry.visibility = Visibility.VISIBLE;
+    }
+
+    void remove(int offset)
+    {
+        Entry state = statesMap.remove(offset);
+        Preconditions.checkNotNull(state);
+        statesSet.remove(state);
+    }
+
+    boolean collect(Token token, TableId tableId, boolean includePending, Offsets.OffsetReciever into)
+    {
+        SortedSet<Entry> subset = statesSet.subSet(Entry.start(token, true), Entry.end(token, true));
+        return collect(subset, tableId, includePending, into);
+    }
+
+    boolean collect(AbstractBounds<PartitionPosition> range, TableId tableId, boolean includePending, Offsets.OffsetReciever into)
+    {
+        Entry start = Entry.start(range.left.getToken(), range.left.kind() != PartitionPosition.Kind.MAX_BOUND);
+        Entry end = Entry.end(range.right.getToken(), range.right.kind() != PartitionPosition.Kind.MIN_BOUND);
+        return collect(start, end, tableId, includePending, into);
+    }
+
+    private boolean collect(SortedSet<Entry> subset, TableId tableId, boolean includePending, Offsets.OffsetReciever into)
+    {
+        boolean found = false;
+        for (Entry entry : subset)
+        {
+            if (entry.contains(tableId) && (includePending || entry.isVisible()))
+            {
+                into.add(entry.offset);
+                found = true;
+            }
+        }
+        return found;
+    }
+
+    private boolean collect(Entry start, Entry end, TableId tableId, boolean includePending, Offsets.OffsetReciever into)
+    {
+        int cmp = start.token.compareTo(end.token);
+        if (cmp == 0)
+        {
+            // full range
+            return collect(statesSet, tableId, includePending, into);
+        }
+        else if (cmp > 0)
+        {
+            // wrap around range
+            boolean lFound = collect(statesSet.headSet(end), tableId, includePending, into);
+            boolean rFound = collect(statesSet.tailSet(start), tableId, includePending, into);
+            return lFound || rFound;
+        }
+        else
+        {
+            // contiguous range
+            return collect(statesSet.subSet(start, end), tableId, includePending, into);
+        }
+    }
+}
diff --git a/src/java/org/apache/cassandra/replication/Log2OffsetsMap.java b/src/java/org/apache/cassandra/replication/Log2OffsetsMap.java
new file mode 100644
index 0000000000..efcdd195ec
--- /dev/null
+++ b/src/java/org/apache/cassandra/replication/Log2OffsetsMap.java
@@ -0,0 +1,230 @@
+/*
+ * 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.replication;
+
+import java.io.IOException;
+import java.util.Iterator;
+
+import com.google.common.base.Preconditions;
+import com.google.common.collect.Iterables;
+
+import org.agrona.collections.Long2ObjectHashMap;
+import org.apache.cassandra.db.TypeSizes;
+import org.apache.cassandra.io.IVersionedSerializer;
+import org.apache.cassandra.io.util.DataInputPlus;
+import org.apache.cassandra.io.util.DataOutputPlus;
+
+public abstract class Log2OffsetsMap<T extends Offsets> implements Iterable<ShortMutationId>
+{
+    abstract Long2ObjectHashMap<T> offsetMap();
+
+    @Override
+    public Iterator<ShortMutationId> iterator()
+    {
+        return Iterables.concat(offsetMap().values()).iterator();
+    }
+
+    public int idCount()
+    {
+        int count = 0;
+        for (T offsets : offsetMap().values())
+            count += offsets.offsetCount();
+        return count;
+    }
+
+    public boolean isEmpty()
+    {
+        for (T offsets : offsetMap().values())
+            if (!offsets.isEmpty())
+                return false;
+        return true;
+    }
+
+    private static abstract class AbstractMutable<T extends Offsets.AbstractMutable<T>> extends Log2OffsetsMap<T>
+    {
+        protected final Long2ObjectHashMap<T> offsetMap = new Long2ObjectHashMap<>();
+
+        @Override
+        Long2ObjectHashMap<T> offsetMap()
+        {
+            return offsetMap;
+        }
+
+        protected abstract T createOrNull(Offsets.RangeIterator iterator);
+        protected abstract T create(CoordinatorLogId logId);
+        protected abstract T copy(Offsets offsets);
+
+        protected T create(long logId)
+        {
+            return create(new CoordinatorLogId(logId));
+        }
+
+        public void add(ShortMutationId id)
+        {
+            T offsets = offsetMap.computeIfAbsent(id.logId(), this::create);
+            offsets.add(id.offset());
+        }
+
+        public void add(Offsets offsets)
+        {
+            if (offsets.isEmpty())
+                return;
+
+            T existing = offsetMap.get(offsets.logId().asLong());
+            if (existing == null)
+            {
+                offsetMap.put(offsets.logId().asLong(), copy(offsets));
+                return;
+            }
+
+            existing.addAll(offsets);
+        }
+
+        public void addAll(Log2OffsetsMap<?> that)
+        {
+            for (Offsets offsets : that.offsetMap().values())
+                add(offsets);
+        }
+
+        public void remove(Offsets offsets)
+        {
+            T existing = offsetMap.get(offsets.logId().asLong());
+            if (existing == null)
+                return;
+
+            if (existing.isEmpty())
+            {
+                offsetMap.remove(offsets.logId().asLong());
+                return;
+            }
+
+            T next = createOrNull(Offsets.difference(existing.rangeIterator(), offsets.rangeIterator()));
+            if (next == null)
+                offsetMap.remove(offsets.logId().asLong());
+            else
+                offsetMap.put(offsets.logId().asLong(), next);
+        }
+
+        public void removeAll(Log2OffsetsMap<?> that)
+        {
+            for (Offsets offsets : that.offsetMap().values())
+                remove(offsets);
+        }
+    }
+
+    public static class Mutable extends AbstractMutable<Offsets.Mutable>
+    {
+        @Override
+        protected Offsets.Mutable createOrNull(Offsets.RangeIterator iterator)
+        {
+            return Offsets.Mutable.createOrNull(iterator);
+        }
+
+        @Override
+        protected Offsets.Mutable create(CoordinatorLogId logId)
+        {
+            return new Offsets.Mutable(logId);
+        }
+
+        @Override
+        protected Offsets.Mutable copy(Offsets offsets)
+        {
+            return Offsets.Mutable.copy(offsets);
+        }
+    }
+
+    public static class Immutable extends Log2OffsetsMap<Offsets.Immutable>
+    {
+        private final Long2ObjectHashMap<Offsets.Immutable> offsetMap;
+
+        private Immutable(Long2ObjectHashMap<Offsets.Immutable> offsetMap)
+        {
+            this.offsetMap = offsetMap;
+        }
+
+        @Override
+        Long2ObjectHashMap<Offsets.Immutable> offsetMap()
+        {
+            return offsetMap;
+        }
+
+        public static class Builder extends AbstractMutable<Offsets.Immutable.Builder>
+        {
+            @Override
+            protected Offsets.Immutable.Builder createOrNull(Offsets.RangeIterator iterator)
+            {
+                return Offsets.Immutable.Builder.createOrNull(iterator);
+            }
+
+            @Override
+            protected Offsets.Immutable.Builder create(CoordinatorLogId logId)
+            {
+                return new Offsets.Immutable.Builder(logId);
+            }
+
+            @Override
+            protected Offsets.Immutable.Builder copy(Offsets offsets)
+            {
+                return Offsets.Immutable.Builder.copy(offsets);
+            }
+
+            public Log2OffsetsMap.Immutable build()
+            {
+                Long2ObjectHashMap<Offsets.Immutable> result = new Long2ObjectHashMap<>();
+                offsetMap.forEachLong((key, builder) -> result.put(key, builder.build()));
+                return new Immutable(result);
+            }
+        }
+
+        public static final IVersionedSerializer<Log2OffsetsMap.Immutable> serializer = new IVersionedSerializer<Log2OffsetsMap.Immutable>()
+        {
+            @Override
+            public void serialize(Log2OffsetsMap.Immutable mo, DataOutputPlus out, int version) throws IOException
+            {
+                out.writeInt(mo.offsetMap.size());
+                for (Offsets.Immutable offsets : mo.offsetMap().values())
+                    Offsets.serializer.serialize(offsets, out, version);
+            }
+
+            @Override
+            public Log2OffsetsMap.Immutable deserialize(DataInputPlus in, int version) throws IOException
+            {
+                Long2ObjectHashMap<Offsets.Immutable> offsetMap = new Long2ObjectHashMap<>();
+                int size = in.readInt();
+                for (int i=0; i<size; i++)
+                {
+                    Offsets.Immutable offsets = Offsets.serializer.deserialize(in, version);
+                    long key = offsets.logId().asLong();
+                    Preconditions.checkState(!offsetMap.containsKey(key));
+                    offsetMap.put(key, offsets);
+                }
+                return new Immutable(offsetMap);
+            }
+
+            @Override
+            public long serializedSize(Log2OffsetsMap.Immutable mo, int version)
+            {
+                long size = TypeSizes.INT_SIZE;
+                for (Offsets.Immutable offsets : mo.offsetMap.values())
+                    size += Offsets.serializer.serializedSize(offsets, version);
+                return size;
+            }
+        };
+    }
+}
diff --git a/src/java/org/apache/cassandra/replication/MutationId.java b/src/java/org/apache/cassandra/replication/MutationId.java
new file mode 100644
index 0000000000..ea33708833
--- /dev/null
+++ b/src/java/org/apache/cassandra/replication/MutationId.java
@@ -0,0 +1,135 @@
+/*
+ * 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.replication;
+
+import java.io.IOException;
+import java.util.Comparator;
+
+import org.apache.cassandra.db.TypeSizes;
+import org.apache.cassandra.io.IVersionedSerializer;
+import org.apache.cassandra.io.util.DataInputPlus;
+import org.apache.cassandra.io.util.DataOutputPlus;
+
+/**
+ * Full mutation id, with the addition of timestamp component.
+ * <p>
+ * equals() and hashCode() are intentionally not overridden by this class, since log id and offset alone
+ * are meant to uniquely identify a mutation.
+ */
+public class MutationId extends ShortMutationId
+{
+    private static final long NONE_LOG_ID = Long.MIN_VALUE;
+    private static final long NONE_SEQUENCE_ID = Long.MIN_VALUE;
+    private static final int NONE_OFFSET = offset(NONE_SEQUENCE_ID);
+    private static final int NONE_TIMESTAMP = timestamp(NONE_SEQUENCE_ID);
+    private static final MutationId NONE = new MutationId(NONE_LOG_ID, NONE_SEQUENCE_ID);
+
+    /**
+     * 4 byte timestamp. The timestamp is monotonically non-decreasing.
+     * The offset alone is sufficient to identify the entry within a coordinator
+     * log, the timestamp is added for correlation purposes.
+     */
+    protected final int timestamp;
+
+    public MutationId(long logId, long sequenceId)
+    {
+        super(logId, offset(sequenceId));
+        this.timestamp = timestamp(sequenceId);
+    }
+
+    public long sequenceId()
+    {
+        return sequenceId(offset, timestamp);
+    }
+
+    public int timestamp()
+    {
+        return timestamp;
+    }
+
+    public static long sequenceId(int offset, int timestamp)
+    {
+        return ((long) offset << 32) | timestamp;
+    }
+
+    public static int offset(long sequenceId)
+    {
+        return (int) (0xffffffffL & (sequenceId >> 32));
+    }
+
+    public static int timestamp(long sequenceId)
+    {
+        return (int) (0xffffffffL & sequenceId);
+    }
+
+    // FIXME: used in place of figuring out if we should use a mutation id or not
+    public static MutationId fixme()
+    {
+        return none();
+    }
+
+    public static MutationId none()
+    {
+        return NONE;
+    }
+
+    public boolean isNone()
+    {
+        if (this == NONE)
+            return true;
+        return logId() == NONE_LOG_ID && offset() == NONE_OFFSET && timestamp() == NONE_TIMESTAMP;
+    }
+
+    @Override
+    public String toString()
+    {
+        return "MutationId{" + hostId() + ", " + hostLogId() + ", " + offset() + ", " + timestamp() + '}';
+    }
+
+    /**
+     * The comparator is intentionally not overridden by this class, since log id and offset alone
+     * are meant to uniquely identify a mutation, and only offset determines the order within a log.
+     */
+    public static final Comparator<MutationId> comparator = ShortMutationId.comparator::compare;
+
+    public static final IVersionedSerializer<MutationId> serializer = new IVersionedSerializer<>()
+    {
+        @Override
+        public void serialize(MutationId id, DataOutputPlus out, int version) throws IOException
+        {
+            out.writeLong(id.logId());
+            out.writeLong(id.sequenceId());
+        }
+
+        @Override
+        public MutationId deserialize(DataInputPlus in, int version) throws IOException
+        {
+            long logId = in.readLong();
+            long sequenceId = in.readLong();
+            if (logId == NONE_LOG_ID && sequenceId == NONE_SEQUENCE_ID)
+                return none();
+            return new MutationId(logId, sequenceId);
+        }
+
+        @Override
+        public long serializedSize(MutationId id, int version)
+        {
+            return TypeSizes.sizeof(id.logId()) + TypeSizes.sizeof(id.sequenceId());
+        }
+    };
+}
diff --git a/src/java/org/apache/cassandra/service/tracking/MutationJournal.java b/src/java/org/apache/cassandra/replication/MutationJournal.java
similarity index 57%
rename from src/java/org/apache/cassandra/service/tracking/MutationJournal.java
rename to src/java/org/apache/cassandra/replication/MutationJournal.java
index 3f00e4387c..59f87d5389 100644
--- a/src/java/org/apache/cassandra/service/tracking/MutationJournal.java
+++ b/src/java/org/apache/cassandra/replication/MutationJournal.java
@@ -15,38 +15,33 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.apache.cassandra.service.tracking;
-
-import java.io.IOException;
-import java.nio.ByteBuffer;
-import java.util.Collection;
-import java.util.concurrent.TimeUnit;
-import java.util.zip.Checksum;
-
-import javax.annotation.Nullable;
+package org.apache.cassandra.replication;
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Preconditions;
-
+import org.apache.cassandra.config.Config;
 import org.apache.cassandra.config.DatabaseDescriptor;
 import org.apache.cassandra.db.Mutation;
 import org.apache.cassandra.db.TypeSizes;
 import org.apache.cassandra.io.util.DataInputPlus;
 import org.apache.cassandra.io.util.DataOutputPlus;
 import org.apache.cassandra.io.util.File;
-import org.apache.cassandra.journal.Journal;
-import org.apache.cassandra.journal.KeySupport;
-import org.apache.cassandra.journal.Params;
-import org.apache.cassandra.journal.RecordConsumer;
-import org.apache.cassandra.journal.RecordPointer;
-import org.apache.cassandra.journal.SegmentCompactor;
-import org.apache.cassandra.journal.ValueSerializer;
+import org.apache.cassandra.journal.*;
 import org.apache.cassandra.net.MessagingService;
 import org.apache.cassandra.utils.FBUtilities;
 
+import javax.annotation.Nullable;
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.util.Collection;
+import java.util.concurrent.TimeUnit;
+import java.util.zip.Checksum;
+
 public class MutationJournal
 {
-    private final Journal<MutationId, Mutation> journal;
+    public static final MutationJournal instance = new MutationJournal();
+
+    private final Journal<ShortMutationId, Mutation> journal;
 
     private MutationJournal()
     {
@@ -69,25 +64,25 @@ public class MutationJournal
         journal.shutdown();
     }
 
-    public RecordPointer write(MutationId id, Mutation mutation)
+    public RecordPointer write(ShortMutationId id, Mutation mutation)
     {
         return journal.blockingWrite(id, mutation);
     }
 
     @Nullable
-    public Mutation read(MutationId id)
+    public Mutation read(ShortMutationId id)
     {
         return journal.readLast(id);
     }
 
-    public boolean read(MutationId id, RecordConsumer<MutationId> consumer)
+    public boolean read(ShortMutationId id, RecordConsumer<ShortMutationId> consumer)
     {
         return journal.readLast(id, consumer);
     }
 
-    public void readAll(Iterable<MutationId> ids, Collection<Mutation> into)
+    public void readAll(Iterable<ShortMutationId> ids, Collection<Mutation> into)
     {
-        for (MutationId id : ids)
+        for (ShortMutationId id : ids)
         {
             Mutation mutation = read(id);
             Preconditions.checkState(mutation != null);
@@ -112,7 +107,18 @@ public class MutationJournal
         @Override
         public FlushMode flushMode()
         {
-            return FlushMode.PERIODIC;
+            Config.CommitLogSync mode = DatabaseDescriptor.getCommitLogSync();
+            switch (DatabaseDescriptor.getCommitLogSync())
+            {
+                case batch:
+                    return FlushMode.BATCH;
+                case periodic:
+                    return FlushMode.PERIODIC;
+                case group:
+                    return FlushMode.GROUP;
+                default:
+                    throw new IllegalStateException("Unhandled flush mode: " + mode);
+            }
         }
 
         @Override
@@ -146,88 +152,88 @@ public class MutationJournal
         }
     }
 
-    static class MutationIdSupport implements KeySupport<MutationId>
+    static class MutationIdSupport implements KeySupport<ShortMutationId>
     {
         static final int LOG_ID_OFFSET = 0;
-        static final int SEQUENCE_ID_OFFSET = LOG_ID_OFFSET + TypeSizes.LONG_SIZE;
+        static final int OFFSET_OFFSET = LOG_ID_OFFSET + TypeSizes.LONG_SIZE;
 
         @Override
         public int serializedSize(int userVersion)
         {
             return TypeSizes.LONG_SIZE  // logId
-                 + TypeSizes.LONG_SIZE; // sequenceId
+                 + TypeSizes.INT_SIZE; // offset
         }
 
         @Override
-        public void serialize(MutationId id, DataOutputPlus out, int userVersion) throws IOException
+        public void serialize(ShortMutationId id, DataOutputPlus out, int userVersion) throws IOException
         {
-            out.writeLong(id.logId);
-            out.writeLong(id.sequenceId);
+            out.writeLong(id.logId());
+            out.writeInt(id.offset());
         }
 
         @Override
-        public void serialize(MutationId id, ByteBuffer out, int userVersion) throws IOException
+        public void serialize(ShortMutationId id, ByteBuffer out, int userVersion) throws IOException
         {
-            out.putLong(id.logId);
-            out.putLong(id.sequenceId);
+            out.putLong(id.logId());
+            out.putInt(id.offset());
         }
 
         @Override
-        public MutationId deserialize(DataInputPlus in, int userVersion) throws IOException
+        public ShortMutationId deserialize(DataInputPlus in, int userVersion) throws IOException
         {
             long logId = in.readLong();
-            long sequenceId = in.readLong();
-            return new MutationId(logId, sequenceId);
+            int offset = in.readInt();
+            return new ShortMutationId(logId, offset);
         }
 
         @Override
-        public MutationId deserialize(ByteBuffer buffer, int position, int userVersion)
+        public ShortMutationId deserialize(ByteBuffer buffer, int position, int userVersion)
         {
             long logId = buffer.getLong(position + LOG_ID_OFFSET);
-            long sequenceId = buffer.getLong(position + SEQUENCE_ID_OFFSET);
-            return new MutationId(logId, sequenceId);
+            int offset = buffer.getInt(position + OFFSET_OFFSET);
+            return new ShortMutationId(logId, offset);
         }
 
         @Override
-        public MutationId deserialize(ByteBuffer buffer, int userVersion)
+        public ShortMutationId deserialize(ByteBuffer buffer, int userVersion)
         {
             long logId = buffer.getLong();
-            long sequenceId = buffer.getLong();
-            return new MutationId(logId, sequenceId);
+            int offset = buffer.getInt();
+            return new ShortMutationId(logId, offset);
         }
 
         @Override
-        public void updateChecksum(Checksum crc, MutationId id, int userVersion)
+        public void updateChecksum(Checksum crc, ShortMutationId id, int userVersion)
         {
-            FBUtilities.updateChecksumLong(crc, id.logId);
-            FBUtilities.updateChecksumLong(crc, id.sequenceId);
+            FBUtilities.updateChecksumLong(crc, id.logId());
+            FBUtilities.updateChecksumInt(crc, id.offset());
         }
 
         @Override
-        public int compareWithKeyAt(MutationId id, ByteBuffer buffer, int position, int userVersion)
+        public int compareWithKeyAt(ShortMutationId id, ByteBuffer buffer, int position, int userVersion)
         {
-            int cmp = Long.compare(id.logId, buffer.getLong(position + LOG_ID_OFFSET));
-            return cmp != 0 ? cmp : Long.compare(id.sequenceId, buffer.getLong(position + SEQUENCE_ID_OFFSET));
+            int cmp = Long.compare(id.logId(), buffer.getLong(position + LOG_ID_OFFSET));
+            return cmp != 0 ? cmp : Integer.compare(id.offset(), buffer.getInt(position + OFFSET_OFFSET));
         }
 
         @Override
-        public int compare(MutationId id1, MutationId id2)
+        public int compare(ShortMutationId id1, ShortMutationId id2)
         {
-            int cmp = Long.compare(id1.logId, id2.logId);
-            return cmp != 0 ? cmp : Long.compare(id1.sequenceId, id2.sequenceId);
+            int cmp = Long.compare(id1.logId(), id2.logId());
+            return cmp != 0 ? cmp : Integer.compare(id1.offset(), id2.offset());
         }
     }
 
-    static class MutationSerializer implements ValueSerializer<MutationId, Mutation>
+    static class MutationSerializer implements ValueSerializer<ShortMutationId, Mutation>
     {
         @Override
-        public void serialize(MutationId id, Mutation mutation, DataOutputPlus out, int userVersion) throws IOException
+        public void serialize(ShortMutationId id, Mutation mutation, DataOutputPlus out, int userVersion) throws IOException
         {
             Mutation.serializer.serialize(mutation, out, userVersion);
         }
 
         @Override
-        public Mutation deserialize(MutationId id, DataInputPlus in, int userVersion) throws IOException
+        public Mutation deserialize(ShortMutationId id, DataInputPlus in, int userVersion) throws IOException
         {
             return Mutation.serializer.deserialize(in, userVersion);
         }
diff --git a/src/java/org/apache/cassandra/replication/MutationSummary.java b/src/java/org/apache/cassandra/replication/MutationSummary.java
new file mode 100644
index 0000000000..3483f710ec
--- /dev/null
+++ b/src/java/org/apache/cassandra/replication/MutationSummary.java
@@ -0,0 +1,351 @@
+/*
+ * 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.replication;
+
+import java.io.IOException;
+import java.util.*;
+
+import com.google.common.base.Preconditions;
+import org.agrona.collections.Long2ObjectHashMap;
+import org.apache.cassandra.db.Digest;
+import org.apache.cassandra.db.TypeSizes;
+import org.apache.cassandra.io.IVersionedSerializer;
+import org.apache.cassandra.io.util.DataInputPlus;
+import org.apache.cassandra.io.util.DataOutputPlus;
+import org.apache.cassandra.schema.TableId;
+
+public class MutationSummary
+{
+    public static class CoordinatorSummary
+    {
+        private static final Comparator<CoordinatorSummary> idComparator =
+            (l, r) -> CoordinatorLogId.comparator.compare(l.logId(), r.logId());
+
+        public final Offsets.Immutable reconciled;
+        public final Offsets.Immutable unreconciled;
+
+        public CoordinatorSummary(Offsets.Immutable reconciled, Offsets.Immutable unreconciled)
+        {
+            Preconditions.checkArgument(reconciled.logId().equals(unreconciled.logId()));
+            this.reconciled = reconciled;
+            this.unreconciled = unreconciled;
+        }
+
+        @Override
+        public boolean equals(Object o)
+        {
+            if (o == null || getClass() != o.getClass()) return false;
+            CoordinatorSummary summary = (CoordinatorSummary) o;
+            return reconciled.equals(summary.reconciled) && unreconciled.equals(summary.unreconciled);
+        }
+
+        @Override
+        public int hashCode()
+        {
+            return Objects.hash(reconciled, unreconciled);
+        }
+
+        @Override
+        public String toString()
+        {
+            return "CoordinatorSummary{" +
+                    "logId=" + logId() +
+                    ", reconciled=" + reconciled +
+                    ", unreconciled=" + unreconciled +
+                    '}';
+        }
+
+        public CoordinatorLogId logId()
+        {
+            return reconciled.logId();
+        }
+
+        boolean contains(int offset)
+        {
+            return reconciled.contains(offset) || unreconciled.contains(offset);
+        }
+
+        /**
+         * Finds all elements that are contained by {@code left} and not contained by {@code right}
+         */
+        static void difference(CoordinatorSummary left, CoordinatorSummary right, Collection<ShortMutationId> into)
+        {
+            Offsets.RangeIterator leftIds = Offsets.union(left.reconciled.rangeIterator(), left.unreconciled.rangeIterator());
+            Offsets.RangeIterator rightIds = Offsets.union(right.reconciled.rangeIterator(), right.unreconciled.rangeIterator());
+            Offsets.RangeIterator missing = Offsets.difference(leftIds, rightIds);
+            Offsets.forEachOffset(missing, (logId, offset) -> into.add(new ShortMutationId(logId, offset)));
+        }
+
+        void digest(Digest digest)
+        {
+            reconciled.digest(digest);
+            unreconciled.digest(digest);
+        }
+
+        public static class Builder
+        {
+            public final CoordinatorLogId logId;
+            public final Offsets.Immutable.Builder reconciled;
+            public final Offsets.Immutable.Builder unreconciled;
+
+            public Builder(CoordinatorLogId logId)
+            {
+                this.logId = logId;
+                reconciled = new Offsets.Immutable.Builder(logId);
+                unreconciled = new Offsets.Immutable.Builder(logId);
+            }
+
+            boolean isEmpty()
+            {
+                return reconciled.isEmpty() && unreconciled.isEmpty();
+            }
+
+            public CoordinatorSummary build()
+            {
+                return new CoordinatorSummary(reconciled.build(), unreconciled.build());
+            }
+        }
+
+        public static final IVersionedSerializer<CoordinatorSummary> serializer = new IVersionedSerializer<>()
+        {
+            @Override
+            public void serialize(CoordinatorSummary t, DataOutputPlus out, int version) throws IOException
+            {
+                Offsets.serializer.serialize(t.reconciled, out, version);
+                Offsets.serializer.serialize(t.unreconciled, out, version);
+            }
+
+            @Override
+            public CoordinatorSummary deserialize(DataInputPlus in, int version) throws IOException
+            {
+                return new CoordinatorSummary(Offsets.serializer.deserialize(in, version),
+                                              Offsets.serializer.deserialize(in, version));
+            }
+
+            @Override
+            public long serializedSize(CoordinatorSummary t, int version)
+            {
+                return Offsets.serializer.serializedSize(t.reconciled, version)
+                     + Offsets.serializer.serializedSize(t.unreconciled, version);
+            }
+        };
+    }
+
+    public static class Builder
+    {
+        public final TableId tableId;
+        private final Long2ObjectHashMap<CoordinatorSummary.Builder> builders = new Long2ObjectHashMap<>();
+
+        public Builder(TableId tableId)
+        {
+            this.tableId = tableId;
+        }
+
+        public CoordinatorSummary.Builder builderForLog(CoordinatorLogId logId)
+        {
+            CoordinatorSummary.Builder builder = builders.get(logId.asLong());
+            if (builder == null)
+            {
+                builder = new CoordinatorSummary.Builder(logId);
+                builders.put(logId.asLong(), builder);
+            }
+
+            return builder;
+        }
+
+        public MutationSummary build()
+        {
+            List<CoordinatorSummary> summaries = new ArrayList<>(builders.size());
+            for (CoordinatorSummary.Builder builder : builders.values())
+                if (!builder.isEmpty())
+                    summaries.add(builder.build());
+
+            summaries.sort(CoordinatorSummary.idComparator);
+            return new MutationSummary(tableId, summaries);
+        }
+    }
+
+    private final TableId tableId;
+    private final List<CoordinatorSummary> summaries;
+    private transient final Long2ObjectHashMap<CoordinatorSummary> coordinatorSummaryMap = new Long2ObjectHashMap<>();
+
+    private MutationSummary(TableId tableId, List<CoordinatorSummary> summaries)
+    {
+        long lastId = 0;
+        for (int i=0, mi=summaries.size(); i<mi; i++)
+        {
+            CoordinatorSummary summary = summaries.get(i);
+            long thisId = summary.logId().asLong();
+            if (i > 0 && thisId <= lastId)
+                throw new IllegalArgumentException("duplicated or unsorted log id found");
+
+            coordinatorSummaryMap.put(thisId, summary);
+            lastId = thisId;
+        }
+
+        this.tableId = tableId;
+        this.summaries = summaries;
+    }
+
+    @Override
+    public boolean equals(Object o)
+    {
+        if (o == null || getClass() != o.getClass()) return false;
+        MutationSummary summary = (MutationSummary) o;
+        return tableId.equals(summary.tableId) && summaries.equals(summary.summaries);
+    }
+
+    @Override
+    public int hashCode()
+    {
+        return tableId.hashCode() + 31 * summaries.hashCode();
+    }
+
+    @Override
+    public String toString()
+    {
+        return "MutationSummary{tableId=" + tableId + ", summaries=" + summaries + '}';
+    }
+
+    public TableId tableId()
+    {
+        return tableId;
+    }
+
+    public byte[] digest()
+    {
+        Digest digest = Digest.forReadResponse();
+        digest.updateWithLong(tableId.asUUID().getMostSignificantBits());
+        digest.updateWithLong(tableId.asUUID().getLeastSignificantBits());
+        digest.updateWithInt(summaries.size());
+
+        for (CoordinatorSummary summary : summaries)
+            summary.digest(digest);
+
+        return digest.digest();
+    }
+
+    public boolean contains(ShortMutationId id)
+    {
+        CoordinatorSummary summary = coordinatorSummaryMap.get(id.logId());
+        return summary != null && summary.contains(id.offset());
+    }
+
+    public int unreconciledIds()
+    {
+        int count = 0;
+        for (CoordinatorSummary summary : summaries)
+            count += summary.unreconciled.offsetCount();
+        return count;
+    }
+
+    public int size()
+    {
+        return summaries.size();
+    }
+
+    boolean isEmpty()
+    {
+        return size() == 0;
+    }
+
+    public CoordinatorSummary get(int i)
+    {
+        return summaries.get(i);
+    }
+
+    public CoordinatorSummary get(CoordinatorLogId logId)
+    {
+        return coordinatorSummaryMap.get(logId.asLong());
+    }
+
+    /**
+     * Finds all elements that are contained by {@code left} and not contained by {@code right}
+     */
+    public static void difference(MutationSummary left, MutationSummary right, Collection<ShortMutationId> into)
+    {
+        int i = 0, j = 0, lsize = left.size(), rsize = right.size();
+
+        while (i < lsize && j < rsize)
+        {
+            CoordinatorSummary l = left.get(i);
+            CoordinatorSummary r = right.get(j);
+
+            int cmp = CoordinatorSummary.idComparator.compare(l, r);
+
+            if (cmp == 0)
+            {
+                CoordinatorSummary.difference(l, r, into);
+                ++i;
+                ++j;
+            }
+            else if (cmp < 0)
+            {
+                l.reconciled.collectIds(into);
+                l.unreconciled.collectIds(into);
+                ++i;
+            }
+            else
+            {
+                ++j;
+            }
+        }
+
+        while (i < lsize)
+        {
+            CoordinatorSummary l = left.get(i);
+            l.reconciled.collectIds(into);
+            l.unreconciled.collectIds(into);
+            ++i;
+        }
+    }
+
+    public static final IVersionedSerializer<MutationSummary> serializer = new IVersionedSerializer<>()
+    {
+        @Override
+        public void serialize(MutationSummary summary, DataOutputPlus out, int version) throws IOException
+        {
+            summary.tableId.serialize(out);
+            out.writeInt(summary.summaries.size());
+            for (int i=0,mi=summary.summaries.size(); i<mi; i++)
+                CoordinatorSummary.serializer.serialize(summary.summaries.get(i), out, version);
+        }
+
+        @Override
+        public MutationSummary deserialize(DataInputPlus in, int version) throws IOException
+        {
+            TableId tableId = TableId.deserialize(in);
+            int size = in.readInt();
+            List<CoordinatorSummary> summaries = new ArrayList<>(size);
+            for (int i = 0; i < size; i++)
+                summaries.add(CoordinatorSummary.serializer.deserialize(in, version));
+
+            return new MutationSummary(tableId, summaries);
+        }
+
+        @Override
+        public long serializedSize(MutationSummary summary, int version)
+        {
+            long size = summary.tableId.serializedSize();
+            size += TypeSizes.sizeof(summary.summaries.size());
+            for (int i=0,mi=summary.summaries.size(); i<mi; i++)
+                size += CoordinatorSummary.serializer.serializedSize(summary.summaries.get(i), version);
+            return size;
+        }
+    };
+}
diff --git a/src/java/org/apache/cassandra/replication/MutationTrackingService.java b/src/java/org/apache/cassandra/replication/MutationTrackingService.java
new file mode 100644
index 0000000000..0c48c3e498
--- /dev/null
+++ b/src/java/org/apache/cassandra/replication/MutationTrackingService.java
@@ -0,0 +1,239 @@
+/*
+ * 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.replication;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.Consumer;
+import java.util.function.IntSupplier;
+
+import org.agrona.collections.IntArrayList;
+import org.apache.cassandra.db.DecoratedKey;
+import org.apache.cassandra.db.Mutation;
+import org.apache.cassandra.db.PartitionPosition;
+import org.apache.cassandra.dht.AbstractBounds;
+import org.apache.cassandra.dht.Range;
+import org.apache.cassandra.dht.Token;
+import org.apache.cassandra.locator.InetAddressAndPort;
+import org.apache.cassandra.schema.KeyspaceMetadata;
+import org.apache.cassandra.schema.Schema;
+import org.apache.cassandra.schema.TableId;
+import org.apache.cassandra.service.reads.tracked.TrackedLocalReads;
+import org.apache.cassandra.tcm.ClusterMetadata;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+// TODO (expected): persistence (handle restarts)
+// TODO (expected): handle topology changes
+public class MutationTrackingService
+{
+    private static final Logger logger = LoggerFactory.getLogger(MutationTrackingService.class);
+    public static final MutationTrackingService instance = new MutationTrackingService();
+
+    private final TrackedLocalReads localReads = new TrackedLocalReads();
+    private final ConcurrentHashMap<String, KeyspaceShards> shards = new ConcurrentHashMap<>();
+
+    private volatile boolean started = false;
+
+    private MutationTrackingService() {}
+
+    // TODO (expected): implement a TCM ChangeListener
+    public synchronized void start(ClusterMetadata metadata)
+    {
+        if (started)
+            return;
+
+        logger.info("Starting replication tracking service");
+
+        for (KeyspaceMetadata keyspace : metadata.schema.getKeyspaces())
+            if (keyspace.useMutationTracking())
+                shards.put(keyspace.name, KeyspaceShards.make(keyspace, metadata, this::nextHostLogId));
+        started = true;
+    }
+
+    public synchronized boolean isStarted()
+    {
+        return started;
+    }
+
+    public void shutdownBlocking() throws InterruptedException
+    {
+        localReads.shutdownBlocking();
+    }
+
+    public TrackedLocalReads localReads()
+    {
+        return localReads;
+    }
+
+    public MutationId nextMutationId(String keyspace, Token token)
+    {
+        MutationId id = getOrCreate(keyspace).nextMutationId(token);
+        logger.trace("Created new mutation id {}", id);
+        return id;
+    }
+
+    public void witnessedRemoteMutation(String keyspace, Token token, MutationId mutationId, InetAddressAndPort onHost)
+    {
+        getOrCreate(keyspace).witnessedRemoteMutation(token, mutationId, onHost);
+    }
+
+    public void startWriting(Mutation mutation)
+    {
+        getOrCreate(mutation.getKeyspaceName()).startWriting(mutation);
+    }
+
+    public void finishWriting(Mutation mutation)
+    {
+        getOrCreate(mutation.getKeyspaceName()).finishWriting(mutation);
+    }
+
+    public MutationSummary createSummaryForKey(DecoratedKey key, TableId tableId, boolean includePending)
+    {
+        return getOrCreate(tableId).createSummaryForKey(key, tableId, includePending);
+    }
+
+    public MutationSummary createSummaryForRange(AbstractBounds<PartitionPosition> range, TableId tableId, boolean includePending)
+    {
+        return getOrCreate(tableId).createSummaryForRange(range, tableId, includePending);
+    }
+
+    public MutationSummary createSummaryForRange(Range<Token> range, TableId tableId, boolean includePending)
+    {
+        return createSummaryForRange(Range.makeRowRange(range), tableId, includePending);
+    }
+
+    private KeyspaceShards getOrCreate(TableId tableId)
+    {
+        //noinspection DataFlowIssue
+        return getOrCreate(Schema.instance.getTableMetadata(tableId).keyspace);
+    }
+
+    private KeyspaceShards getOrCreate(String keyspace)
+    {
+        KeyspaceShards ks = shards.get(keyspace);
+        if (ks != null)
+            return ks;
+
+        ClusterMetadata csm = ClusterMetadata.current();
+        KeyspaceMetadata ksm = csm.schema.getKeyspaceMetadata(keyspace);
+        return shards.computeIfAbsent(keyspace, ignore -> KeyspaceShards.make(ksm, csm, this::nextHostLogId));
+    }
+
+    // TODO (expected): durability
+    int nextHostLogId()
+    {
+        return nextHostLogId.incrementAndGet();
+    }
+    private final AtomicInteger nextHostLogId = new AtomicInteger();
+
+    private static class KeyspaceShards
+    {
+        private final String keyspace;
+        private final Map<Range<Token>, Shard> shards;
+
+        private transient final Map<Range<PartitionPosition>, Shard> ppShards;
+
+        static KeyspaceShards make(KeyspaceMetadata keyspace, ClusterMetadata cluster, IntSupplier logIdProvider)
+        {
+            Map<Range<Token>, Shard> shards = new HashMap<>();
+            cluster.placements.get(keyspace.params.replication).writes.forEach((tokenRange, forRange) -> {
+               IntArrayList participants = new IntArrayList(forRange.size(), IntArrayList.DEFAULT_NULL_VALUE);
+               for (InetAddressAndPort endpoint : forRange.endpoints())
+                   participants.add(cluster.directory.peerId(endpoint).id());
+               Shard shard = new Shard(keyspace.name, tokenRange, cluster.myNodeId().id(), new Participants(participants), forRange.lastModified(), logIdProvider);
+               shards.put(tokenRange, shard);
+            });
+            return new KeyspaceShards(keyspace.name, shards);
+        }
+
+        KeyspaceShards(String keyspace, Map<Range<Token>, Shard> shards)
+        {
+            this.keyspace = keyspace;
+            this.shards = shards;
+
+            this.ppShards = new HashMap<>();
+            shards.forEach((range, shard) -> ppShards.put(Range.makeRowRange(range), shard));
+        }
+
+        MutationId nextMutationId(Token token)
+        {
+            return lookUp(token).nextId();
+        }
+
+        void witnessedRemoteMutation(Token token, MutationId mutationId, InetAddressAndPort onHost)
+        {
+            lookUp(token).witnessedRemoteMutation(mutationId, onHost);
+        }
+
+        void startWriting(Mutation mutation)
+        {
+            lookUp(mutation).startWriting(mutation);
+        }
+
+        void finishWriting(Mutation mutation)
+        {
+            lookUp(mutation).finishWriting(mutation);
+        }
+
+        MutationSummary createSummaryForKey(DecoratedKey key, TableId tableId, boolean includePending)
+        {
+            MutationSummary.Builder builder = new MutationSummary.Builder(tableId);
+            lookUp(key.getToken()).addSummaryForKey(key.getToken(), includePending, builder);
+            return builder.build();
+        }
+
+        MutationSummary createSummaryForRange(AbstractBounds<PartitionPosition> range, TableId tableId, boolean includePending)
+        {
+            MutationSummary.Builder builder = new MutationSummary.Builder(tableId);
+            forEachIntersectingShard(range, shard -> shard.addSummaryForRange(range, includePending, builder));
+            return builder.build();
+        }
+
+        private void forEachIntersectingShard(AbstractBounds<PartitionPosition> bounds, Consumer<Shard> consumer)
+        {
+            ppShards.forEach((range, shard) -> {
+                // TODO (expected): partial workaround - is there a better way to do this?
+                //  SELECT * statements create Bounds[min,min], (PartitionKeyRestrictions.java:L174) not Range(min,min],
+                //  which Ranges generally won't intersect with (Range.java:L148), so contains is used here to make it work
+                if (bounds.contains(range.right) || range.intersects(bounds))
+                    consumer.accept(shard);
+            });
+        }
+
+        Shard lookUp(Mutation mutation)
+        {
+            return lookUp(mutation.key());
+        }
+
+        Shard lookUp(DecoratedKey key)
+        {
+            return lookUp(key.getToken());
+        }
+
+        Shard lookUp(Token token)
+        {
+            ClusterMetadata csm = ClusterMetadata.current();
+            KeyspaceMetadata ksm = csm.schema.getKeyspaceMetadata(keyspace);
+            Range<Token> range = ClusterMetadata.current().placements.get(ksm.params.replication).writes.forRange(token).range();
+            return shards.get(range);
+        }
+    }
+}
diff --git a/src/java/org/apache/cassandra/replication/Offsets.java b/src/java/org/apache/cassandra/replication/Offsets.java
new file mode 100644
index 0000000000..df1512617a
--- /dev/null
+++ b/src/java/org/apache/cassandra/replication/Offsets.java
@@ -0,0 +1,1496 @@
+/*
+ * 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.replication;
+
+import com.google.common.base.Function;
+import com.google.common.base.Objects;
+import com.google.common.base.Preconditions;
+
+import org.apache.cassandra.db.Digest;
+import org.apache.cassandra.db.TypeSizes;
+import org.apache.cassandra.io.IVersionedSerializer;
+import org.apache.cassandra.io.util.DataInputPlus;
+import org.apache.cassandra.io.util.DataOutputPlus;
+import org.apache.cassandra.utils.AbstractIterator;
+
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Iterator;
+
+public abstract class Offsets implements Iterable<ShortMutationId>
+{
+    private static final int INITIAL_CAPACITY = 16;
+
+    protected final CoordinatorLogId logId;
+    // even index is range start, odd index is range end (inclusive)
+    protected int[] bounds;
+    protected int size;
+
+    protected Offsets(CoordinatorLogId logId, int[] bounds)
+    {
+        this(logId, bounds, bounds.length);
+    }
+
+    protected Offsets(CoordinatorLogId logId, int[] bounds, int size)
+    {
+        this.logId = logId;
+        this.bounds = bounds;
+        this.size = size;
+    }
+
+    public static boolean contentsEqual(Offsets a, Offsets b)
+    {
+        if (a == null || b == null)
+            return a == null && b == null;
+        if (a.size != b.size)
+            return false;
+        if (!a.logId.equals(b.logId))
+            return false;
+        return Arrays.equals(a.bounds, 0, a.size, b.bounds, 0, b.size);
+    }
+
+    @Override
+    public boolean equals(Object o)
+    {
+        if (o == null || getClass() != o.getClass()) return false;
+        Offsets that = (Offsets) o;
+        return size == that.size && Objects.equal(logId, that.logId) && Arrays.equals(bounds, 0, size, that.bounds, 0, size);
+    }
+
+    @Override
+    public int hashCode()
+    {
+        int result = logId.hashCode();
+        result = 31 * result + Integer.hashCode(size);
+        for (int i = 0; i < size; i++)
+            result = 31 * result + Integer.hashCode(bounds[i]);
+        return result;
+    }
+
+    @Override
+    public Iterator<ShortMutationId> iterator()
+    {
+        return new AbstractIterator<>()
+        {
+            int range = -1;
+            int lastOffset = Integer.MAX_VALUE;
+
+            @Override
+            protected ShortMutationId computeNext()
+            {
+                if (range < 0 || lastOffset >= bounds[rangeEnd(range)])
+                {
+                    range++;
+                    if (range >= rangeCount())
+                        return endOfData();
+
+                    lastOffset = bounds[rangeStart(range)];
+                }
+                else
+                {
+                    lastOffset++;
+                }
+
+                return new ShortMutationId(logId, lastOffset);
+            }
+        };
+    }
+
+    public CoordinatorLogId logId()
+    {
+        return logId;
+    }
+
+    public int rangeCount()
+    {
+        return size / 2;
+    }
+
+    public int offsetCount()
+    {
+        int count = 0, i = 0;
+        while (i < size)
+        {
+            int start = bounds[i++];
+            int   end = bounds[i++];
+            count += end - start + 1;
+        }
+        return count;
+    }
+
+    public boolean isEmpty()
+    {
+        return size == 0;
+    }
+
+    private static void forEachOffsetInRange(CoordinatorLogId logId, int start, int end, OffsetConsumer consumer)
+    {
+        for (int offset = start; offset <= end; offset++)
+            consumer.accept(logId, offset);
+    }
+
+    public static void forEachOffset(RangeIterator iter, OffsetConsumer consumer)
+    {
+        while (iter.tryAdvance())
+            forEachOffsetInRange(iter.logId(), iter.start(), iter.end(), consumer);
+    }
+
+    public void collectIds(Collection<ShortMutationId> into)
+    {
+        for (int i = 0; i < size; i += 2)
+        {
+            int start = bounds[i];
+            int   end = bounds[i + 1];
+            for (int offset = start; offset <= end; offset++)
+                into.add(new ShortMutationId(logId, offset));
+        }
+    }
+
+    @Override
+    public String toString()
+    {
+        StringBuilder builder = new StringBuilder("{");
+        int i = 0;
+        while (i < size)
+        {
+            int start = bounds[i++];
+            int   end = bounds[i++];
+            builder.append('[').append(start).append(',').append(end).append(']');
+            if (i < size) builder.append(',');
+        }
+        return builder.append('}').toString();
+    }
+
+    public boolean contains(int offset)
+    {
+        if (size == 0)
+            return false;
+
+        int pos = Arrays.binarySearch(bounds, 0, size, offset);
+        if (pos >= 0) return true; // matches one of the bounds
+
+        pos = -pos - 1;
+        return (pos - 1) % 2 == 0; // offset falls within bounds of an existing range if the bound to the left is an open one
+    }
+
+    public void digest(Digest digest)
+    {
+        digest.updateWithLong(logId.asLong());
+        digest.updateWithInt(size);
+        for (int i = 0; i < size; i++)
+            digest.updateWithInt(bounds[i]);
+    }
+
+    static abstract class AbstractMutable<T extends AbstractMutable<T>> extends Offsets implements OffsetReciever
+    {
+        AbstractMutable(RangeIterator rangeIterator)
+        {
+            this(rangeIterator.logId());
+            while (rangeIterator.tryAdvance())
+                append(rangeIterator.start(), rangeIterator.end());
+        }
+
+        AbstractMutable(CoordinatorLogId logId)
+        {
+            this(logId, INITIAL_CAPACITY);
+        }
+
+        AbstractMutable(CoordinatorLogId logId, int capacity)
+        {
+            super(logId, new int[capacity], 0);
+        }
+        AbstractMutable(CoordinatorLogId logId, int[] bounds)
+        {
+            this(logId, bounds, bounds.length);
+        }
+
+        AbstractMutable(CoordinatorLogId logId, int[] bounds, int size)
+        {
+            super(logId, bounds, size);
+        }
+
+        protected static <T extends AbstractMutable<T>> T createOrNull(Offsets.RangeIterator rangeIterator, Function<CoordinatorLogId, T> ctor)
+        {
+            T result = null;
+
+            while (rangeIterator.tryAdvance())
+            {
+                if (result == null)
+                    result = ctor.apply(rangeIterator.logId());
+                result.append(rangeIterator.start(), rangeIterator.end());
+            }
+
+            return result;
+        }
+
+        public void addAll(Offsets other, RangeConsumer onAdded)
+        {
+            for (int i = 0; i < other.size; i += 2)
+                add(other.bounds[i], other.bounds[i + 1], onAdded);
+        }
+
+        public void addAll(Offsets other)
+        {
+            addAll(other, RangeConsumer.NONE);
+        }
+
+        public boolean add(int offset, RangeConsumer onAdded)
+        {
+            boolean added = add(offset);
+            if (added) onAdded.consume(logId, offset, offset);
+            return added;
+        }
+
+        public boolean add(int offset)
+        {
+            if (size == 0)
+            {
+                append(offset, offset);
+                return true;
+            }
+
+            int pos = Arrays.binarySearch(bounds, 0, size, offset);
+            if (pos >= 0) return false; // matches one of the bounds
+
+            pos = -pos - 1;
+            if (pos == size) // after all existing ranges
+            {
+                if (bounds[size - 1] == offset - 1)
+                    bounds[size - 1] = offset; // extend the last range
+                else
+                    append(offset, offset); // append a new single-offset range
+
+                return true;
+            }
+            else if (pos == 0) // before all existing ranges
+            {
+                if (bounds[0] == offset + 1)
+                    bounds[0] = offset; // extend the first range
+                else
+                    insert(0, offset, offset); // prepend a new single-offset range
+
+                return true;
+            }
+            else if ((pos - 1) % 2 == 0) // offset falls within bounds of an existing range (bound to the left is an open bound)
+            {
+                return false;
+            }
+
+            // between two existing ranges
+            boolean extendsPrev = bounds[pos - 1] == offset - 1;
+            boolean extendsNext = bounds[pos] == offset + 1;
+
+            if (extendsPrev && extendsNext) // closes the gap between two adjacent ranges
+            {
+                bounds[pos - 1] = bounds[pos + 1];
+                System.arraycopy(bounds, pos + 2, bounds, pos, size - pos - 2);
+                bounds[--size] = 0;
+                bounds[--size] = 0;
+            }
+            else if (extendsPrev)
+            {
+                bounds[pos - 1] = offset;
+            }
+            else if (extendsNext)
+            {
+                bounds[pos] = offset;
+            }
+            else
+            {
+                insert(pos, offset, offset);
+            }
+
+            return true;
+        }
+
+        private enum AddAction
+        {
+            INSERT, MOVE, INCLUDE;
+
+            boolean isMove()
+            {
+                return this == MOVE;
+            }
+
+            boolean isInclude()
+            {
+                return this == INCLUDE;
+            }
+
+            boolean isInsert()
+            {
+                return this == INSERT;
+            }
+
+            boolean isMoveOrInclude()
+            {
+                return this == MOVE || this == INCLUDE;
+            }
+        }
+
+        public boolean add(final int start, final int end, RangeConsumer onAdded)
+        {
+            Preconditions.checkArgument(start <= end);
+
+            if (size == 0)
+            {
+                append(start, end);
+                return true;
+            }
+
+            if (start == end)
+                return add(start, onAdded);
+
+            int spos = Arrays.binarySearch(bounds, 0, size, start);
+            int epos = Arrays.binarySearch(bounds, 0, size, end);
+
+            if (spos >= 0 && spos % 2 == 0 && epos == spos + 1) return false; // matches an existing bound
+
+            if (spos < 0) spos = -spos - 1;
+            if (epos < 0) epos = -epos - 1;
+
+            int numRanges = rangeCount();
+            int sRange = Math.min(spos/2, numRanges - 1);
+            int eRange = Math.min(epos/2, numRanges - 1);
+
+            AddAction sMerge;
+            {
+                int rStart = bounds[rangeStart(sRange)];
+                int rEnd = bounds[rangeEnd(sRange)];
+                if (start >= rStart)
+                {
+                    // already included in the range or adjacent to range end
+                    sMerge = start <= rEnd + 1
+                             ? AddAction.INCLUDE // included in the range
+                             : AddAction.INSERT; // past the end of the range
+                }
+                else if (sRange > 0 && start == bounds[rangeEnd(sRange - 1)] + 1)
+                {
+                    // adjacent to the previous range, so say we're included in it to merge
+                    sRange--;
+                    sMerge = AddAction.INCLUDE;
+                }
+                else
+                {
+                    sMerge = AddAction.MOVE;
+                }
+            }
+
+            AddAction eMerge;
+            {
+                int rStart = bounds[rangeStart(eRange)];
+                int rEnd = bounds[rangeEnd(eRange)];
+
+                if (end <= rEnd)
+                {
+                    if (end >= rStart - 1)
+                    {
+                        // included in the range or adjacent to range start
+                        eMerge = AddAction.INCLUDE;
+                    }
+                    else if (sRange == eRange - 1)
+                    {
+                        // if we're before the start of this range, and the start is assigned to
+                        // the previous range, then we should just extend the previous range
+                        eRange--;
+                        eMerge = AddAction.MOVE;
+                    }
+                    else
+                    {
+                        // before the start of the range
+                        eMerge = AddAction.INSERT;
+                    }
+                }
+                else if (eRange < numRanges - 1 && end == bounds[rangeStart(eRange + 1)] - 1)
+                {
+                    // adjacent to the next range, so say we're included in it to merge
+                    eRange++;
+                    eMerge = AddAction.INCLUDE;
+                }
+
+                else
+                {
+                    eMerge = AddAction.MOVE;
+                }
+            }
+
+            // this range isn't adjacent and doesn't intersect any existing, so create a new range
+            if (sMerge.isMove() && eMerge.isInsert())
+            {
+                Preconditions.checkState(sRange == eRange);
+                onAdded.consume(logId, start, end);
+                insert(rangeStart(sRange), start, end);
+                return true;
+            }
+
+            // this should only happen if we're adding a range to the very end of the set
+            if (sMerge.isInsert() && eMerge.isMove())
+            {
+                Preconditions.checkState(sRange == eRange);
+                Preconditions.checkState(sRange == numRanges - 1);
+                onAdded.consume(logId, start, end);
+                append(start, end);
+                return true;
+            }
+
+            boolean adjusted = false;
+            if (sMerge.isMove())
+            {
+                onAdded.consume(logId, start, bounds[rangeStart(sRange)] - 1);
+                bounds[rangeStart(sRange)] = start;
+                adjusted = true;
+            }
+
+            // combine existing ranges
+            if (sRange != eRange)
+            {
+                Preconditions.checkState(sMerge.isMoveOrInclude());
+                Preconditions.checkState(eMerge.isMoveOrInclude());
+
+                adjusted = true;
+                // report merged ranges
+                for (int i = sRange; i < eRange; i++)
+                {
+                    int sEnd = bounds[rangeEnd(i)];
+                    int eStart = bounds[rangeStart(i + 1)];
+                    onAdded.consume(logId, sEnd + 1, eStart - 1);
+                }
+
+                // move array back -
+                int dstIdx = rangeEnd(sRange);
+                int srcIdx = rangeEnd(eRange);
+                System.arraycopy(bounds, srcIdx, bounds, dstIdx, size - srcIdx);
+                while (eRange > sRange)
+                {
+                    eRange--;
+                    bounds[--size] = 0;
+                    bounds[--size] = 0;
+                }
+            }
+
+            if (eMerge.isMove())
+            {
+                onAdded.consume(logId, bounds[rangeEnd(eRange)] + 1, end);
+                bounds[rangeEnd(eRange)] = end;
+                adjusted = true;
+            }
+
+            return adjusted;
+        }
+
+        public boolean add(int start, int end)
+        {
+            return add(start, end, RangeConsumer.NONE);
+        }
+
+        private void insert(int pos, int start, int end)
+        {
+            if (bounds.length == size)
+            {
+                int[] newBounds = new int[bounds.length * 2];
+                System.arraycopy(bounds, 0, newBounds, 0, pos);
+                System.arraycopy(bounds, pos, newBounds, pos + 2, size - pos);
+                bounds = newBounds;
+            }
+            else
+            {
+                System.arraycopy(bounds, pos, bounds, pos + 2, size - pos);
+            }
+            bounds[pos] = start;
+            bounds[pos + 1] = end;
+            size += 2;
+        }
+
+        protected void append(int start, int end)
+        {
+            if (bounds.length == size)
+            {
+                int[] newBounds = new int[Math.max(bounds.length * 2, INITIAL_CAPACITY)];
+                System.arraycopy(bounds, 0, newBounds, 0, bounds.length);
+                bounds = newBounds;
+            }
+            Preconditions.checkState(size == 0 || start > bounds[size - 1]);
+            bounds[size++] = start;
+            bounds[size++] = end;
+        }
+
+        public void append(int offset)
+        {
+            if (size == 0)
+            {
+                append(offset, offset);
+                return;
+            }
+
+            int tail = bounds[size - 1];
+            if (offset <= tail)
+                throw new IllegalArgumentException("Can't append " + offset + " to " + tail);
+
+            if (offset == tail + 1)
+                bounds[size-1] = offset;
+            else
+                append(offset, offset);
+        }
+    }
+
+    public static class Mutable extends AbstractMutable<Mutable>
+    {
+        private static final SetSupport.Adaptor<Mutable> adaptor = new SetSupport.Adaptor<Mutable>()
+        {
+            @Override
+            public Mutable empty(CoordinatorLogId logId)
+            {
+                return new Mutable(logId);
+            }
+
+            @Override
+            public Mutable passThrough(Offsets offsets)
+            {
+                if (offsets instanceof Mutable)
+                    return (Mutable) offsets;
+                return copy(offsets);
+            }
+
+            @Override
+            public Mutable create(CoordinatorLogId logId, int[] bounds, int size)
+            {
+                return new Mutable(logId, bounds, size);
+            }
+        };
+
+        public Mutable(RangeIterator rangeIterator)
+        {
+            super(rangeIterator);
+        }
+
+        public Mutable(CoordinatorLogId logId)
+        {
+            super(logId);
+        }
+
+        public Mutable(CoordinatorLogId logId, int[] bounds)
+        {
+            super(logId, bounds);
+        }
+
+        public Mutable(CoordinatorLogId logId, int[] bounds, int size)
+        {
+            super(logId, bounds, size);
+        }
+
+        public static Mutable createOrNull(RangeIterator rangeIterator)
+        {
+            return AbstractMutable.createOrNull(rangeIterator, Mutable::new);
+        }
+
+        public static Mutable copy(Offsets offsets)
+        {
+            return new Mutable(offsets.logId, Arrays.copyOf(offsets.bounds, offsets.size));
+        }
+
+        public static Mutable union(Offsets a, Offsets b)
+        {
+            return Offsets.union(a, b, adaptor);
+        }
+
+        public static Mutable difference(Offsets a, Offsets b)
+        {
+            return Offsets.difference(a, b, adaptor);
+        }
+
+        public static Mutable intersection(Offsets a, Offsets b)
+        {
+            return Offsets.intersection(a, b, adaptor);
+        }
+    }
+
+    public static class Immutable extends Offsets
+    {
+        private static final SetSupport.Adaptor<Immutable> adaptor = new SetSupport.Adaptor<Immutable>()
+        {
+            @Override
+            public Immutable empty(CoordinatorLogId logId)
+            {
+                return new Immutable(logId);
+            }
+
+            @Override
+            public Immutable passThrough(Offsets offsets)
+            {
+                if (offsets instanceof Immutable)
+                    return (Immutable) offsets;
+                return copy(offsets);
+            }
+
+            @Override
+            public Immutable create(CoordinatorLogId logId, int[] bounds, int size)
+            {
+                return new Immutable(logId, bounds, size);
+            }
+        };
+        private static int[] EMPTY = new int[0];
+
+        public Immutable(CoordinatorLogId logId, int[] bounds)
+        {
+            super(logId, bounds);
+        }
+
+        public Immutable(CoordinatorLogId logId, int[] bounds, int size)
+        {
+            super(logId, bounds, size);
+        }
+
+        public Immutable(CoordinatorLogId logId)
+        {
+            this(logId, EMPTY);
+        }
+
+        public static Immutable copy(Offsets offsets)
+        {
+            if (offsets instanceof Immutable)
+                return (Immutable) offsets;
+            return new Immutable(offsets.logId, Arrays.copyOf(offsets.bounds, offsets.size));
+        }
+
+        public static class Builder extends AbstractMutable<Builder>
+        {
+            public Builder(CoordinatorLogId logId)
+            {
+                super(logId);
+            }
+
+            public Builder(CoordinatorLogId logId, int[] bounds)
+            {
+                super(logId, bounds);
+            }
+
+            public Immutable build()
+            {
+                return new Immutable(logId, bounds, size);
+            }
+
+            public static Builder createOrNull(RangeIterator rangeIterator)
+            {
+                return AbstractMutable.createOrNull(rangeIterator, Builder::new);
+            }
+
+            public static Builder copy(Offsets offsets)
+            {
+                return new Builder(offsets.logId, Arrays.copyOf(offsets.bounds, offsets.size));
+            }
+        }
+
+        public static Immutable union(Offsets a, Offsets b)
+        {
+            return Offsets.union(a, b, adaptor);
+        }
+
+        public static Immutable difference(Offsets a, Offsets b)
+        {
+            return Offsets.difference(a, b, adaptor);
+        }
+
+        public static Immutable intersection(Offsets a, Offsets b)
+        {
+            return Offsets.intersection(a, b, adaptor);
+        }
+    }
+
+    private static int rangeStart(int range)
+    {
+        return range * 2;
+    }
+
+    private static int rangeEnd(int range)
+    {
+        return rangeStart(range) + 1;
+    }
+
+    public RangeIterator rangeIterator()
+    {
+        int numRanges = rangeCount();
+
+        return new RangeIterator()
+        {
+            int range = -1;
+
+            @Override
+            public int start()
+            {
+                Preconditions.checkState(range >= 0 && range <= numRanges);
+                return bounds[rangeStart(range)];
+            }
+
+            @Override
+            public int end()
+            {
+                Preconditions.checkState(range >= 0 && range <= numRanges);
+                return bounds[rangeEnd(range)];
+            }
+
+            @Override
+            public boolean tryAdvance()
+            {
+                if (isFinished())
+                    return false;
+                range++;
+                return !isFinished();
+            }
+
+            @Override
+            public CoordinatorLogId logId()
+            {
+                return logId;
+            }
+
+            @Override
+            public boolean isFinished()
+            {
+                return range >= numRanges;
+            }
+        };
+    }
+
+    private static class SetSupport
+    {
+        private static final int NO_SPLIT_SENTINEL = Integer.MIN_VALUE;
+
+        interface Adaptor<O extends Offsets>
+        {
+            O empty(CoordinatorLogId logId);
+            O passThrough(Offsets offsets);
+            O create(CoordinatorLogId logId, int[] bounds, int size);
+        }
+
+        private enum RangeOverlap
+        {
+            BEFORE, BEFORE_ADJACENT, AFTER, AFTER_ADJACENT, INTERSECTING
+        }
+
+        private static RangeOverlap calculateRangeOverlap(int aStart, int aEnd, int bStart, int bEnd)
+        {
+            Preconditions.checkState(aStart <= aEnd);
+            Preconditions.checkState(bStart <= bEnd);
+
+            if (aEnd < bStart)
+                return aEnd == bStart - 1 ? RangeOverlap.BEFORE_ADJACENT : RangeOverlap.BEFORE;
+
+            if (bEnd < aStart)
+                return bEnd == aStart - 1 ? RangeOverlap.AFTER_ADJACENT : RangeOverlap.AFTER;
+
+            return RangeOverlap.INTERSECTING;
+        }
+
+        private static RangeOverlap calculateRangeOverlap(int aSplit, int aStart, int aEnd, int bSplit, int bStart, int bEnd)
+        {
+            aStart = aSplit != NO_SPLIT_SENTINEL ? aSplit : aStart;
+            bStart = bSplit != NO_SPLIT_SENTINEL ? bSplit : bStart;
+            return calculateRangeOverlap(aStart, aEnd, bStart, bEnd);
+        }
+
+        private static RangeOverlap calculateRangeOverlap(int aSplit, int[] a, int aRange, int bSplit, int[] b, int bRange)
+        {
+            return calculateRangeOverlap(aSplit, a[rangeStart(aRange)], a[rangeEnd(aRange)], bSplit, b[rangeStart(bRange)], b[rangeEnd(bRange)]);
+        }
+
+        private static RangeOverlap calculateRangeOverlap(int[] a, int aRange, int[] b, int bRange)
+        {
+            return calculateRangeOverlap(NO_SPLIT_SENTINEL, a, aRange, NO_SPLIT_SENTINEL, b, bRange);
+        }
+
+        private static int[] ensureCapacity(int[] offsets, int capacity, int expectedMaxCapacity)
+        {
+            Preconditions.checkArgument(capacity > 0);
+            if (capacity <= offsets.length)
+                return offsets;
+
+            int newCapacity = capacity * 2;
+
+            // if we overflowed, set to max
+            if (newCapacity < 0)
+                newCapacity = Integer.MAX_VALUE;
+
+            if (newCapacity > expectedMaxCapacity && offsets.length < expectedMaxCapacity)
+                newCapacity = expectedMaxCapacity;
+
+            int[] newOffsets = new int[newCapacity];
+            System.arraycopy(offsets, 0, newOffsets, 0, offsets.length);
+
+            return newOffsets;
+        }
+
+        private static <O extends Offsets> O addRemainder(CoordinatorLogId logId, int dstSplit, int[] dst, int dstRange, int[] src, int srcRange, int srcNumRanges, Adaptor<O> adaptor)
+        {
+            int capacity = (dstRange + srcNumRanges - srcRange) * 2;
+            dst = ensureCapacity(dst, capacity, capacity);
+            while (srcRange < srcNumRanges)
+            {
+                int addStart = dstSplit != NO_SPLIT_SENTINEL ? dstSplit : src[rangeStart(srcRange)];
+                int addEnd = src[rangeEnd(srcRange)];
+                // extend last range if possible
+                if (dstRange > 0 && addStart <= dst[rangeEnd(dstRange - 1)] + 1)
+                {
+                    dst[rangeEnd(dstRange - 1)] = addEnd;
+                }
+                else
+                {
+                    dst[rangeStart(dstRange)] = addStart;
+                    dst[rangeEnd(dstRange)] = addEnd;
+                    dstRange++;
+                }
+                dstSplit = NO_SPLIT_SENTINEL;
+                srcRange++;
+            }
+            return adaptor.create(logId, dst, dstRange * 2);
+        }
+
+        private static <O extends Offsets> O addRemainder(CoordinatorLogId logId, int[] dst, int dstRange, int[] src, int srcRange, int srcNumRanges, Adaptor<O> adaptor)
+        {
+            return addRemainder(logId, NO_SPLIT_SENTINEL, dst, dstRange, src, srcRange, srcNumRanges, adaptor);
+        }
+
+        private static int[] ensureCapacity(int[] offsets, int capacity)
+        {
+            return ensureCapacity(offsets, capacity, Integer.MAX_VALUE);
+        }
+    }
+
+    private static <O extends Offsets> O union(Offsets a, Offsets b, SetSupport.Adaptor<O> adaptor)
+    {
+        if (a == null)
+            return adaptor.passThrough(b);
+
+        if (b == null)
+            return adaptor.passThrough(a);
+
+        Preconditions.checkArgument(a.logId.equals(b.logId));
+        CoordinatorLogId logId = a.logId;
+
+        int aNumRanges = a.rangeCount();
+        int bNumRanges = b.rangeCount();
+
+        if (aNumRanges == 0)
+            return adaptor.passThrough(b);
+
+        if (bNumRanges == 0)
+            return adaptor.passThrough(a);
+
+        int aRange = 0;
+        int bRange = 0;
+
+        int cRange = 0;
+        int[] c = new int[Math.max(aNumRanges, bNumRanges) * 2];
+
+        while (aRange < aNumRanges && bRange < bNumRanges)
+        {
+            int addStart;
+            int addEnd;
+            SetSupport.RangeOverlap rangeOverlap = SetSupport.calculateRangeOverlap(a.bounds, aRange, b.bounds, bRange);
+            switch (rangeOverlap)
+            {
+                case BEFORE:
+                    addStart = a.bounds[rangeStart(aRange)];
+                    addEnd = a.bounds[rangeEnd(aRange)];
+                    aRange++;
+                    break;
+                case AFTER:
+                    addStart = b.bounds[rangeStart(bRange)];
+                    addEnd = b.bounds[rangeEnd(bRange)];
+                    bRange++;
+                    break;
+                case BEFORE_ADJACENT:
+                case AFTER_ADJACENT:
+                case INTERSECTING:
+                    addStart = Math.min(a.bounds[rangeStart(aRange)], b.bounds[rangeStart(bRange)]);
+                    addEnd = Math.max(a.bounds[rangeEnd(aRange)], b.bounds[rangeEnd(bRange)]);
+                    aRange++;
+                    bRange++;
+                    break;
+                default:
+                    throw new IllegalStateException("Unhandled union op: " + rangeOverlap);
+            }
+
+            // extend the tail if we can
+            if (cRange > 0 && addStart <= c[rangeEnd(cRange-1)] + 1)
+            {
+                c[rangeEnd(cRange - 1)] = addEnd;
+            }
+            else
+            {
+                c = SetSupport.ensureCapacity(c, (cRange + 1) * 2);
+                c[rangeStart(cRange)] = addStart;
+                c[rangeEnd(cRange)] = addEnd;
+                cRange++;
+            }
+        }
+
+        if (aRange < aNumRanges)
+        {
+            Preconditions.checkState(bRange == bNumRanges);
+            return SetSupport.addRemainder(logId, c, cRange, a.bounds, aRange, aNumRanges, adaptor);
+        }
+
+        if (bRange < bNumRanges)
+        {
+            Preconditions.checkState(aRange == aNumRanges);
+            return SetSupport.addRemainder(logId, c, cRange, b.bounds, bRange, bNumRanges, adaptor);
+        }
+
+        return adaptor.create(logId, c, cRange * 2);
+    }
+
+    private static abstract class AbstractSetIterator implements RangeIterator
+    {
+        enum State
+        {
+            INITIALIZED, VALID, FINISHED;
+
+            boolean isValid()
+            {
+                return this == VALID;
+            }
+
+            boolean isFinished()
+            {
+                return this == FINISHED;
+            }
+        }
+        final RangeIterator a;
+        final RangeIterator b;
+        int start;
+        int end;
+        private State state = State.INITIALIZED;
+
+        public AbstractSetIterator(RangeIterator a, RangeIterator b)
+        {
+            Preconditions.checkNotNull(a);
+            Preconditions.checkNotNull(b);
+            Preconditions.checkArgument(a.logId().equals(b.logId()));
+            this.a = a;
+            this.b = b;
+        }
+
+        @Override
+        public int start()
+        {
+            Preconditions.checkState(state.isValid());
+            return start;
+        }
+
+        @Override
+        public int end()
+        {
+            Preconditions.checkState(state.isValid());
+            return end;
+        }
+
+        protected abstract State computeNext();
+
+        @Override
+        public boolean tryAdvance()
+        {
+            switch (state)
+            {
+                case INITIALIZED:
+                    a.tryAdvance();
+                    b.tryAdvance();
+                    state = State.VALID;
+                case VALID:
+                    state = computeNext();
+                    if (!state.isFinished())
+                        break;;
+                case FINISHED:
+                    return false;
+                default:
+                    throw new IllegalStateException("Unhandled state: " + state);
+            }
+            return true;
+        }
+
+        @Override
+        public boolean isFinished()
+        {
+            return state.isFinished();
+        }
+
+        @Override
+        public CoordinatorLogId logId()
+        {
+            return a.logId();
+        }
+    }
+
+    private static class UnionIterator extends AbstractSetIterator
+    {
+        public UnionIterator(RangeIterator a, RangeIterator b)
+        {
+            super(a, b);
+        }
+
+        void extendEnd(RangeIterator iter)
+        {
+            while (iter.tryAdvance())
+            {
+                SetSupport.RangeOverlap rangeOverlap = SetSupport.calculateRangeOverlap(iter.start(), iter.end(), start, end);
+                switch (rangeOverlap)
+                {
+                    case BEFORE:
+                    case BEFORE_ADJACENT:
+                        throw new IllegalStateException();
+                    case AFTER:
+                        return;
+                    case INTERSECTING:
+                    case AFTER_ADJACENT:
+                        end = Math.max(end, iter.end());
+                        continue;
+                    default:
+                        throw new IllegalStateException("Unhandled union op: " + rangeOverlap);
+                }
+            }
+        }
+
+        @Override
+        protected State computeNext()
+        {
+
+            if (a.isFinished())
+            {
+                if (b.isFinished())
+                    return State.FINISHED;
+
+                start = b.start();
+                end = b.end();
+                b.tryAdvance();
+                return State.VALID;
+            }
+
+            if (b.isFinished())
+            {
+                start = a.start();
+                end = a.end();
+                a.tryAdvance();
+                return State.VALID;
+            }
+
+            SetSupport.RangeOverlap rangeOverlap = SetSupport.calculateRangeOverlap(a.start(), a.end(), b.start(), b.end());
+            switch (rangeOverlap)
+            {
+                case BEFORE:
+                    start = a.start();
+                    end = a.end();
+                    a.tryAdvance();
+                    break;
+                case AFTER:
+                    start = b.start();
+                    end = b.end();
+                    b.tryAdvance();
+                    break;
+                case BEFORE_ADJACENT:
+                case AFTER_ADJACENT:
+                case INTERSECTING:
+                    start = Math.min(a.start(), b.start());
+                    end = Math.max(a.end(), b.end());
+                    extendEnd(a);
+                    extendEnd(b);
+                    break;
+                default:
+                    throw new IllegalStateException("Unhandled union op: " + rangeOverlap);
+            }
+
+            return State.VALID;
+        }
+    }
+
+    public static RangeIterator union(RangeIterator a, RangeIterator b)
+    {
+        return new UnionIterator(a, b);
+    }
+
+    /**
+     * Subtract b from a
+     */
+    private static <O extends Offsets> O difference(Offsets a, Offsets b, SetSupport.Adaptor<O> adaptor)
+    {
+        Preconditions.checkArgument(a != null);
+        if (b == null)
+            return adaptor.passThrough(a);
+
+        Preconditions.checkArgument(b == null || a.logId.equals(b.logId));
+        CoordinatorLogId logId = a.logId;
+        int aNumRanges = a.rangeCount();
+        int bNumRanges = b.rangeCount();
+
+        if (aNumRanges == 0)
+            return adaptor.empty(logId);
+
+        if (bNumRanges == 0)
+            return adaptor.passThrough(a);
+
+        int aRange = 0;
+        int bRange = 0;
+
+        int cRange = 0;
+        int[] c = new int[Math.max(aNumRanges, bNumRanges) * 2];
+
+        int aSplit = SetSupport.NO_SPLIT_SENTINEL;
+        while (aRange < aNumRanges && bRange < bNumRanges)
+        {
+            int addStart;
+            int addEnd;
+
+            SetSupport.RangeOverlap rangeOverlap = SetSupport.calculateRangeOverlap(aSplit, a.bounds, aRange, SetSupport.NO_SPLIT_SENTINEL, b.bounds, bRange);
+            switch (rangeOverlap)
+            {
+                case BEFORE:
+                case BEFORE_ADJACENT:
+                    addStart = aSplit != SetSupport.NO_SPLIT_SENTINEL ? aSplit : a.bounds[rangeStart(aRange)];
+                    addEnd = a.bounds[rangeEnd(aRange)];
+                    aSplit = SetSupport.NO_SPLIT_SENTINEL;
+                    aRange++;
+                    break;
+                case AFTER:
+                case AFTER_ADJACENT:
+                    aSplit = SetSupport.NO_SPLIT_SENTINEL;
+                    bRange++;
+                    continue;
+                case INTERSECTING:
+                    int aStart = aSplit != SetSupport.NO_SPLIT_SENTINEL ? aSplit : a.bounds[rangeStart(aRange)];
+                    int aEnd = a.bounds[rangeEnd(aRange)];
+                    int bStart = b.bounds[rangeStart(bRange)];
+                    int bEnd = b.bounds[rangeEnd(bRange)];
+
+                    if (bStart <= aStart)
+                    {
+                        if (bEnd >= aEnd)
+                        {
+                            // b consumes the entire a range
+                            aSplit = SetSupport.NO_SPLIT_SENTINEL;
+                            aRange++;
+                        }
+                        else
+                        {
+                            // set the split and start over, the next b range may intersect with the current range
+                            aSplit = bEnd + 1;
+                            bRange++;
+                        }
+                        continue;
+                    }
+
+                    // does not consume the start of the range
+                    Preconditions.checkState(bStart > aStart);
+                    addStart = aStart;
+                    addEnd = bStart - 1;
+
+                    if (bEnd < aEnd)
+                    {
+                        // b splits the range
+                        aSplit = bEnd + 1;
+                        bRange++;
+                    }
+                    else
+                    {
+                        // b consumes the rest of the a range
+                        aSplit = SetSupport.NO_SPLIT_SENTINEL;
+                        aRange++;
+                    }
+                    break;
+                default:
+                    throw new IllegalStateException("Unhandled union op: " + rangeOverlap);
+            }
+
+            // extend the tail if we can
+            if (cRange > 0 && addStart <= c[rangeEnd(cRange-1)] + 1)
+            {
+                c[rangeEnd(cRange - 1)] = addEnd;
+            }
+            else
+            {
+                c = SetSupport.ensureCapacity(c, (cRange + 1) * 2);
+                c[rangeStart(cRange)] = addStart;
+                c[rangeEnd(cRange)] = addEnd;
+                cRange++;
+            }
+        }
+
+        if (aRange < aNumRanges)
+        {
+            Preconditions.checkState(bRange == bNumRanges);
+            return SetSupport.addRemainder(logId, aSplit, c, cRange, a.bounds, aRange, aNumRanges, adaptor);
+        }
+
+        return adaptor.create(logId, c, cRange * 2);
+    }
+
+    private static class DifferenceIterator extends AbstractSetIterator
+    {
+        private int aSplit = SetSupport.NO_SPLIT_SENTINEL;
+
+        public DifferenceIterator(RangeIterator a, RangeIterator b)
+        {
+            super(a, b);
+        }
+
+        @Override
+        protected State computeNext()
+        {
+            while (!a.isFinished() && !b.isFinished())
+            {
+                SetSupport.RangeOverlap rangeOverlap = SetSupport.calculateRangeOverlap(aSplit, a.start(), a.end(),
+                                                                                        SetSupport.NO_SPLIT_SENTINEL, b.start(), b.end());
+                switch (rangeOverlap)
+                {
+                    case BEFORE:
+                    case BEFORE_ADJACENT:
+                        start = aSplit != SetSupport.NO_SPLIT_SENTINEL ? aSplit : a.start();
+                        end = a.end();
+                        aSplit = SetSupport.NO_SPLIT_SENTINEL;
+                        a.tryAdvance();
+                        return State.VALID;
+                    case AFTER:
+                    case AFTER_ADJACENT:
+                        aSplit = SetSupport.NO_SPLIT_SENTINEL;
+                        b.tryAdvance();
+                        continue;
+                    case INTERSECTING:
+                        int aStart = aSplit != SetSupport.NO_SPLIT_SENTINEL ? aSplit : a.start();
+                        int aEnd = a.end();
+                        int bStart = b.start();
+                        int bEnd = b.end();
+
+                        if (bStart <= aStart)
+                        {
+                            if (bEnd >= aEnd)
+                            {
+                                // b consumes the entire a range
+                                aSplit = SetSupport.NO_SPLIT_SENTINEL;
+                                a.tryAdvance();
+                            }
+                            else
+                            {
+                                // set the split and start over, the next b range may intersect with the current range
+                                aSplit = bEnd + 1;
+                                b.tryAdvance();
+                            }
+                            continue;
+                        }
+
+                        // does not consume the start of the range
+                        Preconditions.checkState(bStart > aStart);
+                        start = aStart;
+                        end = bStart - 1;
+
+                        if (bEnd < aEnd)
+                        {
+                            // b splits the range
+                            aSplit = bEnd + 1;
+                            b.tryAdvance();
+                        }
+                        else
+                        {
+                            // b consumes the rest of the a range
+                            aSplit = SetSupport.NO_SPLIT_SENTINEL;
+                            a.tryAdvance();
+                        }
+                        return State.VALID;
+                    default:
+                        throw new IllegalStateException("Unhandled union op: " + rangeOverlap);
+                }
+            }
+
+            if (a.isFinished())
+                return State.FINISHED;
+
+            if (b.isFinished())
+            {
+                start = aSplit != SetSupport.NO_SPLIT_SENTINEL ? aSplit : a.start();
+                end = a.end();
+                aSplit = SetSupport.NO_SPLIT_SENTINEL;
+                a.tryAdvance();
+                return State.VALID;
+            }
+
+            // shouldn't be possible to get here
+            throw new IllegalStateException();
+        }
+    }
+
+    public static RangeIterator difference(RangeIterator a, RangeIterator b)
+    {
+        return new DifferenceIterator(a, b);
+    }
+
+    private static <O extends Offsets> O intersection(Offsets a, Offsets b, SetSupport.Adaptor<O> adaptor)
+    {
+        Preconditions.checkArgument(a.logId.equals(b.logId));
+        CoordinatorLogId logId = a.logId;
+
+        int aNumRanges = a.rangeCount();
+        int bNumRanges = b.rangeCount();
+
+        if (aNumRanges == 0 || bNumRanges == 0)
+            return adaptor.empty(logId);
+
+        int aRange = 0;
+        int bRange = 0;
+
+        int cRange = 0;
+        int[] c = new int[Math.max(aNumRanges, bNumRanges) * 2];
+
+        int aSplit = SetSupport.NO_SPLIT_SENTINEL;
+        int bSplit = SetSupport.NO_SPLIT_SENTINEL;
+        while (aRange < aNumRanges && bRange < bNumRanges)
+        {
+            int addStart;
+            int addEnd;
+
+            SetSupport.RangeOverlap rangeOverlap = SetSupport.calculateRangeOverlap(aSplit, a.bounds, aRange, bSplit, b.bounds, bRange);
+            switch (rangeOverlap)
+            {
+                case BEFORE:
+                case BEFORE_ADJACENT:
+                    aSplit = SetSupport.NO_SPLIT_SENTINEL;
+                    bSplit = SetSupport.NO_SPLIT_SENTINEL;
+                    aRange++;
+                    continue;
+                case AFTER:
+                case AFTER_ADJACENT:
+                    aSplit = SetSupport.NO_SPLIT_SENTINEL;
+                    bSplit = SetSupport.NO_SPLIT_SENTINEL;
+                    bRange++;
+                    continue;
+                case INTERSECTING:
+                    int aStart = aSplit != SetSupport.NO_SPLIT_SENTINEL ? aSplit : a.bounds[rangeStart(aRange)];
+                    int aEnd = a.bounds[rangeEnd(aRange)];
+                    int bStart = bSplit != SetSupport.NO_SPLIT_SENTINEL ? bSplit : b.bounds[rangeStart(bRange)];
+                    int bEnd = b.bounds[rangeEnd(bRange)];
+
+                    addStart = Math.max(aStart, bStart);
+                    addEnd = Math.min(aEnd, bEnd);
+
+                    if (aEnd < bEnd)
+                    {
+                        aSplit = SetSupport.NO_SPLIT_SENTINEL;
+                        bSplit = aEnd;
+                        aRange++;
+                    }
+                    else if (bEnd < aEnd)
+                    {
+                        aSplit = bEnd;
+                        bSplit = SetSupport.NO_SPLIT_SENTINEL;
+                        bRange++;
+                    }
+                    else
+                    {
+                        aSplit = SetSupport.NO_SPLIT_SENTINEL;
+                        bSplit = SetSupport.NO_SPLIT_SENTINEL;
+                        aRange++;
+                        bRange++;
+                    }
+                    break;
+
+                default:
+                    throw new IllegalStateException("Unhandled union op: " + rangeOverlap);
+            }
+
+            // extend the tail if we can, though it shouldn't be possible unless one of the input lists were malformed
+            if (cRange > 0 && addStart <= c[rangeEnd(cRange-1)] + 1)
+            {
+                c[rangeEnd(cRange - 1)] = addEnd;
+            }
+            else
+            {
+                c = SetSupport.ensureCapacity(c, (cRange + 1) * 2);
+                c[rangeStart(cRange)] = addStart;
+                c[rangeEnd(cRange)] = addEnd;
+                cRange++;
+            }
+        }
+
+        return adaptor.create(logId, c, cRange * 2);
+    }
+
+    public interface OffsetReciever
+    {
+        boolean add(int offset);
+
+        void addAll(Offsets offsets);
+    }
+
+    public interface OffsetConsumer
+    {
+        void accept(CoordinatorLogId logId, int offset);
+    }
+
+    public interface RangeConsumer
+    {
+        RangeConsumer NONE = (log, start, end) -> {};
+
+        void consume(CoordinatorLogId logId, int startOffset, int endOffset);
+    }
+
+    /**
+     * Intrusive iterator. Adjacent ranges may or may not be merged
+     *
+     * iterator is created in an invalid state, and tryAdvance needs to be called before calling start or
+     * stop. This is to support empty iterators, and makes iterating through the contents less verbose than
+     * have to check isFinished before reading the values, and makes iterator initialization less error-prone;
+     */
+    public interface RangeIterator
+    {
+        int start();
+        int end();
+        boolean tryAdvance();
+        boolean isFinished();
+        CoordinatorLogId logId();
+    }
+
+    // TODO (consider): delta-encoding + vints
+    public static final IVersionedSerializer<Offsets.Immutable> serializer = new IVersionedSerializer<>()
+    {
+        @Override
+        public void serialize(Offsets.Immutable offsets, DataOutputPlus out, int version) throws IOException
+        {
+            CoordinatorLogId.serializer.serialize(offsets.logId, out, version);
+            out.writeInt(offsets.size);
+            for (int i = 0; i < offsets.size; i++)
+                out.writeInt(offsets.bounds[i]);
+        }
+
+        @Override
+        public Offsets.Immutable deserialize(DataInputPlus in, int version) throws IOException
+        {
+            CoordinatorLogId logId = CoordinatorLogId.serializer.deserialize(in, version);
+            int size = in.readInt();
+            Preconditions.checkArgument(size >= 0 && size % 2 == 0);
+            int[] bounds = new int[size];
+            for (int i = 0; i < size; i++)
+                bounds[i] = in.readInt();
+            return new Offsets.Immutable(logId, bounds);
+        }
+
+        @Override
+        public long serializedSize(Offsets.Immutable offsets, int version)
+        {
+            long size = CoordinatorLogId.serializer.serializedSize(offsets.logId, version);
+            size += TypeSizes.sizeof(offsets.size);
+            size += (long) TypeSizes.INT_SIZE * offsets.size;
+            return size;
+        }
+    };
+}
diff --git a/src/java/org/apache/cassandra/replication/Participants.java b/src/java/org/apache/cassandra/replication/Participants.java
new file mode 100644
index 0000000000..c665d63a99
--- /dev/null
+++ b/src/java/org/apache/cassandra/replication/Participants.java
@@ -0,0 +1,55 @@
+/*
+ * 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.replication;
+
+import java.util.Arrays;
+import java.util.Collection;
+
+public class Participants
+{
+    private final int[] hosts;
+
+    Participants(Collection<Integer> participants)
+    {
+        int i = 0;
+        int[] hosts = new int[participants.size()];
+        for (int host : participants) hosts[i++] = host;
+        Arrays.sort(hosts);
+        this.hosts = hosts;
+    }
+
+    int size()
+    {
+        return hosts.length;
+    }
+
+    int indexOf(int hostId)
+    {
+        int idx = Arrays.binarySearch(hosts, hostId);
+        if (idx < 0)
+            throw new IllegalArgumentException("Unknown host id " + hostId);
+        return idx;
+    }
+
+    int get(int idx)
+    {
+        if (idx < 0 || idx >= hosts.length)
+            throw new IllegalArgumentException("Out of bounds host idx " + idx);
+        return hosts[idx];
+    }
+}
diff --git a/src/java/org/apache/cassandra/replication/ReconciliationPlan.java b/src/java/org/apache/cassandra/replication/ReconciliationPlan.java
new file mode 100644
index 0000000000..ac085bf839
--- /dev/null
+++ b/src/java/org/apache/cassandra/replication/ReconciliationPlan.java
@@ -0,0 +1,210 @@
+/*
+ * 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.replication;
+
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Set;
+
+import com.google.common.base.Preconditions;
+import com.google.common.collect.ImmutableMap;
+
+import org.apache.cassandra.db.TypeSizes;
+import org.apache.cassandra.io.IVersionedSerializer;
+import org.apache.cassandra.io.util.DataInputPlus;
+import org.apache.cassandra.io.util.DataOutputPlus;
+import org.apache.cassandra.locator.InetAddressAndPort;
+import org.apache.cassandra.replication.MutationSummary.CoordinatorSummary;
+
+public class ReconciliationPlan
+{
+    private final ImmutableMap<InetAddressAndPort, Log2OffsetsMap.Immutable> txPlan;
+
+    public ReconciliationPlan(ImmutableMap<InetAddressAndPort, Log2OffsetsMap.Immutable> txPlan)
+    {
+        this.txPlan = txPlan;
+    }
+
+    public Set<InetAddressAndPort> nodes()
+    {
+        return txPlan.keySet();
+    }
+
+    public Log2OffsetsMap.Immutable peerReconciliation(InetAddressAndPort to)
+    {
+        return txPlan.get(to);
+    }
+
+    public Log2OffsetsMap.Immutable offsetsFor(InetAddressAndPort node)
+    {
+        return txPlan.get(node);
+    }
+
+    public boolean isEmpty()
+    {
+        return txPlan.isEmpty();
+    }
+
+    private static class PlanBuilder
+    {
+        final InetAddressAndPort node;
+
+        final MutationSummary summary;
+        final Map<InetAddressAndPort, Log2OffsetsMap.Immutable.Builder> peerReconciliations = new HashMap<>();
+
+        public PlanBuilder(InetAddressAndPort node, MutationSummary summary)
+        {
+            this.node = node;
+            this.summary = summary;
+        }
+
+        public void send(InetAddressAndPort to, Offsets sequenceIds)
+        {
+            peerReconciliations.computeIfAbsent(to, ep -> new Log2OffsetsMap.Immutable.Builder()).add(sequenceIds);
+        }
+
+        ReconciliationPlan build()
+        {
+            ImmutableMap.Builder<InetAddressAndPort, Log2OffsetsMap.Immutable> builder = ImmutableMap.builder();
+            peerReconciliations.forEach((to, ids) -> builder.put(to, ids.build()));
+            return new ReconciliationPlan(builder.build());
+        }
+    }
+
+    // TODO (desired): rework Offsets set logic usage to use Offsets#RangeIterator instead of rematerializing sets
+    private static class CoordinatorLogReconciliation
+    {
+        final CoordinatorLogId logId;
+        Offsets.Immutable reconciled;
+        Offsets.Immutable unreconciled;
+
+        Map<InetAddressAndPort, Offsets> unreconciledNodes = new HashMap<>();
+
+        CoordinatorLogReconciliation(CoordinatorLogId logId)
+        {
+            this.logId = logId;
+        }
+
+        void addPeerSummary(InetAddressAndPort peer, CoordinatorSummary summary)
+        {
+            Preconditions.checkArgument(summary.logId().equals(logId));
+            reconciled = Offsets.Immutable.union(reconciled, summary.reconciled);
+            unreconciled = Offsets.Immutable.union(unreconciled, summary.unreconciled);
+            unreconciledNodes.put(peer, summary.unreconciled);
+        }
+
+        void createPlan(Map<InetAddressAndPort, PlanBuilder> plan)
+        {
+            // remove reconciled ids
+            Offsets.Immutable allIds = Offsets.Immutable.difference(unreconciled, reconciled);
+            for (InetAddressAndPort receiver : plan.keySet())
+            {
+                Offsets.Immutable missing = Offsets.Immutable.difference(allIds, unreconciledNodes.get(receiver));
+                if (missing.isEmpty())
+                    continue;
+
+                // TODO: look into more intelligent ways to distribute mutation requests
+                for (Map.Entry<InetAddressAndPort, Offsets> sender : unreconciledNodes.entrySet())
+                {
+                    if (sender.getKey().equals(receiver))
+                        continue;
+
+                    Offsets senderIds = sender.getValue();
+                    PlanBuilder senderPlan = plan.get(sender.getKey());
+
+                    Offsets.Immutable requestedIds = Offsets.Immutable.intersection(missing, senderIds);
+                    senderPlan.send(receiver, requestedIds);
+
+                    missing = Offsets.Immutable.difference(missing, requestedIds);
+                    if (missing.rangeCount() == 0)
+                        break;
+                }
+            }
+        }
+    }
+
+    public static Map<InetAddressAndPort, ReconciliationPlan> calculateReconciliation(Map<InetAddressAndPort, MutationSummary> summaries)
+    {
+        Map<InetAddressAndPort, PlanBuilder> planBuilders = new HashMap<>();
+        Map<CoordinatorLogId, CoordinatorLogReconciliation> coordinatorReconciliations = new HashMap<>();
+
+        // organize data by peer and log id
+        summaries.forEach((node, summary) -> {
+
+            planBuilders.put(node, new PlanBuilder(node, summary));
+
+            for (int i=0; i<summary.size(); i++)
+            {
+                CoordinatorSummary coordinatorSummary = summary.get(i);
+                CoordinatorLogReconciliation reconciliation = coordinatorReconciliations.computeIfAbsent(coordinatorSummary.logId(), CoordinatorLogReconciliation::new);
+                reconciliation.addPeerSummary(node, coordinatorSummary);
+            }
+        });
+
+        coordinatorReconciliations.values().forEach(planBuilder -> planBuilder.createPlan(planBuilders));
+
+        Map<InetAddressAndPort, ReconciliationPlan> plans = new HashMap<>();
+        planBuilders.forEach((node, planBuilder) -> {
+            ReconciliationPlan plan = planBuilder.build();
+            if (!plan.isEmpty())
+                plans.put(node, plan);
+        });
+        return plans;
+    }
+
+    public static final IVersionedSerializer<ReconciliationPlan> serializer = new IVersionedSerializer<>()
+    {
+        @Override
+        public void serialize(ReconciliationPlan plan, DataOutputPlus out, int version) throws IOException
+        {
+            out.writeInt(plan.txPlan.size());
+            for (Map.Entry<InetAddressAndPort, Log2OffsetsMap.Immutable> entry : plan.txPlan.entrySet())
+            {
+                InetAddressAndPort.Serializer.inetAddressAndPortSerializer.serialize(entry.getKey(), out, version);
+                Log2OffsetsMap.Immutable.serializer.serialize(entry.getValue(), out, version);
+            }
+        }
+
+        @Override
+        public ReconciliationPlan deserialize(DataInputPlus in, int version) throws IOException
+        {
+            int size = in.readInt();
+            ImmutableMap.Builder<InetAddressAndPort, Log2OffsetsMap.Immutable> builder = ImmutableMap.builderWithExpectedSize(size);
+            for (int i = 0; i < size; i++)
+            {
+                InetAddressAndPort endpoint = InetAddressAndPort.Serializer.inetAddressAndPortSerializer.deserialize(in, version);
+                Log2OffsetsMap.Immutable offsets = Log2OffsetsMap.Immutable.serializer.deserialize(in, version);
+                builder.put(endpoint, offsets);
+            }
+            return new ReconciliationPlan(builder.build());
+        }
+
+        @Override
+        public long serializedSize(ReconciliationPlan plan, int version)
+        {
+            long size = TypeSizes.sizeof(plan.txPlan.size());
+            for (Map.Entry<InetAddressAndPort, Log2OffsetsMap.Immutable> entry : plan.txPlan.entrySet())
+            {
+                size += InetAddressAndPort.Serializer.inetAddressAndPortSerializer.serializedSize(entry.getKey(), version);
+                size += Log2OffsetsMap.Immutable.serializer.serializedSize(entry.getValue(), version);
+            }
+            return size;
+        }
+    };
+}
diff --git a/src/java/org/apache/cassandra/replication/Shard.java b/src/java/org/apache/cassandra/replication/Shard.java
new file mode 100644
index 0000000000..538e9a57c0
--- /dev/null
+++ b/src/java/org/apache/cassandra/replication/Shard.java
@@ -0,0 +1,117 @@
+/*
+ * 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.replication;
+
+import java.util.function.IntSupplier;
+
+import com.google.common.base.Preconditions;
+
+import org.apache.cassandra.db.Mutation;
+import org.apache.cassandra.db.PartitionPosition;
+import org.apache.cassandra.dht.AbstractBounds;
+import org.apache.cassandra.dht.Range;
+import org.apache.cassandra.dht.Token;
+import org.apache.cassandra.locator.InetAddressAndPort;
+import org.apache.cassandra.replication.CoordinatorLog.CoordinatorLogPrimary;
+import org.apache.cassandra.tcm.ClusterMetadata;
+import org.apache.cassandra.tcm.Epoch;
+import org.jctools.maps.NonBlockingHashMapLong;
+
+public class Shard
+{
+    private final String keyspace;
+    private final Range<Token> tokenRange;
+    private final int localHostId;
+    private final Participants participants;
+    private final Epoch sinceEpoch;
+    private final NonBlockingHashMapLong<CoordinatorLog> logs;
+    // TODO (expected): add support for log rotation
+    private final CoordinatorLogPrimary currentLocalLog;
+
+    Shard(String keyspace, Range<Token> tokenRange, int localHostId, Participants participants, Epoch sinceEpoch, IntSupplier logIdProvider)
+    {
+        this.keyspace = keyspace;
+        this.tokenRange = tokenRange;
+        this.localHostId = localHostId;
+        this.participants = participants;
+        this.sinceEpoch = sinceEpoch;
+        this.logs = new NonBlockingHashMapLong<>();
+        this.currentLocalLog = startNewLog(localHostId, logIdProvider.getAsInt(), participants);
+        logs.put(currentLocalLog.logId.asLong(), currentLocalLog);
+    }
+
+    MutationId nextId()
+    {
+        return currentLocalLog.nextId();
+    }
+
+    void witnessedRemoteMutation(MutationId mutationId, InetAddressAndPort onHost)
+    {
+        int onHostId = ClusterMetadata.current().directory.peerId(onHost).id();
+        get(mutationId).witnessedRemoteMutation(mutationId, onHostId);
+    }
+
+    void startWriting(Mutation mutation)
+    {
+        get(mutation.id()).startWriting(mutation);
+    }
+
+    void finishWriting(Mutation mutation)
+    {
+        get(mutation.id()).finishWriting(mutation);
+    }
+
+    void addSummaryForKey(Token token, boolean includePending, MutationSummary.Builder builder)
+    {
+        logs.forEach((id, log) -> {
+            MutationSummary.CoordinatorSummary.Builder summaryBuilder = builder.builderForLog(log.logId);
+            log.collectOffsetsFor(token, builder.tableId, includePending, summaryBuilder.unreconciled, summaryBuilder.reconciled);
+        });
+    }
+
+    void addSummaryForRange(AbstractBounds<PartitionPosition> range, boolean includePending, MutationSummary.Builder builder)
+    {
+        logs.forEach((id, log) -> {
+            MutationSummary.CoordinatorSummary.Builder summaryBuilder = builder.builderForLog(log.logId);
+            log.collectOffsetsFor(range, builder.tableId, includePending, summaryBuilder.unreconciled, summaryBuilder.reconciled);
+        });
+    }
+
+    /**
+     * Creates a new coordinator log for this host. Primarily on Shard init (node startup or topology change).
+     * Also on keyspace creation.
+     */
+    private static CoordinatorLog.CoordinatorLogPrimary startNewLog(int localHostId, int hostLogId, Participants participants)
+    {
+        CoordinatorLogId logId = new CoordinatorLogId(localHostId, hostLogId);
+        return new CoordinatorLog.CoordinatorLogPrimary(localHostId, logId, participants);
+    }
+
+    private CoordinatorLog get(MutationId mutationId)
+    {
+        Preconditions.checkArgument(!mutationId.isNone());
+        return get(mutationId.logId());
+    }
+
+    private CoordinatorLog get(long logId)
+    {
+        CoordinatorLog log = logs.get(logId);
+        return log != null
+             ? log : logs.computeIfAbsent(logId, ignore -> CoordinatorLog.create(localHostId, new CoordinatorLogId(logId), participants));
+    }
+}
diff --git a/src/java/org/apache/cassandra/replication/ShortMutationId.java b/src/java/org/apache/cassandra/replication/ShortMutationId.java
new file mode 100644
index 0000000000..93ea294cbe
--- /dev/null
+++ b/src/java/org/apache/cassandra/replication/ShortMutationId.java
@@ -0,0 +1,117 @@
+/*
+ * 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.replication;
+
+import java.io.IOException;
+import java.util.Comparator;
+
+import org.apache.cassandra.db.TypeSizes;
+import org.apache.cassandra.io.IVersionedSerializer;
+import org.apache.cassandra.io.util.DataInputPlus;
+import org.apache.cassandra.io.util.DataOutputPlus;
+
+/**
+ * MutationId without the timestamp component. This is sufficient for uniquely identifying a mutation,
+ * and for lookup in the journal and most tracking data structures.
+ */
+public class ShortMutationId extends CoordinatorLogId
+{
+    /**
+     * 4 byte offset. Offest is incremented, is alone is sufficient to identify
+     * the entry within a coordinator log.
+     * MutationId adds a timestamp for correlation purposes.
+     */
+    protected final int offset;
+
+    public ShortMutationId(long logId, int offset)
+    {
+        super(logId);
+        this.offset = offset;
+    }
+
+    public ShortMutationId(CoordinatorLogId logId, int offset)
+    {
+        super(logId.hostId(), logId.hostLogId());
+        this.offset = offset;
+    }
+
+    public ShortMutationId(MutationId mutationId)
+    {
+        this(mutationId.logId(), mutationId.offset());
+    }
+
+    public long logId()
+    {
+        return super.asLong();
+    }
+
+    public int offset()
+    {
+        return offset;
+    }
+
+    @Override
+    public boolean equals(Object o)
+    {
+        if (this == o) return true;
+        if (!(o instanceof ShortMutationId)) return false;
+        ShortMutationId that = (ShortMutationId) o;
+        return this.logId() == that.logId() && this.offset == that.offset;
+    }
+
+    @Override
+    public int hashCode()
+    {
+        return Integer.hashCode(offset) + 31 * super.hashCode();
+    }
+
+    @Override
+    public String toString()
+    {
+        return "ShortMutationId{" + hostId() + ", " + hostLogId() + ", " + offset() + '}';
+    }
+
+    public static final Comparator<ShortMutationId> comparator = (l, r) -> {
+        int cmp = CoordinatorLogId.comparator.compare(l, r);
+        return cmp != 0 ? cmp : Integer.compare(l.offset, r.offset);
+    };
+
+    public static final IVersionedSerializer<ShortMutationId> serializer = new IVersionedSerializer<>()
+    {
+        @Override
+        public void serialize(ShortMutationId id, DataOutputPlus out, int version) throws IOException
+        {
+            out.writeLong(id.logId());
+            out.writeInt(id.offset());
+        }
+
+        @Override
+        public ShortMutationId deserialize(DataInputPlus in, int version) throws IOException
+        {
+            long logId = in.readLong();
+            int offset = in.readInt();
+            return new ShortMutationId(logId, offset);
+        }
+
+        @Override
+        public long serializedSize(ShortMutationId id, int version)
+        {
+            return TypeSizes.sizeof(id.logId()) + TypeSizes.sizeof(id.offset());
+        }
+    };
+}
diff --git a/src/java/org/apache/cassandra/replication/TrackedWriteRequest.java b/src/java/org/apache/cassandra/replication/TrackedWriteRequest.java
new file mode 100644
index 0000000000..5ff3acb34e
--- /dev/null
+++ b/src/java/org/apache/cassandra/replication/TrackedWriteRequest.java
@@ -0,0 +1,287 @@
+/*
+ * 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.replication;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ThreadLocalRandom;
+
+import com.google.common.base.Preconditions;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import org.apache.cassandra.concurrent.DebuggableTask;
+import org.apache.cassandra.concurrent.Stage;
+import org.apache.cassandra.config.DatabaseDescriptor;
+import org.apache.cassandra.db.ConsistencyLevel;
+import org.apache.cassandra.db.IMutation;
+import org.apache.cassandra.db.Keyspace;
+import org.apache.cassandra.db.Mutation;
+import org.apache.cassandra.db.WriteType;
+import org.apache.cassandra.dht.Token;
+import org.apache.cassandra.exceptions.RequestFailureReason;
+import org.apache.cassandra.exceptions.WriteTimeoutException;
+import org.apache.cassandra.locator.AbstractReplicationStrategy;
+import org.apache.cassandra.locator.DynamicEndpointSnitch;
+import org.apache.cassandra.locator.EndpointsForToken;
+import org.apache.cassandra.locator.Replica;
+import org.apache.cassandra.locator.ReplicaPlan;
+import org.apache.cassandra.locator.ReplicaPlans;
+import org.apache.cassandra.net.ForwardingInfo;
+import org.apache.cassandra.net.Message;
+import org.apache.cassandra.net.MessageFlag;
+import org.apache.cassandra.net.MessagingService;
+import org.apache.cassandra.net.Verb;
+import org.apache.cassandra.service.TrackedWriteResponseHandler;
+import org.apache.cassandra.tracing.Tracing;
+import org.apache.cassandra.transport.Dispatcher;
+import org.apache.cassandra.utils.FBUtilities;
+import org.apache.cassandra.utils.MonotonicClock;
+
+import static java.util.Collections.singletonList;
+import static java.util.concurrent.TimeUnit.NANOSECONDS;
+import static org.apache.cassandra.metrics.ClientRequestsMetricsHolder.writeMetrics;
+import static org.apache.cassandra.net.Verb.MUTATION_REQ;
+
+public class TrackedWriteRequest
+{
+    private static final Logger logger = LoggerFactory.getLogger(TrackedWriteRequest.class);
+
+    /**
+     * Coordinate write of a tracked mutation. Assumes the replica is a coordinator.
+     *
+     * @param mutation the mutation to be applied
+     * @param consistencyLevel the consistency level for the write operation
+     * @param requestTime object holding times when request got enqueued and started execution
+     */
+    public static TrackedWriteResponseHandler perform(
+        Mutation mutation, ConsistencyLevel consistencyLevel, Dispatcher.RequestTime requestTime)
+    {
+        Tracing.trace("Determining replicas for mutation");
+
+        String keyspaceName = mutation.getKeyspaceName();
+        Keyspace keyspace = Keyspace.open(keyspaceName);
+        Token token = mutation.key().getToken();
+
+        MutationId id = MutationTrackingService.instance.nextMutationId(keyspaceName, token);
+        mutation = mutation.withMutationId(id);
+
+        ReplicaPlan.ForWrite plan = ReplicaPlans.forWrite(keyspace, consistencyLevel, token, ReplicaPlans.writeNormal);
+
+        if (plan.lookup(FBUtilities.getBroadcastAddressAndPort()) != null)
+            writeMetrics.localRequests.mark();
+        else
+            writeMetrics.remoteRequests.mark();
+
+        AbstractReplicationStrategy rs = plan.replicationStrategy();
+
+        TrackedWriteResponseHandler handler =
+            TrackedWriteResponseHandler.wrap(rs.getWriteResponseHandler(plan, null, WriteType.SIMPLE, null, requestTime),
+                                             keyspaceName,
+                                             mutation.key().getToken(),
+                                             id);
+        applyLocallyAndSendToReplicas(mutation, plan, handler);
+
+        return handler;
+    }
+
+    public static void applyLocallyAndSendToReplicas(Mutation mutation, ReplicaPlan.ForWrite plan, TrackedWriteResponseHandler handler)
+    {
+        String localDataCenter = DatabaseDescriptor.getLocator().local().datacenter;
+
+        boolean applyLocally = false;
+
+        // this DC replicas
+        List<Replica> localDCReplicas = null;
+
+        // extra-DC, grouped by DC
+        Map<String, List<Replica>> remoteDCReplicas = null;
+
+        // only need to create a Message for non-local writes
+        Message<Mutation> message = null;
+
+        // For performance, Mutation caches serialized buffers that are computed lazily in serializedBuffer(). That
+        // computation is not synchronized however, and we will potentially call that method concurrently for each
+        // dispatched message (not that concurrent calls to serializedBuffer() are "unsafe" per se, just that they
+        // may result in multiple computations, making the caching optimization moot). So forcing the serialization
+        // here to make sure it's already cached/computed when it's concurrently used later.
+        // Side note: we have one cached buffers for each used EncodingVersion and this only pre-compute the one for
+        // the current version, but it's just an optimization, and we're ok not optimizing for mixed-version clusters.
+        Mutation.serializer.prepareSerializedBuffer(mutation, MessagingService.current_version);
+
+        for (Replica destination : plan.contacts())
+        {
+            if (!plan.isAlive(destination))
+            {
+                handler.expired(); // immediately mark the response as expired since the request will not be sent
+                continue;
+            }
+
+            if (destination.isSelf())
+            {
+                applyLocally = true;
+                continue;
+            }
+
+            if (message == null)
+                message = Message.outWithFlags(MUTATION_REQ, mutation, handler.getRequestTime(), singletonList(MessageFlag.CALL_BACK_ON_FAILURE));
+
+            String dc = DatabaseDescriptor.getLocator().location(destination.endpoint()).datacenter;
+
+            if (localDataCenter.equals(dc))
+            {
+                if (localDCReplicas == null)
+                    localDCReplicas = new ArrayList<>(plan.contacts().size());
+                localDCReplicas.add(destination);
+            }
+            else
+            {
+                if (remoteDCReplicas == null)
+                    remoteDCReplicas = new HashMap<>();
+
+                List<Replica> messages = remoteDCReplicas.get(dc);
+                if (messages == null)
+                    messages = remoteDCReplicas.computeIfAbsent(dc, ignore -> new ArrayList<>(3)); // most DCs will have <= 3 replicas
+                messages.add(destination);
+            }
+        }
+
+        Preconditions.checkState(applyLocally); // the coordinator is always a replica
+        applyMutationLocally(mutation, handler);
+
+        if (localDCReplicas != null)
+            for (Replica destination : localDCReplicas)
+                MessagingService.instance().sendWriteWithCallback(message, destination, handler);
+
+        if (remoteDCReplicas != null)
+        {
+            // for each datacenter, send the message to one node to relay the write to other replicas
+            for (List<Replica> dcReplicas : remoteDCReplicas.values())
+                sendMessagesToRemoteDC(message, EndpointsForToken.copyOf(mutation.key().getToken(), dcReplicas), handler);
+        }
+    }
+
+    private static void applyMutationLocally(Mutation mutation, TrackedWriteResponseHandler handler)
+    {
+        Stage.MUTATION.maybeExecuteImmediately(new LocalMutationRunnable(mutation, handler));
+    }
+
+    private static class LocalMutationRunnable implements DebuggableTask.RunnableDebuggableTask
+    {
+        private final Mutation mutation;
+        private final TrackedWriteResponseHandler handler;
+
+        LocalMutationRunnable(Mutation mutation, TrackedWriteResponseHandler handler)
+        {
+            this.mutation = mutation;
+            this.handler = handler;
+        }
+
+        @Override
+        public final void run()
+        {
+            long now = MonotonicClock.Global.approxTime.now();
+            long deadline = handler.getRequestTime().computeDeadline(MUTATION_REQ.expiresAfterNanos());
+
+            if (now > deadline)
+            {
+                long timeTakenNanos = now - startTimeNanos();
+                MessagingService.instance().metrics.recordSelfDroppedMessage(Verb.MUTATION_REQ, timeTakenNanos, NANOSECONDS);
+                return;
+            }
+
+            try
+            {
+                mutation.apply();
+                handler.onResponse(null);
+            }
+            catch (Exception ex)
+            {
+                if (!(ex instanceof WriteTimeoutException))
+                    logger.error("Failed to apply mutation locally : ", ex);
+                handler.onFailure(FBUtilities.getBroadcastAddressAndPort(), RequestFailureReason.forException(ex));
+            }
+        }
+
+        @Override
+        public long creationTimeNanos()
+        {
+            return handler.getRequestTime().enqueuedAtNanos();
+        }
+
+        @Override
+        public long startTimeNanos()
+        {
+            return handler.getRequestTime().startedAtNanos();
+        }
+
+        @Override
+        public String description()
+        {
+            // description is an Object and toString() called so we do not have to evaluate the Mutation.toString()
+            // unless expliclitly checked
+            return mutation.toString();
+        }
+    }
+
+    /*
+     * Send the message to the first replica of targets, and have it forward the message to others in its DC
+     */
+    private static void sendMessagesToRemoteDC(Message<? extends IMutation> message,
+                                               EndpointsForToken targets,
+                                               TrackedWriteResponseHandler handler)
+    {
+        final Replica target;
+
+        if (targets.size() > 1)
+        {
+            target = pickReplica(targets);
+            EndpointsForToken forwardToReplicas = targets.filter(r -> r != target, targets.size());
+
+            for (Replica replica : forwardToReplicas)
+            {
+                MessagingService.instance().callbacks.addWithExpiration(handler, message, replica);
+                logger.trace("Adding FWD message to {}@{}", message.id(), replica);
+            }
+
+            // starting with 4.0, use the same message id for all replicas
+            long[] messageIds = new long[forwardToReplicas.size()];
+            Arrays.fill(messageIds, message.id());
+
+            message = message.withForwardTo(new ForwardingInfo(forwardToReplicas.endpointList(), messageIds));
+        }
+        else
+        {
+            target = targets.get(0);
+        }
+
+        Tracing.trace("Sending mutation to remote replica {}", target);
+        MessagingService.instance().sendWriteWithCallback(message, target, handler);
+        logger.trace("Sending message to {}@{}", message.id(), target);
+    }
+
+    private static Replica pickReplica(EndpointsForToken targets)
+    {
+        EndpointsForToken healthy = targets.filter(r -> DynamicEndpointSnitch.getSeverity(r.endpoint()) == 0);
+        EndpointsForToken select = healthy.isEmpty() ? targets : healthy;
+        return select.get(ThreadLocalRandom.current().nextInt(0, select.size()));
+    }
+}
diff --git a/src/java/org/apache/cassandra/schema/DistributedMetadataLogKeyspace.java b/src/java/org/apache/cassandra/schema/DistributedMetadataLogKeyspace.java
index 82adbb6d80..9bd0378064 100644
--- a/src/java/org/apache/cassandra/schema/DistributedMetadataLogKeyspace.java
+++ b/src/java/org/apache/cassandra/schema/DistributedMetadataLogKeyspace.java
@@ -245,7 +245,7 @@ public final class DistributedMetadataLogKeyspace
 
     public static KeyspaceMetadata initialMetadata(Set<String> knownDatacenters)
     {
-        return KeyspaceMetadata.create(SchemaConstants.METADATA_KEYSPACE_NAME, new KeyspaceParams(true, ReplicationParams.simpleMeta(1, knownDatacenters), FastPathStrategy.simple()), Tables.of(Log));
+        return KeyspaceMetadata.create(SchemaConstants.METADATA_KEYSPACE_NAME, new KeyspaceParams(true, ReplicationParams.simpleMeta(1, knownDatacenters), FastPathStrategy.simple(), ReplicationType.untracked), Tables.of(Log));
     }
 
     public static KeyspaceMetadata initialMetadata(String datacenter)
diff --git a/src/java/org/apache/cassandra/schema/KeyspaceMetadata.java b/src/java/org/apache/cassandra/schema/KeyspaceMetadata.java
index 5239790b19..122c265356 100644
--- a/src/java/org/apache/cassandra/schema/KeyspaceMetadata.java
+++ b/src/java/org/apache/cassandra/schema/KeyspaceMetadata.java
@@ -120,27 +120,27 @@ public final class KeyspaceMetadata implements SchemaElement
 
     public static KeyspaceMetadata create(String name, KeyspaceParams params, Tables tables)
     {
-        return new KeyspaceMetadata(name, Kind.REGULAR, params, tables, Views.none(), Types.none(), UserFunctions.none());
+        return new KeyspaceMetadata(name, Kind.REGULAR, params, tables.withKeyspaceReplicationType(params.replicationType), Views.none(), Types.none(), UserFunctions.none());
     }
 
     public static KeyspaceMetadata create(String name, KeyspaceParams params, Tables tables, Views views, Types types, UserFunctions functions)
     {
-        return new KeyspaceMetadata(name, Kind.REGULAR, params, tables, views, types, functions);
+        return new KeyspaceMetadata(name, Kind.REGULAR, params, tables.withKeyspaceReplicationType(params.replicationType), views, types, functions);
     }
 
     public static KeyspaceMetadata virtual(String name, Tables tables)
     {
-        return new KeyspaceMetadata(name, Kind.VIRTUAL, KeyspaceParams.local(), tables, Views.none(), Types.none(), UserFunctions.none());
+        return new KeyspaceMetadata(name, Kind.VIRTUAL, KeyspaceParams.local(), tables.withKeyspaceReplicationType(ReplicationType.untracked), Views.none(), Types.none(), UserFunctions.none());
     }
 
     public KeyspaceMetadata withSwapped(KeyspaceParams params)
     {
-        return new KeyspaceMetadata(name, kind, params, tables, views, types, userFunctions);
+        return new KeyspaceMetadata(name, kind, params, tables.withKeyspaceReplicationType(params.replicationType), views, types, userFunctions);
     }
 
     public KeyspaceMetadata withSwapped(Tables regular)
     {
-        return new KeyspaceMetadata(name, kind, params, regular, views, types, userFunctions);
+        return new KeyspaceMetadata(name, kind, params, regular.withKeyspaceReplicationType(params.replicationType), views, types, userFunctions);
     }
 
     public KeyspaceMetadata withSwapped(Views views)
@@ -290,6 +290,11 @@ public final class KeyspaceMetadata implements SchemaElement
         return Optional.empty();
     }
 
+    public boolean useMutationTracking()
+    {
+        return params.replicationType.isTracked();
+    }
+
     @Override
     public int hashCode()
     {
@@ -384,7 +389,9 @@ public final class KeyspaceMetadata implements SchemaElement
             params.replication.appendCqlTo(builder);
 
             builder.append("  AND durable_writes = ")
-                   .append(params.durableWrites);
+                   .append(params.durableWrites)
+                   .append("  AND replication_type = ")
+                   .appendWithSingleQuotes(params.replicationType.name());
 
             if (params.fastPath != null)
             {
diff --git a/src/java/org/apache/cassandra/schema/KeyspaceParams.java b/src/java/org/apache/cassandra/schema/KeyspaceParams.java
index 4f41235367..38b203dee3 100644
--- a/src/java/org/apache/cassandra/schema/KeyspaceParams.java
+++ b/src/java/org/apache/cassandra/schema/KeyspaceParams.java
@@ -55,11 +55,14 @@ public final class KeyspaceParams
     @VisibleForTesting
     public static boolean DEFAULT_LOCAL_DURABLE_WRITES = true;
 
+    public static final ReplicationType DEFAULT_REPLICATION_TYPE = ReplicationType.untracked;
+
     public enum Option
     {
         DURABLE_WRITES,
         REPLICATION,
-        FAST_PATH;
+        FAST_PATH,
+        REPLICATION_TYPE;
 
         @Override
         public String toString()
@@ -73,74 +76,97 @@ public final class KeyspaceParams
     public final FastPathStrategy fastPath;
     public final String comment;
     public final String securityLabel;
+    public final ReplicationType replicationType;
 
-    public KeyspaceParams(boolean durableWrites, ReplicationParams replication, FastPathStrategy fastPath)
+    public KeyspaceParams(boolean durableWrites, ReplicationParams replication, FastPathStrategy fastPath, ReplicationType replicationType)
     {
-        this(durableWrites, replication, fastPath, EMPTY_COMMENT, EMPTY_SECURITY_LABEL);
+        this(durableWrites, replication, fastPath, EMPTY_COMMENT, EMPTY_SECURITY_LABEL, replicationType);
     }
 
-    public KeyspaceParams(boolean durableWrites, ReplicationParams replication, FastPathStrategy fastPath, String comment, String securityLabel)
+    public KeyspaceParams(boolean durableWrites, ReplicationParams replication, FastPathStrategy fastPath, String comment, String securityLabel, ReplicationType replicationType)
     {
         this.durableWrites = durableWrites;
         this.replication = replication;
         this.fastPath = fastPath;
         this.comment = comment == null ? EMPTY_COMMENT : comment;
         this.securityLabel = securityLabel == null ? EMPTY_SECURITY_LABEL : securityLabel;
+        this.replicationType = replicationType;
+    }
+
+    public static KeyspaceParams create(boolean durableWrites, Map<String, String> replication, FastPathStrategy fastPath, ReplicationType replicationType)
+    {
+        return new KeyspaceParams(durableWrites, ReplicationParams.fromMap(replication), fastPath, EMPTY_COMMENT, EMPTY_SECURITY_LABEL, replicationType);
     }
 
-    public static KeyspaceParams create(boolean durableWrites, Map<String, String> replication, FastPathStrategy fastPath)
+    public static KeyspaceParams create(boolean durableWrites, Map<String, String> replication, ReplicationType replicationType)
     {
-        return new KeyspaceParams(durableWrites, ReplicationParams.fromMap(replication), fastPath, EMPTY_COMMENT, EMPTY_SECURITY_LABEL);
+        return create(durableWrites, replication, FastPathStrategy.simple(), replicationType);
     }
 
     public static KeyspaceParams create(boolean durableWrites, Map<String, String> replication, Map<String, String> fastPath)
     {
-        return create(durableWrites, replication, FastPathStrategy.fromMap(fastPath));
+        return create(durableWrites, replication, FastPathStrategy.fromMap(fastPath), ReplicationType.untracked);
+    }
+
+    public static KeyspaceParams create(boolean durableWrites, Map<String, String> replication, Map<String, String> fastPath, ReplicationType replicationType)
+    {
+        return create(durableWrites, replication, FastPathStrategy.fromMap(fastPath), replicationType);
     }
 
     public static KeyspaceParams create(boolean durableWrites, Map<String, String> replication)
     {
-        return create(durableWrites, replication, FastPathStrategy.simple());
+        return create(durableWrites, replication, FastPathStrategy.simple(), ReplicationType.untracked);
     }
 
     public static KeyspaceParams local()
     {
-        return new KeyspaceParams(DEFAULT_LOCAL_DURABLE_WRITES, ReplicationParams.local(), FastPathStrategy.simple(), EMPTY_COMMENT, EMPTY_SECURITY_LABEL);
+        return new KeyspaceParams(DEFAULT_LOCAL_DURABLE_WRITES, ReplicationParams.local(), FastPathStrategy.simple(), EMPTY_COMMENT, EMPTY_SECURITY_LABEL, ReplicationType.untracked);
+    }
+
+    public static KeyspaceParams simple(int replicationFactor, ReplicationType replicationType)
+    {
+        return new KeyspaceParams(true, ReplicationParams.simple(replicationFactor), FastPathStrategy.simple(), replicationType);
     }
 
     public static KeyspaceParams simple(int replicationFactor)
     {
-        return new KeyspaceParams(true, ReplicationParams.simple(replicationFactor), FastPathStrategy.simple(), EMPTY_COMMENT, EMPTY_SECURITY_LABEL);
+        return new KeyspaceParams(true, ReplicationParams.simple(replicationFactor), FastPathStrategy.simple(), EMPTY_COMMENT, EMPTY_SECURITY_LABEL, ReplicationType.untracked);
     }
 
     public static KeyspaceParams simple(String replicationFactor)
     {
-        return new KeyspaceParams(true, ReplicationParams.simple(replicationFactor), FastPathStrategy.simple(), EMPTY_COMMENT, EMPTY_SECURITY_LABEL);
+        return new KeyspaceParams(true, ReplicationParams.simple(replicationFactor), FastPathStrategy.simple(), EMPTY_COMMENT, EMPTY_SECURITY_LABEL, ReplicationType.untracked);
     }
 
     public static KeyspaceParams simpleTransient(int replicationFactor)
     {
-        return new KeyspaceParams(false, ReplicationParams.simple(replicationFactor), FastPathStrategy.simple(), EMPTY_COMMENT, EMPTY_SECURITY_LABEL);
+        return new KeyspaceParams(false, ReplicationParams.simple(replicationFactor), FastPathStrategy.simple(), EMPTY_COMMENT, EMPTY_SECURITY_LABEL, ReplicationType.untracked);
     }
 
+    public static KeyspaceParams nts(ReplicationType replicationType, Object... args)
+    {
+        return new KeyspaceParams(true, ReplicationParams.nts(args), FastPathStrategy.simple(), replicationType);
+    }
+
+
     public static KeyspaceParams nts(Object... args)
     {
-        return new KeyspaceParams(true, ReplicationParams.nts(args), FastPathStrategy.simple(), EMPTY_COMMENT, EMPTY_SECURITY_LABEL);
+        return nts(ReplicationType.untracked, args);
     }
 
     public KeyspaceParams withSwapped(ReplicationParams params)
     {
-        return new KeyspaceParams(durableWrites, params, fastPath, comment, securityLabel);
+        return new KeyspaceParams(durableWrites, params, fastPath, comment, securityLabel, replicationType);
     }
 
     public KeyspaceParams withComment(String comment)
     {
-        return new KeyspaceParams(durableWrites, replication, fastPath, comment, securityLabel);
+        return new KeyspaceParams(durableWrites, replication, fastPath, comment, securityLabel, replicationType);
     }
 
     public KeyspaceParams withSecurityLabel(String securityLabel)
     {
-        return new KeyspaceParams(durableWrites, replication, fastPath, comment, securityLabel);
+        return new KeyspaceParams(durableWrites, replication, fastPath, comment, securityLabel, replicationType);
     }
 
     public void validate(String name, ClientState state, ClusterMetadata metadata)
@@ -163,13 +189,14 @@ public final class KeyspaceParams
                && replication.equals(p.replication)
                && fastPath.equals(p.fastPath)
                && comment.equals(p.comment)
-               && securityLabel.equals(p.securityLabel);
+               && securityLabel.equals(p.securityLabel)
+               && replicationType == p.replicationType;
     }
 
     @Override
     public int hashCode()
     {
-        return Objects.hashCode(durableWrites, replication, fastPath, comment, securityLabel);
+        return Objects.hashCode(durableWrites, replication, fastPath, comment, securityLabel, replicationType);
     }
 
     @Override
@@ -181,6 +208,7 @@ public final class KeyspaceParams
                           .add(Option.FAST_PATH.toString(), fastPath.toString())
                           .add("COMMENT", comment)
                           .add("SECURITY_LABEL", securityLabel)
+                          .add(Option.REPLICATION_TYPE.toString(), replicationType)
                           .toString();
     }
 
@@ -188,6 +216,7 @@ public final class KeyspaceParams
     {
         public void serialize(KeyspaceParams t, DataOutputPlus out, Version version) throws IOException
         {
+            ReplicationType.serializer.serialize(t.replicationType, out, version);
             ReplicationParams.serializer.serialize(t.replication, out, version);
             out.writeBoolean(t.durableWrites);
             if (version.isAtLeast(MIN_ACCORD_VERSION))
@@ -201,6 +230,7 @@ public final class KeyspaceParams
 
         public KeyspaceParams deserialize(DataInputPlus in, Version version) throws IOException
         {
+            ReplicationType rtype = ReplicationType.serializer.deserialize(in, version);
             ReplicationParams params = ReplicationParams.serializer.deserialize(in, version);
             boolean durableWrites = in.readBoolean();
             FastPathStrategy fastPath = version.isAtLeast(MIN_ACCORD_VERSION)
@@ -213,12 +243,13 @@ public final class KeyspaceParams
                 comment = in.readUTF();
                 securityLabel = in.readUTF();
             }
-            return new KeyspaceParams(durableWrites, params, fastPath, comment, securityLabel);
+            return new KeyspaceParams(durableWrites, params, fastPath, comment, securityLabel, rtype);
         }
 
         public long serializedSize(KeyspaceParams t, Version version)
         {
-            return ReplicationParams.serializer.serializedSize(t.replication, version) +
+            return ReplicationType.serializer.serializedSize(t.replicationType, version) +
+                   ReplicationParams.serializer.serializedSize(t.replication, version) +
                    TypeSizes.sizeof(t.durableWrites) +
                    (version.isAtLeast(Version.V8) ? TypeSizes.sizeof(t.comment) : 0) +
                    (version.isAtLeast(Version.V8) ? TypeSizes.sizeof(t.securityLabel) : 0) +
diff --git a/src/java/org/apache/cassandra/schema/ReplicationType.java b/src/java/org/apache/cassandra/schema/ReplicationType.java
new file mode 100644
index 0000000000..575e9b7f78
--- /dev/null
+++ b/src/java/org/apache/cassandra/schema/ReplicationType.java
@@ -0,0 +1,92 @@
+/*
+ * 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.schema;
+
+import java.io.IOException;
+
+import org.apache.cassandra.db.TypeSizes;
+import org.apache.cassandra.io.util.DataInputPlus;
+import org.apache.cassandra.io.util.DataOutputPlus;
+import org.apache.cassandra.tcm.serialization.MetadataSerializer;
+import org.apache.cassandra.tcm.serialization.Version;
+
+public enum ReplicationType
+{
+    untracked, tracked;
+
+    public static final MetadataSerializer<ReplicationType> serializer = new MetadataSerializer<>()
+    {
+        @Override
+        public void serialize(ReplicationType t, DataOutputPlus out, Version version) throws IOException
+        {
+            if (version.isBefore(Version.V9))
+                return;
+
+            switch (t)
+            {
+                case untracked:
+                    out.writeByte(0);
+                    break;
+                case tracked:
+                    out.writeByte(1);
+                    break;
+                default:
+                    throw new IllegalArgumentException("Unsupported replication type: " + t);
+            }
+        }
+
+        @Override
+        public ReplicationType deserialize(DataInputPlus in, Version version) throws IOException
+        {
+            if (version.isBefore(Version.V9))
+                return untracked;
+
+            byte t = in.readByte();
+
+            switch (t)
+            {
+                case 0:
+                    return untracked;
+                case 1:
+                    return tracked;
+                default:
+                    throw new IllegalArgumentException("Unsupported replication type: " + t);
+            }
+        }
+
+        @Override
+        public long serializedSize(ReplicationType t, Version version)
+        {
+            if (version.isBefore(Version.V9))
+                return 0;
+            return TypeSizes.BYTE_SIZE;
+        }
+    };
+
+    public boolean isTracked()
+    {
+        return this == tracked;
+    }
+
+    // FIXME: used in lieu of adding support for tracked reads in parameterized tests, fix usages of this method
+    public static ReplicationType[] fixmeValues()
+    {
+        return new ReplicationType[]{ untracked };
+    }
+}
diff --git a/src/java/org/apache/cassandra/schema/SchemaKeyspace.java b/src/java/org/apache/cassandra/schema/SchemaKeyspace.java
index f8057ed8b7..a0934755ee 100644
--- a/src/java/org/apache/cassandra/schema/SchemaKeyspace.java
+++ b/src/java/org/apache/cassandra/schema/SchemaKeyspace.java
@@ -82,6 +82,7 @@ import org.apache.cassandra.db.rows.RowIterator;
 import org.apache.cassandra.db.rows.RowIterators;
 import org.apache.cassandra.db.rows.UnfilteredRowIterator;
 import org.apache.cassandra.exceptions.InvalidRequestException;
+import org.apache.cassandra.replication.MutationId;
 import org.apache.cassandra.schema.ColumnMetadata.ClusteringOrder;
 import org.apache.cassandra.schema.Keyspaces.KeyspacesDiff;
 import org.apache.cassandra.service.accord.fastpath.FastPathStrategy;
@@ -145,6 +146,7 @@ public final class SchemaKeyspace
               "CREATE TABLE %s ("
               + "keyspace_name text,"
               + "durable_writes boolean,"
+              + "replication_type text,"
               + "replication frozen<map<text, text>>,"
               + "fast_path frozen<map<text, text>>,"
               + "PRIMARY KEY ((keyspace_name)))");
@@ -488,7 +490,7 @@ public final class SchemaKeyspace
                         continue;
 
                     DecoratedKey key = partition.partitionKey();
-                    Mutation.PartitionUpdateCollector puCollector = mutationMap.computeIfAbsent(key, k -> new Mutation.PartitionUpdateCollector(SchemaConstants.SCHEMA_KEYSPACE_NAME, key));
+                    Mutation.PartitionUpdateCollector puCollector = mutationMap.computeIfAbsent(key, k -> new Mutation.PartitionUpdateCollector(MutationId.none(), SchemaConstants.SCHEMA_KEYSPACE_NAME, key));
                     puCollector.add(makeUpdateForSchema(partition, cmd.columnFilter()).withOnlyPresentColumns());
                 }
             }
@@ -537,12 +539,13 @@ public final class SchemaKeyspace
 
     private static Mutation.SimpleBuilder makeCreateKeyspaceMutation(String name, KeyspaceParams params, long timestamp)
     {
-        Mutation.SimpleBuilder builder = Mutation.simpleBuilder(Keyspaces.keyspace, decorate(Keyspaces, name))
+        Mutation.SimpleBuilder builder = Mutation.simpleBuilder(MutationId.none(), Keyspaces.keyspace, decorate(Keyspaces, name))
                                                  .timestamp(timestamp);
 
         builder.update(Keyspaces)
                .row()
                .add(KeyspaceParams.Option.DURABLE_WRITES.toString(), params.durableWrites)
+               .add(KeyspaceParams.Option.REPLICATION_TYPE.toString(), params.replicationType.toString())
                .add(KeyspaceParams.Option.REPLICATION.toString(),
                     (params.replication.isMeta() ? params.replication.asNonMeta() : params.replication).asMap())
                .add(KeyspaceParams.Option.FAST_PATH.toString(), params.fastPath.asMap());
@@ -566,7 +569,7 @@ public final class SchemaKeyspace
 
     private static Mutation.SimpleBuilder makeDropKeyspaceMutation(KeyspaceMetadata keyspace, long timestamp)
     {
-        Mutation.SimpleBuilder builder = Mutation.simpleBuilder(SchemaConstants.SCHEMA_KEYSPACE_NAME, decorate(Keyspaces, keyspace.name))
+        Mutation.SimpleBuilder builder = Mutation.simpleBuilder(MutationId.none(), SchemaConstants.SCHEMA_KEYSPACE_NAME, decorate(Keyspaces, keyspace.name))
                                                  .timestamp(timestamp);
 
         for (TableMetadata schemaTable : ALL_TABLE_METADATA)
@@ -1030,12 +1033,24 @@ public final class SchemaKeyspace
 
         UntypedResultSet.Row row = query(query, keyspaceName).one();
         boolean durableWrites = row.getBoolean(KeyspaceParams.Option.DURABLE_WRITES.toString());
+
+        ReplicationType replicationType;
+        if (row.has(KeyspaceParams.Option.REPLICATION_TYPE.toString()))
+        {
+            String replicationTypeName = row.getString(KeyspaceParams.Option.REPLICATION_TYPE.toString());
+            replicationType = replicationTypeName != null ? ReplicationType.valueOf(replicationTypeName) : ReplicationType.untracked;
+        }
+        else
+        {
+            replicationType = ReplicationType.untracked;
+        }
+
         Map<String, String> replication = row.getFrozenTextMap(KeyspaceParams.Option.REPLICATION.toString());
         Map<String, String> fastPath = row.getFrozenTextMap(KeyspaceParams.Option.FAST_PATH.toString());
-        KeyspaceParams params = KeyspaceParams.create(durableWrites, replication, fastPath);
+        KeyspaceParams params = KeyspaceParams.create(durableWrites, replication, fastPath, replicationType);
 
         if (keyspaceName.equals(SchemaConstants.METADATA_KEYSPACE_NAME))
-            params = new KeyspaceParams(params.durableWrites, params.replication.asMeta(), FastPathStrategy.simple());
+            params = new KeyspaceParams(params.durableWrites, params.replication.asMeta(), FastPathStrategy.simple(), ReplicationType.untracked);
 
         return params;
     }
diff --git a/src/java/org/apache/cassandra/schema/TableMetadata.java b/src/java/org/apache/cassandra/schema/TableMetadata.java
index 9589062c8b..a22ccfebb1 100644
--- a/src/java/org/apache/cassandra/schema/TableMetadata.java
+++ b/src/java/org/apache/cassandra/schema/TableMetadata.java
@@ -185,6 +185,7 @@ public class TableMetadata implements SchemaElement
     public final IPartitioner partitioner;
     public final Kind kind;
     public final TableParams params;
+    public final ReplicationType keyspaceReplicationType;
     public final ImmutableSet<Flag> flags;
 
     @Nullable
@@ -233,6 +234,7 @@ public class TableMetadata implements SchemaElement
         partitioner = builder.partitioner;
         kind = builder.kind;
         params = builder.params.build();
+        keyspaceReplicationType = builder.keyspaceReplicationType;
 
         indexName = kind == Kind.INDEX ? name.substring(name.indexOf('.') + 1) : null;
 
@@ -316,6 +318,7 @@ public class TableMetadata implements SchemaElement
                .partitioner(partitioner)
                .kind(kind)
                .params(params)
+               .keyspaceReplicationType(keyspaceReplicationType)
                .flags(flags)
                .addColumns(columns())
                .droppedColumns(droppedColumns)
@@ -329,6 +332,11 @@ public class TableMetadata implements SchemaElement
         return kind == Kind.INDEX;
     }
 
+    public TableMetadata withKeyspaceReplicationType(ReplicationType type)
+    {
+        return unbuild().keyspaceReplicationType(type).build();
+    }
+
     public TableMetadata withSwapped(TableParams params)
     {
         return unbuild().params(params).build();
@@ -364,6 +372,13 @@ public class TableMetadata implements SchemaElement
         return kind == Kind.VIRTUAL;
     }
 
+    public ReplicationType replicationType()
+    {
+        if (kind != Kind.REGULAR)
+            return ReplicationType.untracked;
+        return keyspaceReplicationType;
+    }
+
     public Optional<String> indexName()
     {
         return Optional.ofNullable(indexName);
@@ -975,6 +990,7 @@ public class TableMetadata implements SchemaElement
         private IPartitioner partitioner;
         private Kind kind = Kind.REGULAR;
         private TableParams.Builder params = TableParams.builder();
+        private ReplicationType keyspaceReplicationType = ReplicationType.untracked;
 
         // See the comment on Flag.COMPOUND definition for why we (still) inconditionally add this flag.
         private Set<Flag> flags = EnumSet.of(Flag.COMPOUND);
@@ -1077,6 +1093,12 @@ public class TableMetadata implements SchemaElement
             return new ColumnMetadata(prev.ksName, prev.cfName, prev.name, prev.type, uniqueId, prev.position(), prev.kind, prev.getMask(), prev.getColumnConstraints(), prev.comment, prev.securityLabel);
         }
 
+        public Builder keyspaceReplicationType(ReplicationType type)
+        {
+            keyspaceReplicationType = type;
+            return this;
+        }
+
         public Builder id(TableId val)
         {
             id = val;
@@ -1102,6 +1124,8 @@ public class TableMetadata implements SchemaElement
 
         public Builder kind(Kind val)
         {
+            if (val != Kind.REGULAR)
+                keyspaceReplicationType = null;
             kind = val;
             return this;
         }
diff --git a/src/java/org/apache/cassandra/schema/Tables.java b/src/java/org/apache/cassandra/schema/Tables.java
index f0e7ad03e5..9a576f6c49 100644
--- a/src/java/org/apache/cassandra/schema/Tables.java
+++ b/src/java/org/apache/cassandra/schema/Tables.java
@@ -192,6 +192,13 @@ public final class Tables implements Iterable<TableMetadata>
              : this;
     }
 
+    public Tables withKeyspaceReplicationType(ReplicationType type)
+    {
+        return any(this, t -> t.keyspaceReplicationType != type)
+               ? builder().add(transform(this, t -> t.withKeyspaceReplicationType(type))).build()
+               : this;
+    }
+
     MapDifference<String, TableMetadata> indexesDiff(Tables other)
     {
         Map<String, TableMetadata> thisIndexTables = new HashMap<>();
diff --git a/src/java/org/apache/cassandra/service/CassandraDaemon.java b/src/java/org/apache/cassandra/service/CassandraDaemon.java
index 4a5c2f1d9d..9a40915a60 100644
--- a/src/java/org/apache/cassandra/service/CassandraDaemon.java
+++ b/src/java/org/apache/cassandra/service/CassandraDaemon.java
@@ -80,6 +80,7 @@ import org.apache.cassandra.io.util.File;
 import org.apache.cassandra.io.util.FileUtils;
 import org.apache.cassandra.locator.InetAddressAndPort;
 import org.apache.cassandra.locator.Locator;
+import org.apache.cassandra.replication.MutationJournal;
 import org.apache.cassandra.metrics.CassandraMetricsRegistry;
 import org.apache.cassandra.metrics.DefaultNameFactory;
 import org.apache.cassandra.net.StartupClusterConnectivityChecker;
@@ -271,6 +272,7 @@ public class CassandraDaemon
 
         Keyspace.setInitialized();
         CommitLog.instance.start();
+        MutationJournal.instance.start();
 
         SnapshotManager.instance.start(false);
         SnapshotManager.instance.clearExpiredSnapshots();
diff --git a/src/java/org/apache/cassandra/service/StorageProxy.java b/src/java/org/apache/cassandra/service/StorageProxy.java
index cc97da02aa..78f67ced46 100644
--- a/src/java/org/apache/cassandra/service/StorageProxy.java
+++ b/src/java/org/apache/cassandra/service/StorageProxy.java
@@ -134,6 +134,7 @@ import org.apache.cassandra.net.MessageFlag;
 import org.apache.cassandra.net.MessagingService;
 import org.apache.cassandra.net.RequestCallback;
 import org.apache.cassandra.net.Verb;
+import org.apache.cassandra.replication.TrackedWriteRequest;
 import org.apache.cassandra.schema.PartitionDenylist;
 import org.apache.cassandra.schema.Schema;
 import org.apache.cassandra.schema.SchemaConstants;
@@ -173,6 +174,7 @@ import org.apache.cassandra.service.reads.ReadCallback;
 import org.apache.cassandra.service.reads.ReadCoordinator;
 import org.apache.cassandra.service.reads.range.RangeCommands;
 import org.apache.cassandra.service.reads.repair.ReadRepair;
+import org.apache.cassandra.service.reads.tracked.TrackedRead;
 import org.apache.cassandra.tcm.ClusterMetadata;
 import org.apache.cassandra.tcm.membership.NodeState;
 import org.apache.cassandra.tcm.ownership.VersionedEndpoints;
@@ -194,6 +196,7 @@ import org.apache.cassandra.utils.concurrent.UncheckedInterruptedException;
 import static accord.primitives.Txn.Kind.Read;
 import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.common.collect.Iterables.concat;
+import static java.util.Collections.singleton;
 import static java.util.concurrent.TimeUnit.MILLISECONDS;
 import static java.util.concurrent.TimeUnit.NANOSECONDS;
 import static org.apache.cassandra.db.ConsistencyLevel.SERIAL;
@@ -961,6 +964,71 @@ public class StorageProxy implements StorageProxyMBean
         });
     }
 
+    /**
+     * Performs a coordinated write with mutation tracking.
+     * Assumes that local coordinator is a replica (forwarding implementation pending).
+     *
+     * @param mutation
+     * @param consistencyLevel
+     * @param requestTime
+     */
+    public static void mutateWithTracking(Mutation mutation, ConsistencyLevel consistencyLevel, Dispatcher.RequestTime requestTime)
+    {
+        try
+        {
+            TrackedWriteRequest.perform(mutation, consistencyLevel, requestTime).get();
+        }
+        catch (WriteTimeoutException|WriteFailureException ex)
+        {
+            if (consistencyLevel == ConsistencyLevel.ANY)
+            {
+                // TODO (expected): what exactly?
+            }
+            else
+            {
+                if (ex instanceof WriteFailureException)
+                {
+                    writeMetrics.failures.mark();
+                    writeMetricsForLevel(consistencyLevel).failures.mark();
+                    WriteFailureException fe = (WriteFailureException)ex;
+                    Tracing.trace("Write failure; received {} of {} required replies, failed {} requests",
+                                  fe.received, fe.blockFor, fe.failureReasonByEndpoint.size());
+                }
+                else
+                {
+                    writeMetrics.timeouts.mark();
+                    writeMetricsForLevel(consistencyLevel).timeouts.mark();
+                    WriteTimeoutException te = (WriteTimeoutException)ex;
+                    Tracing.trace("Write timeout; received {} of {} required replies", te.received, te.blockFor);
+                }
+                throw ex;
+            }
+        }
+        catch (UnavailableException e)
+        {
+            writeMetrics.unavailables.mark();
+            writeMetricsForLevel(consistencyLevel).unavailables.mark();
+            Tracing.trace("Unavailable");
+            throw e;
+        }
+        catch (OverloadedException e)
+        {
+            writeMetrics.unavailables.mark();
+            writeMetricsForLevel(consistencyLevel).unavailables.mark();
+            Tracing.trace("Overloaded");
+            throw e;
+        }
+        finally
+        {
+            // We track latency based on request processing time, since the amount of time that request spends in the queue
+            // is not a representative metric of replica performance.
+            long latency = nanoTime() - requestTime.startedAtNanos();
+            writeMetrics.addNano(latency);
+            writeMetricsForLevel(consistencyLevel).addNano(latency);
+            updateCoordinatorWriteLatencyTableMetric(singleton(mutation), latency);
+        }
+    }
+
     /**
      * Use this method to have these Mutations applied
      * across all replicas. This method will take care
@@ -1220,11 +1288,11 @@ public class StorageProxy implements StorageProxyMBean
     }
 
     @SuppressWarnings("unchecked")
-    public static void mutateWithTriggers(List<? extends IMutation> mutations,
-                                          ConsistencyLevel consistencyLevel,
-                                          boolean mutateAtomically,
-                                          Dispatcher.RequestTime requestTime,
-                                          PreserveTimestamp preserveTimestamps)
+    public static void mutateWithoutConditions(List<? extends IMutation> mutations,
+                                               ConsistencyLevel consistencyLevel,
+                                               boolean mutateAtomically,
+                                               Dispatcher.RequestTime requestTime,
+                                               PreserveTimestamp preserveTimestamps)
     throws WriteTimeoutException, WriteFailureException, UnavailableException, OverloadedException, InvalidRequestException
     {
         if (DatabaseDescriptor.getPartitionDenylistEnabled() && DatabaseDescriptor.getDenylistWritesEnabled())
@@ -1256,7 +1324,24 @@ public class StorageProxy implements StorageProxyMBean
         long size = IMutation.dataSize(augmented != null ? augmented : mutations);
         writeMetrics.mutationSize.update(size);
         writeMetricsForLevel(consistencyLevel).mutationSize.update(size);
-        if (augmented != null || mutateAtomically || updatesView)
+
+        boolean isTracked = Schema.instance.getKeyspaceMetadata(mutations.get(0).getKeyspaceName()).params.replicationType.isTracked();
+        if (isTracked)
+        {
+            if (mutations.stream().anyMatch(m -> m instanceof CounterMutation))
+                throw new InvalidRequestException("Mutation tracking is currently unsupported with counters");
+            if (augmented != null)
+                throw new InvalidRequestException("Mutation tracking is currently unsupported with triggers");
+            if (mutateAtomically)
+                throw new InvalidRequestException("Mutation tracking is currently unsupported with logged batches");
+            if (mutations.size() > 1)
+                throw new InvalidRequestException("Mutation tracking is currently unsupported with unlogged batches");
+            if (updatesView)
+                throw new InvalidRequestException("Mutation tracking is currently unsupported with materialized views");
+
+            mutateWithTracking((Mutation) mutations.get(0), consistencyLevel, requestTime);
+        }
+        else if (augmented != null || mutateAtomically || updatesView)
             mutateAtomically(augmented != null ? augmented : (List<Mutation>)mutations, consistencyLevel, updatesView, requestTime);
         else
             dispatchMutationsWithRetryOnDifferentSystem(mutations, consistencyLevel, requestTime, preserveTimestamps);
@@ -2628,6 +2713,30 @@ public class StorageProxy implements StorageProxyMBean
         };
     }
 
+    private static PartitionIterator fetchRowsTracked(List<SinglePartitionReadCommand> commands,
+                                                      ConsistencyLevel consistencyLevel,
+                                                      Dispatcher.RequestTime requestTime)
+    {
+        int cmdCount = commands.size();
+        TrackedRead.Partition[] reads = new TrackedRead.Partition[cmdCount];
+        ClusterMetadata metadata = ClusterMetadata.current();
+
+        for (int i=0; i<cmdCount; i++)
+            reads[i] = TrackedRead.Partition.create(metadata, commands.get(i), consistencyLevel);
+
+        for (TrackedRead.Partition read : reads)
+            read.start(requestTime);
+
+        if (cmdCount == 1)
+            return reads[0].awaitResults();
+
+        List<PartitionIterator> iterators = new ArrayList<>(cmdCount);
+        for (TrackedRead.Partition read : reads)
+            iterators.add(read.awaitResults());
+
+        return PartitionIterators.concat(iterators);
+    }
+
     /**
      * This function executes local and remote reads, and blocks for the results:
      *
@@ -2642,10 +2751,10 @@ public class StorageProxy implements StorageProxyMBean
      * This should not be called directly because it bypasses statistics and error handling. It is public
      * so it can be used by Accord to fetch rows and the statistics will be tracked by Accord.
      */
-    public static PartitionIterator fetchRows(List<SinglePartitionReadCommand> commands,
-                                               ConsistencyLevel consistencyLevel,
-                                               ReadCoordinator coordinator,
-                                               Dispatcher.RequestTime requestTime)
+    public static PartitionIterator fetchRowsUntracked(List<SinglePartitionReadCommand> commands,
+                                                       ConsistencyLevel consistencyLevel,
+                                                       ReadCoordinator coordinator,
+                                                       Dispatcher.RequestTime requestTime)
     throws UnavailableException, ReadFailureException, ReadTimeoutException
     {
         int cmdCount = commands.size();
@@ -2714,6 +2823,22 @@ public class StorageProxy implements StorageProxyMBean
         return concatAndBlockOnRepair(results, repairs);
     }
 
+    private static PartitionIterator fetchRows(List<SinglePartitionReadCommand> commands,
+                                               ConsistencyLevel consistencyLevel,
+                                               ReadCoordinator coordinator,
+                                               Dispatcher.RequestTime requestTime)
+    {
+        if (commands.get(0).metadata().replicationType().isTracked())
+        {
+            return fetchRowsTracked(commands, consistencyLevel, requestTime);
+        }
+        else
+        {
+            return fetchRowsUntracked(commands, consistencyLevel, coordinator, requestTime);
+        }
+
+    }
+
     public static class LocalReadRunnable extends DroppableRunnable implements RunnableDebuggableTask
     {
         private final ReadCommand command;
diff --git a/src/java/org/apache/cassandra/service/StorageService.java b/src/java/org/apache/cassandra/service/StorageService.java
index 424df75b97..1b953d7f96 100644
--- a/src/java/org/apache/cassandra/service/StorageService.java
+++ b/src/java/org/apache/cassandra/service/StorageService.java
@@ -165,6 +165,8 @@ import org.apache.cassandra.repair.RepairParallelism;
 import org.apache.cassandra.repair.SharedContext;
 import org.apache.cassandra.repair.autorepair.AutoRepair;
 import org.apache.cassandra.repair.messages.RepairOption;
+import org.apache.cassandra.replication.MutationJournal;
+import org.apache.cassandra.replication.MutationTrackingService;
 import org.apache.cassandra.schema.CompactionParams.TombstoneOption;
 import org.apache.cassandra.schema.KeyspaceMetadata;
 import org.apache.cassandra.schema.Keyspaces;
@@ -3963,6 +3965,7 @@ public class StorageService extends NotificationBroadcasterSupport implements IE
 
             SnapshotManager.instance.shutdownAndWait(1L, MINUTES);
             HintsService.instance.shutdownBlocking();
+            MutationTrackingService.instance.shutdownBlocking();
 
             // Interrupt ongoing compactions and shutdown CM to prevent further compactions.
             CompactionManager.instance.forceShutdown();
@@ -3972,6 +3975,7 @@ public class StorageService extends NotificationBroadcasterSupport implements IE
             CommitLog.instance.forceRecycleAllSegments();
 
             CommitLog.instance.shutdownBlocking();
+            MutationJournal.instance.shutdownBlocking();
 
             AutoRepair.instance.shutdownBlocking();
 
diff --git a/src/java/org/apache/cassandra/service/TrackedWriteResponseHandler.java b/src/java/org/apache/cassandra/service/TrackedWriteResponseHandler.java
new file mode 100644
index 0000000000..b224cbf1c0
--- /dev/null
+++ b/src/java/org/apache/cassandra/service/TrackedWriteResponseHandler.java
@@ -0,0 +1,117 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.cassandra.service;
+
+import org.apache.cassandra.dht.Token;
+import org.apache.cassandra.exceptions.RequestFailureReason;
+import org.apache.cassandra.exceptions.WriteFailureException;
+import org.apache.cassandra.exceptions.WriteTimeoutException;
+import org.apache.cassandra.locator.InetAddressAndPort;
+import org.apache.cassandra.net.Message;
+import org.apache.cassandra.net.NoPayload;
+import org.apache.cassandra.replication.MutationId;
+import org.apache.cassandra.replication.MutationTrackingService;
+
+public class TrackedWriteResponseHandler extends AbstractWriteResponseHandler<NoPayload>
+{
+    private final AbstractWriteResponseHandler<NoPayload> wrapped;
+
+    private final String keyspace;
+    private final Token token;
+    private final MutationId mutationId;
+
+    private TrackedWriteResponseHandler(
+        AbstractWriteResponseHandler<NoPayload> wrapped, String keyspace, Token token, MutationId mutationId)
+    {
+        super(wrapped.replicaPlan, wrapped.callback, wrapped.writeType, null, wrapped.getRequestTime());
+        this.wrapped = wrapped;
+        this.keyspace = keyspace;
+        this.token = token;
+        this.mutationId = mutationId;
+    }
+
+    public static TrackedWriteResponseHandler wrap(
+        AbstractWriteResponseHandler<NoPayload> handler, String keyspace, Token token, MutationId mutationId)
+    {
+        return new TrackedWriteResponseHandler(handler, keyspace, token, mutationId);
+    }
+
+    @Override
+    public void onResponse(Message<NoPayload> msg)
+    {
+        /* local mutations are witnessed from Keyspace.applyInternalTracked */
+        if (msg != null)
+            MutationTrackingService.instance.witnessedRemoteMutation(keyspace, token, mutationId, msg.from());
+
+        wrapped.onResponse(msg);
+    }
+
+    @Override
+    public void onFailure(InetAddressAndPort from, RequestFailureReason failureReason)
+    {
+        wrapped.onFailure(from, failureReason);
+    }
+
+    @Override
+    public boolean trackLatencyForSnitch()
+    {
+        return wrapped.trackLatencyForSnitch();
+    }
+
+    @Override
+    protected int ackCount()
+    {
+        return wrapped.ackCount();
+    }
+
+    @Override
+    public boolean invokeOnFailure()
+    {
+        return wrapped.invokeOnFailure();
+    }
+
+    @Override
+    public void get() throws WriteTimeoutException, WriteFailureException
+    {
+        wrapped.get();
+    }
+
+    @Override
+    protected int blockFor()
+    {
+        return wrapped.blockFor();
+    }
+
+    @Override
+    protected int candidateReplicaCount()
+    {
+        return wrapped.candidateReplicaCount();
+    }
+
+    @Override
+    protected boolean waitingFor(InetAddressAndPort from)
+    {
+        return wrapped.waitingFor(from);
+    }
+
+    @Override
+    protected void signal()
+    {
+        wrapped.signal();
+    }
+}
diff --git a/src/java/org/apache/cassandra/service/paxos/Commit.java b/src/java/org/apache/cassandra/service/paxos/Commit.java
index fd4102e038..896beabdf2 100644
--- a/src/java/org/apache/cassandra/service/paxos/Commit.java
+++ b/src/java/org/apache/cassandra/service/paxos/Commit.java
@@ -36,6 +36,7 @@ import org.apache.cassandra.db.rows.DeserializationHelper;
 import org.apache.cassandra.io.IVersionedSerializer;
 import org.apache.cassandra.io.util.DataInputPlus;
 import org.apache.cassandra.io.util.DataOutputPlus;
+import org.apache.cassandra.replication.MutationId;
 import org.apache.cassandra.schema.TableMetadata;
 
 import static org.apache.cassandra.db.SystemKeyspace.legacyPaxosTtlSec;
@@ -316,7 +317,15 @@ public class Commit
 
     public Mutation makeMutation()
     {
-        return new Mutation(update, PotentialTxnConflicts.ALLOW);
+        // TODO (expected): what's the best thing to do here? Deriving the mutation id from the ballot seems like the best
+        //   thing to do, like we do with the partition update timestamps, but there are caveats related to id collisions
+        //   and the assumption that a mutation id is unique amonth other mutations, which is not the case w/ paxos ballots,
+        //   which only need to be unique to a given partition key to be accepted. It may be best to keep them separate anyway,
+        //   since the reconciliation process as currently planned will not allow writes with ids before some point in time,
+        //   and there might be edge cases where that locks up paxos execution or has other side effects. The downside of
+        //   not making paxos mutation ids deterministic is that the same commit may create multiple mutation ids if a paxos
+        //   operation is not fully committed, then re-committed on repair or the next operation
+        return new Mutation(MutationId.fixme(), update, PotentialTxnConflicts.ALLOW);
     }
 
     @Override
diff --git a/src/java/org/apache/cassandra/service/reads/AbstractReadExecutor.java b/src/java/org/apache/cassandra/service/reads/AbstractReadExecutor.java
index 814aa74481..f73c33de5c 100644
--- a/src/java/org/apache/cassandra/service/reads/AbstractReadExecutor.java
+++ b/src/java/org/apache/cassandra/service/reads/AbstractReadExecutor.java
@@ -199,6 +199,7 @@ public abstract class AbstractReadExecutor
                                                        ReadCoordinator coordinator,
                                                        Dispatcher.RequestTime requestTime) throws UnavailableException
     {
+        Preconditions.checkArgument(!command.metadata().replicationType().isTracked());
         Keyspace keyspace = Keyspace.open(command.metadata().keyspace);
         ColumnFamilyStore cfs = keyspace.getColumnFamilyStore(command.metadata().id);
         SpeculativeRetryPolicy retry = cfs.metadata().params.speculativeRetry;
diff --git a/src/java/org/apache/cassandra/service/reads/range/RangeCommandIterator.java b/src/java/org/apache/cassandra/service/reads/range/RangeCommandIterator.java
index 62725a1d6d..bf9885e224 100644
--- a/src/java/org/apache/cassandra/service/reads/range/RangeCommandIterator.java
+++ b/src/java/org/apache/cassandra/service/reads/range/RangeCommandIterator.java
@@ -61,6 +61,7 @@ import org.apache.cassandra.service.reads.DataResolver;
 import org.apache.cassandra.service.reads.ReadCallback;
 import org.apache.cassandra.service.reads.ReadCoordinator;
 import org.apache.cassandra.service.reads.repair.ReadRepair;
+import org.apache.cassandra.service.reads.tracked.TrackedRead;
 import org.apache.cassandra.tcm.ClusterMetadata;
 import org.apache.cassandra.tracing.Tracing;
 import org.apache.cassandra.transport.Dispatcher;
@@ -208,7 +209,8 @@ public class RangeCommandIterator extends AbstractIterator<RowIterator> implemen
         // If enabled, request repaired data tracking info from full replicas, but
         // only if there are multiple full replicas to compare results from.
         boolean trackRepairedStatus = DatabaseDescriptor.getRepairedDataTrackingForRangeReadsEnabled()
-                                      && replicaPlan.contacts().filter(Replica::isFull).size() > 1;
+                                      && replicaPlan.contacts().filter(Replica::isFull).size() > 1
+                                      && !command.metadata().replicationType().isTracked();
 
         ReplicaPlan.SharedForRangeRead sharedReplicaPlan = ReplicaPlan.shared(replicaPlan);
         ReadRepair<EndpointsForRange, ReplicaPlan.ForRangeRead> readRepair =
@@ -329,7 +331,39 @@ public class RangeCommandIterator extends AbstractIterator<RowIterator> implemen
                                        command.metadata().enforceStrictLiveness());
     }
 
-    PartitionIterator sendNextRequests()
+    private PartitionIterator sendNextRequestsTracked()
+    {
+        List<PartitionIterator> concurrentQueries = new ArrayList<>(concurrencyFactor);
+
+        try
+        {
+            for (int i = 0; i < concurrencyFactor && replicaPlans.hasNext(); )
+            {
+                ReplicaPlan.ForRangeRead replicaPlan = replicaPlans.next();
+                PartitionRangeReadCommand rangeCommand = command.forSubRange(replicaPlan.range(), i == 0);
+
+                TrackedRead.Range read = TrackedRead.Range.create(rangeCommand, replicaPlan);
+                read.start(requestTime);
+                concurrentQueries.add(read.iterator());
+
+                // due to RangeMerger, coordinator may fetch more ranges than required by concurrency factor.
+                rangesQueried += replicaPlan.vnodeCount();
+                i += replicaPlan.vnodeCount();
+            }
+            batchesRequested++;
+        }
+        catch (Throwable t)
+        {
+            for (PartitionIterator response : concurrentQueries)
+                response.close();
+            throw t;
+        }
+        Tracing.trace("Submitted {} concurrent range requests", concurrentQueries.size());
+
+        return PartitionIterators.concat(concurrentQueries);
+    }
+
+    PartitionIterator sendNextRequestsUntracked()
     {
         List<PartitionIterator> concurrentQueries = new ArrayList<>(concurrencyFactor);
         List<ReadRepair<?, ?>> readRepairs = new ArrayList<>(concurrencyFactor);
@@ -366,13 +400,29 @@ public class RangeCommandIterator extends AbstractIterator<RowIterator> implemen
                 response.close();
             throw t;
         }
-
         Tracing.trace("Submitted {} concurrent range requests", concurrentQueries.size());
+
+        return StorageProxy.concatAndBlockOnRepair(concurrentQueries, readRepairs);
+    }
+
+    PartitionIterator sendNextRequests()
+    {
+        PartitionIterator result;
+        if (command.metadata().replicationType().isTracked())
+        {
+            result = sendNextRequestsTracked();
+
+        }
+        else
+        {
+            result = sendNextRequestsUntracked();
+        }
+
         // We want to count the results for the sake of updating the concurrency factor (see updateConcurrencyFactor)
         // but we don't want to enforce any particular limit at this point (this could break code than rely on
         // postReconciliationProcessing), hence the DataLimits.NONE.
         counter = DataLimits.NONE.newCounter(command.nowInSec(), true, command.selectsFullPartition(), enforceStrictLiveness);
-        return counter.applyTo(StorageProxy.concatAndBlockOnRepair(concurrentQueries, readRepairs));
+        return counter.applyTo(result);
     }
 
     // Wrap the iterator to retry if request routing is incorrect
diff --git a/src/java/org/apache/cassandra/service/reads/range/SingleRangeResponse.java b/src/java/org/apache/cassandra/service/reads/range/SingleRangeResponse.java
index 0274058030..a12b2fc984 100644
--- a/src/java/org/apache/cassandra/service/reads/range/SingleRangeResponse.java
+++ b/src/java/org/apache/cassandra/service/reads/range/SingleRangeResponse.java
@@ -33,7 +33,7 @@ class SingleRangeResponse extends AbstractIterator<RowIterator> implements Parti
 {
     private final DataResolver<EndpointsForRange, ReplicaPlan.ForRangeRead> resolver;
     private final ReadCallback<EndpointsForRange, ReplicaPlan.ForRangeRead> handler;
-    private final ReadRepair<EndpointsForRange, ReplicaPlan.ForRangeRead> readRepair;
+    protected final ReadRepair<EndpointsForRange, ReplicaPlan.ForRangeRead> readRepair;
 
     private PartitionIterator result;
 
diff --git a/src/java/org/apache/cassandra/service/reads/repair/BlockingReadRepairs.java b/src/java/org/apache/cassandra/service/reads/repair/BlockingReadRepairs.java
index 3986a42bf4..2dc64d101c 100644
--- a/src/java/org/apache/cassandra/service/reads/repair/BlockingReadRepairs.java
+++ b/src/java/org/apache/cassandra/service/reads/repair/BlockingReadRepairs.java
@@ -31,6 +31,7 @@ import org.apache.cassandra.db.partitions.PartitionUpdate;
 import org.apache.cassandra.exceptions.ReadTimeoutException;
 import org.apache.cassandra.locator.InetAddressAndPort;
 import org.apache.cassandra.net.MessagingService;
+import org.apache.cassandra.replication.MutationId;
 import org.apache.cassandra.schema.TableMetadata;
 import org.apache.cassandra.tracing.Tracing;
 
@@ -53,7 +54,7 @@ public class BlockingReadRepairs
             return null;
 
         DecoratedKey key = update.partitionKey();
-        Mutation mutation = new Mutation(update, potentialTxnConflicts);
+        Mutation mutation = new Mutation(MutationId.fixme(), update, potentialTxnConflicts);
         int messagingVersion = MessagingService.instance().versions.get(destination);
 
         try
diff --git a/src/java/org/apache/cassandra/service/reads/tracked/AbstractPartialTrackedRead.java b/src/java/org/apache/cassandra/service/reads/tracked/AbstractPartialTrackedRead.java
new file mode 100644
index 0000000000..0cbb3be38d
--- /dev/null
+++ b/src/java/org/apache/cassandra/service/reads/tracked/AbstractPartialTrackedRead.java
@@ -0,0 +1,154 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.service.reads.tracked;
+
+import java.util.List;
+
+import com.google.common.base.Preconditions;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import org.apache.cassandra.db.ColumnFamilyStore;
+import org.apache.cassandra.db.Mutation;
+import org.apache.cassandra.db.ReadExecutionController;
+import org.apache.cassandra.db.partitions.PartitionUpdate;
+import org.apache.cassandra.db.partitions.UnfilteredPartitionIterator;
+import org.apache.cassandra.db.partitions.UnfilteredPartitionIterators;
+import org.apache.cassandra.db.transform.RTBoundValidator;
+import org.apache.cassandra.index.Index;
+
+import static org.apache.cassandra.db.partitions.UnfilteredPartitionIterators.MergeListener.NOOP;
+
+public abstract class AbstractPartialTrackedRead implements PartialTrackedRead
+{
+    private static final Logger logger = LoggerFactory.getLogger(AbstractPartialTrackedRead.class);
+
+    private enum State
+    {
+        INITIALIZED,
+        PREPARED,
+        READING,
+        FINISHED
+    }
+
+    final ReadExecutionController executionController;
+    final Index.Searcher searcher;
+    final ColumnFamilyStore cfs;
+    final long startTimeNanos;
+    volatile State state = State.INITIALIZED;
+
+    public AbstractPartialTrackedRead(ReadExecutionController executionController, Index.Searcher searcher, ColumnFamilyStore cfs, long startTimeNanos)
+    {
+        this.executionController = executionController;
+        this.searcher = searcher;
+        this.cfs = cfs;
+        this.startTimeNanos = startTimeNanos;
+    }
+
+    @Override
+    public ReadExecutionController executionController()
+    {
+        return executionController;
+    }
+
+    @Override
+    public Index.Searcher searcher()
+    {
+        return searcher;
+    }
+
+    @Override
+    public ColumnFamilyStore cfs()
+    {
+        return cfs;
+    }
+
+    @Override
+    public long startTimeNanos()
+    {
+        return startTimeNanos;
+    }
+
+    abstract void freezeInitialData();
+
+    abstract UnfilteredPartitionIterator initialData();
+
+    abstract UnfilteredPartitionIterator augmentedData();
+
+    abstract void augmentResponse(PartitionUpdate update);
+
+    /**
+     * Implementors need to call this before returning this from createInProgressRead
+     */
+    synchronized void prepare()
+    {
+        logger.trace("Preparing read {}", this);
+        Preconditions.checkState(state == State.INITIALIZED);
+        freezeInitialData();
+        state = State.PREPARED;
+    }
+
+    @Override
+    public void augment(Mutation mutation)
+    {
+        Preconditions.checkState(state == State.PREPARED);
+        PartitionUpdate update = mutation.getPartitionUpdate(command().metadata());
+        if (update != null)
+            augmentResponse(update);
+    }
+
+    private UnfilteredPartitionIterator complete(UnfilteredPartitionIterator iterator)
+    {
+        return command().completeTrackedRead(iterator, this);
+    }
+
+    abstract CompletedRead createResult(UnfilteredPartitionIterator iterator);
+
+    @Override
+    public synchronized CompletedRead complete()
+    {
+        Preconditions.checkState(state == State.PREPARED);
+        state = State.READING;
+
+        UnfilteredPartitionIterator initial = initialData();
+        UnfilteredPartitionIterator augmented = augmentedData();
+
+        UnfilteredPartitionIterator result = augmented != null ?
+                                             UnfilteredPartitionIterators.merge(List.of(initial, augmented), NOOP) :
+                                             initial;
+
+        result = complete(result);
+        // validate that the sequence of RT markers is correct: open is followed by close, deletion times for both
+        // ends equal, and there are no dangling RT bound in any partition.
+        result = RTBoundValidator.validate(result, RTBoundValidator.Stage.PROCESSED, true);
+        return createResult(complete(result));
+    }
+
+    @Override
+    public synchronized void close()
+    {
+        if (state == State.FINISHED)
+            return;
+
+        logger.trace("Closing read {}", this);
+        executionController.close();
+        state = State.FINISHED;
+    }
+}
diff --git a/src/java/org/apache/cassandra/service/reads/tracked/PartialTrackedRangeRead.java b/src/java/org/apache/cassandra/service/reads/tracked/PartialTrackedRangeRead.java
new file mode 100644
index 0000000000..4f50442950
--- /dev/null
+++ b/src/java/org/apache/cassandra/service/reads/tracked/PartialTrackedRangeRead.java
@@ -0,0 +1,347 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.service.reads.tracked;
+
+import java.util.Iterator;
+import java.util.SortedMap;
+import java.util.TreeMap;
+
+import com.google.common.base.Preconditions;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import org.apache.cassandra.db.ColumnFamilyStore;
+import org.apache.cassandra.db.ConsistencyLevel;
+import org.apache.cassandra.db.DataRange;
+import org.apache.cassandra.db.DecoratedKey;
+import org.apache.cassandra.db.Keyspace;
+import org.apache.cassandra.db.PartitionPosition;
+import org.apache.cassandra.db.PartitionRangeReadCommand;
+import org.apache.cassandra.db.ReadCommand;
+import org.apache.cassandra.db.ReadExecutionController;
+import org.apache.cassandra.db.filter.DataLimits;
+import org.apache.cassandra.db.partitions.AbstractBTreePartition;
+import org.apache.cassandra.db.partitions.AbstractUnfilteredPartitionIterator;
+import org.apache.cassandra.db.partitions.PartitionIterator;
+import org.apache.cassandra.db.partitions.PartitionUpdate;
+import org.apache.cassandra.db.partitions.SimpleBTreePartition;
+import org.apache.cassandra.db.partitions.UnfilteredPartitionIterator;
+import org.apache.cassandra.db.partitions.UnfilteredPartitionIterators;
+import org.apache.cassandra.db.rows.UnfilteredRowIterator;
+import org.apache.cassandra.db.transform.EmptyPartitionsDiscarder;
+import org.apache.cassandra.db.transform.Transformation;
+import org.apache.cassandra.dht.AbstractBounds;
+import org.apache.cassandra.dht.ExcludingBounds;
+import org.apache.cassandra.dht.Range;
+import org.apache.cassandra.index.Index;
+import org.apache.cassandra.index.transactions.UpdateTransaction;
+import org.apache.cassandra.locator.ReplicaPlan;
+import org.apache.cassandra.locator.ReplicaPlans;
+import org.apache.cassandra.schema.TableMetadata;
+import org.apache.cassandra.tracing.Tracing;
+import org.apache.cassandra.utils.FBUtilities;
+
+public class PartialTrackedRangeRead extends AbstractPartialTrackedRead
+{
+    private static final Logger logger = LoggerFactory.getLogger(PartialTrackedRangeRead.class);
+
+    private final PartitionRangeReadCommand command;
+    private final SortedMap<DecoratedKey, SimpleBTreePartition> data = new TreeMap<>();
+    private final UnfilteredPartitionIterator initialData;
+    private final boolean enforceStrictLiveness;
+
+    // short read support
+    private DecoratedKey lastPartitionKey; // key of the last observed partition
+    private boolean partitionsFetched; // whether we've seen any new partitions since iteration start or last moreContents() call
+    private boolean initialIteratorExhausted;
+    private boolean wasAugmented;
+    AbstractBounds<PartitionPosition> followUpBounds;
+
+    public PartialTrackedRangeRead(ReadExecutionController executionController, Index.Searcher searcher, ColumnFamilyStore cfs, long startTimeNanos, PartitionRangeReadCommand command, UnfilteredPartitionIterator initialData)
+    {
+        super(executionController, searcher, cfs, startTimeNanos);
+        this.command = command;
+        this.initialData = initialData;
+        this.enforceStrictLiveness = command.metadata().enforceStrictLiveness();
+    }
+
+    public static PartialTrackedRangeRead create(ReadExecutionController executionController, Index.Searcher searcher, ColumnFamilyStore cfs, long startTimeNanos, PartitionRangeReadCommand command, UnfilteredPartitionIterator initialData)
+    {
+        PartialTrackedRangeRead read = new PartialTrackedRangeRead(executionController, searcher, cfs, startTimeNanos, command, initialData);
+        try
+        {
+            read.prepare();
+            return read;
+        }
+        catch (Throwable e)
+        {
+            read.close();
+            throw e;
+        }
+    }
+
+    @Override
+    public ReadCommand command()
+    {
+        return command;
+    }
+
+    UnfilteredRowIterator queryPartition(AbstractBTreePartition partition)
+    {
+        return partition.unfilteredIterator(command.columnFilter(),
+                                            command.requestedSlices(),
+                                            command.clusteringIndexFilter(partition.partitionKey()).isReversed());
+    }
+
+    private static void consume(UnfilteredPartitionIterator iterator)
+    {
+        while (iterator.hasNext())
+        {
+            try (UnfilteredRowIterator partition = iterator.next())
+            {
+                while (partition.hasNext())
+                    partition.next();
+            }
+        }
+    }
+
+    @Override
+    void freezeInitialData()
+    {
+        // memtable contents are frozen at read completion time, when the iterator is evaluated, not at the beginning
+        // of the read, when references to memtables and sstables are collected. Because of this, replica coordinated
+        // reads can cause read monotonicity to be broken by returning data that hasn't been replicated to at least
+        // CL other nodes via reconciliation. To prevent this, the contents of the initial iterator are materialized
+        // onto heap at partition granularity until the limits of the read are reached.
+
+        UnfilteredPartitionIterator materializer = new AbstractUnfilteredPartitionIterator()
+        {
+            @Override
+            public TableMetadata metadata()
+            {
+                return initialData.metadata();
+            }
+
+            @Override
+            public boolean hasNext()
+            {
+                return initialData.hasNext();
+            }
+
+            @Override
+            public UnfilteredRowIterator next()
+            {
+                try (UnfilteredRowIterator rowIterator = initialData.next())
+                {
+                    SimpleBTreePartition partition = augmentResponseInternal(PartitionUpdate.fromIterator(rowIterator, command.columnFilter()));
+                    lastPartitionKey = partition.partitionKey();
+                    partitionsFetched = true;
+                    return queryPartition(partition);
+                }
+            }
+
+            @Override
+            public void close()
+            {
+                super.close();
+                initialData.close();
+            }
+        };
+
+        // unmerged per-source counter
+        final DataLimits.Counter singleResultCounter = command.limits().newCounter(command.nowInSec(),
+                                                                                   false,
+                                                                                   command.selectsFullPartition(),
+                                                                                   enforceStrictLiveness);
+        try (UnfilteredPartitionIterator iterator = singleResultCounter.applyTo(materializer))
+        {
+            consume(iterator);
+        }
+        initialIteratorExhausted = command.limits().isExhausted(singleResultCounter);
+        if (partitionsFetched)
+        {
+            AbstractBounds<PartitionPosition> bounds = command.dataRange().keyRange();
+            followUpBounds = bounds.inclusiveRight()
+                             ? new Range<>(lastPartitionKey, bounds.right)
+                             : new ExcludingBounds<>(lastPartitionKey, bounds.right);
+            Preconditions.checkState(!followUpBounds.contains(lastPartitionKey));
+        }
+        wasAugmented = false;
+    }
+
+    @Override
+    UnfilteredPartitionIterator initialData()
+    {
+        Iterator<SimpleBTreePartition> iterator = data.values().iterator();
+        return new AbstractUnfilteredPartitionIterator()
+        {
+            @Override
+            public TableMetadata metadata()
+            {
+                return command.metadata();
+            }
+
+            @Override
+            public boolean hasNext()
+            {
+                return iterator.hasNext();
+            }
+
+            @Override
+            public UnfilteredRowIterator next()
+            {
+                return queryPartition(iterator.next());
+            }
+        };
+    }
+
+    @Override
+    UnfilteredPartitionIterator augmentedData()
+    {
+        return null;
+    }
+
+    private SimpleBTreePartition augmentResponseInternal(PartitionUpdate update)
+    {
+        SimpleBTreePartition partition = data.computeIfAbsent(update.partitionKey(), key -> new SimpleBTreePartition(key, update.metadata(), UpdateTransaction.NO_OP));
+        partition.update(update);
+        return partition;
+    }
+
+    @Override
+    void augmentResponse(PartitionUpdate update)
+    {
+        // if the input iterator reached the row limit, then we can't apply any augmenting mutations that are past
+        // the last materialized key. Since we wouldn't have materialized the local data for that key, applying an
+        // update would cause us to return incomplete data for it.
+        if (initialIteratorExhausted || !followUpBounds.contains(update.partitionKey()))
+            augmentResponseInternal(update);
+        wasAugmented = true;
+    }
+
+    private class ExtendingCompletedRead implements CompletedRead
+    {
+
+        final UnfilteredPartitionIterator iterator;
+        // merged end-result counter
+        final DataLimits.Counter mergedResultCounter = command.limits().newCounter(command.nowInSec(),
+                                                                                   true,
+                                                                                   command.selectsFullPartition(),
+                                                                                   enforceStrictLiveness);
+
+        public ExtendingCompletedRead(UnfilteredPartitionIterator iterator)
+        {
+            this.iterator = iterator;
+        }
+
+        @Override
+        public PartitionIterator iterator()
+        {
+            PartitionIterator filtered = UnfilteredPartitionIterators.filter(iterator, command.nowInSec());
+            PartitionIterator counted = Transformation.apply(filtered, mergedResultCounter);
+            return Transformation.apply(counted, new EmptyPartitionsDiscarder());
+        }
+
+        @Override
+        public TrackedRead<?, ?> followupRead(ConsistencyLevel consistencyLevel, long expiresAtNanos)
+        {
+            // never try to request additional partitions from replicas if our reconciled partitions are already filled to the limit
+            if (mergedResultCounter.isDone())
+                return null;
+
+            // we do not apply short read protection when we have no limits at all
+            if (command.limits().isUnlimited())
+                return null;
+
+            /*
+             * If this is a single partition read command or an (indexed) partition range read command with
+             * a partition key specified, then we can't and shouldn't try fetch more partitions.
+             */
+            if (command.isLimitedToOnePartition())
+                return null;
+
+            /*
+             * If the returned result doesn't have enough rows/partitions to satisfy even the original limit, don't ask for more.
+             *
+             * Can only take the short cut if there is no per partition limit set. Otherwise it's possible to hit false
+             * positives due to some rows being uncounted for in certain scenarios (see CASSANDRA-13911).
+             */
+            if (initialIteratorExhausted && command.limits().perPartitionCount() == DataLimits.NO_LIMIT)
+                return null;
+
+            /*
+             * Either we had an empty iterator as the initial response, or our moreContents() call got us an empty iterator.
+             * There is no point to ask the replica for more rows - it has no more in the requested range.
+             */
+            if (!partitionsFetched)
+                return null;
+            partitionsFetched = false;
+
+            /*
+             * We are going to fetch one partition at a time for thrift and potentially more for CQL.
+             * The row limit will either be set to the per partition limit - if the command has no total row limit set, or
+             * the total # of rows remaining - if it has some. If we don't grab enough rows in some of the partitions,
+             * then future ShortReadRowsProtection.moreContents() calls will fetch the missing ones.
+             */
+            int toQuery = command.limits().count() != DataLimits.NO_LIMIT
+                          ? command.limits().count() - mergedResultCounter.rowsCounted()
+                          : command.limits().perPartitionCount();
+
+            ColumnFamilyStore.metricsFor(command.metadata().id).shortReadProtectionRequests.mark();
+            Tracing.trace("Requesting {} extra rows from {} for short read protection", toQuery, FBUtilities.getBroadcastAddressAndPort());
+            logger.info("Requesting {} extra rows from {} for short read protection", toQuery, FBUtilities.getBroadcastAddressAndPort());
+
+            return makeFollowupRead(toQuery, consistencyLevel, expiresAtNanos);
+        }
+
+        private TrackedRead<?, ?> makeFollowupRead(int toQuery, ConsistencyLevel consistencyLevel, long expiresAtNanos)
+        {
+            DataLimits newLimits = command.limits().forShortReadRetry(toQuery);
+
+            DataRange newDataRange = command.dataRange().forSubRange(followUpBounds);
+
+            Keyspace keyspace = Keyspace.open(command.metadata().keyspace);
+            PartitionRangeReadCommand followUpCmd = command.withUpdatedLimitsAndDataRange(newLimits, newDataRange);
+            ReplicaPlan.ForRangeRead replicaPlan = ReplicaPlans.forRangeRead(keyspace,
+                                                                             followUpCmd.indexQueryPlan(),
+                                                                             consistencyLevel,
+                                                                             followUpCmd.dataRange().keyRange(),
+                                                                             1);
+
+            TrackedRead.Range read = TrackedRead.Range.create(followUpCmd, replicaPlan);
+            logger.trace("Short read detected, starting followup read {}", read);
+            read.start(expiresAtNanos);
+            return read;
+        }
+
+        @Override
+        public void close()
+        {
+            iterator.close();
+        }
+    }
+
+    @Override
+    CompletedRead createResult(UnfilteredPartitionIterator iterator)
+    {
+        if (wasAugmented)
+            return new ExtendingCompletedRead(iterator);
+        return CompletedRead.simple(iterator, command().nowInSec());
+    }
+}
diff --git a/src/java/org/apache/cassandra/service/reads/tracked/PartialTrackedRead.java b/src/java/org/apache/cassandra/service/reads/tracked/PartialTrackedRead.java
new file mode 100644
index 0000000000..daf222239c
--- /dev/null
+++ b/src/java/org/apache/cassandra/service/reads/tracked/PartialTrackedRead.java
@@ -0,0 +1,88 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.service.reads.tracked;
+
+import java.util.Collection;
+
+import org.apache.cassandra.db.ColumnFamilyStore;
+import org.apache.cassandra.db.ConsistencyLevel;
+import org.apache.cassandra.db.Mutation;
+import org.apache.cassandra.db.ReadCommand;
+import org.apache.cassandra.db.ReadExecutionController;
+import org.apache.cassandra.db.partitions.PartitionIterator;
+import org.apache.cassandra.db.partitions.UnfilteredPartitionIterator;
+import org.apache.cassandra.db.partitions.UnfilteredPartitionIterators;
+import org.apache.cassandra.index.Index;
+
+public interface PartialTrackedRead
+{
+    interface CompletedRead extends AutoCloseable
+    {
+        PartitionIterator iterator();
+        TrackedRead<?, ?> followupRead(ConsistencyLevel consistencyLevel, long expiresAtNanos);
+
+        @Override
+        void close();
+
+        static CompletedRead simple(UnfilteredPartitionIterator partition, long nowInSec)
+        {
+            return new CompletedRead()
+            {
+                @Override
+                public PartitionIterator iterator()
+                {
+                    return UnfilteredPartitionIterators.filter(partition, nowInSec);
+                }
+
+                @Override
+                public TrackedRead<?, ?> followupRead(ConsistencyLevel consistencyLevel, long expiresAtNanos)
+                {
+                    return null;
+                }
+
+                @Override
+                public void close()
+                {
+                    partition.close();
+                }
+            };
+        }
+    }
+
+    CompletedRead complete();
+
+    void augment(Mutation mutation);
+
+    default void augment(Collection<Mutation> mutations)
+    {
+        mutations.forEach(this::augment);
+    }
+
+    ReadExecutionController executionController();
+
+    Index.Searcher searcher();
+
+    ColumnFamilyStore cfs();
+
+    long startTimeNanos();
+
+    ReadCommand command();
+
+    void close();
+}
diff --git a/src/java/org/apache/cassandra/service/reads/tracked/PartialTrackedSinglePartitionRead.java b/src/java/org/apache/cassandra/service/reads/tracked/PartialTrackedSinglePartitionRead.java
new file mode 100644
index 0000000000..6aa64a2985
--- /dev/null
+++ b/src/java/org/apache/cassandra/service/reads/tracked/PartialTrackedSinglePartitionRead.java
@@ -0,0 +1,108 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.service.reads.tracked;
+
+import com.google.common.base.Preconditions;
+
+import org.apache.cassandra.db.ColumnFamilyStore;
+import org.apache.cassandra.db.ReadCommand;
+import org.apache.cassandra.db.ReadExecutionController;
+import org.apache.cassandra.db.SinglePartitionReadCommand;
+import org.apache.cassandra.db.Slices;
+import org.apache.cassandra.db.partitions.PartitionUpdate;
+import org.apache.cassandra.db.partitions.SimpleBTreePartition;
+import org.apache.cassandra.db.partitions.SingletonUnfilteredPartitionIterator;
+import org.apache.cassandra.db.partitions.UnfilteredPartitionIterator;
+import org.apache.cassandra.db.rows.UnfilteredRowIterator;
+import org.apache.cassandra.index.Index;
+import org.apache.cassandra.index.transactions.UpdateTransaction;
+
+public class PartialTrackedSinglePartitionRead extends AbstractPartialTrackedRead
+{
+    private final SinglePartitionReadCommand command;
+    private final UnfilteredPartitionIterator initialData;
+    private SimpleBTreePartition augmentedData;
+
+    public PartialTrackedSinglePartitionRead(ReadExecutionController executionController, Index.Searcher searcher, ColumnFamilyStore cfs, long startTimeNanos, SinglePartitionReadCommand command, UnfilteredPartitionIterator initialData)
+    {
+        super(executionController, searcher, cfs, startTimeNanos);
+        this.command = command;
+        this.initialData = initialData;
+    }
+
+    public static PartialTrackedSinglePartitionRead create(ReadExecutionController executionController, Index.Searcher searcher, ColumnFamilyStore cfs, long startTimeNanos, SinglePartitionReadCommand command, UnfilteredPartitionIterator initialData)
+    {
+        PartialTrackedSinglePartitionRead read = new PartialTrackedSinglePartitionRead(executionController, searcher, cfs, startTimeNanos, command, initialData);
+        try
+        {
+            read.prepare();
+            return read;
+        }
+        catch (Throwable e)
+        {
+            read.close();
+            throw e;
+        }
+    }
+
+    @Override
+    void freezeInitialData()
+    {
+        // the iterators from queryStorage grabs sstable references and a
+        // snapshot of the memtable partition so we don't need to do anything here
+    }
+
+    @Override
+    public ReadCommand command()
+    {
+        return command;
+    }
+
+    @Override
+    UnfilteredPartitionIterator initialData()
+    {
+        return initialData;
+    }
+
+    @Override
+    UnfilteredPartitionIterator augmentedData()
+    {
+        if (augmentedData == null)
+            return null;
+        Slices slices = command.clusteringIndexFilter().getSlices(command.metadata());
+        UnfilteredRowIterator augmented = augmentedData.unfilteredIterator(command.columnFilter(), slices, command.clusteringIndexFilter().isReversed());
+        return new SingletonUnfilteredPartitionIterator(augmented);
+    }
+
+    @Override
+    void augmentResponse(PartitionUpdate update)
+    {
+        Preconditions.checkArgument(update.partitionKey().equals(command.partitionKey()));
+        if (augmentedData == null)
+            augmentedData = new SimpleBTreePartition(command.partitionKey(), command.metadata(), UpdateTransaction.NO_OP);
+
+        augmentedData.update(update);
+    }
+
+    @Override
+    CompletedRead createResult(UnfilteredPartitionIterator iterator)
+    {
+        return CompletedRead.simple(iterator, command().nowInSec());
+    }
+}
diff --git a/src/java/org/apache/cassandra/service/reads/tracked/ReadReconcileNotify.java b/src/java/org/apache/cassandra/service/reads/tracked/ReadReconcileNotify.java
new file mode 100644
index 0000000000..3f0e3ca3a5
--- /dev/null
+++ b/src/java/org/apache/cassandra/service/reads/tracked/ReadReconcileNotify.java
@@ -0,0 +1,95 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.service.reads.tracked;
+
+import java.io.IOException;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import org.apache.cassandra.db.TypeSizes;
+import org.apache.cassandra.io.IVersionedSerializer;
+import org.apache.cassandra.io.util.DataInputPlus;
+import org.apache.cassandra.io.util.DataOutputPlus;
+import org.apache.cassandra.net.IVerbHandler;
+import org.apache.cassandra.net.Message;
+import org.apache.cassandra.replication.MutationTrackingService;
+
+/**
+ * Notifies the read coordinator that this node has received mutations
+ * from the sending node
+ */
+public class ReadReconcileNotify
+{
+    private static final Logger logger = LoggerFactory.getLogger(ReadReconcileNotify.class);
+
+    public final TrackedRead.Id readId;
+    public final int syncId;
+
+    public ReadReconcileNotify(TrackedRead.Id readId, int syncId)
+    {
+        this.readId = readId;
+        this.syncId = syncId;
+    }
+
+    @Override
+    public String toString()
+    {
+        return "ReadReconcileNotify{" +
+               "reconciliationId=" + readId +
+               ", syncId=" + syncId +
+               '}';
+    }
+
+    public static final IVerbHandler<ReadReconcileNotify> verbHandler = new IVerbHandler<ReadReconcileNotify>()
+    {
+        @Override
+        public void doVerb(Message<ReadReconcileNotify> message) throws IOException
+        {
+            ReadReconcileNotify notify = message.payload;
+            logger.trace("Received read reconcile notify from {}: {}", message.from(), notify);
+            MutationTrackingService.instance.localReads().acknowledgeSync(notify.readId, notify.syncId);
+        }
+    };
+
+    public static final IVersionedSerializer<ReadReconcileNotify> serializer = new IVersionedSerializer<ReadReconcileNotify>()
+    {
+        @Override
+        public void serialize(ReadReconcileNotify notify, DataOutputPlus out, int version) throws IOException
+        {
+            TrackedRead.Id.serializer.serialize(notify.readId, out, version);
+            out.writeInt(notify.syncId);
+        }
+
+        @Override
+        public ReadReconcileNotify deserialize(DataInputPlus in, int version) throws IOException
+        {
+            TrackedRead.Id readId = TrackedRead.Id.serializer.deserialize(in, version);
+            int syncId = in.readInt();
+            return new ReadReconcileNotify(readId, syncId);
+        }
+
+        @Override
+        public long serializedSize(ReadReconcileNotify notify, int version)
+        {
+            return TrackedRead.Id.serializer.serializedSize(notify.readId, version)
+                   + TypeSizes.sizeof(notify.syncId);
+        }
+    };
+}
diff --git a/src/java/org/apache/cassandra/service/reads/tracked/ReadReconcileReceive.java b/src/java/org/apache/cassandra/service/reads/tracked/ReadReconcileReceive.java
new file mode 100644
index 0000000000..f39f347e57
--- /dev/null
+++ b/src/java/org/apache/cassandra/service/reads/tracked/ReadReconcileReceive.java
@@ -0,0 +1,138 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.service.reads.tracked;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import org.apache.cassandra.db.Mutation;
+import org.apache.cassandra.db.TypeSizes;
+import org.apache.cassandra.io.IVersionedSerializer;
+import org.apache.cassandra.io.util.DataInputPlus;
+import org.apache.cassandra.io.util.DataOutputPlus;
+import org.apache.cassandra.locator.InetAddressAndPort;
+import org.apache.cassandra.net.IVerbHandler;
+import org.apache.cassandra.net.Message;
+import org.apache.cassandra.net.MessagingService;
+import org.apache.cassandra.net.Verb;
+import org.apache.cassandra.replication.MutationTrackingService;
+import org.apache.cassandra.utils.CollectionSerializer;
+
+import static org.apache.cassandra.locator.InetAddressAndPort.Serializer.inetAddressAndPortSerializer;
+
+public class ReadReconcileReceive
+{
+    private static final Logger logger = LoggerFactory.getLogger(ReadReconcileReceive.class);
+
+    public final TrackedRead.Id readId;
+    public final int syncId;
+    public final InetAddressAndPort coordinator;
+    public final List<Mutation> mutations;
+
+    public ReadReconcileReceive(TrackedRead.Id readId, int syncId, InetAddressAndPort coordinator, List<Mutation> mutations)
+    {
+        this.readId = readId;
+        this.syncId = syncId;
+        this.coordinator = coordinator;
+        this.mutations = mutations;
+    }
+
+    private static String mutationString(List<Mutation> mutations)
+    {
+        StringBuilder builder = new StringBuilder('[');
+        boolean isFirst = true;
+        for (Mutation mutation : mutations)
+        {
+            if (!isFirst)
+            {
+                builder.append(", ");
+            }
+            isFirst = false;
+            builder.append(mutation.id());
+        }
+        builder.append(']');
+        return builder.toString();
+    }
+
+    @Override
+    public String toString()
+    {
+        return "ReadReconcileReceive{" +
+               "reconciliationId=" + readId +
+               ", syncId=" + syncId +
+               ", coordinator=" + coordinator +
+               ", mutations=" + mutationString(mutations) +
+               '}';
+    }
+
+    public static final IVerbHandler<ReadReconcileReceive> verbHandler = new IVerbHandler<ReadReconcileReceive>()
+    {
+        @Override
+        public void doVerb(Message<ReadReconcileReceive> message) throws IOException
+        {
+            // TODO: check epoch and tokens?
+            ReadReconcileReceive receive = message.payload;
+            logger.trace("Received read reconciliation from {}: {}", message.from(), receive);
+            receive.mutations.forEach(Mutation::apply);
+
+            if (!MutationTrackingService.instance.localReads().receiveMutations(receive.readId, receive.syncId, receive.mutations))
+            {
+                // if this isn't a locally coordinated read, notify the coordinator
+                ReadReconcileNotify notify = new ReadReconcileNotify(receive.readId, receive.syncId);
+                MessagingService.instance().send(Message.out(Verb.READ_RECONCILE_NOTIFY, notify), receive.coordinator);
+            }
+        }
+    };
+
+    public static final IVersionedSerializer<ReadReconcileReceive> serializer = new IVersionedSerializer<ReadReconcileReceive>()
+    {
+        @Override
+        public void serialize(ReadReconcileReceive rcv, DataOutputPlus out, int version) throws IOException
+        {
+            TrackedRead.Id.serializer.serialize(rcv.readId, out, version);
+            out.writeInt(rcv.syncId);
+            inetAddressAndPortSerializer.serialize(rcv.coordinator, out, version);
+            CollectionSerializer.serializeCollection(Mutation.serializer, rcv.mutations, out, version);
+
+        }
+
+        @Override
+        public ReadReconcileReceive deserialize(DataInputPlus in, int version) throws IOException
+        {
+            TrackedRead.Id readId = TrackedRead.Id.serializer.deserialize(in, version);
+            int syncId = in.readInt();
+            InetAddressAndPort coordinator = inetAddressAndPortSerializer.deserialize(in, version);
+            List<Mutation> mutations = CollectionSerializer.deserializeCollection(Mutation.serializer, ArrayList::new, in, version);
+            return new ReadReconcileReceive(readId, syncId, coordinator, mutations);
+        }
+
+        @Override
+        public long serializedSize(ReadReconcileReceive t, int version)
+        {
+            return TrackedRead.Id.serializer.serializedSize(t.readId, version)
+                   + TypeSizes.sizeof(t.syncId)
+                   + inetAddressAndPortSerializer.serializedSize(t.coordinator, version)
+                   + CollectionSerializer.serializedSizeCollection(Mutation.serializer, t.mutations, version);
+        }
+    };
+}
diff --git a/src/java/org/apache/cassandra/service/reads/tracked/ReadReconcileSend.java b/src/java/org/apache/cassandra/service/reads/tracked/ReadReconcileSend.java
new file mode 100644
index 0000000000..e51ea282b0
--- /dev/null
+++ b/src/java/org/apache/cassandra/service/reads/tracked/ReadReconcileSend.java
@@ -0,0 +1,166 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.cassandra.service.reads.tracked;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import com.google.common.base.Preconditions;
+import com.google.common.collect.ImmutableList;
+
+import org.apache.cassandra.db.Mutation;
+import org.apache.cassandra.db.TypeSizes;
+import org.apache.cassandra.io.IVersionedSerializer;
+import org.apache.cassandra.io.util.DataInputPlus;
+import org.apache.cassandra.io.util.DataOutputPlus;
+import org.apache.cassandra.locator.InetAddressAndPort;
+import org.apache.cassandra.net.IVerbHandler;
+import org.apache.cassandra.net.Message;
+import org.apache.cassandra.net.MessagingService;
+import org.apache.cassandra.net.Verb;
+import org.apache.cassandra.replication.Log2OffsetsMap;
+import org.apache.cassandra.replication.MutationJournal;
+import org.apache.cassandra.utils.CollectionSerializer;
+
+/**
+ * Instructs a node to send mutations to other node
+ */
+public class ReadReconcileSend
+{
+    private static final Logger logger = LoggerFactory.getLogger(ReadReconcileSend.class);
+
+    public static class PeerSync
+    {
+        final int syncId;
+        final InetAddressAndPort to;
+        final Log2OffsetsMap.Immutable plan;
+
+        public PeerSync(int syncId, InetAddressAndPort to, Log2OffsetsMap.Immutable plan)
+        {
+            this.syncId = syncId;
+            this.to = to;
+            this.plan = plan;
+        }
+
+        @Override
+        public String toString()
+        {
+            return "PeerSync{" +
+                   "syncId=" + syncId +
+                   ", to=" + to +
+                   ", plan=" + plan +
+                   '}';
+        }
+
+        public static final IVersionedSerializer<PeerSync> serializer = new IVersionedSerializer<PeerSync>()
+        {
+            @Override
+            public void serialize(PeerSync sync, DataOutputPlus out, int version) throws IOException
+            {
+                out.writeInt(sync.syncId);
+                InetAddressAndPort.Serializer.inetAddressAndPortSerializer.serialize(sync.to, out, version);
+                Log2OffsetsMap.Immutable.serializer.serialize(sync.plan, out, version);
+            }
+
+            @Override
+            public PeerSync deserialize(DataInputPlus in, int version) throws IOException
+            {
+                return new PeerSync(in.readInt(),
+                                    InetAddressAndPort.Serializer.inetAddressAndPortSerializer.deserialize(in, version),
+                                    Log2OffsetsMap.Immutable.serializer.deserialize(in, version));
+            }
+
+            @Override
+            public long serializedSize(PeerSync sync, int version)
+            {
+                return TypeSizes.sizeof(sync.syncId)
+                       + InetAddressAndPort.Serializer.inetAddressAndPortSerializer.serializedSize(sync.to, version)
+                       + Log2OffsetsMap.Immutable.serializer.serializedSize(sync.plan, version);
+            }
+        };
+    }
+
+    public final TrackedRead.Id reconcileId;
+    public final ImmutableList<PeerSync> syncTasks;
+
+    public ReadReconcileSend(TrackedRead.Id reconcileId, List<PeerSync> syncTasks)
+    {
+        this.reconcileId = reconcileId;
+        this.syncTasks = ImmutableList.copyOf(syncTasks);
+    }
+
+    @Override
+    public String toString()
+    {
+        return "ReadReconcileSend{" +
+               "reconcileId=" + reconcileId +
+               ", syncTasks=" + syncTasks +
+               '}';
+    }
+
+    public static final IVerbHandler<ReadReconcileSend> verbHandler = new IVerbHandler<>()
+    {
+        @Override
+        public void doVerb(Message<ReadReconcileSend> message)
+        {
+            logger.trace("Received ReadReconcileSend from {}: {}", message.from(), message.payload);
+            // TODO: check epoch and tokens?
+            ReadReconcileSend payload = message.payload;
+            for (PeerSync sync : message.payload.syncTasks)
+            {
+                // TODO (expected): do not deser just to serialize again, if same messaging versions (common case)
+                // TODO (expected): don't materialize mutation ids, look up from offset collections
+                int mutationCount = sync.plan.idCount();
+                List<Mutation> mutations = new ArrayList<>(mutationCount);
+                MutationJournal.instance.readAll(sync.plan, mutations);
+                Preconditions.checkArgument(mutationCount == mutations.size());
+
+                ReadReconcileReceive receive = new ReadReconcileReceive(payload.reconcileId, sync.syncId, message.from(), mutations);
+                logger.trace("Sending {} to replica {}", receive, sync.to);
+                MessagingService.instance().send(Message.out(Verb.READ_RECONCILE_RCV, receive), sync.to);
+            }
+        }
+    };
+
+    public static final IVersionedSerializer<ReadReconcileSend> serializer = new IVersionedSerializer<>()
+    {
+        @Override
+        public void serialize(ReadReconcileSend send, DataOutputPlus out, int version) throws IOException
+        {
+            TrackedRead.Id.serializer.serialize(send.reconcileId, out, version);
+            CollectionSerializer.serializeList(PeerSync.serializer, send.syncTasks, out, version);
+        }
+
+        @Override
+        public ReadReconcileSend deserialize(DataInputPlus in, int version) throws IOException
+        {
+            TrackedRead.Id readId = TrackedRead.Id.serializer.deserialize(in, version);
+            List<PeerSync> syncTasks = CollectionSerializer.deserializeCollection(PeerSync.serializer, ArrayList::new, in, version);
+            return new ReadReconcileSend(readId, syncTasks);
+        }
+
+        @Override
+        public long serializedSize(ReadReconcileSend send, int version)
+        {
+            return TrackedRead.Id.serializer.serializedSize(send.reconcileId, version) + CollectionSerializer.serializedSizeCollection(PeerSync.serializer, send.syncTasks, version);
+        }
+    };
+}
diff --git a/src/java/org/apache/cassandra/service/reads/tracked/TrackedDataResponse.java b/src/java/org/apache/cassandra/service/reads/tracked/TrackedDataResponse.java
new file mode 100644
index 0000000000..b55095bf02
--- /dev/null
+++ b/src/java/org/apache/cassandra/service/reads/tracked/TrackedDataResponse.java
@@ -0,0 +1,99 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.service.reads.tracked;
+
+import org.apache.cassandra.db.ReadCommand;
+import org.apache.cassandra.db.TypeSizes;
+import org.apache.cassandra.db.filter.ColumnFilter;
+import org.apache.cassandra.db.partitions.PartitionIterator;
+import org.apache.cassandra.db.partitions.PartitionIterators;
+import org.apache.cassandra.io.IVersionedSerializer;
+import org.apache.cassandra.io.util.DataInputBuffer;
+import org.apache.cassandra.io.util.DataInputPlus;
+import org.apache.cassandra.io.util.DataOutputBuffer;
+import org.apache.cassandra.io.util.DataOutputPlus;
+import org.apache.cassandra.net.MessagingService;
+import org.apache.cassandra.utils.ByteBufferUtil;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+
+public class TrackedDataResponse
+{
+    private final int serializationVersion;
+    private final ByteBuffer data;
+
+    public TrackedDataResponse(int serializationVersion, ByteBuffer data)
+    {
+        this.serializationVersion = serializationVersion;
+        this.data = data;
+    }
+
+    public static TrackedDataResponse create(PartitionIterator iter, ColumnFilter selection)
+    {
+        try (DataOutputBuffer buffer = new DataOutputBuffer())
+        {
+            PartitionIterators.Serializer.serialize(iter, selection, buffer, MessagingService.current_version);
+            return new TrackedDataResponse(MessagingService.current_version, buffer.buffer(false));
+        }
+        catch (IOException e)
+        {
+            // We're serializing in memory so this shouldn't happen
+            throw new RuntimeException(e);
+        }
+    }
+
+    public PartitionIterator makeIterator(ReadCommand command)
+    {
+        try (DataInputBuffer in = new DataInputBuffer(data, true))
+        {
+            return PartitionIterators.Serializer.deserialize(command.metadata(), command.columnFilter(), in, serializationVersion);
+        }
+        catch (IOException e)
+        {
+            // We're deserializing in memory so this shouldn't happen
+            throw new RuntimeException(e);
+        }
+    }
+
+    public static final IVersionedSerializer<TrackedDataResponse> serializer = new IVersionedSerializer<TrackedDataResponse>()
+    {
+        @Override
+        public void serialize(TrackedDataResponse response, DataOutputPlus out, int version) throws IOException
+        {
+            out.writeInt(response.serializationVersion);
+            ByteBufferUtil.writeWithVIntLength(response.data, out);
+        }
+
+        @Override
+        public TrackedDataResponse deserialize(DataInputPlus in, int version) throws IOException
+        {
+            int serializationVersion = in.readInt();
+            ByteBuffer data = ByteBufferUtil.readWithVIntLength(in);
+            return new TrackedDataResponse(serializationVersion, data);
+        }
+
+        @Override
+        public long serializedSize(TrackedDataResponse response, int version)
+        {
+            return TypeSizes.sizeof(response.serializationVersion)
+                   + ByteBufferUtil.serializedSizeWithVIntLength(response.data);
+        }
+    };
+}
diff --git a/src/java/org/apache/cassandra/service/reads/tracked/TrackedLocalReadCoordinator.java b/src/java/org/apache/cassandra/service/reads/tracked/TrackedLocalReadCoordinator.java
new file mode 100644
index 0000000000..6732adca4b
--- /dev/null
+++ b/src/java/org/apache/cassandra/service/reads/tracked/TrackedLocalReadCoordinator.java
@@ -0,0 +1,617 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.cassandra.service.reads.tracked;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Preconditions;
+import org.apache.cassandra.concurrent.Stage;
+import org.apache.cassandra.config.DatabaseDescriptor;
+import org.apache.cassandra.db.ColumnFamilyStore;
+import org.apache.cassandra.db.ConsistencyLevel;
+import org.apache.cassandra.db.Mutation;
+import org.apache.cassandra.db.ReadCommand;
+import org.apache.cassandra.db.ReadExecutionController;
+import org.apache.cassandra.db.filter.ColumnFilter;
+import org.apache.cassandra.db.partitions.PartitionIterator;
+import org.apache.cassandra.db.partitions.PartitionIterators;
+import org.apache.cassandra.locator.InetAddressAndPort;
+import org.apache.cassandra.locator.ReplicaPlan;
+import org.apache.cassandra.metrics.ReadRepairMetrics;
+import org.apache.cassandra.net.Message;
+import org.apache.cassandra.net.MessagingService;
+import org.apache.cassandra.net.Verb;
+import org.apache.cassandra.replication.Log2OffsetsMap;
+import org.apache.cassandra.replication.MutationJournal;
+import org.apache.cassandra.replication.MutationSummary;
+import org.apache.cassandra.replication.ReconciliationPlan;
+import org.apache.cassandra.replication.ShortMutationId;
+import org.apache.cassandra.utils.Clock;
+import org.apache.cassandra.utils.FBUtilities;
+import org.apache.cassandra.utils.concurrent.AbstractFuture;
+import org.apache.cassandra.utils.concurrent.Accumulator;
+import org.apache.cassandra.utils.concurrent.AsyncPromise;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.*;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.TimeUnit;
+import java.util.function.BiConsumer;
+
+public class TrackedLocalReadCoordinator
+{
+    private static final Logger logger = LoggerFactory.getLogger(TrackedLocalReadCoordinator.class);
+
+    private final TrackedRead.Id readId;
+    private final AsyncPromise<TrackedDataResponse> promise;
+
+    private volatile State state;
+
+    public TrackedLocalReadCoordinator(TrackedRead.Id readId)
+    {
+        this.readId = readId;
+        this.promise = new AsyncPromise<>();
+        this.state = INITIALIZED;
+    }
+
+    public AbstractFuture<TrackedDataResponse> addCallback(BiConsumer<TrackedDataResponse, Throwable> callback)
+    {
+        return promise.addCallback(callback);
+    }
+
+    enum Status { INITIALIZED, AWAITING_READ, READING, RECONCILING, ABORTED, COMPLETED }
+
+    private static abstract class State
+    {
+        abstract Status status();
+
+        State startLocalRead(TrackedRead.Id readId, AsyncPromise<TrackedDataResponse> promise, ReadCommand command, ReplicaPlan.AbstractForRead<?, ?> replicaPlan, int[] summaryNodes, long expiresAtNanos)
+        {
+            // TODO: validate permitted state instead of just ignoring events?
+            return this;
+        }
+
+        State receiveInProgressRead(PartialTrackedRead read, MutationSummary summary)
+        {
+            throw new IllegalStateException("Received in-progress read with status " + status());
+        }
+
+        State receiveSummary(InetAddressAndPort from, MutationSummary summary)
+        {
+            logger.trace("Ignoring summary from {} with status {}", from, status());
+            return this;
+        }
+
+        State acknowledgeSync(int syncId)
+        {
+            logger.trace("Ignoring sync ack {} with status {}", syncId, status());
+            return this;
+        }
+
+        State receiveMutations(List<Mutation> mutations)
+        {
+            logger.trace("Ignoring {} mutations with status {}", mutations.size(), status());
+            return this;
+        }
+
+        State abort()
+        {
+            return ABORTED;
+        }
+
+        boolean isPurgeable(long nanoTime)
+        {
+            return false;
+        }
+
+        boolean isReading()
+        {
+            return is(Status.READING);
+        }
+
+        boolean isCompleted()
+        {
+            return is(Status.COMPLETED);
+        }
+
+        boolean is(Status status)
+        {
+            return status() == status;
+        }
+
+        @Override
+        public String toString()
+        {
+            return status().toString();
+        }
+    }
+
+    static final State INITIALIZED = new State()
+    {
+        @Override
+        Status status() { return Status.INITIALIZED; }
+
+        @Override
+        State startLocalRead(TrackedRead.Id readId, AsyncPromise<TrackedDataResponse> promise, ReadCommand command, ReplicaPlan.AbstractForRead<?, ?> replicaPlan, int[] summaryNodes, long expiresAtNanos)
+        {
+            return new Reading(readId, promise, command, replicaPlan, summaryNodes, expiresAtNanos);
+        }
+
+        @Override
+        State receiveSummary(InetAddressAndPort from, MutationSummary summary)
+        {
+            return new AwaitingRead(from, summary);
+        }
+    };
+
+    static final State ABORTED = new State()
+    {
+        @Override
+        Status status() { return Status.ABORTED; }
+
+        @Override
+        State receiveInProgressRead(PartialTrackedRead read, MutationSummary summary)
+        {
+            return this; // the read can't complete without data, but it could have been aborted in the meantime
+        }
+
+        @Override
+        boolean isPurgeable(long nanoTime) { return true; }
+    };
+
+    static final State COMPLETED = new State()
+    {
+        @Override
+        Status status() { return Status.COMPLETED; }
+
+        @Override
+        boolean isPurgeable(long nanoTime) { return true; }
+    };
+
+    private static class ReceivedSummary
+    {
+        final InetAddressAndPort from;
+        final MutationSummary summary;
+
+        ReceivedSummary(InetAddressAndPort from, MutationSummary summary)
+        {
+            this.from = from;
+            this.summary = summary;
+        }
+    }
+
+    // if we start receiving summaries before we receive the read command, they're
+    // collected here
+    private static class AwaitingRead extends State
+    {
+        private long lastUpdateNanos;
+        private final List<ReceivedSummary> summaries;
+
+        AwaitingRead(InetAddressAndPort summaryFrom, MutationSummary summary)
+        {
+             summaries = new ArrayList<>();
+             summaries.add(new ReceivedSummary(summaryFrom, summary));
+        }
+
+        @Override
+        Status status()
+        {
+            return Status.AWAITING_READ;
+        }
+
+        @Override
+        State startLocalRead(TrackedRead.Id readId, AsyncPromise<TrackedDataResponse> promise, ReadCommand command, ReplicaPlan.AbstractForRead<?, ?> replicaPlan, int[] summaryNodes, long expiresAtNanos)
+        {
+            return new Reading(readId, promise, command, replicaPlan, summaryNodes, summaries, expiresAtNanos);
+        }
+
+        @Override
+        State receiveSummary(InetAddressAndPort from, MutationSummary summary)
+        {
+            summaries.add(new ReceivedSummary(from, summary));
+            lastUpdateNanos = Clock.Global.nanoTime();
+            return this;
+        }
+
+        @Override
+        boolean isPurgeable(long nanoTime)
+        {
+            return nanoTime - lastUpdateNanos > DatabaseDescriptor.getReadRpcTimeout(TimeUnit.NANOSECONDS);
+        }
+    }
+
+    private static class Reading extends State
+    {
+        private final TrackedRead.Id readId;
+        private final AsyncPromise<TrackedDataResponse> promise;
+        private final ReadCommand command;
+        private volatile PartialTrackedRead read;
+        private final long expiresAtNanos;
+        private final ReplicaPlan.AbstractForRead<?, ?> replicaPlan;
+        private final Accumulator<ReceivedSummary> summaries;
+        private final int[] summaryNodes; // for speculating when we haven't received enough summaries
+
+        Reading(
+            TrackedRead.Id readId,
+            AsyncPromise<TrackedDataResponse> promise,
+            ReadCommand command,
+            ReplicaPlan.AbstractForRead<?, ?> replicaPlan,
+            int[] summaryNodes,
+            long expiresAtNanos)
+        {
+            this.readId = readId;
+            this.promise = promise;
+            this.expiresAtNanos = expiresAtNanos;
+            this.command = command;
+            this.replicaPlan = replicaPlan;
+            this.summaries = new Accumulator<>(replicaPlan.readCandidates().size());
+            this.summaryNodes = summaryNodes;
+        }
+
+        Reading(
+            TrackedRead.Id readId,
+            AsyncPromise<TrackedDataResponse> promise,
+            ReadCommand command,
+            ReplicaPlan.AbstractForRead<?, ?> replicaPlan,
+            int[] summaryNodes,
+            List<ReceivedSummary> summaries,
+            long expiresAtNanos)
+        {
+            this(readId, promise, command, replicaPlan, summaryNodes, expiresAtNanos);
+            for (ReceivedSummary summary : summaries) this.summaries.add(summary);
+        }
+
+        @Override
+        Status status()
+        {
+            return Status.READING;
+        }
+
+        @Override
+        boolean isPurgeable(long nanoTime)
+        {
+            return nanoTime - expiresAtNanos > 0;
+        }
+
+        private State maybeComplete()
+        {
+            if (read == null || summaries.size() < replicaPlan.readQuorum())
+                return this;
+
+            Map<InetAddressAndPort, MutationSummary> summaryMap = new HashMap<>();
+            summaries.snapshot().forEach(rc -> summaryMap.put(rc.from, rc.summary));
+
+            Map<InetAddressAndPort, ReconciliationPlan> reconciliations = ReconciliationPlan.calculateReconciliation(summaryMap);
+
+            if (reconciliations.isEmpty())
+            {
+                logger.trace("Read complete for {}", readId);
+                complete(promise, read, command.columnFilter(), replicaPlan.consistencyLevel(), expiresAtNanos);
+                return COMPLETED;
+            }
+            else
+            {
+                logger.trace("Beginning reconciliation for {}", readId);
+                Reconciling reconciling = new Reconciling(readId, promise, command, read, replicaPlan.consistencyLevel(), expiresAtNanos, reconciliations);
+                reconciling.start();  // TODO: don't do this until after the coordinator state is set to reconciling if converting to lock free
+                return reconciling;
+            }
+        }
+
+        @Override
+        State receiveInProgressRead(PartialTrackedRead read, MutationSummary summary)
+        {
+            if (this.read != null)
+                return this;
+
+            logger.trace("In progress read received for {}", readId);
+            this.read = read;
+            summaries.add(new ReceivedSummary(FBUtilities.getBroadcastAddressAndPort(), summary));
+
+            return maybeComplete();
+        }
+
+        @Override
+        State receiveSummary(InetAddressAndPort from, MutationSummary summary)
+        {
+            logger.trace("Summary received from {} for {}", from, readId);
+            summaries.add(new ReceivedSummary(from, summary));
+            return maybeComplete();
+        }
+
+        @Override
+        State abort()
+        {
+            logger.trace("Aborting read {}", readId);
+            if (read != null)
+                read.close();
+            return ABORTED;
+        }
+    }
+
+    private static class PendingSync
+    {
+        final int syncId;
+        final InetAddressAndPort from;
+        final InetAddressAndPort to;
+        final Log2OffsetsMap.Immutable plan;
+
+        PendingSync(int syncId, InetAddressAndPort from, InetAddressAndPort to, Log2OffsetsMap.Immutable plan)
+        {
+            this.syncId = syncId;
+            this.from = from;
+            this.to = to;
+            this.plan = plan;
+        }
+
+        ReadReconcileSend.PeerSync toPeerSync()
+        {
+            return new ReadReconcileSend.PeerSync(syncId, to, plan);
+        }
+    }
+
+    private static class Reconciling extends State
+    {
+        private final TrackedRead.Id readId;
+        private final AsyncPromise<TrackedDataResponse> promise;
+        private final ReadCommand command;
+        private final PartialTrackedRead read;
+        private final ConsistencyLevel consistencyLevel;
+        private final long expiresAtNanos;
+
+        final Map<InetAddressAndPort, ReconciliationPlan> plans;
+        final Log2OffsetsMap.Mutable outstandingMutations = new Log2OffsetsMap.Mutable();
+        final Map<Integer, PendingSync> pendingSync = new ConcurrentHashMap<>();
+        final int blockFor;
+
+        Reconciling(
+            TrackedRead.Id readId,
+            AsyncPromise<TrackedDataResponse> promise,
+            ReadCommand command,
+            PartialTrackedRead read,
+            ConsistencyLevel consistencyLevel,
+            long expiresAtNanos,
+            Map<InetAddressAndPort, ReconciliationPlan> plans)
+        {
+            this.readId = readId;
+            this.promise = promise;
+            this.command = command;
+            this.read = Preconditions.checkNotNull(read);
+            this.consistencyLevel = consistencyLevel;
+            this.expiresAtNanos = expiresAtNanos;
+            this.plans = plans;
+
+            int syncs = 0;
+            int nextSyncId = 0;
+            for (Map.Entry<InetAddressAndPort, ReconciliationPlan> entry : plans.entrySet())
+            {
+                InetAddressAndPort from = entry.getKey();
+                ReconciliationPlan plan = entry.getValue();
+                for (InetAddressAndPort to : plan.nodes())
+                {
+                    int syncId = nextSyncId++;
+                    PendingSync sync = new PendingSync(syncId, from, to, plan.peerReconciliation(to));
+                    pendingSync.put(syncId, sync);
+                    syncs++;
+                    if (to.equals(FBUtilities.getBroadcastAddressAndPort()))
+                    {
+                        outstandingMutations.addAll(plan.offsetsFor(to));
+                    }
+                }
+            }
+
+            if (logger.isTraceEnabled())
+                logger.trace("Reconciling {} syncs, {} mutations for {}", syncs, outstandingMutations.idCount(), readId);
+
+            this.blockFor = syncs;
+        }
+
+        @Override
+        Status status()
+        {
+            return Status.RECONCILING;
+        }
+
+        @Override
+        boolean isPurgeable(long nanoTime)
+        {
+            return nanoTime - expiresAtNanos > 0;
+        }
+
+        void start()
+        {
+            ReadRepairMetrics.trackedReconcile.mark();
+            ColumnFamilyStore.metricsFor(command.metadata().id).readRepairRequests.mark();
+
+            Map<InetAddressAndPort, List<ReadReconcileSend.PeerSync>> peerSync = new HashMap<>();
+            pendingSync.values().forEach(pending ->
+                peerSync.computeIfAbsent(pending.from, node -> new ArrayList<>()).add(pending.toPeerSync()));
+
+            for (Map.Entry<InetAddressAndPort, List<ReadReconcileSend.PeerSync>> entry : peerSync.entrySet())
+            {
+                Message<ReadReconcileSend> message = Message.out(Verb.READ_RECONCILE_SEND, new ReadReconcileSend(readId, entry.getValue()));
+                logger.trace("Sending read reconciliation for {} {} to {}", readId, message.payload, entry.getKey());
+                MessagingService.instance().send(message, entry.getKey());
+            }
+        }
+
+        private State maybeComplete()
+        {
+            if (!pendingSync.isEmpty() || !outstandingMutations.isEmpty())
+                return this;
+
+            logger.trace("Reconciliation completed for read {}", readId);
+            complete(promise, read, command.columnFilter(), consistencyLevel, expiresAtNanos);
+            return COMPLETED;
+        }
+
+        @Override
+        State acknowledgeSync(int syncId)
+        {
+            logger.trace("Reconciliation sync {} received for {}", syncId, readId);
+            pendingSync.remove(syncId);
+            return maybeComplete();
+        }
+
+        @Override
+        State receiveMutations(List<Mutation> mutations)
+        {
+            Log2OffsetsMap.Mutable received = new Log2OffsetsMap.Mutable();
+            mutations.forEach(mutation -> {
+                logger.trace("Received mutation {} for read {}", mutation.id(), readId);
+                received.add(mutation.id());
+            });
+            outstandingMutations.removeAll(received);
+
+            if (logger.isTraceEnabled())
+                logger.trace("Received {} mutations, {} mutations outstanding for {}", mutations.size(), outstandingMutations.idCount(), readId);
+            read.augment(mutations);
+            return maybeComplete();
+        }
+
+        @Override
+        State abort()
+        {
+            read.close();
+            return ABORTED;
+        }
+    }
+
+    @Override
+    public String toString()
+    {
+        return "TrackedLocalReadCoordinator{" + readId + ':' + state.status() + '}';
+    }
+
+    @VisibleForTesting
+    public static void processDelta(PartialTrackedRead read, MutationSummary initialSummary, MutationSummary secondarySummary)
+    {
+        // Compute any mutations that we could've missed during initial read execution.
+        ArrayList<ShortMutationId> delta = new ArrayList<>();
+        MutationSummary.difference(secondarySummary, initialSummary, delta);
+
+        delta.forEach(mutationId -> {
+            Mutation mutation = MutationJournal.instance.read(mutationId);
+            Preconditions.checkNotNull(mutation);
+            read.augment(mutation);
+        });
+    }
+
+    public void startLocalRead(TrackedRead.Id readId, ReadCommand command, ReplicaPlan.AbstractForRead<?, ?> replicaPlan, int[] summaryNodes, long expiresAtNanos)
+    {
+        synchronized (this)
+        {
+            if (!(state = state.startLocalRead(readId, promise, command, replicaPlan, summaryNodes, expiresAtNanos)).isReading())
+                return;
+        }
+
+        PartialTrackedRead read;
+        MutationSummary secondarySummary;
+
+        MutationSummary initialSummary = command.createMutationSummary(false);
+        ReadExecutionController controller = command.executionController(false);
+        try
+        {
+            read = command.beginTrackedRead(controller);
+            // Create another summary once initial data has been read fully. We do this to catch
+            // any mutations that may have arrived during initial read execution.
+            secondarySummary = command.createMutationSummary(true);
+            processDelta(read, initialSummary, secondarySummary);
+        }
+        catch (Exception e)
+        {
+            controller.close();
+            abort();
+            throw e;
+        }
+
+        synchronized (this)
+        {
+            state = state.receiveInProgressRead(read, secondarySummary);
+        }
+    }
+
+    private static void complete(AsyncPromise<TrackedDataResponse> promise, PartialTrackedRead read, ColumnFilter selection, ConsistencyLevel consistencyLevel, long expiresAtNanos)
+    {
+        Stage.READ.submit(() -> completeInternal(promise, read, selection, consistencyLevel, expiresAtNanos));
+    }
+
+    private static void completeInternal(AsyncPromise<TrackedDataResponse> promise, PartialTrackedRead read, ColumnFilter selection, ConsistencyLevel consistencyLevel, long expiresAtNanos)
+    {
+        try (PartialTrackedRead.CompletedRead completedRead = read.complete())
+        {
+            TrackedDataResponse response = TrackedDataResponse.create(completedRead.iterator(), selection);
+            TrackedRead<?, ?> followUp = completedRead.followupRead(consistencyLevel, expiresAtNanos);
+
+            if (followUp != null)
+            {
+                ReadCommand command = read.command();
+                followUp.future().addCallback((iterator, error) -> {
+                    if (error != null)
+                    {
+                        promise.tryFailure(error);
+                        return;
+                    }
+                    PartitionIterator previous = response.makeIterator(command);
+                    TrackedDataResponse newResponse = TrackedDataResponse.create(PartitionIterators.concat(List.of(previous, iterator)), selection);
+                    promise.trySuccess(newResponse);
+                });
+            }
+            else
+            {
+                promise.trySuccess(response);
+            }
+        }
+        catch (Exception e)
+        {
+            promise.tryFailure(e);
+            throw e;
+        }
+        finally
+        {
+            read.close();
+        }
+    }
+
+    synchronized void receiveSummary(InetAddressAndPort from, MutationSummary summary)
+    {
+        if (logger.isTraceEnabled())
+            logger.trace("Received summary {} from {}, for {}", summary, from, state);
+        state = state.receiveSummary(from, summary);
+    }
+
+    synchronized boolean acknowledgeSync(int syncId)
+    {
+        return (state = state.acknowledgeSync(syncId)).isCompleted();
+    }
+
+    synchronized boolean receiveMutations(List<Mutation> mutations)
+    {
+        return (state = state.receiveMutations(mutations)).isCompleted();
+    }
+
+    synchronized void abort()
+    {
+        state = state.abort();
+    }
+
+    boolean isTimedOutOrComplete(long nanoTime)
+    {
+        return state.isPurgeable(nanoTime);
+    }
+}
diff --git a/src/java/org/apache/cassandra/service/reads/tracked/TrackedLocalReads.java b/src/java/org/apache/cassandra/service/reads/tracked/TrackedLocalReads.java
new file mode 100644
index 0000000000..5c2417e6b5
--- /dev/null
+++ b/src/java/org/apache/cassandra/service/reads/tracked/TrackedLocalReads.java
@@ -0,0 +1,182 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.service.reads.tracked;
+
+import org.apache.cassandra.concurrent.ScheduledExecutorPlus;
+import org.apache.cassandra.concurrent.Shutdownable;
+import org.apache.cassandra.config.DatabaseDescriptor;
+import org.apache.cassandra.db.*;
+import org.apache.cassandra.locator.InetAddressAndPort;
+import org.apache.cassandra.locator.ReplicaPlan;
+import org.apache.cassandra.locator.ReplicaPlans;
+import org.apache.cassandra.service.reads.SpeculativeRetryPolicy;
+import org.apache.cassandra.tcm.ClusterMetadata;
+import org.apache.cassandra.utils.Clock;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+import java.util.concurrent.TimeUnit;
+
+import static java.util.concurrent.TimeUnit.MINUTES;
+import static java.util.concurrent.TimeUnit.NANOSECONDS;
+import static org.apache.cassandra.concurrent.ExecutorFactory.Global.executorFactory;
+import static org.apache.cassandra.concurrent.ExecutorFactory.SimulatorSemantics.NORMAL;
+
+/**
+ * Since the read reconciliations don't use 2 way callbacks, a map of active reconciliations
+ * are maintained and expired here.
+ *
+ * Borrowed heavily from RequestCallbacks
+ */
+public class TrackedLocalReads implements Shutdownable
+{
+    private static final Logger logger = LoggerFactory.getLogger(TrackedLocalReads.class);
+
+    private final ConcurrentMap<TrackedRead.Id, TrackedLocalReadCoordinator> reads = new ConcurrentHashMap<>();
+    private final ScheduledExecutorPlus executor = executorFactory().scheduled("Reconciliation-Map-Reaper", NORMAL);
+
+    public TrackedLocalReads()
+    {
+        long expirationInterval = defaultExpirationInterval();
+        executor.scheduleWithFixedDelay(this::expire, expirationInterval, expirationInterval, NANOSECONDS);
+    }
+
+    private void expire()
+    {
+        long start = Clock.Global.nanoTime();
+        int n = 0;
+        for (Map.Entry<TrackedRead.Id, TrackedLocalReadCoordinator> entry : reads.entrySet())
+        {
+            TrackedLocalReadCoordinator read = entry.getValue();
+            if (read.isTimedOutOrComplete(start))
+            {
+                read.abort();
+                if (reads.remove(entry.getKey(), entry.getValue()))
+                    n++;
+            }
+        }
+        if (n > 0)
+            logger.trace("Expired {} entries", n);
+    }
+
+    private TrackedLocalReadCoordinator getOrCreate(TrackedRead.Id id)
+    {
+        return reads.computeIfAbsent(id, TrackedLocalReadCoordinator::new);
+    }
+
+    public void receiveSummary(InetAddressAndPort from, TrackedSummaryResponse summary)
+    {
+        getOrCreate(summary.readId()).receiveSummary(from, summary.summary());
+    }
+
+    public TrackedLocalReadCoordinator beginRead(TrackedRead.Id readId, ClusterMetadata metadata, ReadCommand command, ConsistencyLevel consistencyLevel, int[] summaryNodes, long expiresAtNanos)
+    {
+        Keyspace keyspace = Keyspace.open(command.metadata().keyspace);
+        ColumnFamilyStore cfs = keyspace.getColumnFamilyStore(command.metadata().id);
+        SpeculativeRetryPolicy retry = cfs.metadata().params.speculativeRetry;
+        ReplicaPlan.AbstractForRead<?, ?> replicaPlan;
+        if (command instanceof SinglePartitionReadCommand)
+        {
+            replicaPlan = ReplicaPlans.forRead(metadata,
+                                               keyspace,
+                                               ((SinglePartitionReadCommand) command).partitionKey().getToken(),
+                                               command.indexQueryPlan(),
+                                               consistencyLevel,
+                                               retry);
+        }
+        else
+        {
+            // TODO: confirm range we're reading doesn't span multiple replica sets
+            replicaPlan = ReplicaPlans.forRangeRead(keyspace,
+                                                    command.indexQueryPlan(),
+                                                    consistencyLevel,
+                                                    command.dataRange().keyRange(),
+                                                    1);
+        }
+        // TODO: confirm all summaryNodes are present in the replica plan
+
+        TrackedLocalReadCoordinator coordinator = getOrCreate(readId);
+        coordinator.startLocalRead(readId, command, replicaPlan, summaryNodes, expiresAtNanos);
+        return coordinator;
+    }
+
+    public void acknowledgeSync(TrackedRead.Id readId, int syncId)
+    {
+        TrackedLocalReadCoordinator read = reads.get(readId);
+        if (read == null)
+            return;
+
+        if (read.acknowledgeSync(syncId))
+            reads.remove(readId);
+    }
+
+    public boolean receiveMutations(TrackedRead.Id readId, int syncId, List<Mutation> mutations)
+    {
+        TrackedLocalReadCoordinator read = reads.get(readId);
+        if (read == null)
+            return false;
+
+        read.receiveMutations(mutations);
+        if (read.acknowledgeSync(syncId))
+            reads.remove(readId);
+        return true;
+    }
+
+    public static long defaultExpirationInterval()
+    {
+        return DatabaseDescriptor.getMinRpcTimeout(NANOSECONDS) / 2;
+    }
+
+    @Override
+    public void shutdown()
+    {
+        executor.shutdown();
+    }
... 5541 lines suppressed ...


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