Skip to content

Commit 3c6e2ce

Browse files
balegasclaude
andauthored
perf(server-rust): cut SSE fan-out per-subscriber memory ~60% (#4661)
## 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>
1 parent 044a8d5 commit 3c6e2ce

8 files changed

Lines changed: 1001 additions & 107 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@electric-ax/durable-streams-server-rust': patch
3+
---
4+
5+
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.

packages/durable-streams-rust/README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,8 @@ publishing is re-enabled.
208208

209209
## Benchmarks
210210

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.
212212

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.

packages/durable-streams-rust/src/api.rs

Lines changed: 43 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
// optional framing bytes, and the engine decides how to serve them (buffered
66
// copy, or sendfile where the platform can).
77

8+
use std::future::Future;
9+
use std::pin::Pin;
810
use std::sync::atomic::AtomicBool;
911
use std::sync::Arc;
1012

@@ -13,6 +15,39 @@ use tokio::sync::mpsc;
1315

1416
use crate::store::Segment;
1517

18+
/// A pull-based streaming body, driven INLINE on the connection task: the engine
19+
/// calls `next_chunk` in a loop and writes each returned frame, ending when it
20+
/// yields `None`. Unlike `Body::Channel` (a spawned producer task feeding an
21+
/// mpsc channel), this adds NO per-response task and NO channel — the producer
22+
/// runs on the connection's own task. SSE fan-out uses it so each subscriber
23+
/// costs only its connection's existing footprint, not an extra ~4–5 KiB of
24+
/// resident task+channel heap (the source of per-subscriber linear memory).
25+
pub trait EventSource: Send {
26+
/// Produce the next framed chunk, or `None` to end the stream. The returned
27+
/// future borrows `self`, so state lives in the source (no channel buffer).
28+
fn next_chunk(&mut self) -> Pin<Box<dyn Future<Output = Option<Bytes>> + Send + '_>>;
29+
30+
/// Reactor eligibility (Linux). When this returns `Some`, the connection task
31+
/// hands the socket to the epoll reactor instead of driving the stream
32+
/// inline, freeing its future — the per-subscriber cost. Default: not
33+
/// eligible (inline / hand-off, as for every non-SSE source).
34+
#[cfg(target_os = "linux")]
35+
fn reactor_reg(&self) -> Option<SseReg> {
36+
None
37+
}
38+
}
39+
40+
/// Live-tail SSE registration: everything the reactor needs to serve a
41+
/// subscriber once the connection task has handed off its socket. Constructed
42+
/// only on Linux (`SseSource::reactor_reg`).
43+
#[cfg(target_os = "linux")]
44+
pub struct SseReg {
45+
pub st: Arc<crate::store::StreamState>,
46+
pub start: u64,
47+
pub encoding: crate::handlers::SseEncoding,
48+
pub client_cursor: Option<u64>,
49+
}
50+
1651
/// A streamed (chunked) response body plus an abort signal.
1752
///
1853
/// `rx` carries the body frames. `failed` is set by the producer if it ends the
@@ -27,16 +62,6 @@ pub struct StreamBody {
2762
pub failed: Arc<AtomicBool>,
2863
}
2964

30-
impl StreamBody {
31-
/// A stream with no failure path (e.g. SSE, where the client reconnects).
32-
pub fn infallible(rx: mpsc::Receiver<Bytes>) -> Self {
33-
StreamBody {
34-
rx,
35-
failed: Arc::new(AtomicBool::new(false)),
36-
}
37-
}
38-
}
39-
4065
/// Maximum accepted request body size; larger bodies get `413 Payload Too
4166
/// Large`.
4267
pub const MAX_BODY_BYTES: usize = 1024 * 1024 * 1024; // 1 GiB safety cap
@@ -95,10 +120,14 @@ impl Req {
95120
pub enum Body {
96121
Empty,
97122
Full(Bytes),
98-
/// Streaming body (SSE / large dynamic responses); engine frames it
99-
/// (e.g. chunked transfer-encoding) and ends when the channel closes. The
100-
/// `failed` flag tells the engine to abort rather than terminate cleanly.
123+
/// Streaming body for large dynamic responses (e.g. cold-tier reads): a
124+
/// spawned producer feeds frames over a channel. The `failed` flag tells the
125+
/// engine to abort rather than terminate cleanly. For SSE fan-out, prefer
126+
/// `Body::Sse`, which avoids the per-response task + channel.
101127
Channel(StreamBody),
128+
/// Pull-based streaming body driven inline on the connection task (SSE).
129+
/// No spawned task, no channel — see `EventSource`.
130+
Sse(Box<dyn EventSource>),
102131
/// Byte ranges of data files plus optional framing (JSON `[` / `]`).
103132
/// Total body length is prefix + Σ segment.len + suffix.
104133
FileRange {
@@ -120,6 +149,7 @@ impl Body {
120149
Body::Empty => Some(0),
121150
Body::Full(b) => Some(b.len() as u64),
122151
Body::Channel(_) => None,
152+
Body::Sse(_) => None,
123153
Body::FileRange {
124154
segments,
125155
prefix,

packages/durable-streams-rust/src/engine_raw.rs

Lines changed: 70 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -174,13 +174,20 @@ pub async fn serve(store: Arc<Store>, listener: TcpListener) {
174174
};
175175
let store = store.clone();
176176
tokio::spawn(async move {
177-
let _permit = permit; // released when the connection ends
178-
let _ = conn_loop(store, stream).await;
177+
// The permit is moved INTO conn_loop so it can hand it on to a
178+
// detached SSE streaming task (keeping the connection counted while
179+
// the big conn_loop future is freed); otherwise it is released when
180+
// conn_loop returns.
181+
let _ = conn_loop(store, stream, permit).await;
179182
});
180183
}
181184
}
182185

183-
async fn conn_loop(store: Arc<Store>, mut stream: TcpStream) -> std::io::Result<()> {
186+
async fn conn_loop(
187+
store: Arc<Store>,
188+
mut stream: TcpStream,
189+
permit: tokio::sync::OwnedSemaphorePermit,
190+
) -> std::io::Result<()> {
184191
const BAD_REQUEST: &[u8] =
185192
b"HTTP/1.1 400 Bad Request\r\ncontent-length: 0\r\nconnection: close\r\n\r\n";
186193
const TOO_LARGE: &[u8] =
@@ -324,6 +331,49 @@ async fn conn_loop(store: Arc<Store>, mut stream: TcpStream) -> std::io::Result<
324331
body,
325332
};
326333
let resp = handlers::handle(store.clone(), req).await;
334+
// SSE: hand the connection off to a DETACHED, minimal streaming task and
335+
// return. An SSE subscriber parks for up to SSE_MAX_DURATION; driving it
336+
// inline would keep this whole `conn_loop` future (sized to the largest
337+
// request handler) resident the entire time — the dominant per-subscriber
338+
// cost. The hand-off task's future holds only what streaming needs (the
339+
// socket, the event source, a small frame buffer), so an idle subscriber's
340+
// resident footprint collapses to ~that, mirroring a per-subscriber cursor
341+
// rather than a parked request state machine. The permit moves with it so
342+
// the connection stays counted; the stream closes when it ends (the client
343+
// reconnects from its last offset — SSE is already capped + reconnecting).
344+
// SSE live-tail fast path (Linux): a reactor-eligible source hands its
345+
// socket to the epoll reactor and frees this connection task's future
346+
// entirely — the dominant per-subscriber cost. HEAD requests and cold
347+
// catch-up fall through to the inline hand-off below.
348+
#[cfg(target_os = "linux")]
349+
if !is_head {
350+
if let Body::Sse(ref src) = resp.body {
351+
if let Some(reg) = src.reactor_reg() {
352+
let mut head: Vec<u8> = Vec::with_capacity(256);
353+
http1::write_head(&mut head, resp.status, &resp.headers, None, keep_alive);
354+
crate::sse_reactor::register(stream, head, reg, permit);
355+
return Ok(());
356+
}
357+
}
358+
}
359+
if matches!(resp.body, Body::Sse(_)) {
360+
tokio::spawn(async move {
361+
let _permit = permit; // keep the connection counted until it ends
362+
// Pass the request's `keep_alive` (true for a normal SSE GET) so
363+
// the framing matches the inline path: `handle_sse` already sets
364+
// `Connection: keep-alive`, and `write_head` must not also emit a
365+
// `Connection: close`. The socket is closed when this task ends
366+
// (it never loops back), so the stream still terminates cleanly.
367+
let _ = write_response(&mut stream, resp, is_head, keep_alive).await;
368+
});
369+
return Ok(());
370+
}
371+
// A cold-tier `Body::Channel` stream also parks; release the 4 KiB read
372+
// buffer first so it isn't pinned for the response (it re-grows on the
373+
// next keep-alive request). Safe only with no pipelined bytes buffered.
374+
if buf.is_empty() && matches!(resp.body, Body::Channel(_)) {
375+
buf = BytesMut::new();
376+
}
327377
write_response(&mut stream, resp, is_head, keep_alive).await?;
328378
if !keep_alive {
329379
return Ok(());
@@ -374,6 +424,23 @@ async fn write_response(
374424
stream.write_all(suffix).await?;
375425
}
376426
}
427+
Body::Sse(mut src) => {
428+
// Pull-based SSE driven INLINE on this connection task — no spawned
429+
// producer task, no mpsc channel (those cost ~4–5 KiB of resident
430+
// heap per subscriber, the source of per-subscriber fan-out memory).
431+
stream.write_all(&head).await?;
432+
debug_assert!(body_len.is_none());
433+
// SSE events are small; `frame_chunk` grows this on demand.
434+
let mut frame: Vec<u8> = Vec::with_capacity(512);
435+
while let Some(b) = src.next_chunk().await {
436+
frame.clear();
437+
http1::frame_chunk(&mut frame, &b);
438+
stream.write_all(&frame).await?;
439+
}
440+
// SSE is infallible (the client reconnects from its last offset), so
441+
// always terminate cleanly.
442+
stream.write_all(b"0\r\n\r\n").await?;
443+
}
377444
Body::Channel(crate::api::StreamBody { mut rx, failed }) => {
378445
stream.write_all(&head).await?;
379446
debug_assert!(body_len.is_none());

0 commit comments

Comments
 (0)