Reactor Saga Orchestrator

repository·main·Indexed 41 days ago

https://github.com/ash-project/reactor

A dynamic, concurrent, dependency-resolving saga orchestrator for Elixir that manages complex, multi-step workflows with distributed transaction semantics. It uses a Directed Acyclic Graph (DAG) to maximize concurrency while ensuring correct execution order. Features include a DSL for defining arguments, conditional execution via `where` and `guard`, modularity through `compose`, and support for iterating over collections with `map`.

Tokens
50.6K
Snippets
137
Records
170
Agent score
52%

What's inside Reactor

  1. What is Reactor?

    main

    Reactor is a dynamic, concurrent, dependency-resolving saga orchestrator. It is designed to provide transaction-like semantics across multiple distinct resources using the following principles:

    • Saga Orchestration: Implements the saga pattern to manage distributed transactions.
    • Dependency Resolution: Uses arguments to describe dependencies between saga steps. These arguments are converted into a Directed Acyclic Graph (DAG) to compute the correct execution order.
    • Concurrency: Reactor automatically runs as many steps as possible concurrently, respecting the dependencies defined in the DAG.
    • Dynamic Workflows: While you can define workflows statically using a DSL, you can also build workflows dynamically and even add new steps while a reactor is currently running.
  2. How Reactor composition works

    main

    Reactor composition allows you to break down complex workflows into smaller, focused, and reusable sub-reactors. Instead of building a single monolithic reactor, you use the compose macro to integrate other reactors into a parent orchestrator.

    Benefits of Composition

    • Complexity Management: Break large workflows into manageable pieces.
    • Reusability: A single sub-reactor (e.g., UserManagementReactor) can be used in multiple different parent workflows.
    • Testability: Each sub-reactor can be tested in isolation before being integrated into a larger system.
    • Error Boundaries: Failures in a sub-reactor can be managed by the parent reactor.

    The compose macro

    When composing, you define a name for the composed step and the module of the sub-reactor. You can also provide a block to map inputs from the parent reactor to the sub-reactor's arguments.

    # Basic composition
    compose :step_name, SubReactorModule
    
    # Composition with argument mapping
    compose :step_name, SubReactorModule do
      argument :sub_reactor_input, input(:parent_input)
    end
    defmodule OrderProcessor do
      use Reactor
      
      compose :user_management, UserManagementReactor
      compose :inventory_check, InventoryReactor  
      compose :payment_processing, PaymentReactor
      compose :fulfillment, FulfillmentReactor
    end
  3. Define a Reactor with inputs, steps, and return values

    main

    A Reactor is defined by using the Reactor module in an Elixir module. It consists of four main components:

    1. Inputs: Defined using input :name, these are the parameters the reactor accepts (similar to function arguments).
    2. Steps: Units of work defined using step :name. Each step contains:
      • Arguments: Defined using argument :name, source. The source can be an input(:name) or a result(:step_name) from a previous step.
      • Run function: A run fn %{args}, _context -> ... end block that performs the work. It should return {:ok, value} or {:error, reason}.
    3. Dependencies: Automatically created by the argument definitions. If a step uses result(:other_step), it will wait for :other_step to complete.
    4. Return: Defined using return :step_name, specifying which step's result should be the final output of the reactor.
    defmodule UserRegistration do
      use Reactor
    
      input :email
      input :password
    
      step :validate_email do
        argument :email, input(:email)
        
        run fn %{email: email}, _context ->
          if String.contains?(email, "@") do
            {:ok, email}
          else
            {:error, "Email must contain @"}
          end
        end
      end
    
      step :hash_password do
        argument :password, input(:password)
        
        run fn %{password: password}, _context ->
          hashed = :crypto.hash(:sha256, password) |> Base.encode16()
          {:ok, hashed}
        end
      end
    
      step :create_user do
        argument :email, result(:validate_email)
        argument :password_hash, result(:hash_password)
        
        run fn %{email: email, password_hash: password_hash}, _context ->
          user = %{id: :rand.uniform(10000), email: email, password_hash: password_hash}
          {:ok, user}
        end
      end
    
      return :create_user
    end
  4. How dependency-based execution works in Reactor

    main

    Reactor determines execution order using argument dependencies rather than explicit sequencing. By declaring a dependency on a previous step's result using result(:step_name), Reactor automatically builds an execution graph.

    Key Benefits:

    • Automatic Concurrency: Steps that do not share dependencies run simultaneously.
    • Natural Data Flow: Dependencies express the actual movement of data through the workflow.
    • Dynamic Workflows: Steps can emit new steps that resolve dependencies automatically.

    Mental Model: Instead of thinking Step A -> Step B, think Step B requires the output of Step A.

    defmodule UserWorkflow do
      use Reactor
      
      step :fetch_user do
        argument :user_id, input(:user_id)
      end
      
      step :fetch_profile do
        argument :user_id, input(:user_id)  # Can run concurrently with fetch_user
      end
      
      step :merge_data do
        argument :user, result(:fetch_user)      # Waits for fetch_user
        argument :profile, result(:fetch_profile) # Waits for fetch_profile
      end
    end
  5. Testing compensation and undo logic

    main

    Reactor supports two types of error recovery: Compensation and Undo.

    • Compensation: A step can return {:continue, result} during a failure to allow the reactor to proceed with a fallback value. Use stub(:compensate, ...) to test this.
    • Undo: If a step fails and no compensation is provided, Reactor may trigger the .undo/4 logic for previously completed steps. Use expect(:undo, ...) to verify that cleanup logic is executed.
    # Testing compensation returning a continue value
    test "compensation returns continue value" do
      MyApp.Steps.ProcessPayment
      |> stub(:run, fn _args, _context, _opts -> 
        {:error, :temporary_failure}
      end)
      |> stub(:compensate, fn _reason, _args, _context, _opts ->
        {:continue, %{id: "fallback_payment", status: :manual_review}}
      end)
      
      # ... rest of test
    end
    
    # Testing undo logic
    test "later step failure triggers undo of earlier steps" do
      MyApp.Steps.ProcessPayment
      |> stub(:run, fn _args, _context, _opts -> 
        {:ok, %{id: "payment_123"}}
      end)
      |> expect(:undo, fn _result, _args, _context, _opts ->
        :ok
      end)
      
      # ... rest of test
    end
  6. Compose workflows using sub-reactors

    main

    Reactor supports a composition model where one reactor can call another (sub-reactors).

    Key Composition Behaviors

    • Resource Sharing: Sub-reactors run using shared concurrency pools, ensuring efficient resource utilization.
    • Context Propagation: The parent reactor's context is automatically propagated to the child sub-reactor.
    • Error Handling: Sub-reactors respect the parent reactor's compensation (undo) strategy.
    • Result Flow: Results from sub-reactors flow back into the parent reactor seamlessly.
  7. How Reactor handles execution order and concurrency

    main

    Reactor does not execute steps in the order they are written in the code. Instead, it builds a dependency graph based on the argument definitions.

    • Parallel Execution: Steps that only depend on input(:name) can run simultaneously.
    • Sequential Execution: A step will only run once all the steps specified in its result(:step_name) arguments have completed successfully.

    This allows for automatic parallelization of independent tasks within a workflow.

  8. How compensation and undo work in Reactor

    main

    Reactor uses two distinct mechanisms to handle failures, depending on when the error occurs in the workflow:

    Compensation

    Trigger: Occurs when a step fails during its run() execution. Purpose: To decide how the reactor should react to a specific error in the current step (e.g., retrying or failing). Return values for compensate/4:

    • :retry: Attempt the step again.
    • {:continue, value}: Continue the workflow using the provided value instead of the failed result.
    • :ok: Successfully handled the error, but the reactor will still proceed to trigger rollback (undo) for previous steps.
    • {:error, reason}: Fail the entire reactor immediately.

    Undo

    Trigger: Occurs when a step succeeded, but a subsequent step in the workflow failed. Purpose: To roll back the side effects of the successful step to maintain system consistency. Return values for undo/4:

    • :ok: Successfully rolled back the changes.
    • {:error, reason}: Failed to undo the changes. This is a critical failure that will cause the entire reactor to fail.

    Error Handling Flow

    1. Step A succeeds.
    2. Step B succeeds.
    3. Step C fails.
    4. Reactor begins compensation for Step B.
    5. Reactor begins compensation for Step A.
    6. Reactor returns the compensated error.
    sequenceDiagram
        participant Reactor
        participant StepA
        participant StepB
        participant StepC
        
        Reactor->>StepA: run()
        StepA-->>Reactor: {:ok, result}
        
        Reactor->>StepB: run()
        StepB-->>Reactor: {:ok, result}
        
        Reactor->>StepC: run()
        StepC-->>Reactor: {:error, reason}
        
        Note over Reactor: Begin compensation
        Reactor->>StepB: compensate()
        StepB-->>Reactor: {:continue, context}
        
        Reactor->>StepA: compensate()
        StepA-->>Reactor: {:continue, context}
        
        Reactor-->>Reactor: Return compensated error
  9. Understand the Reactor struct and its lifecycle

    main

    The Reactor struct is the central data structure containing all information required for workflow execution. It is primarily composed of the following fields:

    • context: Runtime context shared across all steps.
    • id: Unique identifier for the reactor.
    • input_descriptions: Metadata about expected inputs.
    • inputs: The actual input data provided.
    • intermediate_results: A map of step outputs used for dependency resolution.
    • middleware: A stack of middleware for cross-cutting concerns.
    • plan: The dependency graph (DAG) constructed by the planner.
    • return: The final value returned by the reactor.
    • state: The current lifecycle state (:pending, :executing, :halted, :failed, or :successful).
    • steps: The list of defined steps.
    • undo: An undo stack tracking successful undoable steps for rollback (LIFO).
    defstruct context: %{},
              id: nil,
              input_descriptions: %{},
              inputs: [],
              intermediate_results: %{},
              middleware: [],
              plan: nil,
              return: nil,
              state: :pending,
              steps: [],
              undo: []
  10. Manage memory usage in Map Steps

    main

    Map steps have a memory footprint that scales with batch size and result size. The total memory usage can be approximated by the following formula:

    Total Memory ≈ (Batch Size × Input Record Size) + (Batch Size × Output Result Size) + (Final Collection Size)

    Memory Drivers:

    1. Input Record Storage: Each batch of input records is converted into individual steps containing the record as a value argument. These stay in memory until the step runs.
    2. Intermediate Results Storage: All results from the individual batch steps are stored in Reactor's intermediate results until the entire map step completes.
    3. Final Result Storage: The complete collection of all batch results is stored in intermediate results for subsequent steps to use.
  11. Minimize memory usage by controlling intermediate results

    main

    To reduce memory consumption, avoid storing large intermediate results that are not needed by subsequent steps.

    Reactor's memory usage is affected by:

    1. Undoable steps: Results of steps with an undo/4 callback are stored in the reactor's undo stack.
    2. Dependency: Results are kept if other steps depend on them.

    To minimize memory, ensure steps that process large data do not define an undo/4 callback and return only the necessary summary or subset of data for the next step.

    step :validate_data do
      argument :data, input(:large_dataset)
      run fn %{data: data}, _context ->
        validated = validate_large_dataset(data) 
        
        # Return only a small summary instead of the full 'validated' dataset
        summary = %{total_records: length(validated)}
        {:ok, summary}
      end
      # No undo/4 defined = not undoable = result not kept in undo stack
    end
  12. Use `map` for parallel batch transformations

    main

    The map block is used to apply transformations to elements within a collection (e.g., a list of users extracted from a file).

    Key features for data pipelines:

    • source: Specifies the collection to iterate over (can be a result from a previous step).
    • allow_async? true: Enables parallel processing of elements within the map.
    • return: Defines what value each iteration produces for the next step.
    • step: You can nest individual steps inside a map to perform complex logic (like cleaning, enriching, and validating) on each individual element.
    map :transform_users do
      source result(:extract_data, [:users])
      allow_async? true
      return :validate_user
    
      step :clean_user do
        argument :user, element(:transform_users)
        run fn %{user: user} ->
          clean_and_normalize_user(%{user: user, rules: %{}})
        end
      end
    
      # ... other nested steps ...
    end