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

iamaleksey pushed a commit to branch 20386
in repository https://gitbox.apache.org/repos/asf/cassandra.git


The following commit(s) were added to refs/heads/20386 by this push:
     new 33a32e3725 Add an explicit UNLOCK TCM step
33a32e3725 is described below

commit 33a32e372563489e4ac172db6b39d5ffb10d16ae
Author: Aleksey Yeshchenko <[email protected]>
AuthorDate: Mon Aug 24 13:15:05 2026 +0100

    Add an explicit UNLOCK TCM step
    
    Allow shard sealing after FINISH but while still having the ranges locked
---
 .../cassandra/replication/SealingCoordinator.java  |  94 +++++++++++-----
 .../org/apache/cassandra/replication/Shard.java    |   3 +-
 .../org/apache/cassandra/tcm/Transformation.java   |   9 ++
 .../cassandra/tcm/sequences/BootstrapAndJoin.java  |  44 +++++++-
 .../tcm/sequences/BootstrapAndReplace.java         |  53 +++++++--
 .../org/apache/cassandra/tcm/sequences/Move.java   |  34 +++++-
 .../tcm/sequences/UnbootstrapAndLeave.java         |  54 +++++++---
 .../cassandra/tcm/serialization/Version.java       |   2 +
 .../tcm/transformations/ApplyPlacementDeltas.java  |   5 +
 .../cassandra/tcm/transformations/PrepareJoin.java |  32 ++++--
 .../tcm/transformations/PrepareLeave.java          |  48 ++++++++-
 .../cassandra/tcm/transformations/PrepareMove.java |  30 ++++--
 .../tcm/transformations/PrepareReplace.java        |  54 +++++++++-
 .../tcm/transformations/UnlockSequence.java        | 120 +++++++++++++++++++++
 .../cassandra/distributed/test/MoveTest.java       |   2 +-
 .../test/log/ClusterMetadataTestHelper.java        |  16 +++
 .../distributed/test/log/MetadataKeysTest.java     |  17 +++
 .../cassandra/simulator/cluster/OnClusterJoin.java |   7 ++
 .../simulator/cluster/OnClusterLeave.java          |   7 ++
 .../simulator/cluster/OnClusterReplace.java        |   7 ++
 .../tcm/transformations/EventsMetadataTest.java    |   3 +
 21 files changed, 563 insertions(+), 78 deletions(-)

diff --git a/src/java/org/apache/cassandra/replication/SealingCoordinator.java 
b/src/java/org/apache/cassandra/replication/SealingCoordinator.java
index 994c5c771d..cc19be245a 100644
--- a/src/java/org/apache/cassandra/replication/SealingCoordinator.java
+++ b/src/java/org/apache/cassandra/replication/SealingCoordinator.java
@@ -145,17 +145,31 @@ public final class SealingCoordinator
      * 5. FINISH_JOIN at e4:
      *
      *      (10,20] | {n20,n30,n40}     | e0 | SEALED
-     *    S (10,20] | {n20,n30,n35,n40} | e2 | SEALED
+     *      (10,20] | {n20,n30,n35,n40} | e2 | ACTIVE
      *    + (10,20] | {n20,n30,n35}     | e4 | ACTIVE
      *      (20,30] | {n30,n40,n50}     | e0 | SEALED
-     *    S (20,30] | {n30,n35,n40,n50} | e2 | SEALED
+     *      (20,30] | {n30,n35,n40,n50} | e2 | ACTIVE
      *    + (20,30] | {n30,n35,n40}     | e4 | ACTIVE
      *      (30,40] | {n40,n50,n60}     | e0 | SEALED
      *      (30,35] | {n40,n50,n60}     | e1 | SEALED
-     *    S (30,35] | {n35,n40,n50,n60} | e2 | SEALED
+     *      (30,35] | {n35,n40,n50,n60} | e2 | ACTIVE
      *    + (30,35] | {n35,n40,n50}     | e4 | ACTIVE
      *      (35,40] | {n40,n50,n60}     | e1 | ACTIVE
      *
+     * 6. UNLOCK_SEQUENCE:
+     *
+     *      (10,20] | {n20,n30,n40}     | e0 | SEALED
+     *    S (10,20] | {n20,n30,n35,n40} | e2 | SEALED
+     *      (10,20] | {n20,n30,n35}     | e4 | ACTIVE
+     *      (20,30] | {n30,n40,n50}     | e0 | SEALED
+     *    S (20,30] | {n30,n35,n40,n50} | e2 | SEALED
+     *      (20,30] | {n30,n35,n40}     | e4 | ACTIVE
+     *      (30,40] | {n40,n50,n60}     | e0 | SEALED
+     *      (30,35] | {n40,n50,n60}     | e1 | SEALED
+     *    S (30,35] | {n35,n40,n50,n60} | e2 | SEALED
+     *      (30,35] | {n35,n40,n50}     | e4 | ACTIVE
+     *      (35,40] | {n40,n50,n60}     | e1 | ACTIVE
+     *
      * [*] At this stage, after sealing the shards, we stream the sstables.
      * All the offsets belonging to these sealed shards get stripped from
      * stats component of streamed sstables. Otherwise, the new node
@@ -172,16 +186,11 @@ public final class SealingCoordinator
     }
 
     /**
-     * Seal the intermediate shards that were created during START_JOIN.
-     * TODO (required): this MUST eventually run while the affected ranges are 
still locked - between the
-     *      removeFromWrites placement change and the range unlock. 
FinishJoin/FinishReplace currently apply
-     *      removeFromWrites and the unlock atomically in a single 
transformation, so there is no committed
-     *      dropped-but-locked epoch to hook. Until then, this runs just after 
FINISH_JOIN/FINISH_REPLACE,
-     *      when the ranges are already unlocked. Left to implement for the 
unhappy path (AY).
+     * Seal the intermediate, over-replicated shards created during START_JOIN 
and obsoleted by FINISH_JOIN.
      */
-    public static void sealShardsAtFinishJoin(ClusterMetadata metadata, 
PlacementDeltas finishDelta)
+    public static void sealShardsAtFinishJoin(ClusterMetadata metadata, long 
finishEpoch, PlacementDeltas finishDelta)
     {
-        seal(discoverShardsAtFinishJoinOrReplace(metadata, finishDelta, null));
+        seal(discoverShardsAtFinishJoinOrReplace(metadata, finishEpoch, 
finishDelta, null));
     }
 
     /*
@@ -219,15 +228,27 @@ public final class SealingCoordinator
      * 4. FINISH_REPLACE at e3:
      *
      *      (10,20] | {n20,n30,n40}     | e0 | SEALED
-     *    S (10,20] | {n20,n30,n40,$n40}| e1 | SEALED
+     *      (10,20] | {n20,n30,n40,$n40}| e1 | ACTIVE
      *    + (10,20] | {n20,n30,$n40}    | e3 | ACTIVE
      *      (20,30] | {n30,n40,n50}     | e0 | SEALED
-     *    S (20,30] | {n30,n40,$n40,n50}| e1 | SEALED
+     *      (20,30] | {n30,n40,$n40,n50}| e1 | ACTIVE
      *    + (20,30] | {n30,$n40,n50}    | e3 | ACTIVE
      *      (30,40] | {n40,n50,n60}     | e0 | SEALED
-     *    S (30,40] | {n40,$n40,n50,n60}| e1 | SEALED
+     *      (30,40] | {n40,$n40,n50,n60}| e1 | ACTIVE
      *    + (30,40] | {$n40,n50,n60}    | e3 | ACTIVE
      *
+     * 5. UNLOCK_SEQUENCE:
+     *
+     *      (10,20] | {n20,n30,n40}     | e0 | SEALED
+     *    S (10,20] | {n20,n30,n40,$n40}| e1 | SEALED
+     *      (10,20] | {n20,n30,$n40}    | e3 | ACTIVE
+     *      (20,30] | {n30,n40,n50}     | e0 | SEALED
+     *    S (20,30] | {n30,n40,$n40,n50}| e1 | SEALED
+     *      (20,30] | {n30,$n40,n50}    | e3 | ACTIVE
+     *      (30,40] | {n40,n50,n60}     | e0 | SEALED
+     *    S (30,40] | {n40,$n40,n50,n60}| e1 | SEALED
+     *      (30,40] | {$n40,n50,n60}    | e3 | ACTIVE
+     *
      * [*] At this stage, after sealing the shards, we stream the sstables.
      * Non-repaired (not fully reconciled) SSTables must be split into
      * fully reconciled part and non-reconciled part. When streaming the
@@ -247,9 +268,13 @@ public final class SealingCoordinator
         seal(discoverShardsAtMidJoinOrReplace(metadata, movements), deadNode);
     }
 
-    public static void sealShardsAtFinishReplace(ClusterMetadata metadata, 
PlacementDeltas finishDelta, @Nonnull NodeId deadNode)
+    /**
+     * Seal the intermediate, over-replicated shards created during 
START_REPLACE and obsoleted by FINISH_REPLACE.
+     * The {@code deadNode} node cannot participate in the sealing.
+     */
+    public static void sealShardsAtFinishReplace(ClusterMetadata metadata, 
long finishEpoch, PlacementDeltas finishDelta, @Nonnull NodeId deadNode)
     {
-        seal(discoverShardsAtFinishJoinOrReplace(metadata, finishDelta, 
deadNode), deadNode);
+        seal(discoverShardsAtFinishJoinOrReplace(metadata, finishEpoch, 
finishDelta, deadNode), deadNode);
     }
 
     /**
@@ -297,9 +322,9 @@ public final class SealingCoordinator
     /**
      * Discover all shards that need to be sealed at FINISH_JOIN or 
FINISH_REPLACE.
      */
