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-bench crate, 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 records

Benchmark 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.

ScenarioShapeVerification status
read-heavy90% SELECT / 10% INSERT — analytical-leaning mixIMPLEMENTED ratio-controlled mix over a pre-seeded working set
write-heavy20% SELECT / 80% INSERT — ingestionIMPLEMENTED same mix driver, ingestion weights
mixed-oltp70% SELECT / 20% INSERT / 8% UPDATE / 2% DELETE — SaaSIMPLEMENTED all four op kinds in the driver loop, deterministic per-writer PRNG
burstidle → 500 → 5k → 20k rps → idle, repeat; measures cold/warm starts + scaling latencySHIPPED (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-zeroquery → idle past the reaper → query; measures cold boot, compute reuse, cache restoreSHIPPED spec 09 Exp 5 (cold read): controller-driven, reports the cold-boot percentile distribution + the serverless-efficiency figures from pulled ControllerStats deltas
long-runhours/days; detects memory/resource/connection leaks, scheduler driftSHIPPED 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)
customuser-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: 2

The 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.

FieldRequiredMeaning
duration_msyes (> 0)timed measurement window
connectionsyes (>= 1)concurrent writers
mix.{select,insert,update,delete}yes (≥1 non-zero)per-op ratio weights over the four op kinds
urlvia profile or --urlbackend URL; a CLI --url overrides it, and absence of both is a config error
warmup_ms, rows, seed, labelnowarm-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):

AccessorTier / todaySnapshot fields (all backend-neutral)
Storage::stats() → StorageStats LANDEDcrates/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 LANDEDcrates/engine — folds group_commit_stats() + the embedded StorageStats pulled through the seamcommits, durable_appends (→ coalescing ratio), committed_lsn (gauge), storage (the backend snapshot); conflict/commit-latency counters layer on later
Controller::stats() → ControllerStats LANDEDcrates/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 LANDEDcrates/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 snapshotController::stats() in-process for an embedded/controller-driven run; SHOW twill.stats over pgwire for a deployed server. The bench samples it at start and end of a scenario (and on an interval for long-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:

SegmentStatusSource
total (end-to-end)OBSERVABLE TODAYbench driver, client-side (the existing per-op histogram)
client RTT, TLS handshakeOBSERVABLE TODAYbench driver, pgwire transport only
auth≈ 0cleartext pgwire subset — negligible, not separately instrumented
commit (durable round-trip)OBSERVABLE TODAYthis is the Exp 1–3 measurement; EngineStats.commit_latency_us formalizes it
executionOBSERVABLE (engine)engine-side span, exposed via EngineStats once the accessor lands
storage readUNLOCKED BY Decision 1StorageStats.fetch_latency_us — blocked until the stats surface ships
scheduler / admissionUNLOCKED BY Decision 1ControllerStats.admission_wait_us (thundering-herd queue)
compute spin-up, metadata loadCOLD-PATH ONLYcontroller 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 / sourceSignals
query — bench, client-side SHIPS TODAYtwill_query_total, twill_query_ok, twill_query_conflict, twill_query_failed, twill_query_latency_us{p50…p999}, twill_throughput_per_s
commitEngineStatstwill_commit_total, twill_durable_append_total, twill_commit_coalescing_ratio, twill_commit_latency_us, twill_conflict_total, twill_committed_lsn
storageStorageStatstwill_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 / schedulerControllerStatstwill_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 onlytwill_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. 1 ✓ The stats surface is landed — the three in-process accessors (Storage::stats(), additive, STORAGE_TRAIT_VERSION bumped 2→3, C1–C8 green; Database::stats(), folding the backend snapshot pulled through the seam; Controller::stats()) plus the SHOW twill.stats pgwire 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 into SHOW twill.stats once a controller drives the deployment (step 2).
  2. 2 ✓ scale-to-zero (Exp 5) cold-read scenario is landed — the twill-bench scale-to-zero subcommand drives query → idle past the controller's reaper → query for --cycles cold-boot samples (--idle-ms sets the reaper window; spec-09 Exp 5 uses a long one on a real deployment), pulls the ControllerStats snapshot at the run boundaries, and reports the cold-boot percentile distribution against a controller-driven deployment. The compute_active/idle_us, admission_wait_us, and lease_renew_total signals landed in ControllerStats alongside 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. 3 ✓ The serverless-efficiency report is landed — the scale-to-zero run 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 from EngineStats.storage per cold read — 0 on the in-memory file:// path, nonzero on an object store with a cold cache), scale_to_zero_count, and avg_worker_lifetime = total resident time / cold starts, alongside the twill_worker_reuse_ratio and twill_storage_page_reads_total source 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. 4 ✓ The long-run soak scenario is landed (issue #80) — the interval sampler this decision anticipated (“…and on an interval for long-run”) pulls a stats() snapshot plus a Linux /proc/self resource probe (RSS, open fds, threads — degrading to zeros where /proc is unavailable, never a crash) every --sample-interval-ms into 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 a soak section (per-metric first/last/slope/peak/growth + a PASS/FAIL drift_pass verdict); a detected leak/drift fails the run with the correctness exit code (2). Embedded-only (it samples this process's own resources, like scale-to-zero). Unit-tested on the sampler cadence, the /proc parser, 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

