Operations
Benchmarking with novabench
The workload driver: how a run works, the load model, the verbs and their flags, the JSON result, and where to run it
novabench generates load against a Nova cluster through the public SDK over real gRPC and measures what a real caller sees. It never spawns stars and never touches internal packages; provisioning is yours, and it needs only seed addresses. Each run drives exactly one measured verb (append, read, or subscribe), so every number is attributable; mixed load is several processes side by side.
novabench <verb> -addr <seeds> [flags] [stream-id...]How a run works
- Setup: connect, create streams (
append) or validate targets (read,subscribe). A failure here is a setup failure: exit 3, no numbers. - Warmup (
-warmup, default10s): load flows, nothing counts. - Measured window (
-duration, default1m0s): counts, bytes, and latencies land in fixed-memory HDR histograms. - Drain and report: in-flight operations complete, the result goes to stdout, the run's streams are deleted (unless
-keep). The drain is bounded at the run's end plus the retry budget plus 5 seconds: acks still pending resolve as counted errors, never silently, and the exit code is 1.
The load model
-rate 0(default) is the closed loop: workers keep the pipeline full to find the ceiling. Use it for throughput targets. Latency percentiles under saturation include the queueing you created, by design.-rate Nis the open loop: operations issue on a fixed arrival schedule (N per second per stream or reader) and latency is measured from the scheduled send time, so a stalled server shows up as queueing delay in the tail instead of silently slowing the arrival rate. Use it for honest p99 tracking at a chosen load point.-spreadgives each stream a random phase inside its interval, for uniform aggregate arrival instead of synchronized bursts.
Shared flags
| Flag | Default | Meaning |
|---|---|---|
-addr | — | seed addresses, comma-separated; any star redirects |
-duration | 1m0s | measured run length |
-warmup | 10s | lead-in excluded from the stats |
-rate | 0 | calls/s per stream or reader; 0 is closed loop |
-retry-budget | 0s (SDK default, 30s) | the SDK's transparent-retry ceiling; failures shorter than it are invisible to the run; set it low for fault benchmarks so broken sessions surface as counted errors |
-progress | 10s | progress line cadence on stderr; 0 is off |
-start-at | — | a fleet-wide start barrier (UTC RFC3339): finish setup, hold, then begin warmup; aligned windows measure the fleet, offset ones a rolling wave |
-json | off | emit one JSON object instead of the summary |
-token | $NOVABENCH_TOKEN | bearer token for auth-armed clusters |
Verbs
append
Creates fresh streams and writes to them; the run owns its streams and deletes them on exit.
| Flag | Default | Meaning |
|---|---|---|
-streams | 1 | parallel streams, one producer each; a stream has one sequencer, so per-stream parallelism is the window |
-window | 32 | unacked append calls per producer (the closed loop's depth); the SDK coalesces calls into wire batches behind its own server-advertised window |
-inflight-budget | 512MiB | total staged append bytes across all producers; a stalled server paces the run at this bound instead of growing it, and stall time lands in backpressure_s |
-batch | 1 | payloads per append call |
-size | 256KiB | payload bytes per record (KiB/MiB/GiB suffixes) |
-header-count, -header-bytes | 0, 16 | headers per record and value bytes per header; 0 headers is the parity baseline |
-class | standard | standard or express |
-keep | off | retain the streams and report their IDs (kept_streams with -json) |
-spread | off | random per-stream phase within the rate interval |
Latency is issue to durable ack, per append call.
read
Ranged reads against existing streams, given as arguments (from a prior append -keep). The population run and the measurement run are separate on purpose: what happens between them (the tail settling, a rewrite, a star restart) is yours to arrange, and that is how you steer hot-tail versus cold-storage reads.
| Flag | Default | Meaning |
|---|---|---|
-readers | 1 | concurrent readers per stream |
-pattern | seq | seq scans from -from and wraps at the tail; random samples each read's start uniformly across the stream (cold-read traffic); it discovers the extent by point lookup, never a scan, and refuses an empty stream |
-from | 0 | start sequence number |
-limit | 1000 | records per read call |
Latency is per read call.
subscribe
Tails existing streams.
| Flag | Default | Meaning |
|---|---|---|
-subscribers | 1 | concurrent subscribers per stream |
-from | 0 | start sequence number; 0 is the full backlog |
Reports delivery throughput and per-record lag (assigned timestamp to receipt). Over a backlog that is catch-up age; beside a live append process it is end-to-end delivery lag.
A fourth verb, delete, tears down streams from an id list: cleanup, not a measurement.
Output
Human summary by default; -json writes exactly one JSON object to stdout (progress goes to stderr):
{
"verb": "append",
"config": {"streams": 4, "window": 32, "size": 262144, "rate": 0, "...": "..."},
"duration_s": 60,
"records": 75000, "bytes": 19660800000,
"throughput_mibps": 312.4, "ops_per_s": 1250,
"latency_ms": {"p50": 1.8, "p90": 2.9, "p99": 4.2, "p999": 9.1, "max": 15.0},
"errors": {"append": 0},
"kept_streams": ["<id>"]
}Baseline comparison lives outside the tool: store the JSON and diff runs. novabench measures; it does not judge.
jq '{mibps: .throughput_mibps, p99: .latency_ms.p99, errs: .errors}' ceiling.jsonReading the numbers honestly
errors > 0means exit 1. The numbers include failed operations; do not compare them against a clean baseline.ops_per_sversus records. An op is one append or read call; records are what landed: times-batchfor appends, up to-limitfor reads.- Closed-loop latency is queueing. At the ceiling, p99 measures the depth you chose with
-window, not the server. Use-ratefor latency. - One machine is a shape, not a number. Three stars on one laptop share one NIC, one disk under MinIO, and one oxia; use it to see how throughput scales with
-windowor-streams, not for the production figure. - Wire bytes are 2 to 3× client bytes. Append, read, and express fanout all cross the star's NIC; size
--net-capacityand machine choice from that, not fromthroughput_mibps.
Errors and exit codes
Operation failures mid-run are counted, never fatal: a run that aborted on the first shed could never characterize saturation. A permanently broken producer stops only its own worker.
| Exit | Meaning |
|---|---|
0 | clean run |
1 | completed with errors: numbers are suspect, JSON still emitted; CI must not ingest silently |
2 | usage error |
3 | setup failure: unreachable cluster, missing or empty stream |
Where to run it
A dev star, no infrastructure
go run ./cmd/novad --dev & # in-memory single star on :7780
go run ./cmd/novabench append -addr localhost:7780 -size 4KiB -duration 10s -warmup 2sThe --dev star keeps its object store in memory, so this exercises the full append path with nothing else running.
The compose stack
Bring up the compose stack with in-network advertise addresses and run the bench on the compose network; the host-port default routes every redirected byte through Docker's port forwarding, which is fine for a demo and wrong for a number:
NOVA_ADVERTISE_1=novad-1:7780 NOVA_ADVERTISE_2=novad-2:7780 NOVA_ADVERTISE_3=novad-3:7780 \
docker compose -f deploy/docker/docker-compose.yml up -d --build
docker run --rm --network nova_default your/bench-image \
append -addr novad-1:7780,novad-2:7780,novad-3:7780 \
-streams 4 -window 32 -size 256KiB -duration 60s -jsonA representative ladder, with SEEDS set to the three stars:
# 1. The append ceiling: closed loop
novabench append -addr $SEEDS -streams 4 -window 32 -size 256KiB -duration 60s -json > ceiling.json
# 2. Honest p99 at a fixed load point: 200 appends/s per stream
novabench append -addr $SEEDS -rate 200 -streams 2 -size 4KiB -duration 60s -json > p99.json
# 3. Express (on MinIO this exercises the fanout protocol, not express latency)
novabench append -addr $SEEDS -class express -streams 8 -size 64KiB -duration 60s
# 4. Populate, then read two ways
ID=$(novabench append -addr $SEEDS -keep -json -size 4KiB -duration 30s | jq -r '.kept_streams[0]')
novabench read -addr $SEEDS -pattern seq -duration 30s $ID
novabench read -addr $SEEDS -pattern random -limit 10 -duration 30s $ID
# 5. End-to-end delivery lag: a subscriber beside a live writer
novabench append -addr $SEEDS -rate 500 -size 1KiB -duration 90s -keep -json > writer.json &
novabench subscribe -addr $SEEDS -duration 60s -json <the writer's stream id>Watch the run on Grafana's Overview and Chain rows; nova_chain_backlog_stall_total moving during a bench names the bottleneck.
Fly
deploy/fly/bench.sh fans the bench out over N ephemeral machines inside the private network and aggregates their JSON; the seed -addr is injected. See Nova on Fly.io.
deploy/fly/bench.sh --org <your-org> --machines 3 -- append -streams 4 -size 64KiB -duration 60s -jsonKubernetes (Pulumi)
deploy/pulumi/bench.sh runs the bench as a Kubernetes Job (one fresh Job per invocation) and fans out --machines N identical pods, soft-spread across bench nodes. Give the generator its own pool first so it cannot perturb the numbers; the script exits non-zero on any failed pod:
pulumi config set benchNodeCount 1 && pulumi up
deploy/pulumi/bench.sh --machines 3 -- append -streams 8 -size 256KiB -duration 60s -json
pulumi config set benchNodeCount 0 && pulumi up # stop paying for the pool--skip-build reuses the last image; --cpu and --memory size the pods. Finished Jobs self-delete after 24 hours.
Source documents
cmd/novabench/README.mddeploy/fly/README.md,deploy/pulumi/README.mddocs/design/000-decisions.md(the novabench entry)