Sourcing::Saga

A role for saga classes that coordinate long-running multi-step business processes across multiple aggregations. Sagas maintain state machines, track compensating transactions for rollback, and support timeout handling.

Note: You never use this role directly. It's automatically composed when you declare a saga using the saga keyword via Metamodel::SagaHOW.

What It Does

Sagas extend aggregations with additional capabilities for coordinating complex business workflows:

How It's Used

You declare a saga using the saga keyword. The metaclass automatically composes the role and generates necessary infrastructure:

use Sourcing;

saga AccountTransfer {
    has Str    $.saga-id is projection-id;
    has Str    $.state = 'pending';
    has Account $.from-account;
    has Account $.to-account;
    has Rat    $.amount;

    multi method apply(TransferRequested $e --> 'ready') {
        $!from-account = sourcing Account, :id($e.from-id);
        $!to-account   = sourcing Account, :id($e.to-id);
        $!amount = $e.amount;
        self.timeout-in: 'cancel-transfer', :5minutes;
    }

    method execute() is on-state('ready') is command {
        $!from-account.withdraw: $!amount;
        'transferring'
    }

    multi method apply(Withdrawn $e --> 'depositing') {
        $!to-account.deposit: $!amount;
        self.undo: *.from-account.reverse-withdraw: $e.amount;
        self.undo: *.to-account.reverse-deposit: $!amount;
        self.timeout-in: 'confirm-delivery', :5minutes;
    }

    multi method apply(Deposited $e --> 'completed') { }

    method cancel-transfer() {
        self.rollback;
        'cancelled'
    }

    method confirm-delivery() is on-state('depositing') {
        'completed'
    }
}

Attributes

The saga role provides these built-in attributes:

AttributeTypeDescription
$.stateStrCurrent saga state
@.undo-blocksCallableLIFO stack of undo blocks for rollback
@.timeout-schedulePairOrdered array of DateTime => Set of handler names
%.timeout-handlersHashHandler name => Hash{:date-time, :method-name}

Public Methods

method start

Creates a new saga and emits a SagaCreated event to initialize the saga in the event store.

method undo(Callable $block)

Stores a callable (undo block) on the LIFO stack for potential rollback. Unlike compensation events which are emitted to the event store, undo blocks are executed directly during rollback. This is useful for calling methods directly on aggregates without generating new events.

During rollback, all undo blocks are executed in reverse order (LIFO). The blocks receive the saga as the topic ($_), allowing method chaining:

# After withdrawing from an account, register the undo block
$!account.withdraw: 100;
$.undo: *.account.reverse-withdraw: 100;

# During rollback, this block executes:
# $!account.reverse-withdraw: 100;

Note: Undo blocks complement compensation events — use events when you need auditability, use undo blocks for direct aggregate manipulation.

method timeout-in(Str $method-name, *%params)

Schedules a timeout that fires after a specified duration. If no method name is provided (or if the method name is 'rollback'), the default rollback method will be called when the timeout fires:

  1. Computes scheduled-at = DateTime.now.later(|%params)
  2. Adds to %!timeout-handlers
  3. Adds to @!timeout-schedule (keeping it ordered)
  4. Emits TimeOutScheduled event

Default behavior: If you call self.timeout-in: 'rollback', :5minutes or simply don't specify a method name that exists, the timeout will automatically call self.rollback when it fires. This is useful for automatically canceling long-running processes that exceed their time limit.

Note: Can only be called from within an apply() method.

method rollback() is command

Emits all registered compensations in LIFO order and executes all undo blocks in reverse order. Clears both stacks. Called automatically when any exception is thrown.

method verify-timeouts() is command

Iterates over the ordered timeout schedule. While the next entry's DateTime is <= DateTime.now, emits TimedOut events. Should be called periodically (e.g., every 10 seconds) by an external scheduler.

method cancel-timeout(Str $method-name)

Cancels a scheduled timeout by name. Removes the entry from %!timeout-handlers and all entries with that method name from @!timeout-schedule.

method lookup(Sourcing::Saga:U $type, *%ids)

Retrieves a saga by ID from the event store. Returns a new saga instance with all events applied.

method send-command(Mu $aggregate-type, %ids, Mu $command)

Sends a command to an aggregate. Emits a command event to the event store directed at the specified aggregate type.

method bind-aggregate(Str $attr-name, Mu $aggregate-type, %ids)

Binds an aggregate reference to the saga and emits a SagaAggregationBound event to track the relationship in the event store.

Internal Events

Sagas work with these internal event classes (in Sourcing::Saga::Events):

EventAttributesPurpose
SagaCreated$.saga-id, $.saga-type, %.aggregation-idsEmitted when saga starts
SagaAggregationBound$.saga-id, $.attribute-name, $.aggregation-type, %.idsEmitted when binding aggregates
TimeOutScheduled$.saga-id, $.handler-name, $.scheduled-atPersists timeout schedule
TimedOut$.saga-id, $.handler-nameFires when timeout expires

How Timeout Scheduling Works

  1. Scheduling — Call self.timeout-in: "handler", :5minutes inside apply()
  2. Replayingapply(TimeOutScheduled) restores the schedule from the event store
  3. Firing — External scheduler calls verify-timeouts() periodically
  4. Handlingapply(TimedOut) looks up the handler and dispatches to it
  5. Canceling — Call self.cancel-timeout("handler") to remove

State Machine

Each apply method declares its resulting state via the return type syntax --> 'state-name'. The saga automatically tracks the current state in $.state.

Command methods can use the is on-state('state-name') trait to restrict them to specific states.

See Also