Sagalaraflow Documentation

website·Indexed 29 days ago

https://sagalaraflow.dev

A workflow management engine with an integrated Saga pattern built for Laravel. It provides tools for creating deterministic workflows, managing action-level compensation, handling parallel execution, and implementing signals, retries, and timeouts via Artisan commands and Composer installation.

Tokens
21.7K
Snippets
86
Records
135
Agent score
54%

What's inside Sagalaraflow

  1. Overview of Saga Lara Flow

    Saga Lara Flow is a workflow management engine built on Laravel Queues that implements the Saga pattern. It allows developers to define long-running, durable business processes as single deterministic PHP methods. The engine records each step's execution, allowing the process to survive worker restarts via exception-based suspension and replay. If a step fails, the engine automatically executes registered compensation actions in reverse order to roll back completed work.
  2. Understand the Reclaim mechanism in Sagalaraflow

    The Reclaim setting determines whether a step currently marked as Running in the database can be executed again if it has sat in that state for too long. This is specifically designed to recover work when a worker dies mid-execution (e.g., due to an OOM killer, pod eviction, or SIGKILL) without leaving the run hanging indefinitely.

    Unlike Lock TTLs (which manage cache keys) or Business Deadlines (which mark a run as Expired/Failed), Reclaim allows the step to be claimed again and re-run to complete the work.

  3. Understand how workflow runs are driven

    Every workflow and action in Saga Lara Flow runs as a queued job. A run advances by replaying the handle() method against its recorded history. Completed operations return their stored results, and execution continues until the engine hits an un-run operation (which is then dispatched) or a suspension point (such as a signal wait or a queued action). Each operation is uniquely identified by a deterministic (flow_run_id, sequence) pair.
  4. Handle nested workflows within handle()

    When inside a workflow's handle() method, do not use runSync() to start another workflow, as it takes no ordinal and cannot be recognized by the next replay. Instead, use the child() method to reach another workflow.
  5. Execute an action in a workflow

    Use the action() method to return an ActionBuilder, then call run() to execute the action. Arguments passed to action() are forwarded to the action's handle() method after its injected dependencies.
    $tenantId = $this->action(CreateTenant::class, $email)->run();
  6. Manage workflow versions using separate classes

    To prevent breaking in-flight workflow runs when logic changes, keep each version of a workflow in its own class or directory. Because handle() is replayed from the start upon resume, changing the logic of an existing class can cause recorded history to mismatch the new code. By pinning a version at creation, existing runs continue to replay against the original class, while new runs use the updated version.
    namespace App\Workflows\V1;
    class CheckoutWorkflow extends Workflow { /* original logic */ }
    
    namespace App\Workflows\V2;
    class CheckoutWorkflow extends Workflow { /* new logic */ }
    
    // Pinning the version at creation
    SagaFlow::create(\App\Workflows\V2\CheckoutWorkflow::class)
        ->version('v2')
        ->run();
  7. Combine optional actions with retry policies

    Optional actions can be combined with retryOnSignal(). In this configuration, the retry layer takes precedence: the step will park and wait for the signal. The fallback value is only returned after the retry budget has been exhausted or the wait time has timed out.
  8. Retry parked runs without knowing the signal name

    If the specific signal name is unknown (e.g., when using a UI to retry failed runs), use signalRetry(). This method automatically reads the required signal from the run's parked step or child and delivers it.
    use DiscoveryUkraine
    SagaLaraFlow
    FlowHandle;
    
    SagaFlow::query()
        ->whereWorkflow(CheckoutWorkflow::class)
        ->whereTag('tenant', $tenantId)
        ->whereAwaitingRetrySignal()
        ->signalable()
        ->whereId(...$runIds)
        ->handles()
        ->each(fn (FlowHandle $handle) => $handle->signalRetry());
    # CLI: Retry a run regardless of signal name
    php artisan saga-flow:signal-retry 01JABCDEF...
  9. Test workflow deadlines and timeouts

    Deadlines (e.g., timeoutAfter(), expiresAt(), #[FlowTimeout]) are enforced by the expiration sweep, not by a system timer. To test a timeout, you must:

    1. Advance the application clock using travel().
    2. Manually trigger the expiration sweep via Artisan::call('saga-flow:monitor') or app(FlowMonitor::class)->sweep() to mark the wait as timed out.
    3. Drain the queue again, as the resumed run is processed as a queued job.

    Alternatively, enable monitor.queue_looping.enabled in the test environment before the application boots to allow queue:work to sweep on its idle loop.

    use Illuminate\Support\Facades\Artisan;
    
    $run = SagaFlow::create(ApprovalWorkflow::class)->run();
    drainQueue();
    
    // Parked on awaitSignal('approval', now()->addHour()).
    expect(SagaFlow::findRun($run->id)->status)->toBe(FlowStatus::Waiting);
    
    $this->travel(2)->hours();
    
    Artisan::call('saga-flow:monitor'); // flips the overdue wait to timed_out and wakes the run
    drainQueue();                       // the resumed run replays and sees the timeout
    
    expect(SagaFlow::findRun($run->id)->status)->toBe(FlowStatus::Completed);
  10. Ensure determinism in handle() to avoid HistoryContractMismatchException

    Idempotency in SagaFlow depends on the handle() method being deterministic. If a replay of the workflow diverges from the recorded history (e.g., a step appears that wasn't there before or appears in a different order), the engine will throw a HistoryContractMismatchException.
  11. Set a per-step expiration deadline

    Independent of the queue timeout, a step can be assigned a deadline using expiresAt(). This deadline is enforced by the expiration sweep (via saga-flow:monitor or the queue-looping listener) rather than a real-time timer.
    $this->action(ChargeCard::class, $orderId)
        ->expiresAt(now()->addMinutes(2))
        ->run();
  12. Implement retryOnSignal in Saga Groups and Child Workflows

    The retryOnSignal() method can be applied to both standard saga steps and child workflows.

    Saga Groups: Used within $this->saga()->step(...) to park a specific step until a signal is received.

    Child Workflows: When applied to child(), a failure or expiration parks the parent workflow. Delivering the signal starts a new run of the child workflow at the same ordinal with the same arguments.

    Key behaviors for child retries:

    • The parent is the entity that waits and is found via whereAwaitingRetrySignal().
    • Each retry attempt is a distinct run; the parent's flow_children row always points to the newest attempt.
    • Failed and Expired statuses are retried; Cancelled is not.
    • The policy is evaluated against the exception that left the child's handle() method.
    // In a Saga Group
    $this->saga()
        ->step(CreateOrder::class, $orderId)->compensateWith(CancelOrder::class, $orderId)
        ->step(ChargeCard::class, $orderId)
            ->compensateWith(RefundCard::class, $orderId)
            ->retryOnSignal(new BalanceRefillRecovery)
        ->step(ShipOrder::class, $orderId)
        ->run();
    
    // For a Child Workflow
    $analysis = $this->child(SchedulePodcastAnalysis::class, [$podcastId])
        ->retryOnSignal(
            'analysis-service-recovered',
            maxRetries: 3,
            only: [AwaitSignalTimeoutException::class],
        )
        ->run();