HarperFast / HarperFast/harper

MQTT: one delivery loop per session instead of per subscription (share iterator machinery and per-(connection,table) state)

Open
#2,079 0 comments 0 reactions 0 assignees View on GitHub
enhancement
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

Open the contributing 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.