Lockstep.Generator (Lockstep v0.1.0)

Copy Markdown View Source

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.

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.

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

t()

@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

cycle(ops)

@spec cycle([any()]) :: t()

Fixed sequence repeated forever (until take/2-limited).

each(gen, fun)

@spec each(t(), (any() -> any())) :: non_neg_integer()

Iterate gen to exhaustion, calling fun.(op) for each op. Returns the count of ops produced.

mix(gens)

@spec mix([t()]) :: t()

Round-robin interleave outputs from gens. When any child is exhausted, this generator becomes done.

next(gen)

@spec next(t()) :: {any(), t()} | :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).

random(ops, opts \\ [])

@spec random(
  [any()],
  keyword()
) :: t()

Uniform random sampling over ops with replacement. Pass :seed to make the stream reproducible across runs.

reduce(gen, acc, fun)

@spec reduce(t(), acc, (any(), acc -> acc)) :: acc when acc: var

Reduce over gen's ops to exhaustion. fun.(op, acc) returns the new acc. Useful for accumulating results without an external side effect.

take(gen, n)

@spec take(t(), non_neg_integer()) :: t()

Cap any generator at n total ops. After n calls to next/1, the generator returns :done.

weighted(weighted_ops, opts \\ [])

@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.