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

asf-gitbox-commits pushed a commit to branch cassandra-4.0
in repository https://gitbox.apache.org/repos/asf/cassandra.git


The following commit(s) were added to refs/heads/cassandra-4.0 by this push:
     new 24dfcfe057 Ensure transferred_ranges reset on decommision re-attempt 
when pending ranges cannot be proven continous
24dfcfe057 is described below

commit 24dfcfe057412db2379bff48ff3bf8d54311c52b
Author: Matt Byrd <[email protected]>
AuthorDate: Mon Aug 3 16:25:49 2026 +0100

    Ensure transferred_ranges reset on decommision re-attempt when pending 
ranges cannot be proven continous
    
    Patch by Matt Byrd; reviewed by Caleb Rackliffe and Sam Tunnicliffe for 
CASSANDRA-16290
---
 CHANGES.txt                                        |   1 +
 .../org/apache/cassandra/db/SystemKeyspace.java    |  31 ++++-
 .../apache/cassandra/service/StorageService.java   |  31 ++++-
 .../distributed/test/ring/BootstrapTest.java       |   7 +-
 .../distributed/test/ring/DecommissionTest.java    | 150 +++++++++++++++++++++
 5 files changed, 214 insertions(+), 6 deletions(-)

diff --git a/CHANGES.txt b/CHANGES.txt
index a9a45d9ec5..5bbb1e16e0 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -1,4 +1,5 @@
 4.0.22
+ * Ensure transferred_ranges reset on decommision re-attempt when pending 
ranges cannot be proven continous (CASSANDRA-16290)
  * Add validation to uncompressed length during decompression (CASSANDRA-21567)
  * Fix regression in PasswordObfuscator for dollar-quoted passwords 
(CASSANDRA-21559)
  * Do not make DNS lookup when querying system_views.clients for hostname 
column by removing it (CASSANDRA-21539)
diff --git a/src/java/org/apache/cassandra/db/SystemKeyspace.java 
b/src/java/org/apache/cassandra/db/SystemKeyspace.java
index 56dd03a3c4..808e0bbc73 100644
--- a/src/java/org/apache/cassandra/db/SystemKeyspace.java
+++ b/src/java/org/apache/cassandra/db/SystemKeyspace.java
@@ -1477,6 +1477,25 @@ public final class SystemKeyspace
         availableRanges.truncateBlockingWithoutSnapshot();
     }
 
