Sagalaraflow Documentation
website·Indexed 29 days ago
https://sagalaraflow.devA 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.
What's inside Sagalaraflow
- 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.
Understand the Reclaim mechanism in Sagalaraflow
The Reclaim setting determines whether a step currently marked as
Runningin 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.
Understand how workflow runs are driven
Every workflow and action in Saga Lara Flow runs as a queued job. A run advances by replaying thehandle()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.Handle nested workflows within handle()
When inside a workflow'shandle()method, do not userunSync()to start another workflow, as it takes no ordinal and cannot be recognized by the next replay. Instead, use thechild()method to reach another workflow.Execute an action in a workflow
Use theaction()method to return anActionBuilder, then callrun()to execute the action. Arguments passed toaction()are forwarded to the action'shandle()method after its injected dependencies.$tenantId = $this->action(CreateTenant::class, $email)->run();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. Becausehandle()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();Combine optional actions with retry policies
Optional actions can be combined withretryOnSignal(). 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.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), usesignalRetry(). 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...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:- Advance the application clock using
travel(). - Manually trigger the expiration sweep via
Artisan::call('saga-flow:monitor')orapp(FlowMonitor::class)->sweep()to mark the wait as timed out. - Drain the queue again, as the resumed run is processed as a queued job.
Alternatively, enable
monitor.queue_looping.enabledin the test environment before the application boots to allowqueue:workto 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);- Advance the application clock using
Ensure determinism in handle() to avoid HistoryContractMismatchException
Idempotency in SagaFlow depends on thehandle()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 aHistoryContractMismatchException.Set a per-step expiration deadline
Independent of the queue timeout, a step can be assigned a deadline usingexpiresAt(). This deadline is enforced by the expiration sweep (viasaga-flow:monitoror the queue-looping listener) rather than a real-time timer.$this->action(ChargeCard::class, $orderId) ->expiresAt(now()->addMinutes(2)) ->run();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_childrenrow always points to the newest attempt. FailedandExpiredstatuses are retried;Cancelledis 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();- The parent is the entity that waits and is found via