Skip to content

Cascade Execution

Synchronous saga execution with optional atomic commit/rollback. Execute a command and wait for all triggered sagas to complete before returning.


Event sourcing with sagas is naturally asynchronous: commands emit events, sagas react to events, and results arrive eventually. But sometimes you need immediate feedback:

  • User-facing operations that need instant confirmation
  • Validation workflows that must complete before responding
  • Multi-aggregate transactions that should succeed or fail together

Traditional solutions (distributed transactions, 2PC databases) are complex and often unavailable across service boundaries. Cascade execution provides synchronous semantics while preserving event sourcing’s append-only, auditable model.


Angzarr provides three execution modes, controlled by SyncMode:

ModeBehaviorUse Case
ASYNCFire and forget to busDefault, highest throughput
SIMPLEWait for projectors onlyRead-your-writes consistency
CASCADEWait for projectors + sagas + PMsImmediate response, full coordination
Do you need immediate response?
|
+- NO → ASYNC (default)
| Highest throughput, compensation on failure
|
+- YES → Do you need saga results?
|
+- NO → SIMPLE
| Sync projectors only
|
+- YES → CASCADE
Full sync: projectors + sagas + PMs

CASCADE executes the full coordination tree synchronously:

sequenceDiagram
    participant Client
    participant Agg as Aggregate
    participant Saga
    participant Target as Target Aggregate

    Client->>Agg: Command
    Agg->>Agg: Emit events
    Agg->>Saga: Events (sync)
    Saga->>Target: Command
    Target->>Target: Emit events
    Target-->>Saga: Events
    Saga-->>Agg: Done
    Agg-->>Client: All events

When a saga or downstream command fails, CascadeErrorMode controls the response:

ModeOn FailureWhen to Use
FAIL_FASTStop immediately, return errorDefault, most operations
CONTINUECollect all errors, return at endBatch validation
COMPENSATETrack commands, execute reverse on failureUndo partial work
DEAD_LETTERSend to DLQ, continueBest-effort with alerting

For operations requiring atomicity across aggregates, CASCADE can be combined with two-phase commit semantics.

AspectCASCADECASCADE + 2PC
Event visibilityImmediateAfter confirmation
On failureCompensate (reverse commands)Rollback (events hidden)
ConcurrencyNo protectionField-level locking
RecoveryManualTimeout-based
sequenceDiagram
    participant Client
    participant Framework
    participant Agg1 as Aggregate A
    participant Agg2 as Aggregate B

    Client->>Framework: execute_atomic(cmd)
    Framework->>Framework: Generate cascade_id
    Framework->>Agg1: Command (cascade_id)
    Agg1->>Agg1: Write events (no_commit=true)
    Agg1-->>Framework: Pending events
    Framework->>Agg2: Saga command (cascade_id)
    Agg2->>Agg2: Write events (no_commit=true)
    Agg2-->>Framework: Pending events

    alt All succeed
        Framework->>Agg1: Write Confirmation
        Framework->>Agg2: Write Confirmation
        Framework-->>Client: Committed events
    else Any fails
        Framework->>Agg1: Write Revocation
        Framework->>Agg2: Write Revocation
        Framework-->>Client: Rolled back
    end

Events include two fields for 2PC:

FieldTypePurpose
no_commitbooltrue = pending 2PC, needs Confirmation; false (default) = immediately committed
cascade_idstringGroups related pending events

Pending events (no_commit=true) are invisible to other operations until confirmed. If rolled back, they become NoOp at read time.

EventPurpose
ConfirmationMarks sequences as committed
RevocationMarks sequences as rolled back (hidden)
CompensateTriggers client compensation handler
NoOpPlaceholder for filtered events

The coordinator transforms events at read time:

Event StateTransformed To
CommittedPass through
Uncommitted + ConfirmedPass through
Uncommitted + RevokedNoOp
Uncommitted (other cascade)NoOp
Framework eventNoOp

Storage never changes. Only the view seen by business logic is filtered.


2PC provides optimistic field-level locking. Uncommitted events lock the fields they modify.

1. Load events, identify uncommitted
2. Compute locked fields from uncommitted events
3. Compare with incoming command's fields
4. Overlap → reject with ABORTED

Non-overlapping commands proceed. Only conflicting fields block.

Aggregate: Inventory(sku="ABC", qty=100, reserved=0)
Cascade 1: Reserve 10 units
- Pending events: InventoryReserved{qty_delta=-10, reserved_delta=+10}
- Locked fields: [qty, reserved]
Cascade 2: Update description
- Fields touched: [description]
- No overlap → proceeds
Cascade 3: Reserve 5 units
- Fields touched: [qty, reserved]
- Overlaps with Cascade 1 → ABORTED

A background cleanup job handles stale cascades:

impl CascadeReaper {
async fn cleanup_stale_cascades(&self) {
let threshold = Utc::now() - self.timeout;
let stale = self.storage.query_stale_cascades(&threshold).await;
for cascade_id in stale {
let participants = self.storage.query_cascade_participants(&cascade_id).await;
for participant in participants {
self.write_revocation(&participant, &cascade_id, "timeout").await;
}
}
}
}

Stale cascades are uncommitted events older than the timeout with no Confirmation or Revocation.


Process managers can serve as 2PC coordinators for async workflows:

ExecutionCoordinatorLock Window
SynchronousFrameworkMilliseconds
AsyncProcess ManagerWorkflow duration

The PM emits CascadeCommit or CascadeRollback based on workflow outcome. The framework distributes Confirmation/Revocation to all participants.

Long Locks

Async PM 2PC locks fields for the entire workflow duration. Not suitable for high-throughput hot aggregates.


// Existing: sync with immediate commits per step
let events = executor.execute_with_cascade(cmd).await?;
// New: sync with deferred commits
let events = executor.execute_atomic(cmd).await?;
let events = executor.execute_cascade_with_error_mode(
cmd,
CascadeErrorMode::Compensate
).await?;

ScenarioRecommended Mode
Most workflowsASYNC
Fire-and-forget notificationsASYNC
High-throughput hot pathsASYNC
UI needs immediate feedbackCASCADE
Multi-step wizard with validationCASCADE
Financial transaction (user-facing)CASCADE + 2PC
Background job requiring atomicityASYNC + PM 2PC