fix(concurrency): split send/recv locks so heartbeat & recv loop never block each other - #62
Closed
olivier-babelcast wants to merge 1 commit into
Closed
fix(concurrency): split send/recv locks so heartbeat & recv loop never block each other#62olivier-babelcast wants to merge 1 commit into
olivier-babelcast wants to merge 1 commit into
Conversation
…never block each other A single per-plant lock guarded sends, the heartbeat (send_18), AND the recv loop. _recv_loop holds it across its blocking ws.recv(), so a send could be delayed behind a read (and vice-versa). Split into send_lock (all writes, incl. heartbeat) and recv_lock (recv loop + inline-recv paths); try_acquire_lock takes an explicit `lock=` (defaults to send_lock). Only nesting is recv->send in _send_and_recv_immediate (no deadlock). Defense-in-depth: upstream's send-only history (idle_timeout) already removes the inline-replay lock-hold that caused the 1011 keepalive disconnects; this guarantees the heartbeat/recv are never gated by each other. test_connectivity reconnect-deadlock test updated to acquire recv_lock (the lock the recv-path listener now holds). Full suite 36/36. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
we were tracking the same issue you did fix few weeks back on the history blocking and did this potential fix - our heart beat wwas getting stale. it is not strictly mandatory BUT it seems a correct fix to separate both send and recv locks. I let you assess if this is a good idea or not. updating my fork to latest solved my history hang issue. |
Owner
|
Hi, Thanks for reporting this and for looking into it. I decided to address this at the architectural level in #63 instead of using the proposed approach here. The change removes the need for the inline This has been released in version Thanks again! |
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.
Summary
Split the single per-plant
self.lockinto two locks —send_lockandrecv_lock— so the heartbeat send and the recv loop can never block eachother. This removes a class of self-inflicted keepalive starvation under load
without weakening any of the safety the single lock provided.
Background
Each plant runs three background coroutines on one event loop:
_recv_loop,_process_loop,_heartbeat_loop. Today they all contend for oneself.lock:_recv_loopholds it acrossawait ws.recv()(bounded bylisten_interval)._send_and_recv/_send_and_recv_immediatehold it across a fullsend→recv round-trip.
_send/_send_heartbeattake it for eachws.send.So a heartbeat (
template_id=18) can queue behind an inline recv that isitself waiting on the wire. When several plants share an event loop and traffic
is bursty, the heartbeat is delayed past the negotiated interval and Rithmic
drops the connection — triggering a reconnect storm that makes the contention
worse. The root issue is that two genuinely independent concerns — writing to
the socket and reading from it — are serialized against each other.
What changed
Two locks with clear, non-overlapping responsibilities:
send_lock— serializes all websocket writes (ws.send), including theheartbeat. Concurrent
ws.send()on one connection is unsafe, so every writestill goes through one lock. This is the default lock for
try_acquire_lock.recv_lock— serializes websocket reads (_recv_loop+ the inline-recvin
_send_and_recv/_send_and_recv_immediate) so reads never collide andnever block a write.
try_acquire_lock(plant, ..., lock=None)now takes an optionallockanddefaults to
plant.send_lock. The recv paths passlock=self.recv_lock._send_and_recv_immediateholdsrecv_lockfor the whole send+recv op (it mustserialize with
_recv_loop— no two coroutines mayws.recv()at once) buttakes
send_lockonly for the actualws.send. So:Deadlock safety
The only nesting is recv→send (inside
_send_and_recv_immediate); there isno send→recv path anywhere, so there is no lock-ordering cycle. The existing
reconnect-deadlock regression (
test_no_deadlock_on_reconnect) is updated toacquire
recv_lockin its simulated reconnect (the lock the recv listenerholds) and still passes.
Tests
pytest -qgreen on top ofv1.6.1.test_no_deadlock_on_reconnectupdatedfor the rename; all other tests unchanged.
Relationship to #59 / #53
This is independent of and complementary to the historical-data rewrite (#59).
With #59, history fetches no longer hold a lock across a recv, so the most
acute starvation source is already gone; this split is the structural
guarantee that send and recv can't serialize against each other on any path,
which keeps the heartbeat reliable under arbitrary load. It is also independent
of the order-terminal-ack fix in #53.
🤖 Generated with Claude Code