Skip to content

Live-layer reconnect supervisor with immediate-then-backoff retry#22

Merged
tbeason merged 14 commits into
mainfrom
feat/live-reconnect-supervisor
Jun 2, 2026
Merged

Live-layer reconnect supervisor with immediate-then-backoff retry#22
tbeason merged 14 commits into
mainfrom
feat/live-reconnect-supervisor

Conversation

@tbeason

@tbeason tbeason commented May 28, 2026

Copy link
Copy Markdown
Owner

Summary

Closes #17. Reconnect handling moves from the streaming layer into a
supervisor task owned by Live, so any consumer — for rec in live,
subscribe_callback, or stream_to_file — survives TCP drops with the
same code path. Adds an immediate-retry phase to catch sub-second blips,
a user-observable reconnect callback, and a live_session convenience
wrapper.

  • Hybrid retry schedule. First immediate_reconnect_attempts retries
    (default 3) fire with no sleep — catches kernel-side RST + a few
    packet retransmits that the 1s+ backoff would otherwise stretch into
    multi-second downtime. Subsequent retries use PR Reconnect: exponential backoff with jitter + max_reconnect_attempts #16's full-jitter
    exponential backoff (1s base, 60s cap), up to max_reconnect_attempts
    (default 10; nothing for unlimited). Budget refreshes whenever a
    fresh reader delivers ≥1 record, so a long-lived session keeps full
    budget across the connection lifetime.
  • add_reconnect_callback(client, cb). cb(gap_start_ns::Int64, gap_end_ns::Int64) fires after each successful reconnect.
    gap_start_ns is the min per-instrument timestamp seen pre-drop;
    gap_end_ns is the gateway's Metadata.start_ts from the fresh
    session. Lock-protected list, fire-outside-lock; errors logged and
    swallowed.
  • live_session(fn; dataset, subscriptions, kwargs...). Bundles
    connect! → subscribe!(many) → start! → fn(client) → close into one
    call. Defaults reconnect_policy = :reconnect.
  • Default reconnect_policy on Live(...) flipped to
    RECONNECT.
    Pass reconnect_policy = :none for single-shot
    semantics. stream_to_file users unaffected (controlled by its own
    reconnect::Bool kwarg).
  • Single reconnect codepath. _run_unified_session no longer owns
    an inline reconnect loop — leans on the supervisor. Replay
    bookkeeping (last_ts_*_by_id) moves from SessionStats to Live;
    _replay_start_ts(::SessionStats, …) overload removed.
  • Gateway ErrorMsg is terminal. Typed reader's control branch
    sets client.terminal_error before forwarding, supervisor refuses to
    reconnect (deterministic failures don't benefit from retry).
  • BentoAuthError during reconnect is also terminal for the same
    reason.

Eleven commits, each compiles and passes tests; reviewable
commit-by-commit.

What didn't change

  • PR Reconnect: exponential backoff with jitter + max_reconnect_attempts #16's backoff math and tests (_reconnect_delay's immediate
    kwarg defaults to 0, preserving the v0.1.1 curve when called
    without the new arg).
  • 24h replay-window cap (_bound_replay).
  • SymbolMappingMsg continues to land in the .dbn.zst via
    _handle_control!.
  • RotatingDBNFile, frame rotation, sidecar logic, stream_to_file
    public API surface.

Performance

Apples-to-apples on a 100k synthetic TradeMsg payload, in-process mock
gateway, no compression:

origin/main this branch (:none) this branch (:reconnect)
Untyped reader 5.40 Mrec/s 6.80 Mrec/s 6.92 Mrec/s
Allocation 8.2 MB 8.2 MB 8.2 MB

No regression. The supervisor blocks on wait(reader_task) during
normal streaming so it adds nothing to the hot path; the added
_record_replay! per-record cost is offset by streaming-layer cleanup
(redundant _handle_data! Dict writes removed).