-    private static Set<ShardMetadata> 
discoverShardsAtFinishJoinOrReplace(ClusterMetadata metadata, PlacementDeltas 
finishDelta, @Nullable NodeId deadNode)
+    private static Set<ShardMetadata> discoverShardsAtFinishJoinOrReplace(
+        ClusterMetadata metadata, long finishEpoch, PlacementDeltas 
finishDelta, @Nullable NodeId deadNode)
     {
-        long finishEpoch = metadata.epoch.getEpoch();
         Set<ShardMetadata> shards = new HashSet<>();
         for (KeyspaceMetadata ksm : metadata.schema.getKeyspaces())
         {
@@ -364,15 +389,28 @@ public final class SealingCoordinator
      * 4. FINISH_LEAVE at e3:
      *
      *      (10,20] | {n20,n30,n40}     | e0 | SEALED
-     *    S (10,20] | {n20,n30,n40,n50} | e1 | SEALED
+     *      (10,20] | {n20,n30,n40,n50} | e1 | ACTIVE
      *    + (10,20] | {n20,n30,n50}     | e3 | ACTIVE
      *      (20,30] | {n30,n40,n50}     | e0 | SEALED
-     *    S (20,30] | {n30,n40,n50,n60} | e1 | SEALED
+     *      (20,30] | {n30,n40,n50,n60} | e1 | ACTIVE
      *    + (20,30] | {n30,n50,n60}     | e3 | ACTIVE
      *      (30,40] | {n40,n50,n60}     | e0 | SEALED
+     *      (30,40] | {n40,n50,n60,n70} | e1 | ACTIVE
+     *      (40,50] | {n50,n60,n70}     | e0 | ACTIVE
+     *    + (30,50] | {n50,n60,n70}     | e3 | ACTIVE
+     *
+     * 5. UNLOCK_SEQUENCE:
+     *
+     *      (10,20] | {n20,n30,n40}     | e0 | SEALED
+     *    S (10,20] | {n20,n30,n40,n50} | e1 | SEALED
+     *      (10,20] | {n20,n30,n50}     | e3 | ACTIVE
+     *      (20,30] | {n30,n40,n50}     | e0 | SEALED
+     *    S (20,30] | {n30,n40,n50,n60} | e1 | SEALED
+     *      (20,30] | {n30,n50,n60}     | e3 | ACTIVE
+     *      (30,40] | {n40,n50,n60}     | e0 | SEALED
      *    S (30,40] | {n40,n50,n60,n70} | e1 | SEALED
      *    S (40,50] | {n50,n60,n70}     | e0 | SEALED
-     *    + (30,50] | {n50,n60,n70}     | e3 | ACTIVE
+     *      (30,50] | {n50,n60,n70}     | e3 | ACTIVE
      *
      * [*] At this stage, after sealing the shards, we stream the sstables.
      * Non-repaired (not fully reconciled) SSTables must be split into
@@ -424,20 +462,20 @@ public final class SealingCoordinator
     }
 
     /**
-     * Seal the shards that need to be sealed at LEAVE_FINISH for both 
UNBOOTSTRAP and REMOVENODE.
+     * Seal the shards obsoleted by FINISH_LEAVE, for both UNBOOTSTRAP and 
REMOVENODE: the departed node's
+     * over-replicated generations created during START_LEAVE, plus the 
merge-half folded into its range.
      */
-    public static void sealShardsAtFinishLeave(ClusterMetadata postFinish, 
PlacementDeltas finishDelta, NodeId leavingOrDeparted, LeaveStreams.Kind kind)
+    public static void sealShardsAtFinishLeave(ClusterMetadata metadata, long 
finishEpoch, PlacementDeltas finishDelta, NodeId leavingOrDeparted, 
LeaveStreams.Kind kind)
     {
-        long finishEpoch = postFinish.epoch.getEpoch();
-        InetAddressAndPort leavingOrDepartedEndpoint = 
postFinish.directory.endpoint(leavingOrDeparted);
+        InetAddressAndPort leavingOrDepartedEndpoint = 
metadata.directory.endpoint(leavingOrDeparted);
 
         List<Future<Set<ShardMetadata>>> futures = new ArrayList<>();
-        for (KeyspaceMetadata ksm : postFinish.schema.getKeyspaces())
+        for (KeyspaceMetadata ksm : metadata.schema.getKeyspaces())
         {
             if (!ksm.params.replicationType.isTracked())
                 continue;
 
-            DataPlacement placement = 
postFinish.placements.get(ksm.params.replication);
+            DataPlacement placement = 
metadata.placements.get(ksm.params.replication);
             RangesAtEndpoint removals = 
finishDelta.get(ksm.params.replication).writes.removals.get(leavingOrDepartedEndpoint);
             for (Replica removedReplica : removals)
             {
@@ -507,7 +545,7 @@ public final class SealingCoordinator
      *   it no longer allocates new ids on it). The ProgressBarrier gating 
bootstrap()/MID_JOIN only waits for
      *   EACH_QUORUM of the affected replicas, NOT all live replicas, so a 
live-but-lagging participant could still
      *   be on the old shard. Bringing each participant up to the seal epoch 
must be done per-replica (not
-     *   per-shard) and will be added separately. TLDR - add a step to 
guaranteed up-to-speed rest of replicas epochs.
+     *   per-shard) and will be added later.
      */
     private static AsyncPromise<Void> initiate(ShardMetadata shard, @Nullable 
NodeId withoutNode)
     {
diff --git a/src/java/org/apache/cassandra/replication/Shard.java 
b/src/java/org/apache/cassandra/replication/Shard.java
index e2b22f653c..3e345e3d98 100644
--- a/src/java/org/apache/cassandra/replication/Shard.java
+++ b/src/java/org/apache/cassandra/replication/Shard.java
@@ -471,8 +471,9 @@ public class Shard
 
     void markSealing()
     {
+        // TODO (expected): consider what can possibly go wrong with the no-op 
on SEALED
         if (state == State.SEALED)
-            throw new IllegalStateException(format("%s cannot transition to 
SEALING from %s", this, state));
+            return;
 
         if (state != State.SEALING)
         {
diff --git a/src/java/org/apache/cassandra/tcm/Transformation.java 
b/src/java/org/apache/cassandra/tcm/Transformation.java
index 664cf5a6a8..9593386c9a 100644
--- a/src/java/org/apache/cassandra/tcm/Transformation.java
+++ b/src/java/org/apache/cassandra/tcm/Transformation.java
@@ -60,6 +60,7 @@ import 
org.apache.cassandra.tcm.transformations.ReconfigureAccordFastPath;
 import org.apache.cassandra.tcm.transformations.Register;
 import org.apache.cassandra.tcm.transformations.Startup;
 import org.apache.cassandra.tcm.transformations.TriggerSnapshot;
+import org.apache.cassandra.tcm.transformations.UnlockSequence;
 import org.apache.cassandra.tcm.transformations.Unregister;
 import org.apache.cassandra.tcm.transformations.UnsafeJoin;
 import org.apache.cassandra.tcm.transformations.cms.AdvanceCMSReconfiguration;
@@ -275,6 +276,14 @@ public interface Transformation
         FINISH_DROP_ACCORD_TABLE(42, Version.MIN_ACCORD_VERSION, () -> 
FinishDropAccordTable.serializer),
         ACCORD_MARK_HARD_REMOVED(43, Version.MIN_ACCORD_VERSION, () -> 
AccordMarkHardRemoved.serializer),
         ADVANCE_MUTATION_TRACKING_MIGRATION(44, 
Version.MIN_MUTATION_TRACKING_VERSION, () -> 
AdvanceMutationTrackingMigration.serializer),
+
+        /*
+         * Additional final step for JOIN/MOVE/LEAVE/REPLACE sequences that 
seal the
+         * intermediate mutation tracking shards before unlocking the affected 
ranges.
+         * See o.a.c.replication.SealingCoordinator comments for additional 
context.
+         * TODO (now): what should be the correct introducedIn version for 
these?
+         */
+        UNLOCK_SEQUENCE(45, Version.TBD_UNLOCK_STEP, () -> 
UnlockSequence.serializer),
         ;
 
         /**
diff --git a/src/java/org/apache/cassandra/tcm/sequences/BootstrapAndJoin.java 
b/src/java/org/apache/cassandra/tcm/sequences/BootstrapAndJoin.java
index 455953cc42..cbf9724618 100644
--- a/src/java/org/apache/cassandra/tcm/sequences/BootstrapAndJoin.java
+++ b/src/java/org/apache/cassandra/tcm/sequences/BootstrapAndJoin.java
@@ -70,6 +70,7 @@ import 
org.apache.cassandra.tcm.serialization.AsymmetricMetadataSerializer;
 import org.apache.cassandra.tcm.serialization.MetadataSerializer;
 import org.apache.cassandra.tcm.serialization.Version;
 import org.apache.cassandra.tcm.transformations.PrepareJoin;
+import org.apache.cassandra.tcm.transformations.UnlockSequence;
 import org.apache.cassandra.utils.JVMStabilityInspector;
 import org.apache.cassandra.utils.Pair;
 import org.apache.cassandra.utils.concurrent.Future;
@@ -82,6 +83,7 @@ import static 
org.apache.cassandra.tcm.MultiStepOperation.Kind.JOIN;
 import static org.apache.cassandra.tcm.Transformation.Kind.FINISH_JOIN;
 import static org.apache.cassandra.tcm.Transformation.Kind.MID_JOIN;
 import static org.apache.cassandra.tcm.Transformation.Kind.START_JOIN;
+import static org.apache.cassandra.tcm.Transformation.Kind.UNLOCK_SEQUENCE;
 import static org.apache.cassandra.tcm.sequences.SequenceState.continuable;
 import static org.apache.cassandra.tcm.sequences.SequenceState.error;
 import static org.apache.cassandra.tcm.sequences.SequenceState.halted;
@@ -186,7 +188,15 @@ public class BootstrapAndJoin extends 
MultiStepOperation<Epoch>
     @Override
     public Transformation.Result applyTo(ClusterMetadata metadata)
     {
-        return applyMultipleTransformations(metadata, next, of(startJoin, 
midJoin, finishJoin));
+        // FinishJoin unlocks the affected ranges and retires the sequence 
only in legacy (pre-UNLOCK_SEQUENCE) mode.
+        return finishJoin.unlocks()
+             ? applyMultipleTransformations(metadata, next, of(startJoin, 
midJoin, finishJoin))
+             : applyMultipleTransformations(metadata, next, of(startJoin, 
midJoin, finishJoin, unlockSequence()));
+    }
+
+    private UnlockSequence unlockSequence()
+    {
+        return new UnlockSequence(startJoin.nodeId(), lockKey);
     }
 
     @Override
@@ -303,8 +313,24 @@ public class BootstrapAndJoin extends 
MultiStepOperation<Epoch>
                 }
                 ClusterMetadataService.instance().ensureCMSPlacement(metadata);
 
-                if (MutationTrackingService.isEnabled())
-                    SealingCoordinator.sealShardsAtFinishJoin(metadata, 
finishJoin.delta());
+                break;
+            case UNLOCK_SEQUENCE:
+                try
+                {
+                    if (MutationTrackingService.isEnabled())
+                    {
+                        
SealingCoordinator.sealShardsAtFinishJoin(ClusterMetadata.current(),
+                                                                  
latestModification.getEpoch(),
+                                                                  
finishJoin.delta());
+                    }
+                    ClusterMetadataService.instance().commit(unlockSequence());
+                }
+                catch (Throwable e)
+                {
+                    JVMStabilityInspector.inspectThrowable(e);
+                    logger.warn("Exception sealing obsoleted MT shards or 
committing unlockSequence", e);
+                    return continuable();
+                }
 
                 break;
             default:
@@ -333,11 +359,15 @@ public class BootstrapAndJoin extends 
MultiStepOperation<Epoch>
     @Override
     public ClusterMetadata.Transformer cancel(ClusterMetadata metadata)
     {
+        // TODO (expected): reason about shard sealing states at various stages
         DataPlacements placements = metadata.placements;
         switch (next)
         {
-            // need to undo MID_JOIN and START_JOIN, then merge the ranges 
split by PrepareJoin
+            case UNLOCK_SEQUENCE:
+                // FINISH_JOIN has already been enacted - it's too late to be 
reverting anything, so we just unlock.
+                return 
metadata.transformer().with(metadata.lockedRanges.unlock(lockKey));
             case FINISH_JOIN:
+                // need to undo MID_JOIN and START_JOIN, then merge the ranges 
split by PrepareJoin
                 placements = 
midJoin.inverseDelta().apply(metadata.nextEpoch(), placements);
             case MID_JOIN:
                 placements = 
startJoin.inverseDelta().apply(metadata.nextEpoch(), placements);
@@ -488,6 +518,8 @@ public class BootstrapAndJoin extends 
MultiStepOperation<Epoch>
                 return 1;
             case FINISH_JOIN:
                 return 2;
+            case UNLOCK_SEQUENCE:
+                return 3;
             default:
                 throw new IllegalStateException(String.format("Step %s is 
invalid for sequence %s ", next, JOIN));
         }
@@ -503,6 +535,8 @@ public class BootstrapAndJoin extends 
MultiStepOperation<Epoch>
                 return MID_JOIN;
             case 2:
                 return FINISH_JOIN;
+            case 3:
+                return UNLOCK_SEQUENCE;
             default:
                 throw new IllegalStateException(String.format("Step %s is 
invalid for sequence %s ", index, JOIN));
         }
@@ -587,7 +621,7 @@ public class BootstrapAndJoin extends 
MultiStepOperation<Epoch>
             size += LockedRanges.Key.serializer.serializedSize(plan.lockKey, 
version);
             size += 
PlacementDeltas.serializer.serializedSize(plan.toSplitRanges, version);
 
-            size += VIntCoding.computeVIntSize(plan.kind().ordinal());
+            size += VIntCoding.computeVIntSize(plan.next.ordinal());
 
             size += 
PrepareJoin.StartJoin.serializer.serializedSize(plan.startJoin, version);
             size += 
PrepareJoin.MidJoin.serializer.serializedSize(plan.midJoin, version);
diff --git 
a/src/java/org/apache/cassandra/tcm/sequences/BootstrapAndReplace.java 
b/src/java/org/apache/cassandra/tcm/sequences/BootstrapAndReplace.java
index 0abbd5d815..9ee52186b5 100644
--- a/src/java/org/apache/cassandra/tcm/sequences/BootstrapAndReplace.java
+++ b/src/java/org/apache/cassandra/tcm/sequences/BootstrapAndReplace.java
@@ -25,6 +25,7 @@ import java.util.HashSet;
 import java.util.List;
 import java.util.Objects;
 import java.util.Set;
+import java.util.function.Predicate;
 import java.util.stream.StreamSupport;
 
 import com.google.common.annotations.VisibleForTesting;
@@ -56,6 +57,7 @@ import org.apache.cassandra.tcm.Epoch;
 import org.apache.cassandra.tcm.MultiStepOperation;
 import org.apache.cassandra.tcm.Transformation;
 import org.apache.cassandra.tcm.membership.Directory;
+import org.apache.cassandra.tcm.membership.NodeAddresses;
 import org.apache.cassandra.tcm.membership.NodeId;
 import org.apache.cassandra.tcm.membership.NodeState;
 import org.apache.cassandra.tcm.ownership.DataPlacement;
@@ -66,6 +68,7 @@ import 
org.apache.cassandra.tcm.serialization.AsymmetricMetadataSerializer;
 import org.apache.cassandra.tcm.serialization.MetadataSerializer;
 import org.apache.cassandra.tcm.serialization.Version;
 import org.apache.cassandra.tcm.transformations.PrepareReplace;
+import org.apache.cassandra.tcm.transformations.UnlockSequence;
 import org.apache.cassandra.utils.JVMStabilityInspector;
 import org.apache.cassandra.utils.Pair;
 import org.apache.cassandra.utils.vint.VIntCoding;
@@ -75,6 +78,7 @@ import static 
org.apache.cassandra.tcm.MultiStepOperation.Kind.REPLACE;
 import static org.apache.cassandra.tcm.Transformation.Kind.FINISH_REPLACE;
 import static org.apache.cassandra.tcm.Transformation.Kind.MID_REPLACE;
 import static org.apache.cassandra.tcm.Transformation.Kind.START_REPLACE;
+import static org.apache.cassandra.tcm.Transformation.Kind.UNLOCK_SEQUENCE;
 import static org.apache.cassandra.tcm.sequences.BootstrapAndJoin.bootstrap;
 import static org.apache.cassandra.tcm.sequences.SequenceState.continuable;
 import static org.apache.cassandra.tcm.sequences.SequenceState.error;
@@ -180,7 +184,15 @@ public class BootstrapAndReplace extends 
MultiStepOperation<Epoch>
     @Override
     public Transformation.Result applyTo(ClusterMetadata metadata)
     {
-        return applyMultipleTransformations(metadata, next, of(startReplace, 
midReplace, finishReplace));
+        // FinishReplace unlocks the affected ranges and retires the sequence 
only in legacy (pre-UNLOCK_SEQUENCE) mode.
+        return finishReplace.unlocks()
+             ? applyMultipleTransformations(metadata, next, of(startReplace, 
midReplace, finishReplace))
+             : applyMultipleTransformations(metadata, next, of(startReplace, 
midReplace, finishReplace, unlockSequence()));
+    }
+
+    private UnlockSequence unlockSequence()
+    {
+        return new UnlockSequence(startReplace.nodeId(), lockKey);
     }
 
     @Override
@@ -292,8 +304,25 @@ public class BootstrapAndReplace extends 
MultiStepOperation<Epoch>
                 }
                 ClusterMetadataService.instance().ensureCMSPlacement(metadata);
 
-                if (MutationTrackingService.isEnabled())
-                    SealingCoordinator.sealShardsAtFinishReplace(metadata, 
finishReplace.delta(), startReplace.replaced());
+                break;
+            case UNLOCK_SEQUENCE:
+                try
+                {
+                    if (MutationTrackingService.isEnabled())
+                    {
+                        
SealingCoordinator.sealShardsAtFinishReplace(ClusterMetadata.current(),
+                                                                     
latestModification.getEpoch(),
+                                                                     
finishReplace.delta(),
+                                                                     
startReplace.replaced());
+                    }
+                    ClusterMetadataService.instance().commit(unlockSequence());
+                }
+                catch (Throwable e)
+                {
+                    JVMStabilityInspector.inspectThrowable(e);
+                    logger.warn("Exception sealing obsoleted MT shards or 
committing unlockSequence", e);
+                    return continuable();
+                }
 
                 break;
             default:
@@ -316,8 +345,13 @@ public class BootstrapAndReplace extends 
MultiStepOperation<Epoch>
         if (next == START_REPLACE)
             return ProgressBarrier.immediate();
         ClusterMetadata metadata = ClusterMetadata.current();
-        InetAddressAndPort replaced = 
metadata.directory.getNodeAddresses(startReplace.replaced()).broadcastAddress;
-        return new ProgressBarrier(latestModification, 
metadata.directory.location(startReplace.nodeId()), 
metadata.lockedRanges.locked.get(lockKey), e -> !e.equals(replaced));
+        // By UNLOCK_SEQUENCE the replaced node has already been removed from 
the directory by FINISH_REPLACE, so there
+        // is nothing left to exclude from the barrier.
+        NodeAddresses replacedAddresses = 
metadata.directory.getNodeAddresses(startReplace.replaced());
+        Predicate<InetAddressAndPort> filter = replacedAddresses == null
+                                             ? e -> true
+                                             : e -> 
!e.equals(replacedAddresses.broadcastAddress);
+        return new ProgressBarrier(latestModification, 
metadata.directory.location(startReplace.nodeId()), 
metadata.lockedRanges.locked.get(lockKey), filter);
     }
 
     @Override
@@ -326,6 +360,9 @@ public class BootstrapAndReplace extends 
MultiStepOperation<Epoch>
         DataPlacements placements = metadata.placements;
         switch (next)
         {
+            case UNLOCK_SEQUENCE:
+                // FINISH_REPLACE has already been enacted - it's too late to 
be reverting anything, so we just unlock.
+                return 
metadata.transformer().with(metadata.lockedRanges.unlock(lockKey));
             // need to undo MID_REPLACE and START_REPLACE, but PREPARE_REPLACE 
doesn't affect placements
             case FINISH_REPLACE:
                 placements = 
midReplace.inverseDelta().apply(metadata.nextEpoch(), placements);
@@ -385,6 +422,8 @@ public class BootstrapAndReplace extends 
MultiStepOperation<Epoch>
                 return 1;
             case FINISH_REPLACE:
                 return 2;
+            case UNLOCK_SEQUENCE:
+                return 3;
             default:
                 throw new IllegalStateException(String.format("Step %s is 
invalid for sequence %s ", next, REPLACE));
         }
@@ -400,6 +439,8 @@ public class BootstrapAndReplace extends 
MultiStepOperation<Epoch>
                 return MID_REPLACE;
             case 2:
                 return FINISH_REPLACE;
+            case 3:
+                return UNLOCK_SEQUENCE;
             default:
                 throw new IllegalStateException(String.format("Step %s is 
invalid for sequence %s ", index, REPLACE));
         }
@@ -531,7 +572,7 @@ public class BootstrapAndReplace extends 
MultiStepOperation<Epoch>
             size += Epoch.serializer.serializedSize(plan.latestModification, 
version);
             size += LockedRanges.Key.serializer.serializedSize(plan.lockKey, 
version);
 
-            size += VIntCoding.computeVIntSize(plan.kind().ordinal());
+            size += VIntCoding.computeVIntSize(plan.next.ordinal());
 
             size += 
PrepareReplace.StartReplace.serializer.serializedSize(plan.startReplace, 
version);
             size += 
PrepareReplace.MidReplace.serializer.serializedSize(plan.midReplace, version);
diff --git a/src/java/org/apache/cassandra/tcm/sequences/Move.java 
b/src/java/org/apache/cassandra/tcm/sequences/Move.java
index a7f316e38f..d33db1b8c1 100644
--- a/src/java/org/apache/cassandra/tcm/sequences/Move.java
+++ b/src/java/org/apache/cassandra/tcm/sequences/Move.java
@@ -73,6 +73,7 @@ import 
org.apache.cassandra.tcm.serialization.AsymmetricMetadataSerializer;
 import org.apache.cassandra.tcm.serialization.MetadataSerializer;
 import org.apache.cassandra.tcm.serialization.Version;
 import org.apache.cassandra.tcm.transformations.PrepareMove;
+import org.apache.cassandra.tcm.transformations.UnlockSequence;
 import org.apache.cassandra.utils.FBUtilities;
 import org.apache.cassandra.utils.JVMStabilityInspector;
 import org.apache.cassandra.utils.concurrent.Future;
@@ -84,6 +85,7 @@ import static 
org.apache.cassandra.tcm.MultiStepOperation.Kind.MOVE;
 import static org.apache.cassandra.tcm.Transformation.Kind.FINISH_MOVE;
 import static org.apache.cassandra.tcm.Transformation.Kind.MID_MOVE;
 import static org.apache.cassandra.tcm.Transformation.Kind.START_MOVE;
+import static org.apache.cassandra.tcm.Transformation.Kind.UNLOCK_SEQUENCE;
 import static org.apache.cassandra.tcm.sequences.SequenceState.continuable;
 import static org.apache.cassandra.tcm.sequences.SequenceState.error;
 
@@ -193,7 +195,15 @@ public class Move extends MultiStepOperation<Epoch>
     @Override
     public Transformation.Result applyTo(ClusterMetadata metadata)
     {
-        return applyMultipleTransformations(metadata, next, of(startMove, 
midMove, finishMove));
+        // FinishMove unlocks the affected ranges and retires the sequence 
only in legacy (pre-UNLOCK_SEQUENCE) mode.
+        return finishMove.unlocks()
+             ? applyMultipleTransformations(metadata, next, of(startMove, 
midMove, finishMove))
+             : applyMultipleTransformations(metadata, next, of(startMove, 
midMove, finishMove, unlockSequence()));
+    }
+
+    private UnlockSequence unlockSequence()
+    {
+        return new UnlockSequence(startMove.nodeId(), lockKey);
     }
 
     @Override
@@ -317,6 +327,19 @@ public class Move extends MultiStepOperation<Epoch>
                 }
                 ClusterMetadataService.instance().ensureCMSPlacement(metadata);
                 break;
+            case UNLOCK_SEQUENCE:
+                try
+                {
+                    // TODO (required): mutation tracking shard sealing for 
move is not yet implemented
+                    ClusterMetadataService.instance().commit(unlockSequence());
+                }
+                catch (Throwable t)
+                {
+                    JVMStabilityInspector.inspectThrowable(t);
+                    logger.warn("Exception committing unlockSequence", t);
+                    return continuable();
+                }
+                break;
             default:
                 return error(new IllegalStateException("Can't proceed with 
join from " + next));
         }
@@ -346,6 +369,9 @@ public class Move extends MultiStepOperation<Epoch>
 
         switch (next)
         {
+            case UNLOCK_SEQUENCE:
+                // FINISH_MOVE has already been enacted - it's too late to be 
reverting anything, so we just unlock.
+                return 
metadata.transformer().with(metadata.lockedRanges.unlock(lockKey));
             case FINISH_MOVE:
                 placements = 
midMove.inverseDelta().apply(metadata.nextEpoch(), placements);
             case MID_MOVE:
@@ -490,6 +516,8 @@ public class Move extends MultiStepOperation<Epoch>
                 return 1;
             case FINISH_MOVE:
                 return 2;
+            case UNLOCK_SEQUENCE:
+                return 3;
             default:
                 throw new IllegalStateException(String.format("Step %s is 
invalid for sequence %s ", next, MOVE));
         }
@@ -505,6 +533,8 @@ public class Move extends MultiStepOperation<Epoch>
                 return MID_MOVE;
             case 2:
                 return FINISH_MOVE;
+            case 3:
+                return UNLOCK_SEQUENCE;
             default:
                 throw new IllegalStateException(String.format("Step %s is 
invalid for sequence %s ", index, MOVE));
         }
@@ -601,7 +631,7 @@ public class Move extends MultiStepOperation<Epoch>
             size += LockedRanges.Key.serializer.serializedSize(plan.lockKey, 
version);
             size += 
PlacementDeltas.serializer.serializedSize(plan.toSplitRanges, version);
 
-            size += VIntCoding.computeVIntSize(plan.kind().ordinal());
+            size += VIntCoding.computeVIntSize(plan.next.ordinal());
 
             size += 
PrepareMove.StartMove.serializer.serializedSize(plan.startMove, version);
             size += 
PrepareMove.MidMove.serializer.serializedSize(plan.midMove, version);
diff --git 
a/src/java/org/apache/cassandra/tcm/sequences/UnbootstrapAndLeave.java 
b/src/java/org/apache/cassandra/tcm/sequences/UnbootstrapAndLeave.java
index e923c44dab..0afb2b718d 100644
--- a/src/java/org/apache/cassandra/tcm/sequences/UnbootstrapAndLeave.java
+++ b/src/java/org/apache/cassandra/tcm/sequences/UnbootstrapAndLeave.java
@@ -51,6 +51,7 @@ import 
org.apache.cassandra.tcm.serialization.AsymmetricMetadataSerializer;
 import org.apache.cassandra.tcm.serialization.MetadataSerializer;
 import org.apache.cassandra.tcm.serialization.Version;
 import org.apache.cassandra.tcm.transformations.PrepareLeave;
+import org.apache.cassandra.tcm.transformations.UnlockSequence;
 import org.apache.cassandra.utils.JVMStabilityInspector;
 import org.apache.cassandra.utils.vint.VIntCoding;
 
@@ -59,6 +60,7 @@ import static 
org.apache.cassandra.tcm.MultiStepOperation.Kind.LEAVE;
 import static org.apache.cassandra.tcm.Transformation.Kind.FINISH_LEAVE;
 import static org.apache.cassandra.tcm.Transformation.Kind.MID_LEAVE;
 import static org.apache.cassandra.tcm.Transformation.Kind.START_LEAVE;
+import static org.apache.cassandra.tcm.Transformation.Kind.UNLOCK_SEQUENCE;
 import static org.apache.cassandra.tcm.sequences.SequenceState.continuable;
 import static org.apache.cassandra.tcm.sequences.SequenceState.error;
 
@@ -159,7 +161,15 @@ public class UnbootstrapAndLeave extends 
MultiStepOperation<Epoch>
     @Override
     public Transformation.Result applyTo(ClusterMetadata metadata)
     {
-        return applyMultipleTransformations(metadata, next, of(startLeave, 
midLeave, finishLeave));
+        // FinishLeave unlocks the affected ranges and retires the sequence 
only in legacy (pre-UNLOCK_SEQUENCE) mode.
+        return finishLeave.unlocks()
+             ? applyMultipleTransformations(metadata, next, of(startLeave, 
midLeave, finishLeave))
+             : applyMultipleTransformations(metadata, next, of(startLeave, 
midLeave, finishLeave, unlockSequence()));
+    }
+
+    private UnlockSequence unlockSequence()
+    {
+        return new UnlockSequence(startLeave.nodeId(), lockKey);
     }
 
     @Override
@@ -213,10 +223,9 @@ public class UnbootstrapAndLeave extends 
MultiStepOperation<Epoch>
                 }
                 break;
             case FINISH_LEAVE:
-                ClusterMetadata postFinish;
                 try
                 {
-                    postFinish = 
ClusterMetadataService.instance().commit(finishLeave);
+                    ClusterMetadataService.instance().commit(finishLeave);
                     StorageService.instance.clearTransientMode();
                 }
                 catch (Throwable t)
@@ -224,12 +233,26 @@ public class UnbootstrapAndLeave extends 
MultiStepOperation<Epoch>
                     JVMStabilityInspector.inspectThrowable(t);
                     return continuable();
                 }
-
-                // Seal the shards obsoleted by FINISH_LEAVE: the departed 
node's over-replicated generations created
-                // during START_LEAVE, plus the merge-half folded into the 
departed node's range by the merge.
-                if (MutationTrackingService.isEnabled())
-                    if (streams.kind() == LeaveStreams.Kind.UNBOOTSTRAP || 
streams.kind() == LeaveStreams.Kind.REMOVENODE)
-                        SealingCoordinator.sealShardsAtFinishLeave(postFinish, 
finishLeave.delta(), finishLeave.nodeId(), streams.kind());
+                break;
+            case UNLOCK_SEQUENCE:
+                try
+                {
+                    if (MutationTrackingService.isEnabled() && streams.kind() 
!= LeaveStreams.Kind.ASSASSINATE)
+                    {
+                        
SealingCoordinator.sealShardsAtFinishLeave(ClusterMetadata.current(),
+                                                                   
latestModification.getEpoch(),
+                                                                   
finishLeave.delta(),
+                                                                   
finishLeave.nodeId(),
+                                                                   
streams.kind());
+                    }
+                    ClusterMetadataService.instance().commit(unlockSequence());
+                }
+                catch (Throwable t)
+                {
+                    JVMStabilityInspector.inspectThrowable(t);
+                    logger.warn("Exception sealing obsoleted MT shards or 
committing unlockSequence", t);
+                    return continuable();
+                }
                 break;
             default:
                 return error(new IllegalStateException("Can't proceed with 
leave from " + next));
@@ -262,6 +285,9 @@ public class UnbootstrapAndLeave extends 
MultiStepOperation<Epoch>
         DataPlacements placements = metadata.placements;
         switch (next)
         {
+            case UNLOCK_SEQUENCE:
+                // FINISH_LEAVE has already been enacted - it's too late to be 
reverting anything, so we just unlock.
+                return 
metadata.transformer().with(metadata.lockedRanges.unlock(lockKey));
             // need to undo MID_LEAVE and START_LEAVE, but PrepareLeave 
doesn't affect placement
             case FINISH_LEAVE:
                 placements = 
midLeave.inverseDelta().apply(metadata.nextEpoch(), placements);
@@ -296,6 +322,8 @@ public class UnbootstrapAndLeave extends 
MultiStepOperation<Epoch>
                 return 1;
             case FINISH_LEAVE:
                 return 2;
+            case UNLOCK_SEQUENCE:
+                return 3;
             default:
                 throw new IllegalStateException(String.format("Step %s is 
invalid for sequence %s ", next, LEAVE));
         }
@@ -311,6 +339,8 @@ public class UnbootstrapAndLeave extends 
MultiStepOperation<Epoch>
                 return MID_LEAVE;
             case 2:
                 return FINISH_LEAVE;
+            case 3:
+                return UNLOCK_SEQUENCE;
             default:
                 throw new IllegalStateException(String.format("Step %s is 
invalid for sequence %s ", index, LEAVE));
         }
@@ -393,11 +423,11 @@ public class UnbootstrapAndLeave extends 
MultiStepOperation<Epoch>
             long size = 
Epoch.serializer.serializedSize(plan.latestModification, version);
             size += LockedRanges.Key.serializer.serializedSize(plan.lockKey, 
version);
 
-            size += VIntCoding.computeVIntSize(plan.kind().ordinal());
+            size += VIntCoding.computeVIntSize(plan.next.ordinal());
             size += VIntCoding.computeVIntSize(plan.streams.kind().ordinal());
             size += 
PrepareLeave.StartLeave.serializer.serializedSize(plan.startLeave, version);
-            size += 
PrepareLeave.StartLeave.serializer.serializedSize(plan.midLeave, version);
-            size += 
PrepareLeave.StartLeave.serializer.serializedSize(plan.finishLeave, version);
+            size += 
PrepareLeave.MidLeave.serializer.serializedSize(plan.midLeave, version);
+            size += 
PrepareLeave.FinishLeave.serializer.serializedSize(plan.finishLeave, version);
             return size;
         }
     }
diff --git a/src/java/org/apache/cassandra/tcm/serialization/Version.java 
b/src/java/org/apache/cassandra/tcm/serialization/Version.java
index 6f0e72815f..119399bf61 100644
--- a/src/java/org/apache/cassandra/tcm/serialization/Version.java
+++ b/src/java/org/apache/cassandra/tcm/serialization/Version.java
@@ -90,7 +90,9 @@ public enum Version
      * The version that Accord was added to TCM.
      */
     public static final Version MIN_ACCORD_VERSION = V7;
+
     public static final Version MIN_MUTATION_TRACKING_VERSION = V8;
+    public static final Version TBD_UNLOCK_STEP = V9;
 
     private static Map<Integer, Version> values = new HashMap<>();
 
diff --git 
a/src/java/org/apache/cassandra/tcm/transformations/ApplyPlacementDeltas.java 
b/src/java/org/apache/cassandra/tcm/transformations/ApplyPlacementDeltas.java
index 10f48609d4..e893710954 100644
--- 
a/src/java/org/apache/cassandra/tcm/transformations/ApplyPlacementDeltas.java
+++ 
b/src/java/org/apache/cassandra/tcm/transformations/ApplyPlacementDeltas.java
@@ -63,6 +63,11 @@ public abstract class ApplyPlacementDeltas implements 
Transformation
         return delta;
     }
 
+    public boolean unlocks()
+    {
+        return unlock;
+    }
+
     public Set<InetAddressAndPort> affectedEndpoints()
     {
         Set<InetAddressAndPort> affectedEndpoints = new HashSet<>();
diff --git a/src/java/org/apache/cassandra/tcm/transformations/PrepareJoin.java 
b/src/java/org/apache/cassandra/tcm/transformations/PrepareJoin.java
index d8986e6829..065fe9d74f 100644
--- a/src/java/org/apache/cassandra/tcm/transformations/PrepareJoin.java
+++ b/src/java/org/apache/cassandra/tcm/transformations/PrepareJoin.java
@@ -160,7 +160,8 @@ public class PrepareJoin implements Transformation
         LockedRanges.Key lockKey = LockedRanges.keyFor(prev.nextEpoch());
         StartJoin startJoin = new StartJoin(nodeId, 
transitionPlan.addToWrites(), lockKey);
         MidJoin midJoin = new MidJoin(nodeId, transitionPlan.moveReads(), 
lockKey);
-        FinishJoin finishJoin = new FinishJoin(nodeId, tokens, 
transitionPlan.removeFromWrites(), lockKey);
+        FinishJoin finishJoin =
+            new FinishJoin(nodeId, tokens, transitionPlan.removeFromWrites(), 
lockKey, !UnlockSequence.isSupportedBy(prev));
 
         BootstrapAndJoin plan = BootstrapAndJoin.newSequence(prev.nextEpoch(),
                                                              lockKey,
@@ -308,7 +309,12 @@ public class PrepareJoin implements Transformation
 
         public FinishJoin(NodeId nodeId, Set<Token> tokens, PlacementDeltas 
delta, LockedRanges.Key unlockKey)
         {
-            super(nodeId, delta, unlockKey, true);
+            this(nodeId, tokens, delta, unlockKey, true);
+        }
+
+        public FinishJoin(NodeId nodeId, Set<Token> tokens, PlacementDeltas 
delta, LockedRanges.Key unlockKey, boolean unlock)
+        {
+            super(nodeId, delta, unlockKey, unlock);
             this.tokens = tokens;
         }
 
@@ -321,10 +327,12 @@ public class PrepareJoin implements Transformation
         @Override
         public ClusterMetadata.Transformer transform(ClusterMetadata prev, 
ClusterMetadata.Transformer transformer)
         {
-            return transformer.join(nodeId)
-                              .proposeToken(nodeId, tokens)
-                              .addToRackAndDC(nodeId)
-                              .with(prev.inProgressSequences.without(nodeId));
+            ClusterMetadata.Transformer next =
+                transformer.join(nodeId)
+                           .proposeToken(nodeId, tokens)
+                           .addToRackAndDC(nodeId);
+            return unlock ? next.with(prev.inProgressSequences.without(nodeId))
+                          : next.with(prev.inProgressSequences.with(nodeId, 
(BootstrapAndJoin plan) -> plan.advance(prev.nextEpoch())));
         }
 
         public static final class Serializer extends SerializerBase<FinishJoin>
@@ -337,6 +345,9 @@ public class PrepareJoin implements Transformation
                 out.writeUnsignedVInt32(tokens.size());
                 for (Token token : tokens)
                     Token.metadataSerializer.serialize(token, out, version);
+                // TODO (now): confirm that this is the Version to use
+                if (version.isAtLeast(Version.TBD_UNLOCK_STEP))
+                    out.writeBoolean(((FinishJoin) t).unlock);
             }
 
             @Override
@@ -350,7 +361,11 @@ public class PrepareJoin implements Transformation
                 IPartitioner partitioner = 
ClusterMetadata.current().partitioner;
                 for (int i = 0; i < numTokens; i++)
                     tokens.add(Token.metadataSerializer.deserialize(in, 
partitioner, version));
-                return new FinishJoin(nodeId, tokens, delta, lockKey);
+                // UnlockSequence step which follows after FinishJoin was 
introduced in TBD_UNLOCK_STEP
+                // to support mutation tracking, and unlock now happens in 
that step.
+                // TODO (now): confirm the correct Version to use here.
+                boolean unlock = version.isBefore(Version.TBD_UNLOCK_STEP) || 
in.readBoolean();
+                return new FinishJoin(nodeId, tokens, delta, lockKey, unlock);
             }
 
             @Override
@@ -361,6 +376,9 @@ public class PrepareJoin implements Transformation
                 size += TypeSizes.sizeofUnsignedVInt(tokens.size());
                 for (Token token : tokens)
                     size += Token.metadataSerializer.serializedSize(token, 
version);
+                // TODO (now): confirm the correct Version to use here.
+                if (version.isAtLeast(Version.TBD_UNLOCK_STEP))
+                    size += TypeSizes.sizeof(((FinishJoin)t).unlock);
                 return size;
             }
 
diff --git 
a/src/java/org/apache/cassandra/tcm/transformations/PrepareLeave.java 
b/src/java/org/apache/cassandra/tcm/transformations/PrepareLeave.java
index 0ad37f8bcd..a69ca6f58a 100644
--- a/src/java/org/apache/cassandra/tcm/transformations/PrepareLeave.java
+++ b/src/java/org/apache/cassandra/tcm/transformations/PrepareLeave.java
@@ -121,7 +121,7 @@ public class PrepareLeave implements Transformation
 
         StartLeave start = new StartLeave(leaving, startDelta, unlockKey);
         MidLeave mid = new MidLeave(leaving, midDelta, unlockKey);
-        FinishLeave leave = new FinishLeave(leaving, finishDelta, unlockKey);
+        FinishLeave leave = new FinishLeave(leaving, finishDelta, unlockKey, 
!UnlockSequence.isSupportedBy(prev));
 
         UnbootstrapAndLeave plan = 
UnbootstrapAndLeave.newSequence(prev.nextEpoch(),
                                                                    unlockKey,
@@ -294,7 +294,12 @@ public class PrepareLeave implements Transformation
 
         public FinishLeave(NodeId nodeId, PlacementDeltas delta, 
LockedRanges.Key lockKey)
         {
-            super(nodeId, delta, lockKey, true);
+            this(nodeId, delta, lockKey, true);
+        }
+
+        public FinishLeave(NodeId nodeId, PlacementDeltas delta, 
LockedRanges.Key lockKey, boolean unlock)
+        {
+            super(nodeId, delta, lockKey, unlock);
         }
 
         @Override
@@ -306,15 +311,48 @@ public class PrepareLeave implements Transformation
         @Override
         public ClusterMetadata.Transformer transform(ClusterMetadata prev, 
ClusterMetadata.Transformer transformer)
         {
-            return transformer.left(nodeId)
-                              .with(prev.inProgressSequences.without(nodeId));
+            ClusterMetadata.Transformer next = transformer.left(nodeId);
+            return unlock ? next.with(prev.inProgressSequences.without(nodeId))
+                          : next.with(prev.inProgressSequences.with(nodeId, 
(UnbootstrapAndLeave plan) -> plan.advance(prev.nextEpoch())));
         }
 
         public static class Serializer extends SerializerBase<FinishLeave>
         {
+            @Override
+            public void serialize(Transformation t, DataOutputPlus out, 
Version version) throws IOException
+            {
+                super.serialize(t, out, version);
+                // TODO (now): confirm that this is the Version to use
+                if (version.isAtLeast(Version.TBD_UNLOCK_STEP))
+                    out.writeBoolean(((FinishLeave) t).unlock);
+            }
+
+            @Override
+            public FinishLeave deserialize(DataInputPlus in, Version version) 
throws IOException
+            {
+                NodeId nodeId = NodeId.serializer.deserialize(in, version);
+                PlacementDeltas delta = 
PlacementDeltas.serializer.deserialize(in, version);
+                LockedRanges.Key lockKey = 
LockedRanges.Key.serializer.deserialize(in, version);
+                // UnlockSequence step which follows after FinishLeave was 
introduced in TBD_UNLOCK_STEP
+                // to support mutation tracking, and unlock now happens in 
that step.
+                // TODO (now): confirm the correct Version to use here.
+                boolean unlock = version.isBefore(Version.TBD_UNLOCK_STEP) || 
in.readBoolean();
+                return new FinishLeave(nodeId, delta, lockKey, unlock);
+            }
+
+            @Override
+            public long serializedSize(Transformation t, Version version)
+            {
+                long size = super.serializedSize(t, version);
+                // TODO (now): confirm the correct Version to use here.
+                if (version.isAtLeast(Version.TBD_UNLOCK_STEP))
+                    size += TypeSizes.sizeof(((FinishLeave)t).unlock);
+                return size;
+            }
+
             FinishLeave construct(NodeId nodeId, PlacementDeltas delta, 
LockedRanges.Key lockKey)
             {
-                return new FinishLeave(nodeId, delta, lockKey);
+                throw new IllegalStateException();
             }
         }
     }
diff --git a/src/java/org/apache/cassandra/tcm/transformations/PrepareMove.java 
b/src/java/org/apache/cassandra/tcm/transformations/PrepareMove.java
index e7e278d0d2..e6b06d9c21 100644
--- a/src/java/org/apache/cassandra/tcm/transformations/PrepareMove.java
+++ b/src/java/org/apache/cassandra/tcm/transformations/PrepareMove.java
@@ -108,7 +108,8 @@ public class PrepareMove implements Transformation
         LockedRanges.Key lockKey = LockedRanges.keyFor(prev.nextEpoch());
         StartMove startMove = new StartMove(nodeId, 
transitionPlan.addToWrites(), lockKey);
         MidMove midMove = new MidMove(nodeId, transitionPlan.moveReads(), 
lockKey);
-        FinishMove finishMove = new FinishMove(nodeId, tokens, 
transitionPlan.removeFromWrites(), lockKey);
+        FinishMove finishMove =
+            new FinishMove(nodeId, tokens, transitionPlan.removeFromWrites(), 
lockKey, !UnlockSequence.isSupportedBy(prev));
         transitionPlan.assertPreExistingWriteReplica(prev.placements);
 
         Move sequence = Move.newSequence(prev.nextEpoch(),
@@ -244,7 +245,12 @@ public class PrepareMove implements Transformation
         @VisibleForTesting
         public FinishMove(NodeId nodeId, Collection<Token> newTokens, 
PlacementDeltas delta, LockedRanges.Key lockKey)
         {
-            super(nodeId, delta, lockKey, true);
+            this(nodeId, newTokens, delta, lockKey, true);
+        }
+
+        public FinishMove(NodeId nodeId, Collection<Token> newTokens, 
PlacementDeltas delta, LockedRanges.Key lockKey, boolean unlock)
+        {
+            super(nodeId, delta, lockKey, unlock);
             this.newTokens = newTokens;
         }
 
@@ -257,9 +263,11 @@ public class PrepareMove implements Transformation
         @Override
         public ClusterMetadata.Transformer transform(ClusterMetadata prev, 
ClusterMetadata.Transformer transformer)
         {
-            return transformer.moveTokens(nodeId, newTokens)
-                              .join(nodeId)
-                              .with(prev.inProgressSequences.without(nodeId));
+            ClusterMetadata.Transformer next =
+                transformer.moveTokens(nodeId, newTokens)
+                           .join(nodeId);
+            return unlock ? next.with(prev.inProgressSequences.without(nodeId))
+                          : next.with(prev.inProgressSequences.with(nodeId, 
(Move plan) -> plan.advance(prev.nextEpoch())));
         }
 
         public static final class Serializer extends 
SerializerBase<PrepareMove.FinishMove>
@@ -272,6 +280,9 @@ public class PrepareMove implements Transformation
                 out.writeUnsignedVInt32(tokens.size());
                 for (Token token : tokens)
                     Token.metadataSerializer.serialize(token, out, version);
+                // TODO (now): confirm that this is the Version to use
+                if (version.isAtLeast(Version.TBD_UNLOCK_STEP))
+                    out.writeBoolean(((FinishMove) t).unlock);
             }
 
             public FinishMove deserialize(DataInputPlus in, Version version) 
throws IOException
@@ -285,7 +296,11 @@ public class PrepareMove implements Transformation
                 for (int i = 0; i < numTokens; i++)
                     tokens.add(Token.metadataSerializer.deserialize(in, 
partitioner, version));
 
-                return new FinishMove(nodeId, tokens, delta, lockKey);
+                // UnlockSequence step which follows after FinishMove was 
introduced in TBD_UNLOCK_STEP
+                // to support mutation tracking, and unlock now happens in 
that step.
+                // TODO (now): confirm the correct Version to use here.
+                boolean unlock = version.isBefore(Version.TBD_UNLOCK_STEP) || 
in.readBoolean();
+                return new FinishMove(nodeId, tokens, delta, lockKey, unlock);
             }
 
             public long serializedSize(Transformation t, Version version)
@@ -295,6 +310,9 @@ public class PrepareMove implements Transformation
                 size += TypeSizes.sizeofUnsignedVInt(tokens.size());
                 for (Token token : tokens)
                     size += Token.metadataSerializer.serializedSize(token, 
version);
+                // TODO (now): confirm the correct Version to use here.
+                if (version.isAtLeast(Version.TBD_UNLOCK_STEP))
+                    size += TypeSizes.sizeof(((FinishMove)t).unlock);
                 return size;
             }
 
diff --git 
a/src/java/org/apache/cassandra/tcm/transformations/PrepareReplace.java 
b/src/java/org/apache/cassandra/tcm/transformations/PrepareReplace.java
index 8b1577822b..1b45e4c76c 100644
--- a/src/java/org/apache/cassandra/tcm/transformations/PrepareReplace.java
+++ b/src/java/org/apache/cassandra/tcm/transformations/PrepareReplace.java
@@ -108,7 +108,8 @@ public class PrepareReplace implements Transformation
 
         StartReplace start = new StartReplace(replaced, replacement, 
transitionPlan.addToWrites(), unlockKey);
         MidReplace mid = new MidReplace(replaced, replacement, 
transitionPlan.moveReads(), unlockKey);
-        FinishReplace finish = new FinishReplace(replaced, replacement, 
transitionPlan.removeFromWrites(), unlockKey);
+        FinishReplace finish =
+            new FinishReplace(replaced, replacement, 
transitionPlan.removeFromWrites(), unlockKey, 
!UnlockSequence.isSupportedBy(prev));
         transitionPlan.assertPreExistingWriteReplica(prev.placements);
 
         Set<Token> tokens = new HashSet<>(prev.tokenMap.tokens(replaced));
@@ -327,7 +328,16 @@ public class PrepareReplace implements Transformation
                              PlacementDeltas delta,
                              LockedRanges.Key unlockKey)
         {
-            super(replaced, replacement, delta, unlockKey, true);
+            this(replaced, replacement, delta, unlockKey, true);
+        }
+
+        public FinishReplace(NodeId replaced,
+                             NodeId replacement,
+                             PlacementDeltas delta,
+                             LockedRanges.Key unlockKey,
+                             boolean unlock)
+        {
+            super(replaced, replacement, delta, unlockKey, unlock);
         }
 
         @Override
@@ -339,8 +349,9 @@ public class PrepareReplace implements Transformation
         @Override
         public ClusterMetadata.Transformer transform(ClusterMetadata prev, 
ClusterMetadata.Transformer transformer)
         {
-            return transformer.replaced(replaced, replacement())
-                              .with(prev.inProgressSequences.without(nodeId));
+            ClusterMetadata.Transformer next = transformer.replaced(replaced, 
replacement());
+            return unlock ? next.with(prev.inProgressSequences.without(nodeId))
+                          : next.with(prev.inProgressSequences.with(nodeId, 
(BootstrapAndReplace plan) -> plan.advance(prev.nextEpoch())));
         }
 
         @Override
@@ -356,10 +367,43 @@ public class PrepareReplace implements Transformation
 
         public static final class Serializer extends 
PrepareReplace.SerializerBase<FinishReplace>
         {
+            @Override
+            public void serialize(Transformation t, DataOutputPlus out, 
Version version) throws IOException
+            {
+                super.serialize(t, out, version);
+                // TODO (now): confirm that this is the Version to use
+                if (version.isAtLeast(Version.TBD_UNLOCK_STEP))
+                    out.writeBoolean(((FinishReplace) t).unlock);
+            }
+
+            @Override
+            public FinishReplace deserialize(DataInputPlus in, Version 
version) throws IOException
+            {
+                NodeId replaced = NodeId.serializer.deserialize(in, version);
+                NodeId replacement = NodeId.serializer.deserialize(in, 
version);
+                PlacementDeltas delta = 
PlacementDeltas.serializer.deserialize(in, version);
+                LockedRanges.Key lockKey = 
LockedRanges.Key.serializer.deserialize(in, version);
+                // UnlockSequence step which follows after FinishReplace was 
introduced in TBD_UNLOCK_STEP
+                // to support mutation tracking, and unlock now happens in 
that step.
+                // TODO (now): confirm the correct Version to use here.
+                boolean unlock = version.isBefore(Version.TBD_UNLOCK_STEP) || 
in.readBoolean();
+                return new FinishReplace(replaced, replacement, delta, 
lockKey, unlock);
+            }
+
+            @Override
+            public long serializedSize(Transformation t, Version version)
+            {
+                long size = super.serializedSize(t, version);
+                // TODO (now): confirm the correct Version to use here.
+                if (version.isAtLeast(Version.TBD_UNLOCK_STEP))
+                    size += TypeSizes.sizeof(((FinishReplace)t).unlock);
+                return size;
+            }
+
             @Override
             FinishReplace construct(NodeId replaced, NodeId replacement, 
PlacementDeltas delta, LockedRanges.Key lockKey)
             {
-                return new FinishReplace(replaced, replacement, delta, 
lockKey);
+                throw new IllegalStateException();
             }
         }
     }
diff --git 
a/src/java/org/apache/cassandra/tcm/transformations/UnlockSequence.java 
b/src/java/org/apache/cassandra/tcm/transformations/UnlockSequence.java
new file mode 100644
index 0000000000..088d8fc2ce
--- /dev/null
+++ b/src/java/org/apache/cassandra/tcm/transformations/UnlockSequence.java
@@ -0,0 +1,120 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.cassandra.tcm.transformations;
+
+import java.io.IOException;
+
+import org.apache.cassandra.exceptions.ExceptionCode;
+import org.apache.cassandra.io.util.DataInputPlus;
+import org.apache.cassandra.io.util.DataOutputPlus;
+import org.apache.cassandra.tcm.ClusterMetadata;
+import org.apache.cassandra.tcm.Transformation;
+import org.apache.cassandra.tcm.membership.NodeId;
+import org.apache.cassandra.tcm.sequences.LockedRanges;
+import org.apache.cassandra.tcm.serialization.AsymmetricMetadataSerializer;
+import org.apache.cassandra.tcm.serialization.Version;
+
+public class UnlockSequence implements Transformation
+{
+    public static final Serializer serializer = new Serializer();
+
+    private final NodeId nodeId;
+    private final LockedRanges.Key lockKey;
+
+    public UnlockSequence(NodeId nodeId, LockedRanges.Key lockKey)
+    {
+        this.nodeId = nodeId;
+        this.lockKey = lockKey;
+    }
+
+    static boolean isSupportedBy(ClusterMetadata metadata)
+    {
+        return 
metadata.directory.commonSerializationVersion.isAtLeast(Kind.UNLOCK_SEQUENCE.introducedIn);
+    }
+
+    @Override
+    public Kind kind()
+    {
+        return Kind.UNLOCK_SEQUENCE;
+    }
+
+    @Override
+    public Result execute(ClusterMetadata prev)
+    {
+        if (!prev.inProgressSequences.contains(nodeId))
+            return new Rejected(ExceptionCode.INVALID, "Can't find an 
in-progress sequence for this operation");
+
+        if (prev.inProgressSequences.get(nodeId).nextStep() != kind())
+            return new Rejected(ExceptionCode.INVALID, String.format("Can't 
commit sequenced operations out of order. Expected %s, but got %s", 
prev.inProgressSequences.get(nodeId).nextStep(), kind()));
+
+        ClusterMetadata.Transformer next =
+        prev.transformer()
+            .with(prev.inProgressSequences.without(nodeId))
+            .with(prev.lockedRanges.unlock(lockKey));
+
+        return Transformation.success(next, 
prev.lockedRanges.locked.get(lockKey));
+    }
+
+    @Override
+    public String toString()
+    {
+        return getClass().getSimpleName() + '{' + "id=" + nodeId + '}';
+    }
+
+    @Override
+    public boolean equals(Object o)
+    {
+        if (this == o) return true;
+        if (!(o instanceof UnlockSequence)) return false;
+        UnlockSequence that = (UnlockSequence) o;
+        return this.nodeId.equals(that.nodeId) && 
this.lockKey.equals(that.lockKey);
+    }
+
+    @Override
+    public int hashCode()
+    {
+        return nodeId.hashCode() + 31 * lockKey.hashCode();
+    }
+
+    public static final class Serializer implements 
AsymmetricMetadataSerializer<Transformation, UnlockSequence>
+    {
+        @Override
+        public void serialize(Transformation t, DataOutputPlus out, Version 
version) throws IOException
+        {
+            UnlockSequence unlock = (UnlockSequence) t;
+            NodeId.serializer.serialize(unlock.nodeId, out, version);
+            LockedRanges.Key.serializer.serialize(unlock.lockKey, out, 
version);
+        }
+
+        @Override
+        public UnlockSequence deserialize(DataInputPlus in, Version version) 
throws IOException
+        {
+            NodeId nodeId = NodeId.serializer.deserialize(in, version);
+            LockedRanges.Key lockKey = 
LockedRanges.Key.serializer.deserialize(in, version);
+            return new UnlockSequence(nodeId, lockKey);
+        }
+
+        @Override
+        public long serializedSize(Transformation t, Version version)
+        {
+            UnlockSequence unlock = (UnlockSequence) t;
+            return NodeId.serializer.serializedSize(unlock.nodeId, version) +
+                   LockedRanges.Key.serializer.serializedSize(unlock.lockKey, 
version);
+        }
+    }
+}
diff --git 
a/test/distributed/org/apache/cassandra/distributed/test/MoveTest.java 
b/test/distributed/org/apache/cassandra/distributed/test/MoveTest.java
index a5517dc043..981c168ff0 100644
--- a/test/distributed/org/apache/cassandra/distributed/test/MoveTest.java
+++ b/test/distributed/org/apache/cassandra/distributed/test/MoveTest.java
@@ -50,7 +50,7 @@ public class MoveTest extends TestBaseImpl
     private void move(boolean forwards) throws Throwable
     {
         try (Cluster cluster = Cluster.build(4)
-                                      .withConfig(config -> 
config.set("paxos_variant", 
"v2_without_linearizable_reads").with(NETWORK).with(GOSSIP))
+                                      .withConfig(config -> 
config.set("paxos_variant", 
"v2_without_linearizable_reads").set("mutation_tracking.enabled", 
false).with(NETWORK).with(GOSSIP))
                                       .withoutVNodes()
                                       .start())
         {
diff --git 
a/test/distributed/org/apache/cassandra/distributed/test/log/ClusterMetadataTestHelper.java
 
b/test/distributed/org/apache/cassandra/distributed/test/log/ClusterMetadataTestHelper.java
index 4492d1e15d..ae1f8c21b7 100644
--- 
a/test/distributed/org/apache/cassandra/distributed/test/log/ClusterMetadataTestHelper.java
+++ 
b/test/distributed/org/apache/cassandra/distributed/test/log/ClusterMetadataTestHelper.java
@@ -69,6 +69,7 @@ import org.apache.cassandra.tcm.ClusterMetadataService;
 import org.apache.cassandra.tcm.Commit;
 import org.apache.cassandra.tcm.Epoch;
 import org.apache.cassandra.tcm.MetadataSnapshots;
+import org.apache.cassandra.tcm.MultiStepOperation;
 import org.apache.cassandra.tcm.RegistrationStatus;
 import org.apache.cassandra.tcm.Transformation;
 import org.apache.cassandra.tcm.log.LocalLog;
@@ -97,6 +98,7 @@ import org.apache.cassandra.tcm.transformations.PrepareMove;
 import org.apache.cassandra.tcm.transformations.PrepareReplace;
 import org.apache.cassandra.tcm.transformations.Register;
 import org.apache.cassandra.tcm.transformations.Unregister;
+import org.apache.cassandra.tcm.transformations.UnlockSequence;
 import org.apache.cassandra.tcm.transformations.cms.AdvanceCMSReconfiguration;
 import org.apache.cassandra.tcm.transformations.cms.PrepareCMSReconfiguration;
 import org.apache.cassandra.utils.ByteBufferUtil;
@@ -533,6 +535,7 @@ public class ClusterMetadataTestHelper
                     BootstrapAndJoin plan = getBootstrapPlan(endpoint);
                     assert plan.next == Transformation.Kind.FINISH_JOIN;
                     commit(plan.finishJoin);
+                    maybeCommitUnlock(plan.startJoin.nodeId(), plan.lockKey);
                     idx++;
                     return this;
                 }
@@ -632,6 +635,7 @@ public class ClusterMetadataTestHelper
                     UnbootstrapAndLeave plan = getLeavePlan(endpoint);
                     assert plan.next == Transformation.Kind.FINISH_LEAVE;
                     commit(plan.finishLeave);
+                    maybeCommitUnlock(plan.startLeave.nodeId(), plan.lockKey);
                     idx++;
                     return this;
                 }
@@ -741,6 +745,7 @@ public class ClusterMetadataTestHelper
                     BootstrapAndReplace plan = getReplacePlan(replacement);
                     assert plan.next == Transformation.Kind.FINISH_REPLACE;
                     commit(plan.finishReplace);
+                    maybeCommitUnlock(plan.startReplace.nodeId(), 
plan.lockKey);
                     idx++;
                     return this;
                 }
@@ -819,6 +824,7 @@ public class ClusterMetadataTestHelper
                     Move plan = getMovePlan(endpoint);
                     assert plan.next == Transformation.Kind.FINISH_MOVE;
                     commit(plan.finishMove);
+                    maybeCommitUnlock(plan.startMove.nodeId(), plan.lockKey);
                     idx++;
                     return this;
                 }
