Saga Patterns with QueueBear

Implement saga patterns for distributed transactions using workflows with compensating transactions.

What is a Saga?

A saga is a sequence of local transactions where each transaction updates a single service. If one transaction fails, the saga executes compensating transactions to undo the changes made by preceding transactions.

Example: Order Processing Saga

1. Create Order    → (failure) → Cancel Order
2. Reserve Stock   → (failure) → Release Stock
3. Charge Payment  → (failure) → Refund Payment
4. Ship Order      → (failure) → Cancel Shipment

Orchestration vs Choreography

Approach Description QueueBear Support
Orchestration A central coordinator directs the saga Workflows (recommended)
Choreography Services communicate via events Messages + Events

QueueBear workflows are ideal for orchestration because:

  • Step caching prevents duplicate operations on retry
  • Centralized logic is easier to understand and debug
  • Compensation logic lives alongside forward logic
  • Built-in timeout and failure handling

Implementing Compensating Transactions

Use try/catch blocks with context.run() to implement compensation:

import { serve } from "queuebear";

interface OrderInput {
  orderId: string;
  customerId: string;
  items: Array<{ productId: string; quantity: number; price: number }>;
}

export const POST = serve<OrderInput>(async (ctx) => {
  const { orderId, customerId, items } = ctx.input;
  const total = items.reduce((sum, item) => sum + item.price * item.quantity, 0);

  // Step 1: Create order record
  const order = await ctx.run("create-order", async () => {
    return await orderService.create({
      id: orderId,
      customerId,
      items,
      status: "pending",
    });
  });

  // Step 2: Charge payment
  const payment = await ctx.run("charge-payment", async () => {
    return await paymentService.charge({
      customerId,
      amount: total,
      orderId,
    });
  });

  try {
    // Step 3: Reserve inventory
    await ctx.run("reserve-inventory", async () => {
      await inventoryService.reserve(orderId, items);
    });

    try {
      // Step 4: Create shipment
      const shipment = await ctx.run("create-shipment", async () => {
        return await shippingService.createShipment({
          orderId,
          items,
          address: await customerService.getAddress(customerId),
        });
      });

      // Step 5: Update order status
      await ctx.run("complete-order", async () => {
        await orderService.update(orderId, {
          status: "completed",
          paymentId: payment.id,
          shipmentId: shipment.id,
        });
      });

      return { success: true, orderId, paymentId: payment.id, shipmentId: shipment.id };

    } catch (shipmentError) {
      // Compensation: Release inventory if shipment fails
      await ctx.run("compensate-inventory", async () => {
        await inventoryService.release(orderId, items);
      });
      throw shipmentError;
    }

  } catch (inventoryError) {
    // Compensation: Refund payment if inventory reservation fails
    await ctx.run("compensate-payment", async () => {
      await paymentService.refund(payment.id);
    });

    // Update order as failed
    await ctx.run("fail-order", async () => {
      await orderService.update(orderId, { status: "failed" });
    });

    throw inventoryError;
  }
});

Nested Compensation Pattern

For complex sagas with many steps, use a nested try/catch structure:

export const POST = serve<BookingInput>(async (ctx) => {
  const { bookingId, flightId, hotelId, carId } = ctx.input;

  // Book flight
  const flight = await ctx.run("book-flight", async () => {
    return await flightService.book(flightId);
  });

  try {
    // Book hotel
    const hotel = await ctx.run("book-hotel", async () => {
      return await hotelService.book(hotelId);
    });

    try {
      // Book car
      const car = await ctx.run("book-car", async () => {
        return await carService.book(carId);
      });

      // All bookings successful
      return {
        success: true,
        flightConfirmation: flight.confirmation,
        hotelConfirmation: hotel.confirmation,
        carConfirmation: car.confirmation,
      };

    } catch (carError) {
      // Cancel hotel if car booking fails
      await ctx.run("cancel-hotel", async () => {
        await hotelService.cancel(hotel.id);
      });
      throw carError;
    }

  } catch (hotelOrCarError) {
    // Cancel flight if hotel or car booking fails
    await ctx.run("cancel-flight", async () => {
      await flightService.cancel(flight.id);
    });
    throw hotelOrCarError;
  }
});

Idempotent Compensation Steps