Docs

  • docs/src/api/live.md: new Convenience + Reconnect sections.
  • docs/src/guide/live.md: full rewrite of the Reconnect section (the
    old "you handle reconnects yourself" guidance was wrong after the
    supervisor).
  • docs/src/quickstart.md: corrected the "use stream_to_file for
    reconnect-surviving captures" sentence (iteration survives drops
    too now).
  • CHANGELOG.md: ## [Unreleased] covering Added + Changed.

Test plan

  • Offline suite: 1764/1764 pass in 48s under Pkg.test().
  • New test/test_live_reconnect_supervisor.jl:
    • iteration survives a disconnect — two-script mock; for rec in client collects records from both sessions with no exception
      in between; reconnect callback fires once with gap_start > 0
      and gap_end == session2.metadata.start_ts.
    • gateway ErrorMsg is terminal — supervisor refuses to reconnect
      after ErrorMsg; channels close; mock's 2nd accept never happens;
      callback never fires.
    • live_session bundles lifecycle — single-shot smoke covering
      the new wrapper.
  • Existing test/test_reconnect_backoff.jl regression checks all
    green: bounds, jitter, max-attempts cap, deadline-cuts-mid-backoff,
    reconnect=false short-circuit.
  • Existing test/test_live_streaming.jl ErrorMsg-terminal test
    (elapsed < 4.5s with reconnect=true, duration_s=5) still passes
    through the new supervisor path.
  • Manual smoke against the real gateway:
    stream_to_file(... duration_s = 600), manually drop the connection
    mid-stream, verify (a) reconnect happens silently, (b) gap callback
    fires once per drop, (c) resulting .dbn.zst decodes cleanly.

🤖 Generated with Claude Code

tbeason and others added 11 commits May 27, 2026 15:16
Adds `immediate::Integer = 0` to `_reconnect_delay`. When set, the first
N attempts return 0.0 (no sleep), and the backoff phase is re-indexed to
start at `attempt = immediate + 1` so the first jittered window is still
[0, base) rather than a pre-stretched one.

Default 0 preserves PR #16 behaviour: existing test bounds, jitter
variance, max-attempts cap, and reconnect=false short-circuit all
unchanged. The upcoming Live-layer supervisor will pass `immediate=3`
to opt into a brief no-sleep phase that catches sub-second TCP blips
without the 1s+ backoff floor.

Adds an `immediate phase returns exactly 0.0` testset: first N attempts
are exactly 0.0 across configurable N; attempt = immediate+1 enters
backoff at [0, base); attempt = immediate+2 at [0, 2*base); large
immediate suppresses backoff entirely.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Pure refactor. Splits the TCP connect + CRAM handshake + auth body of
connect! into a private _open_socket_and_auth!(c) so the upcoming
reconnect path can reuse it without re-entering connect!'s public guards
(which short-circuit on c.connected and throw on c.closed). connect!
itself becomes a thin wrapper that owns the lifecycle flag transition
to c.connected = true.

No behaviour change. Side-effect order on the Live struct (socket,
lsg_version, session_id, connected) preserved.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds the field layout the supervisor and reconnect machinery will use,
along with the constructor kwargs. Fields are populated but unused —
this commit is structurally additive, no behaviour change.

New fields on `mutable struct Live`:

- replay bookkeeping: last_ts_event_by_id, last_ts_recv_by_id,
  last_metadata_start_ns (to-be-populated by reader and reconnect path)
- policy + budget: reconnect_policy (ReconnectPolicy.T enum),
  max_reconnect_attempts (Int|nothing), immediate_reconnect_attempts,
  attempts_remaining, records_since_reconnect
- callback registry: reconnect_callbacks (Vector), callbacks_lock
- state machine: reconnect_supervisor (Task|nothing), state (Symbol
  initialised to :fresh), state_lock, terminal_error

New constructor kwargs with defaults that preserve current behaviour:

