You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
## What
The Rust durable-streams server added **linear memory per SSE
subscriber** in the fan-out scenario (1 stream, 1 writer, many
subscribers), while the ursula reference keeps it flat — see [ds-bench
results](https://github.com/electric-sql/ds-bench/blob/main/results/sse/sse-comparison.md).
This trims the per-subscriber footprint by ~60%.
### Root cause
With the socket-buffer cap already in place, the residual growth was
**userspace heap per connection**. Each live SSE subscriber:
- spawned a **producer task** + allocated an **mpsc channel**, and
- kept the whole `conn_loop` state machine future resident while parked
(up to `SSE_MAX_DURATION`),
- plus a pinned 4 KiB read buffer.
≈ **14 KiB/subscriber**.
### Fix
- **Inline SSE production** via a new pull-based `Body::Sse` /
`EventSource` — no per-subscriber producer task, no channel. All
caught-up subscribers still share the one resident tail chunk.
- **Hand off to a dedicated streaming task** when a request is SSE, so
the large `conn_loop` future is freed while a subscriber is parked. The
connection permit moves with it (stays counted); the socket closes when
the stream ends (the client reconnects from its last offset).
An idle subscriber's resident state collapses to roughly a cursor over
the shared stream tail.
## Results
Isolated server-process RSS on Linux/cgroup (the ds-bench methodology),
wal mode:
| | per-subscriber slope | @ 2000 subs | idle baseline |
|---|---|---|---|
| before | ~13.6 KiB/sub | ~30 MiB | 3 MiB |
| **after** | **~5 KiB/sub** | **~13.5 MiB** | 3 MiB |
This keeps our low idle baseline (vs ursula's ~15 MiB mimalloc floor)
while bringing the slope into the same regime, so we're comparable at
scale instead of worse.
> Note: the official ds-bench SSE suite (`scripts/run-sse.sh`) is
GKE-only; the numbers above are from a faithful local Docker
reproduction using the same cgroup working-set metric. Re-running the
real suite on GKE is recommended to confirm the slope drop at scale.
### Allocators evaluated (and rejected)
mimalloc and jemalloc were both measured — both are **worse** for our
allocation shape (mimalloc: ~6× higher absolute, ~3× worse slope),
because our per-connection state is kilobyte-sized rather than
cursor-sized. glibc stays the default.
## Verification
- `cargo test --release`: 87 passed
- Conformance suite: 326 passed across wal / tail-cache-on / memory
(Linux) modes
- Single correct `Connection: keep-alive` header on the wire; keep-alive
reuse intact
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Cut SSE fan-out per-subscriber memory by ~60%. Each live subscriber used to spawn a producer task and an mpsc channel and keep the whole connection state machine resident while parked. SSE is now produced inline (new pull-based `Body::Sse`) and the connection is handed to a small dedicated streaming task, so an idle subscriber's resident footprint collapses to roughly a cursor over the shared stream tail.
6
+
7
+
Live-tail SSE subscribers are then served from a fixed pool of epoll reactor threads instead of a parked connection task per subscriber. Each subscriber becomes a compact slab entry, so per-subscriber resident memory drops from ~7 KiB to ~0.6 KiB and stops scaling with the number of active connections. Linux only; other platforms keep the existing path.
Copy file name to clipboardExpand all lines: packages/durable-streams-rust/README.md
+4-2Lines changed: 4 additions & 2 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -208,6 +208,8 @@ publishing is re-enabled.
208
208
209
209
## Benchmarks
210
210
211
-
Numbers from **[ds-bench](https://github.com/electric-sql/ds-bench)**, a reproducible single-node harness. All figures below are the default **`wal` mode** (group-commit fsync, resident tail cache off). One server node (`c4d-standard-16-lssd`) pinned to **4 CPUs**, a Kubernetes client fleet driving 256-byte binary appends. Throughput is the saturation ceiling; latency is fleet-wide p50 / p99; memory is the pod cgroup working set (anon + active page cache), peak / p50.
211
+
Numbers from **[ds-bench](https://github.com/electric-sql/ds-bench)**, a reproducible single-node harness — full matrix, methodology, and the comparison systems are in the **[2026-06-30 report](https://github.com/electric-sql/ds-bench/blob/feat/read-scalability-workload/results-2026-06-30/REPORT.md)** (measured on this build). One server node (`c4d-standard-16-lssd`) pinned to **4 CPUs**, a Kubernetes client fleet driving 256-byte binary appends. Throughput is the saturation ceiling; latency is fleet-wide p50 / p99; memory is the pod cgroup working set (anon + active page cache), peak / p50.
212
212
213
-
**Writes** — peaks at **860,000 append/s** at 4 CPUs, scales cleanly to **100k streams**, with median append latency staying sub-ms → ~1.5 ms. Memory tracks **stream count, not bytes** (each stream is a lean record plus its open file; data lives on disk / in the page cache, never resident), so it stays in tens–hundreds of MiB even at 100k streams, with p50 ≪ peak.
213
+
**Writes** (`wal` mode, group-commit fsync) — peak **~928,000 append/s** at 4 CPUs, scaling cleanly to **100k streams**, with median append latency sub-ms → ~1.5 ms through 10k streams. Memory tracks **stream count, not bytes** (each stream is a lean record plus its open file; data lives on disk / in the page cache, never resident), so it stays in tens–hundreds of MiB even at 100k streams, with p50 ≪ peak — versus the 1–3.5 GB a log-resident design holds at the same load.
214
+
215
+
**Live-tail reads (SSE reactor)** — the per-core epoll reactor serves SSE live tail at **p99 ~0.5–2.5 ms from 64 to 2048 concurrent connections**, the flattest-scaling read path (long-poll holds ~5–7 ms at the same fan-out; catch-up's resident re-scan does not scale past ~64 connections). **Fan-out memory is shared, not per-subscriber**: 1000 SSE subscribers on one stream cost **~27 MiB** total — one resident tail served to all — so subscriber count is decoupled from memory.
0 commit comments