New workspace crate: an open-loop, coordinated-omission-corrected HTTP load generator + capacity ramp for the standalone and multi-process cluster surfaces, modeling a thepeach feed session (feed reads + view/like/skip signals + search, signal-dominated per their user-graph spec). Throttleable target rate, ramp presets (smoke/quick/peach-100k/max) or rps:secs specs, peach/reads/writes/custom mixes, leader vs sharded write paths, per-op p50/p90/p99/p999/max latency, a backpressure-aware status breakdown (429/408/503/4xx/5xx/transport), and a verdict translated to supported DAU. Runs in-cluster as a k8s Job (tidal-stress/k8s/). Open-loop scheduler (scheduler.rs) fires at a fixed arrival rate and measures latency from each request's intended send time, so a server stall inflates the percentiles a closed-loop test hides; it shed-and-counts rather than blocking when the in-flight cap is reached. Pure-Rust (tokio + reqwest/rustls), no engine deps. Findings on the live 3-region k3s cluster (docs/ops/stress-test-thepeach.md): reads scale to thousands/s at <15ms p99; the replicated /signals path saturates at ~90 signals/s (single-leader funnel + 2-worker write pool + synchronous gRPC ship); the sharded path sustains 3,669 signals/s at 0 errors and ~27% cluster CPU (≈ the 100k-DAU peak, knee not reached). Overload degrades gracefully (429; 0 pod restarts). thepeach's planned in-process embedding sidesteps all of it (write ≈82ns).
7.5 KiB
Capacity stress test — thepeach feed workload (100k DAU)
Run with the tidal-stress crate (an open-loop, coordinated-omission-corrected
load generator) against the live 3-region multi-process cluster on k3s
(tidaldb namespace; 3 pods, one region each, cpu limit "2" per pod). Date:
2026-06-10.
TL;DR. Reads scale trivially (feed p99 <15ms at thousands of rps). The replicated write path (
/signals→ single leader) saturates at ~90 signals/s — far short of a 100k-DAU peak — because every write funnels to one leader, gated by a ~2-worker write pool, each write blocking on WAL fsync + a synchronous cross-region gRPC ship. The sharded write path (/sharded/signals, hash-distributed across all 3 regions) cleanly sustains 3,669 signals/s at 0 errors and only ~27% cluster CPU — that is essentially the 100k-DAU evening peak, with headroom. Overload degrades gracefully (HTTP 429 backpressure; zero pod restarts, replication stays caught up). For thepeach's actual plan — embedding the engine in-process — 100k DAU is not even a question (in-process signal write ≈ 82ns ⇒ ~12M signals/s/core).
The workload
thepeach is a character.ai-style AI-companion feed app; its tidaldb integration
is planned (experiment E2 / roadmap R8: feed ranking via named tidaldb
profiles). The test models that future hot path — a feed session — mapped onto
the deployed schema (signals view/like/skip, 128-dim content_vector):
- A session = home feed (24 tiles/page, infinite scroll) →
view/like/skipon tiles → occasional search → repeat. Per thepeach's user-graph spec, signals outnumber feed reads by 1–2 orders of magnitude ("10–1000+ events/session"). - Default mix (
peach): ~78%view, ~10%skip, ~4%like, ~6.5% feed reads, ~1.6% search, a trickle of item/embedding registration. Hot-content power-law entity selection (a few viral items get most signals). - 100k-DAU model: ~5 sessions/user/day × ~5 feed pages × ~15 signal-equivalents ⇒ ~780 signals/s average, ~3,900 signals/s at a 5× evening peak; feed reads ~1/20th of that.
Methodology
tidal-stress is open-loop (constant arrival rate, not N-workers-in-a-loop),
so a server stall inflates the latency percentiles a closed-loop test would hide
(coordinated-omission correction: latency measured from each request's intended
send time). It ramps a throttleable rate low→high, reports per-op p50/p90/p99/p999
- max latency and a backpressure-aware status breakdown (429 = write
backpressure, 408 = concurrency-queue timeout, 503 = leader/region down), and
prints a capacity verdict translated to supported DAU. Run in-cluster as a k8s
Job (a
kubectl port-forwardserializes everything through the API server and adds tens of ms — useless for capacity numbers). Seetidal-stress/k8s/stress-job.yaml.
Results
Reads — never the bottleneck
/feed p50 ~3ms, p99 <15ms; /search p50 ~2ms — sustained even at 4,000 rps
total. tidalDB's own RETRIEVE SLA (p99 <50ms) holds comfortably over the cluster
network hop.
Replicated write path (/signals → leader) — ~90 signals/s
view ok/s was pinned at 76, 76, 74, 71 across offered rates of 100→4,000
rps — that flat line is the ceiling (~90 signals/s incl. like/skip). Everything
above it shed as HTTP 429 (honor-retry backpressure). No 5xx, no timeouts,
reads unaffected, replication lag stayed 0–3.
| offered (total rps) | signal ok/s | errors | feed p99 |
|---|---|---|---|
| 100 | ~90 | 1.6% (429) | 8ms |
| 500 | ~90 | 73% (429) | 5ms |
| 1,500 | ~90 | 86% (429) | 8ms |
| 4,000 | ~90 | 89% (429) | 9ms |
Bottleneck: a single leader (us-east) + a write pool of available_parallelism
clamped to [2,8] (≈2 on a cpu-"2" pod), each write blocking on WAL fsync and
a synchronous gRPC ship to both siblings (~178ms/write). ⇒ ~2k DAU at peak,
~11k DAU at average. Not enough for 100k DAU on this path.
Sharded write path (/sharded/signals) — 3,669 signals/s, 0 errors
Hash-distributes writes across all 3 regions (applied to the owning region's
local store; not replicated — reads use /sharded/* scatter-gather to merge
across shards). One generator pod:
| offered (total rps) | signal ok/s | errors | view p99 | cluster cpu/region |
|---|---|---|---|---|
| 100 | 92 | 0% | 21ms | idle |
| 500 | 458 | 0% | 20ms | ~140m |
| 1,500 | 1,378 | 0% | 22ms | ~300m |
| 4,000 | 3,669 | 0% | 31ms | ~540m (27%) |
The knee was not reached — the cluster was only ~27% utilized at 3,669 signals/s ⇒ ~94k DAU at peak, ~470k DAU at average. ≈ handles the 100k-DAU peak with headroom.
Pushing past one generator — a connection wall, not the engine
A single generator pod tops out ~4–5k rps (its own CPU/connection limit). A 3-pod parallel fleet (aggregate >5k rps) collapsed with 40s connection timeouts + 503s while the cluster CPU stayed idle — successful writes were still fast (p50 ~12ms) but new connections hung. This is a connection- establishment wall (k8s ClusterIP/conntrack/accept-queue under connection churn from many pods), hit before the engine's compute ceiling. The database engine was the bottleneck for neither the clean run nor the collapse.
Verdict — can we handle 100k DAU? more?
| Deployment mode | 100k DAU? | Ceiling found | Headroom |
|---|---|---|---|
Embedded engine (thepeach's plan: Arc<TidalDb>, db.signal()) |
Trivially yes | in-process write ≈82ns ⇒ ~12M signals/s/core | RAM-bound, not CPU (capacity-planning.md) |
| HTTP cluster, sharded writes | Yes | ≥3,669 signals/s @ 27% cpu (knee not reached) | ~2–3× compute; connection wall ~5k aggregate rps |
HTTP cluster, replicated /signals |
No | ~90 signals/s (single leader) | needs sharding or more leader cpu |
Can we handle more? On the sharded/embedded paths, yes — substantially. The replicated single-leader path does not scale writes by design.
Recommendations
- thepeach's planned in-process embedding sidesteps all of this — no HTTP,
no leader funnel, no connection layer; the engine does millions of signals/s
per core. At 100k–10M DAU the constraint is RAM for the item/embedding
index (capacity-planning.md), not signal write throughput. thepeach's real
1536-dim embeddings cost ~2.3× the RAM/item vs the deployed 128-dim
(
~470MBvs~200MBper 100k items). - If using the HTTP cluster for the signal firehose, use
/sharded/*(or raise the leader pod's CPU to lift the write pool toward its 8-worker clamp — ~4× the replicated ceiling to ~360/s, still far short of peak; the single- leader replicated model fundamentally doesn't scale writes). - Graceful under overload: the cluster sheds excess writes as 429 and never fell over (0 pod restarts across every run, replication stayed caught up) — a client that honors the ~50ms retry hint degrades cleanly.
- To push past ~5k aggregate HTTP rps: connection discipline (keep-alive reuse, bounded pools), more region replicas, or front the regions with an ingress/mesh that tolerates connection churn — the next wall is networking, not the database.
Reproduce
# Build + push the image (cross-compile path), then:
kubectl apply -f tidal-stress/k8s/stress-job.yaml # leader path, peach-100k ramp
kubectl logs -f job/tidal-stress -n tidaldb
# Sharded comparison: set --write-path sharded in the args.
See tidal-stress/ for the generator (open-loop scheduler in scheduler.rs,
the thepeach workload model in workload.rs).