Repository navigation
Coalesce high-frequency socket updates and protect against slow consumers #96
Description
Overview
Every market-core Trade event the indexer stores becomes a full TRADE_EXECUTED broadcast to every connected socket (io.emit in src/server.ts). That is fine at demo volume, but it scales badly in exactly the moments that matter. During a busy period the price of a single market can change many times per second; during indexer catch-up (after a restart, an RPC outage, or a first boot with no cursor) backfillEvents() pages through up to the whole RPC retention window, about 7 days on testnet, and emits every historical trade live, as if it had just happened. Clients render thousands of stale prices in a burst and every socket's outbound buffer grows.
socket.io has no per-socket backpressure: emit appends to the client's write buffer and the server keeps the data in memory until the transport drains it. A single slow mobile client or a stalled tab can therefore make the process accumulate unbounded memory, and with polling transport the buffered packets sit in the server until the next poll.
This issue introduces a throttling layer between the indexer and socket.io: per-market latest-wins MARKET_PRICE ticks emitted at a configurable rate, volatile delivery for non-essential events, suppression of live emits during historical catch-up, and slow-consumer detection that drops or disconnects sockets whose buffers exceed a threshold. The documented TRADE_EXECUTED semantics must stay available for clients that rely on every trade, and the change must be proven with a load test showing bounded memory.
Background & current behavior
src/server.ts:startEventListener((event, payload) => io.emit(event, payload)); no rooms, no transport options, defaultmaxHttpBufferSize, polling and websocket enabled.src/services/indexer.service.ts:backfillEvents(fromLedger?, deps)readsgetHealth(), starts atcursor + 1oroldestLedger, pagesgetEventswithPAGE_LIMIT = 200until a short page, and for each newly stored event runsapplySideEffects(), which for market-coreTradeupdatesMarket.currentYesProb = priceYesBps(reserve_yes, reserve_no) / 10000and emitsTRADE_EXECUTEDwith{ onChainMarketId, marketId, marketQuestion, trader, direction, isBuy, collateralRaw, sharesRaw, feeRaw, priceYesBps, txHash, ledger, eventId }.REASONING_PUBLISHEDis emitted forTracePublished. There is no notion of "live" vs "historical": the emitter cannot tell whether an event is seconds or days old.startEventListenerpolls everyINDEXER_POLL_MS(default5000) and rerunsbackfillEventsuntil caught up; there is no lag metric.docs/api.md"Realtime (socket.io)" documentsTRADE_EXECUTEDas "Emitted when the indexer stores a market-coretradeevent" and the frontend uses it for both a trade feed and price updates.- Wave issue Feature/integration and fixes #4 adds per-market rooms and a 20-event replay on subscribe; this issue is compatible with rooms and must not depend on them.
Goals
- New
MARKET_PRICEevent:{ onChainMarketId, marketId, priceYesBps, ledger, eventId, ts }, latest-wins per market, at most once perSOCKET_PRICE_TICK_MSper market. TRADE_EXECUTEDstill emitted for every live trade, but asvolatileby default (configurable), so a socket that cannot take it now skips it instead of buffering.- No live emits for historical events during catch-up; a single
CATCHUP_COMPLETEsummary instead. - Slow-consumer detection with drop and disconnect thresholds.
- A
src/lib/socket-throttle.tsmodule that is unit-testable without socket.io. - Load test proving bounded memory with slow and stalled clients.
Non-goals
- Cross-instance fan-out (separate event-bus issue); the throttle runs where the emit happens.
- Changing
REASONING_PUBLISHEDsemantics beyond volatile delivery being configurable. - Price history or candles (separate issue).
- Socket authentication or connection caps (separate issues).
Proposed implementation
- Liveness classification in
src/services/indexer.service.ts: computelive = page.latestLedger - event.ledger <= SOCKET_LIVE_LEDGER_LAG(new env, default12, about one minute of ledgers) and pass it through the emitter: changeIndexerDeps.emitto(event: string, payload: unknown, meta: { live: boolean; ledger: number; marketKey: string | null }) => void. Updatetests/indexer.test.ts(it buildsemit = (e, p) => emitted.push([e, p])) to accept the extra argument. During a poll that starts more thanSOCKET_LIVE_LEDGER_LAGbehindhealth.latestLedger, mark the poll as catch-up; at its end emit oneCATCHUP_COMPLETE{ fromLedger, toLedger, events: n }so clients refetch REST state. src/lib/socket-throttle.ts(new):Behaviour: forexport interface Sink { emit(event: string, payload: unknown, opts: { volatile: boolean }): void } export interface ThrottleOptions { priceTickMs: number; tradeVolatile: boolean; dropHistorical: boolean; now?: () => number; schedule?: (fn: () => void, ms: number) => unknown } export class SocketThrottle { constructor(sink: Sink, opts: ThrottleOptions); handle(event: string, payload: any, meta: { live: boolean; ledger: number; marketKey: string | null }): void; flush(): void; // emit pending latest prices now (shutdown, tests) stop(): void; stats(): { coalesced: number; droppedHistorical: number; emitted: Record<string, number> }; }
TRADE_EXECUTED, recordpending[marketKey] = { priceYesBps, ledger, eventId }if newer (compareledger, theneventId) and schedule a flush for that market atlastEmit + priceTickMs; emit the trade itself only ifmeta.live(ordropHistoricalis false), withvolatileper options. ForREASONING_PUBLISHED, emit non-volatile if live. Never reorder price ticks backwards: drop a pending update older than the last emitted ledger for that market.- Wiring in
src/server.ts:Callconst throttle = new SocketThrottle({ emit: (e, p, { volatile }) => (volatile ? io.volatile.emit(e, p) : io.emit(e, p)) }, fromConfig()); stopEventListener = startEventListener((e, p, meta) => throttle.handle(e, p, meta));
throttle.flush(); throttle.stop()in shutdown beforeio.close(). - Slow-consumer detection: new
attachSlowConsumerGuard(io, { warnBytes, dropBytes, checkMs })insrc/lib/socket-throttle.ts. EverycheckMsiterateio.of('/').socketsand measure buffered bytes: for the websocket transport,socket.conn.transport.socket?.bufferedAmount(the underlyingwssocket); for polling,socket.conn.writeBuffer.lengthpackets times an estimated size. AbovewarnBytesmark the socketdegraded(onlyMARKET_PRICEticks, which are latest-wins, are sent to it; trades are skipped); abovedropBytesemit nothing further andsocket.disconnect(true)with a log line containing onlysocket.idand the byte count (sampled).
Because globalio.emitcannot skip individual sockets, implement degraded handling by having degraded socketsleavearealtime:fullroom that all sockets join on connection, and emit trades to that room instead of globally (io.to('realtime:full').volatile.emit(...)), while price ticks still go to everyone. - Engine.IO options in
src/server.ts: setpingInterval/pingTimeoutexplicitly (defaults documented),perMessageDeflate: false(CPU cost on bursty small messages), andmaxHttpBufferSizeto a small inbound limit (clients send nothing today). Connection caps and origin hardening are left to the socket hardening issue. - Config (
src/config/index.ts,.env.example):SOCKET_PRICE_TICK_MS(default1000),SOCKET_TRADE_VOLATILE(defaulttrue),SOCKET_LIVE_LEDGER_LAG(default12),SOCKET_SLOW_WARN_BYTES(default262144),SOCKET_SLOW_DROP_BYTES(default2097152),SOCKET_SLOW_CHECK_MS(default2000). - Docs: update "Realtime (socket.io)" in
docs/api.md:MARKET_PRICEpayload and rate,TRADE_EXECUTEDis best-effort (volatile) and live-only,CATCHUP_COMPLETE, and that clients needing every trade should use the REST/indexed history (or the SSE stream if it exists) rather than the socket. - Load test (
tests/socket-throttle.test.tsfor unit behaviour plus a new scriptscripts/socket-load.mjsfor the soak): spin up the server in-process with a fake event source that emits 50 trades/s across 20 markets for 60 s, connect 200 fast clients and 20 clients that never read (paused underlying socket), and recordprocess.memoryUsage().heapUsedand RSS every second. The soak asserts peak heap growth stays below a configured budget and that stalled clients are disconnected.
Edge cases & pitfalls
- Clients that use
TRADE_EXECUTED.priceYesBpsto render prices will see fewer updates if they miss volatile trades; that is whyMARKET_PRICEmust be emitted reliably (non-volatile) and documented as the price source. - A burst of trades on one market followed by silence must still emit the final price (the scheduled flush must fire even if no further events arrive).
- Timers per market must be cleaned up for markets that stop trading, or the throttle leaks.
bufferedAmountis not available on every transport; fall back gracefully and never throw from the guard loop.- Catch-up detection must not suppress events forever if
latestLedgerfrom RPC is stale; cap catch-up mode per poll.
Testing plan
tests/socket-throttle.test.ts (fake clock via injected now/schedule, recording sink):
bursts of trades on one market produce one MARKET_PRICE per tick with the latest pricefinal price after a burst is emitted when the tick elapsesout-of-order older ledger never overwrites a newer pending pricehistorical events during catch-up are not emitted live and CATCHUP_COMPLETE is emitted onceTRADE_EXECUTED is volatile by default and non-volatile when configuredidle markets release their timersslow-consumer guard degrades then disconnects sockets above thresholds(fake socket objects withbufferedAmount)- integration with
socket.io-client:fast client receives MARKET_PRICE while a stalled client is disconnected
Soak: node scripts/socket-load.mjs --duration 60 documented in the test plan; run manually and in a nightly job if available. Commands: npm test, npm run build.
Acceptance criteria
-
src/lib/socket-throttle.tsimplements coalescing, volatile delivery, catch-up suppression and the slow-consumer guard with injected clocks. - The indexer passes
live,ledgerandmarketKeymetadata to the emitter, and existing indexer tests are updated and pass. -
MARKET_PRICEis emitted at most once per tick per market with the latest price, including the trailing update. - Historical events are not emitted live during catch-up;
CATCHUP_COMPLETEis emitted once per catch-up. - Sockets above the warn threshold stop receiving trades; above the drop threshold they are disconnected.
- New env vars are validated in
src/config/index.tsand documented in.env.example. - The soak script demonstrates bounded heap growth with stalled clients; results are recorded in the PR.
-
docs/api.mddocumentsMARKET_PRICE,CATCHUP_COMPLETEand the changed delivery guarantees ofTRADE_EXECUTED. -
npm run build,npm testand CI pass.
Resources
src/server.ts,src/services/indexer.service.ts,src/generated/fpmm.ts(priceYesBps),tests/indexer.test.tssrc/config/index.ts,.env.example,docs/api.md,docs/wave-issues/04-socket-market-rooms.md- socket.io volatile events: https://socket.io/docs/v4/emitting-events/#volatile-events
- socket.io server options (
pingInterval,maxHttpBufferSize,perMessageDeflate): https://socket.io/docs/v4/server-options/ wsbufferedAmount: https://github.com/websockets/ws/blob/master/doc/ws.md#websocketbufferedamount- Stellar RPC
getEvents: https://developers.stellar.org/docs/data/apis/rpc/api-reference/methods/getEvents
Important
Raise your pull request against the develop branch, not main.
All contributor work for this issue must be submitted as a PR into develop. Pull requests opened against main will not be merged and are closed automatically. Create your branch from develop, and before opening the PR double-check that the base branch is set to develop.
Generated by Claude Code
Activity
harmondconsultant-png commentedon Oct 17, 2026
@harmondconsultant-png has applied to work on this issue as part of the Stellar Wave Program's 10th wave.
ℹ️ Repo Maintainers: To accept this application, review their application or assign @harmondconsultant-png to this issue.
Sirbeejay16 commentedon Oct 17, 2026
@Sirbeejay16 has applied to work on this issue as part of the Stellar Wave Program's 10th wave.
ℹ️ Repo Maintainers: To accept this application, review their application or assign @Sirbeejay16 to this issue.
tijanib141 commentedon Oct 17, 2026
@tijanib141 has applied to work on this issue as part of the Stellar Wave Program's 10th wave.
ℹ️ Repo Maintainers: To accept this application, review their application or assign @tijanib141 to this issue.
rubbathabrahband commentedon Oct 17, 2026
@rubbathabrahband has applied to work on this issue as part of the Stellar Wave Program's 10th wave.
ℹ️ Repo Maintainers: To accept this application, review their application or assign @rubbathabrahband to this issue.
drips-wave commentedon Oct 17, 2026
Congratulations, @rubbathabrahband! 🎉 Your application was accepted by the repo's maintainers, and the issue is due on October 22, 2026.
🧑💻 @rubbathabrahband: Please resolve the issue such that the repo's maintainers have enough time to review your contribution before the due date. You'll earn Points for completing the issue on-time, which will make you eligible for a share of the Stellar Wave Program's reward pool.
Warning
When opening a PR, please link it to this issue to ensure it gets tracked accurately. Points are awarded when this issue is marked as completed by the maintainer.
🤠 Repo maintainers: Please keep an eye on the contributor's progress and review their work before the due date. You can manage this issue, including adjusting its complexity and points, here.
🌊 Happy Wave 🌊
rubbathabrahband commentedon Oct 17, 2026
Resolved in PR #174: #174