Core
Shared variables
perfscale's variables: and step outputs are immutable and per-VU — one
VU cannot see another's values, and nothing can be updated mid-run. Shared
variables fill that gap: one run-scoped key/value store with atomic
operations, mutated and read from steps with std/set_shared_variable@v1 /
std/get_shared_variable@v1. They express the coordination patterns
per-VU variables cannot:
- Shared counters — count events across all VUs (
increment). - Producer/consumer queues — one VU appends work items, others
popthem FIFO. - Barriers — a consumer blocks with
wait_foruntil N items have accumulated or a flag flips.
Every operation is a single atomic acquisition — there is no read-modify-write pair of steps to race, and no lock primitive to deadlock.
This page is the guide. Per-step parameters and outputs live in the actions reference.
Declaration (mandatory)
Shared variables are declared in the config file, next to variables:,
as a map of name → initial JSON value. The type is inferred from the initial
value and constrains which ops are legal:
# config.yaml
vus: 8
duration: 30s
shared_variables:
pending_orders: [] # list → append / pop / length_gte
approved_count: 0 # number → increment
last_error: null # any → set / get
A step referencing an undeclared name, or an op incompatible with the
declared type (increment on a list), fails configuration validation
before any VU starts — a typo never becomes a silent null mid-test. There
is no dynamic creation: steps only use declared names. At run start the
declared names are (re)set to their initial values.
Producer / consumer queue
One VU (or one test file) produces work items, the rest consume. append
returns the new list length; pop removes and returns the first element
(null on an empty list — gate on it with check: when emptiness is an
error, or use wait_for to block instead):
# config.yaml
shared_variables:
pending_orders: []
# producer.yaml — every iteration enqueues one order
steps:
- name: enqueue order
use: std/set_shared_variable@v1
with:
name: pending_orders
op: append
value: { id: "ord-${seq}", total: "${randf(10,100,2)}" }
# consumer.yaml — block up to 10s for an item, then take it
steps:
- name: dequeue order
use: std/get_shared_variable@v1
with:
name: pending_orders
op: pop
wait_for: { length_gte: 1, timeout_ms: 10000 }
extract: { order_id: $.id, total: $.total }
check:
body_contains: ord-
Payloads interpolate like any other step — ${seq}, ${randf(10, 100, 2)},
${{ vars.* }}, outputs of previous steps. extract pulls fields out of the
value with the same dotted-path syntax ($.foo.bar, $.[0].x) as
std/llm@v1's extract; each extracted key lands at the top level
of the step output, so outputs: + ${{ name.order_id }} works downstream.
Barrier: accumulate N items
A consumer that must not start until N producers have contributed — the
classic load-test rendezvous. wait_for blocks (polling with a small async
sleep, no spinning) until the condition holds:
steps:
- name: wait for batch of 10 orders
use: std/get_shared_variable@v1
with:
name: pending_orders
wait_for: { length_gte: 10, timeout_ms: 30000 }
wait_for takes exactly one condition — exists, equals: <json>, or
length_gte: <int> — plus timeout_ms (default 5000). On timeout the
step fails with an [err] line reporting the last observed value (the
same contract as std/pubsub@v1's subscribe timeout): a producer that fell
behind is the signal you are sizing for, not a silent pass. The wait time
lands in waited_ms and a shared_variable_wait_ms metric sample, so slow
barriers show up in the summary.
A shared counter pairs naturally with a barrier — increment returns the
new value:
# config.yaml
shared_variables:
approved_count: 0
steps:
- name: approve
use: std/set_shared_variable@v1
with: { name: approved_count, op: increment, value: 1 }
- name: proceed once 50 approvals are in
use: std/get_shared_variable@v1
with:
name: approved_count
wait_for: { equals: 50, timeout_ms: 60000 }
Drivers: memory vs redis
The backing store is pluggable behind a driver, chosen per step:
| Driver | Store |
|---|---|
memory (default) | Process-global map — shared by every VU in one process, no external service needed |
Vendor drivers — Redis, … — are a pro capability registered on the same
driver seam; an unknown driver value fails the step with the list of
registered drivers, so it is always clear which build you are running.
The memory store is per process: when a distributed run spreads VUs
across multiple perfscaled agents, each agent has its own store — an
append on agent A is invisible to agent B. Cross-agent coordination (a
cluster-wide queue or barrier) requires a networked driver such as Redis;
within one agent, memory is the fastest option and needs nothing running.
Limits
- No dynamic creation — undeclared names are config errors, so a test's
shared state is fully listed in
shared_variables:. popis FIFO and there is no peek-by-index; consumer-side assertions on payload fields go throughextractor astd/check@v1step.wait_foris polling, not a push subscription — keeptimeout_mstight in hot loops so a stuck producer fails fast.