HarperFast / HarperFast/harper
MQTT: one delivery loop per session instead of per subscription (share iterator machinery and per-(connection,table) state)
- Dominant language
- JavaScript
- Stars
- 89
- Forks
- 10
- Avg merge
- 2d 6h
- Merged PRs (30d)
- 200
Description
## Problem
`SubscriptionsSession.addSubscription` (`server/DurableSubscriptionsSession.ts`) spawns a forever-running async IIFE with a `for await` loop **per subscription**. At ~15 subscriptions/connection that is ~15 generators, `EventQueueIterator`s, pending promises, and `AsyncContextFrame`s per connection, plus a `RequestTarget`, `TableResource`, context, and `DatabaseTransaction` per subscription — all measured 1:1 with subscription count in both a production heap snapshot (157,894 subs/worker, 5.31 KB/sub) and a local load repro (15k subs, 5.01 KB/sub).
Measured with the repo's own `IterableEventQueue`: the idle iterator machinery costs **1,123 B/sub vs 443 B/sub** for an `on('data')` listener, and **239 ns vs 16 ns per delivery** at 7,200-subscriber fan-out.
## Proposal
One delivery loop per session (connection), not per subscription:
- Each subscription attaches a cheap `'data'` listener that enqueues `(subscription, event)` into a single session-level queue drained by one `for await` loop that owns serialization, socket-drain await, acks, and teardown. Pull semantics (backpressure) are preserved at the session boundary.
- Share `RequestTarget` / context / `TableResource` per (connection, table) — production showed 29 distinct topics across 157k subscriptions, so sharing is highly effective.
**Contract prerequisite:** the replay throttles in `Table.subscribe` gate on `subscription.queue?.length > EVENT_HIGH_WATER_MARK` (`resources/Table.ts` ~4056/4160/4322), which is always empty once a `'data'` listener is attached — replay would lose backpressure. `Table.subscribe` needs a consumer-supplied pause check (e.g. `request.shouldPause?.()`) before the listener path can carry replay traffic.
**Why not just pass `request.listener` per subscription:** `emit('data')` discards the listener's returned promise (kills socket-drain backpressure and the `false`-means-stop contract at `DurableSubscriptionsSession.ts:306-307`), can reorder publishes (the MQTT listener awaits `serialize`), runs consumer code inside the aftercommit inter-thread lock, and is silently ignored by custom-resource `subscribe()` overrides (base `Resource.subscribe` never reads `request.listener`). A session-level loop avoids all four.
Expected win: ~680 B/sub of iterator machinery × ~15 subs/conn, plus shared per-(conn,table) objects — roughly 1.5–2.5 KB/sub of the 5.3 KB/sub total, and ~90% of the delivery-machinery CPU (subsumes the yield-batching fix on `perf/subscription-overhead`).
---
_Filed by an AI agent (Claude Code) from a subscription-path memory/CPU investigation._
Contributor guide
Research direction
Start with SubscriptionsSession.addSubscription in server/DurableSubscriptionsSession.ts and the replay throttles in resources/Table.ts around lines 4056, 4160, and 4322. Trace how subscription listeners, EventQueueIterator backpressure, serialization, acknowledgements, and teardown currently interact. Done means one delivery loop per session, shared per-(connection,table) state, and preserved replay and socket-drain backpressure.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- nodejs, typescript
- Domain
- backend, performance
- Issue type
- Refactor
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 45/100