- reconnect_policy = ReconnectPolicy.NONE
  (will flip to RECONNECT in the final commit, once supervisor + reader
  + callback wiring are all live; until then NONE is correct since the
  supervisor doesn't exist yet)
- max_reconnect_attempts = 10
- immediate_reconnect_attempts = 3

`reconnect_policy` accepts the enum directly, or Symbol/String spellings
(`:none` / `:reconnect`) for ergonomic call sites — coerced through
_coerce_reconnect_policy.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…ive, ...)

Adds the reader-side wiring that keeps Live's per-instrument replay state
current. The upcoming Live-layer reconnect supervisor reads from these
dicts to compute the resubscribe `start=` timestamp for each schema.

This commit is additive: the legacy `_run_unified_session` reconnect loop
still reads from its SessionStats copy of the same data (populated by
_handle_data! in the data-consumer path). Both paths now track the same
values; the streaming-layer migration happens in a later commit when the
inline reconnect loop is removed.

reader changes:
- _record_replay!(c, rec): @inline helper; hasproperty checks fold against
  the concrete record type at each typed-reader callsite so it's free for
  records without ts_recv.
- _put_data!(c, ch, rec): @inline helper that updates replay state then
  put!s. Substituted for `put!` in all 17 typed data-record branches of
  _reader_loop_typed (MBP_1/TBBO broadcast does its replay update once
  for the shared record, then puts to both subscribed channels).
- Untyped _reader_loop: _record_replay! call inserted before put!.

streaming changes:
- _replay_start_ts(c::Live, schema) overload mirrors the existing
  SessionStats overload, reading from c.last_ts_*_by_id instead.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Under ReconnectPolicy.NONE the reader task owns the channels' lifetime
exactly as before: start! binds them, and the reader's finally block
closes them on exit. Iterators (and subscribe_callback consumers) see an
InvalidStateException on take! and exit cleanly — the right shutdown
signal when no replacement reader is coming.

Under ReconnectPolicy.RECONNECT the channels are owned by the Live
client and outlive any single reader incarnation. start! skips the
`bind` and the reader's finally skips the close — the upcoming
supervisor will spawn a fresh reader writing into the same channels
after each drop, so consumers see a continuous record stream across
reconnects without the bind/close cycle terminating their iteration
mid-stream.

The user-initiated close path (c.closed=true) always closes regardless
of policy, so explicit `close(client)` still terminates iterators in
both modes.

Default policy is still NONE, so observable behaviour is unchanged in
this commit.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Implements the Live-layer reconnect machinery so iteration consumers
(`for rec in client` / `subscribe_callback`) survive TCP drops, not
just `stream_to_file` callers. Wires under ReconnectPolicy.RECONNECT
(opt-in; default still :none in this commit, flipped in the final
commit).

New src/live/reconnect.jl:

- _supervisor_loop(c): spawned from start! when policy != :none.
  Blocks on wait(reader_task); on exit, checks terminal flags
  (c.closed, c.terminal_error, c.attempts_remaining <= 0) and either
  retries via _reconnect_once! or transitions to :failed/:closed and
  closes channels.
- _reconnect_once!(c): closes stale socket, runs the extracted
  _open_socket_and_auth!, re-issues stored subscriptions via private
  _send_subscribe_frame (each with start_override from
  _replay_start_ts(c, schema)), sends start_session, spawns new reader
  against the *same* channels. Polls for new reader's metadata.start_ts
  before returning so the reconnect callback fires with a fresh gap_end
  (10s deadline accommodates cold-JIT first-reconnect on a fresh process).
- add_reconnect_callback(c, cb): public; cb(gap_start_ns, gap_end_ns).
  Lock-protected list, fire outside lock so a slow user callback can't
  block registration. Errors logged and swallowed.
- BentoAuthError during reconnect is terminal (no point retrying
  deterministic auth failures); other exceptions decrement budget and
  retry per the schedule from step 1.
