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.
- 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
- Raises with aggregated failure details in
run!/2 - 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
Add blitz to your dependencies.
Default install:
def deps do
[
{:blitz, "~> 0.1.0"}
]
endUse 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.1.0", runtime: false}
]
endUse 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.
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.1.0",
deps: deps(),
dialyzer: dialyzer()
]
end
defp deps do
[
{:blitz, "~> 0.1.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.
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 aggregated failure summary if any command exits non-zero.
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.1.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.1.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.
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)
|| 1
autodetect_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 = 2
Then:
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 = 4
Blitz 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 test==> root: mix test
==> core/contracts: mix test
root | ...
core/contracts | ...
<== core/contracts: ok in 241ms
<== root: ok in 613ms
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"}
)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
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_ms
Results are returned in the same order as the input command list even though the commands themselves run concurrently.
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))
endUse run!/2 when failure should stop execution immediately:
Blitz.run!(commands, max_concurrency: 4, timeout: 30_000)- 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
- Output is streamed as commands run instead of buffered until completion
- Failures are aggregated into a single
Blitz.Errorstructure - Missing executables or command launch errors are treated as failures
- Per-command
cdandenvvalues keep tasks isolated from each other Blitz.MixWorkspacekeeps repo-specific policy inmix.exs, not in bespoke runner modules
mix test
mix credo --strict
mix dialyzerBlitz is released under the MIT License. See LICENSE.