Run Weights & Workers#

Run Weights#

The optional run_weights map controls the workload mix during execution. Each key is a run item name (either a standalone query name or a transaction name), and the value is a relative weight. On each iteration, a single item is chosen by weighted random selection:

weights {
  check_balance = 50
  make_transfer = 50
}

run {
  check_balance `SELECT balance FROM account WHERE id = $1::UUID` (ref('fetch_accounts').id)

  transaction make_transfer {
    let amount = gen('number:1,100')
    read_balance `SELECT id, balance FROM account WHERE id = $1::UUID` (ref_diff('fetch_accounts').id)
    debit(type: exec) `UPDATE account SET balance = balance - $2::FLOAT WHERE id = $1::UUID` (
      ref_same('read_balance').id,
      local('amount')
    )
  }
}

In this example, each iteration picks either the standalone check_balance query (50% of the time) or the entire make_transfer transaction (50% of the time). When a transaction is selected, all its queries run inside a single BEGIN/COMMIT block.

If run_weights is omitted, all run items execute sequentially on each iteration.

Per-stage run weights#

When using stages, each stage can define its own run_weights to override the top-level weights for that phase. This lets you shift the workload mix alongside the worker count:

stages {
  ramp(workers: 1, duration: 10s, weights: {check_balance: 90, make_transfer: 10})
  steady(workers: 10, duration: 30s)
}

weights {
  check_balance = 50
  make_transfer = 50
}

Resolution order:

  1. Stage-level run_weights if defined
  2. Top-level run_weights if defined
  3. No weights - all run items execute sequentially

See examples/stages_run_weights/ for a complete working example.

Weights with QPS#

Combine weights and qps in a stage to control both the workload mix and the overall request rate. Each iteration picks a single item by weighted random selection, and the rate limiter caps total throughput across all workers:

stages {
  main(workers: 10, duration: 24h, weights: {check_balance: 40, make_transfer: 40, register_customer: 20}, qps: 1000)
}

In this example, 10 workers share a combined budget of 1000 queries per second. Each iteration picks check_balance (40%), make_transfer (40%), or register_customer (20%).

The --duration and --workers CLI flags override the stages config. If you pass either flag, stage-level qps and weights will be ignored and a warning is logged.

Ramp duration#

Each stage can specify a ramp_duration to linearly ramp up to full capacity instead of starting at full blast. The ramp behaviour depends on whether the stage uses qps:

  • With qps: All workers start immediately. QPS increases linearly from near-zero to the target rate over the ramp period.
  • Without qps: Workers are spawned incrementally over the ramp period (one at a time, evenly spaced).

ramp_duration must be less than duration.

let sustain_workers = int(coalesce(env_nil('SUSTAIN_WORKERS'), 50))

stages {
  warmup(workers: 10, duration: 30s, ramp_duration: 10s)
  sustain(workers: sustain_workers, duration: 2m, ramp_duration: 15s, qps: 1000)
}

In the warmup stage, workers are added one at a time over 10 seconds until all 10 are running. In the sustain stage, all 50 workers start immediately but QPS ramps linearly from ~10 to 1000 over 15 seconds.

Workers#

The workers section defines background queries that run independently on a fixed schedule alongside the main workload. Each worker is a regular query with an added rate field controlling execution frequency.

workers {
  reap_expired_leases(rate: 1/5s) `UPDATE runs
    SET status = 'pending', worker_id = NULL
    WHERE status IN ('claimed', 'running')
      AND lease_expires_at < now()`

  refresh_stats(rate: 3/1m) `SELECT count(*) AS total FROM events`
}

Workers are useful for background maintenance tasks that should run on a fixed cadence, independent of the main workload loop. For example: lease reapers, stats refreshers, cache warmers, or periodic cleanup jobs.

Ignore#

Setting ignore: true on a worker hides it from progress output and the summary table. The worker still executes normally and its stats are still available to Prometheus metrics and expectations.

workers {
  refresh_stats(ignore: true, rate: 1/10s) `SELECT count(*) AS total FROM events`
}

Delay#

A worker with delay instead of rate executes once after the specified duration, then stops. This is useful for mid-run events like schema changes, data migrations, or one-shot maintenance tasks.