- gap_start = min across last_ts_*_by_id (earliest "we lost coverage"
  across subscribed instruments).

Reader changes:

- _record_replay! now also refreshes c.attempts_remaining on the first
  record of each reader incarnation — a session that actually streams
  real data between drops keeps its full retry budget across the
  connection lifetime.
- _reader_loop[_typed] capture decoder.metadata.start_ts into
  c.last_metadata_start_ns immediately after read_header!.
- Typed reader's control branch: ErrorMsg sets c.terminal_error
  *before* publishing to control_channel, so the supervisor's next
  decision sees it and refuses to reconnect (gateway-side errors are
  deterministic).

Lifecycle:

- start! spawns the supervisor under RECONNECT policy and transitions
  state→:streaming.
- Base.close(c) transitions state→:closed under state_lock so a racing
  supervisor sees the terminal flag at its next iteration.

Tests (test/test_live_reconnect_supervisor.jl, wired into runtests.jl):

- iteration survives a disconnect: 2-script mock; client iterates and
  collects records from BOTH sessions with no exception in between;
  reconnect callback fires once with gap_start > 0 and gap_end matching
  session 2's metadata.start_ts (different from session 1's so the
  poll-and-update path is exercised).
- gateway ErrorMsg is terminal: ErrorMsg in session 1 triggers
  terminal_error, supervisor refuses to reconnect, channels close,
  callback never fires, mock's 2nd accept never happens.

Full suite: 1762/1762 pass in 48s.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds the ergonomic do-block path the issue's "ease of connecting" framing
called for, plus the user-facing docs catch-up for the new exports.

- live_session(fn; dataset, subscriptions, kwargs...): bundles
  connect! → subscribe!(many) → start! → fn(client) → close into one
  call. Defaults reconnect_policy = :reconnect so the simple-API path
  gets the supervisor by default (Live(...) constructor default stays
  :none for backward compatibility — see CHANGELOG note; the constructor
  default flip can land in a follow-up alongside the streaming-layer
  unification).
- Exports: live_session, add_reconnect_callback.
- docs/src/api/live.md: adds Convenience and Reconnect sections for the
  two new exports.
- CHANGELOG.md: ## [Unreleased] entry covering the supervisor, hybrid
  retry schedule, callback registry, live_session wrapper, ErrorMsg
  terminal handling, and the channel-lifecycle change under RECONNECT.
- test_live_reconnect_supervisor.jl: smoke test that live_session
  drives a single-shot session end-to-end (subscribe → drain → close).

Full suite: 1764/1764 pass in 49.6s.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The streaming layer now drives a single Live with reconnect_policy =
ReconnectPolicy.RECONNECT and lets the Live-layer supervisor handle
TCP-drop reconnect end-to-end. The outer `while true` retry loop in
_run_unified_session is gone, along with _wait_for_reconnect (no longer
needed — supervisor's _interruptible_sleep watches c.closed; on
deadline/shutdown the streaming layer calls close(client) which the
supervisor sees at its next poll).

