Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

Transfer stream leadership on node drain#16871

Open
MugemaneBertin2001 wants to merge 7 commits into
rabbitmq: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
Open

Transfer stream leadership on node drain#16871
MugemaneBertin2001 wants to merge 7 commits into
rabbitmq: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

@MugemaneBertin2001

Copy link
Copy Markdown
Contributor

Closes #16340 (partially — this covers the missing per-stream leadership transfer; the coordinator starvation scenario is separate).

rabbit_quorum_queue:drain/1 enumerates 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, 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 down for minutes — exactly what the reporter in #16340 observed.

Change

  • New rabbit_amqqueue:list_local_stream_leaders/0, mirrors list_local_leaders/0.
  • rabbit_stream_queue:drain/1 now walks that list and calls rabbit_stream_coordinator:restart_stream/2 with a preferred_leader_node that is both online (not under maintenance) and already a replica of the stream.
  • Regression test in maintenance_mode_SUITE (cluster_size_3): declare a stream, pin its leader to node A, drain A, assert list_local_stream_leaders/0 on 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.

`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.
@mergify

mergify Bot commented Jul 4, 2026

Copy link
Copy Markdown

Tick the box to add this pull request to the merge queue (same as @mergifyio queue).

  • Queue this pull request

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.
@kjnilsson
kjnilsson requested a review from Copilot July 6, 2026 08:04
@kjnilsson kjnilsson added this to the 4.4.0 milestone Jul 6, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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/0 to enumerate stream queues whose current leader is on the local node
  • Updated rabbit_stream_queue:drain/1 to initiate leadership transfer for all locally-led streams via rabbit_stream_coordinator:restart_stream/2
  • Added a regression test in maintenance_mode_SUITE to 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.

Comment thread deps/rabbit/src/rabbit_stream_queue.erl Outdated
@kjnilsson kjnilsson assigned kjnilsson and unassigned kjnilsson Jul 6, 2026

@kjnilsson kjnilsson left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@MugemaneBertin2001

Copy link
Copy Markdown
Contributor Author

@kjnilsson addressed the feedback in 29583b2transfer_leadership_of_stream/2 now skips the restart_stream/2 call when no online, non-maintenance replica is available among the transfer candidates, and logs at info level instead. Ready for another look when you have a moment.

`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.
@MugemaneBertin2001

Copy link
Copy Markdown
Contributor Author

bcdb0f8fba did not fix it — the sync check on rabbit_stream_coordinator:members/1 only proves the replicas are running as members, not that their stopped-tail matches the writer's. Both parallel-ct-set-5 runs failed on that commit with the same log line: Stream ... new leader is on node rmq-ct-stream_leadership_transfer-1@localhost, epoch 3.

Root cause is in rabbit_stream_coordinator:select_leader/2 (machine version 4+): preferred_leader_node is only honoured when the preferred node is tied with the current writer on the stopped tail. Right after restart_stream advances an epoch on the local node, the writer's tail is {Epoch, 0} while replicas still report empty, so the writer ends up alone in Potential and re-elects itself regardless of the hint.

Fixed in beb58de9cd at the drain site: 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 call finds their tails tied with the writer and the preferred_leader_node hint wins. The test change from bcdb0f8fba is reverted.

@mkuratczyk

Copy link
Copy Markdown
Contributor

I'm testing this PR on Kubernetes. I have a 3-node cluster with stream-perf-test -sc 100 -x 100 -y 0 -r 1000 running.
When I perform kubectl rollout restart sts rmq-server, and rmq-server-2 nodes is being drained, rmq-server-0 and rmq-server-1 log errors like this:

