Skip to content

Fix reconnect races that can leave producers permanently disconnected - #293

Open
AmiradelBeyg wants to merge 2 commits into
apache:masterfrom
AmiradelBeyg:fix-duplicate-reconnect-scheduling
Open

Fix reconnect races that can leave producers permanently disconnected#293
AmiradelBeyg wants to merge 2 commits into
apache:masterfrom
AmiradelBeyg:fix-duplicate-reconnect-scheduling

Conversation

@AmiradelBeyg

@AmiradelBeyg AmiradelBeyg commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

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

  1. Duplicate reconnect scheduling. Every ChannelDisconnected event enqueues a new CloseChannel + EstablishNewChannel action. A producer can receive more than one disconnected event for the same outage — one from the connection teardown (ChannelManager.Dispose) and one from SubProducer.MessageDispatcher when an in-flight send fails. The stale second action then runs after the first reconnect has succeeded: it closes the freshly re-established channel (sending CommandCloseProducer for a healthy producer) and forces another reconnect cycle.

  2. Lost reconnect decision. Process.Handle is invoked synchronously on the caller's thread, so those events race. The switch writes ChannelState, 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 — the producer stays disconnected forever while State reports Connected, 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 schedulingProcess.ScheduleReconnect 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 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 ProcessReconnectTests cover 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_DoesNotCloseReplacementChannel fails against master and passes with this change.

Verifying this change

  • Make sure that the change passes the CI checks.

All existing unit tests pass.

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
AmiradelBeyg force-pushed the fix-duplicate-reconnect-scheduling branch from 936bd89 to ae6f387 Compare August 14, 2026 21:08
@AmiradelBeyg AmiradelBeyg changed the title Fix duplicate reconnect scheduling closing replacement channels Fix reconnect races that can leave producers permanently disconnected Aug 14, 2026
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.

1 participant