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 |