@@ -944,6 +950,16 @@ public class ClusterMetadataTestHelper
         return ClusterMetadataService.instance().commit(transform);
     }
 
+    // On UNLOCK_SEQUENCE-supporting clusters the FINISH step no longer 
unlocks/retires the sequence; a trailing
+    // UNLOCK_SEQUENCE step does. Commit it here so the step-driving helpers 
observe a retired sequence, matching
+    // the legacy (pre-UNLOCK_SEQUENCE) behaviour where FINISH did both.
+    private static void maybeCommitUnlock(NodeId nodeId, LockedRanges.Key 
lockKey) throws ExecutionException, InterruptedException
+    {
+        MultiStepOperation<?> seq = 
ClusterMetadata.current().inProgressSequences.get(nodeId);
+        if (seq != null && seq.nextStep() == 
Transformation.Kind.UNLOCK_SEQUENCE)
+            commit(new UnlockSequence(nodeId, lockKey));
+    }
+
     public static interface NodeOperations
     {
         public void register();
diff --git 
a/test/distributed/org/apache/cassandra/distributed/test/log/MetadataKeysTest.java
 
b/test/distributed/org/apache/cassandra/distributed/test/log/MetadataKeysTest.java
index acc013aff0..a0adf6c702 100644
--- 
a/test/distributed/org/apache/cassandra/distributed/test/log/MetadataKeysTest.java
+++ 
b/test/distributed/org/apache/cassandra/distributed/test/log/MetadataKeysTest.java
@@ -40,6 +40,7 @@ import org.apache.cassandra.tcm.ClusterMetadata;
 import org.apache.cassandra.tcm.ClusterMetadataService;
 import org.apache.cassandra.tcm.MetadataKey;
 import org.apache.cassandra.tcm.MetadataKeys;
+import org.apache.cassandra.tcm.MultiStepOperation;
 import org.apache.cassandra.tcm.Transformation;
 import org.apache.cassandra.tcm.membership.Location;
 import org.apache.cassandra.tcm.membership.NodeAddresses;
@@ -48,6 +49,7 @@ import org.apache.cassandra.tcm.membership.NodeState;
 import org.apache.cassandra.tcm.membership.NodeVersion;
 import org.apache.cassandra.tcm.ownership.PlacementProvider;
 import org.apache.cassandra.tcm.sequences.BootstrapAndJoin;
+import org.apache.cassandra.tcm.sequences.LockedRanges;
 import org.apache.cassandra.tcm.sequences.LeaveStreams;
 import org.apache.cassandra.tcm.sequences.Move;
 import org.apache.cassandra.tcm.sequences.UnbootstrapAndLeave;
@@ -56,6 +58,7 @@ import org.apache.cassandra.tcm.transformations.PrepareJoin;
 import org.apache.cassandra.tcm.transformations.PrepareLeave;
 import org.apache.cassandra.tcm.transformations.PrepareMove;
 import org.apache.cassandra.tcm.transformations.Register;
+import org.apache.cassandra.tcm.transformations.UnlockSequence;
 
 public class MetadataKeysTest extends CMSTestBase
 {
@@ -97,6 +100,7 @@ public class MetadataKeysTest extends CMSTestBase
 
                 checkDiff(sut.service, bootstrapAndJoin.finishJoin);
                 sut.service.commit(bootstrapAndJoin.finishJoin);
+                maybeCommitUnlock(sut.service, prepareJoin.nodeId(), 
bootstrapAndJoin.lockKey);
             }
 
             {
@@ -113,6 +117,7 @@ public class MetadataKeysTest extends CMSTestBase
 
                 checkDiff(sut.service, bootstrapAndMove.finishMove);
                 sut.service.commit(bootstrapAndMove.finishMove);
+                maybeCommitUnlock(sut.service, prepareMove.nodeId(), 
bootstrapAndMove.lockKey);
             }
 
             {
@@ -129,6 +134,7 @@ public class MetadataKeysTest extends CMSTestBase
 
                 checkDiff(sut.service, bootstrapAndLeave.finishLeave);
                 sut.service.commit(bootstrapAndLeave.finishLeave);
+                maybeCommitUnlock(sut.service, prepareLeave.nodeId(), 
bootstrapAndLeave.lockKey);
             }
         }
     }
