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_rand('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')
)
}
}run_weights:
check_balance: 50
make_transfer: 50
run:
- name: check_balance
type: query
args: [ref_rand('fetch_accounts').id]
query: SELECT balance FROM account WHERE id = $1::UUID
- transaction: make_transfer
locals:
amount: gen('number:1,100')
queries:
- name: read_balance
type: query
args: [ref_diff('fetch_accounts').id]
query: SELECT id, balance FROM account WHERE id = $1::UUID
- name: debit
type: exec
args:
- ref_same('read_balance').id
- local('amount')
query: UPDATE account SET balance = balance - $2::FLOAT WHERE id = $1::UUIDIn 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
}stages:
- name: ramp
workers: 1
duration: 10s
run_weights:
check_balance: 90
make_transfer: 10
- name: steady
workers: 10
duration: 30s
# Falls back to top-level run_weights
run_weights:
check_balance: 50
make_transfer: 50Resolution order:
- Stage-level
run_weightsif defined - Top-level
run_weightsif defined - 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)
}stages:
- name: main
workers: 10
duration: 24h
qps: 1000
run_weights:
check_balance: 40
make_transfer: 40
register_customer: 20In 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
--durationand--workersCLI flags override the stages config. If you pass either flag, stage-levelqpsandweightswill 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)
}stages:
- name: warmup
workers: 10
duration: 30s
ramp_duration: 10s
- name: sustain
workers: 50
duration: 2m
ramp_duration: 15s
qps: 1000In 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:
- name: reap_expired_leases
rate: 1/5s
type: exec
query: |-
UPDATE runs
SET status = 'pending', worker_id = NULL
WHERE status IN ('claimed', 'running')
AND lease_expires_at < now()
- name: refresh_stats
rate: 3/1m
type: query
query: SELECT count(*) AS total FROM eventsWorkers 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`
}workers:
- name: refresh_stats
ignore: true
rate: 1/10s
type: query
query: SELECT count(*) AS total FROM eventsDelay#
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`
}workers:
- name: add_index
delay: 30s
type: exec
query: CREATE INDEX IF NOT EXISTS idx_order_status ON orders (status)
- name: schema_migration
delay: 1m
type: exec
query: ALTER TABLE users ADD COLUMN IF NOT EXISTS last_login TIMESTAMPTZA 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:
| Example | Meaning |
|---|---|
1/10s | Once every 10 seconds |
3/1m | 3 times every minute / once every 20 seconds |
5/1m30s | 5 times every minute and a half / once every 18 seconds |
2/1s / 1/500ms | 2 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
rateproperty can achieve the same interval in a number of ways (e.g.2/1sand1/500msboth 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
runqueries, so they appear in progress output, the summary table, Prometheus metrics, andexpectations. - 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.