workers {
  add_index(delay: 30s) `CREATE INDEX IF NOT EXISTS idx_order_status ON orders (status)`

  schema_migration(delay: 1m) `ALTER TABLE users ADD COLUMN IF NOT EXISTS last_login TIMESTAMPTZ`
}

A worker must specify either rate or delay, not both.

Rate#

The rate field specifies how many times the query executes per interval, using the format times/interval:

ExampleMeaning
1/10sOnce every 10 seconds
3/1m3 times every minute / once every 20 seconds
5/1m30s5 times every minute and a half / once every 18 seconds
2/1s / 1/500ms2 times every second

The interval uses Go duration syntax (s, ms, m, h). Executions are evenly spaced: 3/1m fires every 20 seconds, not 3 times at the start of each minute.

The rate property can achieve the same interval in a number of ways (e.g. 2/1s and 1/500ms both result in a worker that fires twice every second), so use whichever expresses your intent the best and is easiest to read.

Behaviour#

  • Each worker runs in its own goroutine with its own environment, so workers are safe to use with ref_* functions and prepared statements.
  • Worker query results flow into the same stats and metrics pipeline as run queries, so they appear in progress output, the summary table, Prometheus metrics, and expectations.
  • Workers support all the same fields as regular queries: type, args, prepared, object, ignore, request_timeout, etc.
  • In staged mode, workers run for the entire duration across all stages (not restarted per stage).
  • Workers respect context cancellation and stop when the workload finishes or is interrupted.

See examples/workers/ for a complete working example.

Stats#

The stats section defines periodic observability queries that run at a fixed rate alongside the main workload. Unlike workers (where each query runs in its own goroutine), stats queries sharing the block rate run sequentially on each tick - useful for lightweight monitoring queries that don’t need their own connection.

stats (rate: 1/5s) {
  read_stats(
    post_print: { key: 'total_mb',    value: to_mb(result().total_mb) },
    post_print: { key: 'old_mvcc_mb', value: to_mb(result().old_mvcc_mb) }
  ) `SELECT
      round(sum(range_size_mb)::DECIMAL, 2) AS total_mb,
      round(sum(range_size_mb - ((span_stats->>'live_bytes')::DECIMAL / 1048576))::DECIMAL, 2) AS old_mvcc_mb
    FROM [SHOW RANGES WITH TABLES, DETAILS]
    GROUP BY table_name
    ORDER BY old_mvcc_mb DESC`
}

Per-query rate override#

Individual queries can specify their own rate to override the block default. Queries with a per-query rate get their own goroutine and ticker:

stats (rate: 1/5s) {
  quick_check `SELECT count(*) FROM events`

  slow_check(rate: 1/30s) `SELECT pg_database_size(current_database())`
}

In this example, quick_check fires every 5 seconds (block rate), while slow_check fires every 30 seconds in its own goroutine.

Rate#

The rate field uses the same format as workers - see Rate above.

Include#

By default, stats queries are hidden from the progress table and TUI graph (they still execute and their post_print values appear in the Stats tab). Set include: true to show them alongside run and worker queries:

stats (rate: 1/5s, include: true) {
  read_stats `SELECT count(*) FROM events`
}

Charts#

A stats query with a print or post_print renders an inline chart below the TUI Stats table. Set type on the query to choose the shape:

ValueShape
line (default)A numeric value plotted over time
barThe frequency distribution of a categorical value
stats (rate: 1 / 1s) {
  node (type: bar) (
    post_print: { key: 'id', value: result().node_id }
  ) `SHOW node_id`
}

Add series: value to a line chart’s print expression to plot one line per distinct value instead, and window to aggregate over a sliding window rather than the whole run. See TUI stats charts.

Behaviour#

  • The block-level rate is required.
  • Queries sharing the block rate run sequentially in a single goroutine (one tick = all queries in order).
  • Queries with a per-query rate override run in their own goroutine, independent of the block rate.
  • By default, stats queries are hidden from the progress table and TUI table/graph. Set include: true to show them. Print Values (from post_print) always appear regardless of include.
  • Stats queries support all the same fields as regular queries: type, post_print, print, ignore, request_timeout, etc.
  • In staged mode, stats run for the entire duration across all stages (not restarted per stage).
  • Stats respect context cancellation and stop when the workload finishes or is interrupted.