Workload: Your Test Driver
- The Workload Trait
- The Three Phases
- Workloads Survive Everything
- The Context
- The Operation Alphabet
- Multiple Instances
- What a Good Workload Looks Like
A Workload exercises the system under test and judges its correctness. Processes are the code you ship. Workloads are the code that finds your bugs.
The Workload Trait
#[async_trait]
pub trait Workload: Send + Sync + 'static {
fn name(&self) -> &str;
async fn setup(&mut self, _ctx: &SimContext) -> SimulationResult<()> {
Ok(())
}
async fn run(&mut self, ctx: &SimContext) -> SimulationResult<()>;
async fn check(&mut self, _ctx: &SimContext) -> SimulationResult<()> {
Ok(())
}
}
Four methods, two with defaults. The lifecycle is strict: setup(), then run(), then check(). Each phase has its own rules.
The Three Phases
Setup: Prepare the Ground
setup() runs sequentially across workloads. Workload A’s setup completes before workload B’s starts.
Use it to find your servers, open connections and build data structures. When run() starts, every workload is ready.
async fn setup(&mut self, ctx: &SimContext) -> SimulationResult<()> {
let server = ctx
.topology()
.all_process_ips()
.first()
.cloned()
.ok_or_else(|| SimulationError::InvalidState("no servers".into()))?;
self.server = Some(server);
Ok(())
}
Do not hold a connection across setup if chaos is on. The server can reboot before run() starts. Connect inside run() and reconnect on error.
Run: Drive the System
run() is where the action is. All workloads run concurrently: several clients hitting the same servers, competing for resources, triggering races.
Send requests, observe responses, keep a reference model, and assert on every answer. The phase ends when every workload returns or when the runner cancels ctx.shutdown().
async fn run(&mut self, ctx: &SimContext) -> SimulationResult<()> {
for _ in 0..self.operations {
if ctx.shutdown().is_cancelled() {
break;
}
match self.random_op(ctx.random()) {
Op::Write { key, value } => {
self.send_write(ctx, &key, &value).await?;
self.model.insert(key, value);
}
Op::Read { key } => {
let got = self.send_read(ctx, &key).await?;
let expected = self.model.get(&key).cloned();
assert_always!(got == expected, "read returns the last committed write");
}
}
}
Ok(())
}
Check: Judge the Outcome
check() runs sequentially after every run() has finished and the event queue has drained. The system is quiescent. No message is in flight, no timer is pending.
Use it for final validation. Did the conservation law hold? Did every acknowledged write survive the reboots? The verdict counts: a check() that returns Err or panics fails the seed, exactly as a failing run() does, even when every run() returned Ok.
async fn check(&mut self, _ctx: &SimContext) -> SimulationResult<()> {
let total = self.model.values().sum::<i64>();
assert_always!(total == self.deposited - self.withdrawn, "money is conserved");
Ok(())
}
Workloads Survive Everything
This is the difference from processes. When the simulation kills a server, your workload keeps running. When a connection breaks, your workload reconnects. When the network partitions, your workload sees the errors and adapts.
Your workload remembers the whole run, across every reboot. That is how you verify recovery: the workload sent a write before the crash, the server rebooted, and the workload reads the value back.
A workload spawns tasks too, through ctx.task().spawn_task(). Those tasks carry no process scope, so they live as long as the workload does. A panic in a detached task is recorded against the workload and fails the seed unless the handle is awaited and the error handled.
The Context
Every phase receives a SimContext. The parts a workload uses:
| Call | What it gives |
|---|---|
ctx.my_ip() | this workload’s address, on 10.0.0.x |
ctx.client_id(), ctx.client_count() | this instance’s index and the instance count of its builder entry |
ctx.providers() | the SimProviders bundle, for code generic over P: Providers |
ctx.network(), ctx.time(), ctx.task(), ctx.random(), ctx.storage() | the individual providers |
ctx.topology() | all_process_ips(), ips_in_group(name), ips_tagged(key, value), locality |
ctx.peer(name), ctx.peers() | other workloads by name |
ctx.state() | shared state: publish(key, value) and get::<T>(key), read by invariants and other workloads |
ctx.shutdown() | the cancellation token the runner fires at the end of the run phase |
ctx.observability() | a TraceQuery over the captured timeline, for check() |
ctx.metrics_handle() | every node’s metrics sources, to read a server’s counters in check() |
ctx.disk_counters(ip) | the simulator’s ground-truth counters for a process’s disk |
ctx.system_samples(ip) | the last samples that process was handed by its SystemProvider |
ctx.slowness(ip) | the process’s current gray-failure factors, Slowness::HEALTHY when none |
The last three let a workload compare what a process reports about itself against what the simulator knows. A process that says “my disk is 90% busy” can be checked against ctx.disk_counters(ip). A workload that sees a slow answer can ask ctx.slowness(ip) whether the machine is in a gray failure, and assert that a healthy one answers fast. CPU Time and Gray Failures builds on this.
The Operation Alphabet
Strong workloads define an operation alphabet: the set of actions they can take. Deposit, withdraw, read, transfer, small delay. Each has a weight:
fn random_op(&self, random: &SimRandomProvider) -> Op {
match random.random_range(0..100) {
0..30 => Op::Deposit { .. },
30..50 => Op::Withdraw { .. },
50..70 => Op::Read { .. },
70..90 => Op::Transfer { .. },
_ => Op::SmallDelay,
}
}
The weights matter. Too many reads and you never test write conflicts. Too many writes and you never test read-after-crash. Cover normal operations, adversarial inputs, and delays that let background work run.
Then let the seeds vary the alphabet. With SimulationBuilder::swarm_operations(), each seed enables a random subset of up to 256 operation ids, and swarm_op_enabled(op_id) tells the workload which ones:
let enabled: Vec<Op> = ALPHABET
.iter()
.copied()
.filter(|op| swarm_op_enabled(op.id()))
.collect();
Why? Some bugs only appear when an operation is absent. A compaction that is never triggered, a read that never races a write. A single workload with a swarmed alphabet finds those across seeds. Many separate workloads, one per scenario, do not. Prefer one workload, many behaviours. Swarm Testing has the evidence, and Designing Workloads That Find Bugs has the patterns.
Multiple Instances
One client is rarely enough. workloads() builds several from a factory:
SimulationBuilder::new()
.workloads(WorkloadCount::Fixed(3), |i| Box::new(ClientWorkload::new(i)))
Each instance gets its own client_id() and its own IP. WorkloadCount::Random(1..6) draws a different count per seed from the simulation stream, so it stays deterministic per seed. The factory receives the index, and the workload is responsible for a unique name(), such as format!("client-{i}").
Factory workloads are also what check_determinism() and exploration need: an instance workload would carry its state from one timeline into the next.
What a Good Workload Looks Like
- Define an operation alphabet with weights, and swarm it.
- Keep a reference model of the expected state.
- Assert on every response with
assert_always!. - Mark interesting paths with
assert_sometimes!, so the run stops only when they fired. - Validate the final state in
check()against the model. - Publish facts through
ctx.state()and plaintracingevents, so invariants can check cross-workload properties.
Your First Simulation builds one from scratch.