@@ -153,6 +159,17 @@ public class MetadataKeysTest extends CMSTestBase
         }
     }
 
+    private static void maybeCommitUnlock(ClusterMetadataService cms, NodeId 
nodeId, LockedRanges.Key lockKey)
+    {
+        MultiStepOperation<?> seq = 
cms.metadata().inProgressSequences.get(nodeId);
+        if (seq != null && seq.nextStep() == 
Transformation.Kind.UNLOCK_SEQUENCE)
+        {
+            UnlockSequence unlock = new UnlockSequence(nodeId, lockKey);
+            checkDiff(cms, unlock);
+            cms.commit(unlock);
+        }
+    }
+
     private static void checkDiff(ClusterMetadataService cms, Transformation 
transformation)
     {
         ClusterMetadata before = cms.metadata();
diff --git 
a/test/simulator/main/org/apache/cassandra/simulator/cluster/OnClusterJoin.java 
b/test/simulator/main/org/apache/cassandra/simulator/cluster/OnClusterJoin.java
index bb36ad7218..58a651c53d 100644
--- 
a/test/simulator/main/org/apache/cassandra/simulator/cluster/OnClusterJoin.java
+++ 
b/test/simulator/main/org/apache/cassandra/simulator/cluster/OnClusterJoin.java
@@ -113,6 +113,13 @@ class OnClusterJoin extends OnClusterChangeTopology
                 assert bootstrapAndJoin.next.ordinal() == kind : 
String.format("Expected next step to be %s, but got %s", 
Transformation.Kind.values()[kind], bootstrapAndJoin.next);
                 boolean res = bootstrapAndJoin.executeNext().isContinuable();
                 assert res;
+
+                // On UNLOCK_SEQUENCE-supporting clusters FINISH_JOIN no 
longer unlocks/retires the sequence;
+                // drain the trailing UNLOCK_SEQUENCE step so the range lock 
is released.
+                ClusterMetadata after = ClusterMetadata.current();
+                MultiStepOperation<?> trailing = 
after.inProgressSequences.get(after.myNodeId());
+                if (trailing != null && trailing.nextStep() == 
Transformation.Kind.UNLOCK_SEQUENCE)
+                    assert trailing.executeNext().isContinuable();
             });
         }
     }
