Blitz
Parallel command runner for Elixir tooling and Mix workspaces.
Blitz has two layers:
Blitz.run/2andBlitz.run!/2for low-level parallel command fanoutBlitz.MixWorkspacefor config-drivenmixorchestration across many child projects
It stays intentionally local and predictable. Blitz is not a job system,
workflow engine, or distributed scheduler.
Features
-
Runs isolated OS commands concurrently with
Task.async_stream/3 -
Prefixes streamed output with a stable
id | ...label - Preserves input ordering in the returned result list
- Keeps a bounded per-command output tail for post-failure summaries
-
Raises with actionable aggregated failure details in
run!/2 - Distinguishes normal exits, startup errors, timeouts, and worker crashes
- Accepts per-command working directories and environment overrides
-
Ships a reusable
Blitz.MixWorkspacelayer for Mix monorepos - Supports config-driven parallelism with task weights, auto machine scaling, optional pinned multipliers, and per-task overrides
- Keeps child projects isolated with per-project deps/build/lockfile/Hex paths
Installation
Add blitz to your dependencies.
Default install:
def deps do
[
{:blitz, "~> 0.2.0"}
]
end
Use this when your project is happy to treat blitz like a normal dependency.
Tooling-only install for monorepo roots, internal Mix tasks, or workspace helpers:
def deps do
[
{:blitz, "~> 0.2.0", runtime: false}
]
end
Use runtime: false when blitz is only there to power tooling such as:
mix blitz.workspace ...- root-level Mix aliases
- custom Mix tasks
- repo-local helper modules that orchestrate child projects
This keeps blitz out of your runtime application startup while still making
its modules available to compile and run your tooling.
Do not automatically move blitz to only: [:dev, :test].
That is usually too narrow for workspace tooling, because repo-level commands
such as CI, docs, compile, or Dialyzer may still need blitz outside a local
test-only flow. If your project uses blitz for root tooling, runtime: false
is usually the right default. Add only: ... only when you are certain the
dependency is never needed outside those environments.
Dialyzer Note For Tooling-Only Installs
If you install blitz with runtime: false and your project keeps a narrow
Dialyzer PLT, you may need to add :blitz explicitly to plt_add_apps.
This commonly matters when your project:
-
calls
BlitzorBlitz.MixWorkspacedirectly from Mix tasks or helper modules -
uses
plt_add_deps: :apps_direct -
uses a small explicit
plt_add_appslist
Example:
def project do
[
app: :my_workspace,
version: "0.2.0",
deps: deps(),
dialyzer: dialyzer()
]
end
defp deps do
[
{:blitz, "~> 0.2.0", runtime: false}
]
end
defp dialyzer do
[
plt_add_deps: :apps_direct,
plt_add_apps: [:mix, :blitz]
]
endWhy this is needed:
runtime: falsemeans:blitzis not treated as a runtime application-
a restricted PLT may therefore omit
:blitz -
Dialyzer can then report
unknown_functionwarnings for calls likeBlitz.MixWorkspace.root_dir/0
If your Dialyzer setup already includes all needed deps or apps, no extra configuration is required.
Quick Start
Build command structs with Blitz.command/1 and execute them with Blitz.run/2
or Blitz.run!/2.
commands = [
Blitz.command(id: "root", command: "mix", args: ["test"], cd: "/repo"),
Blitz.command(id: "core/contracts", command: "mix", args: ["test"], cd: "/repo/core/contracts")
]
Blitz.run!(commands, max_concurrency: 2)
Each command streams output with a stable id | ... prefix and run!/2 raises
with an actionable failure summary if any command fails.
Mix Workspaces
Blitz.MixWorkspace moves the common Mix-monorepo concerns out of repo-local
wrapper code:
- project discovery
-
per-task
mixargs -
preflight
deps.getfor projects that still need deps -
isolated
MIX_DEPS_PATH,MIX_BUILD_PATH,MIX_LOCKFILE, andHEX_HOME - task-specific env hooks
- configurable parallelism per task family
Configure it in your root mix.exs:
def project do
[
app: :my_workspace,
version: "0.2.0",
deps: deps(),
aliases: aliases(),
blitz_workspace: blitz_workspace()
]
end
defp aliases do
[
"monorepo.test": ["blitz.workspace test"],
"monorepo.compile": ["blitz.workspace compile"]
]
end
defp blitz_workspace do
[
root: __DIR__,
projects: [".", "apps/*", "libs/*"],
parallelism: [
env: "MY_WORKSPACE_MAX_CONCURRENCY",
base: [deps_get: 3, format: 4, compile: 2, test: 2],
multiplier: :auto,
overrides: [dialyzer: 1]
],
tasks: [
deps_get: [args: ["deps.get"], preflight?: false],
format: [args: ["format"]],
compile: [args: ["compile", "--warnings-as-errors"]],
test: [args: ["test"], mix_env: "test", color: true]
]
]
endThen run:
mix blitz.workspace test
mix blitz.workspace test -j 6
mix blitz.workspace test -- --seed 0
mix monorepo.test
mix monorepo.test --seed 0
mix monorepo.test -j 6color: true injects --color for tasks that support it, which restores ANSI
output such as the normal ExUnit colors from mix test.
For tooling-root workspaces, the most common dependency shape is:
{:blitz, "~> 0.2.0", runtime: false}
If that project also keeps a narrow Dialyzer PLT, add :blitz to
plt_add_apps as shown in the installation section above.
Parallelism Model
Blitz.MixWorkspace keeps concurrency policy explicit and predictable.
The intended model is:
basedescribes relative task weightmultiplierdescribes machine sizeoverrideshandles exceptional tasks
If you omit multiplier, Blitz defaults to :auto.
Each workspace task gets one effective max_concurrency value. Resolution
order is:
-j Nor--max-concurrency Non the current invocation-
the configured environment override from
parallelism.env -
the per-task value in
parallelism.overrides round(base * resolved_multiplier)fromparallelism.baseandparallelism.multiplier-
fallback
1if the task has no configured base count
The formula is:
resolved_multiplier =
multiplier == :auto ? autodetect_multiplier() : multiplier
effective(task) =
cli_override
|| env_override
|| per_task_override
|| round(base[task] * resolved_multiplier)
|| 1autodetect_multiplier() uses the lower of a CPU class and a memory class:
-
CPU classes:
8 => 2,16 => 3,24 => 4,32 => 6 -
Memory classes:
16 GiB => 2,48 GiB => 3,96 GiB => 4,192 GiB => 6
That keeps auto-scaling simple and legible:
- a machine with more schedulers but not enough RAM does not get an inflated multiplier
- a machine with lots of RAM but modest CPU does not scale only on memory
Example with a pinned multiplier:
parallelism: [
env: "MY_WORKSPACE_MAX_CONCURRENCY",
multiplier: 2,
base: [
deps_get: 3,
format: 4,
compile: 2,
test: 2,
credo: 2,
dialyzer: 1,
docs: 1
],
overrides: []
]That produces these defaults:
deps_get = 6
format = 8
compile = 4
test = 4
credo = 4
dialyzer = 2
docs = 2Then:
mix blitz.workspace testuses4MY_WORKSPACE_MAX_CONCURRENCY=10 mix blitz.workspace testuses10mix blitz.workspace test -j 12uses12
Example with auto mode:
parallelism: [
base: [
deps_get: 3,
format: 4,
compile: 2,
test: 2,
credo: 2,
dialyzer: 1,
docs: 1
],
multiplier: :auto
]
On a machine with 24 schedulers and 160 GiB RAM, autodetect_multiplier()
returns 4, so that same policy becomes:
deps_get = 12
format = 16
compile = 8
test = 8
credo = 8
dialyzer = 4
docs = 4Blitz does not hardcode task-family counts. The library provides the auto
machine scaler and the precedence rules; your workspace still owns the task
weights. If you want a fixed policy, pin multiplier to a number in
mix.exs.
Why not make every task flat by default? Because the base counts are meant to describe task weight, while the multiplier describes machine size. In most workspaces:
deps.getandformatare relatively cheapcompile,test, andcredoalready create meaningful CPU, IO, or service pressure on their owndialyzeranddocsare usually the heaviest on memory and code loading
You can absolutely choose a flatter policy for a stronger machine. Blitz
does not prevent that. The defaults simply encode that these task families are
not equal in cost.
Workspace config keys:
rootsets the workspace root. It defaults to the current directory.projectsis an ordered list of literal paths and glob patterns. Only entries containing amix.exsfile are included.tasksdefines the named workspace tasks thatmix blitz.workspace <task>can run.parallelismconfigures computed concurrency per task family.isolationcontrols which child-project paths and env vars are isolated.
Task config keys:
argsis the childmixargv list, such as["test"]or["compile", "--warnings-as-errors"].mix_envselects the isolated build-path suffix used for the task. Use:inheritto derive it from the currentMIX_ENVor fall back todev.color: trueinjects--colorunless--coloror--no-coloris already present in the extra args.preflight?controls whether the task first runsdeps.getfor projects that have amix.lockbut nodepsdirectory. It defaults totruefor normal tasks andfalsefordeps_get.envadds task-specific environment overrides via a callback. Use it for values such asMIX_ENV, database names, or credentials.
env callbacks may be provided as:
fn context -> ... end{Module, :function}{Module, :function, extra_args}
The callback receives a context map with:
:project_path:project_root:root:task:task_config
Example task env hook:
defp blitz_workspace do
[
root: __DIR__,
projects: [".", "apps/*"],
tasks: [
deps_get: [args: ["deps.get"], preflight?: false],
test: [
args: ["test"],
mix_env: "test",
color: true,
env: &test_database_env/1
]
]
]
end
defp test_database_env(%{project_path: project_path}) do
[
{"PGDATABASE",
Blitz.MixWorkspace.hashed_project_name("my_workspace_test", project_path)}
]
endIsolation defaults:
MIX_DEPS_PATH=><project>/depsMIX_BUILD_PATH=><project>/_build/<mix_env>MIX_LOCKFILE=><project>/mix.lockHEX_HOME=><project>/_build/hexHEX_API_KEYis unset by default
Override or disable them with isolation:
blitz_workspace: [
root: __DIR__,
projects: [".", "apps/*"],
isolation: [
deps_path: true,
build_path: true,
lockfile: true,
hex_home: "_build/hex",
unset_env: ["HEX_API_KEY", "AWS_SESSION_TOKEN"]
],
tasks: [
deps_get: [args: ["deps.get"], preflight?: false],
test: [args: ["test"], mix_env: "test", color: true]
]
]
To override concurrency from the shell without changing mix.exs, set
parallelism.env:
parallelism: [
base: [test: 2, compile: 2],
multiplier: :auto,
env: "BLITZ_MAX_CONCURRENCY"
]Then run with:
BLITZ_MAX_CONCURRENCY=8 mix blitz.workspace testExample Output
==> root: mix test
==> core/contracts: mix test
root | ...
core/contracts | ...
<== core/contracts: ok in 241ms
<== root: ok in 613msCommand Shape
Blitz.command/1 accepts a map or keyword list with these fields:
:id- required stable label for logs and results:command- required executable name or absolute path:args- optional list of CLI arguments:cd- optional working directory:env- optional environment overrides as a keyword list or map
Example with environment overrides:
command =
Blitz.command(
id: "lint",
command: "mix",
args: ["format", "--check-formatted"],
cd: "/workspace/apps/core",
env: %{"MIX_ENV" => "test", "CI" => "true"}
)Run Options
Blitz.run/2 and Blitz.run!/2 accept these options:
:max_concurrency- defaults toSystem.schedulers_online():announce?- prints start and completion lines whentrue:prefix_output?- prefixes command output lines whentrue:timeout- per-task timeout passed toTask.async_stream/3; timed-out tasks are killed and reported as structured timeout failures
Return Values
Blitz.run/2 returns:
{:ok, [%Blitz.Result{}, ...]}on success, or:
{:error, %Blitz.Error{}}when one or more commands fail.
Each Blitz.Result contains:
idcommandargscdexit_codeduration_msoutput_tailfailure_kindfailure_reason
Results are returned in the same order as the input command list even though the commands themselves run concurrently.
output_tail keeps the last 50 rendered lines for that command without storing
the full log in memory.
failure_kind is nil for success and one of:
:exit:startup_error:timeout:worker_crash
exit_code is only set for normal process exits. The other failure kinds carry
their detail in failure_reason.
Failure Handling
Use run/2 when your caller wants to branch on success or failure:
case Blitz.run(commands, max_concurrency: 4) do
{:ok, results} ->
IO.inspect(results, label: "parallel run complete")
{:error, error} ->
IO.puts(Exception.message(error))
end
Use run!/2 when failure should stop execution immediately:
Blitz.run!(commands, max_concurrency: 4, timeout: 30_000)Example raised message:
parallel command run failed:
core/dispatch_runtime
exit: 1
cwd: /repo/core/dispatch_runtime
cmd: mix compile --warnings-as-errors
duration: 2143ms
output tail:
** (Mix) Can't continue due to errors on dependencies
Dependencies have diverged:
* libgraph ...Typical Use Cases
-
Running
mix testacross multiple umbrella children or sibling repos - Fanning out format, lint, or docs generation tasks in internal tooling
- Building lightweight orchestration around shell scripts without introducing a job system
- Keeping monorepo command output readable during local development or CI
Design Notes
- Output is streamed as commands run instead of buffered until completion
-
Failures are aggregated into a single
Blitz.Errorstructure with bounded excerpts for each failing command - Missing executables, timeouts, and worker crashes are reported distinctly from normal non-zero exits
-
Per-command
cdandenvvalues keep tasks isolated from each other Blitz.MixWorkspacekeeps repo-specific policy inmix.exs, not in bespoke runner modules
Development
mix test
mix credo --strict
mix dialyzerLicense
Blitz is released under the MIT License. See LICENSE.