** Generic server <0.15003.0> terminating
** Last message in was {continue,
                        #{name => "__stream-036_1783429184925345148",
                          nodes =>
                           ['rabbit@rmq-server-2.rmq-nodes.foo',
                            'rabbit@rmq-server-0.rmq-nodes.foo',
                            'rabbit@rmq-server-1.rmq-nodes.foo'],
                          reference =>
                           {resource,<<"/">>,queue,<<"stream-036">>},
                          features => #{committed_offset_calculate => true},
                          leader_pid => <31794.14060.0>,
                          leader_node => 'rabbit@rmq-server-2.rmq-nodes.foo',
                          replica_nodes =>
                           ['rabbit@rmq-server-0.rmq-nodes.foo',
                            'rabbit@rmq-server-1.rmq-nodes.foo'],
                          epoch => 3,
                          event_formatter =>
                           {rabbit_stream_queue,format_osiris_event,
                            [{resource,<<"/">>,queue,<<"stream-036">>}]},
                          max_segment_size_bytes => 500000000,
                          retention => [{max_bytes,20000000000}],
                          filter_size => 16}}
** When Server state == undefined
** Reason for termination ==
** {badrpc,{'EXIT',killed}}

  crasher:
    initial call: osiris_replica:init/1
    pid: <0.15003.0>
    registered_name: []
    exception exit: {badrpc,{'EXIT',killed}}
      in function  gen_server:handle_common_reply/5 (gen_server.erl:2562)
    ancestors: [osiris_server_sup,osiris_sup,<0.131.0>]

@mkuratczyk

Copy link
Copy Markdown
Contributor

Problems can be seen with a simple make start-cluster as well:

make start-cluster
stream-perf-test  -sc 100 -x 100 -y 0 -r 1000
make restart-cluster

I'm seeing:

[warning] <0.2653.0> Coordinator timeout on server 'rabbit-3@FH7Q7R4JQ1' when processing command restart_stream
[warning] <0.2653.0> Failed to transfer leadership of stream queue 'stream-093' in vhost '/': {error, coordinator_unavailable}

and:

[warning] <0.1516.0> Error/Timeout when retrieving broker ''rabbit-2@FH7Q7R4JQ1'' metadata: {error, {erpc, timeout}}

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.

@michaelklishin michaelklishin changed the title Transfer stream leadership on node drain DO NOT MERGE Transfer stream leadership on node drain Jul 11, 2026
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.
@MugemaneBertin2001 MugemaneBertin2001 changed the title DO NOT MERGE Transfer stream leadership on node drain Transfer stream leadership on node drain Jul 14, 2026
@MugemaneBertin2001

Copy link
Copy Markdown
Contributor Author

@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 make start-cluster + stream-perf-test case (Coordinator timeout on server rabbit-3…, {error, coordinator_unavailable} on stream-093)

Root cause was on my side: the drain loop was firing restart_stream/2 serially for every local leader with up to 3 retries. Each command can wait up to stream_cmd_timeout (30 s) per raft member before falling through — so a single overloaded coordinator turns into minutes of wasted wall-clock while more commands pile up behind. That's why the drain never made progress until stream-perf-test stopped.

transfer_leadership_of_local_stream_leaders/1 now:

  • caps total wall-clock at 30 s (remaining streams fall back to shutdown-time re-election);
  • bails out on the first {error, coordinator_unavailable} or {timeout, _} from restart_stream/2 — hammering an overloaded coordinator only prolongs the outage;
  • drops retries from 3 to 2;
  • inserts a 25 ms breather between successful transfers so the coordinator command queue can drain;
  • logs a transferred=N, skipped=N, remaining=N summary so it's clear from the logs what actually happened.

On the K8s {badrpc,{'EXIT',killed}} from osiris_replica on the surviving nodes

That's restart_stream/2 semantics: the coordinator sets target = stopped for every member (writer + replicas), so replicas on the other nodes are killed and restarted alongside the writer. osiris_server_sup brings them back, but the transient stop-restart shows up as ugly crash reports on the surviving nodes. This is inherent to the current coordinator API — I don't see a lighter "just move the writer" primitive in rabbit_stream_coordinator. Happy to explore adding one (or capping the number of parallel restarts) if you think that's the right direction, but it feels like a separate change.

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 lukebakken left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@lukebakken lukebakken Jul 14, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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} ->

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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() ->

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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() ->

@lukebakken lukebakken Jul 14, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Stream Coordinator Performance and Availability Issue during Node Drain

5 participants

Morty Proxy This is a proxified and sanitized view of the page, visit original site.