Appearance
Benchmarks
How to run and interpret Cyclone's benchmark suite.
How to Run Benchmarks
The benchmark build tag gates the benchmarks. The default go test does not start them. Without CYCLONETEST_DSN, the harness starts a disposable Postgres with Testcontainers. Set CYCLONETEST_DSN to measure against a real database.
sh
# Optional: measure against a real Postgres instead of a container
export CYCLONETEST_DSN='postgres://user:pass@localhost:5432/mydb?sslmode=disable'
# Throughput and soak profiles
go test -tags benchmark -run '^$' -bench BenchmarkSustainedThroughput -benchtime 1x .
go test -tags benchmark -run '^$' -bench BenchmarkLongRunningSoak -benchtime 1x .
# Graph-shape and maintenance profiles
go test -tags benchmark -run '^$' -bench 'BenchmarkDAG|BenchmarkRetentionSweep|BenchmarkClaimWithDeepBacklog' -benchtime 1x .
# Run with CPU profiling
go test -tags benchmark -run '^$' -bench BenchmarkSustainedThroughput -cpuprofile=cpu.pprof .
go tool pprof cpu.pprofThe defaults fit a smoke test of a few seconds. Environment knobs scale them to production-sized profiles. See the header comments in bench_test.go and bench_dag_test.go for the full list.
Benchmark Profiles
Short jobs
BenchmarkSustainedThroughput measures end-to-end throughput of a minimal job at a target enqueue rate: enqueue → claim → complete, with a no-op handler.
This tests engine overhead at maximum throughput: DB round trips, the claim/complete cycle, and LISTEN/NOTIFY latency.
What to measure:
- ops/sec (1 / (ns/op × 1e-9))
- Latency from enqueue to completion (wall time per op in the benchmark)
- DB connection utilization during the benchmark
Typical bottlenecks:
- DB latency (network between app and Postgres)
- Connection pool saturation (increase MaxConns or reduce workers)
- Autovacuum lag (dead tuples building up; check
pg_stat_user_tables)
Long-running soak
BenchmarkLongRunningSoak measures behavior over a sustained period with handlers that sleep for several seconds.
What to measure:
- Heartbeat renewal rate (a steady rate is the healthy result)
- Memory stability (goroutine count and connection count do not grow)
- Lease expiry rate (0 under normal load)
Large DAG advancement
BenchmarkDAGAdvancementWide, BenchmarkDAGAdvancementDeep, and BenchmarkDAGAdvancementFanIn take wide fan-out, deep chain, and high fan-in graphs to completion. Run advancement re-evaluates graph state on every transition. Total advancement work grows with node count times transition count. That growth is the quadratic risk for large workflows.
What to measure:
- Whole-run wall time as
CYCLONE_BENCH_DAG_NODESgrows (watch for super-linear scaling) Submitlatency and the bulk insert ofcyclone_stepsrows- Transaction size (lock contention is a fault)
Retention and claim scans
BenchmarkRetentionSweep seeds a cohort of aged terminal runs and measures PurgeTerminalRuns draining them in batches. BenchmarkClaimWithDeepBacklog measures claim throughput. The steps table also holds a large cohort of future-scheduled rows. That is the skewed-distribution case. The claim scan must stay index-selective. To capture query plans at these table sizes, start EXPLAIN (ANALYZE, BUFFERS) against the seeded schema while a benchmark is paused. Or execute the claim SQL and the retention SQL from queries.sql by hand.
Interpreting Results
text
BenchmarkSustainedThroughput-8 1000 1234567 ns/op1000: number of iterations.1234567 ns/op: 1.23ms per operation (one enqueue+complete cycle).- Throughput: 1 / 0.00123s ≈ 813 ops/sec with 8 workers.
Reading the right benchmark for your workload:
| Your workload | Relevant benchmark |
|---|---|
| High-volume short jobs | BenchmarkSustainedThroughput |
| Multi-step workflows | BenchmarkLargeDAGSubmit for submit; BenchmarkDAGAdvancementWide, BenchmarkDAGAdvancementDeep, and BenchmarkDAGAdvancementFanIn for execution |
| Long-running handlers | BenchmarkLongRunningSoak |
| Transactional enqueue | BenchmarkSustainedThroughput (enqueue → complete) |
| Retention and claim scans | BenchmarkRetentionSweep and BenchmarkClaimWithDeepBacklog |
Environment Variables
| Variable | Purpose |
|---|---|
CYCLONETEST_DSN | Postgres DSN; omit to use Testcontainers |
CYCLONE_BENCH_DURATION | Short-job window (default 5s) |
CYCLONE_BENCH_WORKERS | Short-job worker count (default 8) |
CYCLONE_BENCH_RATE | Target jobs/sec (default 50) |
CYCLONE_SOAK_HANDLERS | Concurrent long handlers (default 20) |
CYCLONE_SOAK_BODY | Synthetic body duration (default 2s) |
CYCLONE_SOAK_LEASE | Soak lease TTL (default 1s) |
CYCLONE_BENCH_DAG_NODES | Nodes per DAG shape (default 150) |
CYCLONE_BENCH_RETENTION_RUNS | Terminal runs to purge (default 2000) |
CYCLONE_BENCH_BACKLOG_ROWS | Future-scheduled rows polluting the claim scan (default 5000) |
CYCLONE_BENCH_CLAIMABLE | Claimable steps drained through the backlog (default 300) |
Hardware and Database Notes
Co-location puts Postgres on the same host as the benchmarks. That removes network latency and inflates throughput versus production. Execute the benchmarks in an environment that matches the production network topology.
Postgres on NVMe SSD: cyclone_steps is write-heavy. Spinning disk limits throughput compared to SSD.
Connection latency is the main determinant of short-job throughput. A 0.5ms RTT to Postgres allows about 2000 claim+complete cycles/sec with 1 worker. A 5ms RTT allows about 200. Use more workers to hide that latency.
DB configuration for benchmarks:
shared_buffers = 256MBminimumsynchronous_commit = on(keep for accuracy; turn off only to explore theoretical limits)max_connectionsmust accommodate all benchmark workers plus pool reserve