Deterministic, seeded streams of operations for Jepsen-style workloads.
A generator emits a sequence of opaque "ops" — tuples or atoms
describing what a worker should do next. Workers pull from
generators in a loop; the operations themselves are recorded via
Lockstep.History.
Built-in generators
random/2— uniform random over a pool of ops, seeded so iterations are reproducible.cycle/1— fixed sequence repeated forever.weighted/2— biased random sampling.mix/2— interleave outputs from multiple generators round-robin.take/2— limit any generator to N ops total.
Quick example
gen =
Lockstep.Generator.random(
[:read, {:write, 1}, {:write, 2}, {:cas, 1, 2}],
seed: 42
)
|> Lockstep.Generator.take(50)
Lockstep.Generator.each(gen, fn op ->
Lockstep.History.op(history, op_kind(op), op_args(op), fn ->
apply_op(reg, op)
end)
end)Generators are immutable; next/1 returns {op, new_gen} or
:done. They DON'T allocate processes — pass a generator into a
closure and the worker advances it locally with no controller
involvement.
Seeding
When a generator is created without an explicit :seed, it
derives one from :erlang.unique_integer/1 — fine for one-off
use, but iterations may diverge. For Jepsen-style reproducibility,
pass an explicit seed (typically derived from the iteration's seed
via phash2({iter_seed, worker_id})).
Summary
Functions
Fixed sequence repeated forever (until take/2-limited).
Iterate gen to exhaustion, calling fun.(op) for each op.
Returns the count of ops produced.
Round-robin interleave outputs from gens. When any child is
exhausted, this generator becomes done.
Uniform random sampling over ops with replacement. Pass :seed
to make the stream reproducible across runs.
Reduce over gen's ops to exhaustion. fun.(op, acc) returns
the new acc. Useful for accumulating results without an external
side effect.
Cap any generator at n total ops. After n calls to next/1,
the generator returns :done.
Weighted random sampling. weighted_ops is a list of {op, weight}
tuples. Higher weight = more likely to be picked.
Types
@type t() :: %Lockstep.Generator{ children: [t()] | nil, kind: :random | :cycle | :weighted | :mix, next_child: non_neg_integer() | nil, ops: [any()] | nil, remaining: non_neg_integer() | :infinity, seed: any(), total: non_neg_integer() | nil, weights: [non_neg_integer()] | nil }
Functions
Fixed sequence repeated forever (until take/2-limited).
@spec each(t(), (any() -> any())) :: non_neg_integer()
Iterate gen to exhaustion, calling fun.(op) for each op.
Returns the count of ops produced.
Round-robin interleave outputs from gens. When any child is
exhausted, this generator becomes done.
Pull the next op. Returns {op, new_gen} or :done when the
generator has been exhausted (only happens after take/2 or when
mix/1's last child finishes).
Uniform random sampling over ops with replacement. Pass :seed
to make the stream reproducible across runs.
Reduce over gen's ops to exhaustion. fun.(op, acc) returns
the new acc. Useful for accumulating results without an external
side effect.
@spec take(t(), non_neg_integer()) :: t()
Cap any generator at n total ops. After n calls to next/1,
the generator returns :done.
@spec weighted( [{any(), pos_integer()}], keyword() ) :: t()
Weighted random sampling. weighted_ops is a list of {op, weight}
tuples. Higher weight = more likely to be picked.