diff --git 
a/test/simulator/main/org/apache/cassandra/simulator/cluster/OnClusterLeave.java
 
b/test/simulator/main/org/apache/cassandra/simulator/cluster/OnClusterLeave.java
index 2b2ce15086..7cd9fe7a6d 100644
--- 
a/test/simulator/main/org/apache/cassandra/simulator/cluster/OnClusterLeave.java
+++ 
b/test/simulator/main/org/apache/cassandra/simulator/cluster/OnClusterLeave.java
@@ -116,6 +116,13 @@ class OnClusterLeave extends OnClusterChangeTopology
                 assert unbootstrapAndLeave.next.ordinal() == kind : 
String.format("Expected next step to be %s, but got %s", 
Transformation.Kind.values()[kind], unbootstrapAndLeave.next);
                 boolean res = 
unbootstrapAndLeave.executeNext().isContinuable();
                 assert res;
+
+                // On UNLOCK_SEQUENCE-supporting clusters FINISH_LEAVE no 
longer unlocks/retires the sequence;
+                // drain the trailing UNLOCK_SEQUENCE step so the range lock 
is released.
+                ClusterMetadata after = ClusterMetadata.current();
+                MultiStepOperation<?> trailing = 
after.inProgressSequences.get(metadata.myNodeId());
+                if (trailing != null && trailing.nextStep() == 
Transformation.Kind.UNLOCK_SEQUENCE)
+                    assert trailing.executeNext().isContinuable();
             });
         }
     }
