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).
This commit is contained in:
parent
6f17409f40
commit
f640764d89
@ -13,6 +13,7 @@
|
|||||||
!tidal-net
|
!tidal-net
|
||||||
!tidal-server
|
!tidal-server
|
||||||
!tidalctl
|
!tidalctl
|
||||||
|
!tidal-stress
|
||||||
!applications
|
!applications
|
||||||
|
|
||||||
# Re-exclude heavy / generated / secret paths nested inside the allowed dirs.
|
# Re-exclude heavy / generated / secret paths nested inside the allowed dirs.
|
||||||
|
|||||||
@ -96,13 +96,13 @@ Dev servers use port range **59520–59529** (e.g. `site/` on 59520).
|
|||||||
## Repository Structure
|
## Repository Structure
|
||||||
|
|
||||||
This repository is a standalone Cargo workspace (members: `tidal`, `tidal-net`, `tidalctl`,
|
This repository is a standalone Cargo workspace (members: `tidal`, `tidal-net`, `tidalctl`,
|
||||||
`tidal-server`, and the `applications/` consumers). **Documentation has exactly two homes:**
|
`tidal-server`, `tidal-stress`, and the `applications/` consumers). **Documentation has exactly two homes:**
|
||||||
the top-level `*.md` files and `docs/`. Do not create per-crate doc mirrors (e.g. `tidal/docs/`,
|
the top-level `*.md` files and `docs/`. Do not create per-crate doc mirrors (e.g. `tidal/docs/`,
|
||||||
`tidal/ai-lookup/`, `tidal/site/`) — those were a stale duplicate and were consolidated away.
|
`tidal/ai-lookup/`, `tidal/site/`) — those were a stale duplicate and were consolidated away.
|
||||||
|
|
||||||
```
|
```
|
||||||
. # Workspace root — canonical docs + config
|
. # Workspace root — canonical docs + config
|
||||||
├── Cargo.toml # Workspace manifest (8 members)
|
├── Cargo.toml # Workspace manifest (9 members)
|
||||||
├── CLAUDE.md AGENTS.md README.md CONTRIBUTING.md CHANGELOG.md
|
├── CLAUDE.md AGENTS.md README.md CONTRIBUTING.md CHANGELOG.md
|
||||||
├── VISION.md USE_CASES.md SEQUENCE.md ARCHITECTURE.md
|
├── VISION.md USE_CASES.md SEQUENCE.md ARCHITECTURE.md
|
||||||
├── API.md QUICKSTART.md CODING_GUIDELINES.md thoughts.md
|
├── API.md QUICKSTART.md CODING_GUIDELINES.md thoughts.md
|
||||||
@ -124,6 +124,7 @@ the top-level `*.md` files and `docs/`. Do not create per-crate doc mirrors (e.g
|
|||||||
├── tidal-net/ # Network transport primitives (gRPC, WAL shipping)
|
├── tidal-net/ # Network transport primitives (gRPC, WAL shipping)
|
||||||
├── tidal-server/ # Standalone Axum HTTP server (standalone + cluster modes)
|
├── tidal-server/ # Standalone Axum HTTP server (standalone + cluster modes)
|
||||||
├── tidalctl/ # CLI for inspecting persisted databases
|
├── tidalctl/ # CLI for inspecting persisted databases
|
||||||
|
├── tidal-stress/ # Open-loop HTTP load generator + capacity ramp (thepeach feed workload)
|
||||||
├── applications/ # Example consumers (forage, iknowyou)
|
├── applications/ # Example consumers (forage, iknowyou)
|
||||||
└── site/ # Public marketing site (Next.js, dev on 59520)
|
└── site/ # Public marketing site (Next.js, dev on 59520)
|
||||||
```
|
```
|
||||||
|
|||||||
15
Cargo.lock
generated
15
Cargo.lock
generated
@ -3605,6 +3605,21 @@ dependencies = [
|
|||||||
"utoipa",
|
"utoipa",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tidal-stress"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"clap",
|
||||||
|
"rand 0.9.2",
|
||||||
|
"reqwest",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"tokio",
|
||||||
|
"tracing",
|
||||||
|
"tracing-subscriber",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tidalctl"
|
name = "tidalctl"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|||||||
@ -4,6 +4,7 @@ members = [
|
|||||||
"tidal-net",
|
"tidal-net",
|
||||||
"tidalctl",
|
"tidalctl",
|
||||||
"tidal-server",
|
"tidal-server",
|
||||||
|
"tidal-stress",
|
||||||
"applications/forage/engine",
|
"applications/forage/engine",
|
||||||
"applications/forage/server",
|
"applications/forage/server",
|
||||||
"applications/forage/embedder",
|
"applications/forage/embedder",
|
||||||
|
|||||||
37
docker/stress/Dockerfile
Normal file
37
docker/stress/Dockerfile
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
# tidal-stress load generator image.
|
||||||
|
#
|
||||||
|
# Build context is this repository's root (the workspace Cargo.toml / Cargo.lock
|
||||||
|
# and every member manifest live there). Build from the repo root:
|
||||||
|
#
|
||||||
|
# docker build -f docker/stress/Dockerfile -t tidaldb:stress .
|
||||||
|
#
|
||||||
|
# tidal-stress is a pure-Rust HTTP client (tokio + reqwest/rustls): it depends on
|
||||||
|
# NONE of the engine crates, so this build never compiles tidal/tidal-net/usearch
|
||||||
|
# and needs no protoc/g++. Pin the builder to bookworm so its glibc matches the
|
||||||
|
# runtime; trixie's vectorized-math libmvec is handled by the trixie runtime below.
|
||||||
|
FROM rust:1.91-bookworm AS builder
|
||||||
|
ARG DEBIAN_FRONTEND=noninteractive
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy the full (already-pruned by .dockerignore) workspace and build only the
|
||||||
|
# load generator. The unified workspace requires every member manifest present to
|
||||||
|
# resolve metadata, so copy the whole context rather than a fragile subset.
|
||||||
|
COPY . .
|
||||||
|
RUN cargo build -p tidal-stress --release --locked
|
||||||
|
|
||||||
|
FROM debian:trixie-slim
|
||||||
|
ARG DEBIAN_FRONTEND=noninteractive
|
||||||
|
WORKDIR /srv
|
||||||
|
|
||||||
|
# ca-certificates only matters if a target is https:// (in-cluster targets are
|
||||||
|
# plaintext http://); kept for completeness. No libstdc++ (no C++ in this crate).
|
||||||
|
RUN useradd --system --home /srv stress && \
|
||||||
|
apt-get update && apt-get install -y --no-install-recommends ca-certificates && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY --from=builder /app/target/release/tidal-stress /usr/local/bin/tidal-stress
|
||||||
|
|
||||||
|
USER stress
|
||||||
|
|
||||||
|
# ENTRYPOINT is the bare binary so the Job supplies the full ramp/mix/target argv.
|
||||||
|
ENTRYPOINT ["tidal-stress"]
|
||||||
140
docs/ops/stress-test-thepeach.md
Normal file
140
docs/ops/stress-test-thepeach.md
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
# 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 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-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 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
|
||||||
|
|
||||||
|
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 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
|
||||||
|
(`~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
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 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`).
|
||||||
56
tidal-stress/Cargo.toml
Normal file
56
tidal-stress/Cargo.toml
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
[package]
|
||||||
|
name = "tidal-stress"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
description = "Open-loop load generator + capacity ramp for tidalDB's HTTP surface (standalone + multi-process cluster), modeling a thepeach-style feed workload"
|
||||||
|
license.workspace = true
|
||||||
|
|
||||||
|
# ── tidal-crate lint posture (single source of truth) ──────────────────────
|
||||||
|
# IDENTICAL block across tidaldb / tidal-net / tidal-server / tidalctl. These
|
||||||
|
# crates deliberately DO NOT inherit `[workspace.lints]`; they hold the embedded
|
||||||
|
# recommendation DB + its transport/server/CLI to a stricter correctness bar
|
||||||
|
# (`unsafe_code = forbid`, `clippy::all = deny`, `unwrap_used = deny`).
|
||||||
|
# `unwrap_used = "deny"` is kept per-crate rather than in `[workspace.lints]`
|
||||||
|
# because the workspace also hosts the example/consumer crates under
|
||||||
|
# `applications/` (not held to the engine's bar). Keep these four blocks BYTE-IDENTICAL.
|
||||||
|
[lints.rust]
|
||||||
|
unsafe_code = "forbid"
|
||||||
|
|
||||||
|
[lints.clippy]
|
||||||
|
all = { level = "deny", priority = -1 }
|
||||||
|
pedantic = { level = "warn", priority = -1 }
|
||||||
|
nursery = { level = "warn", priority = -1 }
|
||||||
|
# Justified allows: latency/rate math is pervasive usize<->u64<->f64 casting (a
|
||||||
|
# load generator is nothing but counters and durations), so the lossy-cast
|
||||||
|
# pedantic family is allowed exactly as the engine's ranking math allows it.
|
||||||
|
cast_possible_truncation = "allow"
|
||||||
|
cast_possible_wrap = "allow"
|
||||||
|
cast_precision_loss = "allow"
|
||||||
|
cast_sign_loss = "allow"
|
||||||
|
module_name_repetitions = "allow"
|
||||||
|
# Advisory pedantic/nursery style lints that are noise for a CLI load tool (and
|
||||||
|
# which the engine crates already run at warn, not deny): help-text URLs/flags
|
||||||
|
# read fine bare; `out.push_str(&format!(..))` is the clearest way to assemble a
|
||||||
|
# report table; constructors take their config by value; const-ness of trivial
|
||||||
|
# accessors churns. Correctness lints (clippy::all) stay DENY.
|
||||||
|
doc_markdown = "allow"
|
||||||
|
format_push_string = "allow"
|
||||||
|
needless_pass_by_value = "allow"
|
||||||
|
missing_const_for_fn = "allow"
|
||||||
|
map_unwrap_or = "allow"
|
||||||
|
unwrap_used = "deny"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
clap = { version = "4.5", features = ["derive", "env"] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
thiserror = "2"
|
||||||
|
tokio = { version = "1", features = ["macros", "rt-multi-thread", "sync", "time", "signal"] }
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
rand = "0.9"
|
||||||
|
# Async HTTP only (no blocking client): rustls posture matches tidal-server /
|
||||||
|
# tidal-net — drops the OpenSSL/native-tls system dependency for a clean static
|
||||||
|
# build. `json` for the typed request/response bodies.
|
||||||
|
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||||
98
tidal-stress/k8s/stress-job.yaml
Normal file
98
tidal-stress/k8s/stress-job.yaml
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
# In-cluster capacity ramp for the tidalDB multi-process cluster.
|
||||||
|
#
|
||||||
|
# Runs the load generator AS A POD so requests take the real cluster network path
|
||||||
|
# (a `kubectl port-forward` serializes everything through the API server and adds
|
||||||
|
# tens of ms — useless for capacity numbers). Targets the three region ClusterIPs
|
||||||
|
# directly; reads round-robin across them (each serves locally), leader-path
|
||||||
|
# writes are pinned to us-east (10.43.99.11) to measure the single-leader funnel.
|
||||||
|
#
|
||||||
|
# Apply: kubectl apply -f tidal-stress/k8s/stress-job.yaml
|
||||||
|
# Watch: kubectl logs -f job/tidal-stress -n tidaldb
|
||||||
|
# Re-run: kubectl delete job tidal-stress -n tidaldb; kubectl apply -f ...
|
||||||
|
#
|
||||||
|
# To compare the horizontally-scaled path, change `--write-path leader` to
|
||||||
|
# `--write-path sharded` (hash-partitions writes across all 3 regions, no funnel).
|
||||||
|
apiVersion: batch/v1
|
||||||
|
kind: Job
|
||||||
|
metadata:
|
||||||
|
name: tidal-stress
|
||||||
|
namespace: tidaldb
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: tidal-stress
|
||||||
|
app.kubernetes.io/part-of: tidaldb
|
||||||
|
spec:
|
||||||
|
backoffLimit: 0 # a load run is not retried — read the logs
|
||||||
|
ttlSecondsAfterFinished: 7200
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: tidal-stress
|
||||||
|
app.kubernetes.io/part-of: tidaldb
|
||||||
|
spec:
|
||||||
|
restartPolicy: Never
|
||||||
|
automountServiceAccountToken: false
|
||||||
|
affinity:
|
||||||
|
podAntiAffinity:
|
||||||
|
# Keep the generator OFF the leader's node so it never steals CPU from
|
||||||
|
# the write bottleneck we are measuring.
|
||||||
|
requiredDuringSchedulingIgnoredDuringExecution:
|
||||||
|
- labelSelector:
|
||||||
|
matchLabels:
|
||||||
|
tidaldb.region: us-east
|
||||||
|
topologyKey: kubernetes.io/hostname
|
||||||
|
securityContext:
|
||||||
|
runAsNonRoot: true
|
||||||
|
runAsUser: 1000
|
||||||
|
runAsGroup: 1000
|
||||||
|
seccompProfile:
|
||||||
|
type: RuntimeDefault
|
||||||
|
containers:
|
||||||
|
- name: stress
|
||||||
|
image: registry.threesix.ai/tidal/stress@sha256:77395cc2857e2adabaa37607b198316d6f1cd0ec04f749caf2de959e6bade55b # :v1
|
||||||
|
imagePullPolicy: IfNotPresent
|
||||||
|
args:
|
||||||
|
- --target
|
||||||
|
- http://10.43.99.11:9500 # us-east (leader)
|
||||||
|
- --target
|
||||||
|
- http://10.43.99.12:9500 # eu-west
|
||||||
|
- --target
|
||||||
|
- http://10.43.99.13:9500 # ap-south
|
||||||
|
- --leader-url
|
||||||
|
- http://10.43.99.11:9500
|
||||||
|
- --ramp
|
||||||
|
- peach-100k
|
||||||
|
- --stage-secs
|
||||||
|
- "45"
|
||||||
|
- --mix
|
||||||
|
- peach
|
||||||
|
- --write-path
|
||||||
|
- leader
|
||||||
|
- --corpus
|
||||||
|
- "20000"
|
||||||
|
- --users
|
||||||
|
- "100000"
|
||||||
|
- --poll-status
|
||||||
|
env:
|
||||||
|
- name: TIDAL_API_KEY
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: tidaldb-credentials
|
||||||
|
key: TIDAL_API_KEY
|
||||||
|
- name: TIDAL_STRESS_LOG
|
||||||
|
value: warn
|
||||||
|
resources:
|
||||||
|
# Small REQUEST (the cluster is request-saturated though ~15% utilised)
|
||||||
|
# with a high LIMIT: the generator bursts to the cycles it needs on the
|
||||||
|
# idle node. If it ever CPU-saturates, the report's schedule-lag /
|
||||||
|
# client-shed will say so — then split the load across multiple Jobs.
|
||||||
|
requests:
|
||||||
|
cpu: 250m
|
||||||
|
memory: 256Mi
|
||||||
|
limits:
|
||||||
|
cpu: "3"
|
||||||
|
memory: 1Gi
|
||||||
|
securityContext:
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
readOnlyRootFilesystem: true
|
||||||
|
capabilities:
|
||||||
|
drop: ["ALL"]
|
||||||
199
tidal-stress/src/client.rs
Normal file
199
tidal-stress/src/client.rs
Normal file
@ -0,0 +1,199 @@
|
|||||||
|
//! Thin async HTTP client over the tidalDB surface, plus the corpus seeder.
|
||||||
|
//!
|
||||||
|
//! The client returns a [`StatusClass`] per request and nothing else — latency is
|
||||||
|
//! measured by the caller from the request's *intended* send time so the figure
|
||||||
|
//! is coordinated-omission-corrected (a slow server inflates the number it would
|
||||||
|
//! otherwise hide). Response bodies are drained so the keep-alive connection is
|
||||||
|
//! reused (and so the measured time includes full response transfer).
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use rand::Rng;
|
||||||
|
use tokio::sync::Semaphore;
|
||||||
|
|
||||||
|
use crate::error::{Result, StressError};
|
||||||
|
use crate::metrics::StatusClass;
|
||||||
|
use crate::workload::{HttpMethod, Plan};
|
||||||
|
|
||||||
|
/// Async client with optional bearer auth. Cheap to clone (Arc inside reqwest).
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct HttpClient {
|
||||||
|
inner: reqwest::Client,
|
||||||
|
api_key: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HttpClient {
|
||||||
|
/// `request_timeout` should sit ABOVE the server's 30s request timeout so the
|
||||||
|
/// server's own 408 surfaces as a Timeout class rather than a client abort.
|
||||||
|
pub fn new(request_timeout: Duration, api_key: Option<String>) -> Result<Self> {
|
||||||
|
let inner = reqwest::Client::builder()
|
||||||
|
.timeout(request_timeout)
|
||||||
|
.connect_timeout(Duration::from_secs(5))
|
||||||
|
// Warm-connection pool sized to comfortably saturate the server's
|
||||||
|
// 100-concurrency limit per host without exhausting container fds at
|
||||||
|
// 4 host-pools (≈4×256 idle ceiling).
|
||||||
|
.pool_max_idle_per_host(256)
|
||||||
|
.pool_idle_timeout(Duration::from_secs(90))
|
||||||
|
.tcp_nodelay(true)
|
||||||
|
.build()
|
||||||
|
.map_err(StressError::Client)?;
|
||||||
|
Ok(Self { inner, api_key })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_auth(&self, rb: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
|
||||||
|
match &self.api_key {
|
||||||
|
Some(k) => rb.bearer_auth(k),
|
||||||
|
None => rb,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Issue one request and classify the result. Never returns an error — a
|
||||||
|
/// transport fault is a [`StatusClass::Transport`] outcome, not a fatal.
|
||||||
|
pub async fn execute(&self, plan: &Plan) -> StatusClass {
|
||||||
|
let rb = match plan.method {
|
||||||
|
HttpMethod::Get => self.inner.get(&plan.url),
|
||||||
|
HttpMethod::Post => {
|
||||||
|
let rb = self.inner.post(&plan.url);
|
||||||
|
match &plan.body {
|
||||||
|
Some(b) => rb.json(b),
|
||||||
|
None => rb,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match self.apply_auth(rb).send().await {
|
||||||
|
Ok(resp) => {
|
||||||
|
let class = StatusClass::from_status(resp.status().as_u16());
|
||||||
|
// Drain the body so the connection returns to the pool.
|
||||||
|
let _ = resp.bytes().await;
|
||||||
|
class
|
||||||
|
}
|
||||||
|
Err(_) => StatusClass::Transport,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch `GET {base}/cluster/status` (unauthenticated) and return the leader
|
||||||
|
/// name and the worst replication lag across regions — used to watch whether
|
||||||
|
/// follower lag grows under write load between ramp stages. `None` on any fault.
|
||||||
|
pub async fn cluster_status(&self, base: &str) -> Option<(String, i64)> {
|
||||||
|
let url = format!("{base}/cluster/status");
|
||||||
|
let resp = self.inner.get(&url).send().await.ok()?;
|
||||||
|
let v: serde_json::Value = resp.json().await.ok()?;
|
||||||
|
let leader = v.get("leader")?.as_str()?.to_string();
|
||||||
|
let max_lag = v
|
||||||
|
.get("regions")?
|
||||||
|
.as_array()?
|
||||||
|
.iter()
|
||||||
|
.filter_map(|r| r.get("lag_events").and_then(serde_json::Value::as_i64))
|
||||||
|
.max()
|
||||||
|
.unwrap_or(0);
|
||||||
|
Some((leader, max_lag))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One-off POST returning the raw status, for the seeder (which needs to know
|
||||||
|
/// success vs 429-retry rather than a capacity class).
|
||||||
|
async fn post_json(&self, url: &str, body: &serde_json::Value) -> Option<u16> {
|
||||||
|
let rb = self.inner.post(url).json(body);
|
||||||
|
match self.apply_auth(rb).send().await {
|
||||||
|
Ok(resp) => {
|
||||||
|
let code = resp.status().as_u16();
|
||||||
|
let _ = resp.bytes().await;
|
||||||
|
Some(code)
|
||||||
|
}
|
||||||
|
Err(_) => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Register `count` items (id 1..=count) plus a `dim`-wide content embedding for
|
||||||
|
/// each, against `base` (use the LEADER url so items broadcast to every region
|
||||||
|
/// and `/feed` on any region can rank them). Bounded-concurrency, retries 429.
|
||||||
|
/// Returns the number of items confirmed registered.
|
||||||
|
pub async fn seed_corpus(
|
||||||
|
client: &HttpClient,
|
||||||
|
base: &str,
|
||||||
|
count: u64,
|
||||||
|
dim: usize,
|
||||||
|
concurrency: usize,
|
||||||
|
) -> Result<u64> {
|
||||||
|
let sem = Arc::new(Semaphore::new(concurrency.max(1)));
|
||||||
|
let done = Arc::new(AtomicU64::new(0));
|
||||||
|
let items_url = format!("{base}/items");
|
||||||
|
let emb_url = format!("{base}/embeddings");
|
||||||
|
let mut handles = Vec::new();
|
||||||
|
|
||||||
|
for id in 1..=count {
|
||||||
|
let permit = sem
|
||||||
|
.clone()
|
||||||
|
.acquire_owned()
|
||||||
|
.await
|
||||||
|
.map_err(|e| StressError::Seed(format!("semaphore closed: {e}")))?;
|
||||||
|
let client = client.clone();
|
||||||
|
let items_url = items_url.clone();
|
||||||
|
let emb_url = emb_url.clone();
|
||||||
|
let done = done.clone();
|
||||||
|
handles.push(tokio::spawn(async move {
|
||||||
|
let _permit = permit;
|
||||||
|
let category = SEED_CATEGORIES[(id as usize) % SEED_CATEGORIES.len()];
|
||||||
|
// Build bodies and DROP the RNG before the awaits (ThreadRng is !Send).
|
||||||
|
let (item, emb) = {
|
||||||
|
let mut rng = rand::rng();
|
||||||
|
let values: Vec<f32> = (0..dim).map(|_| rng.random::<f32>() - 0.5).collect();
|
||||||
|
let item = serde_json::json!({
|
||||||
|
"entity_id": id,
|
||||||
|
"metadata": { "title": format!("post-{id}"), "category": category },
|
||||||
|
});
|
||||||
|
let emb = serde_json::json!({ "entity_id": id, "values": values });
|
||||||
|
(item, emb)
|
||||||
|
};
|
||||||
|
|
||||||
|
if retry_write(&client, &items_url, &item).await
|
||||||
|
&& retry_write(&client, &emb_url, &emb).await
|
||||||
|
{
|
||||||
|
done.fetch_add(1, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
for h in handles {
|
||||||
|
let _ = h.await;
|
||||||
|
}
|
||||||
|
Ok(done.load(Ordering::Relaxed))
|
||||||
|
}
|
||||||
|
|
||||||
|
const SEED_CATEGORIES: [&str; 12] = [
|
||||||
|
"anime",
|
||||||
|
"gaming",
|
||||||
|
"fitness",
|
||||||
|
"music",
|
||||||
|
"cosplay",
|
||||||
|
"art",
|
||||||
|
"fantasy",
|
||||||
|
"scifi",
|
||||||
|
"romance",
|
||||||
|
"comedy",
|
||||||
|
"horror",
|
||||||
|
"slice-of-life",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// POST with backpressure-aware retry: a 2xx is success; a 429 backs off and
|
||||||
|
/// retries (the seed must not give up just because the write pool is busy); any
|
||||||
|
/// other code / transport fault fails fast (a usage bug, not transient load).
|
||||||
|
async fn retry_write(client: &HttpClient, url: &str, body: &serde_json::Value) -> bool {
|
||||||
|
const MAX_ATTEMPTS: u32 = 8;
|
||||||
|
for attempt in 0..MAX_ATTEMPTS {
|
||||||
|
match client.post_json(url, body).await {
|
||||||
|
Some(code) if (200..=299).contains(&code) => return true,
|
||||||
|
Some(429) => {
|
||||||
|
// Honor the engine's ~50ms hint, with a little growth.
|
||||||
|
tokio::time::sleep(Duration::from_millis(40 + u64::from(attempt) * 20)).await;
|
||||||
|
}
|
||||||
|
Some(503) => {
|
||||||
|
// Leader briefly unreachable (e.g. a roll) — short wait, retry.
|
||||||
|
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||||
|
}
|
||||||
|
_ => return false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
32
tidal-stress/src/error.rs
Normal file
32
tidal-stress/src/error.rs
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
//! Crate error type. Mirrors the `tidal-server` convention: a flat `thiserror`
|
||||||
|
//! enum + `Result` alias, surfaced by `main` as `error: {err}` + exit 1. No
|
||||||
|
//! `anyhow` (workspace convention is explicit error enums).
|
||||||
|
|
||||||
|
/// Anything that can go wrong while *setting up or driving* a load run. Per-request
|
||||||
|
/// failures during a run are NOT errors — they are recorded as outcomes (see
|
||||||
|
/// [`crate::metrics::Outcome`]); this type is only for fatal setup/teardown faults.
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum StressError {
|
||||||
|
/// A target URL was missing or unparseable.
|
||||||
|
#[error("invalid target: {0}")]
|
||||||
|
Target(String),
|
||||||
|
|
||||||
|
/// The ramp profile (preset name or `rps:secs,...` spec) could not be parsed.
|
||||||
|
#[error("invalid ramp profile: {0}")]
|
||||||
|
Ramp(String),
|
||||||
|
|
||||||
|
/// The workload mix (preset name or `op=weight,...` spec) could not be parsed.
|
||||||
|
#[error("invalid workload mix: {0}")]
|
||||||
|
Mix(String),
|
||||||
|
|
||||||
|
/// Corpus seeding could not register enough items to run a meaningful read mix.
|
||||||
|
#[error("seed phase failed: {0}")]
|
||||||
|
Seed(String),
|
||||||
|
|
||||||
|
/// The HTTP client could not be constructed (TLS/builder fault).
|
||||||
|
#[error("http client build failed: {0}")]
|
||||||
|
Client(#[source] reqwest::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Crate-local result alias.
|
||||||
|
pub type Result<T> = std::result::Result<T, StressError>;
|
||||||
403
tidal-stress/src/main.rs
Normal file
403
tidal-stress/src/main.rs
Normal file
@ -0,0 +1,403 @@
|
|||||||
|
//! `tidal-stress` — an open-loop load generator and capacity ramp for tidalDB's
|
||||||
|
//! HTTP surface, modeling a thepeach-style feed workload.
|
||||||
|
//!
|
||||||
|
//! It seeds a corpus, then ramps a throttleable, signal-dominated request mix
|
||||||
|
//! (the thepeach session: feed reads + view/like/skip signals + search) from a
|
||||||
|
//! low rate up past a 100k-DAU TikTok-style peak, reporting per-stage latency
|
||||||
|
//! percentiles and a backpressure-aware status breakdown, and ends with a
|
||||||
|
//! capacity verdict translated back to supported DAU.
|
||||||
|
//!
|
||||||
|
//! Methodology lives in [`scheduler`] (coordinated-omission-corrected open loop);
|
||||||
|
//! the workload shape and its thepeach derivation live in [`workload`].
|
||||||
|
|
||||||
|
mod client;
|
||||||
|
mod error;
|
||||||
|
mod metrics;
|
||||||
|
mod scheduler;
|
||||||
|
mod workload;
|
||||||
|
|
||||||
|
use std::io::Write as _;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use clap::Parser;
|
||||||
|
|
||||||
|
use crate::client::{HttpClient, seed_corpus};
|
||||||
|
use crate::error::{Result, StressError};
|
||||||
|
use crate::metrics::StageStats;
|
||||||
|
use crate::scheduler::{Stage, parse_ramp, run_stage};
|
||||||
|
use crate::workload::{OpKind, Workload, WritePath, parse_mix};
|
||||||
|
|
||||||
|
#[derive(Parser)]
|
||||||
|
#[command(
|
||||||
|
version,
|
||||||
|
about = "Open-loop capacity ramp for tidalDB (thepeach feed workload)"
|
||||||
|
)]
|
||||||
|
struct Cli {
|
||||||
|
/// Region gateway base URL, e.g. http://10.43.99.11:9500 . Repeat for each
|
||||||
|
/// region; reads round-robin across them (each serves locally), writes too
|
||||||
|
/// unless --leader-url pins them.
|
||||||
|
#[arg(long = "target", required = true)]
|
||||||
|
targets: Vec<String>,
|
||||||
|
|
||||||
|
/// Pin leader-path writes (/signals,/items,/embeddings) to this URL to avoid
|
||||||
|
/// the follower→leader forward hop. Ignored for --write-path sharded.
|
||||||
|
#[arg(long)]
|
||||||
|
leader_url: Option<String>,
|
||||||
|
|
||||||
|
/// Bearer key. The deployed cluster requires it; read from $TIDAL_API_KEY.
|
||||||
|
#[arg(long, env = "TIDAL_API_KEY")]
|
||||||
|
api_key: Option<String>,
|
||||||
|
|
||||||
|
/// Ramp: a preset (smoke|quick|peach-100k|max) or `rps:secs,rps:secs,...`.
|
||||||
|
#[arg(long, default_value = "peach-100k")]
|
||||||
|
ramp: String,
|
||||||
|
|
||||||
|
/// Seconds to hold each preset ramp stage.
|
||||||
|
#[arg(long, default_value_t = 45)]
|
||||||
|
stage_secs: u64,
|
||||||
|
|
||||||
|
/// Workload mix: a preset (peach|reads|writes) or `op=weight,...`
|
||||||
|
/// (ops: feed,search,view,like,skip,item,embed).
|
||||||
|
#[arg(long, default_value = "peach")]
|
||||||
|
mix: String,
|
||||||
|
|
||||||
|
/// Write surface: `leader` (replicated, single-leader funnel) or `sharded`
|
||||||
|
/// (hash-partitioned across regions, not replicated).
|
||||||
|
#[arg(long, default_value = "leader")]
|
||||||
|
write_path: String,
|
||||||
|
|
||||||
|
/// Items (id 1..=N) + embeddings to seed before the ramp.
|
||||||
|
#[arg(long, default_value_t = 10_000)]
|
||||||
|
corpus: u64,
|
||||||
|
|
||||||
|
/// Virtual user id space (a 100k-DAU app reuses ids across the run).
|
||||||
|
#[arg(long, default_value_t = 50_000)]
|
||||||
|
users: u64,
|
||||||
|
|
||||||
|
/// Concurrent seed writers.
|
||||||
|
#[arg(long, default_value_t = 64)]
|
||||||
|
seed_concurrency: usize,
|
||||||
|
|
||||||
|
/// Skip seeding (corpus already registered from a prior run).
|
||||||
|
#[arg(long, default_value_t = false)]
|
||||||
|
skip_seed: bool,
|
||||||
|
|
||||||
|
/// Max concurrent in-flight requests before the generator sheds (and reports
|
||||||
|
/// it). Keep well above the server's 100-concurrency limit so the server's
|
||||||
|
/// ceiling, not the client's, is what shows.
|
||||||
|
#[arg(long, default_value_t = 5_000)]
|
||||||
|
max_inflight: usize,
|
||||||
|
|
||||||
|
/// Power-law concentration of signals onto hot (low-id) items (>1 = more
|
||||||
|
/// concentrated; 1 = uniform).
|
||||||
|
#[arg(long, default_value_t = 1.3)]
|
||||||
|
hot_skew: f64,
|
||||||
|
|
||||||
|
/// Feed page size (thepeach home grid = 24).
|
||||||
|
#[arg(long, default_value_t = 24)]
|
||||||
|
feed_limit: u32,
|
||||||
|
|
||||||
|
/// Embedding width. The deployed schema's content_vector is 128 (thepeach's
|
||||||
|
/// real text-embedding-3-small is 1536 — a schema change, noted in the verdict).
|
||||||
|
#[arg(long, default_value_t = 128)]
|
||||||
|
embedding_dim: usize,
|
||||||
|
|
||||||
|
/// Per-request client timeout (s). Keep above the server's 30s so its 408
|
||||||
|
/// surfaces rather than a client abort.
|
||||||
|
#[arg(long, default_value_t = 40)]
|
||||||
|
request_timeout_secs: u64,
|
||||||
|
|
||||||
|
/// DAU the verdict translates the measured ceiling against.
|
||||||
|
#[arg(long, default_value_t = 100_000)]
|
||||||
|
dau: u64,
|
||||||
|
|
||||||
|
/// Poll /cluster/status between stages to watch replication lag under load.
|
||||||
|
#[arg(long, default_value_t = false)]
|
||||||
|
poll_status: bool,
|
||||||
|
|
||||||
|
/// Stop the ramp at the first stage that breaches the SLO (error rate >1% or
|
||||||
|
/// feed p99 >150ms) instead of pushing every stage.
|
||||||
|
#[arg(long, default_value_t = false)]
|
||||||
|
stop_on_knee: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
// SLO thresholds for the per-stage verdict. tidalDB's own RETRIEVE SLA is p99
|
||||||
|
// <50ms in-process; over a cluster network hop we allow 150ms before calling a
|
||||||
|
// stage degraded, and treat >1% errors (429/408/503/5xx/transport) as the knee.
|
||||||
|
const SLO_FEED_P99: Duration = Duration::from_millis(150);
|
||||||
|
const SLO_ERROR_RATE: f64 = 0.01;
|
||||||
|
|
||||||
|
// The thepeach 100k-DAU model: ~5 sessions/user/day × ~5 feed pages × ~15.4
|
||||||
|
// signal-equivalents/page ⇒ ~67M signals/day ≈ 780 signals/s average; a TikTok
|
||||||
|
// evening peak concentrates ~5× ⇒ ~3,900 signals/s peak. Reads are ~1/20th of that.
|
||||||
|
const SIGNALS_PER_DAU_PER_SEC_AVG: f64 = 780.0 / 100_000.0;
|
||||||
|
const PEAK_FACTOR: f64 = 5.0;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
if let Err(err) = run().await {
|
||||||
|
eprintln!("error: {err}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_lines)]
|
||||||
|
async fn run() -> Result<()> {
|
||||||
|
let cli = Cli::parse();
|
||||||
|
init_tracing();
|
||||||
|
|
||||||
|
if cli.targets.is_empty() {
|
||||||
|
return Err(StressError::Target(
|
||||||
|
"at least one --target is required".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let write_path = match cli.write_path.as_str() {
|
||||||
|
"leader" => WritePath::Leader,
|
||||||
|
"sharded" => WritePath::Sharded,
|
||||||
|
other => {
|
||||||
|
return Err(StressError::Target(format!(
|
||||||
|
"unknown --write-path '{other}'"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mix = parse_mix(&cli.mix)?;
|
||||||
|
let stages = parse_ramp(&cli.ramp, cli.stage_secs)?;
|
||||||
|
let client = Arc::new(HttpClient::new(
|
||||||
|
Duration::from_secs(cli.request_timeout_secs),
|
||||||
|
cli.api_key.clone(),
|
||||||
|
)?);
|
||||||
|
|
||||||
|
// The leader URL anchors seeding (items must broadcast from the leader so
|
||||||
|
// every region's /feed can rank them) and, for the leader write path, the
|
||||||
|
// pinned write target when given.
|
||||||
|
let leader_url = cli
|
||||||
|
.leader_url
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| cli.targets[0].clone());
|
||||||
|
let read_bases = cli.targets.clone();
|
||||||
|
let write_bases = match (write_path, &cli.leader_url) {
|
||||||
|
(WritePath::Leader, Some(url)) => vec![url.clone()],
|
||||||
|
_ => cli.targets.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
println!("tidal-stress — thepeach feed workload");
|
||||||
|
println!(" targets : {}", cli.targets.join(", "));
|
||||||
|
println!(
|
||||||
|
" write path : {} ({})",
|
||||||
|
cli.write_path,
|
||||||
|
if cli.leader_url.is_some() {
|
||||||
|
"leader pinned"
|
||||||
|
} else {
|
||||||
|
"round-robin gateways"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
println!(" mix : {}", cli.mix);
|
||||||
|
println!(
|
||||||
|
" corpus/users : {} items / {} users",
|
||||||
|
cli.corpus, cli.users
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
" embedding dim : {}{}",
|
||||||
|
cli.embedding_dim,
|
||||||
|
if cli.embedding_dim == 128 {
|
||||||
|
""
|
||||||
|
} else {
|
||||||
|
" (NB: deployed schema is 128)"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
" 100k-DAU model: ~{:.0} signals/s avg, ~{:.0} signals/s peak (×{:.0})\n",
|
||||||
|
SIGNALS_PER_DAU_PER_SEC_AVG * cli.dau as f64,
|
||||||
|
SIGNALS_PER_DAU_PER_SEC_AVG * cli.dau as f64 * PEAK_FACTOR,
|
||||||
|
PEAK_FACTOR,
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Seed ──────────────────────────────────────────────────────────────────
|
||||||
|
if cli.skip_seed {
|
||||||
|
println!("seed: skipped (--skip-seed)\n");
|
||||||
|
} else {
|
||||||
|
println!(
|
||||||
|
"seed: registering {} items + {}-dim embeddings via {} ...",
|
||||||
|
cli.corpus, cli.embedding_dim, leader_url
|
||||||
|
);
|
||||||
|
let t0 = std::time::Instant::now();
|
||||||
|
let seeded = seed_corpus(
|
||||||
|
&client,
|
||||||
|
&leader_url,
|
||||||
|
cli.corpus,
|
||||||
|
cli.embedding_dim,
|
||||||
|
cli.seed_concurrency,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
println!(
|
||||||
|
"seed: {seeded}/{} items in {:.1}s\n",
|
||||||
|
cli.corpus,
|
||||||
|
t0.elapsed().as_secs_f64()
|
||||||
|
);
|
||||||
|
if seeded < cli.corpus / 2 {
|
||||||
|
return Err(StressError::Seed(format!(
|
||||||
|
"only {seeded}/{} items registered — feed reads would be thin; check auth/schema/leader",
|
||||||
|
cli.corpus
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let workload = Arc::new(Workload::new(
|
||||||
|
read_bases,
|
||||||
|
write_bases,
|
||||||
|
write_path,
|
||||||
|
mix,
|
||||||
|
cli.corpus,
|
||||||
|
cli.users,
|
||||||
|
cli.hot_skew,
|
||||||
|
cli.feed_limit,
|
||||||
|
cli.embedding_dim,
|
||||||
|
));
|
||||||
|
|
||||||
|
let _ = std::io::stdout().flush();
|
||||||
|
|
||||||
|
// ── Ramp ────────────────────────────────────────────────────────────────
|
||||||
|
let mut best_pass: Option<(usize, f64, f64)> = None; // (stage idx, total ok/s, signal ok/s)
|
||||||
|
let mut knee: Option<usize> = None;
|
||||||
|
|
||||||
|
for (i, stage) in stages.iter().enumerate() {
|
||||||
|
if cli.poll_status
|
||||||
|
&& let Some((leader, lag)) = client.cluster_status(&leader_url).await
|
||||||
|
{
|
||||||
|
println!(" [pre-stage] leader={leader} max_replication_lag={lag}");
|
||||||
|
}
|
||||||
|
let label = format!("{}/{}", i + 1, stages.len());
|
||||||
|
let stats = run_stage(workload.clone(), client.clone(), stage, cli.max_inflight).await;
|
||||||
|
print!(
|
||||||
|
"{}",
|
||||||
|
metrics::render_stage(&label, stage.target_rps, &stats)
|
||||||
|
);
|
||||||
|
// Rust block-buffers stdout to a pipe (k8s log capture); flush so each
|
||||||
|
// stage is visible live under `kubectl logs -f`, not only at exit.
|
||||||
|
let _ = std::io::stdout().flush();
|
||||||
|
|
||||||
|
let (passed, signal_ok_rps) = stage_verdict(&stats);
|
||||||
|
if passed {
|
||||||
|
best_pass = Some((i, stats.achieved_rps(), signal_ok_rps));
|
||||||
|
} else if knee.is_none() {
|
||||||
|
knee = Some(i);
|
||||||
|
println!(" ⚠ SLO breached at this stage (the capacity knee).");
|
||||||
|
if cli.stop_on_knee {
|
||||||
|
println!(" stopping ramp (--stop-on-knee).");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if cli.poll_status
|
||||||
|
&& let Some((leader, lag)) = client.cluster_status(&leader_url).await
|
||||||
|
{
|
||||||
|
println!("\n [post-ramp] leader={leader} max_replication_lag={lag}");
|
||||||
|
}
|
||||||
|
|
||||||
|
print_verdict(&cli, &stages, best_pass, knee);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A stage passes if its error rate is under the SLO and feed-read p99 (when feed
|
||||||
|
/// reads are in the mix) is under the latency SLO. Returns (passed, signal ok/s).
|
||||||
|
fn stage_verdict(stats: &StageStats) -> (bool, f64) {
|
||||||
|
let secs = stats.elapsed.as_secs_f64().max(1e-9);
|
||||||
|
let signal_ok: u64 = [OpKind::SignalView, OpKind::SignalLike, OpKind::SignalSkip]
|
||||||
|
.iter()
|
||||||
|
.map(|k| stats.ops[k.idx()].ok())
|
||||||
|
.sum();
|
||||||
|
let signal_ok_rps = signal_ok as f64 / secs;
|
||||||
|
|
||||||
|
let feed = &stats.ops[OpKind::FeedRead.idx()];
|
||||||
|
let feed_p99_ok = feed.ok() == 0 || feed.hist.percentile(0.99) <= SLO_FEED_P99;
|
||||||
|
let passed = stats.error_rate() <= SLO_ERROR_RATE && feed_p99_ok && stats.client_shed == 0;
|
||||||
|
(passed, signal_ok_rps)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_verdict(
|
||||||
|
cli: &Cli,
|
||||||
|
stages: &[Stage],
|
||||||
|
best_pass: Option<(usize, f64, f64)>,
|
||||||
|
knee: Option<usize>,
|
||||||
|
) {
|
||||||
|
println!("\n══════════════════════ CAPACITY VERDICT ══════════════════════");
|
||||||
|
match best_pass {
|
||||||
|
None => {
|
||||||
|
println!(
|
||||||
|
"No stage held the SLO — even the lowest rate breached it. Investigate before scaling."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Some((idx, total_ok_rps, signal_ok_rps)) => {
|
||||||
|
let peak_per_sec = SIGNALS_PER_DAU_PER_SEC_AVG * PEAK_FACTOR; // signals/s per DAU at peak
|
||||||
|
let avg_per_sec = SIGNALS_PER_DAU_PER_SEC_AVG; // signals/s per DAU averaged
|
||||||
|
let dau_peak = signal_ok_rps / peak_per_sec;
|
||||||
|
let dau_avg = signal_ok_rps / avg_per_sec;
|
||||||
|
println!(
|
||||||
|
"Highest stage within SLO: stage {}/{} — target {:.0} rps.",
|
||||||
|
idx + 1,
|
||||||
|
stages.len(),
|
||||||
|
stages[idx].target_rps,
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
" sustained OK throughput : {total_ok_rps:.0} req/s total, of which {signal_ok_rps:.0} signal-writes/s",
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
" → supports ≈ {:.0}k DAU if provisioned for the 5× evening PEAK,",
|
||||||
|
dau_peak / 1000.0,
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
" or ≈ {:.0}k DAU against AVERAGE load (peaks would shed/queue).",
|
||||||
|
dau_avg / 1000.0,
|
||||||
|
);
|
||||||
|
let target = cli.dau as f64;
|
||||||
|
let verdict = if dau_peak >= target {
|
||||||
|
format!(
|
||||||
|
"YES — handles {:.0}k DAU at peak with headroom.",
|
||||||
|
target / 1000.0
|
||||||
|
)
|
||||||
|
} else if dau_avg >= target {
|
||||||
|
format!(
|
||||||
|
"PARTIALLY — handles {:.0}k DAU on average but NOT the 5× peak on this write path; scale or shard.",
|
||||||
|
target / 1000.0
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
"NO — below {:.0}k DAU even on average on this write path.",
|
||||||
|
target / 1000.0
|
||||||
|
)
|
||||||
|
};
|
||||||
|
println!(" 100k-DAU question : {verdict}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(k) = knee {
|
||||||
|
println!(
|
||||||
|
" capacity knee : stage {}/{} (target {:.0} rps) — first SLO breach.",
|
||||||
|
k + 1,
|
||||||
|
stages.len(),
|
||||||
|
stages[k].target_rps
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
println!(
|
||||||
|
" capacity knee : not reached — the cluster never breached the SLO in this ramp. Try --ramp max."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
println!(
|
||||||
|
" scaling levers : write path = {} (try --write-path sharded to remove the single-leader funnel);\n leader pod cpu \"2\" caps the write pool at ~2 workers — more CPU on the leader raises it (clamp max 8).",
|
||||||
|
cli.write_path,
|
||||||
|
);
|
||||||
|
if cli.embedding_dim == 128 {
|
||||||
|
println!(
|
||||||
|
" note : thepeach's real embeddings are 1536-dim; a 1536-dim schema ~2.3× the RAM/item (capacity-planning.md)."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
println!("═══════════════════════════════════════════════════════════════");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn init_tracing() {
|
||||||
|
let env_filter = std::env::var("TIDAL_STRESS_LOG").unwrap_or_else(|_| "warn".into());
|
||||||
|
let _ = tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(env_filter)
|
||||||
|
.try_init();
|
||||||
|
}
|
||||||
409
tidal-stress/src/metrics.rs
Normal file
409
tidal-stress/src/metrics.rs
Normal file
@ -0,0 +1,409 @@
|
|||||||
|
//! Measurement: a dependency-free latency histogram, backpressure-aware status
|
||||||
|
//! classification, and per-stage aggregation/reporting.
|
||||||
|
//!
|
||||||
|
//! WHY THESE BUCKETS: the server distinguishes 429 (write-pool / WAL
|
||||||
|
//! backpressure — slow the write rate), 408 (request-timeout from the 100-deep
|
||||||
|
//! concurrency queue — lower concurrency), and 503 (leader/region down or
|
||||||
|
//! draining — re-target). A load report that lumps them as "errors" is useless
|
||||||
|
//! for capacity work, so [`StatusClass`] keeps them apart and the verdict reads
|
||||||
|
//! the mix to name the ceiling we hit.
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use crate::workload::OpKind;
|
||||||
|
|
||||||
|
// ── Latency histogram ────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Log-spaced buckets with linear interpolation inside the crossing bucket. Min /
|
||||||
|
// max / mean are tracked exactly; only the percentiles are bucket-estimated, to
|
||||||
|
// ~3-4% (≈30 buckets per decade). HdrHistogram would be marginally tighter but a
|
||||||
|
// new lock entry; this is accurate enough to find a capacity knee and matches the
|
||||||
|
// repo's dependency-light posture (the engine's own latency metric uses fixed
|
||||||
|
// 1µs–10ms buckets, docs/ops/monitoring.md).
|
||||||
|
|
||||||
|
const MIN_NS: f64 = 1_000.0; // 1µs — finer than that is noise over a network hop
|
||||||
|
const GROWTH: f64 = 1.0772; // ~30 buckets/decade
|
||||||
|
const BUCKETS: usize = 300; // 1µs * 1.0772^300 ≈ 9.4e12ns ≈ 2.6h — far past any real tail
|
||||||
|
|
||||||
|
/// A single operation's latency distribution. Exact count/min/max/sum, bucketed
|
||||||
|
/// percentiles.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct LatencyHistogram {
|
||||||
|
counts: Vec<u64>,
|
||||||
|
total: u64,
|
||||||
|
min_ns: u64,
|
||||||
|
max_ns: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for LatencyHistogram {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
counts: vec![0; BUCKETS],
|
||||||
|
total: 0,
|
||||||
|
min_ns: u64::MAX,
|
||||||
|
max_ns: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LatencyHistogram {
|
||||||
|
fn index(value_ns: u64) -> usize {
|
||||||
|
let v = value_ns as f64;
|
||||||
|
if v <= MIN_NS {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
let idx = (v / MIN_NS).log(GROWTH).floor() as usize;
|
||||||
|
idx.min(BUCKETS - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lower bound (ns) of bucket `i`.
|
||||||
|
fn bound(i: usize) -> f64 {
|
||||||
|
MIN_NS * GROWTH.powi(i as i32)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn record(&mut self, latency: Duration) {
|
||||||
|
let ns = u64::try_from(latency.as_nanos()).unwrap_or(u64::MAX);
|
||||||
|
self.counts[Self::index(ns)] += 1;
|
||||||
|
self.total += 1;
|
||||||
|
self.min_ns = self.min_ns.min(ns);
|
||||||
|
self.max_ns = self.max_ns.max(ns);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Estimated p-th percentile (`p` in 0.0..=1.0), linearly interpolated within
|
||||||
|
/// the crossing bucket. Returns 0 for an empty histogram.
|
||||||
|
pub fn percentile(&self, p: f64) -> Duration {
|
||||||
|
if self.total == 0 {
|
||||||
|
return Duration::ZERO;
|
||||||
|
}
|
||||||
|
let target = (p * self.total as f64).ceil().max(1.0) as u64;
|
||||||
|
let mut cumulative = 0u64;
|
||||||
|
for (i, &c) in self.counts.iter().enumerate() {
|
||||||
|
if c == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if cumulative + c >= target {
|
||||||
|
// Interpolate within [bound(i), bound(i+1)) by how far into this
|
||||||
|
// bucket's mass `target` falls. Clamp to the exact min/max so a
|
||||||
|
// p100 never reads below the bucket floor or above the real max.
|
||||||
|
let into = (target - cumulative) as f64 / c as f64;
|
||||||
|
let lo = Self::bound(i);
|
||||||
|
let hi = Self::bound(i + 1);
|
||||||
|
let est = (hi - lo).mul_add(into, lo);
|
||||||
|
let clamped = est.clamp(self.min_ns as f64, self.max_ns as f64);
|
||||||
|
return Duration::from_nanos(clamped as u64);
|
||||||
|
}
|
||||||
|
cumulative += c;
|
||||||
|
}
|
||||||
|
Duration::from_nanos(self.max_ns)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Exact worst-case latency (not bucket-estimated) — the tail a load report
|
||||||
|
/// must surface alongside p999.
|
||||||
|
pub const fn max(&self) -> Duration {
|
||||||
|
Duration::from_nanos(if self.total == 0 { 0 } else { self.max_ns })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Status classification ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// What a single request's result *means* for capacity, not just its code.
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum StatusClass {
|
||||||
|
/// 2xx — the operation succeeded.
|
||||||
|
Ok,
|
||||||
|
/// 429 — write-pool / WAL backpressure (honor retry, slow the write rate).
|
||||||
|
Backpressure,
|
||||||
|
/// 408 — request-timeout: queued past 30s behind the 100-concurrency limit.
|
||||||
|
Timeout,
|
||||||
|
/// 503 — leader/region unreachable or draining (re-target / wait).
|
||||||
|
Unavailable,
|
||||||
|
/// Other 4xx (400/401/413/404…) — a client/usage bug, not load.
|
||||||
|
ClientError,
|
||||||
|
/// 5xx other than 503 — a server fault under load.
|
||||||
|
ServerError,
|
||||||
|
/// No HTTP response at all: connect refused, TLS, client-side timeout, reset.
|
||||||
|
Transport,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StatusClass {
|
||||||
|
pub const fn from_status(code: u16) -> Self {
|
||||||
|
match code {
|
||||||
|
200..=299 => Self::Ok,
|
||||||
|
429 => Self::Backpressure,
|
||||||
|
408 => Self::Timeout,
|
||||||
|
503 => Self::Unavailable,
|
||||||
|
400..=499 => Self::ClientError,
|
||||||
|
_ => Self::ServerError,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn idx(self) -> usize {
|
||||||
|
match self {
|
||||||
|
Self::Ok => 0,
|
||||||
|
Self::Backpressure => 1,
|
||||||
|
Self::Timeout => 2,
|
||||||
|
Self::Unavailable => 3,
|
||||||
|
Self::ClientError => 4,
|
||||||
|
Self::ServerError => 5,
|
||||||
|
Self::Transport => 6,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_KINDS: usize = 7;
|
||||||
|
|
||||||
|
/// The result of one issued request, sent from a worker to the collector.
|
||||||
|
pub struct Outcome {
|
||||||
|
pub op: OpKind,
|
||||||
|
pub class: StatusClass,
|
||||||
|
/// Wall-clock from the request's *intended* send time to its completion —
|
||||||
|
/// the coordinated-omission-corrected latency (includes any client backlog).
|
||||||
|
pub latency: Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Per-op + per-stage aggregation ───────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Clone, Default)]
|
||||||
|
pub struct OpStats {
|
||||||
|
pub hist: LatencyHistogram,
|
||||||
|
pub status: [u64; STATUS_KINDS],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OpStats {
|
||||||
|
fn record(&mut self, class: StatusClass, latency: Duration) {
|
||||||
|
self.status[class.idx()] += 1;
|
||||||
|
// Only successful requests' latencies describe service time; a 429/503
|
||||||
|
// rejected fast would otherwise flatter the percentiles. Errors are
|
||||||
|
// counted in `status`, excluded from the latency picture.
|
||||||
|
if class == StatusClass::Ok {
|
||||||
|
self.hist.record(latency);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn total(&self) -> u64 {
|
||||||
|
let mut sum = 0u64;
|
||||||
|
let mut i = 0;
|
||||||
|
while i < STATUS_KINDS {
|
||||||
|
sum += self.status[i];
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
sum
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn ok(&self) -> u64 {
|
||||||
|
self.status[0]
|
||||||
|
}
|
||||||
|
pub const fn backpressure(&self) -> u64 {
|
||||||
|
self.status[1]
|
||||||
|
}
|
||||||
|
pub const fn timeout(&self) -> u64 {
|
||||||
|
self.status[2]
|
||||||
|
}
|
||||||
|
pub const fn unavailable(&self) -> u64 {
|
||||||
|
self.status[3]
|
||||||
|
}
|
||||||
|
pub const fn client_error(&self) -> u64 {
|
||||||
|
self.status[4]
|
||||||
|
}
|
||||||
|
pub const fn server_error(&self) -> u64 {
|
||||||
|
self.status[5]
|
||||||
|
}
|
||||||
|
pub const fn transport(&self) -> u64 {
|
||||||
|
self.status[6]
|
||||||
|
}
|
||||||
|
pub const fn errors(&self) -> u64 {
|
||||||
|
self.total() - self.ok()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Everything one ramp stage produced. Per-op stats plus a roll-up.
|
||||||
|
pub struct StageStats {
|
||||||
|
pub ops: Vec<OpStats>, // indexed by OpKind::idx()
|
||||||
|
pub elapsed: Duration,
|
||||||
|
/// Requests the client could not even dispatch (in-flight cap hit) — a signal
|
||||||
|
/// the LOAD GENERATOR is saturated, not the server. Non-zero ⇒ the reported
|
||||||
|
/// achieved RPS is a client-limited floor, not the server's ceiling.
|
||||||
|
pub client_shed: u64,
|
||||||
|
/// Mean delay between a request's intended send time and its actual dispatch.
|
||||||
|
/// Growing scheduling delay ⇒ the generator is falling behind the target rate.
|
||||||
|
pub mean_schedule_lag: Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StageStats {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
ops: vec![OpStats::default(); OpKind::COUNT],
|
||||||
|
elapsed: Duration::ZERO,
|
||||||
|
client_shed: 0,
|
||||||
|
mean_schedule_lag: Duration::ZERO,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn record(&mut self, outcome: &Outcome) {
|
||||||
|
self.ops[outcome.op.idx()].record(outcome.class, outcome.latency);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn total(&self) -> u64 {
|
||||||
|
self.ops.iter().map(OpStats::total).sum()
|
||||||
|
}
|
||||||
|
pub fn total_ok(&self) -> u64 {
|
||||||
|
self.ops.iter().map(OpStats::ok).sum()
|
||||||
|
}
|
||||||
|
pub fn total_errors(&self) -> u64 {
|
||||||
|
self.ops.iter().map(OpStats::errors).sum()
|
||||||
|
}
|
||||||
|
pub fn error_rate(&self) -> f64 {
|
||||||
|
let t = self.total();
|
||||||
|
if t == 0 {
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
self.total_errors() as f64 / t as f64
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// Achieved throughput: completed requests per second over the stage.
|
||||||
|
pub fn achieved_rps(&self) -> f64 {
|
||||||
|
let s = self.elapsed.as_secs_f64();
|
||||||
|
if s <= 0.0 {
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
self.total() as f64 / s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sum of a status class across every op (for the verdict).
|
||||||
|
pub fn class_total(&self, pick: fn(&OpStats) -> u64) -> u64 {
|
||||||
|
self.ops.iter().map(pick).sum()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fmt_dur(d: Duration) -> String {
|
||||||
|
let us = d.as_nanos() as f64 / 1000.0;
|
||||||
|
if us < 1000.0 {
|
||||||
|
format!("{us:.0}µs")
|
||||||
|
} else if us < 1_000_000.0 {
|
||||||
|
format!("{:.2}ms", us / 1000.0)
|
||||||
|
} else {
|
||||||
|
format!("{:.2}s", us / 1_000_000.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render a per-stage report block as a human-readable table.
|
||||||
|
pub fn render_stage(label: &str, target_rps: f64, stats: &StageStats) -> String {
|
||||||
|
let mut out = String::new();
|
||||||
|
out.push_str(&format!(
|
||||||
|
"\n── stage {label} (target {target_rps:.0} rps, achieved {:.0} rps, {:.1}s) ──\n",
|
||||||
|
stats.achieved_rps(),
|
||||||
|
stats.elapsed.as_secs_f64(),
|
||||||
|
));
|
||||||
|
out.push_str(&format!(
|
||||||
|
"{:<10} {:>8} {:>8} {:>9} {:>9} {:>9} {:>9} {:>9} {:>5} {:>4} {:>4} {:>4} {:>5} {:>4}\n",
|
||||||
|
"op",
|
||||||
|
"count",
|
||||||
|
"ok/s",
|
||||||
|
"p50",
|
||||||
|
"p90",
|
||||||
|
"p99",
|
||||||
|
"p999",
|
||||||
|
"max",
|
||||||
|
"429",
|
||||||
|
"408",
|
||||||
|
"503",
|
||||||
|
"4xx",
|
||||||
|
"5xx",
|
||||||
|
"tx",
|
||||||
|
));
|
||||||
|
for kind in OpKind::ALL {
|
||||||
|
let s = &stats.ops[kind.idx()];
|
||||||
|
if s.total() == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let ok_rps = s.ok() as f64 / stats.elapsed.as_secs_f64().max(1e-9);
|
||||||
|
out.push_str(&format!(
|
||||||
|
"{:<10} {:>8} {:>8.0} {:>9} {:>9} {:>9} {:>9} {:>9} {:>5} {:>4} {:>4} {:>4} {:>5} {:>4}\n",
|
||||||
|
kind.label(),
|
||||||
|
s.total(),
|
||||||
|
ok_rps,
|
||||||
|
fmt_dur(s.hist.percentile(0.50)),
|
||||||
|
fmt_dur(s.hist.percentile(0.90)),
|
||||||
|
fmt_dur(s.hist.percentile(0.99)),
|
||||||
|
fmt_dur(s.hist.percentile(0.999)),
|
||||||
|
fmt_dur(s.hist.max()),
|
||||||
|
s.backpressure(),
|
||||||
|
s.timeout(),
|
||||||
|
s.unavailable(),
|
||||||
|
s.client_error(),
|
||||||
|
s.server_error(),
|
||||||
|
s.transport(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
out.push_str(&format!(
|
||||||
|
"{:<10} {:>8} {:>8.0} {:>52} {:>5} {:>4} {:>4} {:>4} {:>5} {:>4}\n",
|
||||||
|
"ALL",
|
||||||
|
stats.total(),
|
||||||
|
stats.total_ok() as f64 / stats.elapsed.as_secs_f64().max(1e-9),
|
||||||
|
"",
|
||||||
|
stats.class_total(OpStats::backpressure),
|
||||||
|
stats.class_total(OpStats::timeout),
|
||||||
|
stats.class_total(OpStats::unavailable),
|
||||||
|
stats.class_total(OpStats::client_error),
|
||||||
|
stats.class_total(OpStats::server_error),
|
||||||
|
stats.class_total(OpStats::transport),
|
||||||
|
));
|
||||||
|
out.push_str(&format!(
|
||||||
|
"error rate {:.2}% | client-shed {} | schedule-lag {}\n",
|
||||||
|
stats.error_rate() * 100.0,
|
||||||
|
stats.client_shed,
|
||||||
|
fmt_dur(stats.mean_schedule_lag),
|
||||||
|
));
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn histogram_percentiles_are_ordered_and_bounded() {
|
||||||
|
let mut h = LatencyHistogram::default();
|
||||||
|
for ns in 1..=10_000u64 {
|
||||||
|
h.record(Duration::from_nanos(ns * 1000)); // 1µs..10ms
|
||||||
|
}
|
||||||
|
let p50 = h.percentile(0.50);
|
||||||
|
let p99 = h.percentile(0.99);
|
||||||
|
assert!(p50 < p99, "p50 {p50:?} should be < p99 {p99:?}");
|
||||||
|
assert!(p99 <= h.max(), "p99 {p99:?} above exact max {:?}", h.max());
|
||||||
|
assert!(
|
||||||
|
h.max() >= Duration::from_millis(9),
|
||||||
|
"max should be ~10ms, got {:?}",
|
||||||
|
h.max()
|
||||||
|
);
|
||||||
|
// 50th percentile of a uniform 1µs..10ms ≈ 5ms; allow the bucket error.
|
||||||
|
let p50_ms = p50.as_secs_f64() * 1000.0;
|
||||||
|
assert!((p50_ms - 5.0).abs() < 0.5, "p50 {p50_ms}ms not ~5ms");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn status_class_maps_backpressure_codes() {
|
||||||
|
assert!(matches!(
|
||||||
|
StatusClass::from_status(429),
|
||||||
|
StatusClass::Backpressure
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
StatusClass::from_status(408),
|
||||||
|
StatusClass::Timeout
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
StatusClass::from_status(503),
|
||||||
|
StatusClass::Unavailable
|
||||||
|
));
|
||||||
|
assert!(matches!(StatusClass::from_status(201), StatusClass::Ok));
|
||||||
|
assert!(matches!(
|
||||||
|
StatusClass::from_status(400),
|
||||||
|
StatusClass::ClientError
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
StatusClass::from_status(500),
|
||||||
|
StatusClass::ServerError
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
207
tidal-stress/src/scheduler.rs
Normal file
207
tidal-stress/src/scheduler.rs
Normal file
@ -0,0 +1,207 @@
|
|||||||
|
//! Open-loop, constant-arrival-rate load scheduler — the methodology that makes
|
||||||
|
//! the numbers trustworthy.
|
||||||
|
//!
|
||||||
|
//! A CLOSED-loop generator (N workers each doing `send(); await; repeat`) sends
|
||||||
|
//! FEWER requests when the server slows down, so it silently under-measures both
|
||||||
|
//! load and latency — the "coordinated omission" error. This scheduler instead
|
||||||
|
//! fires requests at a fixed target rate regardless of outstanding responses,
|
||||||
|
//! and measures each request's latency from its *intended* send time, so a server
|
||||||
|
//! stall inflates the very percentiles a closed-loop test would hide.
|
||||||
|
//!
|
||||||
|
//! When the in-flight cap is reached we COUNT a client-shed rather than block —
|
||||||
|
//! blocking would re-introduce the closed-loop coupling. A non-zero shed count is
|
||||||
|
//! reported as "the generator, not the server, is the limit here".
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use tokio::sync::{Semaphore, mpsc};
|
||||||
|
use tokio::time::Instant;
|
||||||
|
|
||||||
|
use crate::client::HttpClient;
|
||||||
|
use crate::error::{Result, StressError};
|
||||||
|
use crate::metrics::{Outcome, StageStats};
|
||||||
|
use crate::workload::Workload;
|
||||||
|
|
||||||
|
/// One step of the ramp: hold `target_rps` for `duration`.
|
||||||
|
pub struct Stage {
|
||||||
|
pub target_rps: f64,
|
||||||
|
pub duration: Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run one stage open-loop and return its aggregated stats.
|
||||||
|
pub async fn run_stage(
|
||||||
|
workload: Arc<Workload>,
|
||||||
|
client: Arc<HttpClient>,
|
||||||
|
stage: &Stage,
|
||||||
|
max_inflight: usize,
|
||||||
|
) -> StageStats {
|
||||||
|
let (tx, mut rx) = mpsc::unbounded_channel::<Outcome>();
|
||||||
|
let collector = tokio::spawn(async move {
|
||||||
|
let mut stats = StageStats::new();
|
||||||
|
while let Some(o) = rx.recv().await {
|
||||||
|
stats.record(&o);
|
||||||
|
}
|
||||||
|
stats
|
||||||
|
});
|
||||||
|
|
||||||
|
let sem = Arc::new(Semaphore::new(max_inflight));
|
||||||
|
let shed = Arc::new(AtomicU64::new(0));
|
||||||
|
let lag_sum_ns = Arc::new(AtomicU64::new(0));
|
||||||
|
let lag_count = Arc::new(AtomicU64::new(0));
|
||||||
|
|
||||||
|
let start = Instant::now();
|
||||||
|
let deadline = start + stage.duration;
|
||||||
|
let period = Duration::from_secs_f64(1.0 / stage.target_rps.max(1e-9));
|
||||||
|
let mut next = start;
|
||||||
|
let mut dispatched: u64 = 0;
|
||||||
|
|
||||||
|
while Instant::now() < deadline {
|
||||||
|
let now = Instant::now();
|
||||||
|
if next <= now {
|
||||||
|
match sem.clone().try_acquire_owned() {
|
||||||
|
Ok(permit) => {
|
||||||
|
let intended = next;
|
||||||
|
let lag = now.saturating_duration_since(intended);
|
||||||
|
lag_sum_ns.fetch_add(lag.as_nanos() as u64, Ordering::Relaxed);
|
||||||
|
lag_count.fetch_add(1, Ordering::Relaxed);
|
||||||
|
|
||||||
|
let workload = workload.clone();
|
||||||
|
let client = client.clone();
|
||||||
|
let tx = tx.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let _permit = permit;
|
||||||
|
// Build the plan and DROP the RNG before any await:
|
||||||
|
// `ThreadRng` is !Send (holds an `Rc`), so it must not be
|
||||||
|
// live across `.await` or the task future isn't `Send`.
|
||||||
|
let plan = {
|
||||||
|
let mut rng = rand::rng();
|
||||||
|
workload.next(&mut rng)
|
||||||
|
};
|
||||||
|
let op = plan.op;
|
||||||
|
let class = client.execute(&plan).await;
|
||||||
|
// CO-corrected latency: from when the request was DUE, not
|
||||||
|
// when it was sent — so client backlog counts against us.
|
||||||
|
let latency = Instant::now().saturating_duration_since(intended);
|
||||||
|
let _ = tx.send(Outcome { op, class, latency });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
shed.fetch_add(1, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
next += period;
|
||||||
|
dispatched += 1;
|
||||||
|
// Let the local runtime worker service spawned tasks during a
|
||||||
|
// catch-up burst instead of monopolising it.
|
||||||
|
if dispatched.is_multiple_of(256) {
|
||||||
|
tokio::task::yield_now().await;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tokio::time::sleep_until(next).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let elapsed = start.elapsed();
|
||||||
|
|
||||||
|
// Dropping the producer's sender lets the collector finish once every in-flight
|
||||||
|
// task (each holding its own sender clone) completes — bounded by the client
|
||||||
|
// request timeout, so this can never hang indefinitely.
|
||||||
|
drop(tx);
|
||||||
|
let mut stats = collector.await.unwrap_or_else(|_| StageStats::new());
|
||||||
|
stats.elapsed = elapsed;
|
||||||
|
stats.client_shed = shed.load(Ordering::Relaxed);
|
||||||
|
let lc = lag_count.load(Ordering::Relaxed);
|
||||||
|
stats.mean_schedule_lag = if lc == 0 {
|
||||||
|
Duration::ZERO
|
||||||
|
} else {
|
||||||
|
Duration::from_nanos(lag_sum_ns.load(Ordering::Relaxed) / lc)
|
||||||
|
};
|
||||||
|
stats
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Ramp parsing ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Parse a ramp: a preset name or an explicit `rps:secs,rps:secs,...` spec.
|
||||||
|
///
|
||||||
|
/// Presets are pinned to the thepeach 100k-DAU model (see crate docs): at the
|
||||||
|
/// `peach` mix (~90% signals) a 100k-DAU TikTok-style evening peak is ≈3,900
|
||||||
|
/// total req/s, so `peach-100k` brackets and then doubles past that to answer
|
||||||
|
/// "can we handle more?". `secs` defaults from the preset.
|
||||||
|
pub fn parse_ramp(spec: &str, stage_secs: u64) -> Result<Vec<Stage>> {
|
||||||
|
let mk = |rates: &[f64]| -> Vec<Stage> {
|
||||||
|
rates
|
||||||
|
.iter()
|
||||||
|
.map(|&r| Stage {
|
||||||
|
target_rps: r,
|
||||||
|
duration: Duration::from_secs(stage_secs),
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
match spec {
|
||||||
|
// Local validation through a port-forward (low, short).
|
||||||
|
"smoke" => Ok(vec![
|
||||||
|
Stage {
|
||||||
|
target_rps: 10.0,
|
||||||
|
duration: Duration::from_secs(10),
|
||||||
|
},
|
||||||
|
Stage {
|
||||||
|
target_rps: 40.0,
|
||||||
|
duration: Duration::from_secs(10),
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
"quick" => Ok(mk(&[100.0, 500.0, 1500.0, 4000.0])),
|
||||||
|
// The headline ramp: low → past a 100k-DAU peak → 2× beyond.
|
||||||
|
"peach-100k" | "default" => Ok(mk(&[
|
||||||
|
50.0, 150.0, 400.0, 800.0, 1500.0, 3000.0, 5000.0, 8000.0,
|
||||||
|
])),
|
||||||
|
// Push to find the hard ceiling.
|
||||||
|
"max" => Ok(mk(&[
|
||||||
|
500.0, 1500.0, 3000.0, 6000.0, 10000.0, 15000.0, 20000.0,
|
||||||
|
])),
|
||||||
|
_ => {
|
||||||
|
let mut stages = Vec::new();
|
||||||
|
for part in spec.split(',') {
|
||||||
|
let part = part.trim();
|
||||||
|
if part.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let (r, s) = part
|
||||||
|
.split_once(':')
|
||||||
|
.ok_or_else(|| StressError::Ramp(format!("expected rps:secs, got '{part}'")))?;
|
||||||
|
let target_rps: f64 = r
|
||||||
|
.trim()
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| StressError::Ramp(format!("bad rps '{r}'")))?;
|
||||||
|
let secs: u64 = s
|
||||||
|
.trim()
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| StressError::Ramp(format!("bad secs '{s}'")))?;
|
||||||
|
stages.push(Stage {
|
||||||
|
target_rps,
|
||||||
|
duration: Duration::from_secs(secs),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if stages.is_empty() {
|
||||||
|
return Err(StressError::Ramp("empty ramp".into()));
|
||||||
|
}
|
||||||
|
Ok(stages)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ramp_presets_and_specs_parse() {
|
||||||
|
assert_eq!(parse_ramp("peach-100k", 45).expect("preset").len(), 8);
|
||||||
|
let custom = parse_ramp("100:30,500:30,2000:60", 45).expect("spec");
|
||||||
|
assert_eq!(custom.len(), 3);
|
||||||
|
assert!((custom[2].target_rps - 2000.0).abs() < f64::EPSILON);
|
||||||
|
assert_eq!(custom[2].duration.as_secs(), 60);
|
||||||
|
assert!(parse_ramp("oops", 45).is_err());
|
||||||
|
assert!(parse_ramp("100-30", 45).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
472
tidal-stress/src/workload.rs
Normal file
472
tidal-stress/src/workload.rs
Normal file
@ -0,0 +1,472 @@
|
|||||||
|
//! The workload model: a thepeach feed session expressed as a weighted mix of
|
||||||
|
//! tidalDB operations, with realistic "hot content" entity selection.
|
||||||
|
//!
|
||||||
|
//! WHY THIS SHAPE (from thepeach's specs): a session is anon-mint → home feed
|
||||||
|
//! (24 tiles/page, infinite scroll) → `view`/`like`/`skip` on tiles → occasional
|
||||||
|
//! search → repeat. thepeach's user-graph spec says behaviour is observed at
|
||||||
|
//! "10–1000+ events per session" — signals outnumber feed reads by 1–2 orders of
|
||||||
|
//! magnitude — so the default mix is signal-dominated. The deployed schema only
|
||||||
|
//! declares `view`/`like`/`skip` and a 128-dim `content_vector`, so those are the
|
||||||
|
//! only signal names and the only embedding width we emit.
|
||||||
|
|
||||||
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
|
|
||||||
|
use rand::Rng;
|
||||||
|
|
||||||
|
use crate::error::{Result, StressError};
|
||||||
|
|
||||||
|
/// The operations a feed session performs against tidalDB.
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum OpKind {
|
||||||
|
/// GET /feed — a home-feed page render (the read the experiment will swap a
|
||||||
|
/// tidaldb ranking profile into).
|
||||||
|
FeedRead,
|
||||||
|
/// GET /search — handle/tag search.
|
||||||
|
Search,
|
||||||
|
/// POST /signals view — a tile entering the viewport (the firehose).
|
||||||
|
SignalView,
|
||||||
|
/// POST /signals like — a reaction (positive engagement).
|
||||||
|
SignalLike,
|
||||||
|
/// POST /signals skip — a dismissal / sub-3s skip (strong negative, UC-01).
|
||||||
|
SignalSkip,
|
||||||
|
/// POST /items — a creator publishing a post/companion (rare in a read-heavy app).
|
||||||
|
RegisterItem,
|
||||||
|
/// POST /embeddings — the post's content vector (paired with RegisterItem).
|
||||||
|
RegisterEmbedding,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OpKind {
|
||||||
|
pub const ALL: [Self; 7] = [
|
||||||
|
Self::FeedRead,
|
||||||
|
Self::Search,
|
||||||
|
Self::SignalView,
|
||||||
|
Self::SignalLike,
|
||||||
|
Self::SignalSkip,
|
||||||
|
Self::RegisterItem,
|
||||||
|
Self::RegisterEmbedding,
|
||||||
|
];
|
||||||
|
pub const COUNT: usize = Self::ALL.len();
|
||||||
|
|
||||||
|
pub const fn idx(self) -> usize {
|
||||||
|
match self {
|
||||||
|
Self::FeedRead => 0,
|
||||||
|
Self::Search => 1,
|
||||||
|
Self::SignalView => 2,
|
||||||
|
Self::SignalLike => 3,
|
||||||
|
Self::SignalSkip => 4,
|
||||||
|
Self::RegisterItem => 5,
|
||||||
|
Self::RegisterEmbedding => 6,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::FeedRead => "feed",
|
||||||
|
Self::Search => "search",
|
||||||
|
Self::SignalView => "view",
|
||||||
|
Self::SignalLike => "like",
|
||||||
|
Self::SignalSkip => "skip",
|
||||||
|
Self::RegisterItem => "item",
|
||||||
|
Self::RegisterEmbedding => "embed",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn from_label(s: &str) -> Option<Self> {
|
||||||
|
Self::ALL.into_iter().find(|k| k.label() == s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which write surface the signal/item/embedding writes target.
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum WritePath {
|
||||||
|
/// `/signals` etc. — every write funnels to the single leader (us-east); a
|
||||||
|
/// write to a follower adds a forward hop. Replicated (the real feedback loop).
|
||||||
|
Leader,
|
||||||
|
/// `/sharded/*` — hash-partitioned across all 3 regions, no leader funnel.
|
||||||
|
/// NOT replicated (single owner per entity) — a horizontal-scale comparison.
|
||||||
|
Sharded,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WritePath {
|
||||||
|
fn signal_path(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Leader => "/signals",
|
||||||
|
Self::Sharded => "/sharded/signals",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn item_path(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Leader => "/items",
|
||||||
|
Self::Sharded => "/sharded/items",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn embedding_path(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Leader => "/embeddings",
|
||||||
|
Self::Sharded => "/sharded/embeddings",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
pub enum HttpMethod {
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One concrete request to issue.
|
||||||
|
pub struct Plan {
|
||||||
|
pub op: OpKind,
|
||||||
|
pub method: HttpMethod,
|
||||||
|
pub url: String,
|
||||||
|
pub body: Option<serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Round-robin over a set of base URLs (e.g. the three region gateways).
|
||||||
|
struct RoundRobin {
|
||||||
|
bases: Vec<String>,
|
||||||
|
next: AtomicUsize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RoundRobin {
|
||||||
|
fn new(bases: Vec<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
bases,
|
||||||
|
next: AtomicUsize::new(0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn pick(&self) -> &str {
|
||||||
|
let i = self.next.fetch_add(1, Ordering::Relaxed) % self.bases.len();
|
||||||
|
&self.bases[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cumulative-weight picker over the seven op kinds (no `rand_distr` dependency).
|
||||||
|
struct Mix {
|
||||||
|
cum: Vec<(f64, OpKind)>,
|
||||||
|
total: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Mix {
|
||||||
|
fn from_weights(weights: &[(OpKind, f64)]) -> Self {
|
||||||
|
let mut cum = Vec::with_capacity(weights.len());
|
||||||
|
let mut acc = 0.0;
|
||||||
|
for &(op, w) in weights {
|
||||||
|
if w <= 0.0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
acc += w;
|
||||||
|
cum.push((acc, op));
|
||||||
|
}
|
||||||
|
Self { cum, total: acc }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pick(&self, rng: &mut impl Rng) -> OpKind {
|
||||||
|
let r = rng.random::<f64>() * self.total;
|
||||||
|
for &(threshold, op) in &self.cum {
|
||||||
|
if r < threshold {
|
||||||
|
return op;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.cum.last().map_or(OpKind::FeedRead, |&(_, op)| op)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The session model + corpus parameters, shared (immutable) across all workers.
|
||||||
|
pub struct Workload {
|
||||||
|
reads: RoundRobin,
|
||||||
|
writes: RoundRobin,
|
||||||
|
write_path: WritePath,
|
||||||
|
mix: Mix,
|
||||||
|
feed_profiles: Vec<(f64, &'static str)>, // cumulative
|
||||||
|
profile_total: f64,
|
||||||
|
corpus: u64, // item ids 1..=corpus already seeded
|
||||||
|
users: u64, // virtual user ids 1..=users
|
||||||
|
hot_skew: f64, // >1 concentrates signals on low (hot) ids
|
||||||
|
feed_limit: u32,
|
||||||
|
embedding_dim: usize,
|
||||||
|
categories: Vec<&'static str>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Default category vocabulary — stands in for thepeach companion/post tags so
|
||||||
|
/// `category` keyword filters and `/search` queries hit real keyword values.
|
||||||
|
const CATEGORIES: [&str; 12] = [
|
||||||
|
"anime",
|
||||||
|
"gaming",
|
||||||
|
"fitness",
|
||||||
|
"music",
|
||||||
|
"cosplay",
|
||||||
|
"art",
|
||||||
|
"fantasy",
|
||||||
|
"scifi",
|
||||||
|
"romance",
|
||||||
|
"comedy",
|
||||||
|
"horror",
|
||||||
|
"slice-of-life",
|
||||||
|
];
|
||||||
|
|
||||||
|
impl Workload {
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub fn new(
|
||||||
|
read_bases: Vec<String>,
|
||||||
|
write_bases: Vec<String>,
|
||||||
|
write_path: WritePath,
|
||||||
|
mix_weights: Vec<(OpKind, f64)>,
|
||||||
|
corpus: u64,
|
||||||
|
users: u64,
|
||||||
|
hot_skew: f64,
|
||||||
|
feed_limit: u32,
|
||||||
|
embedding_dim: usize,
|
||||||
|
) -> Self {
|
||||||
|
// for_you is the deployed default and the one E2 will A/B; trending/hot/new
|
||||||
|
// exercise the other built-in sort paths. (related/following need
|
||||||
|
// relationship data we don't seed; date_saved needs a user — both omitted.)
|
||||||
|
let feed_profiles = vec![
|
||||||
|
("for_you", 70.0),
|
||||||
|
("trending", 15.0),
|
||||||
|
("hot", 10.0),
|
||||||
|
("new", 5.0),
|
||||||
|
];
|
||||||
|
let mut cum = Vec::new();
|
||||||
|
let mut acc = 0.0;
|
||||||
|
for (name, w) in feed_profiles {
|
||||||
|
acc += w;
|
||||||
|
cum.push((acc, name));
|
||||||
|
}
|
||||||
|
Self {
|
||||||
|
reads: RoundRobin::new(read_bases),
|
||||||
|
writes: RoundRobin::new(write_bases),
|
||||||
|
write_path,
|
||||||
|
mix: Mix::from_weights(&mix_weights),
|
||||||
|
feed_profiles: cum,
|
||||||
|
profile_total: acc,
|
||||||
|
corpus,
|
||||||
|
users,
|
||||||
|
hot_skew,
|
||||||
|
feed_limit,
|
||||||
|
embedding_dim,
|
||||||
|
categories: CATEGORIES.to_vec(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pick_user(&self, rng: &mut impl Rng) -> u64 {
|
||||||
|
rng.random_range(1..=self.users.max(1))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Power-law item pick: `id = corpus * u^skew` concentrates engagement on the
|
||||||
|
/// low (hot) ids — a few viral posts get most of the views, a long tail gets
|
||||||
|
/// the rest, like a real feed.
|
||||||
|
fn pick_item(&self, rng: &mut impl Rng) -> u64 {
|
||||||
|
let u: f64 = rng.random();
|
||||||
|
let scaled = (self.corpus as f64 * u.powf(self.hot_skew)) as u64;
|
||||||
|
scaled.clamp(1, self.corpus.max(1))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pick_profile(&self, rng: &mut impl Rng) -> &'static str {
|
||||||
|
let r = rng.random::<f64>() * self.profile_total;
|
||||||
|
for &(threshold, name) in &self.feed_profiles {
|
||||||
|
if r < threshold {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"for_you"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pick_category(&self, rng: &mut impl Rng) -> &'static str {
|
||||||
|
let i = rng.random_range(0..self.categories.len());
|
||||||
|
self.categories[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn random_embedding(&self, rng: &mut impl Rng) -> Vec<f32> {
|
||||||
|
// A non-zero vector (the engine rejects zero-norm); exact distribution is
|
||||||
|
// irrelevant to the write-path cost we're measuring.
|
||||||
|
(0..self.embedding_dim)
|
||||||
|
.map(|_| rng.random::<f32>() - 0.5)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the next request to issue.
|
||||||
|
pub fn next(&self, rng: &mut impl Rng) -> Plan {
|
||||||
|
let op = self.mix.pick(rng);
|
||||||
|
match op {
|
||||||
|
OpKind::FeedRead => {
|
||||||
|
let user = self.pick_user(rng);
|
||||||
|
let profile = self.pick_profile(rng);
|
||||||
|
let url = format!(
|
||||||
|
"{}/feed?profile={profile}&user_id={user}&limit={}",
|
||||||
|
self.reads.pick(),
|
||||||
|
self.feed_limit,
|
||||||
|
);
|
||||||
|
Plan {
|
||||||
|
op,
|
||||||
|
method: HttpMethod::Get,
|
||||||
|
url,
|
||||||
|
body: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
OpKind::Search => {
|
||||||
|
let user = self.pick_user(rng);
|
||||||
|
let q = self.pick_category(rng);
|
||||||
|
let url = format!(
|
||||||
|
"{}/search?query={q}&user_id={user}&limit=20",
|
||||||
|
self.reads.pick(),
|
||||||
|
);
|
||||||
|
Plan {
|
||||||
|
op,
|
||||||
|
method: HttpMethod::Get,
|
||||||
|
url,
|
||||||
|
body: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
OpKind::SignalView | OpKind::SignalLike | OpKind::SignalSkip => {
|
||||||
|
let (name, weight) = match op {
|
||||||
|
OpKind::SignalLike => ("like", 1.0),
|
||||||
|
OpKind::SignalSkip => ("skip", 1.0),
|
||||||
|
_ => ("view", 1.0),
|
||||||
|
};
|
||||||
|
let url = format!("{}{}", self.writes.pick(), self.write_path.signal_path());
|
||||||
|
let body = serde_json::json!({
|
||||||
|
"entity_id": self.pick_item(rng),
|
||||||
|
"signal": name,
|
||||||
|
"weight": weight,
|
||||||
|
});
|
||||||
|
Plan {
|
||||||
|
op,
|
||||||
|
method: HttpMethod::Post,
|
||||||
|
url,
|
||||||
|
body: Some(body),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
OpKind::RegisterItem => {
|
||||||
|
let id = self.pick_item(rng);
|
||||||
|
let url = format!("{}{}", self.writes.pick(), self.write_path.item_path());
|
||||||
|
let body = serde_json::json!({
|
||||||
|
"entity_id": id,
|
||||||
|
"metadata": { "title": format!("post-{id}"), "category": self.pick_category(rng) },
|
||||||
|
});
|
||||||
|
Plan {
|
||||||
|
op,
|
||||||
|
method: HttpMethod::Post,
|
||||||
|
url,
|
||||||
|
body: Some(body),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
OpKind::RegisterEmbedding => {
|
||||||
|
let url = format!("{}{}", self.writes.pick(), self.write_path.embedding_path());
|
||||||
|
let body = serde_json::json!({
|
||||||
|
"entity_id": self.pick_item(rng),
|
||||||
|
"values": self.random_embedding(rng),
|
||||||
|
});
|
||||||
|
Plan {
|
||||||
|
op,
|
||||||
|
method: HttpMethod::Post,
|
||||||
|
url,
|
||||||
|
body: Some(body),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Preset / spec parsing ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Parse the workload mix: a preset name or an explicit `op=weight,...` spec.
|
||||||
|
///
|
||||||
|
/// Default preset `peach` (≈ a thepeach feed session): per home-feed page the
|
||||||
|
/// user views ~12 of 24 tiles, likes ~0.6, skips ~1.5, searches ~0.25, and a
|
||||||
|
/// trickle of creator posts. The result is ~78% views, ~10% skips, ~6.5% feed
|
||||||
|
/// reads — the signal-dominated shape thepeach's user-graph spec describes.
|
||||||
|
pub fn parse_mix(spec: &str) -> Result<Vec<(OpKind, f64)>> {
|
||||||
|
match spec {
|
||||||
|
"peach" | "tiktok" | "default" => Ok(vec![
|
||||||
|
(OpKind::FeedRead, 1.0),
|
||||||
|
(OpKind::Search, 0.25),
|
||||||
|
(OpKind::SignalView, 12.0),
|
||||||
|
(OpKind::SignalLike, 0.6),
|
||||||
|
(OpKind::SignalSkip, 1.5),
|
||||||
|
(OpKind::RegisterItem, 0.02),
|
||||||
|
(OpKind::RegisterEmbedding, 0.02),
|
||||||
|
]),
|
||||||
|
// Read-only sanity profile (capacity of the local-read path alone).
|
||||||
|
"reads" => Ok(vec![(OpKind::FeedRead, 4.0), (OpKind::Search, 1.0)]),
|
||||||
|
// Write-only firehose (isolates the leader/sharded write ceiling).
|
||||||
|
"writes" => Ok(vec![
|
||||||
|
(OpKind::SignalView, 12.0),
|
||||||
|
(OpKind::SignalLike, 0.6),
|
||||||
|
(OpKind::SignalSkip, 1.5),
|
||||||
|
]),
|
||||||
|
_ => {
|
||||||
|
let mut weights = Vec::new();
|
||||||
|
for part in spec.split(',') {
|
||||||
|
let part = part.trim();
|
||||||
|
if part.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let (name, w) = part
|
||||||
|
.split_once('=')
|
||||||
|
.ok_or_else(|| StressError::Mix(format!("expected op=weight, got '{part}'")))?;
|
||||||
|
let op = OpKind::from_label(name.trim())
|
||||||
|
.ok_or_else(|| StressError::Mix(format!("unknown op '{name}'")))?;
|
||||||
|
let w: f64 = w
|
||||||
|
.trim()
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| StressError::Mix(format!("bad weight '{w}'")))?;
|
||||||
|
weights.push((op, w));
|
||||||
|
}
|
||||||
|
if weights.is_empty() {
|
||||||
|
return Err(StressError::Mix("empty mix".into()));
|
||||||
|
}
|
||||||
|
Ok(weights)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn peach_mix_is_signal_dominated() {
|
||||||
|
let w = parse_mix("peach").expect("preset parses");
|
||||||
|
let total: f64 = w.iter().map(|&(_, x)| x).sum();
|
||||||
|
let views = w
|
||||||
|
.iter()
|
||||||
|
.find(|&&(o, _)| o == OpKind::SignalView)
|
||||||
|
.map(|&(_, x)| x)
|
||||||
|
.unwrap_or(0.0);
|
||||||
|
assert!(views / total > 0.6, "views should dominate the mix");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn explicit_mix_spec_parses() {
|
||||||
|
let w = parse_mix("feed=1,view=10").expect("spec parses");
|
||||||
|
assert_eq!(w.len(), 2);
|
||||||
|
assert!(parse_mix("feed=oops").is_err());
|
||||||
|
assert!(parse_mix("bogus=1").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hot_skew_biases_low_ids() {
|
||||||
|
let wl = Workload::new(
|
||||||
|
vec!["http://x".into()],
|
||||||
|
vec!["http://x".into()],
|
||||||
|
WritePath::Leader,
|
||||||
|
parse_mix("peach").expect("mix"),
|
||||||
|
1000,
|
||||||
|
100,
|
||||||
|
1.5,
|
||||||
|
24,
|
||||||
|
128,
|
||||||
|
);
|
||||||
|
let mut rng = rand::rng();
|
||||||
|
let mut low = 0;
|
||||||
|
for _ in 0..10_000 {
|
||||||
|
if wl.pick_item(&mut rng) <= 200 {
|
||||||
|
low += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Uniform would put ~2000 picks in the bottom 20% of ids; skew 1.5
|
||||||
|
// concentrates ~34% (≈3400) there — clearly biased toward hot content.
|
||||||
|
assert!(low > 3_000, "hot-skew not biasing low ids: {low}");
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user