Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGES.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
6.0-alpha3
* Allow nodetool cms reconfigure to ignore nodes via --ignore, excluding them from the new CMS (CASSANDRA-21627)
* Add a guardrail to disallow reconfiguring CMS below a minimum size (CASSANDRA-19195)
* Node can report DECOMMISSION_FAILED while actively leaving after a rejected decommission attempt (CASSANDRA-21583)
* Make NoSpamLogger to use Caffeine cache instead of unbounded cache that could lead to memory exhaustion (CASSANDRA-21474)
Expand Down
16 changes: 14 additions & 2 deletions src/java/org/apache/cassandra/tcm/CMSOperations.java
Original file line number Diff line number Diff line change
Expand Up @@ -163,18 +163,30 @@ public void resumeReconfigureCms()

@Override
public void reconfigureCMS(int rf)
{
reconfigureCMS(rf, Collections.emptyList());
}

@Override
public void reconfigureCMS(int rf, List<String> ignoredEndpoints)
{
ReplicationParams params = ReplicationParams.simpleMeta(rf, ClusterMetadata.current().directory.knownDatacenters());
guardMinimumCmsSize(params);
cms.reconfigureCMS(params);
cms.reconfigureCMS(params, ignoredEndpoints);
}

@Override
public void reconfigureCMS(Map<String, Integer> rf)
{
reconfigureCMS(rf, Collections.emptyList());
}

@Override
public void reconfigureCMS(Map<String, Integer> rf, List<String> ignoredEndpoints)
{
ReplicationParams params = ReplicationParams.ntsMeta(rf);
guardMinimumCmsSize(params);
cms.reconfigureCMS(params);
cms.reconfigureCMS(params, ignoredEndpoints);
}

/**
Expand Down
2 changes: 2 additions & 0 deletions src/java/org/apache/cassandra/tcm/CMSOperationsMBean.java
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,9 @@ public interface CMSOperationsMBean
public void abortInitialization(String initiator);
public void resumeReconfigureCms();
public void reconfigureCMS(int rf);
public void reconfigureCMS(int rf, List<String> ignoredEndpoints);
public void reconfigureCMS(Map<String, Integer> rf);
public void reconfigureCMS(Map<String, Integer> rf, List<String> ignoredEndpoints);
public Map<String, List<String>> reconfigureCMSStatus();
public void cancelReconfigureCms();

Expand Down
73 changes: 69 additions & 4 deletions src/java/org/apache/cassandra/tcm/ClusterMetadataService.java
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
package org.apache.cassandra.tcm;

import java.io.IOException;
import java.net.UnknownHostException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Collections;
Expand Down Expand Up @@ -497,13 +498,40 @@ public void upgradeFromGossip(List<String> ignoredEndpoints)
}

public void reconfigureCMS(ReplicationParams replicationParams)
{
reconfigureCMS(replicationParams, Collections.emptyList());
}

/**
* Reconfigure the CMS so that its membership satisfies the supplied replication params.
*
* Nodes named in {@code ignoredEndpoints}, along with any node currently considered down, are not eligible for
* membership of the new CMS. Excluding nodes that are about to be decommissioned lets an operator shrink a cluster
* with a single reconfiguration up front, rather than one per departing CMS member. The ignore list applies only to
* this call; it is not remembered, and does not change whether the CMS is later reported as needing reconfiguration.
*
* Note that a subsequent bootstrap, replace or move recomputes placement without any ignore list and may therefore
* reintroduce an ignored node to the CMS.
*/
public void reconfigureCMS(ReplicationParams replicationParams, List<String> ignoredEndpoints)
{
ClusterMetadata metadata = ClusterMetadata.current();
Set<NodeId> downNodes = new HashSet<>();
Set<NodeId> excludedNodes = new HashSet<>(resolveIgnoredEndpoints(metadata, ignoredEndpoints));
Set<NodeId> allJoinedNodes = Sets.newHashSetWithExpectedSize(metadata.directory.allJoinedEndpoints().size());
for (InetAddressAndPort ep : metadata.directory.allJoinedEndpoints())
{
NodeId id = metadata.directory.peerId(ep);
allJoinedNodes.add(id);
if (!FailureDetector.instance.isAlive(ep))
downNodes.add(metadata.directory.peerId(ep));
PrepareCMSReconfiguration.Complex transformation = new PrepareCMSReconfiguration.Complex(replicationParams, downNodes);
excludedNodes.add(id);
}

// Placement needs at least one candidate to work with; leaving none is rejected here because the strategy
// itself would fail on an assertion rather than reporting anything useful.
if (!excludedNodes.isEmpty() && excludedNodes.size() >= allJoinedNodes.size() && excludedNodes.containsAll(allJoinedNodes))
throw new IllegalStateException("Cannot reconfigure CMS as all joined nodes are DOWN or ignored");

PrepareCMSReconfiguration.Complex transformation = new PrepareCMSReconfiguration.Complex(replicationParams, excludedNodes);
transformation.verify(metadata);

ClusterMetadataService.instance()
Expand All @@ -512,6 +540,43 @@ public void reconfigureCMS(ReplicationParams replicationParams)
InProgressSequences.finishInProgressSequences(ReconfigureCMS.SequenceKey.instance);
}

