Fix reconnect races that can leave producers permanently disconnected - #293
Open
AmiradelBeyg wants to merge 2 commits into
Open
Fix reconnect races that can leave producers permanently disconnected#293AmiradelBeyg wants to merge 2 commits into
AmiradelBeyg wants to merge 2 commits into
Conversation
1 task
Every ChannelDisconnected event enqueued a new CloseChannel + EstablishNewChannel action. When a second disconnected event arrived while a reconnect was already in flight (e.g. a failed in-flight send racing the connection-level disconnect), the stale action would later close the freshly re-established channel, deregistering a healthy producer/consumer from the broker and forcing another reconnect cycle. ScheduleReconnect now runs at most one reconnect action at a time and re-evaluates the channel state after the action completes, so a disconnect arriving mid-reconnect is never lost and a replacement channel is never closed by a stale action.
Handle is invoked synchronously on the caller's thread, so events race: ChannelConnected arrives from the connection response continuation while ChannelDisconnected arrives from the message dispatcher when an in-flight send fails. The switch writes ChannelState based on the event, but CalculateState reads the shared field afterwards - the two steps are not atomic. A stale Connected write can land between a Disconnected write and its CalculateState call, making both threads read Connected: the disconnect is handled but no reconnect is ever scheduled. Since the dispatcher stops itself after reporting the failure and the channel is already deregistered, no further event arrives and the producer stays disconnected forever while its state reports Connected. Guard the state update and the resulting decision with a per-process lock so every event's decision is made against that event's own state: every handled disconnect now schedules a reconnect. The lock is on the lifecycle-event path only (a handful of events per channel transition) and never executes on the message send path.
AmiradelBeyg
force-pushed
the
fix-duplicate-reconnect-scheduling
branch
from
August 14, 2026 21:08
936bd89 to
ae6f387
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Motivation
After a broker restart, producers/consumers with sends in flight can hit two related races in the reconnect handling, observed at scale (tens of thousands of producers on one client):
Duplicate reconnect scheduling. Every
ChannelDisconnectedevent enqueues a newCloseChannel+EstablishNewChannelaction. A producer can receive more than one disconnected event for the same outage — one from the connection teardown (ChannelManager.Dispose) and one fromSubProducer.MessageDispatcherwhen an in-flight send fails. The stale second action then runs after the first reconnect has succeeded: it closes the freshly re-established channel (sendingCommandCloseProducerfor a healthy producer) and forces another reconnect cycle.Lost reconnect decision.
Process.Handleis invoked synchronously on the caller's thread, so those events race. TheswitchwritesChannelState, butCalculateState()reads the shared field afterwards — the two steps are not atomic. A staleConnectedwrite can land between aDisconnectedwrite and itsCalculateState()call, making both threads readConnected: the disconnect is handled but no reconnect is ever scheduled. Since the dispatcher stops itself after reporting the failure and the channel is already deregistered, no further event arrives — the producer stays disconnected forever whileStatereportsConnected, which is unrecoverable from the application side (no terminal state transition ever fires).These races become much easier to observe since #292 (included in 5.3.2): before that fix, large-scale broker-restart scenarios tended to exhaust the ThreadPool before the reconnect logic could run to completion, masking the behavior described here.
Modifications
Two commits, one per race:
Deduplicate reconnect scheduling —
Process.ScheduleReconnectruns at most one reconnect action at a time and re-evaluates the channel state after the action completes, so a disconnect arriving mid-reconnect schedules a follow-up instead of a duplicate, and a replacement channel is never closed by a stale action. Applied to producer, consumer, and reader processes.Synchronize state transitions — a per-process lock makes the state update and the resulting decision atomic, so every event's decision is made against that event's own state and every handled disconnect schedules a reconnect. The lock is on the lifecycle-event path only (a handful of events per channel transition) and never executes on the message send path.
New
ProcessReconnectTestscover initial connect, stale-action-closes-replacement-channel, disconnect-during-reconnect, and a concurrency test firing 500 concurrent connected/disconnected pairs, for all three process kinds.Handle_WhenChannelDisconnectedTwice_DoesNotCloseReplacementChannelfails against master and passes with this change.Verifying this change
All existing unit tests pass.