Skip to content

Commit adbfe60

Browse files
committed
throttled_raft0_test: add learner cancellation...
... test Adds a regression test for deadlocked members_backend. When a node is added to the cluster, joins raft0 as a learner, and dies before it can transition to a voter, raft0 reconfigurations are blocked until this learner can recover. This prior required either the dead learner to recover, or node uuid override to unblock raft0 reconfiguration. This test validates the fix, which is that a decommission on a node which has not finished recovering (is a learner) should cancel the raft0 configuration as its 'node removal' step This allows a decommission to serve as the escape hatch when a raft0 learner has become irrevocably lost. (cherry picked from commit 8edf586)
1 parent 5066312 commit adbfe60

1 file changed

Lines changed: 300 additions & 0 deletions

File tree

Lines changed: 300 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,300 @@
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

Comments
 (0)