Mutare.Runner.Partitions (mutare v0.1.0)

Copy Markdown View Source

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 [] (no extra env), and entry/2 is [] — so the partition feature is pure opt-in and inert by default. The pool is a small Agent holding the free tokens; Mutare.Runner owns its lifecycle (new/2stop/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.

Stop the pool (a no-op when disabled).

Check out a free partition, call fun with its env entries ([{env_name, "#{slot}"}]), and check the partition back in afterwards — even if fun raises. When disabled, calls fun.([]) (no extra env).

Types

t()

@opaque t()

Functions

entry(env_name, n)

@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"}]

max_concurrency(arg1, default)

@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

new(env_name, size)

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

stop(arg1)

@spec stop(t()) :: :ok

Stop the pool (a no-op when disabled).

with_slot(arg1, fun)

@spec with_slot(t(), ([{String.t(), String.t()}] -> result)) :: result
when result: var

Check out a free partition, call fun with its env entries ([{env_name, "#{slot}"}]), and check the partition back in afterwards — even if fun raises. When disabled, calls fun.([]) (no extra env).