diff --git a/lambda-durable-java-sam/DEVELOPER_GUIDE.md b/lambda-durable-java-sam/DEVELOPER_GUIDE.md new file mode 100644 index 000000000..4b31baf9e --- /dev/null +++ b/lambda-durable-java-sam/DEVELOPER_GUIDE.md @@ -0,0 +1,672 @@ +# Lambda Durable Functions in Java - Developer Guide + +## Table of Contents + +1. [Architecture Overview](#architecture-overview) +2. [The Durable Execution Model](#the-durable-execution-model) +3. [Java SDK Reference](#java-sdk-reference) +4. [DurableHandler Base Class](#durablehandler-base-class) +5. [Durable Operations](#durable-operations) +6. [Error Handling and Retries](#error-handling-and-retries) +7. [Deployment Model](#deployment-model) +8. [Best Practices](#best-practices) +9. [Testing](#testing) +10. [Common Pitfalls](#common-pitfalls) + +> **Tip:** For visual step-by-step flow diagrams of each pattern, see [DIAGRAMS.md](DIAGRAMS.md). + +--- + +## Architecture Overview + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ AWS Cloud │ +│ │ +│ ┌───────────┐ ┌──────────────────────────────────────────┐ │ +│ │ SNS Topic │────>│ Lambda Durable Function │ │ +│ └───────────┘ │ │ │ +│ ▲ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ │ +│ │ │ │ Step 1 │─>│ Step 2 │─>│ Step 3 │ │ │ +│ ┌────┴────┐ │ │(chkpt) │ │(chkpt) │ │(chkpt) │ │ │ +│ │Producer │ │ └─────────┘ └─────────┘ └─────────┘ │ │ +│ │(EC2/CLI)│ │ │ │ │ │ │ +│ └─────────┘ │ ▼ ▼ ▼ │ │ +│ │ ┌────────────────────────────────────┐ │ │ +│ │ │ Checkpoint Store (managed) │ │ │ +│ │ └────────────────────────────────────┘ │ │ +│ └──────────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌──────────┐ │ +│ │ DynamoDB │ │ +│ └──────────┘ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### Components + +| Component | Role | +|-----------|------| +| **SNS Topic** | Event source that triggers the durable functions | +| **Lambda Durable Functions** | Business logic with automatic checkpointing | +| **Checkpoint Store** | Managed by Lambda; stores step results and execution state | +| **DynamoDB** | Optional application state for this workshop | +| **ECR** | Container registry for the Java function images | +| **Producer (EC2/CLI)** | Sends messages to SNS to trigger workflows | + +--- + +## The Durable Execution Model + +### Lifecycle of a Durable Execution + +``` +1. INVOKE → Function starts, SDK initializes +2. EXECUTE STEP → Business logic runs inside ctx.step() +3. CHECKPOINT → Result saved to checkpoint store +4. WAIT/SUSPEND → Function exits, no compute charges +5. RESUME → Backend re-invokes the function +6. REPLAY → SDK runs from top, skips checkpointed steps +7. CONTINUE → Execution continues from where it left off +``` + +### Replay: The Core Mechanism + +When a durable function resumes (after a wait, retry delay, or interruption), the SDK uses **replay**: + +1. Your code runs from the **beginning** of `handleRequest()` +2. When it hits a `step()` that already has a checkpoint, the SDK returns the **stored result** instantly +3. When it hits the first un-checkpointed operation, normal execution resumes + +This means your code must be **deterministic** between steps. Do NOT: +- Use `System.currentTimeMillis()` or `Math.random()` outside of steps +- Mutate external state outside of steps +- Branch on non-deterministic values between steps + +### What Goes Inside a Step vs. Outside + +| Inside `context.step()` | Outside steps | +|--------------------------|---------------| +| API calls | Control flow (if/else, loops) | +| Database operations | Variable assignments from step results | +| Current time / random values | Logging via `context.getLogger()` | +| Side effects (notifications) | Calling other durable operations | +| Non-deterministic code | Pure computation on step results | + +--- + +## Java SDK Reference + +### Maven Dependency + +```xml + + software.amazon.lambda.durable + aws-durable-execution-sdk-java + 1.0.0 + +``` + +### Key Packages + +```java +import software.amazon.lambda.durable.DurableContext; +import software.amazon.lambda.durable.DurableHandler; +import software.amazon.lambda.durable.DurableFuture; +import software.amazon.lambda.durable.DurableCallbackFuture; +import software.amazon.lambda.durable.StepContext; +import software.amazon.lambda.durable.TypeToken; + +import software.amazon.lambda.durable.config.StepConfig; +import software.amazon.lambda.durable.config.StepSemantics; +import software.amazon.lambda.durable.config.CallbackConfig; +import software.amazon.lambda.durable.config.WaitForCallbackConfig; +import software.amazon.lambda.durable.config.ParallelConfig; +import software.amazon.lambda.durable.config.MapConfig; +import software.amazon.lambda.durable.config.CompletionConfig; +import software.amazon.lambda.durable.config.WaitForConditionConfig; +import software.amazon.lambda.durable.config.RunInChildContextConfig; +import software.amazon.lambda.durable.config.InvokeConfig; + +import software.amazon.lambda.durable.retry.RetryStrategies; +import software.amazon.lambda.durable.retry.RetryStrategy; +import software.amazon.lambda.durable.retry.RetryDecision; +import software.amazon.lambda.durable.retry.JitterStrategy; +import software.amazon.lambda.durable.retry.WaitStrategies; +import software.amazon.lambda.durable.retry.WaitForConditionWaitStrategy; + +import software.amazon.lambda.durable.model.ParallelResult; +import software.amazon.lambda.durable.model.MapResult; +import software.amazon.lambda.durable.model.WaitForConditionResult; + +import software.amazon.lambda.durable.exception.StepFailedException; +import software.amazon.lambda.durable.exception.StepInterruptedException; +import software.amazon.lambda.durable.exception.CallbackFailedException; +import software.amazon.lambda.durable.exception.DurableOperationException; +``` + +--- + +## DurableHandler Base Class + +Every durable function extends `DurableHandler`: + +```java +public class MyHandler extends DurableHandler, Map> { + @Override + public Map handleRequest(Map event, DurableContext context) { + // Your durable workflow here + return result; + } +} +``` + +- `I` = Input type (the Lambda event) +- `O` = Output type (the return value) +- `DurableContext` replaces the standard Lambda `Context` + +### DurableContext Methods + +| Method | Description | +|--------|-------------| +| `step(name, resultType, func)` | Execute and checkpoint a unit of work | +| `stepAsync(name, resultType, func)` | Non-blocking step, returns `DurableFuture` | +| `wait(name, duration)` | Suspend for a duration (no compute) | +| `waitAsync(name, duration)` | Non-blocking wait | +| `waitForCondition(name, type, checkFunc, config)` | Poll until condition met | +| `createCallback(name, resultType)` | Create a callback and suspend | +| `waitForCallback(name, resultType, func)` | Create + submit + wait in one call | +| `parallel(name)` | Execute branches concurrently | +| `map(name, items, resultType, func)` | Process collection concurrently | +| `runInChildContext(name, resultType, func)` | Isolated sub-workflow | +| `runInChildContextAsync(name, resultType, func)` | Non-blocking sub-workflow | +| `invoke(name, functionName, payload, resultType)` | Call another Lambda function | +| `getLogger()` | Replay-aware logger | + +--- + +## Durable Operations + +### Step + +The fundamental building block. Executes code and checkpoints the result. + +```java +// Basic step +String result = context.step("my-step", String.class, stepCtx -> { + return "computed value"; +}); + +// Step with retry configuration +StepConfig config = StepConfig.builder() + .retryStrategy(RetryStrategies.exponentialBackoff( + 5, // maxAttempts + Duration.ofSeconds(2), // initialDelay + Duration.ofMinutes(1), // maxDelay + 2.0, // backoffRate + JitterStrategy.FULL)) // jitter + .build(); + +Map result = context.step("api-call", Map.class, stepCtx -> { + return callExternalApi(); +}, config); +``` + +**StepContext** provides: +- `stepCtx.getLogger()` - Durable logger +- `stepCtx.getAttempt()` - Current retry attempt (0-based) +- `stepCtx.isReplaying()` - Whether this is a replay + +### Wait + +Suspends execution without compute charges. + +```java +// Fixed delay +context.wait("delay-30s", Duration.ofSeconds(30)); + +// Async wait (minimum duration pattern) +DurableFuture timer = context.waitAsync("min-5s", Duration.ofSeconds(5)); +DurableFuture work = context.stepAsync("process", String.class, ctx -> doWork()); +timer.get(); // Ensure at least 5s elapsed +String r = work.get(); // Get the work result +``` + +### Wait for Condition (Polling) + +Polls an external system with configurable backoff. + +```java +WaitForConditionWaitStrategy> strategy = (state, attempt) -> { + if (attempt >= 20) throw new RuntimeException("Timeout"); + return Duration.ofSeconds(Math.min(5 * (long)Math.pow(2, attempt - 1), 300)); +}; + +Map finalState = context.waitForCondition( + "poll-status", + new TypeToken<>() {}, + (state, stepCtx) -> { + String status = checkStatus((String) state.get("id")); + Map updated = Map.of("id", state.get("id"), "status", status); + if ("DONE".equals(status)) { + return WaitForConditionResult.stopPolling(updated); + } + return WaitForConditionResult.continuePolling(updated); + }, + WaitForConditionConfig.>builder() + .initialState(Map.of("id", "job-123", "status", "PENDING")) + .waitStrategy(strategy) + .build() +); +``` + +### Callback (Human Interaction) + +Suspend and wait for an external system to respond. + +```java +// Approach 1: Manual callback +DurableCallbackFuture> callback = + context.createCallback("wait-approval", (Class>)(Class)Map.class, + CallbackConfig.builder().timeout(Duration.ofHours(72)).build()); + +// Send callback.callbackId() to the approver +notifyApprover(callback.callbackId()); + +// Execution suspends here until external callback arrives +Map decision = callback.get(); + +// Approach 2: Composite (create + submit + wait) +String result = context.waitForCallback("approval", String.class, + (callbackId, stepCtx) -> sendApprovalEmail(callbackId)); +``` + +**External system completes callback via AWS CLI:** +```bash +aws lambda send-durable-execution-callback-success \ + --callback-id --result '{"approved":true}' +``` + +### Parallel (Fan-Out/Fan-In) + +Execute different operations concurrently. + +```java +DurableFuture aFuture; +DurableFuture bFuture; + +try (var parallel = context.parallel("my-parallel")) { + aFuture = parallel.branch("branch-a", String.class, ctx -> + ctx.step("work-a", String.class, s -> computeA())); + bFuture = parallel.branch("branch-b", Integer.class, ctx -> + ctx.step("work-b", Integer.class, s -> computeB())); + ParallelResult result = parallel.get(); + // result.succeeded(), result.failed() +} + +String a = aFuture.get(); +int b = bFuture.get(); +``` + +**Configuration options:** +```java +ParallelConfig config = ParallelConfig.builder() + .maxConcurrency(5) + .completionConfig(CompletionConfig.firstSuccessful()) // Race pattern + .build(); +``` + +### Map (Collection Processing) + +Apply the same operation to every item in a collection. + +```java +MapResult result = context.map( + "process-items", + List.of("a", "b", "c"), + String.class, + (item, index, ctx) -> ctx.step("transform-" + index, String.class, + s -> item.toUpperCase()), + MapConfig.builder() + .maxConcurrency(3) + .completionConfig(CompletionConfig.toleratedFailureCount(1)) + .build() +); + +List successes = result.succeeded(); +``` + +### Child Context (Sub-Orchestration) + +Isolate groups of operations into reusable sub-workflows. + +```java +// Sequential sub-workflow +Map result = context.runInChildContext( + "payment-flow", Map.class, + child -> { + String auth = child.step("authorize", String.class, s -> authorize()); + child.wait("delay", Duration.ofSeconds(2)); + return child.step("capture", Map.class, s -> capture(auth)); + }); + +// Concurrent sub-workflows +DurableFuture paymentFuture = context.runInChildContextAsync( + "payment", String.class, child -> processPayment(child)); +DurableFuture inventoryFuture = context.runInChildContextAsync( + "inventory", String.class, child -> reserveInventory(child)); + +List results = DurableFuture.allOf(paymentFuture, inventoryFuture); +``` + +### Invoke (Cross-Function Calls) + +Call another Lambda function and suspend until it completes. + +```java +OrderResult result = context.invoke( + "process-order", + "order-processor-function:live", // Must include alias/version qualifier + event, + OrderResult.class +); +``` + +--- + +## Error Handling and Retries + +### Exception Hierarchy + +``` +DurableExecutionException (base) +├── UnrecoverableDurableExecutionException +│ ├── IllegalDurableOperationException +│ └── NonDeterministicExecutionException +├── DurableOperationException +│ ├── StepFailedException ← retry exhausted +│ ├── StepInterruptedException ← at-most-once interrupted +│ ├── CallbackFailedException ← external system reported failure +│ └── CallbackTimeoutException ← callback timed out +└── SerDesException ← serialization failed +``` + +### Retry Strategies + +```java +// Built-in exponential backoff +RetryStrategies.exponentialBackoff(maxAttempts, initialDelay, maxDelay, backoffRate, jitter) + +// Fixed delay +RetryStrategies.fixedDelay(maxAttempts, fixedDelay) + +// Presets +RetryStrategies.Presets.DEFAULT // 6 attempts, 5s initial, 60s max, 2x, full jitter +RetryStrategies.Presets.NO_RETRY // Fail immediately + +// Custom strategy +RetryStrategy custom = (error, attempt) -> { + if (error instanceof BusinessLogicException) return RetryDecision.fail(); + if (attempt >= 5) return RetryDecision.fail(); + return RetryDecision.retry(Duration.ofSeconds(attempt * 3)); +}; +``` + +### At-Most-Once Semantics + +For non-idempotent operations (payments, notifications): + +```java +StepConfig config = StepConfig.builder() + .semantics(StepSemantics.AT_MOST_ONCE_PER_RETRY) + .build(); + +try { + String txnId = context.step("charge", String.class, s -> chargeCard(), config); +} catch (StepInterruptedException e) { + // Step started but was interrupted before checkpointing + // Check external system for actual status +} +``` + +### Saga Compensation Pattern + +```java +Map payment = context.step("charge", Map.class, s -> charge()); + +try { + context.step("ship", Map.class, s -> shipOrder()); +} catch (StepFailedException e) { + // Compensation: refund the payment + context.step("refund", Map.class, s -> refund(payment.get("txnId"))); + return Map.of("status", "COMPENSATED"); +} +``` + +--- + +## Deployment Model + +### Container Image (Required for Java) + +Java durable functions deploy as **container images** (not zip files). + +**Dockerfile:** +```dockerfile +FROM --platform=linux/amd64 amazoncorretto:21-alpine AS builder +WORKDIR /build +COPY pom.xml . +COPY src ./src +RUN apk add --no-cache maven && mvn clean package -DskipTests + +FROM public.ecr.aws/lambda/java:21 +COPY --from=builder /build/target/*.jar ${LAMBDA_TASK_ROOT}/lib/ +CMD ["com.example.MyHandler::handleRequest"] +``` + +### DurableConfig + +When creating the function via CLI: +```bash +aws lambda create-function \ + --function-name my-function \ + --package-type Image \ + --code ImageUri= \ + --role \ + --durable-config '{"ExecutionTimeout": 900, "RetentionPeriodInDays": 7}' +``` + +| Parameter | Description | Range | +|-----------|-------------|-------| +| `ExecutionTimeout` | Max total execution time (seconds) | 1 - 31536000 (1 year) | +| `RetentionPeriodInDays` | How long to keep execution history | 1 - 365 | + +### IAM Role + +Use the managed policy: +``` +arn:aws:iam::aws:policy/service-role/AWSLambdaBasicDurableExecutionRolePolicy +``` + +For callback support, also add: +```json +{ + "Effect": "Allow", + "Action": [ + "lambda:SendDurableExecutionCallbackSuccess", + "lambda:SendDurableExecutionCallbackFailure", + "lambda:SendDurableExecutionCallbackHeartbeat" + ], + "Resource": "*" +} +``` + +### Versioning + +Always invoke with a published version or alias: +```bash +aws lambda publish-version --function-name my-function +# Invoke with :1 or :my-alias, not $LATEST +``` + +--- + +## Best Practices + +### 1. Keep Steps Focused + +Each step should do ONE thing. Don't put multiple API calls in a single step. + +```java +// GOOD: One concern per step +String userId = context.step("create-user", String.class, s -> createUser(email)); +String walletId = context.step("create-wallet", String.class, s -> createWallet(userId)); + +// BAD: Multiple concerns in one step +Map result = context.step("setup", Map.class, s -> { + String userId = createUser(email); + String walletId = createWallet(userId); // If this fails, createUser re-runs! + return Map.of("userId", userId, "walletId", walletId); +}); +``` + +### 2. Pass Data Via Return Values + +Never rely on shared mutable state between steps. + +```java +// GOOD: Data flows through step return values +String userId = context.step("register", String.class, s -> register(email)); +context.wait("delay", Duration.ofMinutes(10)); +context.step("welcome", Void.class, s -> { sendWelcome(userId); return null; }); + +// BAD: Shared variable mutation lost on replay +AtomicReference userId = new AtomicReference<>(""); +context.step("register", Void.class, s -> { userId.set(register(email)); return null; }); +context.wait("delay", Duration.ofMinutes(10)); +// userId is "" after replay! +``` + +### 3. Deterministic Code Between Steps + +Code between steps runs on every replay. Keep it deterministic. + +```java +// GOOD: Deterministic branching +String status = context.step("check", String.class, s -> getStatus()); +if ("READY".equals(status)) { // Deterministic: same value on replay + context.step("process", ...); +} + +// BAD: Non-deterministic branching +if (System.currentTimeMillis() % 2 == 0) { // Different on each replay! + context.step("process", ...); +} +``` + +### 4. Use Descriptive Step Names + +Names appear in logs, execution history, and debugging tools. + +```java +// GOOD +context.step("validate-shipping-address", ...); +context.step("charge-credit-card", ...); + +// BAD +context.step("step1", ...); +context.step("s", ...); +``` + +### 5. Configure Appropriate Timeouts + +Set `ExecutionTimeout` based on your longest expected execution path including all waits. + +### 6. Use Replay-Aware Logging + +```java +// GOOD: Uses the durable logger (logs once, not on every replay) +context.getLogger().info("Order processed"); + +// Inside steps: +context.step("work", String.class, stepCtx -> { + stepCtx.getLogger().info("Processing..."); // Only logs on actual execution + return result; +}); +``` + +--- + +## Testing + +### Local Test Runner + +```java +import software.amazon.lambda.durable.testing.LocalDurableTestRunner; +import software.amazon.lambda.durable.model.ExecutionStatus; + +@Test +void testChainingHandler() { + var handler = new OrderProcessingHandler(); + var runner = LocalDurableTestRunner.create(Map.class, handler); + + Map event = Map.of( + "orderId", "TEST-001", + "totalAmount", 99.99, + "customerId", "CUST-001" + ); + + var result = runner.runUntilComplete(event); + + assertEquals(ExecutionStatus.SUCCEEDED, result.getStatus()); + Map output = result.getResult(Map.class); + assertEquals("COMPLETED", output.get("finalStatus")); +} +``` + +### SAM CLI Local Invoke + +```bash +# Build and run locally +sam build +sam local invoke ChainingFunction --event events/chaining-event.json + +# For callbacks, use sam local callback +sam local callback succeed --result '{"decision":"APPROVED"}' +``` + +--- + +## Common Pitfalls + +| Pitfall | Problem | Solution | +|---------|---------|----------| +| Side effects outside steps | Not checkpointed, re-executes on replay | Wrap in `context.step()` | +| Mutable shared state | Lost on replay after wait/resume | Return values from steps | +| Non-deterministic branching | Different path on replay | Only branch on step results | +| `Thread.sleep()` | Blocks compute, charges you | Use `context.wait()` | +| Very large step results | May exceed checkpoint size limit | Keep results under 256KB | +| Nested steps | Not supported | Use `runInChildContext()` | +| Steps inside parallel branches accessing parent context | Corrupts execution state | Use the child context parameter | +| Missing version qualifier | Non-deterministic replay on $LATEST | Always use published version/alias | +| `new Date()` between steps | Non-deterministic on replay | Put inside a step | + +--- + +## Comparison with Python Workshop + +This Java workshop covers all patterns from the Python durable functions workshop, with working implementations: + +| Python Workshop Module | Java Workshop Equivalent | +|------------------------|--------------------------| +| Function Chaining | Module 3: `OrderProcessingHandler` | +| Fan-Out/Fan-In | Module 4: `ParallelProcessingHandler` | +| Human Interaction | Module 5: `ApprovalWorkflowHandler` | +| Monitor (Polling) | Module 6: `JobMonitorHandler` | +| Timer/Delay | Module 7: `ScheduledReminderHandler` | +| Error Handling | Module 8: `ResilientPaymentHandler` | +| (Not in Python) | Module 9: `BatchDataProcessorHandler` (Map) | +| (Not in Python) | Module 10: `OrderFulfillmentHandler` (Sub-orchestration) | + +The Java workshop adds Map Processing and Sub-Orchestration patterns not present in the Python workshop, providing more comprehensive coverage of the SDK's capabilities. diff --git a/lambda-durable-java-sam/DIAGRAMS.md b/lambda-durable-java-sam/DIAGRAMS.md new file mode 100644 index 000000000..9b0d41fea --- /dev/null +++ b/lambda-durable-java-sam/DIAGRAMS.md @@ -0,0 +1,67 @@ +# Lambda Durable Functions - Step Diagrams + +Visual step-by-step flow diagrams for each durable execution pattern. + +--- + +## Pattern 1: Function Chaining + +Sequential steps where each step's output feeds into the next. Completed steps are checkpointed and never re-executed on replay. + +![Pattern 1: Chaining](images/pattern1-chaining.svg) + +--- + +## Pattern 2: Fan-Out / Fan-In + +Parallel execution of independent operations with result aggregation. Uses `parallel()` for heterogeneous tasks and `map()` for homogeneous collection processing. + +![Pattern 2: Fan-Out/Fan-In](images/pattern2-fanout.svg) + +--- + +## Pattern 3: Human Interaction + +Execution suspends while waiting for an external human decision via callback. No compute charges during the wait. An external system calls `send-durable-execution-callback-success` or `send-durable-execution-callback-failure` to resume. + +![Pattern 3: Human Interaction](images/pattern3-human-interaction.svg) + +--- + +## Pattern 4: Monitoring / Polling + +Uses `waitForCondition` to poll an external system with exponential backoff. The function suspends between polls without consuming compute. + +![Pattern 4: Monitoring](images/pattern4-monitoring.svg) + +--- + +## Pattern 5: Timer / Scheduled Delays + +Uses `wait()` to suspend execution for specified durations. The function exits and is automatically resumed when the wait completes — zero compute charges during waits. + +![Pattern 5: Timer](images/pattern5-timer.svg) + +--- + +## Pattern 6: Error Handling & Saga Compensation + +Demonstrates retries with backoff, at-most-once semantics for non-idempotent operations, custom retry logic, and saga compensation when later steps fail. + +![Pattern 6: Error Handling](images/pattern6-error-handling.svg) + +--- + +## Pattern 7: Map Processing + +Processes a collection concurrently with configurable concurrency limits and failure tolerance. Each item gets its own child context with independent checkpoints. + +![Pattern 7: Map Processing](images/pattern7-map-processing.svg) + +--- + +## Pattern 8: Sub-Orchestration + +Uses `runInChildContext()` and `runInChildContextAsync()` to compose complex workflows from isolated, reusable sub-workflows. Each child context has its own operation namespace. + +![Pattern 8: Sub-Orchestration](images/pattern8-sub-orchestration.svg) diff --git a/lambda-durable-java-sam/README.md b/lambda-durable-java-sam/README.md new file mode 100644 index 000000000..507b6c544 --- /dev/null +++ b/lambda-durable-java-sam/README.md @@ -0,0 +1,452 @@ +# Java Lambda Durable Functions Examples + +This project demonstrates **AWS Lambda Durable Functions** patterns in Java. It provides working examples of all major durable execution patterns including chaining, fan-out/fan-in, human interaction, monitoring/polling, timers, error handling with saga compensation, map processing, and sub-orchestration. + +## Architecture + +``` +SNS Topic (Producer) --> Lambda Durable Functions (Consumers) --> DynamoDB (Results) +``` + +- **SNS Message Sender**: A Java CLI application that sends messages to SNS to trigger the durable functions +- **Durable Functions**: Eight Lambda functions, each demonstrating a different durable execution pattern +- **Infrastructure**: CloudFormation template for a VPC + EC2 instance with all tools pre-installed + +## Patterns Demonstrated + +| # | Pattern | Handler | Description | +|---|---------|---------|-------------| +| 1 | **Chaining** | `OrderProcessingHandler` | Sequential steps where each output feeds the next | +| 2 | **Fan-Out/Fan-In** | `ParallelProcessingHandler` | Parallel execution with result aggregation | +| 3 | **Human Interaction** | `ApprovalWorkflowHandler` | Callbacks for external human approval | +| 4 | **Monitoring/Polling** | `JobMonitorHandler` | waitForCondition with exponential backoff | +| 5 | **Timer/Delays** | `ScheduledReminderHandler` | Suspending execution without compute charges | +| 6 | **Error Handling** | `ResilientPaymentHandler` | Retries, at-most-once, and saga compensation | +| 7 | **Map Processing** | `BatchDataProcessorHandler` | Concurrent collection processing with tolerance | +| 8 | **Sub-Orchestration** | `OrderFulfillmentHandler` | Child contexts for workflow composition | + +## Prerequisites + +- AWS Account with permissions for Lambda, SNS, DynamoDB, ECR, IAM, CloudFormation +- Java 21 (Amazon Corretto recommended) +- Maven 3.8+ +- Docker +- AWS CLI v2 +- AWS SAM CLI + +## Quick Start + +You can run this project either from an EC2 instance (Option A) or from your local machine (Option B). + +### Option A: Using the EC2 Dev Environment + +The CloudFormation template deploys a VPC with a public subnet and an EC2 instance pre-configured with Java 21, Maven, Docker, AWS CLI v2, and SAM CLI. The project code is automatically cloned onto the instance. + +**1. Deploy the infrastructure stack:** + +1. Open the [AWS CloudFormation console](https://console.aws.amazon.com/cloudformation/) +2. Click **Create stack** > **With new resources (standard)** +3. Select **Upload a template file**, then upload `infrastructure/ec2-client-instance.yaml` +4. Enter stack name: `durable-functions-infra` +5. Click **Next** through the options, check **I acknowledge that AWS CloudFormation might create IAM resources with custom names**, then click **Submit** +6. Wait for the stack status to reach **CREATE_COMPLETE** (~5 minutes) + +**2. Connect to the instance via EC2 Instance Connect:** + +1. Open the [AWS EC2 console](https://console.aws.amazon.com/ec2/) +2. Click **Instances** in the left navigation +3. Select the instance named `DurableFunctionsDevInstance` (created by the stack) +4. Click **Connect** at the top +5. On the **EC2 Instance Connect** tab, click **Connect** + +**3. Navigate to the project directory:** + +```bash +cd /home/ec2-user/serverless-patterns/lambda-durable-java-sam +``` + +Then continue from [Build and Deploy](#build-and-deploy) below. + +### Option B: Local Development + +Ensure you have Java 21+, Maven 3.8+, Docker, AWS CLI v2, and SAM CLI installed. + +**1. Set JAVA_HOME:** + +Ensure `JAVA_HOME` points to your Java 21 installation directory (not the `bin` subdirectory): + +**macOS (zsh/bash):** +```bash +export JAVA_HOME=/Library/Java/JavaVirtualMachines/amazon-corretto-21.jdk/Contents/Home +``` + +**Linux (bash):** +```bash +export JAVA_HOME=/usr/lib/jvm/java-21-amazon-corretto +``` + +**Windows (PowerShell):** +```powershell +$env:JAVA_HOME = "C:\Program Files\Amazon Corretto\jdk21.0.x_x" +``` + +**Windows (Command Prompt):** +```cmd +set JAVA_HOME=C:\Program Files\Amazon Corretto\jdk21.0.x_x +``` + +Verify it's correct: +```bash +echo $JAVA_HOME # Should NOT end with /bin +$JAVA_HOME/bin/java -version # Should print Java 21 +``` + +Add the export to your shell profile (`~/.zshrc`, `~/.bashrc`, or Windows System Environment Variables) to persist across sessions. + +**2. Configure your AWS credentials and region:** + +If you don't have an AWS CLI profile configured, create one pointing to the account where you want to deploy: + +```bash +aws configure +``` + +If you have multiple profiles, set your desired profile as the default for this shell session: + +**macOS/Linux:** +```bash +export AWS_PROFILE=your-profile-name +export AWS_REGION=us-east-1 # Change to your preferred region +``` + +**Windows (PowerShell):** +```powershell +$env:AWS_PROFILE = "your-profile-name" +$env:AWS_REGION = "us-east-1" # Change to your preferred region +``` + +**Windows (Command Prompt):** +```cmd +set AWS_PROFILE=your-profile-name +set AWS_REGION=us-east-1 +``` + +**3. Clone the project:** + +```bash +git clone --depth 1 --filter=blob:none --sparse \ + https://github.com/aws-samples/serverless-patterns.git +cd serverless-patterns +git sparse-checkout set lambda-durable-java-sam +cd lambda-durable-java-sam +``` + +Then continue from [Build and Deploy](#build-and-deploy) below. + +--- + +## Build and Deploy + +### 1. Build the Durable Functions Container Image + +```bash +cd durable-functions-sam/durable-functions +mvn clean package +``` + +Build the Docker image. Use the command that matches your environment: + +**From EC2 instance (Amazon Linux x86_64):** +```bash +docker build -t durable-functions-java-examples . +``` + +**From local machine (macOS, Linux, or Windows with Docker Desktop):** +```bash +docker build --platform linux/amd64 --provenance=false -t durable-functions-java-examples . +``` + +> **Why these flags?** +> - `--platform linux/amd64` — Lambda runs on x86_64. Required on Apple Silicon Macs (M1/M2/M3/M4) to avoid building an ARM image. Harmless on x86_64 machines. +> - `--provenance=false` — Required on **all platforms** with Docker BuildKit (Docker 23+). Prevents Docker from producing OCI image indexes. Lambda only supports Docker V2 manifest format. Without this flag you'll see: *"The image manifest, config or layer media type... is not supported."* +> +> **Tip:** If you're unsure which platform you're on, always use the local machine command — it works everywhere. + +### 2. Push to ECR + +```bash +AWS_ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text) +AWS_REGION=${AWS_REGION:-$(aws configure get region)} + +# Verify variables are set correctly before proceeding +echo "Account: $AWS_ACCOUNT_ID" +echo "Region: $AWS_REGION" + +# Login to ECR +aws ecr get-login-password --region $AWS_REGION \ + | docker login --username AWS --password-stdin \ + $AWS_ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com + +# Delete the repository if it already exists, then recreate it +aws ecr delete-repository --repository-name durable-functions-java-examples --force 2>/dev/null +aws ecr create-repository --repository-name durable-functions-java-examples + +# Tag and push +docker tag durable-functions-java-examples:latest \ + $AWS_ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com/durable-functions-java-examples:latest + +docker push \ + $AWS_ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com/durable-functions-java-examples:latest +``` + +> **Checkpoint:** Before continuing, verify the push succeeded and the image has the correct manifest type: +> ```bash +> aws ecr describe-images --repository-name durable-functions-java-examples --region $AWS_REGION \ +> --query 'imageDetails[?imageTags[0]==`latest`].imageManifestMediaType' --output text +> ``` +> Expected output: `application/vnd.docker.distribution.manifest.v2+json` +> +> If you see `application/vnd.oci.image.index.v1+json` instead, rebuild the Docker image with `--provenance=false` and push again. + +### 3. Deploy with SAM + +Navigate to the SAM template directory: + +**From EC2:** +```bash +cd /home/ec2-user/serverless-patterns/lambda-durable-java-sam/durable-functions-sam +``` + +**From local machine:** +```bash +cd ../.. +cd durable-functions-sam +``` + +If you have a previously deployed stack, delete it first (durable execution config cannot be added to existing functions): +```bash +aws cloudformation delete-stack --stack-name +aws cloudformation wait stack-delete-complete --stack-name +``` + +Deploy: +```bash +sam deploy --guided --capabilities CAPABILITY_NAMED_IAM +``` + +When prompted: +- **Stack Name**: choose a name (e.g., `lambda-durable-java-sam`) +- **AWS Region**: enter your region (must match where you pushed the ECR image) +- **Parameter SNSTopicName**: press Enter to accept default +- **Confirm changes before deploy**: `N` +- **Allow SAM CLI IAM role creation**: `Y` +- **Disable rollback**: `N` +- **Save arguments to configuration file**: `Y` + +Wait for `CREATE_COMPLETE`. If it fails, check the [Troubleshooting](#troubleshooting) section below. + +### 4. Build the SNS Message Sender + +```bash +cd ../sns-message-sender +mvn clean package +``` + +### 5. Trigger a Durable Function + +Ensure `AWS_PROFILE` and `AWS_REGION` are set to the same profile/region you used for deployment (see Step 2 under Option B), then run: + +```bash +java -cp target/sns-message-sender-1.0-SNAPSHOT.jar \ + sns.producer.DurableFunctionsTrigger DurableFunctionsSNSTopic chaining +``` + +> **Note:** On the EC2 instance (Option A), credentials are provided automatically via the instance role — no extra setup needed. If you get credential errors locally, see the [Troubleshooting](#troubleshooting) section. + +--- + +## Troubleshooting + +### "The image manifest, config or layer media type... is not supported" + +The Docker image was pushed in OCI format. Lambda requires Docker V2 format. Fix: +```bash +docker build --platform linux/amd64 --provenance=false -t durable-functions-java-examples . +aws ecr delete-repository --repository-name durable-functions-java-examples --force +aws ecr create-repository --repository-name durable-functions-java-examples +docker tag durable-functions-java-examples:latest \ + $AWS_ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com/durable-functions-java-examples:latest +docker push \ + $AWS_ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com/durable-functions-java-examples:latest +``` + +### "JAVA_HOME environment variable is not defined correctly" + +`JAVA_HOME` must point to the JDK root (e.g., `.../Contents/Home`), **not** the `bin` directory. Fix: +```bash +# Wrong: +export JAVA_HOME=/Library/Java/JavaVirtualMachines/amazon-corretto-21.jdk/Contents/Home/bin + +# Correct: +export JAVA_HOME=/Library/Java/JavaVirtualMachines/amazon-corretto-21.jdk/Contents/Home +``` + +### "Cannot connect to the Docker daemon" + +Docker is not running. Start your Docker runtime: +- **Docker Desktop**: Open Docker Desktop from Applications +- **Colima**: `colima start && docker context use colima` + +### AWS_REGION is empty / ECR endpoint errors + +Ensure `AWS_REGION` is set before running the commands: +```bash +export AWS_REGION=eu-west-2 # Your target region +``` + +### "Unable to load credentials from any of the providers in the chain" when triggering patterns + +The Java AWS SDK cannot find valid credentials. Common causes and fixes: + +1. **`AWS_PROFILE` not set** — The Java SDK uses the `default` profile unless told otherwise: + ```bash + export AWS_PROFILE=your-profile-name + ``` + +2. **Profile uses `credential_process` or SSO** — Some credential sources (e.g., `credential_process`, `aws sso`) may not be compatible with all versions of the Java AWS SDK. Resolve by exporting credentials into the environment: + ```bash + eval $(aws configure export-credentials --profile your-profile --format env) + ``` + +3. **Verify credentials work** — Test that the Java SDK can reach AWS: + ```bash + aws sts get-caller-identity + ``` + If this works but the Java app doesn't, your profile likely uses a credential method the Java SDK can't invoke. Use the `eval` command above. + +### Stack stuck in ROLLBACK_COMPLETE + +Delete the failed stack before redeploying: +```bash +aws cloudformation delete-stack --stack-name +aws cloudformation wait stack-delete-complete --stack-name +``` + +## Triggering Each Pattern + +```bash +# Pattern 1: Chaining - Sequential order processing +java -cp target/sns-message-sender-1.0-SNAPSHOT.jar \ + sns.producer.DurableFunctionsTrigger DurableFunctionsSNSTopic chaining + +# Pattern 2: Fan-Out/Fan-In - Parallel data analysis +java -cp target/sns-message-sender-1.0-SNAPSHOT.jar \ + sns.producer.DurableFunctionsTrigger DurableFunctionsSNSTopic fanout + +# Pattern 3: Human Interaction - Approval workflow (requires callback) +java -cp target/sns-message-sender-1.0-SNAPSHOT.jar \ + sns.producer.DurableFunctionsTrigger DurableFunctionsSNSTopic approval + +# Pattern 4: Monitoring - Job status polling +java -cp target/sns-message-sender-1.0-SNAPSHOT.jar \ + sns.producer.DurableFunctionsTrigger DurableFunctionsSNSTopic monitoring + +# Pattern 5: Timer - Scheduled reminders +java -cp target/sns-message-sender-1.0-SNAPSHOT.jar \ + sns.producer.DurableFunctionsTrigger DurableFunctionsSNSTopic timer + +# Pattern 6: Error Handling - Resilient payment with saga +java -cp target/sns-message-sender-1.0-SNAPSHOT.jar \ + sns.producer.DurableFunctionsTrigger DurableFunctionsSNSTopic errorhandling + +# Pattern 7: Map Processing - Batch concurrent processing +java -cp target/sns-message-sender-1.0-SNAPSHOT.jar \ + sns.producer.DurableFunctionsTrigger DurableFunctionsSNSTopic mapprocessing + +# Pattern 8: Sub-Orchestration - Nested child workflows +java -cp target/sns-message-sender-1.0-SNAPSHOT.jar \ + sns.producer.DurableFunctionsTrigger DurableFunctionsSNSTopic suborchestration +``` + +## Completing the Human Interaction Callback + +After triggering the `approval` pattern, check CloudWatch Logs for the callback ID, then: + +```bash +# To approve: +aws lambda send-durable-execution-callback-success \ + --callback-id \ + --cli-binary-format raw-in-base64-out \ + --result '{"decision":"APPROVED","approver":"manager@example.com"}' + +# To reject: +aws lambda send-durable-execution-callback-failure \ + --callback-id \ + --error ErrorType=Rejected,ErrorMessage="Budget exceeded policy limit" +``` + +## Step Diagrams + +For visual step-by-step flow diagrams of each pattern, see [DIAGRAMS.md](DIAGRAMS.md). + +## Key Concepts + +### DurableHandler +All durable functions extend `DurableHandler` instead of implementing `RequestHandler`. This provides a `DurableContext` with durable operations. + +### Steps +Steps checkpoint their results. On replay after a wait or failure, the SDK returns the stored result without re-executing the step body. + +### Waits +`context.wait()` suspends execution without consuming compute. The backend resumes the function when the duration elapses. + +### Callbacks +`context.createCallback()` suspends execution and waits for an external system to call `SendDurableExecutionCallbackSuccess`. + +### Parallel & Map +`context.parallel()` runs different operations concurrently. `context.map()` applies the same operation to each item in a collection. + +### Child Contexts +`context.runInChildContext()` creates isolated sub-workflows with their own operation namespaces. + +### Retry Strategies +Configure automatic retries with exponential backoff, jitter, and error filtering using `StepConfig` and `RetryStrategies`. + +## Monitoring + +View durable execution status: +```bash +aws lambda list-durable-executions --function-name durable-chaining-example:1 +aws lambda get-durable-execution --function-name durable-chaining-example:1 --execution-id +``` + +## Project Structure + +``` +lambda-durable-java-sam/ +├── README.md +├── USER_GUIDE.md +├── DEVELOPER_GUIDE.md +├── build-and-deploy.sh +├── infrastructure/ +│ └── ec2-client-instance.yaml # VPC + EC2 CloudFormation template +├── durable-functions-sam/ +│ ├── template.yaml # SAM template for all Lambda functions +│ └── durable-functions/ +│ ├── pom.xml +│ ├── Dockerfile +│ └── src/main/java/com/amazonaws/samples/durable/ +│ ├── models/ # Shared model classes +│ ├── chaining/ # Pattern 1: Sequential steps +│ ├── fanout/ # Pattern 2: Parallel execution +│ ├── humaninteraction/ # Pattern 3: Callbacks +│ ├── monitoring/ # Pattern 4: Polling +│ ├── timer/ # Pattern 5: Scheduled delays +│ ├── errorhandling/ # Pattern 6: Retries & compensation +│ ├── mapprocessing/ # Pattern 7: Collection processing +│ └── suborchestration/ # Pattern 8: Child contexts +└── sns-message-sender/ + ├── pom.xml + └── src/main/java/sns/producer/ + └── DurableFunctionsTrigger.java +``` diff --git a/lambda-durable-java-sam/USER_GUIDE.md b/lambda-durable-java-sam/USER_GUIDE.md new file mode 100644 index 000000000..413140005 --- /dev/null +++ b/lambda-durable-java-sam/USER_GUIDE.md @@ -0,0 +1,498 @@ +# Lambda Durable Functions in Java - Workshop User Guide + +## Introduction + +Welcome to the **AWS Lambda Durable Functions in Java** workshop! In this workshop, you will learn how to build resilient, long-running workflows using Lambda Durable Functions. You'll deploy and run eight different patterns that demonstrate the full capability of durable execution in Java. + +### Visual Step Diagrams + +To see the visual flow of each pattern, refer to [DIAGRAMS.md](DIAGRAMS.md). + +### What are Lambda Durable Functions? + +Lambda Durable Functions enable you to build multi-step applications that can execute for up to **one year** while maintaining reliable progress despite interruptions. Key capabilities: + +- **Automatic checkpointing**: Each step's result is saved. If the function is interrupted, it resumes from the last checkpoint without re-executing completed work. +- **Suspend without cost**: The `wait()` operation suspends your function without incurring compute charges. You pay only for actual processing time. +- **Replay mechanism**: After resuming, your code runs from the beginning but skips completed checkpoints, using stored results instead. + +### How is this different from Step Functions? + +| Feature | Durable Functions | Step Functions | +|---------|-------------------|----------------| +| Definition | Code (Java/Python/TypeScript) | JSON/YAML DSL or visual designer | +| Runs in | Lambda | Standalone service | +| Best for | Tightly-coupled business logic | Service orchestration across AWS | +| State management | SDK handles automatically | Managed by service | + +--- + +## Prerequisites + +Before starting this workshop, ensure you have: + +- An AWS account with admin permissions +- Basic familiarity with Java and AWS Lambda +- One of: + - **Option A**: Deploy the EC2 instance (everything pre-installed) + - **Option B**: Local machine with Java 21, Maven 3.8+, Docker, AWS CLI v2, and SAM CLI + +--- + +## Module 1: Environment Setup + +### Option A: Deploy EC2 Development Environment + +This creates a VPC with a public subnet and an EC2 instance pre-configured with all tools. + +```bash +aws cloudformation create-stack \ + --stack-name durable-functions-infra \ + --template-body file://infrastructure/ec2-client-instance.yaml \ + --capabilities CAPABILITY_NAMED_IAM \ + --parameters \ + ParameterKey=JavaVersion,ParameterValue=java21 +``` + +Wait for the stack to complete (~5 minutes): +```bash +aws cloudformation wait stack-create-complete --stack-name durable-functions-infra +``` + +Connect to the instance via EC2 Instance Connect: +```bash +INSTANCE_ID=$(aws cloudformation describe-stacks --stack-name durable-functions-infra \ + --query 'Stacks[0].Outputs[?OutputKey==`EC2InstanceId`].OutputValue' --output text) +aws ec2-instance-connect ssh --instance-id $INSTANCE_ID +``` + +### Option B: Local Development + +Ensure you have: +```bash +java -version # Should be 21+ +mvn -version # Should be 3.8+ +docker --version +aws --version # Should be v2 +sam --version +``` + +Clone the project: +```bash +git clone https://github.com/aws-samples/serverless-patterns.git +cd serverless-patterns/lambda-durable-java-sam +``` + +--- + +## Module 2: Build and Deploy + +### Step 1: Build the Lambda functions + +```bash +cd durable-functions-sam/durable-functions +mvn clean package +``` + +You should see `BUILD SUCCESS`. + +### Step 2: Build the Docker container image + +```bash +docker build --platform linux/amd64 --provenance=false -t durable-functions-java-examples . +``` + +> **Note:** The `--platform linux/amd64` flag is required when building on Apple Silicon (M1/M2/M3) Macs. The `--provenance=false` flag ensures the image is pushed in Docker V2 manifest format rather than OCI format, which Lambda requires. + +### Step 3: Create ECR repository and push + +```bash +AWS_ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text) +AWS_REGION=${AWS_REGION:-$(aws configure get region)} + +# Login to ECR +aws ecr get-login-password --region $AWS_REGION \ + | docker login --username AWS --password-stdin \ + $AWS_ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com + +# Delete the repository if it already exists, then recreate it +aws ecr delete-repository --repository-name durable-functions-java-examples --force 2>/dev/null +aws ecr create-repository --repository-name durable-functions-java-examples + +# Tag and push +docker tag durable-functions-java-examples:latest \ + $AWS_ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com/durable-functions-java-examples:latest + +docker push \ + $AWS_ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com/durable-functions-java-examples:latest +``` + +### Step 4: Deploy with SAM + +```bash +cd .. +sam deploy --guided --capabilities CAPABILITY_NAMED_IAM +``` + +Accept the defaults or customize: +- Stack name: `durable-functions-java-examples` +- Region: your preferred region +- Confirm changeset: Yes +- Allow SAM CLI to create IAM roles: Yes + +> **Note:** If redeploying, delete the existing stack first since durable execution config cannot be added to existing functions: +> ```bash +> aws cloudformation delete-stack --stack-name durable-functions-java-examples +> aws cloudformation wait stack-delete-complete --stack-name durable-functions-java-examples +> ``` + +### Step 5: Build the SNS message sender + +```bash +cd ../sns-message-sender +mvn clean package +``` + +--- + +## Module 3: Function Chaining + +**Pattern**: Execute steps sequentially where each step's output feeds the next. + +**Why durable**: If the function is interrupted after step 3 of 5, it resumes from step 4 on replay without re-executing steps 1-3. + +### Run it + +```bash +java -cp target/sns-message-sender-1.0-SNAPSHOT.jar \ + sns.producer.DurableFunctionsTrigger DurableFunctionsSNSTopic chaining +``` + +### What happens + +1. **validate-order** - Validates order ID and amount +2. **reserve-inventory** - Reserves items in warehouse +3. **process-payment** - Charges the customer +4. **ship-order** - Creates shipping label and tracking number +5. **notify-customer** - Sends shipment notification + +### Observe + +Check CloudWatch Logs: +```bash +aws logs tail /aws/lambda/durable-chaining-example --follow +``` + +Check execution status: +```bash +aws lambda list-durable-executions --function-name durable-chaining-example:1 +``` + +### Key takeaway + +Each `context.step()` checkpoints its result. The variable returned from step N is safely used in step N+1 even after a replay because the SDK restores it from the checkpoint. + +--- + +## Module 4: Fan-Out / Fan-In + +**Pattern**: Execute multiple independent operations in parallel, then aggregate results. + +**Why durable**: Each parallel branch checkpoints independently. If one branch fails after others complete, only the failed branch re-executes. + +### Run it + +```bash +java -cp target/sns-message-sender-1.0-SNAPSHOT.jar \ + sns.producer.DurableFunctionsTrigger DurableFunctionsSNSTopic fanout +``` + +### What happens + +1. Three analyses run **concurrently** via `parallel()`: + - Sentiment analysis + - Entity extraction + - Summarization +2. Five documents are processed via `map()` with the same logic per item +3. All results are aggregated in a final step + +### Key takeaway + +- `parallel()` is for **different** operations running concurrently +- `map()` is for the **same** operation applied to each item in a collection +- Both support concurrency limits and failure tolerance + +--- + +## Module 5: Human Interaction (Callbacks) + +**Pattern**: Suspend execution while waiting for external human input. + +**Why durable**: The function suspends (no compute charges) for hours or days while waiting for a human to respond. + +### Run it + +```bash +java -cp target/sns-message-sender-1.0-SNAPSHOT.jar \ + sns.producer.DurableFunctionsTrigger DurableFunctionsSNSTopic approval +``` + +### Complete the callback + +Check the logs for the callback ID: +```bash +aws logs tail /aws/lambda/durable-human-interaction-example --follow +``` + +Look for: `Approval callback created. Callback ID: ` + +Then approve: +```bash +aws lambda send-durable-execution-callback-success \ + --callback-id \ + --cli-binary-format raw-in-base64-out \ + --result '{"decision":"APPROVED","approver":"manager@example.com"}' +``` + +Or reject: +```bash +aws lambda send-durable-execution-callback-failure \ + --callback-id \ + --error ErrorType=Rejected,ErrorMessage="Budget exceeded" +``` + +### Key takeaway + +- `createCallback()` returns a callback ID that you send to the external system +- The function **suspends** (exits) - no compute charges while waiting +- When the external system calls `send-durable-execution-callback-success`, the function resumes +- Timeout and heartbeat can detect stalled approvers + +--- + +## Module 6: Monitoring / Polling + +**Pattern**: Poll an external system on a schedule until a condition is met. + +**Why durable**: The function suspends between polling attempts. You don't consume compute while waiting for the external system. + +### Run it + +```bash +java -cp target/sns-message-sender-1.0-SNAPSHOT.jar \ + sns.producer.DurableFunctionsTrigger DurableFunctionsSNSTopic monitoring +``` + +### What happens + +1. Acknowledges the job +2. Polls every few seconds with exponential backoff (5s, 10s, 20s...) +3. Between polls, the function **suspends** (no compute) +4. When the job reports COMPLETED, polling stops +5. Processes the final result + +### Key takeaway + +- `waitForCondition()` handles the entire poll-wait-check cycle +- Return `WaitForConditionResult.stopPolling(state)` to end +- Return `WaitForConditionResult.continuePolling(state)` to keep going +- The wait strategy controls backoff between attempts + +--- + +## Module 7: Timers / Scheduled Delays + +**Pattern**: Insert delays between steps without consuming compute. + +**Why durable**: `context.wait()` suspends the function. You pay nothing during the wait, whether it's 5 seconds or 5 days. + +### Run it + +```bash +java -cp target/sns-message-sender-1.0-SNAPSHOT.jar \ + sns.producer.DurableFunctionsTrigger DurableFunctionsSNSTopic timer +``` + +### What happens + +1. Creates a task assignment +2. Sends initial notification +3. **Waits 30 seconds** (function suspends, no compute) +4. Sends first reminder +5. **Waits 60 seconds** (function suspends again) +6. Escalates to manager +7. Demonstrates async wait pattern (minimum duration guarantee) + +### Key takeaway + +- `context.wait("name", Duration.ofSeconds(30))` suspends for 30s at zero cost +- `context.waitAsync()` combined with `stepAsync()` ensures minimum elapsed time +- Maximum wait is 1 year +- Minimum wait is 1 second + +--- + +## Module 8: Error Handling and Compensation + +**Pattern**: Automatic retries with saga compensation when later steps fail. + +**Why durable**: Retry delays don't consume compute. Failed steps checkpoint the error so compensation logic runs only once. + +### Run it (success path) + +```bash +java -cp target/sns-message-sender-1.0-SNAPSHOT.jar \ + sns.producer.DurableFunctionsTrigger DurableFunctionsSNSTopic errorhandling +``` + +### Run it (compensation path) + +Trigger with `simulateInventoryFailure=true` by modifying the event or using the AWS CLI directly: +```bash +aws lambda invoke \ + --function-name durable-error-handling-example:1 \ + --cli-binary-format raw-in-base64-out \ + --payload '{"orderId":"ORD-FAIL","amount":99.99,"simulateInventoryFailure":true}' \ + response.json && cat response.json +``` + +### What happens + +1. **Validate** - Standard retry (3 attempts, exponential backoff) +2. **Charge payment** - Custom retry strategy + at-most-once semantics +3. **Reserve inventory** - If this fails, triggers compensation +4. **Compensation** - Refunds the payment (saga pattern) + +### Key takeaway + +- `RetryStrategies.exponentialBackoff()` for standard retries +- Custom `RetryStrategy` lambda for error-type filtering +- `StepSemantics.AT_MOST_ONCE_PER_RETRY` prevents double-charging +- Catch `StepFailedException` to implement compensation logic +- Between retry attempts, the function suspends (no compute) + +--- + +## Module 9: Map Processing + +**Pattern**: Process a collection of items concurrently with rate limiting and failure tolerance. + +**Why durable**: Each item checkpoints independently. Partial failures don't prevent successful items from being recorded. + +### Run it + +```bash +java -cp target/sns-message-sender-1.0-SNAPSHOT.jar \ + sns.producer.DurableFunctionsTrigger DurableFunctionsSNSTopic mapprocessing +``` + +### What happens + +1. Generates a batch of 10 items +2. Processes them with `maxConcurrency=3` (only 3 run at once) +3. Each item goes through: validate → transform → store +4. Every 5th item fails validation (to demonstrate failure tolerance) +5. With `toleratedFailures=2`, the overall operation succeeds +6. Produces a summary with success/failure counts + +### Key takeaway + +- `MapConfig.builder().maxConcurrency(N)` limits parallel execution +- `CompletionConfig.toleratedFailureCount(N)` allows N failures +- Each item runs in its own child context (isolated checkpoints) +- `mapResult.succeeded()` and `mapResult.failed()` access results + +--- + +## Module 10: Sub-Orchestration + +**Pattern**: Compose complex workflows from isolated, reusable sub-workflows. + +**Why durable**: Each child context checkpoints as a unit. On replay, entire sub-workflows are skipped if already complete. + +### Run it + +```bash +java -cp target/sns-message-sender-1.0-SNAPSHOT.jar \ + sns.producer.DurableFunctionsTrigger DurableFunctionsSNSTopic suborchestration +``` + +### What happens + +1. Validates the order (main context) +2. Runs **Payment** and **Inventory** sub-workflows **concurrently** via `runInChildContextAsync()` +3. Runs **Shipping** sub-workflow (sequential, depends on inventory) +4. Runs **Notification** sub-workflow + +Each sub-workflow contains multiple steps internally (e.g., Payment: authorize → fraud check → capture). + +### Key takeaway + +- `runInChildContext()` creates an isolated scope with its own checkpoints +- `runInChildContextAsync()` + `DurableFuture.allOf()` runs sub-workflows concurrently +- Child contexts provide namespace isolation (operation IDs don't conflict) +- On replay, a completed child context returns its checkpointed result instantly + +--- + +## Module 11: Cleanup + +Delete all deployed resources: + +```bash +# Delete the SAM stack +aws cloudformation delete-stack --stack-name durable-functions-java-examples +aws cloudformation wait stack-delete-complete --stack-name durable-functions-java-examples + +# Delete the ECR repository +aws ecr delete-repository --repository-name durable-functions-java-examples --force + +# Delete the infrastructure stack (if deployed) +aws cloudformation delete-stack --stack-name durable-functions-infra +aws cloudformation wait stack-delete-complete --stack-name durable-functions-infra +``` + +--- + +## Troubleshooting + +### Build fails with "package software.amazon.lambda.durable does not exist" + +The Durable Execution SDK may not yet be in Maven Central. Check the [SDK repository](https://github.com/aws/aws-durable-execution-sdk-java) for the latest installation instructions. You may need to build from source or use a pre-release repository. + +### "Function not found" when invoking + +Ensure you published a version and are using the qualified ARN: +```bash +aws lambda publish-version --function-name durable-chaining-example +# Then invoke with :1 qualifier +``` + +### Callback not resuming + +- Verify the callback ID is correct (check CloudWatch Logs) +- Ensure you're using `--cli-binary-format raw-in-base64-out` +- Check that the function's IAM role has `lambda:SendDurableExecutionCallbackSuccess` permission + +### Durable execution stuck + +List and inspect executions: +```bash +aws lambda list-durable-executions --function-name :1 +aws lambda get-durable-execution --function-name :1 --execution-id +``` + +Stop a stuck execution: +```bash +aws lambda stop-durable-execution --function-name :1 --execution-id +``` + +--- + +## Next Steps + +- Read the [AWS Durable Execution SDK Developer Guide](https://docs.aws.amazon.com/durable-execution/) +- Explore the [Lambda Durable Functions documentation](https://docs.aws.amazon.com/lambda/latest/dg/durable-functions.html) +- Try combining patterns (e.g., chaining + callbacks + map processing in one workflow) +- Add DynamoDB persistence to store execution results +- Implement idempotency keys for production payment flows diff --git a/lambda-durable-java-sam/build-and-deploy.sh b/lambda-durable-java-sam/build-and-deploy.sh new file mode 100755 index 000000000..b78cf1594 --- /dev/null +++ b/lambda-durable-java-sam/build-and-deploy.sh @@ -0,0 +1,77 @@ +#!/bin/bash +set -e + +echo "============================================================" +echo " Lambda Durable Functions Java - Build and Deploy" +echo "============================================================" + +AWS_ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text) +AWS_REGION=${AWS_REGION:-$(aws configure get region)} +ECR_REPO="durable-functions-java-examples" +IMAGE_URI="$AWS_ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com/$ECR_REPO:latest" + +echo "" +echo "AWS Account: $AWS_ACCOUNT_ID" +echo "Region: $AWS_REGION" +echo "ECR Image: $IMAGE_URI" +echo "" + +# Step 1: Build the durable functions JAR +echo ">>> Step 1: Building durable functions JAR..." +cd durable-functions-sam/durable-functions +mvn clean package +echo " Build complete." + +# Step 2: Build Docker image +echo "" +echo ">>> Step 2: Building Docker image..." +docker build --platform linux/amd64 --provenance=false -t $ECR_REPO . +echo " Docker image built." + +# Step 3: Delete and recreate ECR repository +echo "" +echo ">>> Step 3: Recreating ECR repository..." +aws ecr delete-repository --repository-name $ECR_REPO --force 2>/dev/null || true +aws ecr create-repository --repository-name $ECR_REPO +echo " Repository created." + +# Step 4: Login to ECR and push +echo "" +echo ">>> Step 4: Pushing image to ECR..." +aws ecr get-login-password --region $AWS_REGION \ + | docker login --username AWS --password-stdin \ + $AWS_ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com + +docker tag $ECR_REPO:latest $IMAGE_URI +docker push $IMAGE_URI +echo " Image pushed." + +# Step 5: Deploy SAM stack +echo "" +echo ">>> Step 5: Deploying SAM stack..." +cd .. +sam deploy \ + --template-file template.yaml \ + --stack-name durable-functions-java-examples \ + --capabilities CAPABILITY_NAMED_IAM \ + --no-confirm-changeset \ + --resolve-image-repos + +# Step 6: Build the SNS sender +echo "" +echo ">>> Step 6: Building SNS message sender..." +cd ../sns-message-sender +mvn clean package +echo " SNS sender built." + +echo "" +echo "============================================================" +echo " Deployment Complete!" +echo "============================================================" +echo "" +echo "To trigger a pattern, run:" +echo " cd sns-message-sender" +echo " java -cp target/sns-message-sender-1.0-SNAPSHOT.jar sns.producer.DurableFunctionsTrigger DurableFunctionsSNSTopic " +echo "" +echo "Available patterns: chaining, fanout, approval, monitoring, timer, errorhandling, mapprocessing, suborchestration" +echo "" diff --git a/lambda-durable-java-sam/durable-functions-sam/durable-functions/Dockerfile b/lambda-durable-java-sam/durable-functions-sam/durable-functions/Dockerfile new file mode 100644 index 000000000..115e6c878 --- /dev/null +++ b/lambda-durable-java-sam/durable-functions-sam/durable-functions/Dockerfile @@ -0,0 +1,8 @@ +FROM --platform=linux/amd64 amazoncorretto:21-alpine AS builder +WORKDIR /build +COPY pom.xml . +COPY src ./src +RUN apk add --no-cache maven && mvn clean package + +FROM public.ecr.aws/lambda/java:21 +COPY --from=builder /build/target/*.jar ${LAMBDA_TASK_ROOT}/lib/ diff --git a/lambda-durable-java-sam/durable-functions-sam/durable-functions/pom.xml b/lambda-durable-java-sam/durable-functions-sam/durable-functions/pom.xml new file mode 100644 index 000000000..b64fdf504 --- /dev/null +++ b/lambda-durable-java-sam/durable-functions-sam/durable-functions/pom.xml @@ -0,0 +1,112 @@ + + 4.0.0 + com.amazonaws.samples.durable + durable-functions-examples + 1.0 + jar + Lambda Durable Functions Java Examples + + + 21 + 21 + UTF-8 + 2.42.21 + + + + + + software.amazon.awssdk + bom + ${aws.sdk.version} + pom + import + + + + + + + software.amazon.lambda.durable + aws-durable-execution-sdk-java + 1.0.0 + + + com.amazonaws + aws-lambda-java-core + 1.2.3 + + + com.amazonaws + aws-lambda-java-events + 3.14.0 + + + software.amazon.awssdk + dynamodb + + + software.amazon.awssdk + sns + + + software.amazon.awssdk + lambda + + + com.fasterxml.jackson.core + jackson-databind + 2.17.0 + + + software.amazon.lambda.durable + aws-durable-execution-sdk-java-testing + 1.0.0 + test + + + org.junit.jupiter + junit-jupiter-api + 5.10.2 + test + + + org.junit.jupiter + junit-jupiter-engine + 5.10.2 + test + + + + + + + org.apache.maven.plugins + maven-shade-plugin + 3.6.2 + + false + + + + package + + shade + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + 21 + 21 + + + + + diff --git a/lambda-durable-java-sam/durable-functions-sam/durable-functions/src/main/java/com/amazonaws/samples/durable/chaining/OrderProcessingHandler.java b/lambda-durable-java-sam/durable-functions-sam/durable-functions/src/main/java/com/amazonaws/samples/durable/chaining/OrderProcessingHandler.java new file mode 100644 index 000000000..2d4431460 --- /dev/null +++ b/lambda-durable-java-sam/durable-functions-sam/durable-functions/src/main/java/com/amazonaws/samples/durable/chaining/OrderProcessingHandler.java @@ -0,0 +1,108 @@ +package com.amazonaws.samples.durable.chaining; + +import java.util.Map; +import java.time.Duration; + +import com.amazonaws.samples.durable.models.SnsEventParser; +import software.amazon.lambda.durable.DurableContext; +import software.amazon.lambda.durable.DurableHandler; +import software.amazon.lambda.durable.config.StepConfig; +import software.amazon.lambda.durable.retry.RetryStrategies; +import software.amazon.lambda.durable.retry.JitterStrategy; + +/** + * PATTERN: Function Chaining + * + * Demonstrates sequential step execution where each step's output feeds + * into the next step. The durable execution SDK checkpoints each step's + * result so that on replay (after a wait or failure), completed steps + * are not re-executed. + * + * Flow: Validate Order -> Reserve Inventory -> Process Payment -> Ship Order -> Notify Customer + */ +public class OrderProcessingHandler extends DurableHandler, Map> { + + @Override + public Map handleRequest(Map rawEvent, DurableContext context) { + Map event = SnsEventParser.extractMessage(rawEvent); + context.getLogger().info("Starting order processing chain for: " + event.get("orderId")); + + StepConfig retryConfig = StepConfig.builder() + .retryStrategy(RetryStrategies.exponentialBackoff( + 3, Duration.ofSeconds(2), Duration.ofSeconds(30), 2.0, JitterStrategy.FULL)) + .build(); + + // Step 1: Validate the order + Map validationResult = context.step("validate-order", Map.class, stepCtx -> { + stepCtx.getLogger().info("Validating order..."); + String orderId = (String) event.get("orderId"); + double amount = ((Number) event.get("totalAmount")).doubleValue(); + if (orderId == null || orderId.isEmpty()) { + throw new IllegalArgumentException("Order ID is required"); + } + if (amount <= 0) { + throw new IllegalArgumentException("Order amount must be positive"); + } + return Map.of( + "orderId", orderId, + "status", "VALIDATED", + "amount", amount, + "customerId", event.getOrDefault("customerId", "unknown") + ); + }, retryConfig); + + // Step 2: Reserve inventory + Map inventoryResult = context.step("reserve-inventory", Map.class, stepCtx -> { + stepCtx.getLogger().info("Reserving inventory for order: " + validationResult.get("orderId")); + return Map.of( + "orderId", validationResult.get("orderId"), + "status", "INVENTORY_RESERVED", + "reservationId", "RES-" + validationResult.get("orderId"), + "warehouse", "WAREHOUSE-EAST-1" + ); + }, retryConfig); + + // Step 3: Process payment + Map paymentResult = context.step("process-payment", Map.class, stepCtx -> { + stepCtx.getLogger().info("Processing payment for order: " + inventoryResult.get("orderId")); + return Map.of( + "orderId", inventoryResult.get("orderId"), + "status", "PAYMENT_PROCESSED", + "transactionId", "TXN-" + System.currentTimeMillis(), + "amount", validationResult.get("amount") + ); + }, retryConfig); + + // Step 4: Ship order + Map shippingResult = context.step("ship-order", Map.class, stepCtx -> { + stepCtx.getLogger().info("Shipping order: " + paymentResult.get("orderId")); + return Map.of( + "orderId", paymentResult.get("orderId"), + "status", "SHIPPED", + "trackingNumber", "TRACK-" + paymentResult.get("transactionId"), + "estimatedDelivery", "3-5 business days" + ); + }, retryConfig); + + // Step 5: Notify customer + Map notificationResult = context.step("notify-customer", Map.class, stepCtx -> { + stepCtx.getLogger().info("Notifying customer about shipment"); + return Map.of( + "orderId", shippingResult.get("orderId"), + "status", "CUSTOMER_NOTIFIED", + "trackingNumber", shippingResult.get("trackingNumber"), + "message", "Your order has been shipped!" + ); + }); + + context.getLogger().info("Order processing chain completed successfully"); + + return Map.of( + "pattern", "CHAINING", + "orderId", event.get("orderId"), + "finalStatus", "COMPLETED", + "trackingNumber", shippingResult.get("trackingNumber"), + "transactionId", paymentResult.get("transactionId") + ); + } +} diff --git a/lambda-durable-java-sam/durable-functions-sam/durable-functions/src/main/java/com/amazonaws/samples/durable/errorhandling/ResilientPaymentHandler.java b/lambda-durable-java-sam/durable-functions-sam/durable-functions/src/main/java/com/amazonaws/samples/durable/errorhandling/ResilientPaymentHandler.java new file mode 100644 index 000000000..43b254d07 --- /dev/null +++ b/lambda-durable-java-sam/durable-functions-sam/durable-functions/src/main/java/com/amazonaws/samples/durable/errorhandling/ResilientPaymentHandler.java @@ -0,0 +1,164 @@ +package com.amazonaws.samples.durable.errorhandling; + +import java.time.Duration; +import java.util.Map; + +import com.amazonaws.samples.durable.models.SnsEventParser; +import software.amazon.lambda.durable.DurableContext; +import software.amazon.lambda.durable.DurableHandler; +import software.amazon.lambda.durable.config.StepConfig; +import software.amazon.lambda.durable.config.StepSemantics; +import software.amazon.lambda.durable.exception.StepFailedException; +import software.amazon.lambda.durable.exception.StepInterruptedException; +import software.amazon.lambda.durable.retry.JitterStrategy; +import software.amazon.lambda.durable.retry.RetryDecision; +import software.amazon.lambda.durable.retry.RetryStrategies; +import software.amazon.lambda.durable.retry.RetryStrategy; + +/** + * PATTERN: Error Handling and Compensation (Saga Pattern) + * + * Demonstrates: + * 1. Retry strategies with exponential backoff + * 2. Custom retry strategies with error-type filtering + * 3. At-most-once semantics for non-idempotent operations + * 4. Saga pattern: compensation logic when later steps fail + * 5. Catching and handling StepFailedException + * + * Flow: Validate -> Charge Payment (with retries) -> Reserve Inventory + * If inventory fails -> Refund Payment (compensation) + */ +public class ResilientPaymentHandler extends DurableHandler, Map> { + + @Override + public Map handleRequest(Map rawEvent, DurableContext context) { + Map event = SnsEventParser.extractMessage(rawEvent); + context.getLogger().info("Starting resilient payment workflow"); + + String orderId = (String) event.getOrDefault("orderId", "ORD-001"); + double amount = ((Number) event.getOrDefault("amount", 99.99)).doubleValue(); + boolean simulateInventoryFailure = Boolean.TRUE.equals(event.get("simulateInventoryFailure")); + + // Step 1: Validate with standard retry + StepConfig validationConfig = StepConfig.builder() + .retryStrategy(RetryStrategies.exponentialBackoff( + 3, Duration.ofSeconds(1), Duration.ofSeconds(10), 2.0, JitterStrategy.FULL)) + .build(); + + Map validation = context.step("validate-payment", Map.class, stepCtx -> { + stepCtx.getLogger().info("Validating payment for order: " + orderId); + if (amount <= 0) { + throw new IllegalArgumentException("Invalid amount: " + amount); + } + return Map.of("orderId", orderId, "amount", amount, "valid", true); + }, validationConfig); + + // Step 2: Charge payment with at-most-once semantics and custom retry + // At-most-once ensures we never double-charge even if Lambda is interrupted + RetryStrategy paymentRetry = (error, attempt) -> { + // Only retry on transient errors, not business logic errors + if (error instanceof IllegalArgumentException) { + return RetryDecision.fail(); + } + if (attempt >= 4) { + return RetryDecision.fail(); + } + return RetryDecision.retry(Duration.ofSeconds(2 * attempt)); + }; + + StepConfig paymentConfig = StepConfig.builder() + .retryStrategy(paymentRetry) + .semantics(StepSemantics.AT_MOST_ONCE_PER_RETRY) + .build(); + + Map paymentResult; + try { + paymentResult = context.step("charge-payment", Map.class, stepCtx -> { + stepCtx.getLogger().info("Charging payment: $" + amount + " for order: " + orderId); + stepCtx.getLogger().info("Attempt: " + stepCtx.getAttempt()); + // Simulate transient failure on first attempt + if (stepCtx.getAttempt() == 0) { + throw new RuntimeException("Payment gateway timeout - transient error"); + } + return Map.of( + "orderId", orderId, + "transactionId", "TXN-" + orderId + "-" + System.currentTimeMillis(), + "amount", amount, + "status", "CHARGED" + ); + }, paymentConfig); + } catch (StepInterruptedException e) { + // At-most-once step was interrupted - check external system + context.getLogger().warn("Payment step interrupted - checking payment system"); + paymentResult = context.step("check-payment-status", Map.class, stepCtx -> { + return Map.of("orderId", orderId, "status", "UNKNOWN", "requiresManualCheck", true); + }); + return Map.of( + "pattern", "ERROR_HANDLING", + "orderId", orderId, + "status", "INTERRUPTED", + "paymentResult", paymentResult + ); + } catch (StepFailedException e) { + context.getLogger().info("Payment failed permanently: " + e.getMessage()); + return Map.of( + "pattern", "ERROR_HANDLING", + "orderId", orderId, + "status", "PAYMENT_FAILED", + "error", e.getErrorObject().errorMessage() + ); + } + + // Step 3: Reserve inventory - may fail, triggering compensation + Map inventoryResult; + try { + StepConfig inventoryConfig = StepConfig.builder() + .retryStrategy(RetryStrategies.exponentialBackoff( + 2, Duration.ofSeconds(1), Duration.ofSeconds(5), 2.0, JitterStrategy.NONE)) + .build(); + + inventoryResult = context.step("reserve-inventory", Map.class, stepCtx -> { + stepCtx.getLogger().info("Reserving inventory for order: " + orderId); + if (simulateInventoryFailure) { + throw new RuntimeException("Inventory unavailable - out of stock"); + } + return Map.of( + "orderId", orderId, + "reservationId", "INV-" + orderId, + "status", "RESERVED" + ); + }, inventoryConfig); + } catch (StepFailedException e) { + // SAGA COMPENSATION: Inventory failed, refund the payment + context.getLogger().info("Inventory reservation failed - initiating compensation"); + + final Map capturedPayment = paymentResult; + Map refundResult = context.step("refund-payment", Map.class, stepCtx -> { + stepCtx.getLogger().info("Refunding payment: " + capturedPayment.get("transactionId")); + return Map.of( + "orderId", orderId, + "originalTransactionId", capturedPayment.get("transactionId"), + "refundId", "REFUND-" + orderId, + "amount", amount, + "status", "REFUNDED" + ); + }); + + return Map.of( + "pattern", "ERROR_HANDLING", + "orderId", orderId, + "status", "COMPENSATED", + "reason", "Inventory unavailable", + "refund", refundResult + ); + } + + return Map.of( + "pattern", "ERROR_HANDLING", + "orderId", orderId, + "status", "COMPLETED", + "transactionId", paymentResult.get("transactionId"), + "reservationId", inventoryResult.get("reservationId") + ); + } +} diff --git a/lambda-durable-java-sam/durable-functions-sam/durable-functions/src/main/java/com/amazonaws/samples/durable/fanout/ParallelProcessingHandler.java b/lambda-durable-java-sam/durable-functions-sam/durable-functions/src/main/java/com/amazonaws/samples/durable/fanout/ParallelProcessingHandler.java new file mode 100644 index 000000000..b83279c9f --- /dev/null +++ b/lambda-durable-java-sam/durable-functions-sam/durable-functions/src/main/java/com/amazonaws/samples/durable/fanout/ParallelProcessingHandler.java @@ -0,0 +1,121 @@ +package com.amazonaws.samples.durable.fanout; + +import java.util.List; +import java.util.Map; + +import com.amazonaws.samples.durable.models.SnsEventParser; +import software.amazon.lambda.durable.DurableContext; +import software.amazon.lambda.durable.DurableFuture; +import software.amazon.lambda.durable.DurableHandler; +import software.amazon.lambda.durable.config.CompletionConfig; +import software.amazon.lambda.durable.config.ParallelConfig; +import software.amazon.lambda.durable.model.MapResult; +import software.amazon.lambda.durable.model.ParallelResult; + +/** + * PATTERN: Fan-Out / Fan-In + * + * Demonstrates parallel execution of multiple independent operations that are + * then aggregated. Uses both the parallel() API for heterogeneous tasks and + * map() for homogeneous collection processing. + * + * Flow: Receive data -> Fan-out to multiple processors -> Aggregate results + */ +public class ParallelProcessingHandler extends DurableHandler, Map> { + + @Override + public Map handleRequest(Map rawEvent, DurableContext context) { + Map event = SnsEventParser.extractMessage(rawEvent); + context.getLogger().info("Starting fan-out/fan-in processing"); + + String dataId = (String) event.getOrDefault("dataId", "DATA-001"); + + // Fan-out using parallel(): Execute different analyses concurrently + DurableFuture sentimentFuture; + DurableFuture entityFuture; + DurableFuture summaryFuture; + + try (var parallel = context.parallel("analyze-data")) { + sentimentFuture = parallel.branch("sentiment-analysis", String.class, ctx -> + ctx.step("run-sentiment", String.class, stepCtx -> { + stepCtx.getLogger().info("Running sentiment analysis on: " + dataId); + simulateProcessing(500); + return "POSITIVE (confidence: 0.87)"; + }) + ); + + entityFuture = parallel.branch("entity-extraction", String.class, ctx -> + ctx.step("run-entity-extraction", String.class, stepCtx -> { + stepCtx.getLogger().info("Running entity extraction on: " + dataId); + simulateProcessing(300); + return "Entities: [AWS, Lambda, DurableFunctions, Java]"; + }) + ); + + summaryFuture = parallel.branch("summarization", String.class, ctx -> + ctx.step("run-summarization", String.class, stepCtx -> { + stepCtx.getLogger().info("Running summarization on: " + dataId); + simulateProcessing(700); + return "Summary: Document discusses Lambda Durable Functions patterns in Java"; + }) + ); + + ParallelResult result = parallel.get(); + context.getLogger().info("Parallel analysis complete. Succeeded: " + result.succeeded() + + ", Failed: " + result.failed()); + } + + String sentiment = sentimentFuture.get(); + String entities = entityFuture.get(); + String summary = summaryFuture.get(); + + // Fan-in using map(): Process a collection of items with the same operation + List documents = List.of("doc-1", "doc-2", "doc-3", "doc-4", "doc-5"); + + MapResult> mapResult = context.map( + "process-documents", + documents, + (Class>) (Class) Map.class, + (doc, index, ctx) -> ctx.step("process-" + doc, (Class>) (Class) Map.class, + stepCtx -> { + stepCtx.getLogger().info("Processing document: " + doc); + simulateProcessing(200); + return Map.of( + "documentId", doc, + "wordCount", String.valueOf((index + 1) * 150), + "status", "PROCESSED" + ); + }) + ); + + // Aggregate all results + Map aggregatedResult = context.step("aggregate-results", Map.class, stepCtx -> { + stepCtx.getLogger().info("Aggregating all results"); + int totalDocsProcessed = mapResult.succeeded().size(); + return Map.of( + "dataId", dataId, + "sentiment", sentiment, + "entities", entities, + "summary", summary, + "documentsProcessed", totalDocsProcessed, + "allSucceeded", mapResult.allSucceeded() + ); + }); + + context.getLogger().info("Fan-out/fan-in processing completed"); + + return Map.of( + "pattern", "FAN_OUT_FAN_IN", + "status", "COMPLETED", + "result", aggregatedResult + ); + } + + private void simulateProcessing(long millis) { + try { + Thread.sleep(millis); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } +} diff --git a/lambda-durable-java-sam/durable-functions-sam/durable-functions/src/main/java/com/amazonaws/samples/durable/humaninteraction/ApprovalWorkflowHandler.java b/lambda-durable-java-sam/durable-functions-sam/durable-functions/src/main/java/com/amazonaws/samples/durable/humaninteraction/ApprovalWorkflowHandler.java new file mode 100644 index 000000000..3b9715d9f --- /dev/null +++ b/lambda-durable-java-sam/durable-functions-sam/durable-functions/src/main/java/com/amazonaws/samples/durable/humaninteraction/ApprovalWorkflowHandler.java @@ -0,0 +1,124 @@ +package com.amazonaws.samples.durable.humaninteraction; + +import java.time.Duration; +import java.util.Map; + +import com.amazonaws.samples.durable.models.SnsEventParser; +import software.amazon.lambda.durable.DurableCallbackFuture; +import software.amazon.lambda.durable.DurableContext; +import software.amazon.lambda.durable.DurableHandler; +import software.amazon.lambda.durable.config.CallbackConfig; +import software.amazon.lambda.durable.exception.CallbackFailedException; + +/** + * PATTERN: Human Interaction (Approval Workflow) + * + * Demonstrates waiting for external human input using callbacks. + * The function suspends (no compute charges) while waiting for + * a human to approve or reject a request. Uses the callback mechanism + * where an external system calls SendDurableExecutionCallbackSuccess + * or SendDurableExecutionCallbackFailure. + * + * Flow: Submit Request -> Wait for Manager Approval -> Process Approved Request + * + * To complete the callback externally, use: + * aws lambda send-durable-execution-callback-success \ + * --callback-id \ + * --cli-binary-format raw-in-base64-out \ + * --result '{"decision":"APPROVED","approver":"manager@example.com"}' + * + * Or to reject: + * aws lambda send-durable-execution-callback-failure \ + * --callback-id \ + * --error ErrorType=Rejected,ErrorMessage="Budget exceeded" + */ +public class ApprovalWorkflowHandler extends DurableHandler, Map> { + + @Override + public Map handleRequest(Map rawEvent, DurableContext context) { + Map event = SnsEventParser.extractMessage(rawEvent); + context.getLogger().info("Starting approval workflow"); + + String requestId = (String) event.getOrDefault("requestId", "REQ-" + System.currentTimeMillis()); + double amount = ((Number) event.getOrDefault("amount", 5000)).doubleValue(); + String requestor = (String) event.getOrDefault("requestor", "employee@example.com"); + + // Step 1: Validate and prepare the approval request + Map approvalRequest = context.step("prepare-approval", Map.class, stepCtx -> { + stepCtx.getLogger().info("Preparing approval request: " + requestId); + String approvalLevel = amount > 10000 ? "VP" : "MANAGER"; + return Map.of( + "requestId", requestId, + "amount", amount, + "requestor", requestor, + "approvalLevel", approvalLevel, + "status", "PENDING_APPROVAL" + ); + }); + + // Step 2: Send notification to approver (simulated) + context.step("notify-approver", String.class, stepCtx -> { + stepCtx.getLogger().info("Sending approval notification to: " + approvalRequest.get("approvalLevel")); + return "Notification sent to " + approvalRequest.get("approvalLevel"); + }); + + // Step 3: Wait for human approval using callback + // The function SUSPENDS here - no compute charges while waiting + Map approvalDecision; + try { + CallbackConfig callbackConfig = CallbackConfig.builder() + .timeout(Duration.ofHours(72)) + .heartbeatTimeout(Duration.ofHours(24)) + .build(); + + DurableCallbackFuture> callback = + context.createCallback("wait-for-approval", (Class>) (Class) Map.class, callbackConfig); + + // Log the callback ID so the approver can use it + context.getLogger().info("Approval callback created. Callback ID: " + callback.callbackId()); + context.getLogger().info("To approve, run: aws lambda send-durable-execution-callback-success " + + "--callback-id " + callback.callbackId() + + " --cli-binary-format raw-in-base64-out" + + " --result '{\"decision\":\"APPROVED\",\"approver\":\"manager@example.com\"}'"); + + // Execution suspends here until the external system calls back + approvalDecision = callback.get(); + context.getLogger().info("Received approval decision: " + approvalDecision.get("decision")); + + } catch (CallbackFailedException e) { + context.getLogger().info("Approval was rejected: " + e.getMessage()); + return Map.of( + "pattern", "HUMAN_INTERACTION", + "requestId", requestId, + "status", "REJECTED", + "reason", e.getMessage() + ); + } + + // Step 4: Process the approved request + Map processingResult = context.step("process-approved-request", Map.class, stepCtx -> { + stepCtx.getLogger().info("Processing approved request: " + requestId); + return Map.of( + "requestId", requestId, + "status", "PROCESSED", + "approvedBy", approvalDecision.getOrDefault("approver", "unknown"), + "decision", approvalDecision.getOrDefault("decision", "APPROVED"), + "processedAmount", amount + ); + }); + + // Step 5: Send confirmation + context.step("send-confirmation", String.class, stepCtx -> { + stepCtx.getLogger().info("Sending confirmation to requestor: " + requestor); + return "Confirmation sent"; + }); + + return Map.of( + "pattern", "HUMAN_INTERACTION", + "requestId", requestId, + "status", "COMPLETED", + "approvedBy", processingResult.get("approvedBy"), + "amount", amount + ); + } +} diff --git a/lambda-durable-java-sam/durable-functions-sam/durable-functions/src/main/java/com/amazonaws/samples/durable/mapprocessing/BatchDataProcessorHandler.java b/lambda-durable-java-sam/durable-functions-sam/durable-functions/src/main/java/com/amazonaws/samples/durable/mapprocessing/BatchDataProcessorHandler.java new file mode 100644 index 000000000..c2f0aaa2a --- /dev/null +++ b/lambda-durable-java-sam/durable-functions-sam/durable-functions/src/main/java/com/amazonaws/samples/durable/mapprocessing/BatchDataProcessorHandler.java @@ -0,0 +1,124 @@ +package com.amazonaws.samples.durable.mapprocessing; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +import com.amazonaws.samples.durable.models.SnsEventParser; +import software.amazon.lambda.durable.DurableContext; +import software.amazon.lambda.durable.DurableHandler; +import software.amazon.lambda.durable.config.CompletionConfig; +import software.amazon.lambda.durable.config.MapConfig; +import software.amazon.lambda.durable.model.MapResult; + +/** + * PATTERN: Map Processing (Collection Processing) + * + * Demonstrates the map() operation which processes each item in a collection + * concurrently. Each item runs in its own child context with independent + * checkpointing. Supports concurrency limits, failure tolerance, and + * completion strategies. + * + * Use cases: + * - Batch processing of records + * - Parallel API calls with rate limiting + * - ETL pipelines with partial failure tolerance + * + * Flow: Receive batch -> Map over items (concurrent) -> Collect results -> Summarize + */ +public class BatchDataProcessorHandler extends DurableHandler, Map> { + + @Override + public Map handleRequest(Map rawEvent, DurableContext context) { + Map event = SnsEventParser.extractMessage(rawEvent); + context.getLogger().info("Starting batch data processing"); + + int batchSize = ((Number) event.getOrDefault("batchSize", 10)).intValue(); + int maxConcurrency = ((Number) event.getOrDefault("maxConcurrency", 3)).intValue(); + int toleratedFailures = ((Number) event.getOrDefault("toleratedFailures", 2)).intValue(); + + // Step 1: Generate or receive the batch of items to process + List> batch = context.step("prepare-batch", + (Class>>) (Class) List.class, stepCtx -> { + stepCtx.getLogger().info("Preparing batch of " + batchSize + " items"); + return IntStream.range(0, batchSize) + .mapToObj(i -> Map.of( + "itemId", "ITEM-" + String.format("%03d", i), + "data", "payload-for-item-" + i, + "priority", i % 3 == 0 ? "HIGH" : "NORMAL" + )) + .collect(Collectors.toList()); + }); + + // Step 2: Process all items using map() with concurrency control + MapConfig mapConfig = MapConfig.builder() + .maxConcurrency(maxConcurrency) + .completionConfig(CompletionConfig.toleratedFailureCount(toleratedFailures)) + .build(); + + MapResult> processingResult = context.map( + "process-batch-items", + batch, + (Class>) (Class) Map.class, + (item, index, ctx) -> { + // Each item gets its own child context with independent checkpoints + // Multi-step processing per item + String validated = ctx.step("validate-" + index, String.class, stepCtx -> { + stepCtx.getLogger().info("Validating item: " + item.get("itemId")); + // Simulate validation failure for every 5th item + if (index % 5 == 4) { + throw new RuntimeException("Validation failed for item: " + item.get("itemId")); + } + return "VALID"; + }); + + Map transformed = ctx.step("transform-" + index, Map.class, stepCtx -> { + stepCtx.getLogger().info("Transforming item: " + item.get("itemId")); + return Map.of( + "itemId", item.get("itemId"), + "originalData", item.get("data"), + "transformedData", item.get("data").toUpperCase(), + "priority", item.get("priority") + ); + }); + + return ctx.step("store-" + index, Map.class, stepCtx -> { + stepCtx.getLogger().info("Storing transformed item: " + item.get("itemId")); + return Map.of( + "itemId", transformed.get("itemId"), + "status", "STORED", + "transformedData", transformed.get("transformedData") + ); + }); + }, + mapConfig + ); + + // Step 3: Summarize results + Map summary = context.step("summarize-results", Map.class, stepCtx -> { + int succeeded = processingResult.succeeded().size(); + int failed = processingResult.failed().size(); + stepCtx.getLogger().info("Batch processing complete. Succeeded: " + succeeded + ", Failed: " + failed); + + List failureReasons = processingResult.failed().stream() + .map(error -> error.errorType() + ": " + error.errorMessage()) + .collect(Collectors.toList()); + + return Map.of( + "totalItems", batchSize, + "succeeded", succeeded, + "failed", failed, + "allSucceeded", processingResult.allSucceeded(), + "completionReason", processingResult.completionReason().name(), + "failureReasons", failureReasons + ); + }); + + return Map.of( + "pattern", "MAP_PROCESSING", + "status", processingResult.allSucceeded() ? "ALL_SUCCEEDED" : "PARTIAL_SUCCESS", + "summary", summary + ); + } +} diff --git a/lambda-durable-java-sam/durable-functions-sam/durable-functions/src/main/java/com/amazonaws/samples/durable/models/OrderItem.java b/lambda-durable-java-sam/durable-functions-sam/durable-functions/src/main/java/com/amazonaws/samples/durable/models/OrderItem.java new file mode 100644 index 000000000..e29cfe43a --- /dev/null +++ b/lambda-durable-java-sam/durable-functions-sam/durable-functions/src/main/java/com/amazonaws/samples/durable/models/OrderItem.java @@ -0,0 +1,26 @@ +package com.amazonaws.samples.durable.models; + +public class OrderItem { + private String productId; + private String productName; + private int quantity; + private double price; + + public OrderItem() {} + + public OrderItem(String productId, String productName, int quantity, double price) { + this.productId = productId; + this.productName = productName; + this.quantity = quantity; + this.price = price; + } + + public String getProductId() { return productId; } + public void setProductId(String productId) { this.productId = productId; } + public String getProductName() { return productName; } + public void setProductName(String productName) { this.productName = productName; } + public int getQuantity() { return quantity; } + public void setQuantity(int quantity) { this.quantity = quantity; } + public double getPrice() { return price; } + public void setPrice(double price) { this.price = price; } +} diff --git a/lambda-durable-java-sam/durable-functions-sam/durable-functions/src/main/java/com/amazonaws/samples/durable/models/OrderRequest.java b/lambda-durable-java-sam/durable-functions-sam/durable-functions/src/main/java/com/amazonaws/samples/durable/models/OrderRequest.java new file mode 100644 index 000000000..2aa63061f --- /dev/null +++ b/lambda-durable-java-sam/durable-functions-sam/durable-functions/src/main/java/com/amazonaws/samples/durable/models/OrderRequest.java @@ -0,0 +1,27 @@ +package com.amazonaws.samples.durable.models; + +import java.util.List; + +public class OrderRequest { + private String orderId; + private String customerId; + private String customerEmail; + private List items; + private double totalAmount; + private String shippingAddress; + + public OrderRequest() {} + + public String getOrderId() { return orderId; } + public void setOrderId(String orderId) { this.orderId = orderId; } + public String getCustomerId() { return customerId; } + public void setCustomerId(String customerId) { this.customerId = customerId; } + public String getCustomerEmail() { return customerEmail; } + public void setCustomerEmail(String customerEmail) { this.customerEmail = customerEmail; } + public List getItems() { return items; } + public void setItems(List items) { this.items = items; } + public double getTotalAmount() { return totalAmount; } + public void setTotalAmount(double totalAmount) { this.totalAmount = totalAmount; } + public String getShippingAddress() { return shippingAddress; } + public void setShippingAddress(String shippingAddress) { this.shippingAddress = shippingAddress; } +} diff --git a/lambda-durable-java-sam/durable-functions-sam/durable-functions/src/main/java/com/amazonaws/samples/durable/models/ProcessingResult.java b/lambda-durable-java-sam/durable-functions-sam/durable-functions/src/main/java/com/amazonaws/samples/durable/models/ProcessingResult.java new file mode 100644 index 000000000..169dfe327 --- /dev/null +++ b/lambda-durable-java-sam/durable-functions-sam/durable-functions/src/main/java/com/amazonaws/samples/durable/models/ProcessingResult.java @@ -0,0 +1,32 @@ +package com.amazonaws.samples.durable.models; + +import java.util.Map; + +public class ProcessingResult { + private String executionId; + private String pattern; + private String status; + private Map data; + private String timestamp; + + public ProcessingResult() {} + + public ProcessingResult(String executionId, String pattern, String status, Map data) { + this.executionId = executionId; + this.pattern = pattern; + this.status = status; + this.data = data; + this.timestamp = java.time.Instant.now().toString(); + } + + public String getExecutionId() { return executionId; } + public void setExecutionId(String executionId) { this.executionId = executionId; } + public String getPattern() { return pattern; } + public void setPattern(String pattern) { this.pattern = pattern; } + public String getStatus() { return status; } + public void setStatus(String status) { this.status = status; } + public Map getData() { return data; } + public void setData(Map data) { this.data = data; } + public String getTimestamp() { return timestamp; } + public void setTimestamp(String timestamp) { this.timestamp = timestamp; } +} diff --git a/lambda-durable-java-sam/durable-functions-sam/durable-functions/src/main/java/com/amazonaws/samples/durable/models/SnsEventParser.java b/lambda-durable-java-sam/durable-functions-sam/durable-functions/src/main/java/com/amazonaws/samples/durable/models/SnsEventParser.java new file mode 100644 index 000000000..381e47418 --- /dev/null +++ b/lambda-durable-java-sam/durable-functions-sam/durable-functions/src/main/java/com/amazonaws/samples/durable/models/SnsEventParser.java @@ -0,0 +1,27 @@ +package com.amazonaws.samples.durable.models; + +import java.util.List; +import java.util.Map; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; + +public class SnsEventParser { + + private static final ObjectMapper mapper = new ObjectMapper(); + + @SuppressWarnings("unchecked") + public static Map extractMessage(Map event) { + try { + List> records = (List>) event.get("Records"); + if (records != null && !records.isEmpty()) { + Map sns = (Map) records.get(0).get("Sns"); + String message = (String) sns.get("Message"); + return mapper.readValue(message, new TypeReference>() {}); + } + } catch (Exception e) { + // Not an SNS event envelope, return as-is + } + return event; + } +} diff --git a/lambda-durable-java-sam/durable-functions-sam/durable-functions/src/main/java/com/amazonaws/samples/durable/monitoring/JobMonitorHandler.java b/lambda-durable-java-sam/durable-functions-sam/durable-functions/src/main/java/com/amazonaws/samples/durable/monitoring/JobMonitorHandler.java new file mode 100644 index 000000000..852b50b11 --- /dev/null +++ b/lambda-durable-java-sam/durable-functions-sam/durable-functions/src/main/java/com/amazonaws/samples/durable/monitoring/JobMonitorHandler.java @@ -0,0 +1,131 @@ +package com.amazonaws.samples.durable.monitoring; + +import java.time.Duration; +import java.util.Map; + +import com.amazonaws.samples.durable.models.SnsEventParser; +import software.amazon.lambda.durable.DurableContext; +import software.amazon.lambda.durable.DurableHandler; +import software.amazon.lambda.durable.TypeToken; +import software.amazon.lambda.durable.config.WaitForConditionConfig; +import software.amazon.lambda.durable.model.WaitForConditionResult; +import software.amazon.lambda.durable.retry.JitterStrategy; +import software.amazon.lambda.durable.retry.WaitStrategies; +import software.amazon.lambda.durable.retry.WaitForConditionWaitStrategy; + +/** + * PATTERN: Monitoring / Polling + * + * Demonstrates the waitForCondition operation which polls an external + * system on a schedule until a terminal condition is reached. The function + * suspends between polling attempts without consuming compute. + * + * Use case: Monitor a long-running batch job, waiting for it to complete. + * + * Flow: Start monitoring -> Poll for status -> (suspend between polls) -> Return final status + */ +public class JobMonitorHandler extends DurableHandler, Map> { + + private static int pollCount = 0; + + @Override + public Map handleRequest(Map rawEvent, DurableContext context) { + Map event = SnsEventParser.extractMessage(rawEvent); + context.getLogger().info("Starting job monitor"); + + String jobId = (String) event.getOrDefault("jobId", "JOB-" + System.currentTimeMillis()); + int maxPolls = ((Number) event.getOrDefault("maxPolls", 5)).intValue(); + + // Step 1: Submit or acknowledge the job + Map jobInfo = context.step("acknowledge-job", Map.class, stepCtx -> { + stepCtx.getLogger().info("Acknowledging job: " + jobId); + return Map.of( + "jobId", jobId, + "status", "SUBMITTED", + "submittedAt", java.time.Instant.now().toString() + ); + }); + + // Step 2: Use waitForCondition to poll until job completes + // The function suspends between each poll - no compute charges during waits + WaitForConditionWaitStrategy> strategy = (state, attempt) -> { + if (attempt >= maxPolls) { + throw new RuntimeException("Job monitoring timed out after " + maxPolls + " attempts"); + } + // Exponential backoff: 5s, 10s, 20s, 40s... + long delaySeconds = Math.min(5L * (long) Math.pow(2, attempt - 1), 60); + return Duration.ofSeconds(delaySeconds); + }; + + Map initialState = Map.of( + "jobId", jobId, + "status", "SUBMITTED", + "attempts", 0, + "done", false + ); + + WaitForConditionConfig> config = WaitForConditionConfig + .>builder() + .initialState(initialState) + .waitStrategy(strategy) + .build(); + + Map finalJobState = context.waitForCondition( + "poll-job-status", + new TypeToken<>() {}, + (state, stepCtx) -> { + int attempts = ((Number) state.getOrDefault("attempts", 0)).intValue() + 1; + stepCtx.getLogger().info("Polling job status, attempt: " + attempts); + + // Simulate checking external job status + // In production, this would call an external API + String currentStatus = simulateJobStatus(attempts, maxPolls); + + Map updatedState = Map.of( + "jobId", state.get("jobId"), + "status", currentStatus, + "attempts", attempts, + "done", "COMPLETED".equals(currentStatus) || "FAILED".equals(currentStatus), + "lastChecked", java.time.Instant.now().toString() + ); + + boolean isDone = "COMPLETED".equals(currentStatus) || "FAILED".equals(currentStatus); + if (isDone) { + return WaitForConditionResult.stopPolling(updatedState); + } + return WaitForConditionResult.continuePolling(updatedState); + }, + config + ); + + // Step 3: Process the final result + Map finalResult = context.step("process-job-result", Map.class, stepCtx -> { + String status = (String) finalJobState.get("status"); + stepCtx.getLogger().info("Job completed with status: " + status); + return Map.of( + "jobId", jobId, + "finalStatus", status, + "totalAttempts", finalJobState.get("attempts"), + "completedAt", java.time.Instant.now().toString() + ); + }); + + return Map.of( + "pattern", "MONITORING", + "jobId", jobId, + "status", finalResult.get("finalStatus"), + "pollAttempts", finalResult.get("totalAttempts") + ); + } + + private String simulateJobStatus(int attempt, int maxPolls) { + // Simulate a job that completes after a few polling attempts + if (attempt >= maxPolls - 1) { + return "COMPLETED"; + } else if (attempt == 1) { + return "RUNNING"; + } else { + return "IN_PROGRESS"; + } + } +} diff --git a/lambda-durable-java-sam/durable-functions-sam/durable-functions/src/main/java/com/amazonaws/samples/durable/suborchestration/OrderFulfillmentHandler.java b/lambda-durable-java-sam/durable-functions-sam/durable-functions/src/main/java/com/amazonaws/samples/durable/suborchestration/OrderFulfillmentHandler.java new file mode 100644 index 000000000..f6114b737 --- /dev/null +++ b/lambda-durable-java-sam/durable-functions-sam/durable-functions/src/main/java/com/amazonaws/samples/durable/suborchestration/OrderFulfillmentHandler.java @@ -0,0 +1,192 @@ +package com.amazonaws.samples.durable.suborchestration; + +import java.time.Duration; +import java.util.List; +import java.util.Map; + +import com.amazonaws.samples.durable.models.SnsEventParser; +import software.amazon.lambda.durable.DurableContext; +import software.amazon.lambda.durable.DurableFuture; +import software.amazon.lambda.durable.DurableHandler; + +/** + * PATTERN: Sub-Orchestration (Child Contexts) + * + * Demonstrates runInChildContext() for organizing complex workflows into + * isolated, reusable sub-workflows. Each child context has its own operation + * namespace and checkpoints results as a single unit. + * + * Also demonstrates concurrent child contexts using runInChildContextAsync(). + * + * Use cases: + * - Breaking complex workflows into logical sub-workflows + * - Running independent sub-workflows concurrently + * - Reusable workflow components + * + * Flow: Order Received -> [Payment Sub-workflow || Inventory Sub-workflow] -> Shipping Sub-workflow -> Complete + */ +public class OrderFulfillmentHandler extends DurableHandler, Map> { + + @Override + public Map handleRequest(Map rawEvent, DurableContext context) { + Map event = SnsEventParser.extractMessage(rawEvent); + context.getLogger().info("Starting order fulfillment orchestration"); + + String orderId = (String) event.getOrDefault("orderId", "ORD-" + System.currentTimeMillis()); + double amount = ((Number) event.getOrDefault("amount", 149.99)).doubleValue(); + String customerId = (String) event.getOrDefault("customerId", "CUST-001"); + + // Step 1: Order validation (in main context) + Map orderDetails = context.step("validate-order", Map.class, stepCtx -> { + stepCtx.getLogger().info("Validating order: " + orderId); + return Map.of( + "orderId", orderId, + "customerId", customerId, + "amount", amount, + "status", "VALIDATED" + ); + }); + + // Step 2: Run Payment and Inventory sub-workflows CONCURRENTLY + // Each runs in its own isolated child context + DurableFuture> paymentFuture = context.runInChildContextAsync( + "payment-workflow", (Class>) (Class) Map.class, + child -> processPayment(child, orderId, amount) + ); + + DurableFuture> inventoryFuture = context.runInChildContextAsync( + "inventory-workflow", (Class>) (Class) Map.class, + child -> reserveInventory(child, orderId, event) + ); + + // Wait for both concurrent sub-workflows to complete + DurableFuture.allOf(paymentFuture, inventoryFuture); + Map paymentResult = paymentFuture.get(); + Map inventoryResult = inventoryFuture.get(); + + context.getLogger().info("Payment and inventory sub-workflows completed"); + + // Step 3: Shipping sub-workflow (sequential, depends on previous results) + Map shippingResult = context.runInChildContext( + "shipping-workflow", (Class>) (Class) Map.class, + child -> processShipping(child, orderId, inventoryResult) + ); + + // Step 4: Notification sub-workflow + Map notificationResult = context.runInChildContext( + "notification-workflow", (Class>) (Class) Map.class, + child -> sendNotifications(child, orderId, customerId, shippingResult) + ); + + return Map.of( + "pattern", "SUB_ORCHESTRATION", + "orderId", orderId, + "status", "FULFILLED", + "payment", paymentResult, + "inventory", inventoryResult, + "shipping", shippingResult, + "notification", notificationResult + ); + } + + private Map processPayment(DurableContext child, String orderId, double amount) { + // Sub-workflow with multiple steps, isolated in its own context + Map authorization = child.step("authorize-card", Map.class, stepCtx -> { + stepCtx.getLogger().info("Authorizing payment for order: " + orderId); + return Map.of( + "authCode", "AUTH-" + orderId, + "amount", amount, + "status", "AUTHORIZED" + ); + }); + + child.wait("fraud-check-delay", Duration.ofSeconds(2)); + + Map fraudCheck = child.step("fraud-check", Map.class, stepCtx -> { + stepCtx.getLogger().info("Running fraud check for: " + authorization.get("authCode")); + return Map.of( + "authCode", authorization.get("authCode"), + "fraudScore", 0.05, + "passed", true + ); + }); + + return child.step("capture-payment", Map.class, stepCtx -> { + stepCtx.getLogger().info("Capturing payment: " + authorization.get("authCode")); + return Map.of( + "transactionId", "TXN-" + orderId, + "authCode", authorization.get("authCode"), + "amount", amount, + "status", "CAPTURED" + ); + }); + } + + private Map reserveInventory(DurableContext child, String orderId, Map event) { + Map availability = child.step("check-availability", Map.class, stepCtx -> { + stepCtx.getLogger().info("Checking inventory availability for order: " + orderId); + return Map.of( + "orderId", orderId, + "available", true, + "warehouse", "WAREHOUSE-EAST-1" + ); + }); + + return child.step("reserve-stock", Map.class, stepCtx -> { + stepCtx.getLogger().info("Reserving stock in: " + availability.get("warehouse")); + return Map.of( + "reservationId", "RES-" + orderId, + "warehouse", availability.get("warehouse"), + "status", "RESERVED" + ); + }); + } + + private Map processShipping(DurableContext child, String orderId, Map inventoryResult) { + Map label = child.step("create-shipping-label", Map.class, stepCtx -> { + stepCtx.getLogger().info("Creating shipping label for order: " + orderId); + return Map.of( + "labelId", "LBL-" + orderId, + "warehouse", inventoryResult.get("warehouse"), + "carrier", "EXPRESS-SHIPPING" + ); + }); + + child.step("schedule-pickup", Map.class, stepCtx -> { + stepCtx.getLogger().info("Scheduling pickup from: " + label.get("warehouse")); + return Map.of("pickupScheduled", true, "estimatedPickup", "2 hours"); + }); + + return child.step("generate-tracking", Map.class, stepCtx -> { + stepCtx.getLogger().info("Generating tracking number"); + return Map.of( + "trackingNumber", "TRACK-" + orderId, + "carrier", label.get("carrier"), + "estimatedDelivery", "2-3 business days", + "status", "LABEL_CREATED" + ); + }); + } + + private Map sendNotifications(DurableContext child, String orderId, String customerId, + Map shippingResult) { + child.step("send-order-confirmation", String.class, stepCtx -> { + stepCtx.getLogger().info("Sending order confirmation to customer: " + customerId); + return "Order confirmation sent"; + }); + + child.step("send-shipping-notification", String.class, stepCtx -> { + stepCtx.getLogger().info("Sending shipping notification with tracking: " + shippingResult.get("trackingNumber")); + return "Shipping notification sent"; + }); + + return child.step("notification-summary", Map.class, stepCtx -> { + return Map.of( + "orderId", orderId, + "customerId", customerId, + "notificationsSent", 2, + "trackingNumber", shippingResult.get("trackingNumber") + ); + }); + } +} diff --git a/lambda-durable-java-sam/durable-functions-sam/durable-functions/src/main/java/com/amazonaws/samples/durable/timer/ScheduledReminderHandler.java b/lambda-durable-java-sam/durable-functions-sam/durable-functions/src/main/java/com/amazonaws/samples/durable/timer/ScheduledReminderHandler.java new file mode 100644 index 000000000..1078902b3 --- /dev/null +++ b/lambda-durable-java-sam/durable-functions-sam/durable-functions/src/main/java/com/amazonaws/samples/durable/timer/ScheduledReminderHandler.java @@ -0,0 +1,103 @@ +package com.amazonaws.samples.durable.timer; + +import java.time.Duration; +import java.util.Map; + +import com.amazonaws.samples.durable.models.SnsEventParser; +import software.amazon.lambda.durable.DurableContext; +import software.amazon.lambda.durable.DurableFuture; +import software.amazon.lambda.durable.DurableHandler; + +/** + * PATTERN: Timer / Scheduled Delays + * + * Demonstrates the wait() operation which suspends execution for a specified + * duration WITHOUT consuming compute. The function exits, and the backend + * automatically resumes it when the wait completes. + * + * Use cases: + * - Scheduled reminders + * - Rate limiting between API calls + * - Delayed retry patterns + * - SLA-based escalations + * + * Flow: Process event -> Wait (suspend) -> Send reminder -> Wait -> Escalate + */ +public class ScheduledReminderHandler extends DurableHandler, Map> { + + @Override + public Map handleRequest(Map rawEvent, DurableContext context) { + Map event = SnsEventParser.extractMessage(rawEvent); + context.getLogger().info("Starting scheduled reminder workflow"); + + String taskId = (String) event.getOrDefault("taskId", "TASK-001"); + String assignee = (String) event.getOrDefault("assignee", "developer@example.com"); + int reminderIntervalSeconds = ((Number) event.getOrDefault("reminderIntervalSeconds", 30)).intValue(); + int escalationSeconds = ((Number) event.getOrDefault("escalationSeconds", 60)).intValue(); + + // Step 1: Create the task assignment + Map taskAssignment = context.step("create-task", Map.class, stepCtx -> { + stepCtx.getLogger().info("Creating task assignment: " + taskId + " -> " + assignee); + return Map.of( + "taskId", taskId, + "assignee", assignee, + "status", "ASSIGNED", + "createdAt", java.time.Instant.now().toString() + ); + }); + + // Step 2: Send initial notification + context.step("send-initial-notification", String.class, stepCtx -> { + stepCtx.getLogger().info("Sending initial task notification to: " + assignee); + return "Initial notification sent to " + assignee; + }); + + // Step 3: Wait for the first reminder interval + // Function SUSPENDS here - no compute charges during this wait + context.getLogger().info("Waiting " + reminderIntervalSeconds + " seconds before first reminder"); + context.wait("first-reminder-delay", Duration.ofSeconds(reminderIntervalSeconds)); + + // Step 4: Send first reminder + context.step("send-first-reminder", String.class, stepCtx -> { + stepCtx.getLogger().info("Sending first reminder to: " + assignee); + return "First reminder sent"; + }); + + // Step 5: Wait for escalation period + context.getLogger().info("Waiting " + escalationSeconds + " seconds before escalation"); + context.wait("escalation-delay", Duration.ofSeconds(escalationSeconds)); + + // Step 6: Escalate to manager + Map escalation = context.step("escalate-to-manager", Map.class, stepCtx -> { + stepCtx.getLogger().info("Escalating task " + taskId + " to manager"); + return Map.of( + "taskId", taskId, + "originalAssignee", assignee, + "escalatedTo", "manager@example.com", + "reason", "Task not completed within SLA", + "escalatedAt", java.time.Instant.now().toString() + ); + }); + + // Step 7: Demonstrate async wait with concurrent step (minimum duration pattern) + DurableFuture minimumWait = context.waitAsync("minimum-processing-time", Duration.ofSeconds(5)); + DurableFuture processingFuture = context.stepAsync("final-processing", String.class, stepCtx -> { + stepCtx.getLogger().info("Running final processing (guaranteed minimum 5s total)"); + return "Final processing complete"; + }); + + // Both must complete - ensures at least 5 seconds elapsed + minimumWait.get(); + String processingResult = processingFuture.get(); + + // Step 8: Final summary + return Map.of( + "pattern", "TIMER", + "taskId", taskId, + "status", "ESCALATED", + "assignee", assignee, + "escalatedTo", escalation.get("escalatedTo"), + "totalWaitTime", (reminderIntervalSeconds + escalationSeconds + 5) + " seconds" + ); + } +} diff --git a/lambda-durable-java-sam/durable-functions-sam/durable-functions/src/test/java/com/amazonaws/samples/durable/ChainingPatternTest.java b/lambda-durable-java-sam/durable-functions-sam/durable-functions/src/test/java/com/amazonaws/samples/durable/ChainingPatternTest.java new file mode 100644 index 000000000..533c6aa94 --- /dev/null +++ b/lambda-durable-java-sam/durable-functions-sam/durable-functions/src/test/java/com/amazonaws/samples/durable/ChainingPatternTest.java @@ -0,0 +1,85 @@ +package com.amazonaws.samples.durable; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.Map; + +import org.junit.jupiter.api.Test; +import software.amazon.awssdk.services.lambda.model.OperationType; +import software.amazon.lambda.durable.TypeToken; +import software.amazon.lambda.durable.model.ExecutionStatus; +import software.amazon.lambda.durable.testing.LocalDurableTestRunner; + +import com.amazonaws.samples.durable.chaining.OrderProcessingHandler; + +class ChainingPatternTest { + + private static final TypeToken> MAP_TYPE = new TypeToken<>() {}; + + @Test + void executesAllStepsInSequence() { + var handler = new OrderProcessingHandler(); + var runner = LocalDurableTestRunner.create(MAP_TYPE, handler); + + Map input = Map.of( + "orderId", "ORD-TEST-001", + "totalAmount", 299.99, + "customerId", "CUST-001", + "shippingAddress", "123 Main St" + ); + + var result = runner.runUntilComplete(input); + + assertEquals(ExecutionStatus.SUCCEEDED, result.getStatus()); + + Map output = result.getResult(Map.class); + assertEquals("CHAINING", output.get("pattern")); + assertEquals("COMPLETED", output.get("finalStatus")); + assertEquals("ORD-TEST-001", output.get("orderId")); + assertNotNull(output.get("trackingNumber")); + assertNotNull(output.get("transactionId")); + } + + @Test + void executesAllFiveSteps() { + var handler = new OrderProcessingHandler(); + var runner = LocalDurableTestRunner.create(MAP_TYPE, handler); + + Map input = Map.of( + "orderId", "ORD-TEST-002", + "totalAmount", 100.00, + "customerId", "CUST-002" + ); + + var result = runner.runUntilComplete(input); + + var stepNames = result.getOperations().stream() + .filter(op -> op.getType() == OperationType.STEP) + .map(op -> op.getName()) + .toList(); + + assertTrue(stepNames.contains("validate-order")); + assertTrue(stepNames.contains("reserve-inventory")); + assertTrue(stepNames.contains("process-payment")); + assertTrue(stepNames.contains("ship-order")); + assertTrue(stepNames.contains("notify-customer")); + assertEquals(5, stepNames.size()); + } + + @Test + void failsOnInvalidAmount() { + var handler = new OrderProcessingHandler(); + var runner = LocalDurableTestRunner.create(MAP_TYPE, handler); + + Map input = Map.of( + "orderId", "ORD-TEST-003", + "totalAmount", -10.00, + "customerId", "CUST-003" + ); + + var result = runner.runUntilComplete(input); + + assertEquals(ExecutionStatus.FAILED, result.getStatus()); + assertTrue(result.getError().isPresent()); + } +} diff --git a/lambda-durable-java-sam/durable-functions-sam/durable-functions/src/test/java/com/amazonaws/samples/durable/ErrorHandlingPatternTest.java b/lambda-durable-java-sam/durable-functions-sam/durable-functions/src/test/java/com/amazonaws/samples/durable/ErrorHandlingPatternTest.java new file mode 100644 index 000000000..df0d43435 --- /dev/null +++ b/lambda-durable-java-sam/durable-functions-sam/durable-functions/src/test/java/com/amazonaws/samples/durable/ErrorHandlingPatternTest.java @@ -0,0 +1,98 @@ +package com.amazonaws.samples.durable; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.Map; + +import org.junit.jupiter.api.Test; +import software.amazon.awssdk.services.lambda.model.OperationStatus; +import software.amazon.lambda.durable.TypeToken; +import software.amazon.lambda.durable.model.ExecutionStatus; +import software.amazon.lambda.durable.testing.LocalDurableTestRunner; + +import com.amazonaws.samples.durable.errorhandling.ResilientPaymentHandler; + +class ErrorHandlingPatternTest { + + private static final TypeToken> MAP_TYPE = new TypeToken<>() {}; + + @Test + void completesSuccessfullyOnHappyPath() { + var handler = new ResilientPaymentHandler(); + var runner = LocalDurableTestRunner.create(MAP_TYPE, handler); + + Map input = Map.of( + "orderId", "ORD-TEST-001", + "amount", 199.99, + "simulateInventoryFailure", false + ); + + var result = runner.runUntilComplete(input); + + assertEquals(ExecutionStatus.SUCCEEDED, result.getStatus()); + + Map output = result.getResult(Map.class); + assertEquals("ERROR_HANDLING", output.get("pattern")); + assertEquals("COMPLETED", output.get("status")); + assertNotNull(output.get("transactionId")); + assertNotNull(output.get("reservationId")); + } + + @Test + void inventoryFailureResultsInFailedExecution() { + var handler = new ResilientPaymentHandler(); + var runner = LocalDurableTestRunner.create(MAP_TYPE, handler); + + Map input = Map.of( + "orderId", "ORD-TEST-002", + "amount", 199.99, + "simulateInventoryFailure", true + ); + + var result = runner.runUntilComplete(input); + + assertEquals(ExecutionStatus.FAILED, result.getStatus()); + + var inventoryOp = result.getOperation("reserve-inventory"); + assertNotNull(inventoryOp); + assertEquals(OperationStatus.FAILED, inventoryOp.getStatus()); + } + + @Test + void paymentSucceedsBeforeInventoryFails() { + var handler = new ResilientPaymentHandler(); + var runner = LocalDurableTestRunner.create(MAP_TYPE, handler); + + Map input = Map.of( + "orderId", "ORD-TEST-003", + "amount", 50.00, + "simulateInventoryFailure", true + ); + + var result = runner.runUntilComplete(input); + + var paymentOp = result.getOperation("charge-payment"); + assertNotNull(paymentOp); + assertEquals(OperationStatus.SUCCEEDED, paymentOp.getStatus()); + } + + @Test + void retriesPaymentOnTransientFailure() { + var handler = new ResilientPaymentHandler(); + var runner = LocalDurableTestRunner.create(MAP_TYPE, handler); + + Map input = Map.of( + "orderId", "ORD-TEST-004", + "amount", 75.00, + "simulateInventoryFailure", false + ); + + var result = runner.runUntilComplete(input); + + assertEquals(ExecutionStatus.SUCCEEDED, result.getStatus()); + + var paymentOp = result.getOperation("charge-payment"); + assertNotNull(paymentOp); + assertTrue(paymentOp.getAttempt() >= 1, "Payment should have retried at least once"); + } +} diff --git a/lambda-durable-java-sam/durable-functions-sam/durable-functions/src/test/java/com/amazonaws/samples/durable/FanOutPatternTest.java b/lambda-durable-java-sam/durable-functions-sam/durable-functions/src/test/java/com/amazonaws/samples/durable/FanOutPatternTest.java new file mode 100644 index 000000000..13f7339bb --- /dev/null +++ b/lambda-durable-java-sam/durable-functions-sam/durable-functions/src/test/java/com/amazonaws/samples/durable/FanOutPatternTest.java @@ -0,0 +1,56 @@ +package com.amazonaws.samples.durable; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.Map; + +import org.junit.jupiter.api.Test; +import software.amazon.lambda.durable.TypeToken; +import software.amazon.lambda.durable.model.ExecutionStatus; +import software.amazon.lambda.durable.testing.LocalDurableTestRunner; + +import com.amazonaws.samples.durable.fanout.ParallelProcessingHandler; + +class FanOutPatternTest { + + private static final TypeToken> MAP_TYPE = new TypeToken<>() {}; + + @Test + void executesParallelBranchesAndMapConcurrently() { + var handler = new ParallelProcessingHandler(); + var runner = LocalDurableTestRunner.create(MAP_TYPE, handler); + + Map input = Map.of( + "dataId", "DATA-TEST-001", + "source", "s3://test-bucket/doc.pdf" + ); + + var result = runner.runUntilComplete(input); + + assertEquals(ExecutionStatus.SUCCEEDED, result.getStatus()); + + Map output = result.getResult(Map.class); + assertEquals("FAN_OUT_FAN_IN", output.get("pattern")); + assertEquals("COMPLETED", output.get("status")); + } + + @Test + void aggregatesResultsFromAllBranches() { + var handler = new ParallelProcessingHandler(); + var runner = LocalDurableTestRunner.create(MAP_TYPE, handler); + + Map input = Map.of("dataId", "DATA-TEST-002"); + + var result = runner.runUntilComplete(input); + + assertEquals(ExecutionStatus.SUCCEEDED, result.getStatus()); + + Map output = result.getResult(Map.class); + @SuppressWarnings("unchecked") + Map aggregated = (Map) output.get("result"); + assertNotNull(aggregated.get("sentiment")); + assertNotNull(aggregated.get("entities")); + assertNotNull(aggregated.get("summary")); + assertNotNull(aggregated.get("documentsProcessed")); + } +} diff --git a/lambda-durable-java-sam/durable-functions-sam/durable-functions/src/test/java/com/amazonaws/samples/durable/HumanInteractionPatternTest.java b/lambda-durable-java-sam/durable-functions-sam/durable-functions/src/test/java/com/amazonaws/samples/durable/HumanInteractionPatternTest.java new file mode 100644 index 000000000..7a4b47c65 --- /dev/null +++ b/lambda-durable-java-sam/durable-functions-sam/durable-functions/src/test/java/com/amazonaws/samples/durable/HumanInteractionPatternTest.java @@ -0,0 +1,94 @@ +package com.amazonaws.samples.durable; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.Map; + +import org.junit.jupiter.api.Test; +import software.amazon.lambda.durable.TypeToken; +import software.amazon.lambda.durable.model.ExecutionStatus; +import software.amazon.lambda.durable.testing.LocalDurableTestRunner; + +import com.amazonaws.samples.durable.humaninteraction.ApprovalWorkflowHandler; + +class HumanInteractionPatternTest { + + private static final TypeToken> MAP_TYPE = new TypeToken<>() {}; + + @Test + void completesWhenCallbackApproved() { + var handler = new ApprovalWorkflowHandler(); + var runner = LocalDurableTestRunner.create(MAP_TYPE, handler); + + Map input = Map.of( + "requestId", "REQ-TEST-001", + "amount", 5000.00, + "requestor", "employee@example.com" + ); + + var result = runner.run(input); + assertEquals(ExecutionStatus.PENDING, result.getStatus()); + + String callbackId = runner.getCallbackId("wait-for-approval"); + assertNotNull(callbackId); + + runner.completeCallback(callbackId, + "{\"decision\":\"APPROVED\",\"approver\":\"manager@example.com\"}"); + + result = runner.runUntilComplete(input); + + assertEquals(ExecutionStatus.SUCCEEDED, result.getStatus()); + Map output = result.getResult(Map.class); + assertEquals("HUMAN_INTERACTION", output.get("pattern")); + assertEquals("COMPLETED", output.get("status")); + assertEquals("manager@example.com", output.get("approvedBy")); + } + + @Test + void handlesCallbackRejection() { + var handler = new ApprovalWorkflowHandler(); + var runner = LocalDurableTestRunner.create(MAP_TYPE, handler); + + Map input = Map.of( + "requestId", "REQ-TEST-002", + "amount", 15000.00, + "requestor", "employee@example.com" + ); + + var result = runner.run(input); + assertEquals(ExecutionStatus.PENDING, result.getStatus()); + + String callbackId = runner.getCallbackId("wait-for-approval"); + assertNotNull(callbackId); + + runner.failCallback(callbackId, software.amazon.awssdk.services.lambda.model.ErrorObject.builder() + .errorType("Rejected") + .errorMessage("Budget exceeded policy limit") + .build()); + + result = runner.runUntilComplete(input); + + assertEquals(ExecutionStatus.SUCCEEDED, result.getStatus()); + Map output = result.getResult(Map.class); + assertEquals("HUMAN_INTERACTION", output.get("pattern")); + assertEquals("REJECTED", output.get("status")); + } + + @Test + void requiresVpApprovalForHighAmounts() { + var handler = new ApprovalWorkflowHandler(); + var runner = LocalDurableTestRunner.create(MAP_TYPE, handler); + + Map input = Map.of( + "requestId", "REQ-TEST-003", + "amount", 25000.00, + "requestor", "employee@example.com" + ); + + var result = runner.run(input); + assertEquals(ExecutionStatus.PENDING, result.getStatus()); + + var prepareOp = result.getOperation("prepare-approval"); + assertNotNull(prepareOp); + } +} diff --git a/lambda-durable-java-sam/durable-functions-sam/durable-functions/src/test/java/com/amazonaws/samples/durable/MapProcessingPatternTest.java b/lambda-durable-java-sam/durable-functions-sam/durable-functions/src/test/java/com/amazonaws/samples/durable/MapProcessingPatternTest.java new file mode 100644 index 000000000..f44ccd246 --- /dev/null +++ b/lambda-durable-java-sam/durable-functions-sam/durable-functions/src/test/java/com/amazonaws/samples/durable/MapProcessingPatternTest.java @@ -0,0 +1,99 @@ +package com.amazonaws.samples.durable; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.Map; + +import org.junit.jupiter.api.Test; +import software.amazon.lambda.durable.TypeToken; +import software.amazon.lambda.durable.model.ExecutionStatus; +import software.amazon.lambda.durable.testing.LocalDurableTestRunner; + +import com.amazonaws.samples.durable.mapprocessing.BatchDataProcessorHandler; + +class MapProcessingPatternTest { + + private static final TypeToken> MAP_TYPE = new TypeToken<>() {}; + + @Test + void processesAllItemsWithPartialFailureTolerance() { + var handler = new BatchDataProcessorHandler(); + var runner = LocalDurableTestRunner.create(MAP_TYPE, handler); + + Map input = Map.of( + "batchSize", 10, + "maxConcurrency", 3, + "toleratedFailures", 2 + ); + + var result = runner.runUntilComplete(input); + + assertEquals(ExecutionStatus.SUCCEEDED, result.getStatus()); + + Map output = result.getResult(Map.class); + assertEquals("MAP_PROCESSING", output.get("pattern")); + } + + @Test + void reportsCorrectSuccessAndFailureCounts() { + var handler = new BatchDataProcessorHandler(); + var runner = LocalDurableTestRunner.create(MAP_TYPE, handler); + + Map input = Map.of( + "batchSize", 10, + "maxConcurrency", 3, + "toleratedFailures", 2 + ); + + var result = runner.runUntilComplete(input); + + assertEquals(ExecutionStatus.SUCCEEDED, result.getStatus()); + + Map output = result.getResult(Map.class); + @SuppressWarnings("unchecked") + Map summary = (Map) output.get("summary"); + int succeeded = ((Number) summary.get("succeeded")).intValue(); + int failed = ((Number) summary.get("failed")).intValue(); + + assertEquals(8, succeeded); + assertEquals(2, failed); + assertEquals(10, ((Number) summary.get("totalItems")).intValue()); + } + + @Test + void respectsConcurrencyLimit() { + var handler = new BatchDataProcessorHandler(); + var runner = LocalDurableTestRunner.create(MAP_TYPE, handler); + + Map input = Map.of( + "batchSize", 5, + "maxConcurrency", 2, + "toleratedFailures", 1 + ); + + var result = runner.runUntilComplete(input); + + assertEquals(ExecutionStatus.SUCCEEDED, result.getStatus()); + } + + @Test + void handlesSmallBatch() { + var handler = new BatchDataProcessorHandler(); + var runner = LocalDurableTestRunner.create(MAP_TYPE, handler); + + Map input = Map.of( + "batchSize", 3, + "maxConcurrency", 3, + "toleratedFailures", 1 + ); + + var result = runner.runUntilComplete(input); + + assertEquals(ExecutionStatus.SUCCEEDED, result.getStatus()); + + Map output = result.getResult(Map.class); + @SuppressWarnings("unchecked") + Map summary = (Map) output.get("summary"); + assertEquals(3, ((Number) summary.get("totalItems")).intValue()); + } +} diff --git a/lambda-durable-java-sam/durable-functions-sam/durable-functions/src/test/java/com/amazonaws/samples/durable/MonitoringPatternTest.java b/lambda-durable-java-sam/durable-functions-sam/durable-functions/src/test/java/com/amazonaws/samples/durable/MonitoringPatternTest.java new file mode 100644 index 000000000..03aa64fda --- /dev/null +++ b/lambda-durable-java-sam/durable-functions-sam/durable-functions/src/test/java/com/amazonaws/samples/durable/MonitoringPatternTest.java @@ -0,0 +1,75 @@ +package com.amazonaws.samples.durable; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.Map; + +import org.junit.jupiter.api.Test; +import software.amazon.lambda.durable.TypeToken; +import software.amazon.lambda.durable.model.ExecutionStatus; +import software.amazon.lambda.durable.testing.LocalDurableTestRunner; + +import com.amazonaws.samples.durable.monitoring.JobMonitorHandler; + +class MonitoringPatternTest { + + private static final TypeToken> MAP_TYPE = new TypeToken<>() {}; + + @Test + void pollsUntilJobCompletes() { + var handler = new JobMonitorHandler(); + var runner = LocalDurableTestRunner.create(MAP_TYPE, handler); + + Map input = Map.of( + "jobId", "JOB-TEST-001", + "jobType", "DATA_EXPORT", + "maxPolls", 5 + ); + + var result = runner.runUntilComplete(input); + + assertEquals(ExecutionStatus.SUCCEEDED, result.getStatus()); + + Map output = result.getResult(Map.class); + assertEquals("MONITORING", output.get("pattern")); + assertEquals("COMPLETED", output.get("status")); + assertEquals("JOB-TEST-001", output.get("jobId")); + } + + @Test + void completesWithinPollLimit() { + var handler = new JobMonitorHandler(); + var runner = LocalDurableTestRunner.create(MAP_TYPE, handler); + + Map input = Map.of( + "jobId", "JOB-TEST-002", + "maxPolls", 3 + ); + + var result = runner.runUntilComplete(input); + + assertEquals(ExecutionStatus.SUCCEEDED, result.getStatus()); + + Map output = result.getResult(Map.class); + assertNotNull(output.get("pollAttempts")); + } + + @Test + void reportsCorrectPollAttemptCount() { + var handler = new JobMonitorHandler(); + var runner = LocalDurableTestRunner.create(MAP_TYPE, handler); + + Map input = Map.of( + "jobId", "JOB-TEST-003", + "maxPolls", 4 + ); + + var result = runner.runUntilComplete(input); + + assertEquals(ExecutionStatus.SUCCEEDED, result.getStatus()); + + Map output = result.getResult(Map.class); + int pollAttempts = ((Number) output.get("pollAttempts")).intValue(); + assertTrue(pollAttempts >= 2, "Should have polled at least twice"); + } +} diff --git a/lambda-durable-java-sam/durable-functions-sam/durable-functions/src/test/java/com/amazonaws/samples/durable/SubOrchestrationPatternTest.java b/lambda-durable-java-sam/durable-functions-sam/durable-functions/src/test/java/com/amazonaws/samples/durable/SubOrchestrationPatternTest.java new file mode 100644 index 000000000..51819fbc1 --- /dev/null +++ b/lambda-durable-java-sam/durable-functions-sam/durable-functions/src/test/java/com/amazonaws/samples/durable/SubOrchestrationPatternTest.java @@ -0,0 +1,100 @@ +package com.amazonaws.samples.durable; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import software.amazon.awssdk.services.lambda.model.OperationType; +import software.amazon.lambda.durable.TypeToken; +import software.amazon.lambda.durable.model.ExecutionStatus; +import software.amazon.lambda.durable.testing.LocalDurableTestRunner; + +import com.amazonaws.samples.durable.suborchestration.OrderFulfillmentHandler; + +class SubOrchestrationPatternTest { + + private static final TypeToken> MAP_TYPE = new TypeToken<>() {}; + + @Test + void completesFullOrderFulfillment() { + var handler = new OrderFulfillmentHandler(); + var runner = LocalDurableTestRunner.create(MAP_TYPE, handler); + + Map input = Map.of( + "orderId", "ORD-TEST-001", + "customerId", "CUST-TEST-001", + "amount", 499.99, + "items", List.of( + Map.of("productId", "PROD-001", "name", "Widget", "qty", 2) + ) + ); + + var result = runner.runUntilComplete(input); + + assertEquals(ExecutionStatus.SUCCEEDED, result.getStatus()); + + Map output = result.getResult(Map.class); + assertEquals("SUB_ORCHESTRATION", output.get("pattern")); + assertEquals("FULFILLED", output.get("status")); + assertEquals("ORD-TEST-001", output.get("orderId")); + } + + @Test + void executesPaymentAndInventorySubWorkflowsConcurrently() { + var handler = new OrderFulfillmentHandler(); + var runner = LocalDurableTestRunner.create(MAP_TYPE, handler); + + Map input = Map.of( + "orderId", "ORD-TEST-002", + "customerId", "CUST-TEST-002", + "amount", 150.00 + ); + + var result = runner.runUntilComplete(input); + + assertEquals(ExecutionStatus.SUCCEEDED, result.getStatus()); + + var contextOps = result.getOperations().stream() + .filter(op -> op.getType() == OperationType.CONTEXT) + .toList(); + + var contextNames = contextOps.stream().map(op -> op.getName()).toList(); + assertTrue(contextNames.contains("payment-workflow")); + assertTrue(contextNames.contains("inventory-workflow")); + assertTrue(contextNames.contains("shipping-workflow")); + assertTrue(contextNames.contains("notification-workflow")); + } + + @Test + void producesPaymentAndShippingResults() { + var handler = new OrderFulfillmentHandler(); + var runner = LocalDurableTestRunner.create(MAP_TYPE, handler); + + Map input = Map.of( + "orderId", "ORD-TEST-003", + "customerId", "CUST-TEST-003", + "amount", 200.00 + ); + + var result = runner.runUntilComplete(input); + + assertEquals(ExecutionStatus.SUCCEEDED, result.getStatus()); + + Map output = result.getResult(Map.class); + + @SuppressWarnings("unchecked") + Map payment = (Map) output.get("payment"); + assertEquals("CAPTURED", payment.get("status")); + assertNotNull(payment.get("transactionId")); + + @SuppressWarnings("unchecked") + Map shipping = (Map) output.get("shipping"); + assertNotNull(shipping.get("trackingNumber")); + + @SuppressWarnings("unchecked") + Map inventory = (Map) output.get("inventory"); + assertEquals("RESERVED", inventory.get("status")); + } +} diff --git a/lambda-durable-java-sam/durable-functions-sam/durable-functions/src/test/java/com/amazonaws/samples/durable/TimerPatternTest.java b/lambda-durable-java-sam/durable-functions-sam/durable-functions/src/test/java/com/amazonaws/samples/durable/TimerPatternTest.java new file mode 100644 index 000000000..178c3e064 --- /dev/null +++ b/lambda-durable-java-sam/durable-functions-sam/durable-functions/src/test/java/com/amazonaws/samples/durable/TimerPatternTest.java @@ -0,0 +1,84 @@ +package com.amazonaws.samples.durable; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.Map; + +import org.junit.jupiter.api.Test; +import software.amazon.awssdk.services.lambda.model.OperationType; +import software.amazon.lambda.durable.TypeToken; +import software.amazon.lambda.durable.model.ExecutionStatus; +import software.amazon.lambda.durable.testing.LocalDurableTestRunner; + +import com.amazonaws.samples.durable.timer.ScheduledReminderHandler; + +class TimerPatternTest { + + private static final TypeToken> MAP_TYPE = new TypeToken<>() {}; + + @Test + void completesWithWaitsAdvancedAutomatically() { + var handler = new ScheduledReminderHandler(); + var runner = LocalDurableTestRunner.create(MAP_TYPE, handler); + + Map input = Map.of( + "taskId", "TASK-TEST-001", + "assignee", "dev@example.com", + "reminderIntervalSeconds", 30, + "escalationSeconds", 60 + ); + + var result = runner.runUntilComplete(input); + + assertEquals(ExecutionStatus.SUCCEEDED, result.getStatus()); + + Map output = result.getResult(Map.class); + assertEquals("TIMER", output.get("pattern")); + assertEquals("ESCALATED", output.get("status")); + assertEquals("TASK-TEST-001", output.get("taskId")); + } + + @Test + void includesWaitOperationsInHistory() { + var handler = new ScheduledReminderHandler(); + var runner = LocalDurableTestRunner.create(MAP_TYPE, handler); + + Map input = Map.of( + "taskId", "TASK-TEST-002", + "assignee", "dev@example.com", + "reminderIntervalSeconds", 10, + "escalationSeconds", 20 + ); + + var result = runner.runUntilComplete(input); + + var waitNames = result.getOperations().stream() + .filter(op -> op.getType() == OperationType.WAIT) + .map(op -> op.getName()) + .toList(); + + assertTrue(waitNames.contains("first-reminder-delay"), "Should have first-reminder-delay wait"); + assertTrue(waitNames.contains("escalation-delay"), "Should have escalation-delay wait"); + assertTrue(waitNames.size() >= 2, "Should have at least 2 waits"); + } + + @Test + void escalatesToManager() { + var handler = new ScheduledReminderHandler(); + var runner = LocalDurableTestRunner.create(MAP_TYPE, handler); + + Map input = Map.of( + "taskId", "TASK-TEST-003", + "assignee", "dev@example.com", + "reminderIntervalSeconds", 5, + "escalationSeconds", 10 + ); + + var result = runner.runUntilComplete(input); + + assertEquals(ExecutionStatus.SUCCEEDED, result.getStatus()); + + Map output = result.getResult(Map.class); + assertEquals("manager@example.com", output.get("escalatedTo")); + } +} diff --git a/lambda-durable-java-sam/durable-functions-sam/events/approval-event.json b/lambda-durable-java-sam/durable-functions-sam/events/approval-event.json new file mode 100644 index 000000000..26687a659 --- /dev/null +++ b/lambda-durable-java-sam/durable-functions-sam/events/approval-event.json @@ -0,0 +1,6 @@ +{ + "requestId": "REQ-TEST-001", + "requestor": "employee@example.com", + "amount": 15000.00, + "description": "Conference travel budget request" +} diff --git a/lambda-durable-java-sam/durable-functions-sam/events/chaining-event.json b/lambda-durable-java-sam/durable-functions-sam/events/chaining-event.json new file mode 100644 index 000000000..76e1898ce --- /dev/null +++ b/lambda-durable-java-sam/durable-functions-sam/events/chaining-event.json @@ -0,0 +1,7 @@ +{ + "orderId": "ORD-TEST-001", + "customerId": "CUST-001", + "customerEmail": "test@example.com", + "totalAmount": 299.99, + "shippingAddress": "123 Main St, Seattle, WA 98101" +} diff --git a/lambda-durable-java-sam/durable-functions-sam/events/errorhandling-event.json b/lambda-durable-java-sam/durable-functions-sam/events/errorhandling-event.json new file mode 100644 index 000000000..43ad80c6c --- /dev/null +++ b/lambda-durable-java-sam/durable-functions-sam/events/errorhandling-event.json @@ -0,0 +1,5 @@ +{ + "orderId": "ORD-TEST-001", + "amount": 199.99, + "simulateInventoryFailure": true +} diff --git a/lambda-durable-java-sam/durable-functions-sam/events/fanout-event.json b/lambda-durable-java-sam/durable-functions-sam/events/fanout-event.json new file mode 100644 index 000000000..5b114d323 --- /dev/null +++ b/lambda-durable-java-sam/durable-functions-sam/events/fanout-event.json @@ -0,0 +1,5 @@ +{ + "dataId": "DATA-TEST-001", + "source": "s3://my-bucket/documents/report.pdf", + "analysisTypes": ["sentiment", "entities", "summary"] +} diff --git a/lambda-durable-java-sam/durable-functions-sam/events/mapprocessing-event.json b/lambda-durable-java-sam/durable-functions-sam/events/mapprocessing-event.json new file mode 100644 index 000000000..8ea159879 --- /dev/null +++ b/lambda-durable-java-sam/durable-functions-sam/events/mapprocessing-event.json @@ -0,0 +1,5 @@ +{ + "batchSize": 10, + "maxConcurrency": 3, + "toleratedFailures": 2 +} diff --git a/lambda-durable-java-sam/durable-functions-sam/events/monitoring-event.json b/lambda-durable-java-sam/durable-functions-sam/events/monitoring-event.json new file mode 100644 index 000000000..54c93b3d3 --- /dev/null +++ b/lambda-durable-java-sam/durable-functions-sam/events/monitoring-event.json @@ -0,0 +1,5 @@ +{ + "jobId": "JOB-TEST-001", + "jobType": "DATA_EXPORT", + "maxPolls": 5 +} diff --git a/lambda-durable-java-sam/durable-functions-sam/events/suborchestration-event.json b/lambda-durable-java-sam/durable-functions-sam/events/suborchestration-event.json new file mode 100644 index 000000000..7605b16dd --- /dev/null +++ b/lambda-durable-java-sam/durable-functions-sam/events/suborchestration-event.json @@ -0,0 +1,9 @@ +{ + "orderId": "ORD-TEST-001", + "customerId": "CUST-TEST-001", + "amount": 499.99, + "items": [ + {"productId": "PROD-001", "name": "Widget", "qty": 2}, + {"productId": "PROD-002", "name": "Gadget", "qty": 1} + ] +} diff --git a/lambda-durable-java-sam/durable-functions-sam/events/timer-event.json b/lambda-durable-java-sam/durable-functions-sam/events/timer-event.json new file mode 100644 index 000000000..4c6b4ff80 --- /dev/null +++ b/lambda-durable-java-sam/durable-functions-sam/events/timer-event.json @@ -0,0 +1,6 @@ +{ + "taskId": "TASK-TEST-001", + "assignee": "developer@example.com", + "reminderIntervalSeconds": 10, + "escalationSeconds": 20 +} diff --git a/lambda-durable-java-sam/durable-functions-sam/template.yaml b/lambda-durable-java-sam/durable-functions-sam/template.yaml new file mode 100644 index 000000000..e0cb570fb --- /dev/null +++ b/lambda-durable-java-sam/durable-functions-sam/template.yaml @@ -0,0 +1,523 @@ +AWSTemplateFormatVersion: '2010-09-09' +Transform: AWS::Serverless-2016-10-31 +Description: > + Lambda Durable Functions Java Examples + Demonstrates chaining, fan-out/fan-in, human interaction, monitoring, timers, + error handling, map processing, and sub-orchestration patterns. + +Parameters: + SNSTopicName: + Type: String + Description: Name of the SNS Topic used to trigger durable functions + Default: DurableFunctionsSNSTopic + +Resources: + # SNS Topic for triggering the durable functions + DurableFunctionsSNSTopic: + Type: AWS::SNS::Topic + Properties: + TopicName: !Ref SNSTopicName + + # DynamoDB table for storing results + DurableFunctionsDynamoDBTable: + Type: AWS::DynamoDB::Table + Properties: + TableName: DurableFunctionsResultsTable + AttributeDefinitions: + - AttributeName: ExecutionId + AttributeType: S + - AttributeName: Timestamp + AttributeType: S + KeySchema: + - AttributeName: ExecutionId + KeyType: HASH + - AttributeName: Timestamp + KeyType: RANGE + BillingMode: PAY_PER_REQUEST + + # IAM Role for Durable Lambda Functions + DurableFunctionRole: + Type: AWS::IAM::Role + Properties: + RoleName: !Sub "${AWS::StackName}-durable-function-role" + AssumeRolePolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Principal: + Service: lambda.amazonaws.com + Action: sts:AssumeRole + ManagedPolicyArns: + - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicDurableExecutionRolePolicy + Policies: + - PolicyName: DynamoDBAccess + PolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Action: + - dynamodb:GetItem + - dynamodb:PutItem + - dynamodb:UpdateItem + - dynamodb:Query + - dynamodb:Scan + Resource: + - !GetAtt DurableFunctionsDynamoDBTable.Arn + - !Sub "${DurableFunctionsDynamoDBTable.Arn}/index/*" + - PolicyName: SNSAccess + PolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Action: + - sns:Publish + - sns:Subscribe + Resource: !Ref DurableFunctionsSNSTopic + - PolicyName: LambdaInvokeAccess + PolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Action: + - lambda:InvokeFunction + - lambda:SendDurableExecutionCallbackSuccess + - lambda:SendDurableExecutionCallbackFailure + - lambda:SendDurableExecutionCallbackHeartbeat + Resource: "*" + + # Example 1: Chaining Pattern - Sequential steps with data passing + ChainingFunction: + Type: AWS::Lambda::Function + Properties: + FunctionName: durable-chaining-example + PackageType: Image + Code: + ImageUri: !Sub "${AWS::AccountId}.dkr.ecr.${AWS::Region}.amazonaws.com/durable-functions-java-examples:latest" + ImageConfig: + Command: + - com.amazonaws.samples.durable.chaining.OrderProcessingHandler::handleRequest + Role: !GetAtt DurableFunctionRole.Arn + Timeout: 60 + MemorySize: 512 + Architectures: + - x86_64 + DurableConfig: + ExecutionTimeout: 3600 + RetentionPeriodInDays: 14 + Environment: + Variables: + DYNAMO_DB_TABLE: !Ref DurableFunctionsDynamoDBTable + + ChainingFunctionVersion: + Type: AWS::Lambda::Version + Properties: + FunctionName: !Ref ChainingFunction + + ChainingFunctionAlias: + Type: AWS::Lambda::Alias + Properties: + FunctionName: !Ref ChainingFunction + FunctionVersion: !GetAtt ChainingFunctionVersion.Version + Name: live + + ChainingFunctionSNSPermission: + Type: AWS::Lambda::Permission + Properties: + FunctionName: !Ref ChainingFunctionAlias + Action: lambda:InvokeFunction + Principal: sns.amazonaws.com + SourceArn: !Ref DurableFunctionsSNSTopic + + ChainingFunctionSNSSubscription: + Type: AWS::SNS::Subscription + Properties: + TopicArn: !Ref DurableFunctionsSNSTopic + Protocol: lambda + Endpoint: !Ref ChainingFunctionAlias + FilterPolicy: + pattern: + - chaining + + # Example 2: Fan-Out/Fan-In Pattern - Parallel processing + FanOutFunction: + Type: AWS::Lambda::Function + Properties: + FunctionName: durable-fanout-example + PackageType: Image + Code: + ImageUri: !Sub "${AWS::AccountId}.dkr.ecr.${AWS::Region}.amazonaws.com/durable-functions-java-examples:latest" + ImageConfig: + Command: + - com.amazonaws.samples.durable.fanout.ParallelProcessingHandler::handleRequest + Role: !GetAtt DurableFunctionRole.Arn + Timeout: 60 + MemorySize: 512 + Architectures: + - x86_64 + DurableConfig: + ExecutionTimeout: 3600 + RetentionPeriodInDays: 14 + Environment: + Variables: + DYNAMO_DB_TABLE: !Ref DurableFunctionsDynamoDBTable + + FanOutFunctionVersion: + Type: AWS::Lambda::Version + Properties: + FunctionName: !Ref FanOutFunction + + FanOutFunctionAlias: + Type: AWS::Lambda::Alias + Properties: + FunctionName: !Ref FanOutFunction + FunctionVersion: !GetAtt FanOutFunctionVersion.Version + Name: live + + FanOutFunctionSNSPermission: + Type: AWS::Lambda::Permission + Properties: + FunctionName: !Ref FanOutFunctionAlias + Action: lambda:InvokeFunction + Principal: sns.amazonaws.com + SourceArn: !Ref DurableFunctionsSNSTopic + + FanOutFunctionSNSSubscription: + Type: AWS::SNS::Subscription + Properties: + TopicArn: !Ref DurableFunctionsSNSTopic + Protocol: lambda + Endpoint: !Ref FanOutFunctionAlias + FilterPolicy: + pattern: + - fanout + + # Example 3: Human Interaction Pattern - Callbacks + HumanInteractionFunction: + Type: AWS::Lambda::Function + Properties: + FunctionName: durable-human-interaction-example + PackageType: Image + Code: + ImageUri: !Sub "${AWS::AccountId}.dkr.ecr.${AWS::Region}.amazonaws.com/durable-functions-java-examples:latest" + ImageConfig: + Command: + - com.amazonaws.samples.durable.humaninteraction.ApprovalWorkflowHandler::handleRequest + Role: !GetAtt DurableFunctionRole.Arn + Timeout: 60 + MemorySize: 512 + Architectures: + - x86_64 + DurableConfig: + ExecutionTimeout: 3600 + RetentionPeriodInDays: 14 + Environment: + Variables: + DYNAMO_DB_TABLE: !Ref DurableFunctionsDynamoDBTable + SNS_TOPIC_ARN: !Ref DurableFunctionsSNSTopic + + HumanInteractionFunctionVersion: + Type: AWS::Lambda::Version + Properties: + FunctionName: !Ref HumanInteractionFunction + + HumanInteractionFunctionAlias: + Type: AWS::Lambda::Alias + Properties: + FunctionName: !Ref HumanInteractionFunction + FunctionVersion: !GetAtt HumanInteractionFunctionVersion.Version + Name: live + + HumanInteractionFunctionSNSPermission: + Type: AWS::Lambda::Permission + Properties: + FunctionName: !Ref HumanInteractionFunctionAlias + Action: lambda:InvokeFunction + Principal: sns.amazonaws.com + SourceArn: !Ref DurableFunctionsSNSTopic + + HumanInteractionFunctionSNSSubscription: + Type: AWS::SNS::Subscription + Properties: + TopicArn: !Ref DurableFunctionsSNSTopic + Protocol: lambda + Endpoint: !Ref HumanInteractionFunctionAlias + FilterPolicy: + pattern: + - approval + + # Example 4: Monitoring/Polling Pattern - Wait for condition + MonitoringFunction: + Type: AWS::Lambda::Function + Properties: + FunctionName: durable-monitoring-example + PackageType: Image + Code: + ImageUri: !Sub "${AWS::AccountId}.dkr.ecr.${AWS::Region}.amazonaws.com/durable-functions-java-examples:latest" + ImageConfig: + Command: + - com.amazonaws.samples.durable.monitoring.JobMonitorHandler::handleRequest + Role: !GetAtt DurableFunctionRole.Arn + Timeout: 60 + MemorySize: 512 + Architectures: + - x86_64 + DurableConfig: + ExecutionTimeout: 3600 + RetentionPeriodInDays: 14 + Environment: + Variables: + DYNAMO_DB_TABLE: !Ref DurableFunctionsDynamoDBTable + + MonitoringFunctionVersion: + Type: AWS::Lambda::Version + Properties: + FunctionName: !Ref MonitoringFunction + + MonitoringFunctionAlias: + Type: AWS::Lambda::Alias + Properties: + FunctionName: !Ref MonitoringFunction + FunctionVersion: !GetAtt MonitoringFunctionVersion.Version + Name: live + + MonitoringFunctionSNSPermission: + Type: AWS::Lambda::Permission + Properties: + FunctionName: !Ref MonitoringFunctionAlias + Action: lambda:InvokeFunction + Principal: sns.amazonaws.com + SourceArn: !Ref DurableFunctionsSNSTopic + + MonitoringFunctionSNSSubscription: + Type: AWS::SNS::Subscription + Properties: + TopicArn: !Ref DurableFunctionsSNSTopic + Protocol: lambda + Endpoint: !Ref MonitoringFunctionAlias + FilterPolicy: + pattern: + - monitoring + + # Example 5: Timer Pattern - Scheduled delays + TimerFunction: + Type: AWS::Lambda::Function + Properties: + FunctionName: durable-timer-example + PackageType: Image + Code: + ImageUri: !Sub "${AWS::AccountId}.dkr.ecr.${AWS::Region}.amazonaws.com/durable-functions-java-examples:latest" + ImageConfig: + Command: + - com.amazonaws.samples.durable.timer.ScheduledReminderHandler::handleRequest + Role: !GetAtt DurableFunctionRole.Arn + Timeout: 60 + MemorySize: 512 + Architectures: + - x86_64 + DurableConfig: + ExecutionTimeout: 3600 + RetentionPeriodInDays: 14 + Environment: + Variables: + DYNAMO_DB_TABLE: !Ref DurableFunctionsDynamoDBTable + SNS_TOPIC_ARN: !Ref DurableFunctionsSNSTopic + + TimerFunctionVersion: + Type: AWS::Lambda::Version + Properties: + FunctionName: !Ref TimerFunction + + TimerFunctionAlias: + Type: AWS::Lambda::Alias + Properties: + FunctionName: !Ref TimerFunction + FunctionVersion: !GetAtt TimerFunctionVersion.Version + Name: live + + TimerFunctionSNSPermission: + Type: AWS::Lambda::Permission + Properties: + FunctionName: !Ref TimerFunctionAlias + Action: lambda:InvokeFunction + Principal: sns.amazonaws.com + SourceArn: !Ref DurableFunctionsSNSTopic + + TimerFunctionSNSSubscription: + Type: AWS::SNS::Subscription + Properties: + TopicArn: !Ref DurableFunctionsSNSTopic + Protocol: lambda + Endpoint: !Ref TimerFunctionAlias + FilterPolicy: + pattern: + - timer + + # Example 6: Error Handling Pattern - Retries and compensation + ErrorHandlingFunction: + Type: AWS::Lambda::Function + Properties: + FunctionName: durable-error-handling-example + PackageType: Image + Code: + ImageUri: !Sub "${AWS::AccountId}.dkr.ecr.${AWS::Region}.amazonaws.com/durable-functions-java-examples:latest" + ImageConfig: + Command: + - com.amazonaws.samples.durable.errorhandling.ResilientPaymentHandler::handleRequest + Role: !GetAtt DurableFunctionRole.Arn + Timeout: 60 + MemorySize: 512 + Architectures: + - x86_64 + DurableConfig: + ExecutionTimeout: 3600 + RetentionPeriodInDays: 14 + Environment: + Variables: + DYNAMO_DB_TABLE: !Ref DurableFunctionsDynamoDBTable + + ErrorHandlingFunctionVersion: + Type: AWS::Lambda::Version + Properties: + FunctionName: !Ref ErrorHandlingFunction + + ErrorHandlingFunctionAlias: + Type: AWS::Lambda::Alias + Properties: + FunctionName: !Ref ErrorHandlingFunction + FunctionVersion: !GetAtt ErrorHandlingFunctionVersion.Version + Name: live + + ErrorHandlingFunctionSNSPermission: + Type: AWS::Lambda::Permission + Properties: + FunctionName: !Ref ErrorHandlingFunctionAlias + Action: lambda:InvokeFunction + Principal: sns.amazonaws.com + SourceArn: !Ref DurableFunctionsSNSTopic + + ErrorHandlingFunctionSNSSubscription: + Type: AWS::SNS::Subscription + Properties: + TopicArn: !Ref DurableFunctionsSNSTopic + Protocol: lambda + Endpoint: !Ref ErrorHandlingFunctionAlias + FilterPolicy: + pattern: + - errorhandling + + # Example 7: Map Processing Pattern - Collection processing + MapProcessingFunction: + Type: AWS::Lambda::Function + Properties: + FunctionName: durable-map-processing-example + PackageType: Image + Code: + ImageUri: !Sub "${AWS::AccountId}.dkr.ecr.${AWS::Region}.amazonaws.com/durable-functions-java-examples:latest" + ImageConfig: + Command: + - com.amazonaws.samples.durable.mapprocessing.BatchDataProcessorHandler::handleRequest + Role: !GetAtt DurableFunctionRole.Arn + Timeout: 60 + MemorySize: 512 + Architectures: + - x86_64 + DurableConfig: + ExecutionTimeout: 3600 + RetentionPeriodInDays: 14 + Environment: + Variables: + DYNAMO_DB_TABLE: !Ref DurableFunctionsDynamoDBTable + + MapProcessingFunctionVersion: + Type: AWS::Lambda::Version + Properties: + FunctionName: !Ref MapProcessingFunction + + MapProcessingFunctionAlias: + Type: AWS::Lambda::Alias + Properties: + FunctionName: !Ref MapProcessingFunction + FunctionVersion: !GetAtt MapProcessingFunctionVersion.Version + Name: live + + MapProcessingFunctionSNSPermission: + Type: AWS::Lambda::Permission + Properties: + FunctionName: !Ref MapProcessingFunctionAlias + Action: lambda:InvokeFunction + Principal: sns.amazonaws.com + SourceArn: !Ref DurableFunctionsSNSTopic + + MapProcessingFunctionSNSSubscription: + Type: AWS::SNS::Subscription + Properties: + TopicArn: !Ref DurableFunctionsSNSTopic + Protocol: lambda + Endpoint: !Ref MapProcessingFunctionAlias + FilterPolicy: + pattern: + - mapprocessing + + # Example 8: Sub-Orchestration Pattern - Child contexts + SubOrchestrationFunction: + Type: AWS::Lambda::Function + Properties: + FunctionName: durable-sub-orchestration-example + PackageType: Image + Code: + ImageUri: !Sub "${AWS::AccountId}.dkr.ecr.${AWS::Region}.amazonaws.com/durable-functions-java-examples:latest" + ImageConfig: + Command: + - com.amazonaws.samples.durable.suborchestration.OrderFulfillmentHandler::handleRequest + Role: !GetAtt DurableFunctionRole.Arn + Timeout: 60 + MemorySize: 512 + Architectures: + - x86_64 + DurableConfig: + ExecutionTimeout: 3600 + RetentionPeriodInDays: 14 + Environment: + Variables: + DYNAMO_DB_TABLE: !Ref DurableFunctionsDynamoDBTable + + SubOrchestrationFunctionVersion: + Type: AWS::Lambda::Version + Properties: + FunctionName: !Ref SubOrchestrationFunction + + SubOrchestrationFunctionAlias: + Type: AWS::Lambda::Alias + Properties: + FunctionName: !Ref SubOrchestrationFunction + FunctionVersion: !GetAtt SubOrchestrationFunctionVersion.Version + Name: live + + SubOrchestrationFunctionSNSPermission: + Type: AWS::Lambda::Permission + Properties: + FunctionName: !Ref SubOrchestrationFunctionAlias + Action: lambda:InvokeFunction + Principal: sns.amazonaws.com + SourceArn: !Ref DurableFunctionsSNSTopic + + SubOrchestrationFunctionSNSSubscription: + Type: AWS::SNS::Subscription + Properties: + TopicArn: !Ref DurableFunctionsSNSTopic + Protocol: lambda + Endpoint: !Ref SubOrchestrationFunctionAlias + FilterPolicy: + pattern: + - suborchestration + +Outputs: + SNSTopicArn: + Description: SNS Topic ARN for triggering durable functions + Value: !Ref DurableFunctionsSNSTopic + DynamoDBTableName: + Description: DynamoDB Table for storing results + Value: !Ref DurableFunctionsDynamoDBTable + ECRRepositoryUri: + Description: ECR Repository URI for container images + Value: !Sub "${AWS::AccountId}.dkr.ecr.${AWS::Region}.amazonaws.com/durable-functions-java-examples" diff --git a/lambda-durable-java-sam/example-pattern.json b/lambda-durable-java-sam/example-pattern.json new file mode 100644 index 000000000..e3a5b64aa --- /dev/null +++ b/lambda-durable-java-sam/example-pattern.json @@ -0,0 +1,74 @@ +{ + "title": "AWS Lambda Durable Functions Examples Written in Java and deployed using AWS SAM", + "description": "Demonstrates multiple patterns of AWS Lambda Durable Functions written in Java and deployed using AWS SAM", + "language": "Java", + "level": "300", + "framework": "AWS SAM", + "introBox": { + "headline": "How it works", + "text": [ + "This pattern provides example of AWS Lambda durable functions written in Java and deployed using AWS SAM", + "The different patterns demonstrated are chaining, fan-out/fan-in, human interaction, monitoring/polling, timers, error handling with saga compensation, map processing, and sub-orchestration", + "For detailed deployment instructions instructions see the README.md" + ] + }, + "gitHub": { + "template": { + "repoURL": "https://github.com/aws-samples/serverless-patterns/tree/main/lambda-durable-java-sam", + "templateURL": "serverless-patterns/lambda-durable-java-sam", + "projectFolder": "durable-functions-sam", + "templateFile": "durable-functions-sam/template.yaml" + } + }, + "resources": { + "bullets": [ + { + "text": "Lambda Durable Functions", + "link": "https://docs.aws.amazon.com/lambda/latest/dg/durable-functions.html" + }, + { + "text": "AWS Lambda durable functions", + "link": "https://aws.amazon.com/lambda/lambda-durable-functions/" + }, + { + "text": "AWS Durable Execution SDK Java", + "link": "https://github.com/aws/aws-durable-execution-sdk-java/" + } + ] + }, + "deploy": { + "text": ["sam deploy --guided"] + }, + "testing": { + "text": ["See the GitHub repo for detailed testing instructions."] + }, + "cleanup": { + "text": ["Delete the template: sam delete."] + }, + "authors": [ + { + "name": "Indranil Banerjee", + "bio": "AWS - Senior Solutions Architect", + "image": "https://media.licdn.com/dms/image/v2/C5603AQEL3BG6JZca6A/profile-displayphoto-shrink_800_800/profile-displayphoto-shrink_800_800/0/1653972622784?e=1787184000&v=beta&t=02c8Cs3dvk26XyKhsvieC9FcPEXa8toJnWlUk-rDsU0", + "linkedin": "https://www.linkedin.com/in/indranil-banerjee-b00a261/" + }, + { + "name": "Christian Tomeldan", + "bio": "AWS - Senior Solutions Architect", + "image": "https://media.licdn.com/dms/image/v2/C4E03AQFUq1LazkLjeg/profile-displayphoto-shrink_800_800/profile-displayphoto-shrink_800_800/0/1516572681599?e=1785974400&v=beta&t=hQ4v986Zvjbw5su8_9Xuhj3QWOhmdv7NRh7kbFtOEBI", + "linkedin": "https://www.linkedin.com/in/christian-tomeldan/" + }, + { + "name": "Ajjay Govindaram", + "bio": "AWS - Senior Solutions Architect", + "image": "https://media.licdn.com/dms/image/v2/D5603AQETCOkwv00Lag/profile-displayphoto-crop_800_800/B56ZuQzht7GwAU-/0/1767660985531?e=1785974400&v=beta&t=HoKdUUL1BBmr0OpLFn9nnGwJclVB2UPh_4cxtP7XA_s", + "linkedin": "https://www.linkedin.com/in/ajjaykumar/" + }, + { + "name": "Greg Medard", + "bio": "AWS - Solutions Architect", + "image": "https://media.licdn.com/dms/image/v2/C4E03AQGveSDnRH9aCg/profile-displayphoto-shrink_800_800/profile-displayphoto-shrink_800_800/0/1565226836110?e=1787184000&v=beta&t=XYi5wg0YCeRS5kigIA58hKRYrogUahMkzzLFtzowm5o", + "linkedin": "https://www.linkedin.com/in/gregorymedard/" + } + ] +} diff --git a/lambda-durable-java-sam/images/pattern1-chaining.svg b/lambda-durable-java-sam/images/pattern1-chaining.svg new file mode 100644 index 000000000..dfcfebf88 --- /dev/null +++ b/lambda-durable-java-sam/images/pattern1-chaining.svg @@ -0,0 +1,175 @@ + + + + + + + + + + + + + + + + + + + + + + + + + Pattern 1: Function Chaining + | + Sequential steps with checkpointed results + + + + SNS Topic + + + + + + + + + Step 1: validate-order + Validate orderId exists + Verify amount > 0 + Retry: 3x, exp backoff + + + + checkpointed + + + + + + Step 2: reserve-inventory + Reserve stock in warehouse + Generate reservationId + Retry: 3x, exp backoff + + + + checkpointed + + + + + + Step 3: process-payment + Charge customer card + Generate transactionId + Retry: 3x, exp backoff + + + + checkpointed + + + + + + Step 4: ship-order + Create shipping label + Generate trackingNumber + Retry: 3x, exp backoff + + + + + + + + + Step 5: notify-customer + Send shipment email + Include tracking info + + + + + + Key Concept: Checkpointing & Replay + + Each step's result is durably checkpointed. If the function is interrupted (timeout, error, wait), it resumes from + the last checkpoint without re-executing completed steps. + + + + Step 1 + + + + + + Step 2 + + + + + + Step 3 + + + + + + Step 4 + + + + + Step 5 + + ← On replay: skips 1-2, resumes at 3 + + + + + + Data Flow Between Steps + + + + Input (SNS Message) + orderId: "ORD-123" + totalAmount: 299.99 + customerId: "CUST-001" + shippingAddress: "123 Main St" + + + + + Validate Output + orderId: "ORD-123" + status: "VALIDATED" + amount: 299.99 + customerId: "CUST-001" + + + + + Inventory Output + orderId: "ORD-123" + status: "INVENTORY_RESERVED" + reservationId: "RES-ORD-123" + warehouse: "WAREHOUSE-EAST-1" + + + + + Final Output + pattern: "CHAINING" + finalStatus: "COMPLETED" + trackingNumber: "TRACK-..." + transactionId: "TXN-..." + + → DynamoDB + diff --git a/lambda-durable-java-sam/images/pattern2-fanout.svg b/lambda-durable-java-sam/images/pattern2-fanout.svg new file mode 100644 index 000000000..87d2ef14b --- /dev/null +++ b/lambda-durable-java-sam/images/pattern2-fanout.svg @@ -0,0 +1,137 @@ + + + + + + + + + + + + + + + + + + + + + + + + + Pattern 2: Fan-Out / Fan-In + | + Parallel execution with result aggregation + + + + SNS Topic + + + + + + + parallel("analyze-data") - 3 concurrent branches + + + + + + Branch: sentiment-analysis + Analyze document sentiment + Result: "POSITIVE (0.87)" + + + + + + Branch: entity-extraction + Extract named entities + Result: [AWS, Lambda, Java...] + + + + + + Branch: summarization + Generate document summary + Result: "Document discusses..." + + + + All branches execute concurrently - total time = slowest branch (~700ms) + + + + BARRIER + + + + + + + + + map("process-documents") - 5 items processed concurrently + + + + doc-1 + wordCount: 150 + PROCESSED ✓ + + + doc-2 + wordCount: 300 + PROCESSED ✓ + + + doc-3 + wordCount: 450 + PROCESSED ✓ + + + doc-4 + wordCount: 600 + PROCESSED ✓ + + + doc-5 + wordCount: 750 + PROCESSED ✓ + + + + Each item gets its own child context with independent checkpointing + + + + + + + + + Step: aggregate-results + Combines parallel analysis + map results + sentiment + entities + summary + docsProcessed + → Returns: FAN_OUT_FAN_IN COMPLETED + + + + Legend + + parallel() - heterogeneous + + map() - homogeneous + + Barrier (wait for all) + + Checkpointed step + parallel() runs different + tasks concurrently; map() + applies same logic to each + diff --git a/lambda-durable-java-sam/images/pattern3-human-interaction.svg b/lambda-durable-java-sam/images/pattern3-human-interaction.svg new file mode 100644 index 000000000..3a30b2bde --- /dev/null +++ b/lambda-durable-java-sam/images/pattern3-human-interaction.svg @@ -0,0 +1,139 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Pattern 3: Human Interaction + | + Suspend execution awaiting external callback + + + + + + prepare-approval + Build approval request + Identify approver + Retry: 3x, exp backoff + + + + + + + + + notify-approver + Send email/SMS + Include callback URL + Retry: 3x, exp backoff + + + + + + + EXECUTION SUSPENDED + createCallback() + No compute charges during wait + Timeout: 72 hours + + $0.00 / hour billed + + + + Callback ID logged for external use: + send-durable-execution-callback-success + + + + + + + APPROVED + + + + + + + REJECTED + + + + + + process-request + Execute approved action + Update database record + + + + + + + + + send-confirmation + Notify requester + Include approval details + + + + + + + COMPLETED + + + + + + return-status + Return REJECTED status + + + + + + Key Concept: Callback-Based Suspension + + The execution is fully suspended during the wait. No Lambda compute is consumed. The function resumes only when an + external system calls back with the callback ID, or when the timeout (72 hours) expires. + + + + + Active (~200ms) + + SUSPENDED - Zero compute (minutes to 72 hours) + + Resumed (~100ms) + + External trigger: aws lambda invoke ... send-durable-execution-callback-success --payload '{"callbackId": "...", "result": "APPROVED"}' + diff --git a/lambda-durable-java-sam/images/pattern4-monitoring.svg b/lambda-durable-java-sam/images/pattern4-monitoring.svg new file mode 100644 index 000000000..70ca1f4e5 --- /dev/null +++ b/lambda-durable-java-sam/images/pattern4-monitoring.svg @@ -0,0 +1,144 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + Pattern 4: Monitoring + | + Polling with exponential backoff and durable suspension + + + + + + acknowledge-job + Record job start time + Initialize attempt = 0 + + + + + + + + + waitForCondition() - Polling Loop with Exponential Backoff + + + + Poll #1 + check status + NOT READY + + + + + SUSPEND + wait 5s + + + + + Poll #2 + check status + NOT READY + + + + + SUSPEND + wait 10s + + + + + Poll #3 + check status + NOT READY + + + + + SUSPEND + wait 20s + + + ... + + + + Poll + #N + DONE! + + + + Backoff Formula: + delay = 5s × 2^(attempt-1), capped at 60s | Sequence: 5s, 10s, 20s, 40s, 60s, 60s, ... + + + + + + + + + Condition Check (stopPolling) + COMPLETED or FAILED → exit loop + Max attempts or timeout → FAILED + + + + + + + + + process-result + Format final status + Store job outcome + + + + + + + COMPLETED + + + + + + Key Concept: Durable Polling vs. Busy Waiting + + Unlike traditional polling (Lambda running continuously), each wait() call suspends the execution entirely. + The function resumes only when the timer fires. You pay only for the brief poll check (~50ms), not the wait time. + + + + Traditional: 10 polls × 15min timeout = 150min billed + + Durable: 10 polls × 50ms active = 500ms billed + diff --git a/lambda-durable-java-sam/images/pattern5-timer.svg b/lambda-durable-java-sam/images/pattern5-timer.svg new file mode 100644 index 000000000..7fbc43b86 --- /dev/null +++ b/lambda-durable-java-sam/images/pattern5-timer.svg @@ -0,0 +1,148 @@ + + + + + + + + + + + + + + + + + + + + + + + + + Pattern 5: Timer / Scheduled Delays + | + Durable waits with zero compute charges + + + + + + + create-task + Create escalation task + Record creation time + + + + + + + + + send-initial-notification + Notify assignee + Start escalation timer + + + + + + + SUSPENDED + wait(30s) + + + + + + + + + send-first-reminder + Remind assignee + Warning: escalation soon + + + + + + + SUSPENDED + wait(60s) + + + + + + + + + escalate-to-manager + Notify manager + Mark as escalated + + + + + + + + + Parallel: waitAsync(5s) + stepAsync(final-processing) + + + waitAsync(5s) + + + stepAsync: final-processing + + + + + + + ESCALATED + + + + + + Key Concept: Zero-Cost Durable Timers + + During wait() periods, the execution is fully suspended. No Lambda compute is consumed. + The function resumes precisely when the timer fires - whether 30 seconds or 30 days later. + + + Execution Timeline: + + + ~100ms + + + wait(30s) - $0 + + + ~100ms + + + wait(60s) - $0 + + + ~100ms + + + wait(5s) - $0 + + + ~100ms + + + Total billed compute: ~400ms | Total elapsed time: ~95 seconds | Cost savings vs continuous execution: 99.6% + + + + Zero compute charges during all wait() periods + diff --git a/lambda-durable-java-sam/images/pattern6-error-handling.svg b/lambda-durable-java-sam/images/pattern6-error-handling.svg new file mode 100644 index 000000000..6e472324f --- /dev/null +++ b/lambda-durable-java-sam/images/pattern6-error-handling.svg @@ -0,0 +1,139 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Pattern 6: Error Handling & Compensation + | + Retry policies, saga compensation, and interrupt handling + + + + + + validate-payment + Verify card details + Check fraud signals + Retry: default (3x, backoff) + + + + + + + + + charge-payment (AT_MOST_ONCE) + + Custom Retry Policy: + • Skip: IllegalArgumentException + • Retry transient errors: up to 4x + • Backoff: exponential + + + + Attempt 0: FAIL + + Attempt 1: OK ✓ + + + + + + StepInterruptedException + Check payment status externally + before retrying (idempotency) + + + + + + + + + reserve-inventory + Reserve stock + Lock quantity + Retry: 3x, exp backoff + + + + + SUCCESS + + + + COMPLETED + + + + + FAIL + + + + + + SAGA COMPENSATION + + + + + + refund-payment + Reverse charge + Notify customer + + + + + + + COMPENSATED + + + + + + Key Concepts: Retry Semantics & Saga Pattern + + + AT_MOST_ONCE Semantics: + If the function is interrupted during a step marked AT_MOST_ONCE, it will NOT automatically retry. + Instead, a StepInterruptedException is thrown so you can check the external state before deciding to retry. + + + Saga Compensation Pattern: + When a later step fails, previously completed steps are compensated (reversed) to maintain consistency. + Each step has an associated compensation action that undoes its effect. + + + Custom Retry Configuration: + + retryOn(RuntimeException.class).skipOn(IllegalArgumentException.class).maxRetries(4).backoff(exponential) | Non-retryable errors skip immediately + diff --git a/lambda-durable-java-sam/images/pattern7-map-processing.svg b/lambda-durable-java-sam/images/pattern7-map-processing.svg new file mode 100644 index 000000000..97bbe8f01 --- /dev/null +++ b/lambda-durable-java-sam/images/pattern7-map-processing.svg @@ -0,0 +1,147 @@ + + + + + + + + + + + + + + + + + + + + + + + + + Pattern 7: Map Processing + | + Bounded-concurrency batch processing with failure tolerance + + + + + + prepare-batch + Load 10 items from queue + Partition into work units + + + + + + + + + map("process-items") - maxConcurrency=3, toleratedFailures=2 + + + 3 Concurrent Slots: + + + + + + Slot 1 + + + validate + + + transform + + + store + + Items 1, 4, 7, 10 + item-5: ✗ fails validation + + + + + + Slot 2 + + + validate + + + transform + + + store + + Items 2, 5, 8 + item-10: ✗ fails validation + + + + + + Slot 3 + + + validate + + + transform + + + store + + Items 3, 6, 9 + All succeed ✓ + + + + Failure Tolerance: 2 failures tolerated + Every 5th item fails validation. 2 failures (items 5, 10) ≤ toleratedFailures=2 → map() continues and completes + + + + Progress: 10/10 processed | 8 succeeded | 2 failed (within tolerance) + + + + + + + + + summarize-results + Aggregate outcomes: + 8 succeeded ✓ + 2 failed ✗ + Success rate: 80% + Store summary in DDB + Total items: 10 + + + + + + Key Concept: Bounded Concurrency & Failure Tolerance + + map() processes items with bounded concurrency (maxConcurrency=3). Items are distributed across slots round-robin. + If failures ≤ toleratedFailures, the map completes successfully. If exceeded, it throws MapBatchFailedException. + + + + Configuration: + maxConcurrency=3 | toleratedFailures=2 | each item: validate → transform → store + Items assigned to slots: Slot1=[1,4,7,10] Slot2=[2,5,8] Slot3=[3,6,9] + + + + vs. Step Functions Map: + Same bounded concurrency concept, but runs within a single Lambda + No state machine transitions overhead - sub-steps are checkpointed within the function + diff --git a/lambda-durable-java-sam/images/pattern8-sub-orchestration.svg b/lambda-durable-java-sam/images/pattern8-sub-orchestration.svg new file mode 100644 index 000000000..a3c92e758 --- /dev/null +++ b/lambda-durable-java-sam/images/pattern8-sub-orchestration.svg @@ -0,0 +1,160 @@ + + + + + + + + + + + + + + + + + + + + + + + + + Pattern 8: Sub-Orchestration + | + Child workflow contexts with isolated operation namespaces + + + + Main Context + + + + + + validate-order + Verify order details + Check inventory exists + + + + + + + + + CONCURRENT: allOf() barrier - both must complete + + + + + + Payment Sub-Workflow (child context: "payment") + + + authorize-card + + + + + wait 2s + SUSPEND + + + + + fraud-check + + + + + capture-payment + + + Isolated namespace: payment.authorize-card, payment.fraud-check, ... + + + + + + Inventory Sub-Workflow ("inventory") + + + check-availability + + + + + reserve-stock + + + Isolated: inventory.check-availability, inventory.reserve-stock + + + + BARRIER + + + + sequential + + + + + + Shipping Sub-Workflow (child context: "shipping") + + + create-label + + + + + schedule-pickup + + + + + generate-tracking + + + Isolated: shipping.* + + + + + + + + + Notification Sub-Workflow (child context: "notification") + + + send-confirmation + + + + + send-shipping-notification + + + notification.* + + + + + + + FULFILLED + + + + + + Key Concept: Isolated Child Contexts + + Each child workflow runs in its own context with namespaced operations (e.g., "payment.authorize-card"). This prevents + step name collisions and enables independent checkpointing. allOf() waits for all concurrent children before proceeding. + diff --git a/lambda-durable-java-sam/infrastructure/ec2-client-instance.yaml b/lambda-durable-java-sam/infrastructure/ec2-client-instance.yaml new file mode 100644 index 000000000..c9bf2f85d --- /dev/null +++ b/lambda-durable-java-sam/infrastructure/ec2-client-instance.yaml @@ -0,0 +1,365 @@ +AWSTemplateFormatVersion: '2010-09-09' +Parameters: + LatestAmiId: + Type: 'AWS::SSM::Parameter::Value' + Default: '/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-6.1-x86_64' + JavaVersion: + Type: String + Description: Choose the version of Java. Lambda currently supports Java 17 and 21 + AllowedValues: + - java17 + - java21 + Default: java21 + ServerlessLandGithubLocation: + Type: String + Default: https://github.com/aws-samples/serverless-patterns + Description: Github location of the serverless-patterns repo. Make sure to leave the .git at the end even if you are using a fork + +Mappings: + SubnetConfig: + VPC: + CIDR: '10.1.0.0/16' + PublicOne: + CIDR: '10.1.0.0/24' + +Resources: + VPC: + Type: AWS::EC2::VPC + Properties: + EnableDnsSupport: true + EnableDnsHostnames: true + CidrBlock: !FindInMap ['SubnetConfig', 'VPC', 'CIDR'] + Tags: + - Key: 'Name' + Value: 'DurableFunctionsVPC' + + PublicSubnetOne: + Type: AWS::EC2::Subnet + Properties: + AvailabilityZone: + Fn::Select: + - 0 + - Fn::GetAZs: {Ref: 'AWS::Region'} + VpcId: !Ref 'VPC' + CidrBlock: !FindInMap ['SubnetConfig', 'PublicOne', 'CIDR'] + MapPublicIpOnLaunch: true + Tags: + - Key: 'Name' + Value: 'DurableFunctionsPublicSubnet' + + InternetGateway: + Type: AWS::EC2::InternetGateway + GatewayAttachement: + Type: AWS::EC2::VPCGatewayAttachment + Properties: + VpcId: !Ref 'VPC' + InternetGatewayId: !Ref 'InternetGateway' + + PublicRouteTable: + Type: AWS::EC2::RouteTable + Properties: + VpcId: !Ref 'VPC' + + PublicRoute: + Type: AWS::EC2::Route + DependsOn: GatewayAttachement + Properties: + RouteTableId: !Ref 'PublicRouteTable' + DestinationCidrBlock: '0.0.0.0/0' + GatewayId: !Ref 'InternetGateway' + + PublicSubnetOneRouteTableAssociation: + Type: AWS::EC2::SubnetRouteTableAssociation + Properties: + SubnetId: !Ref PublicSubnetOne + RouteTableId: !Ref PublicRouteTable + + ClientInstanceSecurityGroup: + Type: AWS::EC2::SecurityGroup + Properties: + GroupDescription: Security group for the Durable Functions client EC2 instance + GroupName: !Sub "${AWS::StackName}-client-sg" + VpcId: !Ref VPC + SecurityGroupIngress: + - IpProtocol: tcp + FromPort: 22 + ToPort: 22 + CidrIp: 10.0.0.0/16 + + ClientEC2Instance: + Type: AWS::EC2::Instance + Properties: + InstanceType: m5.large + IamInstanceProfile: !Ref EC2InstanceProfile + AvailabilityZone: + Fn::Select: + - 0 + - Fn::GetAZs: {Ref: 'AWS::Region'} + SubnetId: !Ref PublicSubnetOne + SecurityGroupIds: [!GetAtt ClientInstanceSecurityGroup.GroupId] + ImageId: !Ref LatestAmiId + Tags: + - Key: 'Name' + Value: 'DurableFunctionsClientInstance' + BlockDeviceMappings: + - DeviceName: /dev/xvda + Ebs: + VolumeSize: 50 + VolumeType: gp2 + DeleteOnTermination: true + UserData: + Fn::Base64: + !Sub + - | + #!/bin/bash + yum update -y + + # install Java + max_attempts=5 + attempt_num=1 + success=false + while [ $success = false ] && [ $attempt_num -le $max_attempts ]; do + echo "Trying yum install Java" + JAVA_VERSION=${java_version} + echo "export JAVA_VERSION=$JAVA_VERSION" >> /home/ec2-user/.bash_profile + if [ "$JAVA_VERSION" == "java17" ]; then + sudo yum install java-17-amazon-corretto-devel -y + elif [ "$JAVA_VERSION" == "java21" ]; then + sudo yum install java-21-amazon-corretto-devel -y + else + sudo yum install java-21-amazon-corretto-devel -y + fi + if [ $? -eq 0 ]; then + echo "Yum install of Java succeeded" + success=true + else + echo "Attempt $attempt_num failed. Sleeping for 3 seconds and trying again..." + sleep 3 + ((attempt_num++)) + fi + done + + # Set JAVA_HOME based on installed version + if [ "$JAVA_VERSION" == "java17" ]; then + JAVA_HOME=/usr/lib/jvm/java-17-amazon-corretto + else + JAVA_HOME=/usr/lib/jvm/java-21-amazon-corretto + fi + echo "export JAVA_HOME=$JAVA_HOME" >> /home/ec2-user/.bash_profile + echo "export PATH=$JAVA_HOME/bin:\$PATH" >> /home/ec2-user/.bash_profile + export JAVA_HOME + export PATH=$JAVA_HOME/bin:$PATH + + max_attempts=5 + attempt_num=1 + success=false + while [ $success = false ] && [ $attempt_num -le $max_attempts ]; do + echo "Trying yum install git" + yum install git -y + if [ $? -eq 0 ]; then + echo "Yum install of git succeeded" + success=true + else + echo "Attempt $attempt_num failed. Sleeping for 3 seconds and trying again..." + sleep 3 + ((attempt_num++)) + fi + done + + max_attempts=5 + attempt_num=1 + success=false + while [ $success = false ] && [ $attempt_num -le $max_attempts ]; do + echo "Trying yum install jq" + yum install jq -y + if [ $? -eq 0 ]; then + echo "Yum install of jq succeeded" + success=true + else + echo "Attempt $attempt_num failed. Sleeping for 3 seconds and trying again..." + sleep 3 + ((attempt_num++)) + fi + done + + max_attempts=5 + attempt_num=1 + success=false + while [ $success = false ] && [ $attempt_num -le $max_attempts ]; do + echo "Trying yum install docker" + sudo yum install -y docker + if [ $? -eq 0 ]; then + echo "Yum install of docker succeeded" + success=true + else + echo "Attempt $attempt_num failed. Sleeping for 3 seconds and trying again..." + sleep 3 + ((attempt_num++)) + fi + done + + service docker start + usermod -a -G docker ec2-user + + max_attempts=5 + attempt_num=1 + success=false + while [ $success = false ] && [ $attempt_num -le $max_attempts ]; do + echo "Trying yum install maven" + sudo yum install -y maven + if [ $? -eq 0 ]; then + echo "Yum install of maven succeeded" + success=true + else + echo "Attempt $attempt_num failed. Sleeping for 3 seconds and trying again..." + sleep 3 + ((attempt_num++)) + fi + done + + # Remove old AWS CLI and install AWS CLI 2 + yum erase awscli -y + cd /home/ec2-user + mkdir -p awscli + cd awscli + curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip" + unzip awscliv2.zip + sudo ./aws/install + + # Install AWS SAM CLI + cd /home/ec2-user + mkdir -p awssam + cd awssam + wget https://github.com/aws/aws-sam-cli/releases/latest/download/aws-sam-cli-linux-x86_64.zip + unzip aws-sam-cli-linux-x86_64.zip -d sam-installation + sudo ./sam-installation/install + + # Set environment variables + AWS_REGION=${aws_region} + echo "export AWS_REGION=$AWS_REGION" >> /home/ec2-user/.bash_profile + + # Clone the durable functions project from serverless-patterns + cd /home/ec2-user + SERVERLESS_LAND_GITHUB_LOCATION=${serverless_land_github_location} + git clone -n --depth=1 --filter=tree:0 $SERVERLESS_LAND_GITHUB_LOCATION + cd ./serverless-patterns + git sparse-checkout set --no-cone /lambda-durable-java-sam + git checkout + cd lambda-durable-java-sam + sudo chown -R ec2-user . + + # Get IP CIDR range for EC2 Instance Connect + cd /home/ec2-user + mkdir -p ip_prefix + cd ip_prefix + git clone https://github.com/joetek/aws-ip-ranges-json.git + cd aws-ip-ranges-json + EC2_CONNECT_IP=$(cat ip-ranges-ec2-instance-connect.json | jq -r --arg AWS_REGION "$AWS_REGION" '.prefixes[] | select(.region==$AWS_REGION).ip_prefix') + echo "export EC2_CONNECT_IP=$EC2_CONNECT_IP" >> /home/ec2-user/.bash_profile + SECURITY_GROUP=${security_group_id} + echo "export SECURITY_GROUP=$SECURITY_GROUP" >> /home/ec2-user/.bash_profile + aws ec2 authorize-security-group-ingress --region $AWS_REGION --group-id $SECURITY_GROUP --protocol tcp --port 22 --cidr $EC2_CONNECT_IP + + - serverless_land_github_location: !Ref ServerlessLandGithubLocation + aws_region: !Ref 'AWS::Region' + java_version: !Ref JavaVersion + security_group_id: !GetAtt ClientInstanceSecurityGroup.GroupId + + EC2InstanceEndpoint: + Type: AWS::EC2::InstanceConnectEndpoint + Properties: + PreserveClientIp: true + SecurityGroupIds: + - !GetAtt ClientInstanceSecurityGroup.GroupId + SubnetId: !Ref PublicSubnetOne + + EC2Role: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Version: 2012-10-17 + Statement: + - Sid: '' + Effect: Allow + Principal: + Service: ec2.amazonaws.com + Action: 'sts:AssumeRole' + Path: "/" + ManagedPolicyArns: + - arn:aws:iam::aws:policy/AWSCloudFormationFullAccess + - arn:aws:iam::aws:policy/CloudWatchFullAccess + - arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore + - arn:aws:iam::aws:policy/AmazonS3FullAccess + - arn:aws:iam::aws:policy/IAMFullAccess + - arn:aws:iam::aws:policy/AWSLambda_FullAccess + - arn:aws:iam::aws:policy/AmazonDynamoDBFullAccess_v2 + - arn:aws:iam::aws:policy/AmazonSNSFullAccess + - arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryFullAccess + Policies: + - PolicyName: SecurityGroupsPolicy + PolicyDocument: !Sub '{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "ec2:DescribeSecurityGroups", + "ec2:DescribeSecurityGroupRules", + "ec2:DescribeTags" + ], + "Resource": "*" + }, + { + "Effect": "Allow", + "Action": [ + "ec2:AuthorizeSecurityGroupIngress", + "ec2:RevokeSecurityGroupIngress", + "ec2:AuthorizeSecurityGroupEgress", + "ec2:RevokeSecurityGroupEgress", + "ec2:ModifySecurityGroupRules", + "ec2:UpdateSecurityGroupRuleDescriptionsIngress", + "ec2:UpdateSecurityGroupRuleDescriptionsEgress" + ], + "Resource": [ + "arn:aws:ec2:${AWS::Region}:${AWS::AccountId}:security-group/*" + ] + }, + { + "Effect": "Allow", + "Action": [ + "ec2:ModifySecurityGroupRules" + ], + "Resource": [ + "arn:aws:ec2:${AWS::Region}:${AWS::AccountId}:security-group-rule/*" + ] + } + ] + }' + + EC2InstanceProfile: + Type: AWS::IAM::InstanceProfile + Properties: + InstanceProfileName: !Join + - '-' + - - 'EC2DurableFunctionsProfile' + - !Ref 'AWS::StackName' + Roles: + - !Ref EC2Role + +Outputs: + VPCId: + Description: The ID of the VPC created + Value: !Ref 'VPC' + Export: + Name: !Sub "${AWS::StackName}-VPCID" + PublicSubnetOne: + Description: The name of the public subnet created + Value: !Ref 'PublicSubnetOne' + Export: + Name: !Sub "${AWS::StackName}-PublicSubnetOne" + EC2InstanceEndpointID: + Description: The ID of the EC2 Instance Connect Endpoint + Value: !Ref EC2InstanceEndpoint + EC2InstanceId: + Description: The ID of the EC2 Client Instance + Value: !Ref ClientEC2Instance diff --git a/lambda-durable-java-sam/sns-message-sender/pom.xml b/lambda-durable-java-sam/sns-message-sender/pom.xml new file mode 100644 index 000000000..0bc510ef9 --- /dev/null +++ b/lambda-durable-java-sam/sns-message-sender/pom.xml @@ -0,0 +1,59 @@ + + 4.0.0 + com.amazonaws.samples.durable + sns-message-sender + 1.0-SNAPSHOT + jar + SNS Message Sender for Durable Functions + + + UTF-8 + 21 + 21 + + + + + software.amazon.awssdk + sns + 2.25.0 + + + com.fasterxml.jackson.core + jackson-databind + 2.17.0 + + + + + + + org.apache.maven.plugins + maven-shade-plugin + 3.6.2 + + false + + + + package + + shade + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + 21 + 21 + + + + + diff --git a/lambda-durable-java-sam/sns-message-sender/src/main/java/sns/producer/DurableFunctionsTrigger.java b/lambda-durable-java-sam/sns-message-sender/src/main/java/sns/producer/DurableFunctionsTrigger.java new file mode 100644 index 000000000..159e72d4c --- /dev/null +++ b/lambda-durable-java-sam/sns-message-sender/src/main/java/sns/producer/DurableFunctionsTrigger.java @@ -0,0 +1,166 @@ +package sns.producer; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + +import software.amazon.awssdk.services.sns.SnsClient; +import software.amazon.awssdk.services.sns.model.ListTopicsRequest; +import software.amazon.awssdk.services.sns.model.ListTopicsResponse; +import software.amazon.awssdk.services.sns.model.MessageAttributeValue; +import software.amazon.awssdk.services.sns.model.PublishRequest; +import software.amazon.awssdk.services.sns.model.PublishResponse; +import software.amazon.awssdk.services.sns.model.Topic; + +/** + * SNS Message Sender for triggering Lambda Durable Function examples. + * + * Usage: + * java -cp target/sns-message-sender-1.0-SNAPSHOT.jar sns.producer.DurableFunctionsTrigger + * + * Patterns: + * chaining - Order processing with sequential steps + * fanout - Parallel data analysis + * approval - Human-in-the-loop approval workflow + * monitoring - Job status polling + * timer - Scheduled reminders with delays + * errorhandling - Resilient payment with saga compensation + * mapprocessing - Batch data processing + * suborchestration - Order fulfillment with sub-workflows + */ +public class DurableFunctionsTrigger { + + private static final ObjectMapper mapper = new ObjectMapper(); + + public static void main(String[] args) { + if (args.length < 2) { + System.out.println("Usage: DurableFunctionsTrigger "); + System.out.println("Patterns: chaining, fanout, approval, monitoring, timer, errorhandling, mapprocessing, suborchestration"); + System.exit(1); + } + + String topicName = args[0]; + String pattern = args[1]; + + Map message = buildMessage(pattern); + sendToSNS(topicName, message, pattern); + } + + private static Map buildMessage(String pattern) { + switch (pattern) { + case "chaining": + return Map.of( + "orderId", "ORD-" + System.currentTimeMillis(), + "customerId", "CUST-001", + "customerEmail", "customer@example.com", + "totalAmount", 299.99, + "shippingAddress", "123 Main St, Seattle, WA 98101" + ); + case "fanout": + return Map.of( + "dataId", "DATA-" + System.currentTimeMillis(), + "source", "s3://my-bucket/documents/report.pdf", + "analysisTypes", List.of("sentiment", "entities", "summary") + ); + case "approval": + return Map.of( + "requestId", "REQ-" + System.currentTimeMillis(), + "requestor", "employee@example.com", + "amount", 15000.00, + "description", "Conference travel budget request" + ); + case "monitoring": + return Map.of( + "jobId", "JOB-" + System.currentTimeMillis(), + "jobType", "DATA_EXPORT", + "maxPolls", 5 + ); + case "timer": + return Map.of( + "taskId", "TASK-" + System.currentTimeMillis(), + "assignee", "developer@example.com", + "reminderIntervalSeconds", 30, + "escalationSeconds", 60 + ); + case "errorhandling": + return Map.of( + "orderId", "ORD-" + System.currentTimeMillis(), + "amount", 199.99, + "simulateInventoryFailure", false + ); + case "mapprocessing": + return Map.of( + "batchSize", 10, + "maxConcurrency", 3, + "toleratedFailures", 2 + ); + case "suborchestration": + return Map.of( + "orderId", "ORD-" + System.currentTimeMillis(), + "customerId", "CUST-" + System.currentTimeMillis(), + "amount", 499.99, + "items", List.of( + Map.of("productId", "PROD-001", "name", "Widget", "qty", 2), + Map.of("productId", "PROD-002", "name", "Gadget", "qty", 1) + ) + ); + default: + System.err.println("Unknown pattern: " + pattern); + System.exit(1); + return Map.of(); + } + } + + private static void sendToSNS(String topicName, Map message, String pattern) { + SnsClient snsClient = SnsClient.builder().build(); + String topicArn = getTopicArnFromName(snsClient, topicName); + + if (topicArn.isEmpty()) { + System.err.println("Topic not found: " + topicName); + System.exit(1); + } + + try { + String messageBody = mapper.writeValueAsString(message); + + Map attributes = new HashMap<>(); + attributes.put("pattern", MessageAttributeValue.builder() + .dataType("String").stringValue(pattern).build()); + + PublishRequest request = PublishRequest.builder() + .topicArn(topicArn) + .message(messageBody) + .messageAttributes(attributes) + .subject("Durable Functions Trigger - " + pattern) + .build(); + + System.out.println("============================================================"); + System.out.println("Sending SNS message to trigger: " + pattern); + System.out.println("Topic: " + topicArn); + System.out.println("Message: " + messageBody); + System.out.println("============================================================"); + + PublishResponse response = snsClient.publish(request); + System.out.println("Message sent! ID: " + response.messageId()); + System.out.println("Status: " + response.sdkHttpResponse().statusCode()); + System.out.println("============================================================"); + + } catch (JsonProcessingException e) { + System.err.println("Failed to serialize message: " + e.getMessage()); + System.exit(1); + } + } + + private static String getTopicArnFromName(SnsClient snsClient, String topicName) { + ListTopicsResponse response = snsClient.listTopics(ListTopicsRequest.builder().build()); + for (Topic topic : response.topics()) { + if (topic.topicArn().endsWith(topicName)) { + return topic.topicArn(); + } + } + return ""; + } +}