Saga engine
Banks, anchors and blockchains do not take part in distributed transactions, so a cross-border payment cannot be one atomic commit. Corridor runs each plan as a saga: a sequence of legs, each with a compensating action, persisted in Postgres so it survives crashes. See ADR 0004.
The leg contract
Section titled “The leg contract”interface Leg<D> { kind: LegKind; forward(ctx: LegContext<D>): Promise<LegOutcome>; compensate?(ctx: LegContext<D>, output: Json): Promise<LegOutcome>; // omit if nothing to undo maxAttempts?: number; backoffMs?: number;}
interface LegOutcome { output: Json; // stored on the step; passed to later steps and to compensate() bookings?: EntryInput[]; // posted to the ledger after the leg succeeds}Every call gets an idempotencyKey of ${sagaId}:${stepIndex}. Bookings without an external reference are keyed on it, so re-running a step never double-books.
Legs that move money first look for the movement by its Corridor Reference:
const existing = await findTransfersByReference(client, { account: from, reference, cursor });const done = existing.find((o) => o.from === from && o.to === args.to && o.amount === args.amount && o.asset === args.asset);if (done) return done;So a retry after a crash between “sent” and “recorded” finds the payment on Horizon instead of sending it again. A stored Horizon paging token (stellarCursor, taken when the saga started) bounds how far back it has to look.
sagas (id, kind, reference, status, input, lease_owner, lease_until, fence)saga_steps (saga_id, idx, kind, status, attempts, output, error)saga_events (saga_id, idx, event, detail, at) -- the audit trailstateDiagram-v2 [*] --> running running --> completed: all steps done running --> compensating: step failed after retries compensating --> compensated: completed steps undone in reverse running --> manual_review: ManualReviewError compensating --> manual_review: compensation failedFailure semantics
Section titled “Failure semantics”Retryable errors
The step is retried up to maxAttempts with backoff. Horizon timeouts and anchor 5xx land here.
NonRetryableError
The step fails at once — a validation that a retry cannot change — and the saga compensates.
Compensation, in reverse order
Completed steps are undone from last to first, each through its own compensate with its own bookings. Order matters: unwinding out of order can leave a balanced ledger with a false audit trail. On a route with a leg off the hub, compensation sends the value back on Stellar with the same reference, and books bridge.unfill and bridge.refund against it.
ManualReviewError
No retries and no automatic compensation. Used when money has already left Corridor’s control — an anchor failing a payout it was already funded for. Undoing earlier legs would be wrong until the anchor’s refund is confirmed, so a person closes it.
Leases
Section titled “Leases”A saga is driven by one worker at a time. run() takes a lease (lease_owner, lease_until, default 120 s) with a conditional update; another live worker that tries gets the current status back without doing work. If a worker dies, its lease expires and any worker can resume from the last durable step.
Fencing tokens
Section titled “Fencing tokens”Every lease carries a fencing token (sagas.fence). A renewal by the live holder keeps it; any takeover — another worker, or the same worker after its lease lapsed — increments it. Every commit runs inside a transaction that first checks the token:
- ledger bookings, through the ledger’s
preconditionhook; - step results (
done,failed,compensated) and saga status changes.
So a worker that stalls inside a leg, loses its lease and wakes up later is refused at its next commit with StaleLeaseError, writes nothing, and returns the saga’s real status.
The chain side is covered separately: legs find their own earlier payments by memo, so a repeated call never moves money twice. A test runs exactly this race — a worker stalled past its lease while a second worker completes the saga — and checks that only one booking exists; with fencing disabled the same test finds two.
Fault injection
Section titled “Fault injection”SagaInput.faults.failAt makes a chosen leg kind fail, which is how the tests prove compensation order and the manual_review stop. See Failure and compensation.