polish(ws,auth): MessageQueue maxlen + types consolidation + RSA-PSS sign offload (#173 + #174 + #178)#182
Conversation
…sign offload #173 — WS MessageQueue defense-in-depth Underlying deque now carries maxlen=maxsize+1 as a hard memory ceiling independent of the manual _size counter. Counter-drift regression test asserts the cap holds even when _size lies about occupancy. #174 — types.py consolidation Collapse byte-identical _to_decimal_dollars + _to_decimal_fp into shared _coerce_decimal. DollarDecimal / FixedPointCount aliases unchanged. #178 — RSA-PSS sign offload to dedicated executor Added KalshiAuth.sign_request_async() routing the 1-10 ms sign through a dedicated ThreadPoolExecutor(max_workers=2). Async REST + async WS connect paths use it; sync sign_request unchanged. Per community feedback on #178: the executor is dedicated (not asyncio's default pool) so signs don't queue behind getaddrinfo / file I/O / other to_thread() work. Falsifiable microbench under scripts/bench_sign_offload.py uses real loop.time() deltas, not the special-cased asyncio.sleep(0) ticker. Measured: inline p99=2.95 ms vs. offloaded p99=0.68 ms on a 2048-bit key. KalshiClient.close() / AsyncKalshiClient.close() shut down the sign executor too.
Code Review — PR #182: WS/auth polish batch (#173 + #174 + #178)Three well-scoped, well-documented changes. Each is independently justified and passes the project's full test suite. Overall the PR is in good shape; a few things worth examining before merge. #173 —
|
Bot round-2 found one item to action and one based on a misreading of
convention.
Actioned:
* ROADMAP.md "Open trackers" trimmed from four lines of tombstone prose
("None. The previous entries here (#45, #53, #106) are all closed:
... ") down to "None." The why-they-closed history will rot by the
next release; anyone curious can run `gh issue view 45` or check git
history.
Not actioned:
* Bot suggested changing CHANGELOG.md "Pre-release docs audit (#179)"
to "(#188)" on the claim that "every other CHANGELOG entry uses the
PR number." This is backwards. Existing CHANGELOG entries
(#175, #176, #177, #178, #183) are all *issue* numbers; the PRs that
closed them were #185, #187, #186, #182, #184 respectively. Verified
via `gh issue view <N> --json closedByPullRequestsReferences`. The
convention is "(#<issue-number>)" — using #179 is correct.
mkdocs --strict still clean.
Closes #173, #174, #178.
Three independent WS / auth polish items batched into one PR.
#173 —
MessageQueuedefense-in-depthkalshi/ws/backpressure.py— the underlyingcollections.dequenow carriesmaxlen=maxsize+1as a hard memory ceiling enforced by deque itself, independent of the manual_sizecounter.If the counter ever drifts (a put path that forgets to increment, an exception between append and increment) the buffer cannot grow without bound —
maxlenevicts at the C level.+1is the safety margin so the manualDROP_OLDEST/RAISEoverflow logic still gets to observe the boundary one slot before deque silently evicts;put_sentinelconsumes the +1.New regression test in
tests/ws/test_backpressure.pyinjects counter drift (q._size = 0mid-stream) and asserts the cap holds for the next 1000 puts.#174 —
_to_decimal_*consolidationkalshi/types.py—_to_decimal_dollarsand_to_decimal_fpwere byte-identical apart from docstrings. Collapsed into a single private_coerce_decimalhelper shared by bothDollarDecimalandFixedPointCount. The publicAnnotatedaliases stay distinct (differentBeforeValidatorinstances would have been fine for Pydantic identity, but sharing the same validator is fine too — Pydantic distinguishes by theAnnotatedtuple, not the inner function identity, and audit confirmed no test introspects the validator function).#178 — async RSA-PSS sign offload
KalshiAuth.sign_request_async()routes the ~1-10 ms RSA-PSS sign through a dedicatedThreadPoolExecutor(max_workers=2), lazy-initialised on first use. Async REST (AsyncTransport.request) and async WS connect (ConnectionManager._build_auth_headers) now use the async sign path; the syncsign_requestAPI is unchanged.Community feedback (issue #178 comment)
The commenter raised three substantive points. Triaging:
Shared default executor collision with
getaddrinfo— adopted. The original acceptance criterion calledasyncio.to_thread()which dispatches toloop.run_in_executor(None, ...), sharing the default pool withloop.getaddrinfoand otherto_thread()work. A reconnect storm where DNS dominates (5-50 ms) would push the sign behind it and reintroduce the loop stall as await-latency. This PR uses a dedicatedThreadPoolExecutor(max_workers=2)for sign-only work, isolating crypto from any unrelated executor traffic at trivial cost (2 threads, lazy-init).asyncio.sleep(0)is not a wall-clock ticker — adopted. The original acceptance methodology was wrong:asyncio.sleep(0)is special-cased to a singlecall_soonyield (tasks.__sleep0) and does NOT measure wall-clock blocking. The microbench inscripts/bench_sign_offload.pyuses realasyncio.sleep(target_interval)ticks and measuresloop.time()deltas.DNS dominates over sign on reconnect — acknowledged. Valid for the WS reconnect path; doesn't change the offload value for the batch-REST-burst path (no DNS, just many signs). The dedicated executor matters more than the offload itself for one of the two cited scenarios; both ship in this PR.
Microbench results
scripts/bench_sign_offload.py, 2048-bit key, 200 signs vs. 50x ticker @ 5 ms:Inline gaps track the sign cost (1-3 ms per sign on 2048-bit). Offloaded gaps stay at scheduler jitter.
Behavior tests
tests/test_auth.py::TestSignRequestAsync— 4 tests:test_async_signature_verifies_for_same_message— RSA-PSS is randomized (PSS salt), so sync vs. async signatures are byte-different but both verify against the same public key + canonical message.test_lazy_executor_creation— executor lazy-initialised on first call, reused on subsequent calls, cleared byclose().test_close_is_idempotent—close()safe to call before any sign and twice in a row.test_concurrent_signs_do_not_stall_event_loop— in-suite version of the microbench; bound is conservative (max gap < 30 ms) so CI runners don't flake.Lifecycle
KalshiClient.close()/AsyncKalshiClient.close()now shut down the sign executor too. The executor is daemon-style — long-lived clients that don't callclose()get cleaned up via interpreter-shutdown anyway (atexit-joined).Verification
uv run pytest tests/ --ignore=tests/integration→ 2112 passed.uv run pytest tests/ws/test_backpressure.py tests/test_auth.py tests/test_types.py tests/test_models.py -v→ all green including the 4 new async sign tests + the counter-drift regression test.uv run ruff check .→ clean.uv run mypy kalshi/→Success: no issues found in 76 source files.