/**
* Resolve operator supplied hosts to the NodeIds they identify, rejecting any host which does not exist in the
* cluster.
*/
private static Set<NodeId> resolveIgnoredEndpoints(ClusterMetadata metadata, List<String> ignoredEndpoints)
{
if (ignoredEndpoints.isEmpty())
return Collections.emptySet();

Set<InetAddressAndPort> ignored = new HashSet<>(ignoredEndpoints.size());
for (String host : ignoredEndpoints)
{
try
{
ignored.add(InetAddressAndPort.getByName(host));
}
catch (UnknownHostException e)
{
throw new IllegalArgumentException("Unknown host in ignore list: " + host, e);
}
}

// Unlike CMS initialization, the local node may legitimately be ignored here. Reconfiguring the CMS away from
// the node running the command is valid, and is expected when that node is itself due to be decommissioned.
Set<InetAddressAndPort> unknown = Sets.difference(ignored, metadata.directory.allAddresses());
if (!unknown.isEmpty())
{
String msg = "Ignored host(s) " + unknown + " don't exist in the cluster";
logger.error(msg);
throw new IllegalStateException(msg);
}

Set<NodeId> ignoredIds = metadata.directory.toNodeIds(ignored);
logger.info("Excluding operator specified hosts from CMS reconfiguration: {}", ignoredIds);
return ignoredIds;
}

public void ensureCMSPlacement(ClusterMetadata metadata)
{
if (TCM_SKIP_CMS_RECONFIGURATION_AFTER_TOPOLOGY_CHANGE.getBoolean())
Expand Down Expand Up @@ -1130,4 +1195,4 @@ public enum State
{
LOCAL, REMOTE, GOSSIP, RESET, OFFLINE_TOOL
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,12 @@
public abstract class PrepareCMSReconfiguration implements Transformation
{
private static final Logger logger = LoggerFactory.getLogger(PrepareCMSReconfiguration.class);

/**
* Nodes to exclude from the new CMS: those the failure detector considered down when the reconfiguration was
* initiated, plus any the operator excluded via {@code nodetool cms reconfigure --ignore}. Resolved by the
* initiating node and serialized so that every node executing or replaying this derives the same {@link Diff}.
*/
final Set<NodeId> downNodes;

public PrepareCMSReconfiguration(Set<NodeId> downNodes)
Expand Down Expand Up @@ -118,7 +124,9 @@ public void verify(ClusterMetadata prev)
int expectedSize = dcRf.values().stream().mapToInt(Integer::intValue).sum();
Set<NodeId> newCms = prepareNewCMS(dcRf, prev);
if (newCms.size() < (expectedSize / 2) + 1)
throw new IllegalStateException("Too many nodes are currently DOWN to safely perform the reconfiguration");
throw new IllegalStateException(String.format("Too many nodes are currently DOWN or ignored to safely perform " +
"the reconfiguration (only %d of %d members could be placed)",
newCms.size(), expectedSize));
}

private static void serializeDownNodes(PrepareCMSReconfiguration transformation, DataOutputPlus out, Version version) throws IOException
Expand Down
16 changes: 14 additions & 2 deletions src/java/org/apache/cassandra/tools/nodetool/CMSAdmin.java
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,13 @@ public static class ReconfigureCMS extends AbstractCommand
description = "Cancels any in progress CMS reconfiguration")
private boolean cancel = false;

@Option(paramLabel = "ignored_endpoints",
names = { "-i", "--ignore" },
description = "Hosts to exclude from the new CMS, in addition to any which are currently down. Useful " +
"before shrinking a cluster: excluding the nodes which are about to be decommissioned " +
"keeps the CMS membership stable, avoiding a reconfiguration per decommissioned member.")
private List<String> ignoredEndpoints = new ArrayList<>();

@CassandraUsage(usage = "[<replication factor>] or <datacenter>:<replication_factor> ... ", description = "Replication factor of new CMS")
@Parameters(paramLabel = "replication_factor", description = "Replication factors of new CMS in format <replication factor> or <datacenter>:<replication_factor>")
private List<String> args = new ArrayList<>();
Expand All @@ -148,13 +155,18 @@ protected void execute(NodeProbe probe)
{
if (!args.isEmpty())
throw new IllegalArgumentException("Replication factor should not be set if previous operation is resumed");
if (!ignoredEndpoints.isEmpty())
throw new IllegalArgumentException("Ignored hosts should not be set if previous operation is resumed");

probe.getCMSOperationsProxy().resumeReconfigureCms();
return;
}

if (cancel)
{
if (!ignoredEndpoints.isEmpty())
throw new IllegalArgumentException("Ignored hosts should not be set when cancelling a reconfiguration");

probe.getCMSOperationsProxy().cancelReconfigureCms();
return;
}
Expand All @@ -178,7 +190,7 @@ protected void execute(NodeProbe probe)
{
throw new IllegalArgumentException(String.format("Can not parse replication factor from %s", args.get(0)));
}
probe.getCMSOperationsProxy().reconfigureCMS(parsedRf);
probe.getCMSOperationsProxy().reconfigureCMS(parsedRf, ignoredEndpoints);
return;
}
else
Expand All @@ -200,7 +212,7 @@ protected void execute(NodeProbe probe)
}
}

