fix(ws): roll back seq on backpressure + reset all tickers on multi-ticker gap#139
Conversation
When MessageQueue.put() raises KalshiBackpressureError on an ERROR-strategy queue, the seq tracker had already advanced _last_seq for that sid. The dropped message's seq was then treated as already-seen, so a post-reconnect or post-resync stream would silently desync without ever firing a gap. Fix: capture peek(sid) before track(), wrap dispatch in a try/except, rollback the watermark if the dispatch raises BackpressureError, then re-raise so the recv loop's existing #83 handler broadcasts sentinels and tears the loop down (invariant preserved). Orderbook state is left as-is — the loop teardown via sentinel means the local book is orphaned, never desynced-and-still-consumed. Adds SequenceTracker.peek(sid) and rollback(sid, prev). Regression: maxsize=1 ERROR queue, snapshot fills, delta overflows -> tracker.peek(sid) must remain at the snapshot's seq, not advance to the dropped delta. Closes #78 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A single orderbook_delta subscription with tickers=["A","B","C"] returns one sid covering all books. On a sequence gap, _handle_seq_gap was only clearing tickers[0] from the orderbook manager, leaving the rest with stale state that diverges from server truth. The gap envelope does not identify which ticker missed an update, so the safe option is to clear every ticker in the affected subscription. Iterate the full market_tickers list. Single-ticker subs and subscribe-to-all subs (no market_tickers param) continue to work unchanged. Closes #79 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Code Review — PR #139OverviewTwo targeted correctness fixes for WebSocket sequence tracking. Both patches are surgical, well-scoped, and accompanied by dedicated regression tests. The design choices (two-phase rollback vs. deferred advance, all-tickers reset vs. per-ticker) are well-reasoned and match the constraints of the existing architecture. Fix #78 — Seq rollback on backpressureApproach is correct. Two-phase provisional/rollback is the right call here given that One clarity nit — ok = await self._seq_tracker.track(...)
tracked = True # ← set before the gap-check early return
if not ok:
await self._handle_seq_gap(...)
return
ok = await self._seq_tracker.track(...)
if not ok:
await self._handle_seq_gap(...)
return
tracked = True # only reached when tracking succeeded and we'll dispatchThe deliberate non-rollback of orderbook state is correct and well-documented. On Fix #79 — Multi-ticker gap clearClean and obvious fix. Changing Tests
await fake_ws.send_to_all({ ...snapshot... })
await asyncio.sleep(0.1) # ← timing-based
assert session._seq_tracker.peek(sid) == 1This is a potential flakiness source under CI load. The async with asyncio.timeout(2.0):
while not await all_seeded():
await asyncio.sleep(0.01)The backpressure test should use the same approach — poll until
Convention — issue numbers in source docstringsCLAUDE.md: "Don't reference the current task, fix, or callers... those belong in the PR description and rot as the codebase evolves." The docstrings and inline comments liberally reference def peek(self, sid: int) -> int | None:
"""...Used by ... to capture the pre-track watermark so it can be
restored via :meth:`rollback` if the downstream dispatch fails
(issue #78: ERROR-overflow backpressure must not silently advance ...)
"""The invariant being documented is valuable; the issue number isn't. Replace with a description of the invariant itself, e.g.: Same for the Summary
The logic is solid. Two things to address before merge: swap the fixed sleep for event-based polling, and strip the |
🔴 CI failure: ast-serialize 0.5.0 ships cp314-only wheels mypy 2.1.0 pulls in ast-serialize transitively. ast-serialize 0.5.0 was published between Wave 3 landing and Wave 4 PR open; it dropped the cp39-abi3 wheels and only ships cp314, breaking CI's fresh resolution on every Python 3.12/3.13 runner (uv.lock is gitignored so each CI run re-resolves). Drift-check + test (3.13) both failed at `uv sync`. Fix: add `ast-serialize<0.5` to the dev dependency-group with a comment explaining the situation and the trigger to remove it. Fresh local re-resolve picks ast-serialize 0.4.0 cleanly with the existing mypy 2.1.0. 🟦 Bot review nits (per pass on #139): - `tracked = True` was set before the `if not ok: return` early-exit branch; the value was only meaningful inside the `except KalshiBackpressureError` block, where the early return makes it unreachable. Moved the assignment after the early return so the read order matches the intent. - Replaced `await asyncio.sleep(0.1)` in test_dropped_delta_keeps_seq_watermark_unchanged with a poll loop on `peek(sid) == 1` bounded by asyncio.wait_for(2.0). Matches the pattern already used in test_multi_ticker_gap.py. - Stripped #78 / #79 references from production-code docstrings and inline comments per CLAUDE.md §3 (issue numbers belong in PR/commit context, not source). The invariants themselves are still documented — just without the "#78:" prefix. Test-file docstrings retain the cross-references since that's reasonable in tests. Verify: tests/ws/test_seq_correctness.py + test_multi_ticker_gap.py 8 passed. ruff + mypy --strict clean. Fresh uv lock resolves ast-serialize 0.4.0. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
CI failure root-caused + bot nits addressed in latest commit: 🔴 CI failure — Bot review:
Verify: |
Code ReviewOverall: Solid, surgical correctness fix. Both bugs are real and the solutions are well-reasoned. The two-phase peek/rollback pattern is clean, and the multi-ticker fix is the minimal correct change. A few observations below — mostly minor.
|
| Area | Verdict |
|---|---|
| Correctness (fix #78) | ✅ Correct; two-phase rollback is the right approach |
| Correctness (fix #79) | ✅ Correct; all-tickers clear is the only safe option |
| Style / conventions | Minor: redundant guards in except block, verbose docstrings |
| Tests | ✅ Comprehensive; 8 new tests, deterministic, no fixed sleeps |
pyproject.toml pin |
✅ Acceptable temporary fix; track for removal |
| mypy/ruff | Per PR description: both pass clean |
Two action items worth considering before merge:
- Simplify
if tracked and sid is not None and self._seq_tracker:→if tracked:in the except block. - Add a TODO comment on the orderbook-apply block (or open a follow-up issue) noting that rollback only covers seq, not book state, so future changes to the tear-down path should re-evaluate.
Neither is a blocker — happy to approve once you've had a look.
Fixes silent message loss when the server sends data frames on a freshly-assigned sid before the corresponding subscribe ack reaches `_wait_for_response`. Previously these frames were non-matching from the wait's perspective and discarded with a debug log; under market-burst reconnects on ticker / trade / fill channels, the SDK could drop tens of messages per reconnect. SubscriptionManager now stashes non-matching data frames in a per-sid bounded deque (stash_maxlen=1000 default) for the duration of resubscribe_all. _handle_reconnect drains the stash through _process_frame after resubscribe completes so the frames flow through the normal dispatch path -- seq tracker advances correctly, orderbook manager applies, iterators receive them in arrival order. Coordinates with #139's seq-gap tracking: replayed frames go through seq_tracker.track exactly once, so the first live frame after resubscribe sees the right watermark and doesn't trip a spurious gap. Stash is bounded; overflow evicts oldest via deque maxlen with a single WARNING per fill event. Frames whose sid wasn't re-mapped (per-sub resubscribe failure, F-P-01 path from #77) are dropped on drain with a debug log. 5 unit tests cover the stash mechanics directly (collect-by-sid, no-sid drop, maxlen eviction, take_stash atomicity, default-off flag). 3 integration tests cover the dispatcher integration: - drain replays through dispatch into the iterator queue - drain advances seq_tracker on sequenced channels (orderbook) - drain skips frames whose sid didn't get re-mapped The race-engineered end-to-end test (server sends pre-ack data during a reconnect) is intentionally not included: race timing is hardware-dependent and the value is the post-drain dispatch path, which the direct tests cover deterministically. Existing reconnect tests in this file exercise the close -> reconnect -> resubscribe pipeline.
Fixes #176. **The race:** `SubscriptionManager.resubscribe_all` clears `_sid_to_client` at the start (per #77 F-P-03, to prevent stale-sid mis-routing) and rebuilds it as new sids land in `_wait_for_response`. Between those steps, the server can send data frames on a just-assigned sid; `_wait_for_response` saw non-matching `id`s, logged debug, and silently dropped them. Under burst reconnects on `ticker`/`trade`/`fill`, the SDK could lose tens of messages per reconnect with no surface signal. **The fix:** Per-sid bounded stash on `SubscriptionManager`, toggled on during `resubscribe_all` via `_stashing` flag in a try/finally. Frames with an int sid land in `_stash[sid]` (bounded `collections.deque(maxlen=1000)`); on resubscribe completion, `_drain_resubscribe_stash` replays them through `_process_frame` so seq tracker and orderbook state see them exactly once. Atomic `take_stash()` swap, single WARNING per (sid, replay-cycle) for overflow, defensive clear at cycle start. **Tests:** 5 unit (`tests/ws/test_channels.py::TestResubscribeStash`) + 3 integration (`tests/ws/test_recv_loop_hardening.py::TestResubscribeStashIntegration`) covering per-sid arrival order, no-/non-int-sid drop, maxlen + warning dedup, warning reset between cycles, atomic take_stash, default off, stale-stash defensive clear, replay through dispatch, seq-tracker coordination (#139), unmapped-sid graceful drop. **Drive-by:** `asyncio.get_event_loop()` → `asyncio.get_running_loop()` in `_wait_for_response` (deprecated inside a coroutine since 3.10+). **Review iteration:** 4 rounds. Round 1 caught a real overfire bug + sentinel divergence; rounds 2–4 progressively tightened close() exception safety, simultaneous-completion semantics, defensive stash clear, and the `isinstance(sid, int)` type guard. The current state is meaningfully tighter than the original push. Verification: 2126 unit tests pass. `mypy kalshi/` strict clean, `ruff check .` clean.
Summary
Wave 4 — two correctness fixes that build on the recv-loop hardening landed in #138. Two commits, one PR.
#78 — ERROR-overflow advances
last_seq, silently desyncing orderbookSequenceTracker.track(sid, seq, msg_type)advanced_last_seq[sid] = seqbefore the message was enqueued. With ERROR overflow strategy,put()raisesKalshiBackpressureError— but the tracker had already moved the watermark. On reconnect / future gap check, the dropped sequence is treated as already-seen → silent orderbook desync.Fix (option (b), two-phase provisional/rollback):
kalshi/ws/sequence.py— addedpeek(sid)androllback(sid, prev)toSequenceTracker.kalshi/ws/client.py—_process_framecaptures the pre-track watermark viapeek, wraps dispatch intry/except KalshiBackpressureError, callsrollback(sid, prev), then re-raises so the recv-loop's fix(ws): recv-loop overhaul — reconnect races, narrowed exceptions, log scrub, ConnectionManager API #138 sentinel-broadcast handler still fires.Option (a) — "advance after dispatch" — would also require deferring
OrderbookManager.apply_*(in-place internal state) past the queue.put; that's a larger structural change. Two-phase rollback is surgical and preserves the existing gap-skip early-return semantics.#79 — Multi-ticker seq-gap: only
tickers[0]was clearedFor a multi-ticker
orderbook_deltasubscription, the gap handler only reset_last_seqfortickers[0]. Other tickers' seq state lingered → false gaps or missed gaps on later deltas.Fix (all-tickers reset): the gap envelope itself (sid + expected + received) carries no ticker — per-ticker resolution is impossible at the gap-detection layer.
_handle_seq_gapnow iterates the full subscription'smarket_tickerslist and resets each.Sibling-branch invariants (Wave 3) preserved
KalshiBackpressureErrorfrom dispatch still breaks the recv loop and broadcasts sentinels (WS recv loop swallows all exceptions with broadexcept Exception: continue#83 in fix(ws): recv-loop overhaul — reconnect races, narrowed exceptions, log scrub, ConnectionManager API #138)._process_framere-raises after rollback.test_backpressure_error_breaks_loop_and_sentinelspasses unchanged.Closes #78
Closes #79
Test plan
tests/ws/test_seq_correctness.py(new), 3 intests/ws/test_multi_ticker_gap.py(new)uv run ruff check .cleanuv run mypy kalshi/clean (strict)