diff --git 
a/test/simulator/main/org/apache/cassandra/simulator/cluster/OnClusterReplace.java
 
b/test/simulator/main/org/apache/cassandra/simulator/cluster/OnClusterReplace.java
index 140da9f18e..e7e6a03d45 100644
--- 
a/test/simulator/main/org/apache/cassandra/simulator/cluster/OnClusterReplace.java
+++ 
b/test/simulator/main/org/apache/cassandra/simulator/cluster/OnClusterReplace.java
@@ -168,6 +168,13 @@ class OnClusterReplace extends OnClusterChangeTopology
                 assert bootstrapAndReplace.next.ordinal() == kind : 
String.format("Expected next step to be %s, but got %s", 
Transformation.Kind.values()[kind], bootstrapAndReplace.next);
                 boolean res = 
bootstrapAndReplace.executeNext().isContinuable();
                 assert res;
+
+                // On UNLOCK_SEQUENCE-supporting clusters FINISH_REPLACE no 
longer unlocks/retires the sequence;
+                // drain the trailing UNLOCK_SEQUENCE step so the range lock 
is released.
+                ClusterMetadata after = ClusterMetadata.current();
+                MultiStepOperation<?> trailing = 
after.inProgressSequences.get(metadata.myNodeId());
+                if (trailing != null && trailing.nextStep() == 
Transformation.Kind.UNLOCK_SEQUENCE)
+                    assert trailing.executeNext().isContinuable();
             });
         }
     }