Compensation steps must be idempotent because they may be retried:

// BAD: Not idempotent - will fail on retry if already refunded
await ctx.run("refund-payment", async () => {
  await paymentService.refund(paymentId);
});

// GOOD: Idempotent - checks state before refunding
await ctx.run("refund-payment", async () => {
  const payment = await paymentService.get(paymentId);
  if (payment.status === "charged") {
    await paymentService.refund(paymentId);
  }
  // If already refunded, do nothing (idempotent)
});

Parallel Steps with Compensation

Use context.parallel() for steps that can run concurrently:

export const POST = serve<MultiServiceInput>(async (ctx) => {
  let results: { inventory: any; payment: any } | null = null;

  try {
    const [inventory, payment] = await ctx.parallel([
      {
        name: "reserve-inventory",
        fn: async () => inventoryService.reserve(ctx.input.items),
      },
      {
        name: "authorize-payment",
        fn: async () => paymentService.authorize(ctx.input.amount),
      },
    ]);

    results = { inventory, payment };

    // Complete the transaction
    await ctx.run("complete", async () => {
      await inventoryService.confirm(inventory.reservationId);
      await paymentService.capture(payment.authorizationId);
    });

    return { success: true };

  } catch (error) {
    // Compensate both if either failed
    if (results?.inventory) {
      await ctx.run("release-inventory", async () => {
        await inventoryService.release(results!.inventory.reservationId);
      });
    }
    if (results?.payment) {
      await ctx.run("void-authorization", async () => {
        await paymentService.void(results!.payment.authorizationId);
      });
    }
    throw error;
  }
});

Long-Running Sagas with Human Approval

export const POST = serve<LoanApplicationInput>(async (ctx) => {
  const { applicationId, amount, customerId } = ctx.input;

  // Step 1: Create application
  const application = await ctx.run("create-application", async () => {
    return await loanService.createApplication({
      id: applicationId,
      customerId,
      amount,
      status: "pending_review",
    });
  });

  // Step 2: Run automated checks
  const autoCheck = await ctx.run("auto-check", async () => {
    return await riskService.autoCheck(customerId, amount);
  });

  if (autoCheck.needsManualReview) {
    // Step 3: Wait for human approval (up to 7 days)
    const approval = await ctx.waitForEvent(
      "wait-approval",
      "loan.reviewed",
      {
        eventKey: applicationId,
        timeoutSeconds: 7 * 24 * 60 * 60,
      }
    );

    if (!approval.payload.approved) {
      await ctx.run("reject-application", async () => {
        await loanService.updateStatus(applicationId, "rejected");
      });
      return { success: false, reason: approval.payload.reason };
    }
  }

  // Step 4: Disburse funds
  const disbursement = await ctx.run("disburse-funds", async () => {
    return await paymentService.disburse(customerId, amount);
  });

  // Step 5: Activate loan
  await ctx.run("activate-loan", async () => {
    await loanService.activate(applicationId, disbursement.id);
  });

  return { success: true, loanId: application.id };
});

Send the approval event from your admin interface:

// Admin approves the loan
await qb.workflows.sendEvent("loan.reviewed", {
  eventKey: applicationId,
  payload: { approved: true, reviewerId: adminId },
});

// Or rejects it
await qb.workflows.sendEvent("loan.reviewed", {
  eventKey: applicationId,
  payload: { approved: false, reason: "Insufficient credit history" },
});

Best Practices

1. Make Compensation Idempotent

Compensation may run multiple times due to retries.

2. Log Compensation Actions

await ctx.run("compensate-payment", async () => {
  const refund = await paymentService.refund(paymentId);
  await auditLog.record({
    type: "payment_refunded",
    sagaId: ctx.runId,
    paymentId,
    refundId: refund.id,
    reason: "saga_compensation",
  });
});

3. Use Semantic Step Names

// Good
await ctx.run("reserve-inventory-for-order", fn);
await ctx.run("compensate-release-inventory", fn);

// Avoid
await ctx.run("step-3", fn);
await ctx.run("undo-3", fn);

Summary

Pattern Use Case
Nested try/catch Simple sagas with linear steps
State tracking Complex sagas with many steps
Parallel compensation Independent operations that can be undone concurrently
Event-based sagas Long-running processes with human approval