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

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.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]
  ]
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.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]
    ]
  ]
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.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:

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.