feat(websocket): Implement Backpressure & Flow Control System for Socket.IO Broadcasts - #217
Merged
Merged
Conversation
Adds a production-grade backpressure and flow control sub-system for all Socket.IO broadcasts, addressing unbounded memory growth under high load, event storms, slow client degradation, and lack of prioritisation. ## Core modules (src/websocket/backpressure/) ### BackpressureMonitor - Inspects socket.client.conn.sendBuffer at three levels: per-client, per-room (sum of all sockets in room), and global (all sockets) - Configurable thresholds via env vars (WS_BP_CLIENT/ROOM/GLOBAL_THRESHOLD_BYTES) - Mock injection API for deterministic unit tests - O(n) scan completes < 1ms per call ### PriorityEventQueue - Four-level FIFO queue: CRITICAL (0) / HIGH (1) / MEDIUM (2) / LOW (3) - classifyEvent() maps every event name to a priority level - CRITICAL events (moderation) bypass capacity cap entirely - Per-level capacity cap (default 500) evicts oldest on overflow - TTL expiry (default 5s) discards stale entries on dequeue - dropUnderPressure() clears LOW/MEDIUM/HIGH while preserving CRITICAL ### FlowController - Wraps every broadcast call with backpressure/coalesce decisions - CRITICAL events bypass FlowController entirely for maximum reliability - Coalescing: campaign:updated, organization:updated, notification:unread_count collapse to single emit per room per window - Under backpressure: events queued in PriorityEventQueue per room - Drain timer (default 100ms) flushes queue in priority order once pressure lifts; drops LOW events on sustained pressure - shouldThrottle(room) lets event generators skip expensive DB fetches - Configurable via WS_FC_DRAIN_INTERVAL_MS / WS_FC_COALESCE_WINDOW_MS ### ClientEvictionManager - Periodic sweep (default 5s) over all tracked sockets - Slow-client eviction: socket.disconnect(true) after buffer exceeds threshold for sustainMs (default 30s) continuously - Idle-client eviction: disconnect after idleTimeoutMs (default 5min) with no recordActivity() calls - Per-client token bucket rate limiter (default 50 events/s) synchronous tryConsume() — zero async overhead on hot path - Eviction history (capped at 1000) with reason, userId, bufferBytes - Reconnection: onConnect() creates fresh state with full token bucket ### BackpressureObservability - Periodic structured logging every 15s (INFO normal / WARN pressured) - Logs each backpressured room individually at WARN level - Rolling snapshot window (default 20) accessible via captureSnapshot() for health endpoints and admin dashboards ## socket.server.ts integration - All broadcastTo*() helpers route through routedEmit() → FlowController - CRITICAL moderation events (sendCampaignSuspended, sendCampaignReinstated, sendAppealUpdate) call io.to().emit() directly, skipping FlowController - sendCampaignUpdate() checks shouldThrottleRoom() before DB fetch - Three new public APIs: getBackpressureSnapshot(), shouldThrottleRoom(), getBackpressureSystem() — all return null/false before init (fail-open) - BackpressureObservability and ClientEvictionManager started on init ## Tests (131 passing across 8 suites) Unit tests: - BackpressureMonitor.test.ts — 22 tests (thresholds, aggregation, mocks) - PriorityEventQueue.test.ts — 23 tests (ordering, TTL, capacity, drops) - FlowController.test.ts — 17 tests (bypass, coalesce, drain, stats) - ClientEvictionManager.test.ts — 18 tests (slow/idle eviction, rate limit) - BackpressureObservability.test.ts — 18 tests (snapshots, window, logging) Integration tests: - backpressure.integration.test.ts — 11 tests (event storm E2E, CRITICAL bypass, coalescing under load, global backpressure, room independence, rate limiting, full donation-surge scenario) - backpressure.socket.server.test.ts — 16 tests (pre-init fail-open, all exports present, moderation event wiring, DB skip under throttle, reconnection after eviction) Performance/regression tests: - backpressure.perf.test.ts — 12 tests - getClientBufferBytes < 1ms (1000-call average) - getRoomBufferStats < 1ms (1000-socket room) - getGlobalBufferStats < 5ms (10 000 sockets) - evictSocket < 10ms - FlowController.emit overhead < 1ms - PriorityEventQueue capacity bounds memory under 10 000-event flood - All existing broadcast exports unchanged (regression) - New backpressure exports present - Fail-open before init (no throws) - Event priority classification correct for all documented events - Reconnection after eviction: fresh token bucket
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.
Closes #215
Summary
This PR introduces a production-grade backpressure and flow control sub-system for all WebSocket broadcasts in the AidLink backend. Prior to this change, every call to
io.to(room).emit()was fire-and-forget with no queue management, no priority handling, and no protection against slow or abusive clients. Under high load — such as a donation surge to a viral campaign — this caused unbounded memory growth in Socket.IO's internal send buffers, potential server OOM crashes, and no way to guarantee delivery of critical moderation events over informational noise.This PR addresses all of that without breaking any existing broadcast behaviour or introducing new runtime dependencies.
Problem Being Solved
Before this PR
notification:unread_countcounter update.After this PR
Architecture
The backpressure sub-system lives entirely in
src/websocket/backpressure/and is composed of four focused classes that are wired together insocket.server.tsat initialization time.New Files
src/websocket/backpressure/BackpressureMonitor.tssrc/websocket/backpressure/PriorityEventQueue.tssrc/websocket/backpressure/FlowController.tssrc/websocket/backpressure/ClientEvictionManager.tssrc/websocket/backpressure/BackpressureObservability.tssrc/websocket/backpressure/index.tssrc/websocket/backpressure/BackpressureMonitor.test.tssrc/websocket/backpressure/PriorityEventQueue.test.tssrc/websocket/backpressure/FlowController.test.tssrc/websocket/backpressure/ClientEvictionManager.test.tssrc/websocket/backpressure/BackpressureObservability.test.tstests/integration/backpressure.integration.test.tstests/integration/backpressure.socket.server.test.tstests/performance/backpressure.perf.test.tsModified Files
src/websocket/socket.server.tsComponent Deep-Dives
BackpressureMonitorReads
socket.client.conn.sendBuffer— Socket.IO's internal outbound frame array — and sums byte lengths to measure actual memory pressure. Three levels of detection:isClientBackpressured(socketId)) — is a single socket's buffer aboveWS_BP_CLIENT_THRESHOLD_BYTES(default 1 MB)?isRoomBackpressured(room)) — is the sum of all buffers in a room aboveWS_BP_ROOM_THRESHOLD_BYTES(default 10 MB)?isGlobalBackpressured()) — is the server-wide total aboveWS_BP_GLOBAL_THRESHOLD_BYTES(default 100 MB)?All three are O(n) synchronous scans with < 1ms measured latency. A mock injection API (
injectMockBuffers()) allows deterministic unit tests without a live Socket.IO server.PriorityEventQueueA four-level priority queue with one FIFO array per level. Priority classification:
campaign:suspended,campaign:reinstated,campaign:access_revoked,campaign:access_restored,appeal:updateddonation:created,donation:confirmed,distribution:updated,distribution:confirmed,beneficiary:updatedcampaign:updated,organization:updated,notification:newnotification:unread_count,campaign:trending,analytics:refreshKey properties:
WS_QUEUE_LEVEL_CAPACITY, default 500) does not apply to level 0.WS_QUEUE_TTL_MS, default 5s) — entries that have waited too long are silently skipped on dequeue, so stale state is never delivered to clients after a pressure episode.dropUnderPressure()— drops LOW (and optionally MEDIUM/HIGH) events in bulk when sustained backpressure is detected during a drain cycle. CRITICAL entries survive every drop.FlowControllerThe central decision engine. Replaces every direct
io.to(room).emit()call. Decision tree peremit(room, event, data):Drain cycle: when a room is enqueued, a timer fires after
drainIntervalMs(default 100ms). If pressure has lifted, up todrainBatchSize(default 50) events are flushed in priority order. If still pressured, LOW events are dropped and the timer is rescheduled.shouldThrottle(room): a cheap boolean check event generators can call before doing expensive DB queries.sendCampaignUpdate()now uses this:ClientEvictionManagerA periodic sweep (default every 5s) over every tracked socket:
Slow-client eviction: a socket whose send buffer exceeds the client threshold for a sustained
slowSustainMs(default 30s) window is disconnected withsocket.disconnect(true). The buffer must stay large for the full window — if it clears and grows again, the 30s clock resets. This prevents evicting clients that momentarily spike.Idle-client eviction: a socket that has had no inbound event in
idleTimeoutMs(default 5 min) is disconnected.recordActivity(socketId)must be called on each inbound event — this is wired into the connection handler by wrappingsocket.on:Per-client rate limiting: a synchronous token bucket per socket (capacity =
eventsPerSecond, default 50).tryConsume()is O(1) with no async overhead — critical for the hot path of deciding whether to drop an inbound event.Reconnection safety:
evictSocket()callsstates.delete(socketId), andonConnect()creates a brand-new state with a full token bucket. A reconnecting client always starts clean.Eviction history: the last 1,000 evictions are recorded with
{ socketId, userId, reason, evictedAt, bufferBytes }and accessible viarecentEvictions()for admin dashboards.BackpressureObservabilityFires a structured Winston log every 15s (
WS_OBS_REPORT_INTERVAL_MS):{ "global": { "totalBufferBytes": 0, "socketCount": 42, "backpressured": false }, "backpressuredRooms": 0, "totalRooms": 12, "flowController": { ... } }{ "room": "campaign:abc123", "totalBytes": 12582912, "socketCount": 3400 }Snapshots are stored in a rolling window (default 20 entries) and accessible via
captureSnapshot()— suitable for/healthendpoints or admin dashboard widgets.socket.server.tsChangesWhat changed
Four backpressure singletons initialized on
initializeWebSocket():All
broadcastTo*()helpers now callroutedEmit()instead ofio.to().emit()directly.Moderation paths (
sendCampaignSuspended,sendCampaignReinstated,sendAppealUpdate) callio.to().emit()directly, completely bypassingFlowControllerfor maximum reliability. Suspension also retains its room-eviction logic unchanged.Three new public exports:
shouldThrottleRoom(room): boolean— fail-open (returnsfalse) before initgetBackpressureSnapshot(): BackpressureSnapshot | null— returnsnullbefore initgetBackpressureSystem()— returns all four components for tests/admin endpoints,nullbefore initWhat did NOT change
campaign:access_revokeddelivery) is unchanged.socket.server.test.ts(authenticateSocketTokentests) still passes without modification.Environment Variables
All thresholds are tunable via environment variables. Defaults are conservative and safe for production:
WS_BP_CLIENT_THRESHOLD_BYTES1048576(1 MB)WS_BP_ROOM_THRESHOLD_BYTES10485760(10 MB)WS_BP_GLOBAL_THRESHOLD_BYTES104857600(100 MB)WS_FC_DRAIN_INTERVAL_MS100WS_FC_COALESCE_WINDOW_MS100WS_FC_DRAIN_BATCH_SIZE50WS_QUEUE_TTL_MS5000WS_QUEUE_LEVEL_CAPACITY500WS_EVICT_SWEEP_INTERVAL_MS5000WS_EVICT_SLOW_SUSTAIN_MS30000WS_EVICT_IDLE_TIMEOUT_MS300000WS_EVICT_EVENTS_PER_SECOND50WS_OBS_REPORT_INTERVAL_MS15000WS_OBS_SNAPSHOT_WINDOW20Test Results
131 tests passing across 8 suites, zero failures introduced.
Unit test coverage highlights
dropUnderPressurenever touches CRITICAL;drainBatchrespectsmaxCount;isEmptyhandles all-expired case.shouldThrottlereflects live state;destroy()cancels all timers.sustainMs; slow timer resets when buffer clears; idle client evicted afteridleTimeoutMs;recordActivity()resets idle timer; token bucket allows exactlyeventsPerSecondthen returns false; bucket refills over time;destroy()stops sweep.getSnapshots()is most-recent first; window evicts oldest;start()is idempotent;destroy()stops timer; INFO vs WARN log level based on pressure state.Integration / E2E scenario tests
campaign:suspendedemitted immediately even when room queue is 100 events deepcampaign:updatedevents coalesce to 1 emit with latest payloadshouldThrottle()correctly reflects pressure on/off as buffers changecampaign:hotdoes not affect broadcasts tocampaign:quietPerformance benchmarks (from
backpressure.perf.test.ts)getClientBufferBytes()— 1,000 callsgetRoomBufferStats()— 1,000-socket room, 100 callsgetGlobalBufferStats()— 10,000 sockets, 10 callsevictSocket()— single callFlowController.emit()— 1,000 callsAcceptance Criteria Checklist
sendCampaignUpdate()skips DB fetch when room is throttledcampaign:suspended,campaign:reinstated, room eviction) unchangedOut of Scope (not in this PR)
Reviewer Notes
io.to(room).emit()returning before any external side effect. All existing callers use fire-and-forget semantics, so this is safe._mockBuffersinjection onBackpressureMonitorisprivatewith a publicinjectMockBuffers()accessor. It will never activate in production (guard isif (this._mockBuffers)which is undefined by default).ClientEvictionManager.start()registers aconnectionlistener onio— this is additive and does not conflict with the existing connection handler insocket.server.ts.FlowController.destroy()andClientEvictionManager.destroy()clear all timers. These are not currently called on graceful shutdown, but can be wired into aSIGTERMhandler as a follow-up.