Saga Pattern
The saga pattern coordinates multi-step operations across services. If any step fails, previously completed steps are automatically compensated (rolled back) in reverse order.
Quick Start
Section titled “Quick Start”import { saga, createDefaultSagaStore } from '@venturekit/runtime';
const store = await createDefaultSagaStore();
const createOrder = saga('create-order', [ { name: 'reserve-inventory', execute: async (ctx) => { const reservation = await reserveItems(ctx.items); return { ...ctx, reservationId: reservation.id }; }, compensate: async (ctx) => { await releaseReservation(ctx.reservationId); }, }, { name: 'charge-payment', execute: async (ctx) => { const charge = await chargeCard(ctx.paymentMethod, ctx.total); return { ...ctx, chargeId: charge.id }; }, compensate: async (ctx) => { await refundCharge(ctx.chargeId); }, }, { name: 'send-confirmation', execute: async (ctx) => { await sendEmail(ctx.email, 'Order confirmed'); return ctx; }, // No compensate — email can't be unsent },], { store });
const result = await createOrder.run({ items: [{ sku: 'WIDGET-1', qty: 2 }], paymentMethod: 'pm_xxx', total: 4999, email: 'alice@example.com',});
if (result.success) { console.log('Order created:', result.context.chargeId);} else { console.error('Order failed:', result.record.error);}Parallel Steps
Section titled “Parallel Steps”Use parallel() to execute independent steps concurrently. If any step in the group fails, all completed steps in the group are compensated.
import { saga, parallel } from '@venturekit/runtime';
const checkout = saga('checkout', [ // These two run at the same time parallel('validate', [ { name: 'check-inventory', execute: async (ctx) => { const available = await checkStock(ctx.items); return { ...ctx, stockAvailable: available }; }, }, { name: 'verify-payment', execute: async (ctx) => { const valid = await verifyPaymentMethod(ctx.paymentMethod); return { ...ctx, paymentVerified: valid }; }, }, ]), // This runs after both parallel steps complete { name: 'charge', execute: async (ctx) => { const charge = await chargeCard(ctx.paymentMethod, ctx.total); return { ...ctx, chargeId: charge.id }; }, compensate: async (ctx) => { await refundCharge(ctx.chargeId); }, },]);Parallel step contexts are shallow-merged — each parallel step can add new keys to the context and they’ll all be available to subsequent steps.
Distributed Tracing
Section titled “Distributed Tracing”Pass a traceId to track the entire saga execution across logs:
const result = await createOrder.run(context, { traceId: ctx.trace?.traceId, // from the incoming request});// All step logs include: [saga:create-order][trace:abc-123]Or set a default traceId for all executions:
const mySaga = saga('order', steps, { store, traceId: 'default-trace-id',});State Persistence & Resume
Section titled “State Persistence & Resume”When a store is configured, saga state is persisted after every step. If a saga fails, you can resume it later — already-completed steps are skipped (idempotent execution).
DynamoDB Store (Production)
Section titled “DynamoDB Store (Production)”import { saga, createDynamoDBSagaStore } from '@venturekit/runtime';
const store = await createDynamoDBSagaStore({ tableName: 'venturekit-sagas', // default ttlSeconds: 604800, // 7 days (default)});
const mySaga = saga('create-order', steps, { store });Auto-Selecting Store
Section titled “Auto-Selecting Store”createDefaultSagaStore() picks the right backend automatically:
- Lambda: DynamoDB
- Local dev: In-memory
import { createDefaultSagaStore } from '@venturekit/runtime';
const store = await createDefaultSagaStore();Resume from Failure
Section titled “Resume from Failure”// First attempt — assign an explicit IDconst result = await mySaga.run(context, { id: 'order-abc-123' });
if (!result.success) { // Fix the external issue, then resume: const resumed = await mySaga.resume('order-abc-123'); // Skips already-completed steps, retries from the failure point console.log(resumed.record.attempts); // 2}Context & Step Results
Section titled “Context & Step Results”Shared Context
Section titled “Shared Context”Every step receives the accumulated context and returns an updated version. Each step can add new keys or modify existing ones:
const mySaga = saga('order', [ { name: 'reserve', execute: async (ctx) => { const res = await reserve(ctx.items); return { ...ctx, reservationId: res.id }; // adds reservationId }, }, { name: 'charge', execute: async (ctx) => { // ctx.reservationId is available here from the previous step const charge = await chargeCard(ctx.paymentMethod, ctx.total); return { ...ctx, chargeId: charge.id }; // adds chargeId }, },]);The final result.context contains all keys accumulated from every step.
Per-Step Results (result.steps)
Section titled “Per-Step Results (result.steps)”After the saga completes, you can access each step’s individual output:
const result = await mySaga.run(initialContext);
// Access individual step outputsconst reserveOutput = result.steps['reserve'].output;const chargeOutput = result.steps['charge'].output;
console.log(reserveOutput.reservationId); // from the reserve stepconsole.log(chargeOutput.chargeId); // from the charge step
// Check if a step was async (fire-and-forget)if (result.steps['send-email'].async) { console.log('Email was fired asynchronously');}For parallel groups, each sub-step has its own entry in result.steps.
Async (Fire-and-Forget) Steps
Section titled “Async (Fire-and-Forget) Steps”Mark a step as async: true to run it without waiting. The saga immediately moves to the next step. This is useful for side-effects that don’t affect the main flow:
const createOrder = saga('create-order', [ { name: 'reserve-inventory', execute: async (ctx) => { const res = await reserve(ctx.items); return { ...ctx, reservationId: res.id }; }, compensate: async (ctx) => { await release(ctx.reservationId); }, }, { name: 'charge-payment', execute: async (ctx) => { const charge = await chargeCard(ctx.paymentMethod, ctx.total); return { ...ctx, chargeId: charge.id }; }, compensate: async (ctx) => { await refund(ctx.chargeId); }, }, // Fire-and-forget: send confirmation email without blocking { name: 'send-confirmation', async: true, execute: async (ctx) => { await sendEmail(ctx.email, 'Order confirmed', { orderId: ctx.chargeId }); return ctx; }, // No compensate needed — async steps are NOT compensated }, // Fire-and-forget: trigger analytics { name: 'track-analytics', async: true, execute: async (ctx) => { await analytics.track('order.created', { orderId: ctx.chargeId }); return ctx; }, },]);Async Step Behavior
Section titled “Async Step Behavior”- Does not block: The saga moves to the next step immediately
- Does not merge context: The step’s return value is ignored for context accumulation
- Not compensated: If the saga fails later, async steps are skipped during compensation (they may still be running)
- Errors are logged but ignored: An async step failure does not fail the saga
- Good for: Notifications, analytics, audit logging, cache warming, webhooks
Compensation Best Practices
Section titled “Compensation Best Practices”- Make compensations idempotent — they may be called more than once on retry
- Log compensation actions — use the
onCompensatingcallback for observability - Handle partial failures — if compensation itself fails, the saga enters
'failed'status - Order matters — compensations run in reverse order (last completed → first completed)
- Not all steps need compensation — logging, emails, and read-only operations can skip it
const mySaga = saga('order', steps, { store, onStepComplete: (name, index) => { console.log(`Step ${name} completed (${index})`); }, onCompensating: (name, index) => { console.log(`Compensating step ${name} (${index})`); }, onFailed: (error, record) => { // Alert, send to dead-letter queue, etc. console.error(`Saga ${record.id} failed: ${error.message}`); },});Saga Status Flow
Section titled “Saga Status Flow”pending → running → completed ↘ compensating → compensated ↘ failed (compensation error)| Status | Meaning |
|---|---|
running | Executing steps |
completed | All steps succeeded |
compensating | A step failed, running compensations |
compensated | All compensations succeeded |
failed | A compensation failed — manual intervention needed |
DynamoDB Table Schema
Section titled “DynamoDB Table Schema”The saga store table needs:
- Partition key:
pk(String) - TTL attribute:
ttl(Number)
aws dynamodb create-table \ --table-name venturekit-sagas \ --attribute-definitions AttributeName=pk,AttributeType=S \ --key-schema AttributeName=pk,KeyType=HASH \ --billing-mode PAY_PER_REQUEST
aws dynamodb update-time-to-live \ --table-name venturekit-sagas \ --time-to-live-specification "Enabled=true, AttributeName=ttl"Related
Section titled “Related”- Lambda Invocation — calling other functions from saga steps
- Idempotency — preventing duplicate processing
- Middleware — rate limiting, error handling