diff --git 
a/test/unit/org/apache/cassandra/tcm/transformations/EventsMetadataTest.java 
b/test/unit/org/apache/cassandra/tcm/transformations/EventsMetadataTest.java
index 11fd03c696..bb767c94c1 100644
--- a/test/unit/org/apache/cassandra/tcm/transformations/EventsMetadataTest.java
+++ b/test/unit/org/apache/cassandra/tcm/transformations/EventsMetadataTest.java
@@ -136,6 +136,7 @@ public class EventsMetadataTest
 
         ClusterMetadataService.instance().commit(plan.midJoin);
         ClusterMetadataService.instance().commit(plan.finishJoin);
+        ClusterMetadataService.instance().commit(new 
org.apache.cassandra.tcm.transformations.UnlockSequence(nodeId, plan.lockKey));
 
         assertEquals(NodeState.JOINED, 
ClusterMetadata.current().directory.peerState(nodeId));
         assertTrue(ClusterMetadata.current().lockedRanges.locked.isEmpty());
@@ -170,6 +171,7 @@ public class EventsMetadataTest
         ClusterMetadataService.instance().commit(join.startJoin);
         ClusterMetadataService.instance().commit(join.midJoin);
         ClusterMetadataService.instance().commit(join.finishJoin);
+        ClusterMetadataService.instance().commit(new 
org.apache.cassandra.tcm.transformations.UnlockSequence(nodeId, join.lockKey));
 
         ClusterMetadata before = ClusterMetadata.current();
         ClusterMetadataService.instance().commit(new PrepareLeave(nodeId, 
true, PrepareLeaveTest.dummyPlacementProvider, LeaveStreams.Kind.UNBOOTSTRAP));;
@@ -184,6 +186,7 @@ public class EventsMetadataTest
         ClusterMetadataService.instance().commit(leave.startLeave);
         ClusterMetadataService.instance().commit(leave.midLeave);
         ClusterMetadataService.instance().commit(leave.finishLeave);
+        ClusterMetadataService.instance().commit(new 
org.apache.cassandra.tcm.transformations.UnlockSequence(nodeId, leave.lockKey));
 
     }
 


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

Reply via email to