+    /**
+     * Wipes the whole table rather than deleting per (operation, keyspace): 
keyspace_name is part of the
+     * partition key, so an operation-scoped delete would have to iterate 
keyspaces. Safe because the
+     * decommission path is the only reader - see getTransferredRanges. 
Revisit if another topology
+     * change starts consulting this table.
+     */
+    public static synchronized void resetTransferredRanges()
+    {
+        
Keyspace.open(SchemaConstants.SYSTEM_KEYSPACE_NAME).getColumnFamilyStore(TRANSFERRED_RANGES_V2).truncateBlockingWithoutSnapshot();
+        try
+        {
+            
Keyspace.open(SchemaConstants.SYSTEM_KEYSPACE_NAME).getColumnFamilyStore(LEGACY_TRANSFERRED_RANGES).truncateBlockingWithoutSnapshot();
+        }
+        catch (Throwable t)
+        {
+            logger.warn("failed to truncate system table {} but it should no 
longer be being consulted", LEGACY_TRANSFERRED_RANGES, t);
+        }
+    }
+
     public static synchronized void updateTransferredRanges(StreamOperation 
streamOperation,
                                                          InetAddressAndPort 
peer,
                                                          String keyspace,
@@ -1493,11 +1512,19 @@ public final class SystemKeyspace
         executeInternal(String.format(cql, TRANSFERRED_RANGES_V2), 
rangesToUpdate, streamOperation.getDescription(), peer.address, peer.port, 
keyspace);
     }
 
-    public static synchronized Map<InetAddressAndPort, Set<Range<Token>>> 
getTransferredRanges(String description, String keyspace, IPartitioner 
partitioner)
+    // Only consulted on the decommission path, where the leaving node is the 
only streamer and its
+    // local transferred_ranges_v2 is therefore a complete record of what has 
already moved.
+    //
+    // Being node-local rules out the other topology changes. Under removenode 
the surviving replicas
+    // stream, and the node running it need not be one of them, so its local 
table may record nothing;
+    // even when it is a replica it can only account for its own streams. Move 
could use it for the
+    // ranges the moving node gives up, but the move path registers no 
listener so nothing is recorded,
+    // and it would still miss the ranges moving the other way.
+    public static synchronized Map<InetAddressAndPort, Set<Range<Token>>> 
getTransferredRanges(StreamOperation streamOperation, String keyspace, 
IPartitioner partitioner)
     {
         Map<InetAddressAndPort, Set<Range<Token>>> result = new HashMap<>();
         String query = "SELECT * FROM system.%s WHERE operation = ? AND 
keyspace_name = ?";
-        UntypedResultSet rs = executeInternal(String.format(query, 
TRANSFERRED_RANGES_V2), description, keyspace);
+        UntypedResultSet rs = executeInternal(String.format(query, 
TRANSFERRED_RANGES_V2), streamOperation.getDescription(), keyspace);
         for (UntypedResultSet.Row row : rs)
         {
             InetAddress peerAddress = row.getInetAddress("peer");
diff --git a/src/java/org/apache/cassandra/service/StorageService.java 
b/src/java/org/apache/cassandra/service/StorageService.java
index d3dab125e4..8bcc67dd31 100644
--- a/src/java/org/apache/cassandra/service/StorageService.java
+++ b/src/java/org/apache/cassandra/service/StorageService.java
@@ -4649,6 +4649,32 @@ public class StorageService extends 
NotificationBroadcasterSupport implements IE
         {
             PendingRangeCalculatorService.instance.blockUntilFinished();
 
+            // In 5.0 we need to handle DECOMMISSION_FAILED which can be 
legitimately resumed
+            // hence operationMode != NORMAL clause chosen, and kept same on 
4.0/4.1/5.0 for simplicity
+            // Checking OperationMode alone is insufficient since consider the 
following sequence:
+            //
+            // 1. Run a decommission, get far enough to persist transferred 
ranges, then fail, setting operationMode to DECOMMISSION_FAILED
+            // 2. Restart instance go NORMAL again, lose pending endpoints
+            // 3. Write comes in and is now not written to pending endpoint 
for one of the transferred ranges
+            // 4. Attempt decommission which fails before 
resetTransferredRanges, leaving operationMode as DECOMMISSION_FAILED
+            // 5. Re-attempt decommission which now sees operationMode == 
DECOMMISSION_FAILED
+            // (!= NORMAL) so skips resetting transferred ranges
+            // 6. decommission completes and write from step 3 has not been 
transferred via streaming or write path to the new owner.
+            //
+            // So checking tokenMetadata.isLeaving in addition means that on 
step 5 we now fail the isLeaving check
+            // (cleared by the restart in step 2, and step 4 never reached 
startLeaving) and truncate transferred ranges
+            boolean resumingInFlightDecommission = 
tokenMetadata.isLeaving(FBUtilities.getBroadcastAddressAndPort())
+                                                   && operationMode != 
Mode.NORMAL;
+
+            // We reset transferred ranges upon starting a new decommission so 
that we fully stream
+            // anything written since a previous attempt, which may not have 
been persisted to a pending endpoint.
+            // See CASSANDRA-16290.
+            if (!resumingInFlightDecommission)
+            {
+                logger.info("resetting transferred ranges to force 
re-streaming");
+                SystemKeyspace.resetTransferredRanges();
+            }
+
             String dc = 
DatabaseDescriptor.getEndpointSnitch().getLocalDatacenter();
 
             if (operationMode != Mode.LEAVING) // If we're already 
decommissioning there is no point checking RF/pending ranges
@@ -5647,8 +5673,7 @@ public class StorageService extends 
NotificationBroadcasterSupport implements IE
             if (rangesWithEndpoints.isEmpty())
                 continue;
 
-            //Description is always Unbootstrap? Is that right?
-            Map<InetAddressAndPort, Set<Range<Token>>> 
transferredRangePerKeyspace = SystemKeyspace.getTransferredRanges("Unbootstrap",
+            Map<InetAddressAndPort, Set<Range<Token>>> 
transferredRangePerKeyspace = 
SystemKeyspace.getTransferredRanges(StreamOperation.DECOMMISSION,
                                                                                
                                          keyspace,
                                                                                
                                          
StorageService.instance.getTokenMetadata().partitioner);
             RangesByEndpoint.Builder replicasPerEndpoint = new 
RangesByEndpoint.Builder();
@@ -5659,7 +5684,7 @@ public class StorageService extends 
NotificationBroadcasterSupport implements IE
                 Set<Range<Token>> transferredRanges = 
transferredRangePerKeyspace.get(remote.endpoint());
                 if (transferredRanges != null && 
transferredRanges.contains(local.range()))
                 {
-                    logger.debug("Skipping transferred range {} of keyspace 
{}, endpoint {}", local, keyspace, remote);
+                    logger.info("Skipping transferred range {} of keyspace {}, 
endpoint {}", local, keyspace, remote);
                     continue;
                 }
 
diff --git 
a/test/distributed/org/apache/cassandra/distributed/test/ring/BootstrapTest.java
 
b/test/distributed/org/apache/cassandra/distributed/test/ring/BootstrapTest.java
index d6e715a009..e0c6a7e8d0 100644
--- 
a/test/distributed/org/apache/cassandra/distributed/test/ring/BootstrapTest.java
+++ 
b/test/distributed/org/apache/cassandra/distributed/test/ring/BootstrapTest.java
@@ -107,9 +107,14 @@ public class BootstrapTest extends TestBaseImpl
     }
 
     public static void populate(ICluster cluster, int from, int to, int coord, 
int rf, ConsistencyLevel cl)
+    {
+        populate(cluster, from, to, coord, rf, cl, "pk int, ck int, v int");
+    }
+
+    public static void populate(ICluster cluster, int from, int to, int coord, 
int rf, ConsistencyLevel cl, String columnDefinitions)
     {
         cluster.schemaChange("CREATE KEYSPACE IF NOT EXISTS " + KEYSPACE + " 
WITH replication = {'class': 'SimpleStrategy', 'replication_factor': " + rf + 
"};");
-        cluster.schemaChange("CREATE TABLE IF NOT EXISTS " + KEYSPACE + ".tbl 
(pk int, ck int, v int, PRIMARY KEY (pk, ck))");
+        cluster.schemaChange("CREATE TABLE IF NOT EXISTS " + KEYSPACE + ".tbl 
(" + columnDefinitions + ", PRIMARY KEY (pk, ck))");
         for (int i = from; i < to; i++)
         {
             cluster.coordinator(coord).execute("INSERT INTO " + KEYSPACE + 
".tbl (pk, ck, v) VALUES (?, ?, ?)",
diff --git 
a/test/distributed/org/apache/cassandra/distributed/test/ring/DecommissionTest.java
 
b/test/distributed/org/apache/cassandra/distributed/test/ring/DecommissionTest.java
new file mode 100644
index 0000000000..1a913d1b74
--- /dev/null
+++ 
b/test/distributed/org/apache/cassandra/distributed/test/ring/DecommissionTest.java
@@ -0,0 +1,150 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.distributed.test.ring;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.Callable;
+import java.util.concurrent.Future;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import com.google.common.util.concurrent.Futures;
+
+import net.bytebuddy.ByteBuddy;
+import net.bytebuddy.dynamic.loading.ClassLoadingStrategy;
+import net.bytebuddy.implementation.MethodDelegation;
+import net.bytebuddy.implementation.bind.annotation.SuperCall;
+
+import org.junit.Test;
+
+import org.apache.cassandra.dht.Murmur3Partitioner;
+import org.apache.cassandra.distributed.Cluster;
+import org.apache.cassandra.distributed.api.ConsistencyLevel;
+import org.apache.cassandra.distributed.api.IInvokableInstance;
+import org.apache.cassandra.distributed.shared.ClusterUtils;
+import org.apache.cassandra.distributed.test.TestBaseImpl;
+import org.apache.cassandra.service.StorageService;
+
+import static net.bytebuddy.matcher.ElementMatchers.named;
+import static org.apache.cassandra.db.SystemKeyspace.TRANSFERRED_RANGES_V2;
+import static org.apache.cassandra.distributed.api.Feature.GOSSIP;
+import static org.apache.cassandra.distributed.api.Feature.NETWORK;
+import static 
org.apache.cassandra.distributed.test.ring.BootstrapTest.populate;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+public class DecommissionTest extends TestBaseImpl
+{
+    @Test
+    public void testAbortingDecommissionRestreams() throws Exception
+    {
+        // https://issues.apache.org/jira/browse/CASSANDRA-16290
+        // We demonstrate here that decommissioning and then aborting 
decommission is unsafe
+        // if we've persisted transferred ranges and then skip them for 
something which was delivered after we aborted the decommission but before we 
resumed
+
+        // Only install on the first classloader handed out for node2: a 
restarted instance gets a
+        // fresh classloader, so any static "already failed once" state would 
be reset and the
+        // resumed decommission would fail again. On this branch the 
initializer is a
+        // BiConsumer<ClassLoader, Integer>, so there is no generation 
argument to key off.
+        AtomicBoolean streamHintsInstalled = new AtomicBoolean();
+
+        try (Cluster cluster = builder().withNodes(4)
+                                        .withConfig(config -> 
config.with(NETWORK, GOSSIP)
+                                                                    // disable 
hints to simplify test
+                                                                    
.set("hinted_handoff_enabled", false)
+                                        )
+                                        .withInstanceInitializer((cl, num) -> {
+                                            if (num == 2 && 
streamHintsInstalled.compareAndSet(false, true))
+                                                BB.streamHintsInstall(cl);
+                                        })
+                                        .start())
+        {
+            // We need blob columns here so later we can do 
Murmur3Partitioner.LongToken.keyForToken(token);
+            populate(cluster, 0, 100, 1, 2, ConsistencyLevel.QUORUM, "pk blob, 
ck blob, v blob");
+
+            IInvokableInstance leavingNode = cluster.get(2);
+
+            leavingNode.nodetoolResult("decommission").asserts().failure();
+
+            // abort the decommission
+            ClusterUtils.stopUnchecked(leavingNode);
+            ClusterUtils.start(leavingNode, props -> {});
+            ClusterUtils.awaitRingHealthy(leavingNode);
+
+            // Stop the non leaving nodes so we can write at ONE and fail to 
stream that datum
+            ClusterUtils.stopUnchecked(cluster.get(1));
+            ClusterUtils.stopUnchecked(cluster.get(3));
+            ClusterUtils.stopUnchecked(cluster.get(4));
+
+            // Mirroring the upstream getLocalTokens which we don't have here
+            List<Murmur3Partitioner.LongToken> tokens = 
Collections.singletonList(new 
Murmur3Partitioner.LongToken(Long.parseLong(ClusterUtils.getLocalToken(leavingNode))));
+            for (Murmur3Partitioner.LongToken token : tokens)
+            {
+                ByteBuffer key = 
Murmur3Partitioner.LongToken.keyForToken(token);
+                leavingNode.coordinator().execute("INSERT INTO " + KEYSPACE + 
".tbl (pk, ck, v) VALUES (?, ?, ?)", ConsistencyLevel.ONE, key, key, key);
+            }
+
+            ClusterUtils.start(cluster.get(1), props -> {});
+            ClusterUtils.start(cluster.get(3), props -> {});
+            ClusterUtils.start(cluster.get(4), props -> {});
+
+            ClusterUtils.awaitRingHealthy(leavingNode);
+
+            Object[][] ranges = leavingNode.executeInternal("SELECT 
keyspace_name from system." + TRANSFERRED_RANGES_V2);
+
+            assertTrue("transferred ranges missing entirely", ranges.length > 
0);
+            assertTrue("transferred ranges present for keyspace", 
Arrays.stream(ranges).anyMatch(x -> x[0].equals(KEYSPACE)));
+
+            // Resume decomm
+            leavingNode.nodetoolResult("decommission").asserts().success();
+
+            // Try and read data we wrote at ONE at ALL
+            for (Murmur3Partitioner.LongToken token : tokens)
+            {
+                ByteBuffer key = 
Murmur3Partitioner.LongToken.keyForToken(token);
+                Object[][] resp = cluster.get(1).coordinator().execute("SELECT 
pk from " + KEYSPACE + ".tbl where pk=?", ConsistencyLevel.ALL, key);
+                assertTrue("We should get a response for this key we wrote it 
at ONE", resp.length > 0);
+                assertEquals(key, resp[0][0]);
+            }
+        }
+    }
+
+    public static class BB
+    {
+        static void streamHintsInstall(ClassLoader cl)
+        {
+            new ByteBuddy().rebase(StorageService.class)
+                           .method(named("streamHints"))
+                           .intercept(MethodDelegation.to(BB.class))
+                           .make()
+                           .load(cl, ClassLoadingStrategy.Default.INJECTION);
+        }
+
+        @SuppressWarnings({ "unused", "rawtypes" })
+        public static Future streamHints(@SuperCall Callable<Future> zuper)
+        {
+            // this is only installed on the first startup of the leaving 
node, so every invocation
+            // here belongs to the decommission attempt we want to fail at the 
last moment possible
+            return Futures.immediateFailedFuture(new IOException("failing 
hints so that decomm fails at last moment possible"));
+        }
+    }
+}


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

Reply via email to