Process Supervision Strategies for Elixir Migration
This document outlines the process supervision strategies to implement when migrating the ebook conversion system from Ruby to Elixir. It provides concrete examples and patterns to leverage OTP's supervision capabilities for improved fault tolerance and system resilience.
Table of Contents
- Introduction to OTP Supervision
- Supervision Tree Design
- Supervisor Strategies
- Migration Examples
- Best Practices
- Testing Supervision
Introduction to OTP Supervision
Ruby vs. Elixir Error Handling
In our Ruby implementation, error handling typically follows these patterns:
# Pattern 1: Try/rescue with explicit error handling
def convert_to_pdf(manuscript)
begin
result = PdfConverter.convert(manuscript)
# Process result
return result
rescue PdfConversionError => e
logger.error("PDF conversion failed: #{e.message}")
notify_admin(e)
return nil
rescue => e
logger.error("Unexpected error: #{e.message}")
raise
end
end
# Pattern 2: Service objects with result objects
class FormatConversionService
def convert(manuscript, format)
result = Result.new
begin
# Conversion logic
result.success = true
result.output_path = output_path
rescue => e
result.success = false
result.error = e.message
logger.error("Conversion failed: #{e.message}")
end
result
end
end
In Elixir, we replace these patterns with OTP supervision, which provides:
- Process isolation: Failures in one component don't affect others
- Automatic restarts: Supervisors can restart crashed processes
- Declarative recovery: Define how components should recover from failures
- Fault tolerance: System continues functioning despite partial failures
Key OTP Supervision Concepts
- Supervisor: A process that monitors other processes (workers or supervisors)
- Child Specification: Defines how a child process is started and supervised
- Restart Strategy: Determines how and when to restart crashed processes
- Supervision Tree: Hierarchical arrangement of supervisors and workers
Supervision Tree Design
Designing a Supervision Tree for the Ebook Conversion System
The ebook conversion system can be organized into a supervision tree that reflects its domain structure:
Codexis.Application (Supervisor)
├── Codexis.BuildManager.Supervisor (Supervisor)
│ ├── Codexis.BuildManager.Registry (Worker)
│ ├── Codexis.BuildManager.BuildSupervisor (DynamicSupervisor)
│ │ └── Codexis.BuildManager.Build (Worker, dynamically created per build)
│ └── Codexis.BuildManager.QueueManager (Worker)
├── Codexis.FormatGeneration.Supervisor (Supervisor)
│ ├── Codexis.FormatGeneration.PdfGenerator (Worker)
│ ├── Codexis.FormatGeneration.EpubGenerator (Worker)
│ └── Codexis.FormatGeneration.MobiGenerator (Worker)
├── Codexis.Validation.Supervisor (Supervisor)
│ ├── Codexis.Validation.RuleStore (Worker)
│ └── Codexis.Validation.Validator (Worker)
└── Codexis.Tools.Supervisor (Supervisor)
├── Codexis.Tools.LatexTool (Worker)
├── Codexis.Tools.CalibreTool (Worker)
└── Codexis.Tools.KindlegenTool (Worker)
Implementation Example
defmodule Codexis.Application do
use Application
def start(_type, _args) do
children = [
# Build management supervision tree
{Codexis.BuildManager.Supervisor, []},
# Format generation supervision tree
{Codexis.FormatGeneration.Supervisor, []},
# Validation supervision tree
{Codexis.Validation.Supervisor, []},
# External tools supervision tree
{Codexis.Tools.Supervisor, []}
]
opts = [strategy: :one_for_one, name: Codexis.Supervisor]
Supervisor.start_link(children, opts)
end
end
defmodule Codexis.BuildManager.Supervisor do
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 = [
{Codexis.BuildManager.Registry, []},
{Codexis.BuildManager.BuildSupervisor, []},
{Codexis.BuildManager.QueueManager, []}
]
Supervisor.init(children, strategy: :one_for_all)
end
end
Supervisor Strategies
Available Strategies
- :one_for_one - If a child process terminates, only that process is restarted
- :one_for_all - If a child process terminates, all other child processes are terminated and restarted
- :rest_for_one - If a child process terminates, the terminated process and the rest of the processes started after it are terminated and restarted
Strategy Selection Guidelines
| Strategy | Use When | Example in Ebook System |
|---|---|---|
| :one_for_one | Processes are independent | Format generators that can operate independently |
| :one_for_all | Processes depend on each other's state | Build components that must be consistent with each other |
| :rest_for_one | Processes form a dependency chain | Workflow steps that must be executed in order |
Example: Format Generation Supervisor
defmodule Codexis.FormatGeneration.Supervisor do
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 = [
{Codexis.FormatGeneration.PdfGenerator, []},
{Codexis.FormatGeneration.EpubGenerator, []},
{Codexis.FormatGeneration.MobiGenerator, []}
]
# Use one_for_one because each format generator can operate independently
Supervisor.init(children, strategy: :one_for_one)
end
end
Example: Build Manager Supervisor
defmodule Codexis.BuildManager.Supervisor do
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 = [
{Codexis.BuildManager.Registry, []},
{Codexis.BuildManager.BuildSupervisor, []},
{Codexis.BuildManager.QueueManager, []}
]
# Use one_for_all because these components must maintain consistent state
Supervisor.init(children, strategy: :one_for_all)
end
end
Migration Examples
Example 1: Migrating Ruby Command Execution to Supervised Process
Ruby Implementation
class CommandExecutor
def execute(command)
begin
output = `#{command}`
if $?.success?
return {success: true, output: output}
else
return {success: false, error: "Command failed with status #{$?.exitstatus}"}
end
rescue => e
return {success: false, error: e.message}
end
end
end
Elixir Implementation with Supervision
# 1. Define the worker module
defmodule Codexis.Tools.CommandExecutor do
use GenServer
require Logger
# Client API
def start_link(opts) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
end
def execute(command) do
GenServer.call(__MODULE__, {:execute, command})
end
# Server callbacks
@impl true
def init(_opts) do
{:ok, %{}}
end
@impl true
def handle_call({:execute, command}, _from, state) do
result = case System.cmd("sh", ["-c", command], stderr_to_stdout: true) do
{output, 0} -> {:ok, output}
{output, status} -> {:error, "Command failed with status #{status}: #{output}"}
end
{:reply, result, state}
end
end
# 2. Add to supervisor
defmodule Codexis.Tools.Supervisor do
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 = [
{Codexis.Tools.CommandExecutor, []},
# Other tool-related workers...
]
Supervisor.init(children, strategy: :one_for_one)
end
end
Example 2: Migrating Format Generation with Error Recovery
Ruby Implementation
class FormatGenerator
def generate_all_formats(manuscript)
results = {}
[:pdf, :epub, :mobi].each do |format|
begin
results[format] = generate_format(manuscript, format)
rescue => e
logger.error("Failed to generate #{format}: #{e.message}")
results[format] = {success: false, error: e.message}
end
end
results
end
def generate_format(manuscript, format)
# Format-specific generation logic
# Returns {success: true/false, output_path: path}
end
end
Elixir Implementation with Supervision
# 1. Define a supervisor for format generation jobs
defmodule Codexis.FormatGeneration.JobSupervisor do
use DynamicSupervisor
def start_link(init_arg) do
DynamicSupervisor.start_link(__MODULE__, init_arg, name: __MODULE__)
end
@impl true
def init(_init_arg) do
DynamicSupervisor.init(strategy: :one_for_one)
end
def start_format_generation(manuscript, format) do
child_spec = {Codexis.FormatGeneration.Job, {manuscript, format}}
DynamicSupervisor.start_child(__MODULE__, child_spec)
end
end
# 2. Define the job worker
defmodule Codexis.FormatGeneration.Job do
use GenServer, restart: :transient
require Logger
def start_link({manuscript, format}) do
GenServer.start_link(__MODULE__, {manuscript, format})
end
@impl true
def init({manuscript, format}) do
# Trap exits so we can clean up if needed
Process.flag(:trap_exit, true)
# Start the generation asynchronously
send(self(), :generate)
{:ok, %{manuscript: manuscript, format: format, status: :starting}}
end
@impl true
def handle_info(:generate, state) do
%{manuscript: manuscript, format: format} = state
result = case format do
:pdf -> generate_pdf(manuscript)
:epub -> generate_epub(manuscript)
:mobi -> generate_mobi(manuscript)
end
# Store result in ETS or send to a result collector process
Codexis.FormatGeneration.ResultStore.store_result(manuscript.id, format, result)
# Job is complete, terminate normally
{:stop, :normal, %{state | status: :completed}}
end
defp generate_pdf(manuscript) do
# PDF generation logic
# Returns {:ok, output_path} or {:error, reason}
end
# Similar functions for other formats...
end
# 3. Define a result store
defmodule Codexis.FormatGeneration.ResultStore do
use GenServer
# Implementation to store and retrieve format generation results
end
# 4. Orchestrate the process
defmodule Codexis.FormatGeneration do
def generate_all_formats(manuscript) do
# Start jobs for each format
formats = [:pdf, :epub, :mobi]
Enum.each(formats, fn format ->
Codexis.FormatGeneration.JobSupervisor.start_format_generation(manuscript, format)
end)
# Return a task that will collect all results
Task.async(fn ->
# Wait for all formats to complete (with timeout)
wait_for_completion(manuscript.id, formats)
end)
end
defp wait_for_completion(manuscript_id, formats, timeout \\ 300_000) do
# Implementation to wait for and collect all results
end
end
Best Practices
1. Process Isolation and Fault Containment
- Isolate critical components: Place critical components under separate supervisors
- Contain failures: Design supervision trees to contain failures within subsystems
- Separate concerns: Group processes by domain responsibility
# Example: Isolating external tool interactions
defmodule Codexis.Tools.Supervisor do
use Supervisor
def init(_init_arg) do
children = [
# Each external tool gets its own supervised process
{Codexis.Tools.LatexTool, []},
{Codexis.Tools.CalibreTool, []},
{Codexis.Tools.KindlegenTool, []}
]
# If one tool crashes, others continue working
Supervisor.init(children, strategy: :one_for_one)
end
end
2. Graceful Degradation
- Implement fallbacks: Provide alternative paths when components fail
- Partial success handling: Design for partial completion of operations
- Feature toggles: Use runtime configuration to disable problematic features
defmodule Codexis.FormatGeneration do
def generate_formats(manuscript, requested_formats) do
# Try each format, but continue if some fail
results = Enum.reduce(requested_formats, %{}, fn format, acc ->
result = try_generate_format(manuscript, format)
Map.put(acc, format, result)
end)
# Return partial success if at least one format succeeded
if Enum.any?(results, fn {_, result} -> match?({:ok, _}, result) end) do
{:partial_success, results}
else
{:error, :all_formats_failed, results}
end
end
defp try_generate_format(manuscript, format) do
try do
# Attempt to generate the format
Codexis.FormatGeneration.Generator.generate(manuscript, format)
rescue
e ->
Logger.error("Failed to generate #{format}: #{Exception.message(e)}")
{:error, Exception.message(e)}
end
end
end
3. Restart Strategies
- Transient vs. permanent processes: Use
:transientfor task-like processes and:permanentfor service processes - Restart frequency limits: Configure
max_restartsandmax_secondsto prevent restart loops - Backoff strategies: Implement exponential backoff for external service connections
defmodule Codexis.Tools.ExternalServiceConnector do
use GenServer, restart: :transient
def init(opts) do
# Configure backoff strategy
state = %{
service: opts[:service],
backoff: %{current: 1_000, max: 30_000, factor: 2},
status: :disconnected
}
# Try to connect immediately
send(self(), :connect)
{:ok, state}
end
def handle_info(:connect, state) do
case connect_to_service(state.service) do
{:ok, conn} ->
# Reset backoff on successful connection
{:noreply, %{state | status: :connected, connection: conn, backoff: %{state.backoff | current: 1_000}}}
{:error, reason} ->
# Schedule reconnect with backoff
next_backoff = min(state.backoff.current * state.backoff.factor, state.backoff.max)
Logger.warn("Failed to connect to #{state.service}: #{reason}. Retrying in #{next_backoff}ms")
Process.send_after(self(), :connect, next_backoff)
{:noreply, %{state | backoff: %{state.backoff | current: next_backoff}}}
end
end
end
4. Child Specification Customization
- Customize restart behavior: Adjust
:restartoption based on process type - Set shutdown timeouts: Configure
:shutdownoption for graceful termination - Specify process type: Use
:workeror:supervisorto clarify process role
defmodule Codexis.BuildManager.Build do
use GenServer, restart: :transient
def child_spec(args) do
%{
id: __MODULE__,
start: {__MODULE__, :start_link, [args]},
restart: :transient, # Only restart if terminated abnormally
shutdown: 10_000, # Allow 10 seconds for cleanup
type: :worker
}
end
# Rest of implementation...
end
Testing Supervision
1. Testing Supervisor Configuration
defmodule Codexis.BuildManager.SupervisorTest do
use ExUnit.Case, async: true
test "supervisor uses correct strategy" do
# Start the supervisor
{:ok, pid} = Codexis.BuildManager.Supervisor.start_link([])
# Get supervisor information
{_, supervisor_config} = :sys.get_state(pid)
# Assert on the supervision strategy
assert supervisor_config.strategy == :one_for_all
# Assert on the number of children
assert length(supervisor_config.children) == 3
end
end
2. Testing Process Restarts
defmodule Codexis.Tools.SupervisorTest do
use ExUnit.Case
test "restarting a crashed tool process" do
# Start the supervisor
{:ok, sup_pid} = Codexis.Tools.Supervisor.start_link([])
# Get the PID of the LaTeX tool process
latex_pid = Process.whereis(Codexis.Tools.LatexTool)
assert is_pid(latex_pid)
# Monitor the process
ref = Process.monitor(latex_pid)
# Kill the process
Process.exit(latex_pid, :kill)
# Wait for DOWN message
assert_receive {:DOWN, ^ref, :process, ^latex_pid, :killed}
# Wait a bit for the supervisor to restart it
:timer.sleep(100)
# Verify the process was restarted with a new PID
new_latex_pid = Process.whereis(Codexis.Tools.LatexTool)
assert is_pid(new_latex_pid)
assert new_latex_pid != latex_pid
end
end
3. Testing Graceful Degradation
defmodule Codexis.FormatGeneration.GracefulDegradationTest do
use ExUnit.Case
test "continues with other formats when one fails" do
# Create a test manuscript
manuscript = %Codexis.Manuscript{id: "test-123", content: "test content"}
# Mock the format generators to make PDF fail but others succeed
mock_generators = %{
pdf: fn _ -> {:error, "PDF generation failed"} end,
epub: fn _ -> {:ok, "path/to/epub"} end,
mobi: fn _ -> {:ok, "path/to/mobi"} end
}
# Call the function with our mocks
results = Codexis.FormatGeneration.TestHelper.with_mock_generators(
mock_generators,
fn -> Codexis.FormatGeneration.generate_formats(manuscript, [:pdf, :epub, :mobi]) end
)
# Assert we got partial success
assert {:partial_success, format_results} = results
# Assert individual format results
assert {:error, "PDF generation failed"} = format_results.pdf
assert {:ok, "path/to/epub"} = format_results.epub
assert {:ok, "path/to/mobi"} = format_results.mobi
end
end
Conclusion
Effective supervision strategies are a cornerstone of reliable Elixir applications. By properly designing your supervision tree and selecting appropriate strategies, you can create a system that gracefully handles failures and provides a more robust user experience.
When migrating from Ruby to Elixir, focus on:
- Identifying components that should be isolated in separate processes
- Determining appropriate supervision strategies for each group of processes
- Implementing proper error handling and recovery mechanisms
- Testing your supervision strategies to ensure they behave as expected
By leveraging OTP's supervision capabilities, your ebook conversion system will gain significant improvements in fault tolerance and reliability compared to traditional error handling approaches in Ruby.