ProfileDrivesAsserts
bank-transfer IMPLEMENTEDconcurrent atomic transfers between two accountsACID, balance invariant (sum conserved), transaction correctness
counter IMPLEMENTEDthousands of concurrent incrementsatomicity, zero lost updates (generalizes exp3)
inventory IMPLEMENTEDconcurrent stock decrements (read → refuse-oversell → decrement, in a txn)no negative inventory (no oversell), optimistic-lock conflict handling
document-editing IMPLEMENTEDconcurrent client-side read-modify-write edits to one documentno 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-issueCommandWhat it lands
V-1exp1 (+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-2exp2-sweepThe 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-3exp3-shardThe 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-4herdThe 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-5boundary + CIThe 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--json emits only the one-line JSON record on stdout (for scripting / CI), suppressing the human summary.
  • NEEDS DESIGN--prometheus export for Grafana/CI. Keep the exporter dependency CLI-only and feature-gated; it must never reach the engine.

Exit codes

CodeMeaning
0success
1benchmark failed
2correctness validation failed
3configuration error
4connection 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.

CapabilityStatusNotes
p50/p99/p999 via HDR histogram, JSON record, git SHAIMPLEMENTEDexp1/exp2/exp3, embedded + pgwire, file:// + s3://
Group-commit + contention experimentsIMPLEMENTEDmaps to spec 09 Exp 1–3; conflict retry proven
Crash-safety gateIMPLEMENTEDspec 09 Exp 4, in crates/storage (CI gate), not yet a bench subcommand
Request-mix scenarios (read/write/mixed)IMPLEMENTEDread-heavy/write-heavy/mixed-oltp — ratio-controlled SELECT/INSERT/UPDATE/DELETE over a seeded working set
Correctness workload profilesIMPLEMENTEDcounter/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 comparisonIMPLEMENTEDcompare post-processes two archived JSON records into a PASS/regression verdict
Exit-code contract, --json sole payload, p90/p95IMPLEMENTED0–4 exit table, --json, widened percentiles + ok/conflict/failure split
Cold-read / scale-to-zero scenario (Exp 5)SHIPPEDtwill-bench scale-to-zero: controller-driven cold-boot distribution + serverless-efficiency from pulled ControllerStats deltas
Compute / scheduler / serverless-efficiency metricsSHIPPEDscale-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 breakdownDESIGN RESOLVEDread-only Storage::stats() snapshot, backend-neutral, additive (#53); seam-safe
YAML custom profilesSHIPPEDcustom --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 exportNEEDS DESIGNCLI-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/engine or move a backend concept into the Storage trait — these live in the bench/server/controller crates only.
  • MUST NOT generate lifecycle/compute metrics inside the thread-free engine; consume them from twill-controller and 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.

Related specifications

Serverless OLTP Engine — internal development specification. Draft, 2026-06-21. · Author