Sig
Sig is a modern, resilient Workflow Engine library for Elixir, supporting Directed Acyclic Graph (DAG) flows, persistence with Ecto (PostgreSQL), and distributed queue execution with BullMQ (Redis).
🚀 Features
- Workflow & DAG Definition:
- Build flows with dependencies between nodes (
depends_on). - Automatic detection of cycles and missing dependencies (Kahn's Algorithm).
- Workflow-level context and params objects.
- Build flows with dependencies between nodes (
- Executions with Their Own Context:
- Each execution has its own context and params objects, which are merged hierarchically with those of the workflow and the node.
- Custom Node Modules (
run/1orrun/2):- User-defined nodes implementing
run/1(receiving a%Sig.NodeContext{}) orrun/2(receivingparams, context). - Node outputs (
node_results) are persisted and automatically shared with downstream nodes viacontext.results.
- User-defined nodes implementing
- Full Lifecycle Control:
- Suspend (
suspend_execution,suspend_workflow). - Resume (
resume_execution,resume_workflow). - Stop / Cancel (
stop_execution,stop_workflow).
- Suspend (
- Retries & Resilience:
- Configurable maximum number of attempts (
max_retries) per node. - Linear/exponential backoff between attempts.
- Attempt history and error logs stored in the database.
- Configurable maximum number of attempts (
- Idempotency:
- Execution level: an
idempotency_keyprevents accidental duplicate executions. - Node level: already completed nodes are not re-run when the flow is resumed.
- Execution level: an
- Execution Modes:
- Synchronous / Inline: ideal for scripts, tests, and direct inline processing.
- Asynchronous & Distributed: natively integrated with BullMQ and Redis for distribution across workers.
📥 Installation in a Phoenix application
Sig works like Oban: its tables are created by a migration in your own app, and it reads and writes them through your app's Ecto repo.
1. Add the dependency
# mix.exs
defp deps do
[
# ...
{:sig, "~> 0.1.0"}
]
end
mix deps.get
2. Create the migration
mix ecto.gen.migration add_sig_tables
# priv/repo/migrations/20261113000000_add_sig_tables.exs
defmodule MyApp.Repo.Migrations.AddSigTables do
use Ecto.Migration
def up, do: Sig.Migration.up(version: 1)
# version: 1 drops all of Sig's tables
def down, do: Sig.Migration.down(version: 1)
end
mix ecto.migrate
This creates the workflows, workflow_executions and node_executions
tables. Since it is a regular migration of MyApp.Repo, Phoenix's test
alias and your release's MyApp.Release.migrate/0 pick it up with no extra
setup.
When a future Sig release adds schema changes, add a new migration with
Sig.Migration.up(version: N) / Sig.Migration.down(version: N). Pass
prefix: "sig" to install the tables in another PostgreSQL schema (see
Sig.Migration).
3. Configure Sig
Like Oban, Sig does not start a repo of its own: it runs every query through your application's repo. Tell it which one to use:
# config/config.exs
config :sig, repo: MyApp.Repo
If you installed the tables in another schema with Sig.Migration.up(prefix: "sig"),
set the same prefix here:
config :sig, repo: MyApp.Repo, prefix: "sig"
That's all the database setup there is. Sig shares MyApp.Repo's connection
pool, its writes take part in your transactions (MyApp.Repo.transaction/1),
and in tests it uses the same SQL sandbox as the rest of your app, so your
existing DataCase works as is.
4. Start the BullMQ worker (optional)
Only needed to run executions with async: true. Configure Redis:
# config/dev.exs
config :sig, redis_url: "redis://localhost:6379"
# config/runtime.exs
if config_env() == :prod do
config :sig, redis_url: System.fetch_env!("REDIS_URL")
end
and add the supervisor to lib/my_app/application.ex, after your repo:
children = [
MyAppWeb.Telemetry,
MyApp.Repo,
# ...
Sig.BullMQ.Supervisor,
MyAppWeb.Endpoint
]
It accepts :redis_url, :queue_name and :concurrency options, e.g.
{Sig.BullMQ.Supervisor, concurrency: 10}.
In tests, BullMQ jobs run in the worker's processes, which do not own a sandbox connection. Prefer synchronous executions (the default) in tests, or put the sandbox in shared mode for tests that use
async: true.
5. Trigger a workflow from your app
Define your nodes under lib/my_app/ and create the workflow once (e.g. in
priv/repo/seeds.exs, or lazily on first use). Then start executions from a
controller, LiveView or context:
defmodule MyAppWeb.OrderController do
use MyAppWeb, :controller
def create(conn, %{"order_id" => order_id}) do
workflow_id = Application.fetch_env!(:my_app, :order_workflow_id)
{:ok, execution} =
Sig.run_execution(workflow_id, %{"order_id" => order_id}, %{},
idempotency_key: "order-#{order_id}",
async: true
)
json(conn, %{execution_id: execution.id, status: execution.status})
end
end
📦 Usage
1. Defining Node Modules
Each node is an Elixir module with a run/1 or run/2 function:
defmodule MyNodes.FetchUser do
use Sig.Node
@impl true
def run(context) do
user_id = context.params["user_id"]
# Return the result to be used by downstream nodes
{:ok, %{"user_id" => user_id, "name" => "Pedro", "balance" => 150}}
end
end
defmodule MyNodes.DebitAccount do
use Sig.Node
@impl true
def run(context) do
# Access the result of the previous 'fetch_user' node
user = context.results["fetch_user"]
amount = context.params["amount"] || 50
new_balance = user["balance"] - amount
{:ok, %{"new_balance" => new_balance, "debited" => amount}}
end
end
defmodule MyNodes.SendReceipt do
# Arity 2 is also supported: run(params, context)
def run(params, context) do
receipt = context.results["debit"]
email = context.context["user_email"] || "pedro@example.com"
prefix = params["prefix"] || "Receipt:"
{:ok, %{"receipt" => "#{prefix} Debit of #{receipt["debited"]} sent to #{email}"}}
end
end
2. Creating the Workflow
You can create it via the Sig.Flow DSL or by passing a map of attributes:
flow =
Sig.Flow.new("payment_pipeline",
params: %{"currency" => "BRL"},
context: %{"environment" => "production"}
)
|> Sig.Flow.add_node(:fetch_user, MyNodes.FetchUser)
|> Sig.Flow.add_node(:debit, MyNodes.DebitAccount,
depends_on: [:fetch_user],
max_retries: 3,
backoff_ms: 1000
)
|> Sig.Flow.add_node(:receipt, MyNodes.SendReceipt,
depends_on: [:debit]
)
{:ok, workflow} = Sig.create_workflow(flow)
3. Creating and Triggering Executions
Each execution has its own params and context:
Params, context and results are stored as JSONB, so inside a node all map keys are strings, even if you used atoms when defining them. Read them with
params["key"], notparams[:key].
# Direct execution with run_execution
{:ok, execution} =
Sig.run_execution(
workflow.id,
%{"user_id" => 10, "amount" => 40}, # Execution params
%{"user_email" => "pedro@email.com"}, # Execution context
idempotency_key: "tx-order-001" # Idempotency
)
IO.inspect(execution.status)
# => "completed"
IO.inspect(execution.node_results)
# => %{
# "fetch_user" => %{"balance" => 150, "name" => "Pedro", "user_id" => 10},
# "debit" => %{"debited" => 40, "new_balance" => 110},
# "receipt" => %{"receipt" => "Receipt: Debit of 40 sent to pedro@email.com"}
# }
4. Control Functions
Execution Level:
# Suspend
{:ok, execution} = Sig.suspend_execution(execution_id)
# Resume (picks up where it left off, without re-running completed nodes)
{:ok, execution} = Sig.resume_execution(execution_id)
# Stop / Cancel
{:ok, execution} = Sig.stop_execution(execution_id)
Workflow Level:
# Suspend the workflow and all in-progress executions
{:ok, workflow} = Sig.suspend_workflow(workflow_id)
# Resume the workflow
{:ok, workflow} = Sig.resume_workflow(workflow_id)
# Stop the workflow and halt its active executions
{:ok, workflow} = Sig.stop_workflow(workflow_id)
5. Queue Execution with BullMQ (Distributed)
To run asynchronously with BullMQ and Redis:
# Start the supervisor in your supervision tree, after your repo:
children = [
MyApp.Repo,
Sig.BullMQ.Supervisor
]
# Trigger the asynchronous execution:
{:ok, execution} = Sig.create_execution(workflow.id, %{"user_id" => 10})
{:ok, execution} = Sig.start_execution(execution.id, async: true)
6. Running Tests
mix test
The test suite uses Testcontainers to start throwaway PostgreSQL and Redis containers automatically, so the only requirement is a running Docker daemon. You don't need a local Postgres or Redis.
📄 License
Sig is released under the MIT License.