Packages
A resilient DAG workflow engine for Elixir, backed by Ecto (PostgreSQL) and BullMQ (Redis).
Current section
Files
Jump to
Current section
Files
README.md
# 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
1. **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.
2. **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.
3. **Custom Node Modules (`run/1` or `run/2`)**:
- User-defined nodes implementing `run/1` (receiving a `%Sig.NodeContext{}`) or `run/2` (receiving `params, context`).
- Node outputs (`node_results`) are persisted and automatically shared with downstream nodes via `context.results`.
4. **Full Lifecycle Control**:
- Suspend (`suspend_execution`, `suspend_workflow`).
- Resume (`resume_execution`, `resume_workflow`).
- Stop / Cancel (`stop_execution`, `stop_workflow`).
5. **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.
6. **Idempotency**:
- **Execution level**: an `idempotency_key` prevents accidental duplicate executions.
- **Node level**: already completed nodes are not re-run when the flow is resumed.
7. **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
```elixir
# mix.exs
defp deps do
[
# ...
{:sig, "~> 0.1.0"}
]
end
```
```bash
mix deps.get
```
### 2. Create the migration
```bash
mix ecto.gen.migration add_sig_tables
```
```elixir
# 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
```
```bash
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:
```elixir
# 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:
```elixir
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:
```elixir
# 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:
```elixir
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:
```elixir
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:
```elixir
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:
```elixir
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"]`, not `params[:key]`.
```elixir
# 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:
```elixir
# 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:
```elixir
# 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:
```elixir
# 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
```bash
mix test
```
The test suite uses [Testcontainers](https://github.com/testcontainers/testcontainers-elixir) 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](LICENSE).