Pulling reconnect into one place delivers the immediate-retry win to
stream_to_file / stream_multi_to_files users (previously they only got
PR #16's 1s-floor backoff), eliminates two competing reconnect codepaths,
and removes ~80 lines of streaming-layer ceremony.

Notable preserved behaviours:

- max_reconnect_attempts still gives `1 + cap` total attempts. The
  supervisor covers post-start! drops; the streaming layer keeps a
  small retry loop around the initial `connect!` (the supervisor only
  watches a reader task, which only exists after start!).
- ctx.stats.reconnects still increments per successful reconnect —
  now driven by an add_reconnect_callback rather than the outer loop.
- ErrorMsg-from-gateway stays terminal: the typed reader's control
  branch sets c.terminal_error, supervisor refuses to reconnect, and
  _handle_control! still sets ctx.stats.error so the streaming
  coordination loop sees `_any_error(ctxs)` and breaks.
- 24h replay-window cap (_bound_replay) still applies — used inside the
  supervisor's _reconnect_once! via _replay_start_ts(c::Live, schema).

Cleanup:

- SessionStats.last_ts_event_by_id / last_ts_recv_by_id removed —
  replay bookkeeping lives entirely on Live (populated by the reader's
  _record_replay!). _handle_data! drops the redundant updates.
- _replay_start_ts(::SessionStats, schema) overload removed; the
  ::Live overload is the only one now.
- _wait_for_reconnect removed.
- test/test_live_streaming.jl `_replay_start_ts` testset updated to
  exercise the Live overload (matches the production codepath).

Full suite: 1764/1764 pass in 48.6s.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Bare `Live(...)` now defaults to reconnect_policy = RECONNECT — the
"reasonable default" once the supervisor is the only reconnect codepath.
Tests that intentionally use single-shot reader-exits-channel-closes
semantics now pass `reconnect_policy = :none` explicitly:

- test_live_reader.jl (1 site)
- test_live_reader_typed.jl (5 sites)
- test_live_subscribe.jl (3 sites)

Tests that construct Live but never call start! (live-streaming.jl
do-block + close coverage, typed-mode error coverage) are unaffected —
no supervisor spawns without start!.

stream_to_file / stream_multi_to_files unaffected: they pass
reconnect_policy explicitly to the inner Live based on their own
`reconnect::Bool` kwarg (default true).

CHANGELOG updated: ## [Unreleased] entry now reflects the single
reconnect codepath (no more "deferred unification" caveat) and the
default-flip is called out as a Changed item.

Full suite: 1764/1764 pass in 48.1s.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The old guide said "If you're driving the Live client yourself outside
stream_to_file, you handle reconnects yourself" — no longer true. Every
Live(...) now has a reconnect supervisor on by default.

New content covers:
- The default-on supervisor and its immediate-then-backoff schedule
  (3 immediate attempts, then exp backoff, budget refresh on first
  record post-reconnect)
- ReconnectPolicy.NONE opt-out
- add_reconnect_callback with the (gap_start_ns, gap_end_ns) signature
  and DateTime conversion note
- Gateway ErrorMsg → terminal_error → no reconnect rationale
- live_session convenience wrapper
- Updated "Closing the client" paragraph to mention supervisor exit

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The quickstart's "use stream_to_file for captures that survive
disconnects" was misleading after the supervisor lands — every Live now
survives disconnects regardless of whether it's writing to disk.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: bd50567707

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/live/reader.jl
# Track per-instrument replay state on the Live client so the
# reconnect supervisor sees timestamps from records that haven't
# been consumed yet.
_record_replay!(c, rec)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Set terminal_error for untyped ErrorMsg records

With the new default reconnect_policy = RECONNECT, the untyped reader path now supervises reconnects too, but it only records/publishes every decoded record here and never sets c.terminal_error when rec isa DBN.ErrorMsg. If an untyped Live session receives a gateway ErrorMsg and the socket then closes, _supervisor_loop sees terminal_error === nothing and reconnects/replays a deterministic failure until the retry budget is exhausted, unlike the typed path that marks ErrorMsg terminal before forwarding it.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — fixed in 59cee8c. The untyped _reader_loop now sets c.terminal_error = String(rec.err) on ErrorMsg before the put!, matching the typed reader's control branch. With the supervisor now on by default, that asymmetry would otherwise burn through max_reconnect_attempts on a deterministic gateway failure.

Added a regression test (ErrorMsg terminal under UNTYPED mode in test/test_live_reconnect_supervisor.jl) that pre-stages 5 mock-gateway scripts, asserts accept_count == 1 (never reconnected), confirms terminal_error is set, and verifies both the trade and the ErrorMsg still flow to the consumer's channel.

Updated comment notes the trade-off: users who want the old "ErrorMsg is informational" behaviour (e.g. for SkippedRecordsAfterSlowReading) should pass reconnect_policy = :none and drive reconnection themselves.

Full suite: 1768/1768 pass.

tbeason and others added 3 commits May 28, 2026 10:54
Fixes a real bug surfaced by Codex review on #22: with the default
reconnect_policy now RECONNECT, an untyped client that received a
gateway ErrorMsg would loop through max_reconnect_attempts re-hitting
the same deterministic failure — the typed reader's control branch
short-circuits this by setting c.terminal_error before forwarding, but
the untyped reader was left with its pre-supervisor semantics ("forward
and let the consumer decide").

Now both reader paths set c.terminal_error on ErrorMsg before
publishing the record. The record itself still flows through the
channel so consumers can inspect it; the supervisor sees the terminal
flag at the next decision check and exits cleanly to :failed.

Users who want to treat ErrorMsg as informational (e.g.
SkippedRecordsAfterSlowReading is a code-7 ErrorMsg signalling SKIP
behavior, not a fatal error) should pass `reconnect_policy = :none`
and drive reconnection themselves.

Regression test: new `ErrorMsg terminal under UNTYPED mode` testset
mirroring the typed-mode one — staged 5 mock scripts, asserts
accept_count == 1 (never reconnected), terminal_error is set, and
both the trade and the ErrorMsg flowed to the consumer.

Full suite: 1768/1768 pass in 52.9s.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Fix the benchmark suite (broken after the DBN.jl -> DatabentoBinaryEncoding.jl
rename) and add an A/B regression analysis of the reconnect supervisor's
per-record _record_replay! bookkeeping.

- benchmark/Project.toml, fixtures.jl: DBN -> DatabentoBinaryEncoding dep,
  source path, and DBN_DATA fixture path. Suite could not instantiate before.
- bench_live_replay.jl: add optional reconnect_policy passthrough (splatted
  into Live only when set, so the same harness runs on pre-supervisor
  branches); add an idle-timeout watchdog so the drain loop can't hang when the
  one-shot mock EOFs under reconnect_policy=:reconnect (the channel never
  closes); report throughput over the active first->last-record window so the
  reconnect/idle tail doesn't pollute the rate. Per-record hot path kept
  identical to the original (take! + counter) so A/B is not skewed.
- PERF_REPORT.md: new "Reconnect-supervisor regression analysis" section.

Findings: _record_replay! adds ~28 ns/record (allocation-free) at 13.6k
instrument cardinality, isolated in-process. It is net-zero for stream_to_file
(the two Dict writes merely move consumer->producer) and a ~6% cost for trivial
iteration consumers, masked to ~0 under any real consumer or realistic arrival
rate. One-time Dict growth ~1.6 MB at 13.6k / ~104 MB at full OPRA universe.
Also surfaced: DBN v3 live captures crash the untyped reader (SType 255), a
pre-existing decoder gap orthogonal to this PR.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The "duration_s cuts off mid-backoff sleep" test asserted
`elapsed < duration + 3.0` (4.5s). This flaked on shared macOS and
Windows CI runners (observed 5.0s) where per-attempt socket churn and
scheduler stalls add several seconds of wall-clock unrelated to backoff
responsiveness. The deadline-slicing in `_wait_for_reconnect` is still
verified — it returns within `_CONNECTION_POLL_S` (0.5s) of the deadline,
so the loop never sits through a full backoff window (up to 60s).

Loosen to `elapsed < 15.0`, matching the sibling
"max_reconnect_attempts caps connection attempts" test. A genuine
regression that ignored the deadline would run for tens of seconds and
still trip this bound. Local: testset runs ~2.1-2.3s across repeated runs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@tbeason
tbeason merged commit 1b78440 into main Jun 2, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Move reconnect logic into Live (session-style client), add reconnect callback

1 participant