From ec711632b06a1edc82e406399c6e23f182fb2d1b Mon Sep 17 00:00:00 2001 From: Rishi Tiwari Date: Tue, 12 May 2026 13:47:39 +0530 Subject: [PATCH] saga pattern documentation --- docs/patterns/best-practices/index.md | 2 + docs/patterns/best-practices/saga-pattern.md | 140 ++++++++++++++++++ docs/patterns/index.md | 3 + .../saga-pattern/idempotent-compensation.java | 22 +++ .../saga-pattern/pitfall1-non-durable.java | 14 ++ .../pitfall2-in-memory-state.java | 12 ++ .../saga-pattern/saga-pattern-parallel.java | 123 +++++++++++++++ .../patterns/saga-pattern/saga-pattern.java | 110 ++++++++++++++ .../saga-pattern/idempotent-compensation.py | 17 +++ .../saga-pattern/pitfall1-non-durable.py | 10 ++ .../saga-pattern/pitfall2-in-memory-state.py | 8 + .../saga-pattern/saga-pattern-parallel.py | 81 ++++++++++ .../patterns/saga-pattern/saga-pattern.py | 58 ++++++++ .../saga-pattern/idempotent-compensation.ts | 22 +++ .../saga-pattern/pitfall1-non-durable.ts | 13 ++ .../saga-pattern/pitfall2-in-memory-state.ts | 13 ++ .../saga-pattern/saga-pattern-parallel.ts | 83 +++++++++++ .../patterns/saga-pattern/saga-pattern.ts | 64 ++++++++ zensical.toml | 1 + 19 files changed, 796 insertions(+) create mode 100644 docs/patterns/best-practices/saga-pattern.md create mode 100644 examples/java/patterns/saga-pattern/idempotent-compensation.java create mode 100644 examples/java/patterns/saga-pattern/pitfall1-non-durable.java create mode 100644 examples/java/patterns/saga-pattern/pitfall2-in-memory-state.java create mode 100644 examples/java/patterns/saga-pattern/saga-pattern-parallel.java create mode 100644 examples/java/patterns/saga-pattern/saga-pattern.java create mode 100644 examples/python/patterns/saga-pattern/idempotent-compensation.py create mode 100644 examples/python/patterns/saga-pattern/pitfall1-non-durable.py create mode 100644 examples/python/patterns/saga-pattern/pitfall2-in-memory-state.py create mode 100644 examples/python/patterns/saga-pattern/saga-pattern-parallel.py create mode 100644 examples/python/patterns/saga-pattern/saga-pattern.py create mode 100644 examples/typescript/patterns/saga-pattern/idempotent-compensation.ts create mode 100644 examples/typescript/patterns/saga-pattern/pitfall1-non-durable.ts create mode 100644 examples/typescript/patterns/saga-pattern/pitfall2-in-memory-state.ts create mode 100644 examples/typescript/patterns/saga-pattern/saga-pattern-parallel.ts create mode 100644 examples/typescript/patterns/saga-pattern/saga-pattern.ts diff --git a/docs/patterns/best-practices/index.md b/docs/patterns/best-practices/index.md index e87da02..bffb9fa 100644 --- a/docs/patterns/best-practices/index.md +++ b/docs/patterns/best-practices/index.md @@ -14,3 +14,5 @@ Best practices for your Durable Execution SDK workflow code. and polling external services with backoff. - [Code organization](code-organization.md) Separating business logic from orchestration, using child contexts, and grouping configuration. +- [Saga pattern](saga-pattern.md) Implementing sagas with compensation logic + for distributed transactions. diff --git a/docs/patterns/best-practices/saga-pattern.md b/docs/patterns/best-practices/saga-pattern.md new file mode 100644 index 0000000..d0cf8be --- /dev/null +++ b/docs/patterns/best-practices/saga-pattern.md @@ -0,0 +1,140 @@ +# Saga Pattern + +The Saga pattern is used in multi-step workflows to ensure a graceful rollback to a consistent state in the event of a workflow step failure. + +The core idea is to keep a record of compensating steps for each successful forward step. If there is failure at any step in the workflow, execute compensating steps for all previously completed steps in reverse order. This makes sure that the system can return to a consistent state even when something goes wrong. + +Durable functions are well-suited for sagas because: + +- Each step is automatically retried on transient failures +- When compensating actions are made durable steps, they are checkpointed and retried +- If Lambda crashes mid-compensation, execution resumes from the last checkpoint + +## Example + +Consider an order processing workflow with three steps: reserve inventory, charge payment, and fulfill shipment. If the shipment fulfilment step fails, we need to refund the payment and cancel the inventory reservation to avoid holding stock indefinitely. + +``` +Forward: reserve inventory → charge payment → fulfill shipment +Failure at fulfill shipment: +Compensate: refund payment → cancel reservation +``` + +=== "TypeScript" + + ```typescript + --8<-- "examples/typescript/patterns/saga-pattern/saga-pattern.ts" + ``` +=== "Python" + + ```python + --8<-- "examples/python/patterns/saga-pattern/saga-pattern.py" + ``` +=== "Java" + + ```java + --8<-- "examples/java/patterns/saga-pattern/saga-pattern.java" + ``` + +## Parallel Steps + +If steps are independent, they can be executed concurrently using the `context.parallel()` method. + +=== "TypeScript" + + ```typescript + --8<-- "examples/typescript/patterns/saga-pattern/saga-pattern-parallel.ts" + ``` +=== "Python" + + ```python + --8<-- "examples/python/patterns/saga-pattern/saga-pattern-parallel.py" + ``` +=== "Java" + + ```java + --8<-- "examples/java/patterns/saga-pattern/saga-pattern-parallel.java" + ``` + +## Idempotency of Compensating Steps + +Compensating steps must be idempotent. The SDK retries a durable compensation step if it +fails due to a transient error, so the step can execute more than once. Design +compensation logic to handle duplicate execution gracefully. + +For example, a step that cancels an inventory reservation should succeed even if the reservation was already cancelled. + +!!! warning + Durable compensation steps can execute more than once hence design them to be idempotent. Cancelling an already-cancelled forward step must not error or produce side effects. + +=== "TypeScript" + + ```typescript + --8<-- "examples/typescript/patterns/saga-pattern/idempotent-compensation.ts" + ``` +=== "Python" + + ```python + --8<-- "examples/python/patterns/saga-pattern/idempotent-compensation.py" + ``` +=== "Java" + + ```java + --8<-- "examples/java/patterns/saga-pattern/idempotent-compensation.java" + ``` + + +## Common Pitfalls + +**1. Non-durable compensations** + +Always wrap compensations in a durable step. Without it, a compensation that fails will not be retried and the failure will be silently lost. + +!!! warning + If a compensation fails outside a durable step, the exception aborts the loop. The failed + compensation is not retried and all remaining compensations are skipped, leaving + the system partially rolled back. + +=== "TypeScript" + + ```typescript + --8<-- "examples/typescript/patterns/saga-pattern/pitfall1-non-durable.ts" + ``` +=== "Python" + + ```python + --8<-- "examples/python/patterns/saga-pattern/pitfall1-non-durable.py" + ``` +=== "Java" + + ```java + --8<-- "examples/java/patterns/saga-pattern/pitfall1-non-durable.java" + ``` + +**2. Compensations that depend on non-deterministic data** + +The compensations list is rebuilt on every replay because it lives outside any step. The list is always rebuilt in the same order because the steps that add to it always replay in the same order. However, never store non-deterministic data (values computed outside a step like timestamps or random IDs) in the compensation closure. + +=== "TypeScript" + + ```typescript + --8<-- "examples/typescript/patterns/saga-pattern/pitfall2-in-memory-state.ts" + ``` +=== "Python" + + ```python + --8<-- "examples/python/patterns/saga-pattern/pitfall2-in-memory-state.py" + ``` +=== "Java" + + ```java + --8<-- "examples/java/patterns/saga-pattern/pitfall2-in-memory-state.java" + ``` + +## See Also + +- [Steps](../../sdk-reference/operations/step.md) +- [Parallel](../../sdk-reference/operations/parallel.md) +- [Error Handling](../../sdk-reference/error-handling/errors.md) +- [Determinism and Replay](./determinism.md) +- [Idempotency and Retries](./idempotency.md) diff --git a/docs/patterns/index.md b/docs/patterns/index.md index 50538d0..faad331 100644 --- a/docs/patterns/index.md +++ b/docs/patterns/index.md @@ -22,3 +22,6 @@ Best practices for deterministic workflows. timeouts, heartbeats, and polling external services with backoff. - [Code organization](best-practices/code-organization.md) Separating business logic from orchestration, using child contexts, and grouping configuration. +- [Saga pattern](best-practices/saga-pattern.md) Implementing sagas with compensation logic + for distributed transactions. + \ No newline at end of file diff --git a/examples/java/patterns/saga-pattern/idempotent-compensation.java b/examples/java/patterns/saga-pattern/idempotent-compensation.java new file mode 100644 index 0000000..e867e15 --- /dev/null +++ b/examples/java/patterns/saga-pattern/idempotent-compensation.java @@ -0,0 +1,22 @@ +try { + Map reservation = context.step("reserve-inventory", Map.class, + ctx -> reserveInventory(orderId)); + compensations.add(new Compensation( + "cancel-reservation", + // reservation id is the idempotency key and service uses it to deduplicate + () -> cancelReservation((String) reservation.get("id")) + )); + + // ... more steps ... + +} catch (Exception error) { + for (int i = compensations.size() - 1; i >= 0; i--) { + Compensation comp = compensations.get(i); + try { + context.step(comp.name, Void.class, ctx -> { comp.fn.run(); return null; }); + } catch (Exception compError) { + context.getLogger().error("Compensation failed: " + comp.name); + } + } + throw error; +} \ No newline at end of file diff --git a/examples/java/patterns/saga-pattern/pitfall1-non-durable.java b/examples/java/patterns/saga-pattern/pitfall1-non-durable.java new file mode 100644 index 0000000..18fef44 --- /dev/null +++ b/examples/java/patterns/saga-pattern/pitfall1-non-durable.java @@ -0,0 +1,14 @@ +// WRONG +for (int i = compensations.size() - 1; i >= 0; i--) { + compensations.get(i).fn.run(); // if this throws error, subsequent compensations don't run +} + +// CORRECT +for (int i = compensations.size() - 1; i >= 0; i--) { + Compensation comp = compensations.get(i); + try { + context.step(comp.name, Void.class, ctx -> { comp.fn.run(); return null; }); + } catch (Exception e) { + context.getLogger().error("Compensation failed: " + comp.name); + } +} \ No newline at end of file diff --git a/examples/java/patterns/saga-pattern/pitfall2-in-memory-state.java b/examples/java/patterns/saga-pattern/pitfall2-in-memory-state.java new file mode 100644 index 0000000..3629c45 --- /dev/null +++ b/examples/java/patterns/saga-pattern/pitfall2-in-memory-state.java @@ -0,0 +1,12 @@ +// WRONG - timestamp outside a step, changes on replay +long timestamp = System.currentTimeMillis(); +compensations.add(new Compensation("cancel", + () -> cancelWithTimestamp((String) reservation.get("id"), timestamp) +)); + +// CORRECT - data from step return value, stable on replay +Map reservation = context.step("reserve", Map.class, + ctx -> reserveInventory(orderId)); +compensations.add(new Compensation("cancel-reservation", + () -> cancelReservation((String) reservation.get("id")) +)); \ No newline at end of file diff --git a/examples/java/patterns/saga-pattern/saga-pattern-parallel.java b/examples/java/patterns/saga-pattern/saga-pattern-parallel.java new file mode 100644 index 0000000..dda2048 --- /dev/null +++ b/examples/java/patterns/saga-pattern/saga-pattern-parallel.java @@ -0,0 +1,123 @@ +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import software.amazon.lambda.durable.DurableContext; +import software.amazon.lambda.durable.DurableHandler; +import software.amazon.lambda.durable.DurableFuture; +import software.amazon.lambda.durable.TypeToken; + +public class ParallelSaga extends DurableHandler, Map> { + + static class Compensation { + final String name; + final Runnable fn; + + Compensation(String name, Runnable fn) { + this.name = name; + this.fn = fn; + } + } + + @Override + public Map handleRequest(Map event, DurableContext context) { + List compensations = new ArrayList<>(); + + try { + String orderId = (String) event.get("order_id"); + double amount = ((Number) event.get("amount")).doubleValue(); + Map address = (Map) event.get("address"); + + // Reserve inventory AND verify address in parallel + DurableFuture> reservationFuture; + DurableFuture> addressFuture; + + try (var parallel = context.parallel("pre-checks")) { + reservationFuture = parallel.branch("reserve-inventory", + new TypeToken>() {}, + ctx -> ctx.step("reserve", Map.class, s -> reserveInventory(orderId)) + ); + addressFuture = parallel.branch("verify-address", + new TypeToken>() {}, + ctx -> ctx.step("address", Map.class, s -> verifyAddress(address)) + ); + } + + List> results = DurableFuture.allOf(reservationFuture, addressFuture); + Map reservation = results.get(0); + Map addressVerification = results.get(1); + + // Only reserve-inventory needs a compensation, verify-address is stateless + compensations.add(new Compensation( + "cancel-reservation", + () -> cancelReservation((String) reservation.get("id")) + )); + + // Stop execution if address is invalid, catch block will cancel reservation + if (!"true".equals(addressVerification.get("valid"))) { + throw new IllegalArgumentException("Invalid shipping address"); + } + + // Charge payment + Map payment = context.step("charge-payment", Map.class, + ctx -> chargePayment(amount) + ); + compensations.add(new Compensation( + "refund-payment", + () -> refundPayment((String) payment.get("id")) + )); + + // Create shipment + Map shipment = context.step("create-shipment", Map.class, + ctx -> createShipment(orderId) + ); + + context.getLogger().info("Order completed: " + orderId); + return Map.of("success", true, "tracking_id", shipment.get("tracking_id")); + + } catch (Exception error) { + context.getLogger().error("Order failed, running compensations: " + error.getMessage()); + + for (int i = compensations.size() - 1; i >= 0; i--) { + Compensation comp = compensations.get(i); + try { + context.step(comp.name, Void.class, ctx -> { + comp.fn.run(); + return null; + }); + } catch (Exception compError) { + context.getLogger().error("Compensation failed: " + comp.name + " - " + compError.getMessage()); + } + } + + throw error; + } + } + + // Mock APIs for demonstration purposes + + private Map reserveInventory(String orderId) { + return Map.of("id", "RES-" + orderId); + } + + private void cancelReservation(String reservationId) { + System.out.println("Reservation " + reservationId + " cancelled"); + } + + private Map verifyAddress(Map address) { + // Stateless — no compensation needed + return Map.of("valid", "true"); + } + + private Map chargePayment(double amount) { + return Map.of("id", "PAY-" + amount); + } + + private void refundPayment(String paymentId) { + System.out.println("Payment " + paymentId + " refunded"); + } + + private Map createShipment(String orderId) { + return Map.of("tracking_id", "TRACK-" + orderId); + } +} diff --git a/examples/java/patterns/saga-pattern/saga-pattern.java b/examples/java/patterns/saga-pattern/saga-pattern.java new file mode 100644 index 0000000..fda313f --- /dev/null +++ b/examples/java/patterns/saga-pattern/saga-pattern.java @@ -0,0 +1,110 @@ +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import software.amazon.lambda.durable.DurableContext; +import software.amazon.lambda.durable.DurableHandler; +import software.amazon.lambda.durable.StepContext; + +public class Saga extends DurableHandler, Map> { + + // Compensation holder + static class Compensation { + final String name; + final Runnable fn; + + Compensation(String name, Runnable fn) { + this.name = name; + this.fn = fn; + } + } + + @Override + public Map handleRequest(Map event, DurableContext context) { + List compensations = new ArrayList<>(); + + try { + String orderId = (String) event.get("order_id"); + double amount = ((Number) event.get("amount")).doubleValue(); + + // Step 1: Reserve inventory + Map reservation = context.step( + "reserve-inventory", Map.class, + (StepContext ctx) -> { + ctx.getLogger().info("Reserving inventory for order: " + orderId); + return reserveInventory(orderId); + } + ); + compensations.add(new Compensation( + "cancel-reservation", + () -> cancelReservation((String) reservation.get("id")) + )); + + // Step 2: Charge payment + Map payment = context.step( + "charge-payment", Map.class, + (StepContext ctx) -> { + ctx.getLogger().info("Charging payment: " + amount); + return chargePayment(amount); + } + ); + compensations.add(new Compensation( + "refund-payment", + () -> refundPayment((String) payment.get("id")) + )); + + // Step 3: Create shipment (no compensation because it's the last step) + context.step( + "create-shipment", Map.class, + (StepContext ctx) -> { + ctx.getLogger().info("Creating shipment for order: " + orderId); + return createShipment(orderId); + } + ); + + context.getLogger().info("Order completed successfully: " + orderId); + return Map.of("success", true); + + } catch (Exception error) { + context.getLogger().error("Order failed, running compensations: " + error.getMessage()); + + // Run compensations in reverse to undo completed steps + for (int i = compensations.size() - 1; i >= 0; i--) { + Compensation comp = compensations.get(i); + try { + context.step(comp.name, Void.class, (StepContext ctx) -> { + comp.fn.run(); + return null; + }); + } catch (Exception compError) { + // Log but continue to attempt all compensations even if one fails + context.getLogger().error("Compensation failed: " + comp.name + " - " + compError.getMessage()); + } + } + + throw error; + } + } + + // Mock APIs for demonstration purposes + + private Map reserveInventory(String orderId) { + return Map.of("id", "RES-" + orderId); + } + + private void cancelReservation(String reservationId) { + System.out.println("Reservation " + reservationId + " cancelled"); + } + + private Map chargePayment(double amount) { + return Map.of("id", "PAY-" + amount); + } + + private void refundPayment(String paymentId) { + System.out.println("Payment " + paymentId + " refunded"); + } + + private Map createShipment(String orderId) { + return Map.of("tracking_id", "TRACK-" + orderId); + } +} diff --git a/examples/python/patterns/saga-pattern/idempotent-compensation.py b/examples/python/patterns/saga-pattern/idempotent-compensation.py new file mode 100644 index 0000000..5fdff41 --- /dev/null +++ b/examples/python/patterns/saga-pattern/idempotent-compensation.py @@ -0,0 +1,17 @@ +try: + reservation = context.step(reserve_inventory(event['order_id'])) + compensations.append(( + 'cancel-reservation', + cancel_reservation, + reservation['id'] # reservation['id'] is the idempotency key and service uses it to deduplicate + )) + + # ... more steps ... + +except Exception as error: + for name, comp_step, resource_id in reversed(compensations): + try: + context.step(comp_step(resource_id), name=name) + except Exception as comp_error: + context.logger.error(f"Compensation failed: {name}", comp_error) + raise error \ No newline at end of file diff --git a/examples/python/patterns/saga-pattern/pitfall1-non-durable.py b/examples/python/patterns/saga-pattern/pitfall1-non-durable.py new file mode 100644 index 0000000..c5e5c18 --- /dev/null +++ b/examples/python/patterns/saga-pattern/pitfall1-non-durable.py @@ -0,0 +1,10 @@ +# WRONG +for name, comp_fn, resource_id in reversed(compensations): + comp_fn(resource_id) # if this throws error, subsequent compensations don't run + +# CORRECT +for name, comp_step, resource_id in reversed(compensations): + try: + context.step(comp_step(resource_id), name=name) + except Exception as e: + context.logger.error(f"Compensation failed: {name}", e) \ No newline at end of file diff --git a/examples/python/patterns/saga-pattern/pitfall2-in-memory-state.py b/examples/python/patterns/saga-pattern/pitfall2-in-memory-state.py new file mode 100644 index 0000000..2e98109 --- /dev/null +++ b/examples/python/patterns/saga-pattern/pitfall2-in-memory-state.py @@ -0,0 +1,8 @@ +# WRONG - timestamp outside a step, changes on replay +import time +timestamp = time.time() +compensations.append(('cancel-reservation', lambda: cancel_reservation(timestamp))) # timestamp changes on replay! + +# CORRECT - data from step return value, stable on replay +reservation = context.step(reserve_inventory(order_id)) +compensations.append(('cancel-reservation', lambda: cancel_reservation(reservation['id']))) \ No newline at end of file diff --git a/examples/python/patterns/saga-pattern/saga-pattern-parallel.py b/examples/python/patterns/saga-pattern/saga-pattern-parallel.py new file mode 100644 index 0000000..4a81af5 --- /dev/null +++ b/examples/python/patterns/saga-pattern/saga-pattern-parallel.py @@ -0,0 +1,81 @@ +from aws_durable_execution_sdk_python import ( + BatchResult, + DurableContext, + StepContext, + durable_execution, + durable_step, +) + +@durable_step +def reserve_inventory(ctx: StepContext, order_id: str) -> dict: + return {"id": f"RES-{order_id}"} + + +@durable_step +def cancel_reservation(ctx: StepContext, reservation_id: str) -> None: + print(f"Reservation {reservation_id} cancelled") + + +@durable_step +def verify_address(ctx: StepContext, address: dict) -> dict: + # Stateless — no compensation needed + return {"valid": True} + + +@durable_step +def charge_payment(ctx: StepContext, amount: float) -> dict: + return {"id": f"PAY-{amount}"} + + +@durable_step +def refund_payment(ctx: StepContext, payment_id: str) -> None: + print(f"Payment {payment_id} refunded") + + +@durable_step +def create_shipment(ctx: StepContext, order_id: str) -> dict: + return {"tracking_id": f"TRACK-{order_id}"} + +@durable_execution +def handler(event: dict, context: DurableContext) -> dict: + compensations = [] + + try: + order_id = event["order_id"] + amount = event["amount"] + address = event["address"] + + # Reserve inventory AND verify address in parallel + pre_checks: BatchResult = context.parallel( + [ + lambda ctx: ctx.step(reserve_inventory(order_id)), + lambda ctx: ctx.step(verify_address(address)), + ], + name="pre-checks", + ) + + reservation, address_verification = pre_checks.get_results() + + compensations.append(("cancel-reservation", cancel_reservation, reservation["id"])) + + # Stop execution if address is invalid + if not address_verification.get("valid"): + raise ValueError("Invalid shipping address") + + payment = context.step(charge_payment(amount)) + compensations.append(("refund-payment", refund_payment, payment["id"])) + + shipment = context.step(create_shipment(order_id)) + + return {"success": True, "tracking_id": shipment["tracking_id"]} + + except Exception as error: + context.logger.error("Order failed, running compensations", error) + + for name, comp_step, resource_id in reversed(compensations): + try: + context.step(comp_step(resource_id), name=name) + except Exception as comp_error: + context.logger.error(f"Compensation failed: {name}", comp_error) + + raise error diff --git a/examples/python/patterns/saga-pattern/saga-pattern.py b/examples/python/patterns/saga-pattern/saga-pattern.py new file mode 100644 index 0000000..8167bb8 --- /dev/null +++ b/examples/python/patterns/saga-pattern/saga-pattern.py @@ -0,0 +1,58 @@ +from aws_durable_execution_sdk_python import ( + DurableContext, + StepContext, + durable_execution, + durable_step, +) + +@durable_step +def reserve_inventory(ctx: StepContext, order_id: str) -> dict: + return {"id": f"RES-{order_id}"} + + +@durable_step +def cancel_reservation(ctx: StepContext, reservation_id: str) -> None: + print(f"Reservation {reservation_id} cancelled") + + +@durable_step +def charge_payment(ctx: StepContext, amount: float) -> dict: + return {"id": f"PAY-{amount}"} + + +@durable_step +def refund_payment(ctx: StepContext, payment_id: str) -> None: + print(f"Payment {payment_id} refunded") + + +@durable_step +def create_shipment(ctx: StepContext, order_id: str) -> dict: + return {"tracking_id": f"TRACK-{order_id}"} + +@durable_execution +def handler(event: dict, context: DurableContext) -> dict: + compensations = [] + + try: + # Forward steps: each registers a compensation on success + reservation = context.step(reserve_inventory(event["order_id"])) + compensations.append(("cancel-reservation", cancel_reservation, reservation["id"])) + + payment = context.step(charge_payment(event["amount"])) + compensations.append(("refund-payment", refund_payment, payment["id"])) + + context.step(create_shipment(event["order_id"])) + + return {"success": True} + + except Exception as error: + context.logger.error("Order failed, running compensations", error) + + # Run compensations in reverse to undo completed steps + for name, comp_step, resource_id in reversed(compensations): + try: + context.step(comp_step(resource_id), name=name) + except Exception as comp_error: + context.logger.error(f"Compensation failed: {name}", comp_error) + + raise error diff --git a/examples/typescript/patterns/saga-pattern/idempotent-compensation.ts b/examples/typescript/patterns/saga-pattern/idempotent-compensation.ts new file mode 100644 index 0000000..3eed5b7 --- /dev/null +++ b/examples/typescript/patterns/saga-pattern/idempotent-compensation.ts @@ -0,0 +1,22 @@ +try { + const reservation = await context.step('reserve-inventory', async () => + reserveInventory(event.orderId) + ); + compensations.push({ + name: 'cancel-reservation', + // reservation.id is the idempotency key and service uses it to deduplicate + fn: () => cancelReservation(reservation.id) + }); + + // ... more steps ... + +} catch (error) { + for (const comp of compensations.reverse()) { + try { + await context.step(comp.name, async () => comp.fn()); + } catch (compError) { + context.logger.error(`Compensation failed: ${comp.name}`, compError); + } + } + throw error; +} \ No newline at end of file diff --git a/examples/typescript/patterns/saga-pattern/pitfall1-non-durable.ts b/examples/typescript/patterns/saga-pattern/pitfall1-non-durable.ts new file mode 100644 index 0000000..f0f7139 --- /dev/null +++ b/examples/typescript/patterns/saga-pattern/pitfall1-non-durable.ts @@ -0,0 +1,13 @@ +// WRONG - not durable, not retried on failure +for (const comp of compensations.reverse()) { + await comp.fn(); // if this throws error, subsequent compensations don't run +} + +// CORRECT - each compensation is a durable step +for (const comp of compensations.reverse()) { + try { + await context.step(comp.name, async () => comp.fn()); + } catch (compError) { + context.logger.error(`Compensation failed: ${comp.name}`, compError); + } +} \ No newline at end of file diff --git a/examples/typescript/patterns/saga-pattern/pitfall2-in-memory-state.ts b/examples/typescript/patterns/saga-pattern/pitfall2-in-memory-state.ts new file mode 100644 index 0000000..e385cb6 --- /dev/null +++ b/examples/typescript/patterns/saga-pattern/pitfall2-in-memory-state.ts @@ -0,0 +1,13 @@ +// WRONG - timestamp generated outside a step, changes on replay +const timestamp = Date.now(); +compensations.push({ + name: 'cancel', + fn: () => cancelWithTimestamp(reservation.id, timestamp) // different on replay! +}); + +// CORRECT - data comes from step return values (checkpointed, stable on replay) +const reservation = await context.step('reserve', async () => reserveInventory()); +compensations.push({ + name: 'cancel', + fn: () => cancelReservation(reservation.id) // reservation.id from checkpoint, stable +}); \ No newline at end of file diff --git a/examples/typescript/patterns/saga-pattern/saga-pattern-parallel.ts b/examples/typescript/patterns/saga-pattern/saga-pattern-parallel.ts new file mode 100644 index 0000000..4451d19 --- /dev/null +++ b/examples/typescript/patterns/saga-pattern/saga-pattern-parallel.ts @@ -0,0 +1,83 @@ +import { withDurableExecution, DurableContext } from '@aws/durable-execution-sdk-js'; + +export const handler = withDurableExecution(async (event: any, context: DurableContext) => { + const compensations: Array<{ name: string; fn: () => Promise }> = []; + + try { + // Reserve inventory AND verify address in parallel + const preChecks = await context.parallel('pre-checks', [ + async (ctx) => ctx.step('reserve-inventory', async () => + reserveInventory(event.order_id, event.items) + ), + async (ctx) => ctx.step('verify-address', async () => + verifyAddress(event.address) + ), + ]); + + const [reservation, isValidAddress] = preChecks.getResults() as [{ id: string }, { valid: boolean }]; + + // Only reserve-inventory needs a compensation because verify-address is stateless + compensations.push({ + name: 'cancel-reservation', + fn: () => cancelReservation(reservation.id) + }); + + // Stop execution if address is invalid, catch block will cancel reservation + if (!isValidAddress.valid) { + throw new Error('Invalid shipping address'); + } + + const payment = await context.step('charge-payment', async () => + chargePayment(event.payment_method, event.amount) + ); + compensations.push({ + name: 'refund-payment', + fn: () => refundPayment(payment.id) + }); + + const shipment = await context.step('create-shipment', async () => + createShipment(event.order_id, event.address) + ); + + return { success: true, tracking_id: shipment.tracking_id }; + + } catch (error) { + context.logger.error('Order failed, running compensations', { error }); + + for (const comp of compensations.reverse()) { + try { + await context.step(comp.name, async () => comp.fn()); + } catch (compError) { + context.logger.error(`Compensation failed: ${comp.name}`, compError); + } + } + + throw error; + } +}); + +// Mock APIs for demonstration purposes + +async function reserveInventory(orderId: string, items: any[]): Promise<{ id: string }> { + return { id: `RES-${orderId}` }; +} + +async function cancelReservation(reservationId: string): Promise { + console.log(`Reservation ${reservationId} cancelled`); +} + +async function verifyAddress(address: any): Promise<{ valid: boolean }> { + return { valid: true }; +} + +async function chargePayment(method: any, amount: number): Promise<{ id: string }> { + return { id: `PAY-${amount}` }; +} + +async function refundPayment(paymentId: string): Promise { + console.log(`Payment ${paymentId} refunded`); +} + +async function createShipment(orderId: string, address: any): Promise<{ tracking_id: string }> { + return { tracking_id: `TRACK-${orderId}` }; +} diff --git a/examples/typescript/patterns/saga-pattern/saga-pattern.ts b/examples/typescript/patterns/saga-pattern/saga-pattern.ts new file mode 100644 index 0000000..cf08db8 --- /dev/null +++ b/examples/typescript/patterns/saga-pattern/saga-pattern.ts @@ -0,0 +1,64 @@ +import { withDurableExecution, DurableContext } from '@aws/durable-execution-sdk-js'; + +export const handler = withDurableExecution(async (event: any, context: DurableContext) => { + + const compensations: Array<{ name: string; fn: () => Promise }> = []; + + try { + // Forward steps: each registers a compensation on success + const reservation = await context.step('reserve-inventory', async () => + reserveInventory(event.orderId) + ); + compensations.push({ + name: 'cancel-reservation', + fn: () => cancelReservation(reservation.id) + }); + + const payment = await context.step('charge-payment', async () => + chargePayment(event.amount) + ); + compensations.push({ + name: 'refund-payment', + fn: () => refundPayment(payment.id) + }); + + await context.step('create-shipment', async () => + createShipment(event.orderId) + ); + + return { success: true }; + + } catch (error) { + // Run compensations in reverse to undo completed steps + for (const comp of compensations.reverse()) { + try { + await context.step(comp.name, async () => comp.fn()); + } catch (compError) { + context.logger.error(`Compensation failed: ${comp.name}`, compError); + } + } + throw error; + } +}); + +// Mock APIs for demonstration purposes + +async function reserveInventory(orderId: string): Promise<{ id: string }> { + return { id: `RES-${orderId}` }; +} + +async function cancelReservation(reservationId: string): Promise { + console.log(`Reservation ${reservationId} cancelled`); +} + +async function chargePayment(amount: number): Promise<{ id: string }> { + return { id: `PAY-${amount}` }; +} + +async function refundPayment(paymentId: string): Promise { + console.log(`Payment ${paymentId} refunded`); +} + +async function createShipment(orderId: string): Promise<{ trackingId: string }> { + return { trackingId: `TRACK-${orderId}` }; +} diff --git a/zensical.toml b/zensical.toml index fbcbbaa..e0b4dd0 100644 --- a/zensical.toml +++ b/zensical.toml @@ -113,6 +113,7 @@ nav = [ { "Step Design" = "patterns/best-practices/step-design.md" }, { "Pause and Resume" = "patterns/best-practices/pause-resume.md" }, { "Code Organization" = "patterns/best-practices/code-organization.md" }, + { "Saga Pattern" = "patterns/best-practices/saga-pattern.md" } ]}, ]}, ]