Date: 2025-03-01
Status
Accepted
Context
Our ebook conversion system relies heavily on external command-line tools such as LaTeX, Pandoc, Calibre, and others. In our current Ruby implementation, we use the CommandRunner and CommandExecutor classes to handle command execution, with features like:
- Error handling and logging
- Timeout management
- Working directory control
- Environment variable management
- Retry logic for flaky commands
- Platform-specific command handling
As we migrate to Elixir, we need to design a robust command execution pattern that:
- Leverages Elixir's concurrency model
- Provides proper error handling and recovery
- Integrates with our supervision strategy (ADR-0021)
- Supports timeout and cancellation
- Handles platform-specific commands
- Provides comprehensive logging and telemetry
Decision
We will implement a command execution pattern in Elixir with the following components:
1. Core Command Execution Module
defmodule BookProcessing.System.CommandExecutor do
@moduledoc """
Handles execution of system commands with proper error handling,
timeout management, and logging.
"""
@doc """
Executes a system command and returns the result.
## Options
* `:timeout` - Timeout in milliseconds (default: 30000)
* `:cd` - Working directory for the command
* `:env` - Environment variables as a map
* `:return_on_error` - Return error output instead of raising (default: false)
## Returns
* `{:ok, output}` - Command executed successfully
* `{:error, reason}` - Command failed and return_on_error is true
## Raises
* `BookProcessing.System.CommandError` - Command failed and return_on_error is false
"""
def execute(command, opts \\ [])
end
2. Command Execution GenServer
defmodule BookProcessing.System.CommandRunner do
@moduledoc """
GenServer for managing command execution with additional features
like retry logic, circuit breaking, and telemetry.
"""
use GenServer
# Client API
@doc """
Starts the CommandRunner.
"""
def start_link(opts \\ [])
@doc """
Runs a command with the given options.
"""
def run(command, opts \\ [])
@doc """
Runs a command with retry logic.
"""
def run_with_retry(command, opts \\ [])
@doc """
Checks if a command exists in the system PATH.
"""
def command_exists?(command)
# Server callbacks and implementation
end
3. Port-Based Command Execution
For long-running commands or those requiring interactive input/output:
defmodule BookProcessing.System.PortCommand do
@moduledoc """
Handles execution of commands via Erlang ports, allowing
for streaming output and interactive communication.
"""
@doc """
Executes a command via a port and returns a stream of output.
"""
def stream_command(command, opts \\ [])
@doc """
Executes a command via a port with the ability to send input.
"""
def interactive_command(command, opts \\ [])
end
4. Platform-Specific Command Handling
defmodule BookProcessing.System.PlatformCommands do
@moduledoc """
Handles platform-specific command execution.
"""
@doc """
Executes a platform-specific command based on the current OS.
## Examples
iex> PlatformCommands.run(%{
...> darwin: "open -a Preview file.pdf",
...> linux: "xdg-open file.pdf",
...> windows: "start file.pdf"
...> })
"""
def run(commands, opts \\ [])
@doc """
Returns the current platform identifier (:darwin, :linux, :windows).
"""
def current_platform
end
5. Command Execution Supervisor
defmodule BookProcessing.System.Supervisor do
@moduledoc """
Supervises command execution processes.
"""
use Supervisor
def start_link(init_arg) do
Supervisor.start_link(__MODULE__, init_arg, name: __MODULE__)
end
@impl true
def init(_init_arg) do
children = [
{BookProcessing.System.CommandRunner, []},
{DynamicSupervisor, name: BookProcessing.System.CommandSupervisor, strategy: :one_for_one}
]
Supervisor.init(children, strategy: :one_for_one)
end
end
6. Command Execution Telemetry
defmodule BookProcessing.System.CommandTelemetry do
@moduledoc """
Telemetry events for command execution.
"""
@doc """
Emits telemetry events for command execution.
"""
def emit_event(event, measurements, metadata)
# Event definitions
def start_event, do: [:book_processing, :command, :start]
def stop_event, do: [:book_processing, :command, :stop]
def error_event, do: [:book_processing, :command, :error]
end
7. Error Handling
defmodule BookProcessing.System.CommandError do
@moduledoc """
Error raised when a command fails.
"""
defexception [:message, :command, :output, :exit_code]
@doc """
Creates a new CommandError.
"""
def new(command, output, exit_code) do
%__MODULE__{
message: "Command failed with exit code #{exit_code}: #{command}",
command: command,
output: output,
exit_code: exit_code
}
end
end
Implementation Example
Basic Command Execution
defmodule BookProcessing.System.CommandExecutor do
require Logger
def execute(command, opts \\ []) do
timeout = Keyword.get(opts, :timeout, 30_000)
cd = Keyword.get(opts, :cd)
env = Keyword.get(opts, :env, %{})
return_on_error = Keyword.get(opts, :return_on_error, false)
Logger.debug("Executing command: #{command}")
# Prepare options for System.cmd
cmd_opts = [
stderr_to_stdout: true,
into: "",
cd: cd,
env: env
]
# Add timeout if specified
cmd_opts = if timeout, do: [{:timeout, timeout} | cmd_opts], else: cmd_opts
# Execute the command
try do
{cmd, args} = parse_command(command)
{output, exit_code} = System.cmd(cmd, args, cmd_opts)
if exit_code == 0 do
Logger.debug("Command succeeded: #{command}")
{:ok, String.trim(output)}
else
Logger.error("Command failed (#{exit_code}): #{command}\nOutput: #{output}")
if return_on_error do
{:error, %{output: output, exit_code: exit_code}}
else
raise BookProcessing.System.CommandError.new(command, output, exit_code)
end
end
rescue
e in [ErlangError] ->
case e do
%ErlangError{original: :timeout} ->
Logger.error("Command timed out after #{timeout}ms: #{command}")
if return_on_error do
{:error, :timeout}
else
raise BookProcessing.System.CommandError.new(command, "Timed out after #{timeout}ms", :timeout)
end
_ ->
reraise e, __STACKTRACE__
end
end
end
defp parse_command(command) when is_binary(command) do
case String.split(command, " ", parts: 2) do
[cmd] -> {cmd, []}
[cmd, args] -> {cmd, String.split(args, " ")}
end
end
end
Command Runner GenServer
defmodule BookProcessing.System.CommandRunner do
use GenServer
require Logger
alias BookProcessing.System.CommandExecutor
alias BookProcessing.System.CommandTelemetry
# Client API
def start_link(opts \\ []) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
end
def run(command, opts \\ []) do
GenServer.call(__MODULE__, {:run, command, opts}, :infinity)
end
def run_with_retry(command, opts \\ []) do
max_retries = Keyword.get(opts, :max_retries, 3)
delay = Keyword.get(opts, :delay, 1000)
backoff = Keyword.get(opts, :backoff, 2.0)
GenServer.call(__MODULE__, {:run_with_retry, command, opts, max_retries, delay, backoff}, :infinity)
end
def command_exists?(command) do
GenServer.call(__MODULE__, {:command_exists, command})
end
# Server callbacks
@impl true
def init(opts) do
{:ok, %{opts: opts}}
end
@impl true
def handle_call({:run, command, opts}, _from, state) do
start_time = System.monotonic_time()
CommandTelemetry.emit_event(
CommandTelemetry.start_event(),
%{system_time: System.system_time()},
%{command: command}
)
result = CommandExecutor.execute(command, opts)
end_time = System.monotonic_time()
duration = System.convert_time_unit(end_time - start_time, :native, :millisecond)
case result do
{:ok, output} ->
CommandTelemetry.emit_event(
CommandTelemetry.stop_event(),
%{duration: duration},
%{command: command, result: :success}
)
{:reply, {:ok, output}, state}
{:error, reason} ->
CommandTelemetry.emit_event(
CommandTelemetry.error_event(),
%{duration: duration},
%{command: command, reason: reason}
)
{:reply, {:error, reason}, state}
end
end
@impl true
def handle_call({:run_with_retry, command, opts, max_retries, delay, backoff}, from, state) do
# Implementation of retry logic
{:reply, {:ok, "result"}, state}
end
@impl true
def handle_call({:command_exists, command}, _from, state) do
result = case :os.type() do
{:unix, _} ->
case CommandExecutor.execute("which #{command}", return_on_error: true) do
{:ok, _} -> true
{:error, _} -> false
end
{:win32, _} ->
case CommandExecutor.execute("where #{command}", return_on_error: true) do
{:ok, _} -> true
{:error, _} -> false
end
end
{:reply, result, state}
end
end
Consequences
Positive
- Concurrency: Leverages Elixir's concurrency model for parallel command execution
- Supervision: Integrates with OTP supervision for fault tolerance
- Telemetry: Provides comprehensive telemetry for monitoring and debugging
- Error handling: Structured error handling with detailed context
- Timeout management: Proper handling of command timeouts
- Retry logic: Built-in support for retrying flaky commands
- Platform abstraction: Consistent interface across different platforms
- Cancellation: Support for cancelling long-running commands
Negative
- Complexity: More complex than simple System.cmd calls
- Resource management: Need to manage port resources carefully
- Error propagation: Need to ensure errors are properly propagated
- Testing challenges: System commands are harder to test
- Security considerations: Command injection risks must be addressed
Mitigation Strategies
- Comprehensive testing: Create thorough tests for command execution
- Command sanitization: Implement proper command sanitization to prevent injection
- Resource monitoring: Monitor port usage to prevent resource leaks
- Timeout defaults: Set sensible default timeouts for different command types
- Mock interface: Create a mock interface for testing without executing real commands
- Documentation: Thoroughly document command execution patterns and best practices
References
- Elixir documentation on System.cmd and Port
- "Elixir in Action" by Saša Jurić (sections on ports and external processes)
- "Designing Elixir Systems with OTP" by James Edward Gray II and Bruce Tate
- Erlang documentation on ports and external program communication
- "Programming Erlang" by Joe Armstrong (sections on ports)