tidaldb/docs/ops/stress-test-thepeach.md
jx12n f640764d89 feat(tidal-stress): open-loop capacity load generator (thepeach feed workload)
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).
2026-06-10 21:54:21 -06:00

7.5 KiB
Raw Permalink Blame History

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/skip on tiles → occasional search → repeat. Per thepeach's user-graph spec, signals outnumber feed reads by 12 orders of magnitude ("101000+ 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-forward serializes everything through the API server and adds tens of ms — useless for capacity numbers). See tidal-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 03.

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 ~45k 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) ~23× 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

  1. 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 100k10M 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 (~470MB vs ~200MB per 100k items).
  2. 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).
  3. 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.
  4. 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).