Durable Workflow Engine
repository·main·Indexed 41 days ago
https://github.com/wavezync/durableA resumable and reliable workflow engine for Elixir using PostgreSQL for persistence. Features include support for the Saga pattern with compensations, parallel execution, cron-driven scheduling, and human-in-the-loop inputs. Includes a LiveView-based dashboard (durable_dashboard) for monitoring executions, visualizing workflows via ReactFlow, and managing runtime configuration.
What's inside Durable
- Durable is a path-dependent monorepo containing the core workflow engine and its ecosystem. It is structured as a collection of independent Mix projects, where each subdirectory functions as its own package with its own dependencies, build artifacts, and publishing pipeline. This architecture allows for modularity while maintaining a unified workspace for development.
Use the 'Live Rail' accent color correctly
mainThe primary indigo accent (
--primary) has a strict semantic meaning: now / live / you are here. It should never be used for generic decoration.Use it exclusively for:
- The active nav rail (a
h-4 w-0.5indigo bar on the left edge of the current nav item). - The selected row in the ⌘K palette.
- The running edge in a workflow graph (
.flow-edge-running). - The "now" line or in-flight bars in the Timeline.
- The active nav rail (a
Differentiate between Parallel-child and Sub-workflow steps
mainWhen interacting with nodes that have children, the system distinguishes between two types of execution contexts:
- Parallel-child step: The child runs in its own independent
WorkflowExecution. Clicking the node navigates the user to the child's flow page because the data lives in a separate execution context. - Sub-workflow step (
call_workflow/start_workflow): The child is spawned viaDurable.Orchestrationbut shares a parent-context narrative. Clicking opens an inline inspector with a Child tab, allowing the user to preview the child's status, input, and result without leaving the current page.
Mapping is determined by
__parallel_children(for parallel blocks) and__call_children(for orchestration calls), merged viaparallel_children_lookup/1inflow_graph.ex.- Parallel-child step: The child runs in its own independent
How Cascade Cancellation works
mainWhen you cancel a parent workflow using
Durable.cancel(parent_workflow_id, reason), Durable automatically cancels all active children (pending, running, or waiting).Important: Children that have already completed are not affected by the cancellation of the parent.
# This cancels the parent AND any pending/running/waiting children Durable.cancel(parent_workflow_id, "User cancelled order")Understand `call_workflow` return values
mainInlib/durable/orchestration.ex, thecall_workflowfunction returns the child workflow's full context, not just the explicit outputs defined by the child. If you only need specific outputs, you must implement a workaround to extract them from the context object.Using `call_workflow/3` inside `parallel` blocks
maincall_workflow/3can be used insideparallelblocks. In this specific context, child workflows are executed inline (synchronously) within the parallel task rather than being sent to the queue and suspending the parent.Behavior:
- The parent's state is saved before the child runs and restored after it completes.
- Results are available immediately in the
data[:__results__]map after the parallel block finishes.
Critical Limitation: Child workflows used inside a
parallelblock cannot use waits (such assleep,wait_for_event, etc.). Because they are running inline, they cannot suspend the execution thread. Attempting to use a waiting child inside a parallel block will result in an error.workflow "enrich_order" do step :init, fn input -> {:ok, %{order_id: input["order_id"]}} end parallel on_error: :complete_all do step :enrich_customer, fn data -> case call_workflow(MyApp.CustomerLookup, %{"id" => data.order_id}, ref: :customer) do {:ok, result} -> {:ok, assign(data, :customer, result)} {:error, reason} -> {:error, reason} end end step :enrich_inventory, fn data -> case call_workflow(MyApp.InventoryCheck, %{"id" => data.order_id}, ref: :inventory) do {:ok, result} -> {:ok, assign(data, :inventory, result)} {:error, reason} -> {:error, reason} end end end step :process, fn data -> results = data[:__results__] # Handle results from parallel call_workflow steps... {:ok, data} end endCompare branch vs decision for flow control
mainDurable provides two primary mechanisms for controlling workflow execution flow:
branchUse
branchwhen you want to execute distinct groups of steps based on a condition. It is highly readable and supports multiple sequential steps per branch.- Syntax: Pattern matching clauses.
- Best for: Distinct paths with different logic/steps.
decisionUse
decisionfor simple conditional jumps or skipping specific steps. It returns a single jump target.- Syntax: Returns
{:goto, :step_name, ctx}. - Best for: Linear workflows that occasionally need to skip steps or jump to a specific point.
# Decision for simple skips decision :check_skip, fn ctx -> if ctx.skip_optional do {:goto, :final_step, ctx} else {:ok, ctx} end end step :optional_step, fn ctx -> {:ok, ctx} end step :final_step, fn ctx -> {:ok, ctx} endDefine a Durable Workflow
mainWorkflows are defined by modules that
use Durable. You use theworkflow "name" do ... endblock to encapsulate a sequence of steps.Steps
Steps are the atomic units of work within a workflow. They are defined using
step :name, [options], fn ctx -> ... end.- Context (
ctx): Each step receives a context object containing accumulated data. Useassign(ctx, :key, value)to persist new data into the workflow state. - Retries: You can configure automatic retries for steps using the
retryoption, specifyingmax_attemptsand abackoffstrategy (e.g.,:exponential). - Error Handling: Raising an error within a step (e.g., when an AI response is invalid) will trigger the configured retry logic.
Branching
Use
branch on: fn ctx -> ... end do ... endto implement conditional logic.- Only one path in a branch executes based on the value returned by the function.
- Steps defined after a
branchblock will execute regardless of which branch was taken, once that branch completes.
Human-in-the-Loop
Use
Durable.Waitto pause a workflow for external input. Thewait_for_input/2function allows you to wait for a specific input type with a definedtimeout.defmodule MyApp.DocumentProcessor do use Durable use Durable.Helpers workflow "process_document" do step :fetch, fn ctx -> doc = DocumentStore.get(ctx["doc_id"]) {:ok, %{doc: doc}} end step :classify, [retry: [max_attempts: 3, backoff: :exponential]], fn ctx -> # ... logic ... {:ok, assign(ctx, :doc_type, doc_type)} end branch on: fn ctx -> ctx.doc_type end do :invoice -> step :extract_invoice, [retry: [max_attempts: 3]], fn ctx -> # ... logic ... {:ok, assign(ctx, :extracted, extracted)} end _ -> step :flag_for_review, fn ctx -> {:ok, assign(ctx, :needs_review, true)} end end step :store, fn ctx -> # ... logic ... {:ok, ctx} end end end # Start the workflow {:ok, workflow_id} = Durable.start(MyApp.DocumentProcessor, %{"doc_id" => "doc_123"})- Context (
Avoid using get_context/1 inside parallel step bodies
mainWhen writing workflow logic using the DSL, be aware that
get_context/1behaves differently inside aparallelblock.The Constraint:
get_context/1inside a parallel step only sees the context as it existed at the moment the parallel step was spawned. Mutations made by other parallel siblings are not visible to the current step.The Recommended Pattern: Instead of relying on shared context via
get_context/1, pass all required values explicitly via the step'sdataargument to ensure data consistency across parallel branches.Access results from parallel steps
mainWhen a
parallelblock completes, the results of each step are collected into a map under the__results__key in the context. Results are stored as tagged tuples:{:ok, data}or{:error, reason}.You can access these results in two ways:
1. Using Helper Functions
If you are inside a step, use these helpers to interact with the results:
parallel_results(): Returns the entire map of results.parallel_result(step_name): Returns the tagged tuple for a specific step.parallel_ok?(step_name): Returnstrueif the specific step succeeded.
2. Direct Context Access
Access the map directly via
ctx[:__results__][step_name].step :handle_results, fn ctx -> # Using helpers results = parallel_results() case parallel_result(:payment) do {:ok, payment} -> # handle success {:error, reason} -> # handle error end # Direct access case ctx[:__results__][:payment] do {:ok, payment} -> {:ok, Map.put(ctx, :payment_id, payment.id)} {:error, _} -> {:goto, :handle_payment_failure, ctx} end endMonitor Scheduler resilience and failures
mainThe Durable scheduler tracks failures for
ScheduledWorkflowinstances. If a workflow module fails to load or execute repeatedly, the scheduler will automatically disable it to prevent resource exhaustion.Failure Threshold:
- A schedule is automatically disabled after 5 consecutive failures.
- Failures are tracked via
consecutive_failures,last_error, andlast_error_atfields in theScheduledWorkflowschema.
How retries affect `put_context` state
mainWhen a step is configured with retries (e.g.,
retry: [max_attempts: 3]), Durable ensures that state set viaput_contextis preserved across retry attempts.In the current implementation,
StepRunner.execute_with_retry/6merges the existing process dictionary with new data at the start of a retry, rather than overwriting it. This allows you to maintain counters or stateful information even when a step fails and is re-run.step :flaky, retry: [max_attempts: 3], fn data -> # This count will persist across retries count = (get_context(:attempt) || 0) + 1 put_context(:attempt, count) # ... logic that might fail ... end