Skip to content

fix(ws): roll back seq on backpressure + reset all tickers on multi-ticker gap#139

Merged
TexasCoding merged 3 commits into
mainfrom
fix/ws-backpressure-gap-correctness
May 17, 2026
Merged

fix(ws): roll back seq on backpressure + reset all tickers on multi-ticker gap#139
TexasCoding merged 3 commits into
mainfrom
fix/ws-backpressure-gap-correctness

Conversation

@TexasCoding

Copy link
Copy Markdown
Owner

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 orderbook

SequenceTracker.track(sid, seq, msg_type) advanced _last_seq[sid] = seq before the message was enqueued. With ERROR overflow strategy, put() raises KalshiBackpressureError — 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):

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 cleared

For a multi-ticker orderbook_delta subscription, the gap handler only reset _last_seq for tickers[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_gap now iterates the full subscription's market_tickers list and resets each.

Sibling-branch invariants (Wave 3) preserved

Closes #78
Closes #79

Test plan

  • +8 tests: 5 in tests/ws/test_seq_correctness.py (new), 3 in tests/ws/test_multi_ticker_gap.py (new)
  • Suite: 1654 passed, 48 skipped (was 1646)
  • uv run ruff check . clean
  • uv run mypy kalshi/ clean (strict)

TexasCoding and others added 2 commits May 17, 2026 12:45
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>
@claude

claude Bot commented May 17, 2026

Copy link
Copy Markdown

Code Review — PR #139

Overview

Two 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 backpressure

Approach is correct. Two-phase provisional/rollback is the right call here given that OrderbookManager.apply_* mutates in-place. The alternative (deferred advance) would require significant structural changes to the dispatch/orderbook pipeline.

One clarity nit — tracked = True placement:

ok = await self._seq_tracker.track(...)
tracked = True          # ← set before the gap-check early return
if not ok:
    await self._handle_seq_gap(...)
    return

tracked is only meaningful in the except KalshiBackpressureError block, which is unreachable when ok=False (we return first). It works correctly, but setting tracked = True before the gap branch is a subtle read-order surprise. Consider moving it to after the early-return check:

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 dispatch

The deliberate non-rollback of orderbook state is correct and well-documented. On KalshiBackpressureError, the recv loop tears down via sentinel broadcast anyway, so an orphaned-but-never-desynced book is fine.


Fix #79 — Multi-ticker gap clear

Clean and obvious fix. Changing if tickers and self._orderbook_mgr:if self._orderbook_mgr: for ticker in tickers: correctly handles both the empty-list case (loop is a no-op) and the multi-ticker case. No issues here.


Tests

test_seq_correctness.py — The unit tests for peek/rollback are well-structured and cover the first-message (prev=None) edge case.

test_dropped_delta_keeps_seq_watermark_unchanged — One concern: the test uses a fixed await asyncio.sleep(0.1) to ensure the snapshot is processed before the overflow delta is sent:

await fake_ws.send_to_all({ ...snapshot... })
await asyncio.sleep(0.1)   # ← timing-based
assert session._seq_tracker.peek(sid) == 1

This is a potential flakiness source under CI load. The test_multi_ticker_gap.py tests use the better event-based polling pattern:

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 session._seq_tracker.peek(sid) == 1 before sending the overflow delta.

test_multi_ticker_gap.py — All three scenarios are well-chosen: multi-ticker (the regression), single-ticker (sanity), and no-tickers-param (edge case). Test isolation via _StubMgr for the no-tickers case is the right call.


Convention — issue numbers in source docstrings

CLAUDE.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 #78 and #79:

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

Capture the pre-track watermark before calling :meth:`track`. If the
downstream dispatch raises, pass it to :meth:`rollback` to prevent the
dropped message from being silently treated as already-seen.

Same for the rollback docstring, the _handle_seq_gap docstring, and the inline # #78:/# #79: comment markers in client.py. The test module docstrings ("""Regression tests for ... (#78)""") are fine — cross-referencing issues in test files is reasonable.


Summary

Correctness ✅ Both fixes are correct
Architecture fit ✅ Surgical; preserves existing exception ladder
Test coverage ✅ 8 new tests, all meaningful
Flakiness risk ⚠️ asyncio.sleep(0.1) in one test
Convention ⚠️ Issue numbers in production code docstrings/comments

The logic is solid. Two things to address before merge: swap the fixed sleep for event-based polling, and strip the #78/#79 references from production-code comments (keep them in tests and PR description).

🔴 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>
@TexasCoding

Copy link
Copy Markdown
Owner Author

CI failure root-caused + bot nits addressed in latest commit:

🔴 CI failureast-serialize 0.5.0 (transitive of mypy 2.1.0) was published between Wave 3 landing and this PR opening. It dropped cp39-abi3 wheels and only ships cp314; CI's fresh resolution (uv.lock is gitignored) broke on Python 3.12 + 3.13. Pinned ast-serialize<0.5 in dev deps with a comment explaining the trigger to remove. Fresh local re-resolve picks 0.4.0 cleanly.

Bot review:

  • 🟦 tracked = True placement — moved past the if not ok: return early-exit so the value is only set when it's actually meaningful.
  • 🟦 asyncio.sleep(0.1) in test_dropped_delta_keeps_seq_watermark_unchanged — replaced with a poll on peek(sid) == 1 bounded by wait_for(2.0), matching the pattern in test_multi_ticker_gap.py.
  • 🟦 #78 / #79 references in production-code docstrings / comments — stripped per CLAUDE.md §3. The invariants themselves are still documented, just without the issue-number prefix. Test-file docstrings retain the cross-references.

Verify: tests/ws/test_seq_correctness.py + test_multi_ticker_gap.py 8 passed. ruff + mypy --strict clean.

@claude

claude Bot commented May 17, 2026

Copy link
Copy Markdown

Code Review

Overall: 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.


kalshi/ws/sequence.pypeek / rollback

  • Both methods are sync (no async) which is correct — they only access an in-memory dict and have no I/O.
  • rollback using pop(sid, None) when prev is None is correct: it removes the entry entirely, so the next track() is treated as the first-ever message for that sid. The test test_rollback_to_none_removes_entry verifies this.
  • Minor nit: the docstrings are longer than the methods themselves. Per project conventions (CLAUDE.md §2), comments explaining what the code does are discouraged; the body of each method is self-evident. The why (two-phase rollback for backpressure compensation) belongs in the caller's docstring, which it already has in _process_frame. Consider trimming to a single-line each.

kalshi/ws/client.py_process_frame

Redundant guards in the except block:

if tracked and sid is not None and self._seq_tracker:
    self._seq_tracker.rollback(sid, prev_seq)

tracked = True is only reachable inside if sid is not None and seq is not None and self._seq_tracker:, so the two extra conditions can never be False when tracked is True. The guard can be simplified to just if tracked:. The current form is harmless but adds noise.

Acknowledged design gap (not a new bug):

The docstring correctly notes that OrderbookManager state is mutated before dispatch and is not rolled back on KalshiBackpressureError. The recv loop tears down immediately after, so the orphaned book is never consumed — this is a safe tradeoff. That said, the current code applies the orderbook mutation unconditionally before the try/except:

# snapshot/delta applied here (mutates OrderbookManager in place)
...
try:
    await self._dispatcher.dispatch(raw)
except KalshiBackpressureError:
    ...  # book already mutated; only seq is rolled back

If a future refactor ever changes the tear-down behavior (e.g., soft overflow instead of fatal), the inconsistency between seq rollback and orderbook state could silently resurface. Worth a TODO-comment on the orderbook apply block, or a GitHub issue to track. The PR description already explains it, but in-code visibility would help future readers.


kalshi/ws/client.py_handle_seq_gap

The fix (iterate all tickers, not just tickers[0]) is correct. The if self._orderbook_mgr: guard (moved outside the if tickers check) is an improvement — it avoids silently skipping the seq reset when there are no tickers. No issues here.


pyproject.toml

"ast-serialize<0.5",

The comment explains the rationale clearly. A few thoughts:

  • This is a transitive dep pin (brought in by mypy 2.1.x), which can cause solver conflicts if other packages later require >=0.5. Transitive pins are fine for CI stability but tend to rot.
  • Is there a GitHub issue open for removing this once upstream publishes broader wheels? If not, worth filing so it doesn't get forgotten.

Tests

test_seq_correctness.py:

  • TestSequenceTrackerRollback is a clean unit test of the new API surface. All four cases are meaningful.
  • TestBackpressureDoesNotAdvanceSeq.test_dropped_delta_keeps_seq_watermark_unchanged is the critical regression path. Using wait_for(snapshot_landed(), timeout=2.0) to avoid fixed sleeps is the right pattern. Awaiting _recv_task directly instead of sleeping is clean.
  • One observation: the test asserts len(collected) == 1 (only the snapshot landed). This is a good invariant. But if the test ever runs on a very slow CI box, the asyncio.timeout(2.0) on the snapshot-landed poll could be tight. Not a real concern given this is local-loopback, but consider bumping to 5.0 for consistency with config.timeout.

test_multi_ticker_gap.py:

  • test_gap_clears_all_tickers_in_sub directly calls _handle_seq_gap which removes the race from forcing a real wire gap. Good test design.
  • test_no_tickers_param_does_not_raise covers the empty-params edge case. The _StubMgr inline class is a reasonable approach for this isolated unit test. The # type: ignore[assignment] on ws._sub_mgr = _StubMgr() is expected since it bypasses the type system.
  • The # type: ignore[no-untyped-def] annotations on fixture params are consistent with the rest of the test suite.

Summary

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:

  1. Simplify if tracked and sid is not None and self._seq_tracker:if tracked: in the except block.
  2. 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.

@TexasCoding
TexasCoding merged commit b1983a0 into main May 17, 2026
4 checks passed
@TexasCoding
TexasCoding deleted the fix/ws-backpressure-gap-correctness branch May 17, 2026 17:59
TexasCoding added a commit that referenced this pull request May 20, 2026
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.
TexasCoding added a commit that referenced this pull request May 20, 2026
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant