Transfer stream leadership on node drain#16871
Transfer stream leadership on node drain#16871MugemaneBertin2001 wants to merge 7 commits intorabbitmq:mainrabbitmq/rabbitmq-server:mainfrom MugemaneBertin2001:fix-16340-transfer-stream-leaders-on-drainMugemaneBertin2001/rabbitmq-server:fix-16340-transfer-stream-leaders-on-drainCopy head branch name to clipboard
Conversation
`rabbit_quorum_queue:drain/1` walks every quorum queue with a local leader and stops the local Ra leader so a peer takes over. `rabbit_stream_queue:drain/1` only transferred the stream coordinator's own leadership and left individual stream leaders in place. Re-election therefore only happened when the drained node was actually stopped. On a cluster with many streams and thousands of clients, that moment produced a mass reconnect that starved the stream coordinator and kept streams reported as `down` for minutes. Enumerate local stream leaders on drain and call `rabbit_stream_coordinator:restart_stream/2` with a `preferred_leader_node` that is both online and already a replica of the stream. rabbitmq#16340.
|
Tick the box to add this pull request to the merge queue (same as
|
Drain closes local client connections, so calling `queue.delete`
through the channel opened on node A returned `{noproc, ...}` and
marked the test as failed even after the leadership transfer had
succeeded. The QQ equivalent lets `end_per_testcase` handle cleanup.
Mirror that.
There was a problem hiding this comment.
Pull request overview
This PR improves RabbitMQ stream maintenance behavior by ensuring that draining a node transfers per-stream leadership (not just the stream coordinator), reducing shutdown-time reconnect storms and avoiding prolonged “stream down” periods in large clusters (issue #16340).
Changes:
- Added
rabbit_amqqueue:list_local_stream_leaders/0to enumerate stream queues whose current leader is on the local node - Updated
rabbit_stream_queue:drain/1to initiate leadership transfer for all locally-led streams viarabbit_stream_coordinator:restart_stream/2 - Added a regression test in
maintenance_mode_SUITEto verify local stream leaders are moved off the drained node
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| deps/rabbit/test/maintenance_mode_SUITE.erl | Adds a regression test that drains a node and asserts local stream leadership is transferred away |
| deps/rabbit/src/rabbit_stream_queue.erl | Extends drain behavior to restart streams locally led on the drained node, selecting an eligible preferred leader |
| deps/rabbit/src/rabbit_amqqueue.erl | Introduces list_local_stream_leaders/0 helper used during drain to find streams led locally |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
kjnilsson
left a comment
There was a problem hiding this comment.
I think we need to handle the case where there are no candidates, otherwise this change looks fine. Cheers.
`transfer_leadership_of_stream/2` previously fell back to calling `rabbit_stream_coordinator:restart_stream/2` with an empty options map when none of the transfer candidates hosted a replica for the stream. `restart_stream/2` still restarts the stream in that case, which can re-elect the leader onto the draining node (or another node under maintenance) — the opposite of what draining wants. Skip the restart when the eligible-replica list is empty and log at info level. Shutdown-time re-election handles the fallback. rabbitmq#16340.
|
@kjnilsson addressed the feedback in 29583b2 — |
`stream_leadership_transfer` forces the leader onto A and then drains A immediately. If B and C have not yet come up as running members under the new epoch, drain's `restart_stream` sees A ahead of them in on-disk state, `select_leader/2` picks A back, and the assertion that `list_local_stream_leaders/0` becomes empty times out. The failure was observed on the `parallel-ct-set-5` OTP-28 job with the error log confirming the coordinator re-elected the leader on A. The identical run in the mixed-cluster job passed on the same commit. Poll `rabbit_stream_coordinator:members/1` until all three members report a live pid before calling `drain_node/2`. The stream is empty in this test, so once every replica is running under the current epoch it ties A in state and the `preferred_leader_node` hint from `transfer_leadership_of_stream/2` decides the election. rabbitmq#16340.
`rabbit_stream_coordinator:select_leader/2` (machine version 4+) only honours `preferred_leader_node` when that node ties the current writer on the stopped tail. Right after a stream's writer advances an epoch on the local node — as happens when the drain sequence stops the members before the replicas have caught up on the new epoch marker — the draining node has a higher tail than B and C, so it lands in the `Potential` set alone and re-elects itself regardless of the hint. That matches the failing CT log for `stream_leadership_transfer`: `Stream ... new leader is on node rmq-ct-stream_leadership_transfer-1` — same node we were draining, epoch 3. Retry `restart_stream/2` up to three times with a 500 ms backoff when the returned leader is the local node. Between attempts the members run under the new epoch long enough for the replicas to catch up, so the next `restart_stream` finds their tails tied with the writer and the `preferred_leader_node` hint wins. The previous test change reverted here (`members/1` polling) only guaranteed the replicas were up, not that their tails had caught up, so it did not fix the race. rabbitmq#16340.
|
Root cause is in Fixed in |
|
I'm testing this PR on Kubernetes. I have a 3-node cluster with |
|
Problems can be seen with a simple I'm seeing: and: After a few minutes, it's still trying to drain the first node. As soon as I stop stream-perf-test, the restart can make progress and succeeds pretty quickly. |
Under load (e.g. `stream-perf-test -sc 100 -r 1000`), a serial run of `restart_stream/2` for every local leader can hammer the stream coordinator: each command can take up to `stream_cmd_timeout` per raft member before falling through, and three retries per stream multiply the pressure. Reported in rabbitmq#16871 (`rabbit-3` coordinator timeout, `{error, coordinator_unavailable}` on `stream-093`). Changes to `transfer_leadership_of_local_stream_leaders/1`: * Cap total wall-clock at 30s. Remaining streams are left to shutdown- time re-election so the drain does not stretch into minutes. * Bail out on the first `{error, coordinator_unavailable}` or `{timeout, _}` from `restart_stream/2`; continuing to fire commands against an already-overloaded coordinator only prolongs the outage. * Drop retries from three to two. * Insert a 25 ms breather between successful transfers to reduce the coordinator command queue depth. * Summarise the transferred/skipped/remaining counts at the end of the drain so operators can tell what happened.
|
@mkuratczyk thanks for running this on a real cluster — those two reports pointed at a genuine problem with the drain loop under load. Pushed 571f7ff. On the Root cause was on my side: the drain loop was firing
On the K8s That's Ready for another test cycle when you have a moment. Title updated back — the DO NOT MERGE tag was left on so the maintainer flip is intentional if you want to keep it there. cc @kjnilsson |
`rabbit_stream_coordinator:process_command/2` recurses across the
coordinator members on raft timeouts and, if every member fails,
returns `{error, coordinator_unavailable}` — not `{timeout, _}`. The
timeout branch in `restart_stream_off_local/4` was therefore dead code
(dialyzer flagged it in CI).
Keep the `coordinator_unavailable` handling as the sole overload bail-
out path and let any other error fall through to `skipped`.
lukebakken
left a comment
There was a problem hiding this comment.
A few things worth a look, mostly around the drain-time budget. Main question: does this bound drain wall-clock, or just the number of streams we attempt? See inline on the deadline check.
| transfer_leaders_until([], _Candidates, _Deadline, Acc) -> | ||
| Acc; | ||
| transfer_leaders_until([Q | Rest] = Queues, Candidates, Deadline, Acc) -> | ||
| case erlang:monotonic_time(millisecond) >= Deadline of |
There was a problem hiding this comment.
The deadline is only checked between streams. restart_stream/2 -> process_command/2 retries each coordinator member at cmd_timeout() (30s) before giving up, so one stuck stream can block ~90s on a 3-member coordinator - well past the 30s budget. Cap intent is per-stream count, not wall-clock; worth clarifying in the comment or bounding the call itself.
| %% Small pause between per-stream restart_stream commands to reduce | ||
| %% coordinator command queue pressure when there are many local leaders. | ||
| -define(STREAM_DRAIN_INTER_STREAM_MS, 25). | ||
| -define(STREAM_DRAIN_RESTART_ATTEMPTS, 2). |
There was a problem hiding this comment.
beb58de9cd set this to 3, arguing replicas need a couple of 500ms backoffs to tie the writer's tail before preferred_leader_node wins; 571f7ff225 dropped it to 2 (one backoff). Deliberate? Is CI stable across repeated runs of stream_leadership_transfer at 2?
| %% can tie them and let the hint win. | ||
| timer:sleep(?STREAM_DRAIN_RESTART_BACKOFF_MS), | ||
| restart_stream_off_local(Q, QNameStr, Preferred, Attempts - 1); | ||
| {ok, NewLeader} -> |
There was a problem hiding this comment.
Only NewLeader =:= node() is rejected. select_leader/2 isn't constrained to Preferred, so if another replica-hosting node is draining concurrently it can win and we count it as transferred. Narrow (rolling restarts drain one at a time), but that node then shuts down and re-elects under load.
| restart_stream_off_local(Q, QNameStr, Preferred, Attempts) -> | ||
| Options = #{preferred_leader_node => Preferred}, | ||
| case rabbit_stream_coordinator:restart_stream(Q, Options) of | ||
| {ok, NewLeader} when NewLeader =:= node() -> |
There was a problem hiding this comment.
This works around select_leader/2 treating preferred_leader_node as a tie-breaker only (single longest-tail member is returned directly, coordinator.erl:2238). Retrying hopes replicas tie within the backoff. Longer term, coordinator-side node exclusion (like QQ drain stopping the local Ra server) would be deterministic.
| amqqueue:get_state(Q) =/= crashed, amqqueue:get_leader_node(Q) =:= node()]. | ||
|
|
||
| -spec list_local_stream_leaders() -> [amqqueue:amqqueue()]. | ||
| list_local_stream_leaders() -> |
There was a problem hiding this comment.
This is is_local_to_node/2 inlined (and calls get_pid/1 twice). Could be [Q || Q <- list_local_stream_queues(), is_local_to_node(amqqueue:get_pid(Q), node())] - matches list_local/1.
| ?LOG_INFO("Will transfer leadership of ~b streams with current leader on this node", | ||
| [length(LocalLeaders)]), | ||
| Deadline = erlang:monotonic_time(millisecond) + ?STREAM_DRAIN_BUDGET_MS, | ||
| Outcome = transfer_leaders_until(LocalLeaders, TransferCandidates, Deadline, |
There was a problem hiding this comment.
remaining is only ever set on early exit; it's total - transferred - skipped. Could drop it from the map and compute once at the call site.
| transfer_leadership_of_stream(Q, TransferCandidates) -> | ||
| %% A stream can only elect a new leader on a node that already hosts a replica. | ||
| Replicas = get_nodes(Q), | ||
| QNameStr = rabbit_misc:rs(amqqueue:get_name(Q)), |
There was a problem hiding this comment.
Formatted for every stream, but on the transferred path only used by a ?LOG_DEBUG. Cheap to format lazily in the branches that log at info/warning.
Closes #16340 (partially — this covers the missing per-stream leadership transfer; the coordinator starvation scenario is separate).
rabbit_quorum_queue:drain/1enumerates every quorum queue with a local leader and stops the local Ra leader so a peer takes over.rabbit_stream_queue:drain/1only transferred the stream coordinator's own leadership and left individual stream leaders in place, so re-election for each stream only kicked in when the drained node was actually stopped.On a cluster with many streams and a lot of clients, that produced a mass reconnect at shutdown time, which starved the stream coordinator and kept streams reported as
downfor minutes — exactly what the reporter in #16340 observed.Change
rabbit_amqqueue:list_local_stream_leaders/0, mirrorslist_local_leaders/0.rabbit_stream_queue:drain/1now walks that list and callsrabbit_stream_coordinator:restart_stream/2with apreferred_leader_nodethat is both online (not under maintenance) and already a replica of the stream.maintenance_mode_SUITE(cluster_size_3): declare a stream, pin its leader to node A, drain A, assertlist_local_stream_leaders/0on A becomes empty.Scope
This does not touch the coordinator starvation scenario reported in the second half of #16340 (thousands of reconnects contending for a single-threaded coordinator on shutdown). That's a scheduler / backpressure problem in the stream coordinator and belongs in a separate change.