Blitz

Blitz logo

Hex.pm VersionHexDocsGitHub

Parallel command runner for Elixir tooling and Mix workspaces.

Blitz has two layers:

It stays intentionally local and predictable. Blitz is not a job system, workflow engine, or distributed scheduler.

Features

Installation

Add blitz to your dependencies.

Default install:

def deps do
  [
    {:blitz, "~> 0.3.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.3.0", runtime: false}
  ]
end

Use runtime: false when blitz is only there to power tooling such as:

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:

Example:

def project do
  [
    app: :my_workspace,
    version: "0.3.0",
    deps: deps(),
    dialyzer: dialyzer()
  ]
end

defp deps do
  [
    {:blitz, "~> 0.3.0", runtime: false}
  ]
end

defp dialyzer do
  [
    plt_add_deps: :apps_direct,
    plt_add_apps: [:mix, :blitz]
  ]
end

Why this is needed:

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:

Configure it in your root mix.exs:

def project do
  [
    app: :my_workspace,
    version: "0.3.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]
    ]
  ]
end

Then 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 6

color: 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.3.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.

Impact-Aware CI

Blitz.MixWorkspace.Impact adds a deterministic test-state layer on top of the normal workspace planner. It fingerprints each project, local dependency edges, mix.lock content, the command/environment, workspace configuration, Elixir/OTP, and the Blitz version. A command is skipped when that exact task state has a latest passed result, or when a dirty workspace change does not impact that project/task and the current state is covered by the latest clean baseline.

Persisted state defaults to:

.blitz/test_state_v1

Set BLITZ_TEST_STATE_DIR or pass --store-dir when CI should read/write a shared cache volume. The default store is compact: it keeps exact task-state indexes, a current clean baseline, and clean-pipeline manifests. It prunes stale local artifacts after successful multi-stage runs.

Use audit retention only when you need append-only result streams:

BLITZ_TEST_STATE_RETENTION=audit mix blitz.workspace.impact test

Run a dry plan:

mix blitz.workspace.impact test --dry-run
mix blitz.workspace.impact compile --base main --head HEAD
mix blitz.workspace.impact docs --force
mix blitz.test_state.prune

Task-specific arguments should be passed after --:

mix blitz.workspace.impact test --dry-run -- --seed 0

Impact-aware execution is memoization of verified task states, not a build cache. If the project files, dependency state, lockfile content, command, environment, workspace configuration, configured workspace invalidator files, Elixir/OTP, or Blitz version changes, the task state changes and the command runs again unless that exact state has already passed. For multi-stage callers using Blitz.MixWorkspace.Impact.run_many!/3, a clean workspace writes a clean baseline ledger and can also skip from a pipeline manifest before rebuilding every project fingerprint. Dirty workspaces use the baseline ledger plus the current git diff to avoid rerunning unimpacted project/task pairs. Decision records expose whether a skip came from an exact passed task state or the clean baseline, so downstream tests can assert the first dirty run without parsing terminal text.

See the guides for the full design:

Parallelism Model

Blitz.MixWorkspace keeps concurrency policy explicit and predictable.

The intended model is:

If you omit multiplier, Blitz defaults to :auto.

Each workspace task gets one effective max_concurrency value. Resolution order is:

  1. -j N or --max-concurrency N on the current invocation
  2. the configured environment override from parallelism.env
  3. the per-task value in parallelism.overrides
  4. round(base * resolved_multiplier) from parallelism.base and parallelism.multiplier
  5. fallback 1 if 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:

That keeps auto-scaling simple and legible:

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:

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:

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:

Task config keys:

env callbacks may be provided as:

The callback receives a context map with:

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)}
  ]
end

Isolation defaults:

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

Example Output

==> root: mix test
==> core/contracts: mix test
root | ...
core/contracts | ...
<== core/contracts: ok in 241ms
<== root: ok in 613ms

Command Shape

Blitz.command/1 accepts a map or keyword list with these fields:

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:

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:

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_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

Design Notes

Development

mix test
mix credo --strict
mix dialyzer

License

Blitz is released under the MIT License. See LICENSE.