fix: buffer live trades/candles until the async subscribe snapshot flushes (#97)#107
Merged
Merged
Conversation
…ushes (#97) On subscribe the channel joins the market broadcast group synchronously, but the initial recent-trades and candle-history snapshots are fetched asynchronously (a DB round trip, up to ~1.5s). Live TRADES_BATCH/CANDLE_UPDATE frames arriving in that window were delivered BEFORE the older initial snapshot, so a client that renders each batch as current state briefly showed a trade then dropped it when the stale snapshot landed (the tape appeared to rewind). Buffer the deferrable live frame types per channel while the async initial state is in flight, then flush them in arrival order after the snapshot lands. Book frames are untouched: the book snapshot is synchronous and deltas are version chained on top of it. The buffer is bounded (drop-oldest, log once) so a stuck DB read cannot balloon the heap, and a failed or timed-out fetch still flushes and clears so a channel never wedges permanently buffering. The buffering flag and drain are held under one lock and the flush is scheduled on the channel event loop, so a concurrent broadcast can never lose or reorder a frame. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Mechanism
MarketDataWebSockethandles subscribe like this: the channel joins the market broadcastChannelGroupsynchronously (subscribeToMarket), thensendInitialStatewrites the BOOK snapshot synchronously but fetches recent-trades and candle-history asynchronously (recentTradesJsonAsync/buildCandleHistoryJsonAsync, a DB round trip up to ~1.5s, completing on a read-pool thread).Because the group join happens first, live
TRADES_BATCH/CANDLE_UPDATEframes start flowing immediately. Any that arrive during the async window were delivered before the older "initial" trades snapshot. A client that renders eachTRADES_BATCHas the current list briefly showed a live trade, then dropped it when the stale initial snapshot landed on top: the tape appeared to rewind. (The initial recent-trades snapshot is itself aTRADES_BATCH, so it overwrites the newer live one.) It gets likelier under connect/resync storms contending for the 2-thread read pool.Design
Per-channel buffering of the live frame types whose initial state is async, held until the initial snapshot is flushed (joining after the async completes would instead lose frames; buffering is the lossless option).
InitState(channel attribute) is armed before the group join, so no live frame can slip out ahead of it.sendInitialStatere-arms on the refresh/resync path (channelWritabilityChanged) too, which has the same race.broadcastMarketDataroutes each frame throughdeliver(...): a liveTRADES_BATCH/CANDLE_UPDATEdestined to an initializing channel is buffered in arrival order instead of written. Book frames are never buffered: the book snapshot is synchronous andBOOK_DELTAs are version-chained on top of it (already safe today), so the change stays scoped to the async frame types.allOf(...).whenComplete(...); on settle we schedule a flush on the channel event loop: write the initial snapshot (whichever fetch succeeded), then the buffered live frames in arrival order, then stop buffering.MAX_INIT_BUFFER_FRAMES= 512): past the cap the oldest frame is dropped (the stream is conflatable) and a single warning is logged, so a stuck DB read cannot balloon the heap.initializing == falseand writes through after the drained frames. No frame is lost or reordered.The change is scoped to the subscribe/initial-state flow in
MarketDataWebSocket.java;GatewayStateManageris untouched.Tests
New
MarketDataWebSocketInitOrderingTest(EmbeddedChannel, driving the realMarketDataHandlerwith a state manager whose async fetches complete on command):mvn -pl match-gateway test: 266 run, 0 failures, 4 skipped (the skipped 4 are the TimescaleDB integration tests, self-skipped viaAssumewhenMARKET_PG_TEST_URLis unset). No live stack required.Closes #97
🤖 Generated with Claude Code