Durable Workflow Engine

repository·main·Indexed 41 days ago

https://github.com/wavezync/durable

A 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.

Tokens
38K
Snippets
114
Records
157
Agent score
53%

What's inside Durable

  1. Overview of the Durable workspace

    main
    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.
  2. Use the 'Live Rail' accent color correctly

    main

    The 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.5 indigo 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.
  3. Differentiate between Parallel-child and Sub-workflow steps

    main

    When interacting with nodes that have children, the system distinguishes between two types of execution contexts:

    1. 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.
    2. Sub-workflow step (call_workflow / start_workflow): The child is spawned via Durable.Orchestration but 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 via parallel_children_lookup/1 in flow_graph.ex.

  4. How Cascade Cancellation works

    main

    When 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")
  5. Using `call_workflow/3` inside `parallel` blocks

    main

    call_workflow/3 can be used inside parallel blocks. 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 parallel block cannot use waits (such as sleep, 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
    end
  6. Compare branch vs decision for flow control

    main

    Durable provides two primary mechanisms for controlling workflow execution flow:

    branch

    Use branch when 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.

    decision

    Use decision for 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} end
  7. Define a Durable Workflow

    main

    Workflows are defined by modules that use Durable. You use the workflow "name" do ... end block 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. Use assign(ctx, :key, value) to persist new data into the workflow state.
    • Retries: You can configure automatic retries for steps using the retry option, specifying max_attempts and a backoff strategy (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 ... end to implement conditional logic.

    • Only one path in a branch executes based on the value returned by the function.
    • Steps defined after a branch block will execute regardless of which branch was taken, once that branch completes.

    Human-in-the-Loop

    Use Durable.Wait to pause a workflow for external input. The wait_for_input/2 function allows you to wait for a specific input type with a defined timeout.

    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"})
  8. Avoid using get_context/1 inside parallel step bodies

    main

    When writing workflow logic using the DSL, be aware that get_context/1 behaves differently inside a parallel block.

    The Constraint: get_context/1 inside 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's data argument to ensure data consistency across parallel branches.

  9. Access results from parallel steps

    main

    When a parallel block 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): Returns true if 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
    end
  10. Monitor Scheduler resilience and failures

    main

    The Durable scheduler tracks failures for ScheduledWorkflow instances. 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, and last_error_at fields in the ScheduledWorkflow schema.
  11. How retries affect `put_context` state

    main

    When a step is configured with retries (e.g., retry: [max_attempts: 3]), Durable ensures that state set via put_context is preserved across retry attempts.

    In the current implementation, StepRunner.execute_with_retry/6 merges 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