probe.getCMSOperationsProxy().reconfigureCMS(parsedRfs);
probe.getCMSOperationsProxy().reconfigureCMS(parsedRfs, ignoredEndpoints);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,89 @@ public void testReconfigurePickAliveNodesIfPossible() throws Exception
}
}

@Test
public void testIgnoredNodesRemainExcludedWhileDecommissioning() throws Exception
{
try (Cluster cluster = init(Cluster.build(5)
.withConfig(conf -> conf.with(Feature.NETWORK, Feature.GOSSIP))
.start()))
{
// A single reconfiguration up front, excluding the nodes which are about to be decommissioned. Ignoring
// nodes 2 and 3 must produce the same placement as those nodes being down (see
// testReconfigurePickAliveNodesIfPossible), even though every node here is up.
cluster.get(1).nodetoolResult("cms", "reconfigure", "3",
"--ignore", broadcastAddress(cluster, 2),
"--ignore", broadcastAddress(cluster, 3))
.asserts().success();

Set<String> expectedCMSMembers = expectedCMS(cluster, 1, 4, 5);
cluster.forEach(inst -> assertEquals(expectedCMSMembers, ClusterUtils.getCMSMembers(inst)));

// The ignore list is not persisted, so while the ignored nodes are still members of the cluster the CMS
// is legitimately reported as not matching the placement they imply.
cluster.get(1).runOnInstance(() -> assertTrue(PrepareCMSReconfiguration.needsReconfiguration(ClusterMetadata.current())));

// Decommissioning a node which is not a CMS member does not trigger a reconfiguration, so membership is
// stable for the duration of the shrink and no further reconfiguration is required per departing node.
cluster.get(2).nodetoolResult("decommission", "--force").asserts().success();
assertEquals(expectedCMSMembers, ClusterUtils.getCMSMembers(cluster.get(1)));

cluster.get(3).nodetoolResult("decommission", "--force").asserts().success();
assertEquals(expectedCMSMembers, ClusterUtils.getCMSMembers(cluster.get(1)));

// Once the ignored nodes have left, the CMS matches the placement implied by the remaining nodes again.
cluster.get(1).runOnInstance(() -> assertFalse(PrepareCMSReconfiguration.needsReconfiguration(ClusterMetadata.current())));
}
}

