|
| 1 | +# Copyright 2026 Redpanda Data, Inc. |
| 2 | +# |
| 3 | +# Use of this software is governed by the Business Source License |
| 4 | +# included in the file licenses/BSL.md |
| 5 | +# |
| 6 | +# As of the Change Date specified in that file, in accordance with |
| 7 | +# the Business Source License, use of this software will be governed |
| 8 | +# by the Apache License, Version 2.0 |
| 9 | + |
| 10 | +""" |
| 11 | +Regression tests which require a throttled raft0 recovery. |
| 12 | +""" |
| 13 | + |
| 14 | +import re |
| 15 | +import signal |
| 16 | +from dataclasses import dataclass |
| 17 | +from enum import Enum |
| 18 | +from typing import Any |
| 19 | + |
| 20 | +from ducktape.tests.test import TestContext |
| 21 | +from ducktape.utils.util import wait_until |
| 22 | + |
| 23 | +from rptest.clients.types import TopicSpec |
| 24 | +from rptest.services.cluster import cluster |
| 25 | +from rptest.tests.redpanda_test import RedpandaTest |
| 26 | +from rptest.utils.node_operations import NodeDecommissionWaiter |
| 27 | + |
| 28 | + |
| 29 | +class GroupConfigurationState(Enum): |
| 30 | + # no reconfiguration ongoing |
| 31 | + SIMPLE = "simple" |
| 32 | + # node being added |
| 33 | + TRANSITIONAL = "transitional" |
| 34 | + # node being removed |
| 35 | + JOINT = "joint" |
| 36 | + |
| 37 | + |
| 38 | +# regex for determining group state, the cpp is inconsistent with spacing so made to be whitespace agnostic. |
| 39 | +# An empty std::optional renders as "none" with fmt>=10 (dev) but as "{nullopt}" |
| 40 | +# with the fmt 9.1 used on this release branch, so accept both spellings. |
| 41 | +_EMPTY_OPTIONAL = r"(?:none|\{nullopt\})" |
| 42 | +_GROUP_CFG_OLD_UNSET_PATTERN = re.compile( |
| 43 | + rf"old\s*:\s*{_EMPTY_OPTIONAL}\s*,\s*revision\s*:" |
| 44 | +) |
| 45 | +_GROUP_CFG_UPDATE_UNSET_PATTERN = re.compile( |
| 46 | + rf"update\s*:\s*{_EMPTY_OPTIONAL}\s*,\s*version\s*:" |
| 47 | +) |
| 48 | + |
| 49 | + |
| 50 | +def is_old_config_set(cfg: str) -> bool: |
| 51 | + """given a raft configuration, do we have an old configuration""" |
| 52 | + return _GROUP_CFG_OLD_UNSET_PATTERN.search(cfg) is None |
| 53 | + |
| 54 | + |
| 55 | +def is_configuration_update_set(cfg: str) -> bool: |
| 56 | + """given a raft configuration is there an update (new nodes)""" |
| 57 | + return _GROUP_CFG_UPDATE_UNSET_PATTERN.search(cfg) is None |
| 58 | + |
| 59 | + |
| 60 | +def raft_configuration_to_configuration_state(cfg: str) -> GroupConfigurationState: |
| 61 | + """parse a config into the group configuration state""" |
| 62 | + has_old_config = is_old_config_set(cfg) |
| 63 | + has_update = is_configuration_update_set(cfg) |
| 64 | + if has_old_config: |
| 65 | + return GroupConfigurationState.JOINT |
| 66 | + if has_update: |
| 67 | + return GroupConfigurationState.TRANSITIONAL |
| 68 | + return GroupConfigurationState.SIMPLE |
| 69 | + |
| 70 | + |
| 71 | +@dataclass |
| 72 | +class TimeoutConfig: |
| 73 | + timeout_s: int |
| 74 | + backoff_s: int |
| 75 | + |
| 76 | + |
| 77 | +SHORT_TIMEOUT = TimeoutConfig(timeout_s=30, backoff_s=2) |
| 78 | +MEDIUM_TIMEOUT = TimeoutConfig(timeout_s=60, backoff_s=2) |
| 79 | +LONG_TIMEOUT = TimeoutConfig(timeout_s=120, backoff_s=2) |
| 80 | + |
| 81 | + |
| 82 | +class StuckRaft0LearnerTest(RedpandaTest): |
| 83 | + """Decommissioning a dead learner cancels the in-flight raft0 add.""" |
| 84 | + |
| 85 | + INITIAL_CLUSTER_SIZE = 3 |
| 86 | + # Seeds are [1,2,3] joiner is then 4 |
| 87 | + JOINER_NODE_ID = 4 |
| 88 | + |
| 89 | + def __init__(self, test_context: TestContext, *args: Any, **kwargs: Any): |
| 90 | + # 4 nodes: 3 initial seeds + 1 joiner held in reserve. |
| 91 | + super().__init__( |
| 92 | + test_context, |
| 93 | + num_brokers=4, |
| 94 | + *args, |
| 95 | + **kwargs, |
| 96 | + ) |
| 97 | + |
| 98 | + def setUp(self) -> None: |
| 99 | + # Manual start so we can hold the joiner in reserve. |
| 100 | + pass |
| 101 | + |
| 102 | + # ── helpers ───────────────────────────────────────────────────────── |
| 103 | + |
| 104 | + def _controller_state(self) -> GroupConfigurationState | None: |
| 105 | + """get the controller group configuration state from the controller leader""" |
| 106 | + for node in self.redpanda.started_nodes(): |
| 107 | + try: |
| 108 | + state = self.redpanda._admin.get_partition_state( |
| 109 | + "redpanda", "controller", 0, node=node |
| 110 | + ) |
| 111 | + except Exception: |
| 112 | + continue |
| 113 | + |
| 114 | + for replica in state.get("replicas", []): |
| 115 | + raft_state = replica.get("raft_state", {}) |
| 116 | + # only consider the leaders perspective |
| 117 | + if not raft_state.get("is_leader"): |
| 118 | + continue |
| 119 | + cfg = raft_state.get("group_configuration", "") |
| 120 | + return raft_configuration_to_configuration_state(cfg) |
| 121 | + return None |
| 122 | + |
| 123 | + def _node_in_raft0(self, node_id: int) -> bool: |
| 124 | + """True if ``node_id`` is in the leader's raft0 group configuration""" |
| 125 | + for node in self.redpanda.started_nodes(): |
| 126 | + try: |
| 127 | + state = self.redpanda._admin.get_partition_state( |
| 128 | + "redpanda", "controller", 0, node=node |
| 129 | + ) |
| 130 | + except Exception: |
| 131 | + continue |
| 132 | + for replica in state.get("replicas", []): |
| 133 | + rs = replica.get("raft_state", {}) |
| 134 | + # only consider the leader's perspective |
| 135 | + if not rs.get("is_leader"): |
| 136 | + continue |
| 137 | + cfg = rs.get("group_configuration", "") |
| 138 | + if not isinstance(cfg, str): |
| 139 | + continue |
| 140 | + return f"id: {node_id}" in cfg |
| 141 | + return False |
| 142 | + |
| 143 | + # ── test ──────────────────────────────────────────────────────────── |
| 144 | + |
| 145 | + @cluster(num_nodes=4) |
| 146 | + def test_decommission_cancels_in_flight_raft0_add(self): |
| 147 | + """ |
| 148 | + Decommissioning a raft0 learner should cancel the underlying raft0 reconfiguration rather than waiting for it to complete and then decommissioning. |
| 149 | + Without this, a lost learner can lock membership changes. |
| 150 | +
|
| 151 | + Steps: |
| 152 | + 1. start a 3 node cluster with throttled raft0 learner rate |
| 153 | + 2. push controller commands to fill the log past snapshot |
| 154 | + 3. join node 4 |
| 155 | + 4. wait for / assert we see node 4 as a learner |
| 156 | + 5. kill node 4 |
| 157 | + 6. decommission node 4 |
| 158 | + 7. wait for / assert raft0 returns to simple |
| 159 | + 8. assert clean removal of 4 |
| 160 | + """ |
| 161 | + # 1. Start the first 3 of 4 allocated nodes; the 4th is the joiner. |
| 162 | + seed_nodes = self.redpanda.nodes[: self.INITIAL_CLUSTER_SIZE] |
| 163 | + joiner = self.redpanda.nodes[self.INITIAL_CLUSTER_SIZE] |
| 164 | + |
| 165 | + self.logger.info( |
| 166 | + f"[raft0-cancel] step 1: starting {len(seed_nodes)}-node " |
| 167 | + f"cluster (seeds: {[n.name for n in seed_nodes]}); " |
| 168 | + f"holding {joiner.name} in reserve" |
| 169 | + ) |
| 170 | + self.redpanda.set_seed_servers(seed_nodes) |
| 171 | + |
| 172 | + self.redpanda.add_extra_rp_conf( |
| 173 | + { |
| 174 | + "internal_topic_replication_factor": self.INITIAL_CLUSTER_SIZE, |
| 175 | + "raft_learner_recovery_rate": 0, |
| 176 | + "controller_log_learner_recovery_rate_enabled": True, |
| 177 | + } |
| 178 | + ) |
| 179 | + self.redpanda.start(nodes=seed_nodes, omit_seeds_on_idx_one=False) |
| 180 | + self.logger.info("[raft0-cancel] cluster up") |
| 181 | + |
| 182 | + # 2. Add some non-bootstrap state to the controller log so that |
| 183 | + # catch-up actually has data to ship |
| 184 | + self.logger.info("[raft0-cancel] step 2: creating test topic") |
| 185 | + topic = TopicSpec(replication_factor=3, partition_count=10) |
| 186 | + self.client().create_topic(topic) |
| 187 | + |
| 188 | + # Sanity: raft0 is `simple` on a healthy 3-node cluster. |
| 189 | + wait_until( |
| 190 | + lambda: self._controller_state() == GroupConfigurationState.SIMPLE, |
| 191 | + timeout_sec=SHORT_TIMEOUT.timeout_s, |
| 192 | + backoff_sec=SHORT_TIMEOUT.backoff_s, |
| 193 | + err_msg="raft0 did not start in simple state", |
| 194 | + ) |
| 195 | + self.logger.info("[raft0-cancel] raft0 confirmed `simple`") |
| 196 | + |
| 197 | + # 3. Start the joiner |
| 198 | + self.logger.info( |
| 199 | + f"[raft0-cancel] step 3: starting joiner {joiner.name} " |
| 200 | + f"with skip_readiness_check=True" |
| 201 | + ) |
| 202 | + self.redpanda.start_node(joiner, skip_readiness_check=True) |
| 203 | + |
| 204 | + def joiner_in_brokers() -> bool: |
| 205 | + for survivor in seed_nodes: |
| 206 | + try: |
| 207 | + brokers = self.redpanda._admin.get_brokers(node=survivor) |
| 208 | + except Exception: |
| 209 | + continue |
| 210 | + return any(b.get("node_id") == self.JOINER_NODE_ID for b in brokers) |
| 211 | + return False |
| 212 | + |
| 213 | + wait_until( |
| 214 | + joiner_in_brokers, |
| 215 | + timeout_sec=LONG_TIMEOUT.timeout_s, |
| 216 | + backoff_sec=LONG_TIMEOUT.backoff_s, |
| 217 | + err_msg="joiner never appeared in the leader's broker list", |
| 218 | + ) |
| 219 | + joiner_id = self.JOINER_NODE_ID |
| 220 | + self.logger.info( |
| 221 | + f"[raft0-cancel] joiner appeared in cluster as node_id={joiner_id}" |
| 222 | + ) |
| 223 | + |
| 224 | + # 4. Wait for raft0 to enter `transitional` with the joiner as |
| 225 | + # the in-flight learner addition. |
| 226 | + self.logger.info( |
| 227 | + "[raft0-cancel] step 4: waiting for raft0 to enter `transitional`" |
| 228 | + ) |
| 229 | + wait_until( |
| 230 | + lambda: ( |
| 231 | + self._controller_state() == GroupConfigurationState.TRANSITIONAL |
| 232 | + and self._node_in_raft0(joiner_id) |
| 233 | + ), |
| 234 | + timeout_sec=MEDIUM_TIMEOUT.timeout_s, |
| 235 | + backoff_sec=MEDIUM_TIMEOUT.backoff_s, |
| 236 | + err_msg="raft0 never entered transitional state with joiner present", |
| 237 | + ) |
| 238 | + self.logger.info( |
| 239 | + "[raft0-cancel] raft0 is `transitional` (learner pending promotion)" |
| 240 | + ) |
| 241 | + |
| 242 | + # 5. kill the joiner while it is still a learner. |
| 243 | + self.logger.info( |
| 244 | + f"[raft0-cancel] step 5: SIGKILLing joiner node_id={joiner_id} " |
| 245 | + f"mid-promotion" |
| 246 | + ) |
| 247 | + self.redpanda.remove_from_started_nodes(joiner) |
| 248 | + self.redpanda.signal_redpanda(joiner, signal=signal.SIGKILL, idempotent=True) |
| 249 | + |
| 250 | + # 6. Decommission the dead joiner, should un-add from learners |
| 251 | + self.logger.info(f"[raft0-cancel] step 6: decommissioning node_id={joiner_id}") |
| 252 | + survivor = self.redpanda.controller() |
| 253 | + assert survivor is not None, "no controller leader to send decommission to" |
| 254 | + self.redpanda._admin.decommission_broker(joiner_id, node=survivor) |
| 255 | + |
| 256 | + # 7. Wait for raft0 to return to `simple` with the joiner removed |
| 257 | + # from raft0's group_configuration. |
| 258 | + self.logger.info( |
| 259 | + "[raft0-cancel] step 7: waiting for raft0 to return to `simple` " |
| 260 | + "with joiner removed" |
| 261 | + ) |
| 262 | + wait_until( |
| 263 | + lambda: ( |
| 264 | + self._controller_state() == GroupConfigurationState.SIMPLE |
| 265 | + and not self._node_in_raft0(joiner_id) |
| 266 | + ), |
| 267 | + timeout_sec=LONG_TIMEOUT.timeout_s, |
| 268 | + backoff_sec=LONG_TIMEOUT.backoff_s, |
| 269 | + err_msg=( |
| 270 | + "raft0 did not return to simple with joiner removed — " |
| 271 | + "decommission appears stalled on configuration_change_in_progress" |
| 272 | + ), |
| 273 | + ) |
| 274 | + self.logger.info( |
| 275 | + "[raft0-cancel] raft0 returned to `simple`; joiner removed from raft0" |
| 276 | + ) |
| 277 | + |
| 278 | + # 8. And the broker should be fully removed from cluster |
| 279 | + # membership. |
| 280 | + self.logger.info( |
| 281 | + "[raft0-cancel] step 8: waiting for broker removal from membership" |
| 282 | + ) |
| 283 | + recovery_waiter = NodeDecommissionWaiter( |
| 284 | + self.redpanda, |
| 285 | + joiner_id, |
| 286 | + self.logger, |
| 287 | + progress_timeout=MEDIUM_TIMEOUT.timeout_s, |
| 288 | + ) |
| 289 | + recovery_waiter.wait_for_removal() |
| 290 | + self.logger.info("[raft0-cancel] joiner removed from cluster membership") |
| 291 | + |
| 292 | + # Final sanity. |
| 293 | + assert self._controller_state() == GroupConfigurationState.SIMPLE |
| 294 | + assert not self._node_in_raft0(joiner_id), ( |
| 295 | + f"joiner {joiner_id} still in raft0 after decommission completed" |
| 296 | + ) |
| 297 | + assert not joiner_in_brokers(), ( |
| 298 | + f"joiner {joiner_id} still in broker list after decommission completed" |
| 299 | + ) |
| 300 | + self.logger.info("[raft0-cancel] all assertions passed — test PASSED") |
0 commit comments