Per-worker partition slots, for DB (or any resource) isolation across the concurrent mutant runs.
When :partition_env names an environment variable, every concurrently-running
mutant mix test process is given a distinct partition id under that name,
drawn from a bounded, recycled pool. The user's config/test.exs reads it to
pick a per-worker database:
config :my_app, MyApp.Repo,
database: "my_app_test#{System.get_env("MIX_TEST_PARTITION")}"— exactly the mix test --partitions convention (default name
MIX_TEST_PARTITION), so two live runs never collide on one database.
Why a pool, not rem(index, workers)
Task.async_stream hands each item no stable lane index, and rem(index, workers) is unsafe: tasks don't finish in index order, so task 0 (→
partition 1) can still be running when task workers (→ partition 1) starts —
two live runs on one database. So this is a checkout/checkin pool of
workers tokens (partitions 1..workers): a task checks out a free partition
before spawning mix, runs it (harness retries included, since they recurse
inside the same task), then checks it back in. A counting argument makes
checkout non-blocking: when a task checks out it is itself alive, so the other
alive tasks (≤ workers - 1) hold ≤ workers - 1 tokens, leaving ≥ 1 free.
The baseline and coverage probe run sequentially before the pool, so they take
a fixed partition (entry/2 with 1) rather than a pooled slot — there is no
concurrency to isolate there, and a partitioned suite still needs some valid
partition to find its database.
Disabled
A nil env name disables everything: new/2 returns :disabled, with_slot/2
yields nil (no slot), and entry/2 of the nil name and slot_entry/2 of
the nil slot are [] — so the partition feature is pure opt-in and inert by
default. The pool is a small Agent holding the free tokens; Mutare.Runner
manages its lifecycle (new/2 … stop/1).
Summary
Functions
Env entries for a fixed partition n (the baseline and coverage probe use
1): [] when partitioning is off (env_name is nil), else [{env_name, "#{n}"}]. Pure, so the env contract is unit-testable.
The Task.async_stream max_concurrency that keeps the non-blocking checkout
invariant: the pool size when partitioning is on (one token per lane), else
default (no pool, so nothing to bound — use the caller's worker count). Drive
max_concurrency from this so it can never drift from the token count.
Build a slot pool of size tokens (partitions 1..size) for the variable
env_name, or :disabled when env_name is nil. size is the worker count,
so there is exactly one token per concurrency lane.
Env entries for slot, a partition of pool's (a number with_slot/2 yielded, or one
a result recorded): [] for the disabled pool's nil, else entry/2 under the pool's
variable. The pool, not the options, names the variable, so a slot can never be
paired with a variable from a different configuration; a slot outside the pool raises.
Stop the pool (a no-op when disabled).
Check out a free partition, call fun with its number, and check the partition
back in afterwards — even if fun raises. slot_entry/2 turns the number into the
run's env entry, and a result records it, so a run's partition can be compared
with the others' afterwards. When disabled, calls fun.(nil).
Types
Functions
@spec entry(String.t() | nil, pos_integer()) :: [{String.t(), String.t()}]
Env entries for a fixed partition n (the baseline and coverage probe use
1): [] when partitioning is off (env_name is nil), else [{env_name, "#{n}"}]. Pure, so the env contract is unit-testable.
iex> Mutare.Runner.Partitions.entry(nil, 1)
[]
iex> Mutare.Runner.Partitions.entry("MIX_TEST_PARTITION", 3)
[{"MIX_TEST_PARTITION", "3"}]
@spec max_concurrency(t(), pos_integer()) :: pos_integer()
The Task.async_stream max_concurrency that keeps the non-blocking checkout
invariant: the pool size when partitioning is on (one token per lane), else
default (no pool, so nothing to bound — use the caller's worker count). Drive
max_concurrency from this so it can never drift from the token count.
iex> Mutare.Runner.Partitions.max_concurrency(:disabled, 8)
8
@spec new(String.t() | nil, pos_integer()) :: t()
Build a slot pool of size tokens (partitions 1..size) for the variable
env_name, or :disabled when env_name is nil. size is the worker count,
so there is exactly one token per concurrency lane.
@spec slot_entry(t(), pos_integer() | nil) :: [{String.t(), String.t()}]
Env entries for slot, a partition of pool's (a number with_slot/2 yielded, or one
a result recorded): [] for the disabled pool's nil, else entry/2 under the pool's
variable. The pool, not the options, names the variable, so a slot can never be
paired with a variable from a different configuration; a slot outside the pool raises.
@spec stop(t()) :: :ok
Stop the pool (a no-op when disabled).
@spec with_slot(t(), (pos_integer() | nil -> result)) :: result when result: var
Check out a free partition, call fun with its number, and check the partition
back in afterwards — even if fun raises. slot_entry/2 turns the number into the
run's env entry, and a result records it, so a run's partition can be compared
with the others' afterwards. When disabled, calls fun.(nil).