Skip to content

Commit 9acd04f

Browse files
balegasclaude
andauthored
perf(durable-streams-rust): batch memory-mode meta sidecar flushes into a periodic sweep (#4692)
Fixes #4691. ## Problem In `--durability memory`, every append fell back to `StreamState::schedule_meta_flush`: a per-stream `tokio::spawn` + 100 ms sleep + `spawn_blocking` running a full `.meta` sidecar rewrite (JSON serialize + `File::create` + `rename`, plus parent-dir inode contention). Whenever the per-stream inter-append gap is ≥ the 100 ms debounce (any rate ≤ ~10 ops/s/stream — the common case at high stream cardinality), every stream pays a timer task plus a full sidecar rewrite every 100 ms. Wal mode stopped paying this in #4675 (the checkpoint batches sidecar writes), so memory mode — supposedly the *cheaper* path — burned ~5x wal's CPU at identical load. ## Fix Give memory mode the same batched treatment (`packages/durable-streams-rust`): - `Store::mark_meta_dirty(st)` — appends (memory branch) and TTL read touches now only CAS `meta_dirty` and, on the false→true edge, push the stream into a store-level `meta_sweep` set (deduped: at most one entry per stream per cycle). No timer task, no per-append write. - `Store::sweep_meta_once()` — drains the set and writes every still-dirty sidecar in **one** `spawn_blocking` pass. It skips hard-deleted streams (`Arc` identity check against the streams map), so a pending flush can no longer resurrect an unlinked sidecar — a race the old debounce had. - `spawn_meta_sweeper` (main.rs) — a single 1 s ticker drives the sweep in both durability modes (wal appends still flush via the checkpoint; only TTL read touches use the sweeper there). - `StreamState::schedule_meta_flush` is deleted. Durable flush paths (close/delete/shutdown `write_meta_sync(st, true)` call sites) are unchanged. The sidecar's producer/access state is documented as a non-durable, lagging flush; the lag bound moves from 100 ms to the 1 s sweep cadence — exactly the trade wal mode made in #4675. ## Validation (local ds-bench, kind, server capped at 2 CPU) ds-bench `sustained` suite (10 append/s per stream, 256 B payloads, 90 s window, 1 fleet pod), baseline image = main @ 640509c, fix image = this branch: | streams | config | cpu_mean before | cpu_mean after | p99 before (ms) | p99 after (ms) | |---|---|---|---|---|---| | 10 | memory | 4.3 | **1.1** | 3.363 | 2.627 | | 100 | memory | 31.3 | **5.6** | 9.455 | 6.191 | | 150 | memory | 50.7 | **8.1** | 11.639 | 7.007 | | 10 | wal | 2.6 | 2.2 | 16.607 | 8.455 | | 100 | wal | 10.2 | 8.5 | 18.799 | 15.087 | | 150 | wal | 12.6 | 11.1 | 23.951 | 13.087 | Memory-mode CPU drops ~4–6x at fixed load and lands **below** wal at every stream count — the issue's expected outcome (memory does strictly less work per append). Throughput held at the offered rate in every cell (`stable=True`, zero errors); wal cells are unchanged within noise (its append path doesn't touch this code — only TTL read touches moved to the sweeper). bench-latency (in-repo host harness, `--quick`, after the fix): memory-mode write ack mean 0.29–0.51 ms across 1–64 KiB payloads — within the ~0.2–0.5 ms band the issue expects; wal unchanged (2.5–3.3 ms, macOS F_FULLFSYNC dominated). ## Tests - `store::meta_sweep_tests::mark_dedupes_and_sweep_flushes` — CAS dedupe, sweep persists pending producer state, clears the flag, second sweep is a no-op. - `store::meta_sweep_tests::sweep_skips_hard_deleted_stream` — a sweep after a hard delete does not resurrect the sidecar. - `handlers::memory_mode_tests::memory_append_defers_sidecar_to_store_sweep` — a memory-mode append no longer flushes the sidecar within the old 100 ms debounce window; the batched sweep is what persists it. - Full `cargo test` (105) and the protocol conformance suite (326 tests) pass; clippy clean. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01SmhCex16r9JUPxHunf7Roy --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 3ef7614 commit 9acd04f

5 files changed

Lines changed: 248 additions & 16 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+
Memory-mode CPU fix: batch meta sidecar flushes into a periodic sweep (#4691).
6+
7+
`--durability memory` appends no longer schedule a per-stream debounced sidecar flush (a timer task + full sidecar rewrite per stream per 100 ms — ~5x wal-mode CPU at high stream cardinality under low per-stream rates). Appends and TTL read touches now only mark the stream dirty in a store-level set; a single 1 s sweeper flushes all dirty sidecars in one pass, mirroring the batched checkpoint treatment wal mode got in the write-path overhaul. The sidecar's producer/access state remains a non-durable lagging flush; its lag bound moves from 100 ms to the 1 s sweep cadence. Durable flush-on-close/delete paths are unchanged, and a pending flush can no longer resurrect the sidecar of a hard-deleted stream.

.github/workflows/auto-approve-durable-streams.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ jobs:
2424
run: |
2525
scopes=(
2626
"packages/durable-streams-rust/"
27+
".changeset/"
2728
)
2829
2930
in_scope=true

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

Lines changed: 61 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1167,9 +1167,11 @@ async fn handle_append_inner(store: Arc<Store>, req: Req, path: String) -> (Resp
11671167
// bound moves from the 100 ms debounce to the checkpoint cadence.
11681168
st.meta_dirty.store(true, std::sync::atomic::Ordering::Release);
11691169
} else {
1170-
// No WAL record staged (memory durability): no checkpoint will flush the
1171-
// sidecar — keep the debounced flush.
1172-
st.schedule_meta_flush();
1170+
// No WAL record staged (memory durability): no checkpoint will flush
1171+
// the sidecar — queue it for the store-level periodic sweeper. Same
1172+
// batched treatment the wal branch above gets from the checkpoint: no
1173+
// per-stream timer task, no per-append sidecar rewrite (#4691).
1174+
store.mark_meta_dirty(&st);
11731175
}
11741176
if !wire.is_empty() {
11751177
maybe_seal_bg(&store, &st);
@@ -1416,7 +1418,7 @@ async fn handle_read(store: Arc<Store>, req: Req, path: String) -> Resp {
14161418
// non-TTL streams to keep their read path lock-free.
14171419
if st.config.ttl_seconds.is_some() {
14181420
st.touch();
1419-
st.schedule_meta_flush(); // sliding TTL must survive restarts
1421+
store.mark_meta_dirty(&st); // sliding TTL must survive restarts
14201422
}
14211423
let q = match parse_query(req.query.as_deref()) {
14221424
Ok(q) => q,
@@ -2241,5 +2243,60 @@ mod memory_mode_tests {
22412243

22422244
let _ = std::fs::remove_dir_all(&dir);
22432245
}
2246+
2247+
/// #4691: a memory-mode append must NOT flush the meta sidecar via a
2248+
/// per-stream debounced timer (100 ms sleep + spawn_blocking per stream —
2249+
/// ~5x wal-mode CPU at high stream cardinality). It only marks the stream
2250+
/// dirty; the store-level periodic sweeper writes the sidecar in batch,
2251+
/// mirroring wal mode's checkpoint treatment from #4675.
2252+
#[tokio::test]
2253+
async fn memory_append_defers_sidecar_to_store_sweep() {
2254+
let _guard = crate::handlers::test_support::DurabilityGuard::memory();
2255+
let dir = tmp("mem-sweep");
2256+
let store = Arc::new(
2257+
Store::new_with_tier(dir.clone(), TierConfig::default()).unwrap(),
2258+
);
2259+
2260+
let resp = handle(Arc::clone(&store), put_req("m/s", "application/octet-stream")).await;
2261+
assert!((200..300).contains(&resp.status), "create: {}", resp.status);
2262+
2263+
// Append with a producer so the pending sidecar change is observable.
2264+
let mut req = post_req("m/s", "application/octet-stream", b"payload");
2265+
req.headers.push(("producer-id".into(), "p1".into()));
2266+
req.headers.push(("producer-epoch".into(), "1".into()));
2267+
req.headers.push(("producer-seq".into(), "0".into()));
2268+
let resp = handle(Arc::clone(&store), req).await;
2269+
assert!((200..300).contains(&resp.status), "append: {}", resp.status);
2270+
2271+
// Well past the old 100 ms debounce: the sidecar must still be
2272+
// unwritten — no per-append timer task may exist anymore.
2273+
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
2274+
let st = store.get("m/s").unwrap();
2275+
let meta: crate::store::Meta =
2276+
serde_json::from_slice(&std::fs::read(crate::store::meta_path(&st.file_path)).unwrap())
2277+
.unwrap();
2278+
assert!(
2279+
!meta.producers.contains_key("p1"),
2280+
"sidecar was flushed per-append (debounce timer still active)"
2281+
);
2282+
2283+
// The batched store sweep is what persists it.
2284+
let flushed = tokio::task::spawn_blocking({
2285+
let store = Arc::clone(&store);
2286+
move || store.sweep_meta_once()
2287+
})
2288+
.await
2289+
.unwrap();
2290+
assert_eq!(flushed, 1, "the appended stream is swept");
2291+
let meta: crate::store::Meta =
2292+
serde_json::from_slice(&std::fs::read(crate::store::meta_path(&st.file_path)).unwrap())
2293+
.unwrap();
2294+
assert!(
2295+
meta.producers.contains_key("p1"),
2296+
"sweep must persist the pending producer state"
2297+
);
2298+
2299+
let _ = std::fs::remove_dir_all(&dir);
2300+
}
22442301
}
22452302

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

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,12 @@ fn main() {
296296
let store = Arc::new(
297297
Store::new_with_tier(data_dir.clone(), tier.clone()).expect("failed to init store"),
298298
);
299+
// Batched meta-sidecar sweeper (#4691): flushes every stream queued by
300+
// `Store::mark_meta_dirty` (memory-mode appends, TTL read touches) in
301+
// one pass per tick, replacing the per-stream 100 ms debounce timer.
302+
// Spawned in BOTH durability modes — wal mode still queues TTL read
303+
// touches here (its append path flushes via the checkpoint instead).
304+
spawn_meta_sweeper(Arc::clone(&store));
299305

300306
// ---- WAL wiring (Wal mode only) ----
301307
//
@@ -442,6 +448,33 @@ fn spawn_checkpoint_ticker(walset: Arc<wal::walset::WalSet>) {
442448
});
443449
}
444450

451+
/// How often the meta sweeper flushes dirty sidecars (#4691). The sidecar's
452+
/// producer/access state is a non-durable, lagging flush by contract; 1 s keeps
453+
/// the lag tighter than the wal checkpoint's 3 s cadence while still batching
454+
/// away the per-stream timer + per-append rewrite the 100 ms debounce cost.
455+
const META_SWEEP_INTERVAL: std::time::Duration = std::time::Duration::from_secs(1);
456+
457+
/// Spawn the store-level meta-sidecar sweeper: each tick drains the
458+
/// `mark_meta_dirty` queue and writes every still-dirty stream's sidecar in one
459+
/// `spawn_blocking` task (vs one timer task + one blocking task PER STREAM per
460+
/// 100 ms under the old debounce — the ~5x memory-mode CPU overhead of #4691).
461+
fn spawn_meta_sweeper(store: Arc<Store>) {
462+
tokio::spawn(async move {
463+
let mut ticker = tokio::time::interval(META_SWEEP_INTERVAL);
464+
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
465+
// Skip the immediate first tick — nothing can be dirty at boot.
466+
ticker.tick().await;
467+
loop {
468+
ticker.tick().await;
469+
if store.meta_sweep.lock().unwrap().is_empty() {
470+
continue;
471+
}
472+
let s = Arc::clone(&store);
473+
let _ = tokio::task::spawn_blocking(move || s.sweep_meta_once()).await;
474+
}
475+
});
476+
}
477+
445478
/// Resolve on SIGINT (Ctrl-C) or SIGTERM (systemd/Kubernetes stop). On non-Unix,
446479
/// only Ctrl-C.
447480
async fn shutdown_signal() {

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

Lines changed: 146 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -382,6 +382,11 @@ pub struct Store {
382382
/// the `WalSet`, runs WAL recovery, and `set`s it here — all before serving.
383383
/// The hot-path read (`store.wal.get()`) is lock-free.
384384
pub wal: std::sync::OnceLock<Arc<crate::wal::walset::WalSet>>,
385+
/// Streams with a pending non-durable sidecar flush (memory-mode appends,
386+
/// TTL read touches), drained in batch by the periodic meta sweeper
387+
/// (`sweep_meta_once`). The `meta_dirty` CAS in `mark_meta_dirty` keeps
388+
/// each stream in here at most once per sweep cycle (#4691).
389+
pub meta_sweep: StdMutex<Vec<Arc<StreamState>>>,
385390
}
386391

387392
pub enum CreateResult {
@@ -421,6 +426,7 @@ impl Store {
421426
tier_config,
422427
blobstore,
423428
wal: std::sync::OnceLock::new(),
429+
meta_sweep: StdMutex::new(Vec::new()),
424430
};
425431
store.recover(&streams_dir)?;
426432
Ok(store)
@@ -1147,22 +1153,53 @@ pub(crate) fn fsync_parent_dir(path: &std::path::Path) -> std::io::Result<()> {
11471153
}
11481154
}
11491155

1150-
impl StreamState {
1151-
/// Schedule a debounced, non-durable meta flush (producer/access updates).
1152-
pub fn schedule_meta_flush(self: &Arc<Self>) {
1153-
if self
1156+
impl Store {
1157+
/// Queue a non-durable meta sidecar flush (producer/access updates) for the
1158+
/// next periodic sweep (#4691). Replaces the per-stream 100 ms debounce
1159+
/// timer, which cost a tokio timer task + `spawn_blocking` + full sidecar
1160+
/// rewrite per stream per 100 ms — at high stream cardinality ~5x wal-mode
1161+
/// CPU for the same load. The lag bound moves from the 100 ms debounce to
1162+
/// the sweep cadence, exactly the trade wal mode made in #4675.
1163+
///
1164+
/// The `meta_dirty` CAS dedupes: while a flush is pending the stream sits
1165+
/// in `meta_sweep` at most once. The wal-mode append path never calls this
1166+
/// — it stores `meta_dirty` directly and the shard checkpoint flushes the
1167+
/// sidecar (see `handle_append_inner`); if that path already set the flag,
1168+
/// the checkpoint owns the flush and the CAS failing here avoids a
1169+
/// duplicate write.
1170+
pub fn mark_meta_dirty(&self, st: &Arc<StreamState>) {
1171+
if st
11541172
.meta_dirty
11551173
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
1156-
.is_err()
1174+
.is_ok()
11571175
{
1158-
return; // flush already scheduled
1176+
self.meta_sweep.lock().unwrap().push(Arc::clone(st));
11591177
}
1160-
let st = self.clone();
1161-
tokio::spawn(async move {
1162-
tokio::time::sleep(Duration::from_millis(100)).await;
1163-
st.meta_dirty.store(false, Ordering::Release);
1164-
let _ = tokio::task::spawn_blocking(move || write_meta_sync(&st, false)).await;
1165-
});
1178+
}
1179+
1180+
/// Drain the pending sweep set and write each still-dirty stream's sidecar
1181+
/// (non-durable), returning how many were written. Blocking file I/O —
1182+
/// call from a blocking context. Errors are ignored exactly like the
1183+
/// debounced flush ignored them (the sidecar is non-durable by contract).
1184+
pub fn sweep_meta_once(&self) -> usize {
1185+
let drained: Vec<Arc<StreamState>> =
1186+
std::mem::take(&mut *self.meta_sweep.lock().unwrap());
1187+
let mut n = 0;
1188+
for st in drained {
1189+
// A hard-deleted stream's files are already unlinked — flushing
1190+
// would resurrect its sidecar. Same `Arc` identity check as
1191+
// delete's `remove_if`. (Soft-deleted streams stay in the map and
1192+
// must flush: the sidecar records the `soft_deleted` flag.)
1193+
let live = self
1194+
.streams
1195+
.get(&st.path)
1196+
.is_some_and(|cur| Arc::ptr_eq(cur.value(), &st));
1197+
if live && st.meta_dirty.swap(false, Ordering::AcqRel) {
1198+
let _ = write_meta_sync(&st, false);
1199+
n += 1;
1200+
}
1201+
}
1202+
n
11661203
}
11671204
}
11681205

@@ -2177,3 +2214,100 @@ mod tier_tests {
21772214
let _ = std::fs::remove_dir_all(&dir);
21782215
}
21792216
}
2217+
2218+
// ---------------- batched meta sweep tests (#4691) ----------------
2219+
2220+
#[cfg(test)]
2221+
mod meta_sweep_tests {
2222+
use super::*;
2223+
use crate::tier::TierConfig;
2224+
2225+
fn tmp_dir(tag: &str) -> PathBuf {
2226+
let p = std::env::temp_dir().join(format!(
2227+
"ds-meta-sweep-{tag}-{}-{}",
2228+
std::process::id(),
2229+
SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos()
2230+
));
2231+
let _ = std::fs::remove_dir_all(&p);
2232+
p
2233+
}
2234+
2235+
fn octet_cfg() -> StreamConfig {
2236+
StreamConfig {
2237+
content_type: "application/octet-stream".into(),
2238+
ttl_seconds: None,
2239+
expires_at: None,
2240+
expires_at_raw: None,
2241+
create_closed: false,
2242+
forked_from: None,
2243+
fork_offset_raw: None,
2244+
fork_sub_offset: None,
2245+
}
2246+
}
2247+
2248+
fn create(store: &Store, path: &str) -> Arc<StreamState> {
2249+
match store.create(path, octet_cfg(), None, 0).unwrap() {
2250+
CreateResult::Created(s) => s,
2251+
_ => panic!("create failed"),
2252+
}
2253+
}
2254+
2255+
fn disk_meta(st: &StreamState) -> Meta {
2256+
serde_json::from_slice(&std::fs::read(meta_path(&st.file_path)).unwrap()).unwrap()
2257+
}
2258+
2259+
/// Marking is idempotent while a flush is pending (one sweep entry per
2260+
/// stream per cycle), the sweep persists the pending state, and a clean
2261+
/// sweep is a no-op.
2262+
#[tokio::test]
2263+
async fn mark_dedupes_and_sweep_flushes() {
2264+
let dir = tmp_dir("flush");
2265+
let store = Store::new_with_tier(dir.clone(), TierConfig::default()).unwrap();
2266+
let st = create(&store, "s");
2267+
2268+
st.shared
2269+
.write()
2270+
.unwrap()
2271+
.producers
2272+
.insert("p1".into(), ProducerState { epoch: 1, last_seq: 3 });
2273+
store.mark_meta_dirty(&st);
2274+
store.mark_meta_dirty(&st); // second mark while pending: deduped
2275+
2276+
assert!(
2277+
!disk_meta(&st).producers.contains_key("p1"),
2278+
"marking alone must not write the sidecar"
2279+
);
2280+
assert_eq!(store.sweep_meta_once(), 1, "one dirty stream, one flush");
2281+
let meta = disk_meta(&st);
2282+
let p = meta.producers.get("p1").expect("sweep persists the pending producer state");
2283+
assert_eq!((p.epoch, p.last_seq), (1, 3));
2284+
assert!(
2285+
!st.meta_dirty.load(Ordering::Acquire),
2286+
"sweep clears the dirty flag"
2287+
);
2288+
assert_eq!(store.sweep_meta_once(), 0, "nothing left to sweep");
2289+
2290+
let _ = std::fs::remove_dir_all(&dir);
2291+
}
2292+
2293+
/// A stream hard-deleted after being marked dirty must NOT get its sidecar
2294+
/// resurrected by a later sweep (the file unlinks already happened).
2295+
#[tokio::test]
2296+
async fn sweep_skips_hard_deleted_stream() {
2297+
let dir = tmp_dir("deleted");
2298+
let store = Store::new_with_tier(dir.clone(), TierConfig::default()).unwrap();
2299+
let st = create(&store, "s");
2300+
2301+
store.mark_meta_dirty(&st);
2302+
store.delete_or_soft_delete_durable(&st).unwrap();
2303+
assert!(!meta_path(&st.file_path).exists(), "hard delete unlinked the sidecar");
2304+
2305+
assert_eq!(store.sweep_meta_once(), 0, "deleted stream is skipped");
2306+
assert!(
2307+
!meta_path(&st.file_path).exists(),
2308+
"sweep must not resurrect a deleted stream's sidecar"
2309+
);
2310+
2311+
let _ = std::fs::remove_dir_all(&dir);
2312+
}
2313+
}

0 commit comments

Comments
 (0)