@Test
public void testReconfigureIgnoreRejectsUnknownAndExcessiveHosts() throws Exception
{
try (Cluster cluster = init(Cluster.build(3)
.withConfig(conf -> conf.with(Feature.NETWORK, Feature.GOSSIP))
.start()))
{
// Each case below fails for a different reason, so assert on the message rather than just the exit status.
// A bare failure() would pass even if the relevant check were removed.

// A host which is not part of the cluster is rejected rather than silently ignored.
cluster.get(1).nodetoolResult("cms", "reconfigure", "3", "--ignore", "127.0.0.99")
.asserts().failure()
.errorContains("don't exist in the cluster")
.errorContains("127.0.0.99");

// A host which cannot be resolved at all is rejected rather than being silently dropped from the list.
cluster.get(1).nodetoolResult("cms", "reconfigure", "3", "--ignore", "999.999.999.999")
.asserts().failure()
.errorContains("Unknown host in ignore list: 999.999.999.999");

// Ignoring so many nodes that fewer than a quorum of the requested members can be placed is rejected.
cluster.get(1).nodetoolResult("cms", "reconfigure", "3",
"--ignore", broadcastAddress(cluster, 2),
"--ignore", broadcastAddress(cluster, 3))
.asserts().failure()
.errorContains("Too many nodes are currently DOWN or ignored to safely perform the reconfiguration");

// Ignoring every joined node leaves placement with no candidates at all. This is rejected up front, as
// the placement strategy would otherwise fail on an assertion rather than reporting anything useful.
cluster.get(1).nodetoolResult("cms", "reconfigure", "3",
"--ignore", broadcastAddress(cluster, 1),
"--ignore", broadcastAddress(cluster, 2),
"--ignore", broadcastAddress(cluster, 3))
.asserts().failure()
.errorContains("Cannot reconfigure CMS as all joined nodes are DOWN or ignored");

// Ignored hosts are meaningless when resuming or cancelling. Note that cancelling with nothing in flight
// fails on its own, so only the message distinguishes the guard from that unrelated failure.
cluster.get(1).nodetoolResult("cms", "reconfigure", "--resume", "--ignore", broadcastAddress(cluster, 2))
.asserts().failure()
.errorContains("Ignored hosts should not be set if previous operation is resumed");
cluster.get(1).nodetoolResult("cms", "reconfigure", "--cancel", "--ignore", broadcastAddress(cluster, 2))
.asserts().failure()
.errorContains("Ignored hosts should not be set when cancelling a reconfiguration");
}
}

@Test
public void testReconfigurationViolatesRackDiversityIfNecessary() throws Exception
{
Expand Down Expand Up @@ -345,4 +428,9 @@ private Set<String> expectedCMS(Cluster cluster, int... instanceIds)
expectedCMSMembers.add(cluster.get(id).config().broadcastAddress().getAddress().toString());
return expectedCMSMembers;
}

private String broadcastAddress(Cluster cluster, int instanceId)
{
return cluster.get(instanceId).config().broadcastAddress().getAddress().getHostAddress();
}
}
11 changes: 9 additions & 2 deletions test/resources/nodetool/help/cms
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,10 @@ SYNOPSIS
[(-pw <password> | --password <password>)]
[(-pwf <passwordFilePath> | --password-file <passwordFilePath>)]
[(-u <username> | --username <username>)] cms reconfigure
[(-c | --cancel)] [(-r | --resume)] [--status] [--] [<replication
factor>] or <datacenter>:<replication_factor> ...
[(-c | --cancel)]
[(-i <ignored_endpoints> | --ignore <ignored_endpoints>)...]
[(-r | --resume)] [--status] [--] [<replication factor>] or
<datacenter>:<replication_factor> ...

nodetool [(-h <host> | --host <host>)] [(-p <port> | --port <port>)]
[(-pw <password> | --password <password>)]
Expand Down Expand Up @@ -102,6 +104,11 @@ COMMANDS
should be resumed

With --cancel option, Cancels any in progress CMS reconfiguration

With --ignore option, Hosts to exclude from the new CMS, in addition to any
which are currently down. Useful before shrinking a cluster: excluding the
nodes which are about to be decommissioned keeps the CMS membership stable,
avoiding a reconfiguration per decommissioned member.
snapshot
Request a checkpointing snapshot of cluster metadata
unregister
Expand Down
12 changes: 10 additions & 2 deletions test/resources/nodetool/help/cms$reconfigure
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@ SYNOPSIS
[(-pw <password> | --password <password>)]
[(-pwf <passwordFilePath> | --password-file <passwordFilePath>)]
[(-u <username> | --username <username>)] cms reconfigure
[(-c | --cancel)] [(-r | --resume)] [--status] [--] [<replication
factor>] or <datacenter>:<replication_factor> ...
[(-c | --cancel)]
[(-i <ignored_endpoints> | --ignore <ignored_endpoints>)...]
[(-r | --resume)] [--status] [--] [<replication factor>] or
<datacenter>:<replication_factor> ...

OPTIONS
-c, --cancel
Expand All @@ -16,6 +18,12 @@ OPTIONS
-h <host>, --host <host>
Node hostname or ip address

-i <ignored_endpoints>, --ignore <ignored_endpoints>
Hosts to exclude from the new CMS, in addition to any which are
currently down. Useful before shrinking a cluster: excluding the
nodes which are about to be decommissioned keeps the CMS membership
stable, avoiding a reconfiguration per decommissioned member.

-p <port>, --port <port>
Remote jmx agent port number

Expand Down