Hephaestus is modular by design. The core package provides the workflow engine with in-memory storage (ETS) and local execution (GenServer per instance) — enough to develop, test, and run single-node deployments. Extension packages plug into the same adapter contracts to add production capabilities without changing your workflow definitions or step implementations.
This guide explains what each extension does, when to use it, and how to set it up.
Choosing the right stack
| You need... | Packages | Why |
|---|---|---|
| Prototyping, tests, single-node | hephaestus | ETS storage + local runner. Zero external deps. |
| Workflows that survive restarts | hephaestus + hephaestus_ecto | Ecto adapter persists instances as JSONB in PostgreSQL. |
| Distributed execution, retries, job queues | hephaestus + hephaestus_ecto + hephaestus_oban | Oban runner replaces GenServer with durable, retryable jobs. |
| Full production stack | All three + built-in telemetry | Persistent, distributed, observable workflows. |
Start with core alone during development. Add extensions as your requirements grow — each one is a dependency swap, not a rewrite.
hephaestus_ecto
Ecto/PostgreSQL storage adapter. Replaces the built-in ETS storage with a single
workflow_instances table backed by JSONB and GIN indexing.
What it provides
HephaestusEcto.Storage— implements theHephaestus.Runtime.Storagebehaviour (get/1,put/1,delete/1,query/1).- Migration generator —
mix hephaestus_ecto.gen.migrationcreates theworkflow_instancestable with UUID primary key, B-tree indexes onworkflowandstatus, and a GIN index (jsonb_path_ops) on thestateJSONB column. - Serializer — handles round-trip conversion between Elixir types (atoms,
MapSets, DateTime) and JSONB-safe representations. Deserialization uses
String.to_existing_atom/1— no arbitrary atom creation from database values. - Named instances — multiple storage instances can coexist for multi-tenant setups.
When to use
- Your workflows must survive VM restarts or deployments.
- You need to query workflow state from outside the running application (admin dashboards, reporting, debugging).
- You're running multiple nodes and need a shared persistence layer.
Setup
Add to your mix.exs:
def deps do
[
{:hephaestus, "~> 0.2.0"},
{:hephaestus_ecto, "~> 0.1.0"}
]
endGenerate and run the migration:
mix hephaestus_ecto.gen.migration
mix ecto.migrate
Configure your engine module to use Ecto storage:
defmodule MyApp.Hephaestus do
use Hephaestus,
storage: {HephaestusEcto.Storage, repo: MyApp.Repo},
runner: Hephaestus.Runtime.Runner.Local
endEverything else stays the same — your workflows, steps, and supervision tree don't change. Instances are now persisted to PostgreSQL instead of ETS.
Workflow versioning in hephaestus 0.2.x does not require a new Ecto migration:
the resolved workflow_version is stored inside the serialized instance state.
Querying instances
The storage adapter supports filtering by status and workflow:
HephaestusEcto.Storage.query(status: :running)
HephaestusEcto.Storage.query(workflow: MyApp.OrderWorkflow)
HephaestusEcto.Storage.query(status: :waiting, workflow: MyApp.PaymentWorkflow)For advanced queries against the JSONB state column, use Ecto directly:
import Ecto.Query
from(i in HephaestusEcto.Schema.Instance,
where: fragment("state @> ?", ^%{"context" => %{"initial" => %{"order_id" => 123}}})
)
|> MyApp.Repo.all()Requirements
- PostgreSQL 9.4+ (JSONB and GIN indexes)
- Ecto SQL ~> 3.10
hephaestus_oban
Oban-based runner adapter. Replaces the built-in local runner (one GenServer per instance) with durable Oban jobs. Brings distributed execution, persistent job queues, automatic retries with backoff, and advisory-lock-based concurrency control.
Requires hephaestus_ecto — the Oban runner persists instances through the
Ecto storage adapter.
What it provides
HephaestusOban.Runner— implements theHephaestus.Runtime.Runnerbehaviour using three Oban workers.Three-worker architecture:
Worker Role Writes to Instance? AdvanceWorker Orchestrator. Reads step results, applies engine transitions, persists the instance. Serialized per instance via Oban unique + pg_advisory_xact_lock.Yes (single writer) ExecuteStepWorker Executes a single step. Writes result to step_resultstable, enqueues AdvanceWorker. Idempotent via existence check.No ResumeWorker Handles external events and durable timers. Writes to step_results, enqueues AdvanceWorker.No Zero-contention fan-out — during parallel execution, each step worker writes to an auxiliary
hephaestus_step_resultstable instead of the instance directly. The AdvanceWorker is the single writer, serialized via advisory lock.Failure handling — when a step exhausts all retries, the
FailureHandlertelemetry listener marks the workflow as:failedand cancels remaining jobs.Durable timers —
schedule_resume/3creates an Oban job that survives VM restarts, unlike the local runner's process-local timers.Job observability — automatically populates Oban job
metaandtagsfrom the workflow's__tags__/0and__metadata__/0(declared viause Hephaestus.Workflow, tags: [...], metadata: %{...}). Enables filtering in Oban Web by workflow type, instance ID, step name, or custom labels.Retry configuration — resolves with most-specific-wins priority:
Step.retry_config/0(per-step override)Workflow.default_retry_config/0(per-workflow default)- Library default:
%{max_attempts: 5, backoff: :exponential, max_backoff: 60_000}
When to use
- You need workflows to execute across multiple nodes (distributed).
- Steps must retry automatically on failure with configurable backoff.
- You need durable timers that survive deployments.
- You want Oban's job management UI (Oban Web) for monitoring workflow execution.
Setup
Add to your mix.exs:
def deps do
[
{:hephaestus, "~> 0.2.0"},
{:hephaestus_ecto, "~> 0.1.0"},
{:hephaestus_oban, "~> 0.1.0"}
]
endGenerate and run both migrations (Ecto migration must come first):
mix hephaestus_ecto.gen.migration
mix hephaestus_oban.gen.migration
mix ecto.migrate
Configure your engine module:
defmodule MyApp.Hephaestus do
use Hephaestus,
storage: {HephaestusEcto.Storage, repo: MyApp.Repo},
runner: {HephaestusOban.Runner, oban: MyApp.Oban}
endSet up your supervision tree:
def start(_type, _args) do
children = [
MyApp.Repo,
{Oban, name: MyApp.Oban, repo: MyApp.Repo, queues: [hephaestus: 10]},
MyApp.Hephaestus
]
Supervisor.start_link(children, strategy: :one_for_one)
endThe hephaestus: 10 queue configuration means up to 10 Oban jobs run
concurrently. In a fan-out of 20 steps, only 10 execute at once — the rest wait
in the queue. Adjust based on your workload.
Workflow versioning also requires no extra Oban migration. The runner reads the
resolved workflow module and workflow_version from the serialized instance state.
For fine-grained control, use separate queues for orchestration and execution:
{Oban, queues: [hephaestus_advance: 5, hephaestus_execute: 20]}Requirements
- Oban >= 2.14
- PostgreSQL (advisory locks and JSONB)
hephaestus_ecto~> 0.1.0
Combining extensions
The typical production stack uses both extensions together. Here's a complete setup:
# mix.exs
def deps do
[
{:hephaestus, "~> 0.2.0"},
{:hephaestus_ecto, "~> 0.1.0"},
{:hephaestus_oban, "~> 0.1.0"}
]
end# lib/my_app/hephaestus.ex
defmodule MyApp.Hephaestus do
use Hephaestus,
storage: {HephaestusEcto.Storage, repo: MyApp.Repo},
runner: {HephaestusOban.Runner, oban: MyApp.Oban}
end# lib/my_app/application.ex
def start(_type, _args) do
# Telemetry is built-in — just attach the log handler
Hephaestus.Telemetry.LogHandler.attach()
children = [
MyApp.Repo,
{Oban, name: MyApp.Oban, repo: MyApp.Repo, queues: [hephaestus: 10]},
MyApp.Hephaestus
]
Supervisor.start_link(children, strategy: :one_for_one)
endYour workflows and steps remain identical to the core-only version. The only
change is the adapter configuration in use Hephaestus and the supervision tree.
Migration path
Hephaestus is designed to grow with your application:
Start with core alone. Use ETS storage and the local runner during development and testing. No database, no external dependencies. Focus on getting your workflow logic right.
Add Ecto when you need persistence. When workflows must survive restarts or you need to query state externally, add
hephaestus_ecto. Swap the storage adapter — everything else stays the same.Add Oban when you need distribution. When you deploy to multiple nodes or need automatic retries and durable timers, add
hephaestus_oban. Swap the runner adapter.Enable telemetry when you need observability. Telemetry is built into the core since 0.1.5. Just call
Hephaestus.Telemetry.LogHandler.attach()in your application startup for structured logs, or plugHephaestus.Telemetry.Metrics.metrics()into your reporter for dashboards.
Each step is additive. You never need to rewrite workflows or steps — the adapter pattern ensures that the runtime changes underneath while your business logic stays exactly the same.