Skip to content

fix: edge relay hardening (gapped replay, snapshot starvation, cold start, trades race) (#99)#111

Merged
emrebulutlar merged 1 commit into
mainfrom
fix/match99-relay-hardening
Jul 7, 2026
Merged

fix: edge relay hardening (gapped replay, snapshot starvation, cold start, trades race) (#99)#111
emrebulutlar merged 1 commit into
mainfrom
fix/match99-relay-hardening

Conversation

@emrebulutlar

Copy link
Copy Markdown
Member

Hardens the market-relay edge path against four related, mostly self-healing weaknesses found in the match#84 bug-hunt (issue #99), now that the relay is in the live path. Each fix is minimal and keeps the existing best-effort, bounded-memory semantics.

1. Snapshot starvation (drop-oldest can evict the repair snapshot)

EdgePublisher.publish dropped from the head of a single 8192 queue under pressure with no frame priority, so the periodic bundle/snapshot frame (the only thing that resets the relay's snapshotVersion and trims its delta buffer) could be evicted before it shipped.

Fix: route snapshot/bundle frames (EDGE_CACHE envelopes and any live BOOK_SNAPSHOT) to a separate bounded priority lane. drain flushes that lane first, and live deltas can never evict it. The delta lane keeps its drop-oldest "fresh state wins" behaviour. Both lanes are bounded, so memory stays bounded. Deltas are never reordered among themselves (single FIFO lane), so only a snapshot can jump ahead of an older delta, which is safe: the relay drops or chains it correctly.

2. refresh re-serves a known-gapped buffer

A refresh was identical to subscribe and re-sent the same snapshot plus a delta buffer whose earliest surviving entry could start past the snapshot, so the client just re-refreshed in a loop until the next clean bundle.

Fix: each delta now records its fromVersion. When the earliest surviving delta cannot chain from the snapshot (from > snapshotVersion), sendBook drops the buffer and serves snapshot-only. This covers both refresh and subscribe.

3. Cold-start blind-applies deltas

If a market's first frames were BOOK_DELTAs (before any snapshot), sendBook sent emptyBookSnapshot(v0) followed by the whole delta buffer, which the UI's bookVersion===0 branch applies with no chain gate, leaving the book permanently wrong with no resync.

Fix: while no real snapshot exists for a market, serve the empty snapshot alone and never replay buffered deltas onto it.

4. Trades bundle-replace races the live tee

enqueueCacheBundles builds an authoritative trades tape on another thread; a wholesale replace in the relay could clobber a newer live-teed trade queued just ahead of a slightly-older bundle.

Fix: merge the bundle tape into the live tape by trade key (timestamp/price/quantity/side) and keep the newest N, instead of replacing wholesale. The live-append path is unchanged.

Tests

TypeScript (edge/market-relay, npx vitest run — 17 passed):

  • cold start serves the empty snapshot alone with no delta replay
  • a gapped buffer makes refresh serve snapshot-only (no BOOK_DELTA)
  • a chaining (non-gapped) buffer still replays after the snapshot
  • a stale authoritative bundle no longer clobbers a newer live trade (merge keeps it, de-duped, oldest-first)

Java (match-gateway, EdgePublisherTest — 3 passed):

  • a bundle snapshot survives a delta flood that evicts the normal lane
  • a live BOOK_SNAPSHOT is routed to the priority lane, a delta is not
  • the priority lane stays bounded and keeps the freshest snapshots

Closes #99

🤖 Generated with Claude Code

…d start, and the trades race (#99)

Four related, mostly self-healing weaknesses in the market-relay edge path,
hardened now that the relay sits in the live path.

1. Snapshot starvation (EdgePublisher). The periodic bundle/snapshot frames are
   the relay's only resync path (they reset snapshotVersion and trim its delta
   buffer), yet they shared the single drop-oldest queue with live deltas and
   could be evicted under a spike before being sent. Route snapshot/bundle
   frames to a separate bounded priority lane that deltas cannot evict and that
   drains first. Both lanes stay bounded, so memory stays bounded.

2. Gapped-buffer refresh (feed.ts). Track each delta's fromVersion. When the
   earliest surviving delta cannot chain from the snapshot
   (from > snapshotVersion), drop the buffer and serve snapshot-only instead of
   replaying a hole the client would just re-refresh on. This covers both the
   refresh path and subscribe.

3. Cold-start blind-apply (feed.ts). Never replay buffered deltas on top of the
   empty synthetic v0 snapshot; until a real snapshot exists for a market, serve
   the empty snapshot alone.

4. Trades bundle-replace race (feed.ts). Merge the authoritative bundle tape
   into the live-teed one by trade key and keep the newest N, instead of a
   wholesale replace that could clobber a newer live trade queued just ahead of
   a slightly-older bundle.

Tests:
- EdgePublisherTest: the priority lane retains a bundle snapshot under a delta
  flood that evicts the normal lane, routes live BOOK_SNAPSHOT frames, and stays
  bounded while keeping the freshest snapshot.
- feed.spec.ts: cold start serves the empty snapshot alone (no delta replay);
  a gapped buffer makes refresh serve snapshot-only; a chaining buffer still
  replays; a stale bundle no longer clobbers a newer live trade.

Closes #99

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@emrebulutlar
emrebulutlar merged commit 7708685 into main Jul 7, 2026
2 checks passed
@emrebulutlar
emrebulutlar deleted the fix/match99-relay-hardening branch July 7, 2026 21:52
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.

Edge relay hardening: refresh can re-serve a gapped replay buffer; drop-oldest can starve the repair snapshot; cold-start blind-applies deltas

1 participant