Twill Bench CLI
The official benchmarking, correctness, and serverless-efficiency CLI for Twill DB — a single driver that knows the engine's internals, so it measures not just request latency but commit durability, lifecycle behaviour, and data correctness under load. This page captures the product vision and verifies it against what ships today, separating what already exists from what is aligned-but-unbuilt and what would strain the architecture's rules.
Purpose & relationship to the validation plan
Unlike a generic load generator (k6, Locust, Vegeta), Twill Bench understands the engine: it can attribute latency to the commit round-trip, watch a database scale to zero and back, and assert ACID invariants over the result set. It exists to answer three questions on every run — is the database fast, is the data still correct under stress, and how efficiently did the serverless architecture use compute and storage?
15 is the CLI; 09 is the falsifiable plan it operationalizes
The Benchmark & Validation Plan defines the five adversarial experiments (latency floor, group-commit curve, contention wall, crash safety, cold read), the measurement methodology (percentiles via HDR histogram, never means), and the ship/route/block decision rule. This page does not replace those — it wraps them in an ergonomic CLI and adds scenario-oriented workloads, correctness profiles, and reporting on top. Where the two meet, spec 09 is authoritative on methodology; any number Twill Bench reports for a placement decision MUST still satisfy 09 (real object store for W1, the Experiment-4 durability gate before real data).
Goals & design principles
- MUST capture all latency as a distribution (p50/p90/p95/p99/p999 via an HDR-style histogram); mean-only reporting is prohibited (inherited from spec 09).
- MUST validate data correctness alongside performance — a fast run that loses an acked write or an update is a failure, not a fast success.
- MUST emit both human-friendly terminal output and a machine-readable JSON record for archiving, plotting, and CI gating.
- SHOULD be zero-config for the common scenarios and reproducible (pinned engine + storage SHA, region, instance type, seed — per spec 09).
- SHOULD be safe to run against production for read-only scenarios, and refuse to mutate unless explicitly opted in.
- SHOULD be extensible via benchmark profiles without recompiling the driver.
- MUST NOT pull heavyweight or non-auditable dependencies into the engine core to satisfy a reporting feature; CLI-only concerns (YAML profiles, Prometheus export) stay in the
twill-benchcrate, feature-gated, and never cross the storage seam or the thread-free engine boundary.
Command structure
The brief proposes a twill bench <scenario> [options] surface. The binary is invoked as twill-bench <command> --url … [flags]; both are sub-command driven. The request-mix scenarios, the correctness profiles, the lifecycle scenarios, the custom profile loader, and compare below all ship; what remains is the optional Prometheus export, not a re-architected entry point. (There is no separate twilldb CLI — twill-bench is the driver's own binary, as the brief intends.)
twill-bench read-heavy --url file://./b.db # shipped
twill-bench write-heavy --url file://./b.db # shipped
twill-bench mixed-oltp --url file://./b.db # shipped
twill-bench counter --url file://./b.db # correctness profile (shipped)
twill-bench bank-transfer --url file://./b.db # correctness profile (shipped)
twill-bench inventory --url file://./b.db # correctness profile (shipped)
twill-bench document-editing --url file://./b.db # correctness profile (shipped)
twill-bench compare --baseline base.json --candidate cand.json # shipped
twill-bench burst --url file://./b.db # shipped (issue #79 — closed-loop rate driver)
twill-bench scale-to-zero --url file://./b.db # shipped (spec 09 Exp 5 cold read)
twill-bench custom --profile workload.yaml # shipped (issue #81 — feature-gated YAML loader)
twill-bench exp1 --url s3://b/db --max-stall-ratio 50 # V-1 stall gate + region/cold-connection variants
twill-bench exp2-sweep --url s3://b/db --sweep-max 64 # V-2 group-commit-window sweep + plateau knee
twill-bench exp3-shard --url s3://b/db --databases 16 # V-3 N-database sharding + cross-DB CAS verdict
twill-bench herd --url file://./b.db --concurrency 64 # V-4 thundering-herd cold-start knee
twill-bench boundary --dir bench-archive --gate # V-5 W1/W2 boundary tables from archived recordsBenchmark scenarios
Scenarios are named workload shapes. The three implemented experiments map onto the request-mix scenarios; the lifecycle scenarios (burst, scale-to-zero, long-run) are new and depend on the controller being in the loop.
| Scenario | Shape | Verification status |
|---|---|---|
| read-heavy | 90% SELECT / 10% INSERT — analytical-leaning mix | IMPLEMENTED ratio-controlled mix over a pre-seeded working set |
| write-heavy | 20% SELECT / 80% INSERT — ingestion | IMPLEMENTED same mix driver, ingestion weights |
| mixed-oltp | 70% SELECT / 20% INSERT / 8% UPDATE / 2% DELETE — SaaS | IMPLEMENTED all four op kinds in the driver loop, deterministic per-writer PRNG |
| burst | idle → 500 → 5k → 20k rps → idle, repeat; measures cold/warm starts + scaling latency | SHIPPED (issue #79) closed-loop token-bucket rate driver holds a target rps (bounded, seeded jitter) while a deterministic load-shape schedule swings it; offered load fans out across --writers connections under one pacer; controller-driven and in-process, reporting cold/warm starts, peak workers, admission wait, and per-ramp scaling latency from pulled ControllerStats deltas; the run fails on no scale-up, no scale-down, or any acked-write loss across a teardown |
| scale-to-zero | query → idle past the reaper → query; measures cold boot, compute reuse, cache restore | SHIPPED spec 09 Exp 5 (cold read): controller-driven, reports the cold-boot percentile distribution + the serverless-efficiency figures from pulled ControllerStats deltas |
| long-run | hours/days; detects memory/resource/connection leaks, scheduler drift | SHIPPED interval sampler pulls a stats() snapshot + a /proc/self resource probe (RSS / fds / threads, zeros where /proc is absent) every --sample-interval-ms; a least-squares slope over the post-warm-up window flags a leak/drift on the cumulative leak gauges (memory/fds) when projected growth crosses both --drift-threshold and an absolute floor → exit 2 (p99 latency is sampled and reported but informational — a noisy per-window tail statistic, not a monotone leak gauge, so it never gates the verdict) |
| custom | user-supplied YAML profile (duration, connections, rows, seed, mix) | SHIPPED (issue #81) the custom --profile workload.yaml loader translates a YAML profile into the same request-mix driver the named scenarios use (no second execution path) and reports the realized-vs-configured op mix; the loader is gated behind the custom-profile cargo feature (a default build carries no profile-parsing code; the custom command then prints a rebuild hint) and the parser is hand-rolled, so the feature adds no third-party dependency — see the profile schema below |
Lifecycle scenarios need the controller, not the embedded engine
Burst, scale-to-zero, and long-run measure cold start, worker reuse, and scheduling — signals the embedded library deliberately does not own (the engine core is thread-free; lifecycle lives in twill-controller). These scenarios are meaningful over the server / pgwire path against a controller-driven deployment, and degrade to a single warm process when run purely embedded.
The custom profile schema (issue #81)
The named scenarios make the op ratios data; custom makes the whole shape data — duration, connection count, working-set size, seed, and the op mix — so a workload we don't want to name in the binary becomes a file, not code. The deliberate scope line: a profile expresses exactly the mix-driver vocabulary (the four op kinds' weights plus the run knobs). There is no custom SQL in v1 — the value over a hypothetical --mix flag is making the whole shape data, not turning the profile into a mini query language. Anything outside the documented field set is a hard parse error, not a silent ignore.
# workload.yaml — a custom twill-bench workload profile
label: saas-oltp # free-form tag recorded in the output (optional)
url: file:///tmp/bench.db # backend URL (optional; a CLI --url overrides it)
duration_ms: 5000 # timed measurement window (required, > 0)
warmup_ms: 500 # discarded warm-up window (default 200)
connections: 8 # concurrent writers (required, >= 1)
rows: 10000 # pre-seeded working-set size (default 1000)
seed: 1 # fixed PRNG seed → reproducible op stream (optional)
mix: # per-op weights (arbitrary positive ints; ≥1 non-zero)
select: 70
insert: 20
update: 8
delete: 2The numeric knobs carry the same millisecond / count vocabulary as the CLI flags, so a profile reads like the flags it replaces. The run is driven through the same transport-agnostic mix driver as the named scenarios (so custom runs on both the embedded and pgwire transports), and the report adds a mix section — the configured weights beside the realized op counts — so a run proves the driven shape tracked the requested ratios.
| Field | Required | Meaning |
|---|---|---|
duration_ms | yes (> 0) | timed measurement window |
connections | yes (>= 1) | concurrent writers |
mix.{select,insert,update,delete} | yes (≥1 non-zero) | per-op ratio weights over the four op kinds |
url | via profile or --url | backend URL; a CLI --url overrides it, and absence of both is a config error |
warmup_ms, rows, seed, label | no | warm-up window, working-set size, fixed op-stream seed, output tag |
Guardrail: the loader is feature-gated, and adds no dependency
Per #78 guardrail 1, the YAML profile loader is a CLI-only concern: it is gated behind the custom-profile cargo feature, so a default twill-bench build compiles no profile-parsing code and the custom command reports a rebuild hint (cargo build -p twill-bench --features custom-profile). A with/without-feature build matrix in CI keeps the wall up. The parser itself is hand-rolled — a small line-based reader for the flat-keys-plus-one-mix-block subset the schema needs — in keeping with the workspace's minimal-dependency ethos (the engine hand-rolls its SQL parser, WAL codec, and base64), so the feature pulls in no third-party YAML crate at all. The loader never reaches the engine or the storage seam; malformed or contradictory profiles (weights all zero, missing url, unknown op/field, bad syntax) are rejected with the config-error exit code (3).
Metrics
The brief groups metrics into five families. The query family ships today; the compute and scheduler families are the new, lifecycle-dependent surface and must be sourced from the controller/server, never invented inside the engine.
Query metrics IMPLEMENTED
Total / successful / failed requests, requests/sec, and the latency distribution (median, p90, p95, p99, max). The driver records per-op latency into an HDR-style histogram and reports p50/p90/p95/p99/p999 (plus min/max/mean) alongside an ok / retried-conflict / failure split — both in the human summary and the JSON record.
Storage metrics PARTIAL
Commit latency is measured today (it is the experiment). Snapshot/WAL read & write counts, storage-fetch latency, and cache hit/miss ratio require the engine/storage to expose counters through a read-only stats surface — additive, but a new seam-respecting introspection path (no backend internals leak into the Storage trait). That surface is now designed: see Observability design decision (#53), Decision 1 (Storage::stats() → StorageStats).
Compute & scheduler metrics DESIGN RESOLVED
Cold/warm starts, average start time, worker-reuse rate, compute active/idle duration, scale-to-zero events, peak workers; queue depth, scheduling delay, allocation/placement time. These come from twill-controller and the server, and overlap directly with a future observability/OTLP export. Twill Bench consumes them; it does not generate them — by pulling a ControllerStats snapshot, never scraping logs or pushing from the core (Observability design decision (#53), Decision 2).
Network metrics NEEDS DESIGN
Client RTT, TLS handshake, request/response transfer — meaningful only on the pgwire transport; measured client-side in the bench driver.
Observability design decision (#53): metric source & read-only stats surface
The lifecycle scenarios and the serverless-efficiency report need numbers the bench cannot make up — cold starts, worker reuse, scale-to-zero events, storage reads, cache hit/miss, compute active/idle time. Issue #53 asks three questions before any of that can be built: what read-only surface exposes those counters without leaking backend internals across the seam; how does the bench observe controller lifecycle events; and which latency segments are observable today versus blocked. This section resolves all three and settles the metric vocabulary once, so the future observability/OTLP exporter emits the same names the bench reports.
One rule decides the shape: pull a snapshot, never push from the core
Each tier already holds its counters as in-process atomics (the controller's warm_count/peak_concurrent_warms, the engine's group-commit (durable_appends, commits)). The decision is to expose one read-only stats() snapshot accessor per tier — additive, allocation-free, returning a plain value struct of backend-neutral counters — and have the bench pull it. No log scraping (format-coupled, brittle), and no push/emit from the engine or storage (that would put a metrics pipeline inside the thread-free core and across the seam — both forbidden). The live OTLP exporter, when it lands, reads the same snapshots and pushes them; the bench and the exporter share one source, one vocabulary.
Decision 1 — the read-only stats surface (per tier, pulled, seam-safe)
Four snapshot accessors, each owned by the tier that already tracks the signal, each a cheap relaxed-atomic load returning a Copy value struct (no allocation, safe to poll on a hot loop):
| Accessor | Tier / today | Snapshot fields (all backend-neutral) |
|---|---|---|
Storage::stats() → StorageStats LANDED | crates/storage — additive trait method, default-zero impl, bumped STORAGE_TRAIT_VERSION 2→3 (C1–C8 green) | wal_appends, wal_bytes, page_reads, page_read_bytes, cache_hits, cache_misses, fetch_latency_us_total, fsyncs — wired in LocalFileStorage; a branch reports its overlay-private counters; object-store cache/latency fields land with that backend's wiring |
Database::stats() → EngineStats LANDED | crates/engine — folds group_commit_stats() + the embedded StorageStats pulled through the seam | commits, durable_appends (→ coalescing ratio), committed_lsn (gauge), storage (the backend snapshot); conflict/commit-latency counters layer on later |
Controller::stats() → ControllerStats LANDED | crates/controller — extends warm_count()/peak_concurrent_warms() | cold_starts, warm_starts, scale_to_zero_events, peak_workers, warm_instances (gauge); compute_active/idle_us + admission_wait_us + lease_renew_total LANDED with the scale-to-zero scenario |
SHOW twill.stats / twill_stats view LANDED | crates/server — intercepts the query and answers from the live EngineStats (engine + storage, pulled through the seam) | a (metric, value) result under the settled twill_* names, surfaced in-band over pgwire so the bench (a plain client) pulls it on the same connection — no side channel. The compute/scheduler rows join it over a controller-driven deployment |
Seam invariant: StorageStats is counters, never internals
The new trait method returns only backend-neutral aggregates — counts, byte totals, latency histograms. It MUST NOT carry an S3 key, a file offset, an LSM layer id, or any other backend-specific concept (the storage-seam rule). LocalFileStorage and ObjectStorage populate the same field set; a field that is meaningless for a backend stays zero (fetch latency ≈ 0 and cache hit/miss unused for file://; both live for object stores). Because the method is additive with a default-zero impl, every existing backend and the C1–C8 conformance suite stay green; the bump to STORAGE_TRAIT_VERSION is the only contract change.
Decision 2 — how the bench observes controller lifecycle events
Pull the controller's snapshot; do not scrape logs and do not require a collector. Three candidate mechanisms were weighed:
- REJECTED log scrape — couples the bench to a log format, needs a parser, races with rotation, and carries no histograms. Brittle for a number that gates a placement decision.
- REJECTED (for the bench) push / emit from the controller — that is the future OTLP exporter, and it would force the bench to stand up a collector to read its own run. The exporter reads the same snapshot; it does not replace it.
- CHOSEN pull a snapshot —
Controller::stats()in-process for an embedded/controller-driven run;SHOW twill.statsover pgwire for a deployed server. The bench samples it at start and end of a scenario (and on an interval forlong-run), and the per-scenario delta becomes the report. Cumulative counters make deltas exact; gauges (peak_workers, committed_lsn) are read as-of.
This keeps lifecycle generation where it belongs — the controller and server own the events; the engine core stays thread-free and metric-free — and gives the bench a stable, typed, histogram-carrying read with no new runtime dependency.
Decision 3 — latency-breakdown attribution: observable now vs. blocked
The full request path is receive → auth → scheduler/admission → compute spin-up → metadata load → storage read → execution → commit → response. What each segment costs, and where the number comes from:
| Segment | Status | Source |
|---|---|---|
| total (end-to-end) | OBSERVABLE TODAY | bench driver, client-side (the existing per-op histogram) |
| client RTT, TLS handshake | OBSERVABLE TODAY | bench driver, pgwire transport only |
| auth | ≈ 0 | cleartext pgwire subset — negligible, not separately instrumented |
| commit (durable round-trip) | OBSERVABLE TODAY | this is the Exp 1–3 measurement; EngineStats.commit_latency_us formalizes it |
| execution | OBSERVABLE (engine) | engine-side span, exposed via EngineStats once the accessor lands |
| storage read | UNLOCKED BY Decision 1 | StorageStats.fetch_latency_us — blocked until the stats surface ships |
| scheduler / admission | UNLOCKED BY Decision 1 | ControllerStats.admission_wait_us (thundering-herd queue) |
| compute spin-up, metadata load | COLD-PATH ONLY | controller cold-start timing, split spin-up vs. replay/metadata — meaningful only on the first request to a cold instance; steady-state requests have zero spin-up, so this is a cold-start breakdown, not a per-request one. It is exactly the scale-to-zero (Exp 5) scenario's payload. |
So the breakdown ships in two steps: a client-side + commit breakdown is available now; the storage / scheduler / spin-up segments unlock together the moment Decision 1's accessors land, and the spin-up/metadata split is reported only on the cold path the scale-to-zero scenario drives.
The settled metric vocabulary (shared with the future OTLP exporter)
One namespaced signal set, defined here once. The bench reports these names in its JSON record; the live exporter emits the same names. Families map onto the snapshot accessors above.
| Family / source | Signals |
|---|---|
| query — bench, client-side SHIPS TODAY | twill_query_total, twill_query_ok, twill_query_conflict, twill_query_failed, twill_query_latency_us{p50…p999}, twill_throughput_per_s |
commit — EngineStats | twill_commit_total, twill_durable_append_total, twill_commit_coalescing_ratio, twill_commit_latency_us, twill_conflict_total, twill_committed_lsn |
storage — StorageStats | twill_storage_wal_appends_total, twill_storage_wal_bytes_total, twill_storage_page_reads_total, twill_storage_cache_hits_total, twill_storage_cache_misses_total, twill_storage_fetch_latency_us, twill_storage_fsync_total |
compute / scheduler — ControllerStats | twill_cold_start_total, twill_warm_start_total, twill_worker_reuse_ratio, twill_scale_to_zero_total, twill_compute_active_seconds_total, twill_compute_idle_seconds_total, twill_peak_workers, twill_admission_wait_us, twill_lease_renew_total |
| serverless-efficiency — derived (report-only) | compute_seconds_per_query = active / query_total, storage_reads_per_query, utilization = active / (active + idle), scale_to_zero_count, avg_worker_lifetime |
| network — bench, pgwire only | twill_client_rtt_us, twill_tls_handshake_us |
The efficiency family is pure arithmetic over the others — no new instrumentation, only the snapshot deltas a controller-driven run already yields. That is the report issue #53 asks for, and it falls out of Decisions 1–2 for free.
Build order this unblocks
- 1 ✓ The stats surface is landed — the three in-process accessors (
Storage::stats(), additive,STORAGE_TRAIT_VERSIONbumped 2→3, C1–C8 green;Database::stats(), folding the backend snapshot pulled through the seam;Controller::stats()) plus theSHOW twill.statspgwire surface (the server intercepts it and answers from the live engine + storage snapshot, returning(metric, value)rows under the settled names). Each has a test that moves the counters, including an end-to-end pull over the wire. The controller's compute/scheduler rows fold intoSHOW twill.statsonce a controller drives the deployment (step 2). - 2 ✓
scale-to-zero(Exp 5) cold-read scenario is landed — thetwill-bench scale-to-zerosubcommand drives query → idle past the controller's reaper → query for--cyclescold-boot samples (--idle-mssets the reaper window; spec-09 Exp 5 uses a long one on a real deployment), pulls theControllerStatssnapshot at the run boundaries, and reports the cold-boot percentile distribution against a controller-driven deployment. Thecompute_active/idle_us,admission_wait_us, andlease_renew_totalsignals landed inControllerStatsalongside it. Controller-driven and in-process (the--server/pgwire form is rejected — a deployed server runs its own controller); a test drives the full cold path and asserts no durable loss across teardowns. - 3 ✓ The serverless-efficiency report is landed — the
scale-to-zerorun now emits the full derived family from the pulled snapshot deltas:utilization= active / (active + idle),compute_seconds_per_query,storage_reads_per_query(the backend page-read delta pulled fromEngineStats.storageper cold read —0on the in-memoryfile://path, nonzero on an object store with a cold cache),scale_to_zero_count, andavg_worker_lifetime= total resident time / cold starts, alongside thetwill_worker_reuse_ratioandtwill_storage_page_reads_totalsource signals. Reported in both the human summary and the JSON record under the settled names; the derived arithmetic is pinned by a unit test (exact + zero-safe) and the full cold path by the scenario integration test. - 4 ✓ The
long-runsoak scenario is landed (issue #80) — the interval sampler this decision anticipated (“…and on an interval forlong-run”) pulls astats()snapshot plus a Linux/proc/selfresource probe (RSS, open fds, threads — degrading to zeros where/procis unavailable, never a crash) every--sample-interval-msinto an in-memory time series, decoupled from a steady-state read load over a pre-seeded working set. A least-squares slope over the post-warm-up window fits memory, fds, and p99; a metric is flagged as a leak/drift only when its projected growth crosses both the relative--drift-threshold(default 10%) and a per-metric absolute floor, so short-run jitter never trips it. The JSON record gains asoaksection (per-metric first/last/slope/peak/growth + a PASS/FAILdrift_passverdict); a detected leak/drift fails the run with the correctness exit code (2). Embedded-only (it samples this process's own resources, likescale-to-zero). Unit-tested on the sampler cadence, the/procparser, and synthetic rising-vs-flat series; an integration test proves a seeded monotonic growth exits 2 while a flat control passes.
Data-correctness validation
Performance is only valid if correctness is preserved. Twill Bench asserts, over the workload it just drove, that none of the classic anomalies occurred.
- MUST detect: missing rows, duplicate rows, lost updates, dirty reads, non-repeatable reads, phantom reads, serializable violations, and transaction-consistency breaks.
- MUST exit non-zero (code 2) when any invariant is violated, regardless of latency.
All four named, result-checking workload profiles now ship (counter, bank-transfer, inventory, document-editing): each drives a contended fixed-work load, then reads the durable state back and asserts an invariant, setting a correctness verdict that the CLI turns into exit code 2 on violation. They build on what exp3 proved informally — first-committer-wins conflicts are counted and retried (over pgwire the conflict surfaces as SQLSTATE 40001), and the conformance and group-commit suites prove durability and isolation at the crate level — and lift it into an explicit pass/fail assertion over the result set. Because all four are fixed-work (each writer does exactly --ops operations), the expected result is known exactly, so the assertion is a precise equality rather than a heuristic. A test-only --inject-fault lost-update hook deliberately drops one acked write so the negative test can prove the checker itself bites — a seeded violation must exit 2, not pass.
Workload profiles
| Profile | Drives | Asserts |
|---|---|---|
| bank-transfer IMPLEMENTED | concurrent atomic transfers between two accounts | ACID, balance invariant (sum conserved), transaction correctness |
| counter IMPLEMENTED | thousands of concurrent increments | atomicity, zero lost updates (generalizes exp3) |
| inventory IMPLEMENTED | concurrent stock decrements (read → refuse-oversell → decrement, in a txn) | no negative inventory (no oversell), optimistic-lock conflict handling |
| document-editing IMPLEMENTED | concurrent client-side read-modify-write edits to one document | no lost edit under concurrent RMW, conflict rate, merge/retry latency |
Reporting
Latency breakdown DESIGN RESOLVED
Rather than a single total, attribute time across the request path — receive, auth, scheduler, compute spin-up, metadata load, storage read, execution, commit, response. The spin-up / metadata / storage segments require the same controller + storage introspection as the compute/storage metrics above; until those land, the breakdown is partial (client-side + commit only). The per-segment attribution and what is observable today versus unlocked by the stats surface is settled in Observability design decision (#53), Decision 3.
Serverless-efficiency report SHIPPED
Unique to Twill DB: compute-active vs idle time, scale-to-zero count, average worker lifetime, compute-seconds/query, storage-reads/query, average compute utilization. This reframes a run around operational cost, not just latency — the most differentiated piece of the brief, and entirely controller-sourced. It is pure arithmetic over the snapshot deltas a controller-driven run yields; the signal set is fixed in the settled metric vocabulary, and the build order it falls out of is listed there. The scale-to-zero scenario emits the full family (human + JSON) under those names: utilization, compute_seconds_per_query, storage_reads_per_query, scale_to_zero_count, and avg_worker_lifetime, sourced from the pulled ControllerStats + EngineStats deltas.
Release comparison IMPLEMENTED
twill-bench compare --baseline base.json --candidate cand.json [--threshold 0.10] diffs two archived JSON records (throughput, p50/p99/p999) into a PASS/regression verdict — a regression (throughput down, or a tail latency up, beyond the threshold) exits 1. It is pure post-processing over the records the driver already emits (each carries the git SHA and the full percentile set), so it needs neither the engine nor a transport, and is naturally CI-friendly. Cold-start and memory deltas join the diff once those metrics are sourced from the controller.
Validation campaign — sweeps, gates & boundary tables (issue #91) SHIPPED
The single-lever experiments (exp1/exp2/exp3) became the falsifiable boundary the platform routes by. Each sweep drives the public commit path and hands the resulting spec-09 curve to a pure, unit-tested analysis layer (crates/bench/src/analysis.rs) for the verdict; the gates are report-only by default and map to a non-zero exit under --gate for CI.
| Sub-issue | Command | What it lands |
|---|---|---|
| V-1 | exp1 (+flags) | The Exp-1 p999/p50 stall acceptance gate (--max-stall-ratio; commit-path-stall signal → exit 1), plus the same-region/cross-region (--region) and cold-connection (--cold-connection, fresh connect per sample) variants. Real-S3 runs are the spec-09 MUST (W1 is the round-trip); a test fires the gate on an injected stall and passes a clean run. |
| V-2 | exp2-sweep | The group-commit-window sweep: sweeps 1..--sweep-max offered-concurrency points (the coalescing window in the leader/follower group commit — no engine knob added), plots throughput vs the p99 tail, detects the plateau knee (Kneedle distance-from-chord), and gates the plateau against the Exp-1 ceiling (≤ 1.5× ⇒ batching not engaging). |
| V-3 | exp3-shard | The N-database sharding orchestrator: 1..--databases independent databases, each with --writers contending on one hot row, driven under one synchronized pacer; reports aggregate throughput vs lane count and the near-linear-scaling verdict (efficiency ≥ 0.80) — answering whether the S3-CAS commit log is a cross-DB serialization point. |
| V-4 | herd | The thundering-herd variant: 1..--concurrency simultaneous cold starts (barrier-synchronized), reporting the spin-up saturation knee plus the controller-pulled admission-wait and peak-worker gauges — never generated in the thread-free engine core. |
| V-5 | boundary + CI | The archived record now pins provenance (region, instance_type, backend_sha, captured_at); boundary --dir … --out … --gate builds the W1/W2 boundary tables from the records (each tool's placement read off, not re-measured); and the scheduled bench-validation CI workflow runs Exp 1–3 against real S3 (when the BENCH_S3_* secrets are set, else a file:// smoke), gates regressions via compare, and folds in the Experiment-4 crash-safety verdict so the durability proof travels with the numbers. |
Output formats
- IMPLEMENTED — terminal summary + one-line JSON record (experiment, transport, backend, SHA, throughput, percentiles, and the correctness verdict for profiles).
- IMPLEMENTED —
--jsonemits only the one-line JSON record on stdout (for scripting / CI), suppressing the human summary. - NEEDS DESIGN —
--prometheusexport for Grafana/CI. Keep the exporter dependency CLI-only and feature-gated; it must never reach the engine.
Exit codes
| Code | Meaning |
|---|---|
| 0 | success |
| 1 | benchmark failed |
| 2 | correctness validation failed |
| 3 | configuration error |
| 4 | connection error |
IMPLEMENTED — the binary now follows this table exactly: 0 success, 1 benchmark failed (or a compare regression), 2 a violated correctness invariant, 3 a configuration/usage error, 4 a connection failure. The mapping is pinned by the scenario tests (crates/bench/tests/scenarios.rs).
Verification summary — brief vs. what ships
Where each brief capability stands against the current crates/bench driver and the project's architecture rules.
| Capability | Status | Notes |
|---|---|---|
| p50/p99/p999 via HDR histogram, JSON record, git SHA | IMPLEMENTED | exp1/exp2/exp3, embedded + pgwire, file:// + s3:// |
| Group-commit + contention experiments | IMPLEMENTED | maps to spec 09 Exp 1–3; conflict retry proven |
| Crash-safety gate | IMPLEMENTED | spec 09 Exp 4, in crates/storage (CI gate), not yet a bench subcommand |
| Request-mix scenarios (read/write/mixed) | IMPLEMENTED | read-heavy/write-heavy/mixed-oltp — ratio-controlled SELECT/INSERT/UPDATE/DELETE over a seeded working set |
| Correctness workload profiles | IMPLEMENTED | counter/bank-transfer/inventory/document-editing — assert the invariant, exit code 2 on violation; a seeded-violation negative test (--inject-fault) proves the checker bites |
| Release comparison | IMPLEMENTED | compare post-processes two archived JSON records into a PASS/regression verdict |
Exit-code contract, --json sole payload, p90/p95 | IMPLEMENTED | 0–4 exit table, --json, widened percentiles + ok/conflict/failure split |
| Cold-read / scale-to-zero scenario (Exp 5) | SHIPPED | twill-bench scale-to-zero: controller-driven cold-boot distribution + serverless-efficiency from pulled ControllerStats deltas |
| Compute / scheduler / serverless-efficiency metrics | SHIPPED | scale-to-zero emits the full derived family (utilization, compute_seconds_per_query, storage_reads_per_query, scale_to_zero_count, avg_worker_lifetime) from pulled ControllerStats + EngineStats deltas; vocabulary shared with a future OTLP export (#53) — bench consumes, engine never generates |
| Storage counters, latency breakdown | DESIGN RESOLVED | read-only Storage::stats() snapshot, backend-neutral, additive (#53); seam-safe |
| YAML custom profiles | SHIPPED | custom --profile workload.yaml (issue #81): feature-gated (custom-profile) hand-rolled YAML loader → the shared mix driver; schema above; default build excludes it (rebuild hint + CI build matrix); no third-party dependency |
| Prometheus export | NEEDS DESIGN | CLI-only, feature-gated dep; keep out of engine core |
Constraints & open questions
- MUST NOT let any reporting feature (YAML, Prometheus, OTLP) add a dependency to
crates/engineor move a backend concept into theStoragetrait — these live in the bench/server/controller crates only. - MUST NOT generate lifecycle/compute metrics inside the thread-free engine; consume them from
twill-controllerand the server. - RESOLVED — yes, the compute/efficiency metrics share one signal vocabulary with the future observability/OTLP exporter. The set is settled in Observability design decision (#53): each tier exposes a pulled
stats()snapshot, the bench reports those names, and the live exporter emits the same names from the same snapshots. - SHOULD resolve: what is the safe-against-production guard? Read-only scenarios run anywhere; any mutating scenario requires an explicit opt-in flag and a non-production URL by default.
- MAY defer (future enhancements per the brief): multi-region runs, fault injection, network-latency simulation, storage-backend comparison, flamegraphs, continuous mode, a historical benchmark DB, HTML reports, and an interactive TUI.