fix(execution): bound alpaca-py websocket mutators behind async facades (ALP-946) - #357
Conversation
ALP-946 alpaca-py sync subscribe/unsubscribe self-deadlocks the monitor event loop on every position-set change — wrap the broker websocket seams in bounded async facades
SummaryCorrected from the original report: the report attributed the block to a degraded/mid-reconnect websocket whose subscribe future "never resolves," resolving "in milliseconds" when healthy. The installed library refutes that: Also corrected: this is not a one-off. The faulthandler deadman (ALP-941) has captured four wedges — 2026-06-10 13:02 MT (the reported 42-minute outage, bounded only by the operator because of ALP-945) and three on 2026-06-11 at 07:37, 11:03, and 13:02 MT, each immediately after a pipeline run whose Phase-1 changed the open-position ticker set, each auto-recovered by the now-working out-of-process watchdog in ~150 s + restart + replay. The monitor currently takes a deterministic ~150 s full outage (no fills, no options stops, no entry-window, no monitor-side guardrails; broker bracket floor still in place) on every position-set-changing invocation, ~3×/trading day. The report's preserved-artifact pointer is also stale: Evidence (prod logs + prod DB + installed library; log times MT, DB/IDs UTC)
Root cause (confirmed)
The disease is bare loop-affined sync broker-websocket calls on the event loop (the ALP-841 class, which fixed its REST instances via Why it escaped testsEvery test fakes the vendor stream at the sanctioned broker boundary ( Scope — bound every sync alpaca-py websocket call behind an async facade (operator-selected scope, 2026-06-11)(A) Shared bounded-offload helper in broker_adapter. Add module (B) Stock-stream facade. In (C) Trading-stream facade. In (D) Make the monitor seams async. (E) Timeout failure path — keep the existing reconnect semantics. A Acceptance criteria
Verification
Reading
One-time prod data cleanupNo bulk cleanup needed. Both wedge-1 fills were recaptured by the startup replay ( Separate observation — do NOT bundle
Related
|
…es (ALP-946) alpaca-py's stream mutators (subscribe_quotes / unsubscribe_quotes / subscribe_trade_updates) are loop-affined sync APIs: on a running stream they marshal the send onto the stream's own event loop and block the calling thread on an untimed Future.result(). Called from the monitor's own loop thread (the periodic subscription diff) that is a guaranteed self-deadlock — four production wedges, one per position-set-changing invocation since 2026-06-10. - bounded_streams.bounded_call: shared off-thread + wait_for-bounded offload, generalizing queries._rest_call (deleted); the safety-core quote fetcher collapses onto it. - BoundedStockDataStream / BoundedTradingStream: async facades routing every vendor mutator through bounded_call; the stock-stream factory and client_factory.build_trading_stream() now hand out only facades. - StockDataStreamProtocol / _SubscribableStream mutators are async by contract, so a bare sync call on the loop is unrepresentable at the seam; all call sites await. - A facade TimeoutError from the diff path rides the existing budget-counted reconnect; the rebuilt connection re-subscribes the full target set (pinned by a new test). Deviation from the issue's literal signature: bounded_call's keyword is timeout_seconds (not timeout) — ruff ASYNC109 flags async defs with a `timeout` parameter, and the facades' constructor keyword in the spec already uses timeout_seconds. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- BoundedTradingStream gains stop_ws delegation (vendor has it; the stock facade exposes it — asymmetry would AttributeError a future teardown path) - task.py imports QuoteHandler from bounded_streams instead of redefining it - retry.bounded_broker_call delegates to bounded_call (same offload idiom) - corrected the false no-symbol subscribe comment (vendor registers nothing; _run_forever waits for the first delta, connect-time send carries the set) - stale _rest_call / wait_for mechanism comments now reference bounded_call Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
dd2ce49 to
42ed4b3
Compare
Summary
subscribe_quotes/unsubscribe_quotes/subscribe_trade_updates) are loop-affined sync APIs: on a running stream they block the calling thread on an untimedFuture.result()whose resolution needs the very loop the caller holds — a guaranteed self-deadlock from the monitor's subscription diff, wedging the monitor ~150 s on every position-set-changing invocation (4 production wedges since 2026-06-10).broker_adapter/bounded_streams.py:bounded_call(off-thread +wait_for-bounded offload, generalizingqueries._rest_call, now deleted) plusBoundedStockDataStream/BoundedTradingStreamfacades; the stream factories hand out only facades.StockDataStreamProtocol/_SubscribableStreammutators are nowasyncby contract, so a bare sync vendor call on the loop is unrepresentable at the seam; all call sites await.retry.bounded_broker_calland the safety-core quote fetcher collapse ontobounded_call.TimeoutErrorfrom the diff path rides the existing budget-counted reconnect; the rebuilt connection re-subscribes the full target set (pinned by a new test).Closes https://linear.app/alphamind-jatassi/issue/ALP-946/alpaca-py-sync-subscribeunsubscribe-self-deadlocks-the-monitor-event
Test plan
.github/workflows/ci.yml) green on the PR — lint on Linux + full pytest on Windowsruff check,ruff format --check,mypy,lint-imports)bounded_call's keyword istimeout_seconds, not the spec's literaltimeout— ruff ASYNC109 flags async defs with atimeoutparameter; the spec's own facade constructors already usetimeout_seconds)🤖 Generated with Claude Code