Describe the bug
When a stream has its writer member on node A and replicas on nodes B and C, when nodes B and C are removed from the cluster in sequence (by forget_cluster_node), the writer on node A is terminated as a side effect of the first delete_replica and is not re-elected. The writer can't be healed by anything in the current stream coordinator API:
evaluate_stream/3 has no branch that re-acts on a writer already in {stopped, _, _}
- the only path to
{ready, NextEpoch} is the select_leader/2 call inside member_stopped's is_quorum branch, and once the first member_stopped event has fired for the writer, no later event re-evaluates the quorum predicate
- the
filter_command/3 clause for delete_replica rejects any further delete as last_stream_member
The log is still intact on A, but the writer can't be recovered. This sequence is actually OK as long as the writer was originally on B or C during the disaster event: then the stream can be recovered with a sequence of force-shrinks of Khepri, the Stream Coordinator (and any QQs), and then normal stream membership change commands.
Reproduction steps
I found this behavior by simulating a disaster scenario where 2/3 nodes of a cluster are lost.
- 3-node cluster with a stream declared.
- Identify the node running the stream writer, call it
A.
- Kill nodes
B and C (SIGTERM, simultaneous or staggered). Wait for rabbit on node B/C down on A. (Treat B and C as permanently lost now, all commands are against A from here on.)
- Force-shrink Khepri:
rabbitmqctl force_standalone_khepri_boot
- Force-shrink the stream coordinator:
rabbitmqctl eval 'ra_server_proc:force_shrink_members_to_current_member({rabbit_stream_coordinator, node()}).'
- Issue
delete_replica for B and then C. (forget_cluster_node also does this.) Note that the writer terminates on A (osiris_writer:terminate/2 ... reason:shutdown) when the first delete_replica is applied, and no started writer ... in 2 is logged.
The condition for triggering the bug is precisely 'the stream's writer was on the surviving node at the time delete_replica is first issued'. Streams whose writer was on one of the dead nodes recover normally via the {down, Pid, Reason} -> {down, Epoch} path in update_stream0/3.
add_replica invoked on the stuck stream fails with:
[warning] queue '<Q>' ... failed to add replica on 'rabbit@<D>':
{noproc,{gen_batch_server,call,[<OLD_WRITER_PID>,query_replication_state,5000]}}
where <OLD_WRITER_PID> is the PID that terminated at the osiris_writer:terminate line above.
rabbitmq-streams restart_stream <Q> hangs indefinitely and never returns. The command is applied to the Ra state machine, sets target = stopped on the single existing member (a no-op since it is already stopped), but evaluate_stream/3 finds no actionable state transition and never emits a reply effect, so the reply_to Pid waits forever.
Expected behavior
The stream coordinator should either not leave the writer member in a stopped state, or have an API to recover it. I would lean towards the former since that's how it behaves if the writer is on one of the lost nodes.
Additional context
This was reproduced against main from a few weeks ago, 2bd3449. Like QQ/Khepri/Coordinator force-shrinking, we can't guarantee that there won't be data loss in the disaster-recovery, but we can provide tools to recover availability. I think we might be able to fix it simply by re-evaluating is_quorum in member_deleted.
Describe the bug
When a stream has its writer member on node A and replicas on nodes B and C, when nodes B and C are removed from the cluster in sequence (by
forget_cluster_node), the writer on node A is terminated as a side effect of the firstdelete_replicaand is not re-elected. The writer can't be healed by anything in the current stream coordinator API:evaluate_stream/3has no branch that re-acts on a writer already in{stopped, _, _}{ready, NextEpoch}is theselect_leader/2call insidemember_stopped'sis_quorumbranch, and once the firstmember_stoppedevent has fired for the writer, no later event re-evaluates the quorum predicatefilter_command/3clause fordelete_replicarejects any further delete aslast_stream_memberThe log is still intact on A, but the writer can't be recovered. This sequence is actually OK as long as the writer was originally on B or C during the disaster event: then the stream can be recovered with a sequence of force-shrinks of Khepri, the Stream Coordinator (and any QQs), and then normal stream membership change commands.
Reproduction steps
I found this behavior by simulating a disaster scenario where 2/3 nodes of a cluster are lost.
A.BandC(SIGTERM, simultaneous or staggered). Wait forrabbit on node B/C downonA. (Treat B and C as permanently lost now, all commands are against A from here on.)rabbitmqctl force_standalone_khepri_bootrabbitmqctl eval 'ra_server_proc:force_shrink_members_to_current_member({rabbit_stream_coordinator, node()}).'delete_replicaforBand thenC. (forget_cluster_nodealso does this.) Note that the writer terminates onA(osiris_writer:terminate/2 ... reason:shutdown) when the firstdelete_replicais applied, and nostarted writer ... in 2is logged.The condition for triggering the bug is precisely 'the stream's writer was on the surviving node at the time
delete_replicais first issued'. Streams whose writer was on one of the dead nodes recover normally via the{down, Pid, Reason}->{down, Epoch}path inupdate_stream0/3.add_replicainvoked on the stuck stream fails with:where
<OLD_WRITER_PID>is the PID that terminated at theosiris_writer:terminateline above.rabbitmq-streams restart_stream <Q>hangs indefinitely and never returns. The command is applied to the Ra state machine, setstarget = stoppedon the single existing member (a no-op since it is already stopped), butevaluate_stream/3finds no actionable state transition and never emits a reply effect, so thereply_toPid waits forever.Expected behavior
The stream coordinator should either not leave the writer member in a stopped state, or have an API to recover it. I would lean towards the former since that's how it behaves if the writer is on one of the lost nodes.
Additional context
This was reproduced against
mainfrom a few weeks ago, 2bd3449. Like QQ/Khepri/Coordinator force-shrinking, we can't guarantee that there won't be data loss in the disaster-recovery, but we can provide tools to recover availability. I think we might be able to fix it simply by re-evaluatingis_quoruminmember_deleted.