diff --git a/cdk-dotnet-durablefunction-imageprocessing/.gitignore b/cdk-dotnet-durablefunction-imageprocessing/.gitignore new file mode 100644 index 000000000..a4609e758 --- /dev/null +++ b/cdk-dotnet-durablefunction-imageprocessing/.gitignore @@ -0,0 +1,342 @@ +# CDK asset staging directory +.cdk.staging +cdk.out + +# Created by https://www.gitignore.io/api/csharp + +### Csharp ### +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore + +# User-specific files +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUNIT +*.VisualState.xml +TestResult.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_i.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# JustCode is a .NET coding add-in +.JustCode + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# JetBrains Rider +.idea/ +*.sln.iml + +# CodeRush +.cr/ + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + + +# End of https://www.gitignore.io/api/csharp \ No newline at end of file diff --git a/cdk-dotnet-durablefunction-imageprocessing/.kiro/steering.md b/cdk-dotnet-durablefunction-imageprocessing/.kiro/steering.md new file mode 100644 index 000000000..cf931434f --- /dev/null +++ b/cdk-dotnet-durablefunction-imageprocessing/.kiro/steering.md @@ -0,0 +1,338 @@ +# Project Steering: CDK .NET Durable Function — Image Processing + +## Overview + +This project implements a serverless image-processing pipeline using **AWS Lambda Durable Functions** for .NET, deployed with **AWS CDK** (C#). Lambda Durable Functions allow you to build resilient, long-running workflows that automatically checkpoint progress, resume after failures, and can run for up to one year — with charges only for active compute time. + +## Technology Stack + +- **Runtime:** .NET 10 (Lambda `dotnet10` managed runtime) +- **SDK:** `Amazon.Lambda.DurableExecution` (preview, 0.x) +- **Infrastructure:** AWS CDK v2 (C#) +- **Packaging:** Zip deployment (required for durable functions) +- **Serialization:** `Amazon.Lambda.Serialization.SystemTextJson` + +## Project Structure + +``` +src/ +├── infra/ # CDK infrastructure (C#) +│ ├── Program.cs # CDK app entry point +│ ├── InfraStack.cs # Stack definition +│ └── infra.csproj # CDK project file +└── CdkDotnetDurablefunctionImageprocessing.sln +``` + +Lambda function projects should be added under `src/` as sibling directories to `infra/`. + +## Building Lambda Durable Functions in .NET + +### NuGet Package + +```bash +dotnet add package Amazon.Lambda.DurableExecution +``` + +### Core Concepts + +Lambda Durable Functions use **deterministic replay** to resume workflows from checkpoints. Your handler delegates to `DurableFunction.WrapAsync`, which provides an `IDurableContext` — the interface to all durable operations: + +- `ctx.StepAsync` — Run code and checkpoint the result. On replay, cached results are returned without re-executing. +- `ctx.WaitAsync` — Suspend execution for a duration without compute charges (1s to ~1 year). +- `ctx.WaitForConditionAsync` — Poll a check function until a condition is met, suspending between polls. +- `ctx.CreateCallbackAsync` / `ctx.WaitForCallbackAsync` — Wait for external events (approvals, webhooks). +- `ctx.RunInChildContextAsync` — Run an isolated child context with its own checkpoint log. +- `ctx.ParallelAsync` — Fan out independent branches concurrently with configurable completion policies. + +### Programming Models + +#### Executable Model (recommended for AOT/custom runtimes) + +```csharp +using Amazon.Lambda.Core; +using Amazon.Lambda.DurableExecution; +using Amazon.Lambda.RuntimeSupport; +using Amazon.Lambda.Serialization.SystemTextJson; + +public class ImageProcessor +{ + public static async Task Main() + { + var handler = new ImageProcessor(); + var serializer = new DefaultLambdaJsonSerializer(); + using var wrapper = HandlerWrapper.GetHandlerWrapper( + handler.Handler, serializer); + using var bootstrap = new LambdaBootstrap(wrapper); + await bootstrap.RunAsync(); + } + + public Task Handler( + DurableExecutionInvocationInput input, ILambdaContext context) + => DurableFunction.WrapAsync(Workflow, input, context); + + private async Task Workflow(ImageInput input, IDurableContext ctx) + { + // workflow body + } +} +``` + +#### Class-Library Model (managed `dotnet10` runtime) + +```csharp +using Amazon.Lambda.Core; +using Amazon.Lambda.DurableExecution; +using Amazon.Lambda.Serialization.SystemTextJson; + +[assembly: LambdaSerializer(typeof(DefaultLambdaJsonSerializer))] + +namespace ImageProcessor; + +public class Function +{ + public Task Handler( + DurableExecutionInvocationInput input, ILambdaContext context) + => DurableFunction.WrapAsync(Workflow, input, context); + + private async Task Workflow(ImageInput input, IDurableContext ctx) + { + // workflow body + } +} +``` + +Handler string: `Assembly::Namespace.Type::Method` + +#### Lambda Annotations Model + +```csharp +using Amazon.Lambda.Annotations; +using Amazon.Lambda.DurableExecution; + +public class ImageProcessor +{ + [LambdaFunction] + [DurableExecution(executionTimeout: 300, RetentionPeriodInDays = 7)] + public async Task Workflow(ImageInput input, IDurableContext ctx) + { + // workflow body — handler wrapper is source-generated + } +} +``` + +### Steps — Checkpointed Work Units + +```csharp +var thumbnail = await ctx.StepAsync( + async (_, ct) => await resizeService.CreateThumbnailAsync(imageUrl, ct), + name: "create-thumbnail"); +``` + +Every step must have a unique, stable `name` within the workflow. The `CancellationToken` parameter links the caller's token with the SDK's workflow-shutdown signal — always pass it to async APIs. + +#### Retry Strategies + +```csharp +var result = await ctx.StepAsync( + async (stepCtx, ct) => await unreliableService.CallAsync(ct), + name: "call-service", + config: new StepConfig + { + RetryStrategy = RetryStrategy.Exponential( + maxAttempts: 5, + initialDelay: TimeSpan.FromSeconds(2), + maxDelay: TimeSpan.FromSeconds(30), + backoffRate: 2.0, + jitter: JitterStrategy.Full) + }); +``` + +Built-in strategies: `RetryStrategy.Default` (6 attempts, 2× backoff), `RetryStrategy.Transient` (3 attempts), `RetryStrategy.None`, `RetryStrategy.Exponential(...)`, `RetryStrategy.FromDelegate(...)`. + +#### Step Semantics + +- `StepSemantics.AtLeastOncePerRetry` (default) — Body may re-execute if interrupted. Use for idempotent operations. +- `StepSemantics.AtMostOncePerRetry` — Body executes at most once per attempt. Use for non-idempotent operations (payments, emails). Combine with `RetryStrategy.None` for true at-most-once. + +### Wait — Suspend Without Compute Charges + +```csharp +await ctx.WaitAsync(TimeSpan.FromHours(24), name: "wait-for-approval-window"); +``` + +Duration: 1 second to ~1 year. The Lambda terminates and is re-invoked when the timer fires. + +### Wait For Condition — Poll Until Ready + +```csharp +var status = await ctx.WaitForConditionAsync( + check: async (state, checkCtx, ct) => + { + return await processingService.GetStatusAsync(jobId, ct); + }, + config: new WaitForConditionConfig + { + InitialState = ProcessingStatus.Pending, + WaitStrategy = WaitStrategy.Exponential( + isDone: s => s == ProcessingStatus.Complete || s == ProcessingStatus.Failed, + maxAttempts: 60, + initialDelay: TimeSpan.FromSeconds(5), + maxDelay: TimeSpan.FromSeconds(300)) + }, + name: "poll-processing-status"); +``` + +Built-in wait strategies: `WaitStrategy.Exponential`, `WaitStrategy.Linear`, `WaitStrategy.Fixed`, `WaitStrategy.FromDelegate`. + +Throws `WaitForConditionException` when max attempts are exhausted without the condition being met. + +### Callbacks — Wait for External Events + +```csharp +var approval = await ctx.WaitForCallbackAsync( + submitter: async (callbackId, cbCtx, ct) => + { + // Hand callbackId to an external system (e.g., invoke another Lambda, send to SQS) + await notificationService.RequestApprovalAsync(callbackId, ct); + }, + name: "human-approval", + config: new WaitForCallbackConfig + { + Timeout = TimeSpan.FromHours(48), + HeartbeatTimeout = TimeSpan.FromHours(1) + }); +``` + +The external system completes the callback via the Lambda API: +- `SendDurableExecutionCallbackSuccessAsync` — resume with a result +- `SendDurableExecutionCallbackFailureAsync` — resume with failure +- `SendDurableExecutionCallbackHeartbeatAsync` — extend the heartbeat deadline + +### Child Contexts — Isolated Sub-Workflows + +```csharp +var phaseResult = await ctx.RunInChildContextAsync( + async (childCtx, ct) => + { + var validated = await childCtx.StepAsync( + async (_, c) => await validator.ValidateAsync(image, c), name: "validate"); + var processed = await childCtx.StepAsync( + async (_, c) => await processor.ProcessAsync(validated, c), name: "process"); + return processed; + }, + name: "image-pipeline", + config: new ChildContextConfig { SubType = "ImageProcessing" }); +``` + +The child context has its own operation-ID space. Its return value is checkpointed as a single operation in the parent. + +### Parallel — Fan-Out Concurrent Work + +```csharp +var batch = await ctx.ParallelAsync( + new[] + { + new DurableBranch("thumbnail", async (branchCtx, ct) => + await branchCtx.StepAsync((_, t) => resizer.ResizeAsync(image, Size.Thumb, t), name: "resize")), + new DurableBranch("medium", async (branchCtx, ct) => + await branchCtx.StepAsync((_, t) => resizer.ResizeAsync(image, Size.Medium, t), name: "resize")), + new DurableBranch("large", async (branchCtx, ct) => + await branchCtx.StepAsync((_, t) => resizer.ResizeAsync(image, Size.Large, t), name: "resize")), + }, + name: "resize-all", + config: new ParallelConfig + { + MaxConcurrency = 3, + CompletionConfig = CompletionConfig.AllSuccessful() + }); + +var results = batch.GetResults(); // IReadOnlyList +if (batch.HasFailure) +{ + batch.ThrowIfError(); +} +``` + +Completion policies: `CompletionConfig.AllSuccessful()` (default, fail-fast), `CompletionConfig.AllCompleted()` (run all, inspect failures), `CompletionConfig.FirstSuccessful()` (race). + +## Determinism Rules + +Durable functions replay from checkpoints. The workflow body MUST be deterministic: + +- **DO** put all side effects inside `StepAsync` (HTTP calls, DB writes, SDK calls). +- **DO** use `WaitAsync` instead of `Task.Delay` for delays. +- **DO** give every step/operation a stable, unique `name`. +- **DO** pass `CancellationToken` to all async operations inside step bodies. +- **DON'T** use `DateTime.Now`, `Guid.NewGuid()`, `Random`, or other non-deterministic values outside steps. +- **DON'T** branch workflow logic on `CancellationToken.IsCancellationRequested`. +- **DON'T** catch `OperationCanceledException` and continue — rethrow or don't catch. + +## Cancellation + +Every user `Func` receives a `CancellationToken` that is a linked source combining: +1. The caller's cancel intent. +2. The SDK's workflow-shutdown signal (fires when the workflow is being torn down). + +Pass it to all cancellation-aware APIs. If the token fires and the body throws `OperationCanceledException`, no FAIL checkpoint is written — the exception propagates up. Unrelated `OperationCanceledException` (token never fired) is treated as a normal failure subject to retry. + +## CDK Deployment + +Infrastructure is defined in `src/infra/InfraStack.cs`. When adding the Lambda function: + +```csharp +using Amazon.CDK; +using Amazon.CDK.AWS.Lambda; +using Constructs; + +public class InfraStack : Stack +{ + internal InfraStack(Construct scope, string id, IStackProps props = null) : base(scope, id, props) + { + var imageProcessor = new Function(this, "ImageProcessorFunction", new FunctionProps + { + Runtime = Runtime.DOTNET_10, // managed runtime + Handler = "ImageProcessor::ImageProcessor.Function::Handler", + Code = Code.FromAsset("src/ImageProcessor/bin/Release/net10.0/publish"), + MemorySize = 512, + Timeout = Duration.Seconds(900), + // DurableConfig is applied via the DurableExecution feature at deploy time + }); + } +} +``` + +## Build & Deploy Commands + +```bash +# Build the solution +dotnet build src + +# Synthesize CloudFormation +cdk synth + +# Deploy +cdk deploy + +# Compare changes +cdk diff +``` + +## Key Constraints + +- `Amazon.Lambda.DurableExecution` is **preview (0.x)** — APIs may change before 1.0. +- Packaging must be **Zip** (container image is not supported for durable functions). +- Workflows can run for up to **1 year**. +- `WaitAsync` duration: minimum 1 second, maximum ~1 year (31,622,400 seconds). +- Step results must be serializable by the configured `ILambdaSerializer`. +- For AOT/trimmed deployments, use `SourceGeneratorLambdaJsonSerializer`. + +## References + +- [Amazon.Lambda.DurableExecution SDK](https://github.com/aws/aws-lambda-dotnet/tree/master/Libraries/src/Amazon.Lambda.DurableExecution) +- [Steps documentation](https://github.com/aws/aws-lambda-dotnet/blob/master/Libraries/src/Amazon.Lambda.DurableExecution/docs/core/steps.md) +- [Wait documentation](https://github.com/aws/aws-lambda-dotnet/blob/master/Libraries/src/Amazon.Lambda.DurableExecution/docs/core/wait.md) +- [Wait For Condition documentation](https://github.com/aws/aws-lambda-dotnet/blob/master/Libraries/src/Amazon.Lambda.DurableExecution/docs/core/wait-for-condition.md) +- [Callbacks documentation](https://github.com/aws/aws-lambda-dotnet/blob/master/Libraries/src/Amazon.Lambda.DurableExecution/docs/core/callbacks.md) +- [Child Contexts documentation](https://github.com/aws/aws-lambda-dotnet/blob/master/Libraries/src/Amazon.Lambda.DurableExecution/docs/core/child-contexts.md) +- [Parallel documentation](https://github.com/aws/aws-lambda-dotnet/blob/master/Libraries/src/Amazon.Lambda.DurableExecution/docs/core/parallel.md) +- [Cancellation documentation](https://github.com/aws/aws-lambda-dotnet/blob/master/Libraries/src/Amazon.Lambda.DurableExecution/docs/core/cancellation.md) diff --git a/cdk-dotnet-durablefunction-imageprocessing/README.md b/cdk-dotnet-durablefunction-imageprocessing/README.md new file mode 100644 index 000000000..66d38341d --- /dev/null +++ b/cdk-dotnet-durablefunction-imageprocessing/README.md @@ -0,0 +1,437 @@ +# AWS Lambda Durable Function — Image Processing Pipeline (Fan-Out/Fan-In) + +This sample demonstrates how to build a resilient, long-running image-processing pipeline using **AWS Lambda Durable Functions** for .NET. It uses the fan-out/fan-in pattern to process an uploaded image in parallel across three branches, then aggregates the results and writes them to DynamoDB. + +## Architecture + +``` +┌──────────────┐ ┌───────────────┐ ┌──────────────────────────────────────────────┐ +│ Upload to │ │ S3 Trigger │ │ Lambda Durable Function │ +│ S3 Bucket │──────▶│ Lambda │──────▶│ │ +│ uploads/* │ │ │ │ ┌────────── ParallelAsync ────────────┐ │ +└──────────────┘ └───────────────┘ │ │ │ │ + │ │ ┌───────────┐ ┌───────────┐ ┌────┐│ │ + S3 Event Invokes durable │ │ │ Thumbnail │ │ Watermark │ │Meta││ │ + Notification function async │ │ │ (200px) │ │ (logo │ │data││ │ + (uploads/ prefix) │ │ │ │ │ overlay) │ │ ││ │ + │ │ └─────┬─────┘ └─────┬─────┘ └──┬─┘│ │ + │ └────────┼─────────────┼──────────┼──┘ │ + │ └──────┬──────┘──────────┘ │ + │ ▼ │ + │ ┌────────────────────┐ │ + │ │ Aggregate Results │ │ + │ └─────────┬──────────┘ │ + │ ▼ │ + │ ┌────────────────────┐ │ + │ │ Write to DynamoDB │ │ + │ └────────────────────┘ │ + └──────────────────────────────────────────────┘ +``` + +## What It Demonstrates + +- **Parallel execution** — Three image-processing tasks (thumbnail, watermark, metadata) run concurrently using `ctx.ParallelAsync` with named `DurableBranch` instances. +- **Automatic checkpointing** — Each branch is independently checkpointed. If the Lambda is interrupted, completed branches resume from cache without re-executing. +- **Fan-in aggregation** — Branch results are collected from `IBatchResult` and aggregated into a single `PipelineResult`. +- **Retry resilience** — Each step uses `RetryStrategy.Default` (6 attempts with exponential backoff) to handle transient S3/DynamoDB failures. +- **CDK infrastructure** — Deploys S3 bucket, DynamoDB table, and Lambda functions with least-privilege IAM permissions. + +### Processing Operations + +| Branch | Operation | +|--------|-----------| +| Thumbnail | Resizes the image to a max of 200×200 px (preserving aspect ratio) and saves as JPEG | +| Watermark | Overlays a logo image (`img/Lambda.png`) in the bottom-right corner at 50% opacity, then applies 5px rounded corners and a dark border | +| Metadata | Extracts width, height, format, and file size from the original image | + +## Project Structure + +``` +├── cdk.json # CDK app configuration +├── src/ +│ ├── CdkDotnetDurablefunctionImageprocessing.slnx # Solution file +│ ├── .globalconfig # Code analysis configuration +│ ├── Infra/ # CDK infrastructure (C#) +│ │ ├── Program.cs # CDK app entry point +│ │ ├── InfraStack.cs # Stack: S3, DynamoDB, Lambda +│ │ └── Infra.csproj +│ └── ImageProcessor/ # Lambda function project +│ ├── Function.cs # Durable workflow (entry point + handler) +│ ├── S3TriggerHandler.cs # S3 event trigger (starts durable executions) +│ ├── ImageProcessingService.cs # Image manipulation operations +│ ├── ResultsWriter.cs # DynamoDB persistence +│ ├── Models.cs # Input/output record types +│ └── ImageProcessor.csproj +└── README.md +``` + +--- + +## Prerequisites + +### 1. .NET 10 SDK + +Download and install from https://dotnet.microsoft.com/download/dotnet/10.0 + +Verify installation: + +```bash +dotnet --version +# Expected: 10.0.x +``` + +### 2. Node.js (required for AWS CDK CLI) + +Install Node.js 18+ from https://nodejs.org/ or via a version manager: + +```bash +# macOS with Homebrew +brew install node + +# Verify +node --version +``` + +### 3. AWS CDK CLI + +Install the CDK CLI globally via npm: + +```bash +npm install -g aws-cdk + +# Verify +cdk --version +``` + +### 4. AWS CLI & Credentials + +Install the AWS CLI v2 from https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html + +Configure credentials for your target account: + +```bash +aws configure +# Enter: AWS Access Key ID, Secret Access Key, default region (e.g. us-east-1), output format (json) +``` + +Verify access: + +```bash +aws sts get-caller-identity +``` + +### 5. CDK Bootstrap + +CDK requires a one-time bootstrap in each account/region pair you deploy to. This creates the staging resources CDK needs (S3 bucket, IAM roles, ECR repo): + +```bash +cdk bootstrap aws://ACCOUNT_ID/REGION +# Example: +cdk bootstrap aws://123456789012/us-east-1 +``` + +Or let it use your default credentials: + +```bash +cdk bootstrap +``` + +--- + +## Build & Deploy + +### Step 1: Build the solution + +```bash +dotnet build src/CdkDotnetDurablefunctionImageprocessing.slnx -c Release +``` + +### Step 2: Preview the CloudFormation changes + +```bash +cdk diff +``` + +### Step 3: Deploy + +```bash +cdk deploy +``` + +When prompted, confirm the IAM changes. The deployment creates: + +| Resource | Description | +|----------|-------------| +| S3 Bucket | Stores uploaded images, thumbnails, and watermarked outputs | +| DynamoDB Table | Stores aggregated pipeline results (partition key: `ImageId`) | +| Lambda Function (Durable) | Image processing workflow (1024 MB, durable execution) | +| Lambda Function (Trigger) | S3 event handler that starts durable executions on upload | +| IAM Roles | Least-privilege: S3 read/write, DynamoDB write, Lambda invoke | +| S3 Event Notification | Triggers processing on object creation in `uploads/` prefix | + +Stack outputs (printed after deploy): + +- `ImagesBucketName` — S3 bucket name +- `ResultsTableName` — DynamoDB table name +- `ImageProcessorFunctionArn` — Lambda function ARN + +--- + +## Testing + +### Capture stack outputs + +```bash +STACK_NAME="CdkDotnetDurablefunctionImageprocessingStack" + +BUCKET_NAME=$(aws cloudformation describe-stacks \ + --stack-name $STACK_NAME \ + --query "Stacks[0].Outputs[?OutputKey=='ImagesBucketName'].OutputValue" \ + --output text) + +TABLE_NAME=$(aws cloudformation describe-stacks \ + --stack-name $STACK_NAME \ + --query "Stacks[0].Outputs[?OutputKey=='ResultsTableName'].OutputValue" \ + --output text) + +FUNCTION_ARN=$(aws cloudformation describe-stacks \ + --stack-name $STACK_NAME \ + --query "Stacks[0].Outputs[?OutputKey=='ImageProcessorFunctionArn'].OutputValue" \ + --output text) +``` + +### Upload a test image to S3 + +Uploading to the `uploads/` prefix automatically triggers the image processing pipeline: + +```bash +aws s3 cp my-photo.jpg s3://$BUCKET_NAME/uploads/my-photo.jpg +``` + +The S3 event notification invokes the trigger Lambda, which starts a durable execution of the image processor. No manual invoke is needed. + +### (Optional) Invoke the durable function manually + +You can also invoke the durable function directly: + +```bash +aws lambda invoke \ + --function-name "$FUNCTION_ARN:\$LATEST" \ + --invocation-type Event \ + --cli-binary-format raw-in-base64-out \ + --payload '{"Bucket":"'$BUCKET_NAME'","Key":"uploads/my-photo.jpg"}' \ + /tmp/invoke-response.json +``` + +> **Note:** Durable functions require a qualified ARN (append `:\$LATEST`). Functions with `ExecutionTimeout` greater than 15 minutes must be invoked asynchronously (`--invocation-type Event`), which returns immediately with HTTP 202. + +### Check execution status + +```bash +aws lambda list-durable-executions-by-function \ + --function-name $FUNCTION_ARN +``` + +To get details on a specific execution: + +```bash +aws lambda get-durable-execution \ + --function-name $FUNCTION_ARN \ + --durable-execution-arn +``` + +### Verify results in DynamoDB + +```bash +aws dynamodb scan --table-name $TABLE_NAME +``` + +Expected output includes: + +```json +{ + "Items": [ + { + "ImageId": {"S": "my-photo"}, + "SourceBucket": {"S": ""}, + "SourceKey": {"S": "uploads/my-photo.jpg"}, + "ThumbnailKey": {"S": "thumbnails/my-photo.jpg"}, + "ThumbnailWidth": {"N": "200"}, + "ThumbnailHeight": {"N": "150"}, + "WatermarkKey": {"S": "watermarked/my-photo.jpg"}, + "OriginalWidth": {"N": "1920"}, + "OriginalHeight": {"N": "1080"}, + "Format": {"S": "Jpeg"}, + "FileSizeBytes": {"N": "524288"}, + "CompletedAt": {"S": "2026-07-13T14:30:00.0000000Z"} + } + ] +} +``` + +### Verify processed images in S3 + +```bash +# List thumbnails +aws s3 ls s3://$BUCKET_NAME/thumbnails/ + +# List watermarked images +aws s3 ls s3://$BUCKET_NAME/watermarked/ + +# Download the thumbnail +aws s3 cp s3://$BUCKET_NAME/thumbnails/my-photo.jpg ./my-photo-thumb.jpg +``` + +--- + +## Monitoring + +### CloudWatch Logs + +Lambda logs are automatically sent to CloudWatch Logs. View them in the console or via CLI: + +```bash +# Get the log group name +LOG_GROUP="/aws/lambda/$(aws lambda get-function \ + --function-name $FUNCTION_ARN \ + --query 'Configuration.FunctionName' --output text)" + +# Tail recent logs +aws logs tail $LOG_GROUP --follow + +# Search for errors in the last hour +aws logs filter-log-events \ + --log-group-name $LOG_GROUP \ + --start-time $(date -v-1H +%s000) \ + --filter-pattern "ERROR" +``` + +### CloudWatch Metrics + +Key metrics to monitor for the Lambda function: + +| Metric | What it tells you | +|--------|-------------------| +| `Invocations` | How many times the function was invoked | +| `Duration` | Execution time per invocation | +| `Errors` | Unhandled exceptions | +| `Throttles` | Invocations rejected due to concurrency limits | +| `ConcurrentExecutions` | Number of in-flight executions | + +View in the AWS Console: **CloudWatch → Metrics → Lambda → By Function Name** + +### DynamoDB Metrics + +| Metric | What it tells you | +|--------|-------------------| +| `ConsumedWriteCapacityUnits` | Write throughput consumed | +| `SystemErrors` | Internal DynamoDB errors | +| `UserErrors` | Client-side errors (validation, conditions) | + +### Setting up a CloudWatch Alarm (example) + +```bash +aws cloudwatch put-metric-alarm \ + --alarm-name "ImageProcessor-Errors" \ + --namespace "AWS/Lambda" \ + --metric-name "Errors" \ + --dimensions Name=FunctionName,Value=$(aws lambda get-function \ + --function-name $FUNCTION_ARN \ + --query 'Configuration.FunctionName' --output text) \ + --statistic Sum \ + --period 300 \ + --threshold 1 \ + --comparison-operator GreaterThanOrEqualToThreshold \ + --evaluation-periods 1 \ + --treat-missing-data notBreaching +``` + +### Durable Execution Monitoring + +List active durable executions: + +```bash +aws lambda list-durable-executions --function-name $FUNCTION_ARN +``` + +Get details on a specific execution: + +```bash +aws lambda get-durable-execution \ + --function-name $FUNCTION_ARN \ + --execution-id +``` + +--- + +## Cleanup + +### Delete the stack and all resources + +```bash +cdk destroy +``` + +This removes: +- The S3 bucket and all objects (configured with `RemovalPolicy.DESTROY` and `AutoDeleteObjects`) +- The DynamoDB table and all items +- The Lambda function, IAM role, and CloudWatch log group + +### Verify deletion + +```bash +aws cloudformation describe-stacks \ + --stack-name CdkDotnetDurablefunctionImageprocessingStack 2>&1 \ + | grep -q "does not exist" && echo "Stack deleted successfully" +``` + +--- + +## Useful Commands + +| Command | Description | +|---------|-------------| +| `dotnet build src/CdkDotnetDurablefunctionImageprocessing.slnx` | Build the entire solution | +| `cdk synth` | Emit the synthesized CloudFormation template (publishes Lambda automatically) | +| `cdk diff` | Compare deployed stack with current state | +| `cdk deploy` | Deploy this stack to your default AWS account/region | +| `cdk destroy` | Tear down the stack and all resources | +| `dotnet format src/CdkDotnetDurablefunctionImageprocessing.slnx` | Auto-format code | + +--- + +## Troubleshooting + +### "CDK bootstrap is required" + +If you see `This stack uses assets, so the toolkit stack must be deployed`, run: + +```bash +cdk bootstrap +``` + +### Lambda timeout + +The function has a 15-minute timeout. If processing large images causes timeouts, increase `MemorySize` in `InfraStack.cs` — Lambda allocates CPU proportional to memory. + +### S3 access denied + +Verify the Lambda role has the correct permissions by checking the IAM policy in the CloudFormation template: + +```bash +cdk synth | grep -A 20 "PolicyDocument" +``` + +### DynamoDB throttling + +The table uses on-demand billing (`PAY_PER_REQUEST`) so throttling is unlikely. If it occurs, check the `ThrottledRequests` CloudWatch metric. + +--- + +## References + +- [Amazon.Lambda.DurableExecution SDK](https://github.com/aws/aws-lambda-dotnet/tree/master/Libraries/src/Amazon.Lambda.DurableExecution) +- [Parallel documentation](https://github.com/aws/aws-lambda-dotnet/blob/master/Libraries/src/Amazon.Lambda.DurableExecution/docs/core/parallel.md) +- [Steps documentation](https://github.com/aws/aws-lambda-dotnet/blob/master/Libraries/src/Amazon.Lambda.DurableExecution/docs/core/steps.md) +- [AWS CDK .NET Reference](https://docs.aws.amazon.com/cdk/api/v2/dotnet/) +- [AWS CDK Getting Started](https://docs.aws.amazon.com/cdk/v2/guide/getting-started.html) diff --git a/cdk-dotnet-durablefunction-imageprocessing/cdk-dotnet-durablefunction-imageprocessing.json b/cdk-dotnet-durablefunction-imageprocessing/cdk-dotnet-durablefunction-imageprocessing.json new file mode 100644 index 000000000..4a6549dbb --- /dev/null +++ b/cdk-dotnet-durablefunction-imageprocessing/cdk-dotnet-durablefunction-imageprocessing.json @@ -0,0 +1,63 @@ +{ + "title": "Lambda Durable Function — Image Processing Pipeline with Fan-Out/Fan-In (.NET)", + "description": "A durable function that processes an uploaded image in parallel (thumbnail, watermark, metadata extraction), then writes aggregated results to DynamoDB.", + "language": ".NET", + "level": "300", + "framework": "CDK", + "introBox": { + "headline": "How it works", + "text": [ + "This pattern deploys a Lambda durable function triggered by S3 uploads. When an image is uploaded, the function fans out to three parallel branches: generate a thumbnail, apply a watermark, and extract metadata.", + "All three branches execute concurrently using ParallelAsync and are independently checkpointed. Once all complete, the function aggregates results and writes them to DynamoDB.", + "This demonstrates the fan-out/fan-in pattern with durable functions for parallel image processing, with built-in resilience — if any branch fails, only that branch retries on replay." + ] + }, + "gitHub": { + "template": { + "repoURL": "https://github.com/aws-samples/serverless-patterns/tree/main/cdk-dotnet-durablefunction-imageprocessing", + "templateURL": "serverless-patterns/cdk-dotnet-durablefunction-imageprocessing", + "projectFolder": "cdk-dotnet-durablefunction-imageprocessing", + "templateFile": "cdk.json" + } + }, + "resources": { + "bullets": [ + { + "text": "Lambda Durable Functions Documentation", + "link": "https://docs.aws.amazon.com/lambda/latest/dg/durable-functions.html" + }, + { + "text": "Build multi-step applications and AI workflows with Lambda durable functions", + "link": "https://aws.amazon.com/blogs/aws/build-multi-step-applications-and-ai-workflows-with-aws-lambda-durable-functions/" + }, + { + "text": "Amazon DynamoDB", + "link": "https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Introduction.html" + } + ] + }, + "deploy": { + "text": [ + "cdk deploy" + ] + }, + "testing": { + "text": [ + "See the GitHub repo for detailed testing instructions." + ] + }, + "cleanup": { + "text": [ + "Delete the stack: cdk destroy." + ] + }, + "authors": [ + { + "name": "Doug Perkes", + "image": "", + "bio": "Senior Solutions Architect at AWS, focused on .NET and serverless.", + "linkedin": "dougperkes", + "twitter": "" + } + ] +} diff --git a/cdk-dotnet-durablefunction-imageprocessing/cdk.json b/cdk-dotnet-durablefunction-imageprocessing/cdk.json new file mode 100644 index 000000000..f7ec87774 --- /dev/null +++ b/cdk-dotnet-durablefunction-imageprocessing/cdk.json @@ -0,0 +1,104 @@ +{ + "app": "dotnet run --project src/Infra/Infra.csproj", + "watch": { + "include": [ + "**" + ], + "exclude": [ + "README.md", + "cdk*.json", + "src/*/obj", + "src/*/bin", + "src/*.sln", + "src/*/GlobalSuppressions.cs", + "src/*/*.csproj" + ] + }, + "context": { + "@aws-cdk/aws-signer:signingProfileNamePassedToCfn": true, + "@aws-cdk/aws-ecs-patterns:secGroupsDisablesImplicitOpenListener": true, + "@aws-cdk/aws-lambda:recognizeLayerVersion": true, + "@aws-cdk/core:checkSecretUsage": true, + "@aws-cdk/core:target-partitions": [ + "aws", + "aws-cn" + ], + "@aws-cdk-containers/ecs-service-extensions:enableDefaultLogDriver": true, + "@aws-cdk/aws-ec2:uniqueImdsv2TemplateName": true, + "@aws-cdk/aws-ecs:arnFormatIncludesClusterName": true, + "@aws-cdk/aws-iam:minimizePolicies": true, + "@aws-cdk/core:validateSnapshotRemovalPolicy": true, + "@aws-cdk/aws-codepipeline:crossAccountKeyAliasStackSafeResourceName": true, + "@aws-cdk/aws-s3:createDefaultLoggingPolicy": true, + "@aws-cdk/aws-sns-subscriptions:restrictSqsDescryption": true, + "@aws-cdk/aws-apigateway:disableCloudWatchRole": true, + "@aws-cdk/core:enablePartitionLiterals": true, + "@aws-cdk/aws-events:eventsTargetQueueSameAccount": true, + "@aws-cdk/aws-ecs:disableExplicitDeploymentControllerForCircuitBreaker": true, + "@aws-cdk/aws-iam:importedRoleStackSafeDefaultPolicyName": true, + "@aws-cdk/aws-s3:serverAccessLogsUseBucketPolicy": true, + "@aws-cdk/aws-route53-patters:useCertificate": true, + "@aws-cdk/customresources:installLatestAwsSdkDefault": false, + "@aws-cdk/aws-rds:databaseProxyUniqueResourceName": true, + "@aws-cdk/aws-codedeploy:removeAlarmsFromDeploymentGroup": true, + "@aws-cdk/aws-apigateway:authorizerChangeDeploymentLogicalId": true, + "@aws-cdk/aws-ec2:launchTemplateDefaultUserData": true, + "@aws-cdk/aws-secretsmanager:useAttachedSecretResourcePolicyForSecretTargetAttachments": true, + "@aws-cdk/aws-redshift:columnId": true, + "@aws-cdk/aws-stepfunctions-tasks:enableEmrServicePolicyV2": true, + "@aws-cdk/aws-ec2:restrictDefaultSecurityGroup": true, + "@aws-cdk/aws-apigateway:requestValidatorUniqueId": true, + "@aws-cdk/aws-kms:aliasNameRef": true, + "@aws-cdk/aws-kms:applyImportedAliasPermissionsToPrincipal": true, + "@aws-cdk/aws-autoscaling:generateLaunchTemplateInsteadOfLaunchConfig": true, + "@aws-cdk/core:includePrefixInUniqueNameGeneration": true, + "@aws-cdk/aws-efs:denyAnonymousAccess": true, + "@aws-cdk/aws-opensearchservice:enableOpensearchMultiAzWithStandby": true, + "@aws-cdk/aws-lambda-nodejs:useLatestRuntimeVersion": true, + "@aws-cdk/aws-efs:mountTargetOrderInsensitiveLogicalId": true, + "@aws-cdk/aws-rds:auroraClusterChangeScopeOfInstanceParameterGroupWithEachParameters": true, + "@aws-cdk/aws-appsync:useArnForSourceApiAssociationIdentifier": true, + "@aws-cdk/aws-rds:preventRenderingDeprecatedCredentials": true, + "@aws-cdk/aws-codepipeline-actions:useNewDefaultBranchForCodeCommitSource": true, + "@aws-cdk/aws-cloudwatch-actions:changeLambdaPermissionLogicalIdForLambdaAction": true, + "@aws-cdk/aws-codepipeline:crossAccountKeysDefaultValueToFalse": true, + "@aws-cdk/aws-codepipeline:defaultPipelineTypeToV2": true, + "@aws-cdk/aws-kms:reduceCrossAccountRegionPolicyScope": true, + "@aws-cdk/aws-eks:nodegroupNameAttribute": true, + "@aws-cdk/aws-eks:useNativeOidcProvider": true, + "@aws-cdk/aws-ec2:ebsDefaultGp3Volume": true, + "@aws-cdk/aws-ecs:removeDefaultDeploymentAlarm": true, + "@aws-cdk/custom-resources:logApiResponseDataPropertyTrueDefault": false, + "@aws-cdk/aws-s3:keepNotificationInImportedBucket": false, + "@aws-cdk/core:explicitStackTags": true, + "@aws-cdk/aws-ecs:reduceEc2FargateCloudWatchPermissions": true, + "@aws-cdk/aws-dynamodb:resourcePolicyPerReplica": true, + "@aws-cdk/aws-ec2:ec2SumTImeoutEnabled": true, + "@aws-cdk/aws-appsync:appSyncGraphQLAPIScopeLambdaPermission": true, + "@aws-cdk/aws-rds:setCorrectValueForDatabaseInstanceReadReplicaInstanceResourceId": true, + "@aws-cdk/core:cfnIncludeRejectComplexResourceUpdateCreatePolicyIntrinsics": true, + "@aws-cdk/aws-lambda-nodejs:sdkV3ExcludeSmithyPackages": true, + "@aws-cdk/aws-stepfunctions-tasks:fixRunEcsTaskPolicy": true, + "@aws-cdk/aws-ec2:bastionHostUseAmazonLinux2023ByDefault": true, + "@aws-cdk/aws-route53-targets:userPoolDomainNameMethodWithoutCustomResource": true, + "@aws-cdk/aws-elasticloadbalancingV2:albDualstackWithoutPublicIpv4SecurityGroupRulesDefault": true, + "@aws-cdk/aws-iam:oidcRejectUnauthorizedConnections": true, + "@aws-cdk/core:enableAdditionalMetadataCollection": true, + "@aws-cdk/aws-lambda:createNewPoliciesWithAddToRolePolicy": false, + "@aws-cdk/aws-s3:setUniqueReplicationRoleName": true, + "@aws-cdk/aws-events:requireEventBusPolicySid": true, + "@aws-cdk/core:aspectPrioritiesMutating": true, + "@aws-cdk/aws-dynamodb:retainTableReplica": true, + "@aws-cdk/aws-stepfunctions:useDistributedMapResultWriterV2": true, + "@aws-cdk/s3-notifications:addS3TrustKeyPolicyForSnsSubscriptions": true, + "@aws-cdk/aws-ec2:requirePrivateSubnetsForEgressOnlyInternetGateway": true, + "@aws-cdk/aws-s3:publicAccessBlockedByDefault": true, + "@aws-cdk/aws-lambda:useCdkManagedLogGroup": true, + "@aws-cdk/aws-elasticloadbalancingv2:networkLoadBalancerWithSecurityGroupByDefault": true, + "@aws-cdk/aws-ecs-patterns:uniqueTargetGroupId": true, + "@aws-cdk/aws-route53-patterns:useDistribution": true, + "@aws-cdk/aws-cloudfront:defaultFunctionRuntimeV2_0": true, + "@aws-cdk/aws-elasticloadbalancingv2:usePostQuantumTlsPolicy": true, + "@aws-cdk/aws-batch:defaultToAL2023": true + } +} diff --git a/cdk-dotnet-durablefunction-imageprocessing/generate-and-upload.sh b/cdk-dotnet-durablefunction-imageprocessing/generate-and-upload.sh new file mode 100755 index 000000000..2a8e05e1c --- /dev/null +++ b/cdk-dotnet-durablefunction-imageprocessing/generate-and-upload.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +set -euo pipefail + +# ────────────────────────────────────────────────────────────────────────────── +# generate-and-upload.sh +# +# Generates a test image locally and uploads it to the S3 bucket created +# by the CDK stack, then prints the command to invoke the durable function. +# +# Usage: +# ./generate-and-upload.sh [STACK_NAME] +# ────────────────────────────────────────────────────────────────────────────── + +STACK_NAME="${1:-CdkDotnetDurablefunctionImageprocessingStack}" +IMAGE_NAME="test-image-$(date +%s).png" +OUTPUT_FILE="/tmp/${IMAGE_NAME}" + +echo "▶ Retrieving S3 bucket name from stack '${STACK_NAME}'..." +BUCKET_NAME=$(aws cloudformation describe-stacks \ + --stack-name "$STACK_NAME" \ + --query "Stacks[0].Outputs[?OutputKey=='ImagesBucketName'].OutputValue" \ + --output text) + +if [[ -z "$BUCKET_NAME" || "$BUCKET_NAME" == "None" ]]; then + echo "✗ Could not find ImagesBucketName output. Is the stack deployed?" >&2 + exit 1 +fi + +echo " Bucket: ${BUCKET_NAME}" +echo "▶ Generating test image locally..." + +# Generate a 1024x1024 gradient PNG using Python (no external dependencies) +python3 -c " +import struct, zlib + +width, height = 1024, 1024 +raw = b'' +for y in range(height): + raw += b'\x00' # PNG filter: None + for x in range(width): + r = int(70 + 150 * (y / height)) + g = int(100 + 100 * abs(0.5 - x / width) * 2) + b = int(200 - 120 * (y / height)) + raw += struct.pack('BBB', r, g, b) + +compressed = zlib.compress(raw, 6) + +def chunk(ctype, data): + c = ctype + data + return struct.pack('>I', len(data)) + c + struct.pack('>I', zlib.crc32(c) & 0xffffffff) + +with open('$OUTPUT_FILE', 'wb') as f: + f.write(b'\x89PNG\r\n\x1a\n') + f.write(chunk(b'IHDR', struct.pack('>IIBBBBB', width, height, 8, 2, 0, 0, 0))) + f.write(chunk(b'IDAT', compressed)) + f.write(chunk(b'IEND', b'')) + +print(f' Generated: $OUTPUT_FILE ({len(compressed) + 57} bytes)') +" + +# Upload to S3 +S3_KEY="uploads/${IMAGE_NAME}" +echo "▶ Uploading to s3://${BUCKET_NAME}/${S3_KEY}..." +aws s3 cp "$OUTPUT_FILE" "s3://${BUCKET_NAME}/${S3_KEY}" --content-type "image/png" + +echo "✓ Done! Image uploaded to s3://${BUCKET_NAME}/${S3_KEY}" +echo " The S3 event notification will automatically trigger the image processing pipeline." + +# Clean up local temp file +rm -f "$OUTPUT_FILE" diff --git a/cdk-dotnet-durablefunction-imageprocessing/img/Lambda.png b/cdk-dotnet-durablefunction-imageprocessing/img/Lambda.png new file mode 100644 index 000000000..9b3861894 Binary files /dev/null and b/cdk-dotnet-durablefunction-imageprocessing/img/Lambda.png differ diff --git a/cdk-dotnet-durablefunction-imageprocessing/src/.globalconfig b/cdk-dotnet-durablefunction-imageprocessing/src/.globalconfig new file mode 100644 index 000000000..01268a03a --- /dev/null +++ b/cdk-dotnet-durablefunction-imageprocessing/src/.globalconfig @@ -0,0 +1,13 @@ +is_global = true + +# CA2007: Do not directly await a Task without ConfigureAwait +# Not applicable in application code running on default synchronization context (Lambda, console apps). +dotnet_diagnostic.CA2007.severity = none + +# CA1711: Identifiers should not have incorrect suffix +# 'Stack' is the standard naming convention for AWS CDK stacks. +dotnet_diagnostic.CA1711.severity = none + +# CA1806: Do not ignore method results +# CDK constructs register themselves with the construct tree via side-effects in constructors. +dotnet_diagnostic.CA1806.severity = none diff --git a/cdk-dotnet-durablefunction-imageprocessing/src/CdkDotnetDurablefunctionImageprocessing.slnx b/cdk-dotnet-durablefunction-imageprocessing/src/CdkDotnetDurablefunctionImageprocessing.slnx new file mode 100644 index 000000000..b3dd1ead3 --- /dev/null +++ b/cdk-dotnet-durablefunction-imageprocessing/src/CdkDotnetDurablefunctionImageprocessing.slnx @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/cdk-dotnet-durablefunction-imageprocessing/src/ImageProcessor/Function.cs b/cdk-dotnet-durablefunction-imageprocessing/src/ImageProcessor/Function.cs new file mode 100644 index 000000000..999eed423 --- /dev/null +++ b/cdk-dotnet-durablefunction-imageprocessing/src/ImageProcessor/Function.cs @@ -0,0 +1,123 @@ +using Amazon.DynamoDBv2; +using Amazon.Lambda.Core; +using Amazon.Lambda.DurableExecution; +using Amazon.Lambda.RuntimeSupport; +using Amazon.Lambda.Serialization.SystemTextJson; +using Amazon.S3; + +[assembly: LambdaSerializer(typeof(DefaultLambdaJsonSerializer))] + +namespace ImageProcessor; + +/// +/// Lambda Durable Function that orchestrates an image-processing pipeline. +/// +/// Workflow: +/// 1. Fan out three parallel branches: thumbnail generation, watermark application, metadata extraction. +/// 2. Fan in: aggregate all branch results. +/// 3. Write the aggregated result to DynamoDB. +/// 4. Return the final PipelineResult. +/// +internal sealed class ImageProcessorHandler +{ + private static readonly IAmazonS3 S3Client = new AmazonS3Client(); + private static readonly IAmazonDynamoDB DynamoDbClient = new AmazonDynamoDBClient(); + + private static readonly string TableName = + Environment.GetEnvironmentVariable("RESULTS_TABLE_NAME") + ?? throw new InvalidOperationException("RESULTS_TABLE_NAME environment variable is not set."); + + private readonly ImageProcessingService _imageService = new(S3Client); + private readonly ResultsWriter _resultsWriter = new(DynamoDbClient, TableName); + + public static async Task Main() + { + var handler = new ImageProcessorHandler(); + var serializer = new DefaultLambdaJsonSerializer(); + using var wrapper = HandlerWrapper.GetHandlerWrapper( + handler.Handler, serializer); + using var bootstrap = new LambdaBootstrap(wrapper); + await bootstrap.RunAsync(); + } + + public Task Handler( + DurableExecutionInvocationInput input, ILambdaContext context) + => DurableFunction.WrapAsync(Workflow, input, context); + + private async Task Workflow(ImageInput input, IDurableContext ctx) + { + // ────────────────────────────────────────────────────────────────── + // Fan-Out: Run thumbnail, watermark, and metadata extraction in parallel. + // Each branch is independently checkpointed and survives Lambda re-invocations. + // ────────────────────────────────────────────────────────────────── + var batch = await ctx.ParallelAsync( + [ + new DurableBranch("thumbnail", async (branchCtx, ct) => + { + var result = await branchCtx.StepAsync( + async (_, t) => await _imageService.GenerateThumbnailAsync(input.Bucket, input.Key, t), + name: "generate-thumbnail", + config: new StepConfig { RetryStrategy = RetryStrategy.Default }, + cancellationToken: ct); + return new ProcessingBranchResult("thumbnail", Thumbnail: result, null, null); + }), + + new DurableBranch("watermark", async (branchCtx, ct) => + { + var result = await branchCtx.StepAsync( + async (_, t) => await _imageService.ApplyWatermarkAsync(input.Bucket, input.Key, t), + name: "apply-watermark", + config: new StepConfig { RetryStrategy = RetryStrategy.Default }, + cancellationToken: ct); + return new ProcessingBranchResult("watermark", null, Watermark: result, null); + }), + + new DurableBranch("metadata", async (branchCtx, ct) => + { + var result = await branchCtx.StepAsync( + async (_, t) => await _imageService.ExtractMetadataAsync(input.Bucket, input.Key, t), + name: "extract-metadata", + config: new StepConfig { RetryStrategy = RetryStrategy.Default }, + cancellationToken: ct); + return new ProcessingBranchResult("metadata", null, null, Metadata: result); + }), + ], + name: "image-processing-fanout", + config: new ParallelConfig + { + MaxConcurrency = 3, + CompletionConfig = CompletionConfig.AllSuccessful() + }); + + // Fail the workflow if any branch failed + batch.ThrowIfError(); + + // ────────────────────────────────────────────────────────────────── + // Fan-In: Aggregate all branch results into a single PipelineResult. + // ────────────────────────────────────────────────────────────────── + var results = batch.GetResults(); + var thumbnail = results.First(r => r.Thumbnail is not null).Thumbnail!; + var watermark = results.First(r => r.Watermark is not null).Watermark!; + var metadata = results.First(r => r.Metadata is not null).Metadata!; + + var imageId = Path.GetFileNameWithoutExtension(input.Key); + var pipelineResult = new PipelineResult( + ImageId: imageId, + SourceBucket: input.Bucket, + SourceKey: input.Key, + Thumbnail: thumbnail, + Watermark: watermark, + Metadata: metadata, + CompletedAt: DateTime.UtcNow); + + // ────────────────────────────────────────────────────────────────── + // Write aggregated result to DynamoDB (idempotent — upsert by ImageId). + // ────────────────────────────────────────────────────────────────── + await ctx.StepAsync( + async (_, ct) => await _resultsWriter.WriteResultAsync(pipelineResult, ct), + name: "write-results-to-dynamodb", + config: new StepConfig { RetryStrategy = RetryStrategy.Default }); + + return pipelineResult; + } +} diff --git a/cdk-dotnet-durablefunction-imageprocessing/src/ImageProcessor/ImageProcessingService.cs b/cdk-dotnet-durablefunction-imageprocessing/src/ImageProcessor/ImageProcessingService.cs new file mode 100644 index 000000000..98c191860 --- /dev/null +++ b/cdk-dotnet-durablefunction-imageprocessing/src/ImageProcessor/ImageProcessingService.cs @@ -0,0 +1,211 @@ +using Amazon.S3; +using Amazon.S3.Model; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Formats.Jpeg; +using SixLabors.ImageSharp.Formats.Png; +using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Processing; + +namespace ImageProcessor; + +/// +/// Service that performs the actual image manipulation operations. +/// Each method is designed to be called from a durable step. +/// +internal sealed class ImageProcessingService(IAmazonS3 s3Client) +{ + private const int ThumbnailMaxWidth = 200; + private const int ThumbnailMaxHeight = 200; + + private readonly IAmazonS3 _s3Client = s3Client; + + /// + /// Downloads the source image from S3, resizes it to a thumbnail, and uploads the result. + /// + public async Task GenerateThumbnailAsync( + string bucket, string key, CancellationToken ct) + { + using var response = await _s3Client.GetObjectAsync(bucket, key, ct); + using var sourceStream = response.ResponseStream; + using var image = await Image.LoadAsync(sourceStream, ct); + + image.Mutate(x => x.Resize(new ResizeOptions + { + Size = new Size(ThumbnailMaxWidth, ThumbnailMaxHeight), + Mode = ResizeMode.Max + })); + + var outputKey = GetOutputKey(key, "thumbnails"); + + using var outputStream = new MemoryStream(); + await image.SaveAsync(outputStream, new JpegEncoder { Quality = 80 }, ct); + outputStream.Position = 0; + + await _s3Client.PutObjectAsync(new PutObjectRequest + { + BucketName = bucket, + Key = outputKey, + InputStream = outputStream, + ContentType = "image/jpeg" + }, ct); + + return new ThumbnailResult(outputKey, image.Width, image.Height); + } + + /// + /// Downloads the source image from S3, overlays a watermark logo in the bottom-right corner, + /// and uploads the result. + /// + public async Task ApplyWatermarkAsync( + string bucket, string key, CancellationToken ct) + { + using var response = await _s3Client.GetObjectAsync(bucket, key, ct); + using var sourceStream = response.ResponseStream; + using var image = await Image.LoadAsync(sourceStream, ct); + + // Load the watermark logo from the bundled file + var watermarkPath = Path.Combine(AppContext.BaseDirectory, "watermark.png"); + using var watermark = await Image.LoadAsync(watermarkPath, ct); + + // Resize watermark to fit within 50x50 px, preserving aspect ratio + watermark.Mutate(w => w.Resize(new ResizeOptions + { + Size = new Size(50, 50), + Mode = ResizeMode.Max + })); + + // Position in the bottom-right corner with a 10px margin + const int margin = 10; + var x = image.Width - watermark.Width - margin; + var y = image.Height - watermark.Height - margin; + + image.Mutate(ctx => ctx.DrawImage(watermark, new Point(x, y), opacity: 0.5f)); + + // Apply 5px rounded corners and dark border + ApplyRoundedCornersAndBorder(image, 20f, 3); + + var outputKey = GetOutputKey(key, "watermarked"); + + using var outputStream = new MemoryStream(); + await image.SaveAsync(outputStream, new PngEncoder(), ct); + outputStream.Position = 0; + + await _s3Client.PutObjectAsync(new PutObjectRequest + { + BucketName = bucket, + Key = outputKey, + InputStream = outputStream, + ContentType = "image/png" + }, ct); + + return new WatermarkResult(outputKey); + } + + /// + /// Downloads the source image metadata from S3 and extracts dimensions and format info. + /// + public async Task ExtractMetadataAsync( + string bucket, string key, CancellationToken ct) + { + // Get object metadata for file size + var metadataResponse = await _s3Client.GetObjectMetadataAsync(bucket, key, ct); + var fileSize = metadataResponse.ContentLength; + + // Load the image to extract dimensions and format + using var response = await _s3Client.GetObjectAsync(bucket, key, ct); + using var sourceStream = response.ResponseStream; + var imageInfo = await Image.IdentifyAsync(sourceStream, ct); + + return new ImageMetadata( + Width: imageInfo.Width, + Height: imageInfo.Height, + Format: imageInfo.Metadata.DecodedImageFormat?.Name ?? "Unknown", + FileSizeBytes: fileSize); + } + + private static string GetOutputKey(string sourceKey, string folder) + { + var fileName = Path.GetFileNameWithoutExtension(sourceKey); + var extension = Path.GetExtension(sourceKey); + return $"{folder}/{fileName}{extension}"; + } + + /// + /// Applies rounded corners and a dark border to the image. + /// Works at the pixel level to avoid ImageSharp.Drawing dependency. + /// + private static void ApplyRoundedCornersAndBorder(Image image, float cornerRadius, int borderWidth) + { + var width = image.Width; + var height = image.Height; + var borderColor = new Rgba32(30, 30, 30, 255); + var r2 = cornerRadius * cornerRadius; + + image.ProcessPixelRows(accessor => + { + for (var y = 0; y < height; y++) + { + var row = accessor.GetRowSpan(y); + for (var x = 0; x < width; x++) + { + // Check if pixel is outside the rounded rectangle + if (IsOutsideRoundedRect(x, y, width, height, cornerRadius, r2)) + { + row[x] = new Rgba32(0, 0, 0, 0); // Transparent + } + // Check if pixel is in the border zone + else if (IsInBorderZone(x, y, width, height, cornerRadius, borderWidth, r2)) + { + row[x] = borderColor; + } + } + } + }); + } + + private static bool IsOutsideRoundedRect(int x, int y, int width, int height, float r, float r2) + { + // Only corners need checking + if (x < r && y < r) // Top-left + return DistanceSquared(x, y, r, r) > r2; + if (x >= width - r && y < r) // Top-right + return DistanceSquared(x, y, width - r - 1, r) > r2; + if (x < r && y >= height - r) // Bottom-left + return DistanceSquared(x, y, r, height - r - 1) > r2; + if (x >= width - r && y >= height - r) // Bottom-right + return DistanceSquared(x, y, width - r - 1, height - r - 1) > r2; + return false; + } + + private static bool IsInBorderZone(int x, int y, int width, int height, float r, int borderWidth, float r2) + { + // Check if on the straight border edges + if (x < borderWidth || x >= width - borderWidth || y < borderWidth || y >= height - borderWidth) + { + return !IsOutsideRoundedRect(x, y, width, height, r, r2); + } + + // Check inner corner arcs + var innerR = r - borderWidth; + if (innerR <= 0) return false; + var innerR2 = innerR * innerR; + + if (x < r && y < r) + return DistanceSquared(x, y, r, r) > innerR2; + if (x >= width - r && y < r) + return DistanceSquared(x, y, width - r - 1, r) > innerR2; + if (x < r && y >= height - r) + return DistanceSquared(x, y, r, height - r - 1) > innerR2; + if (x >= width - r && y >= height - r) + return DistanceSquared(x, y, width - r - 1, height - r - 1) > innerR2; + + return false; + } + + private static float DistanceSquared(float x1, float y1, float x2, float y2) + { + var dx = x1 - x2; + var dy = y1 - y2; + return (dx * dx) + (dy * dy); + } +} diff --git a/cdk-dotnet-durablefunction-imageprocessing/src/ImageProcessor/ImageProcessor.csproj b/cdk-dotnet-durablefunction-imageprocessing/src/ImageProcessor/ImageProcessor.csproj new file mode 100644 index 000000000..2ee849928 --- /dev/null +++ b/cdk-dotnet-durablefunction-imageprocessing/src/ImageProcessor/ImageProcessor.csproj @@ -0,0 +1,32 @@ + + + + Exe + net10.0 + enable + enable + ImageProcessor + ImageProcessor + + + + + + + + + + + + NU1902 + + + + + + PreserveNewest + PreserveNewest + + + + diff --git a/cdk-dotnet-durablefunction-imageprocessing/src/ImageProcessor/Models.cs b/cdk-dotnet-durablefunction-imageprocessing/src/ImageProcessor/Models.cs new file mode 100644 index 000000000..2640e4087 --- /dev/null +++ b/cdk-dotnet-durablefunction-imageprocessing/src/ImageProcessor/Models.cs @@ -0,0 +1,46 @@ +using System.Diagnostics.CodeAnalysis; + +namespace ImageProcessor; + +/// +/// Input to the durable workflow — identifies the uploaded image. +/// +[SuppressMessage("Performance", "CA1812:Avoid uninstantiated internal classes", Justification = "Deserialized by Lambda runtime")] +internal sealed record ImageInput(string Bucket, string Key); + +/// +/// Result of generating a thumbnail. +/// +internal sealed record ThumbnailResult(string OutputKey, int Width, int Height); + +/// +/// Result of applying a watermark. +/// +internal sealed record WatermarkResult(string OutputKey); + +/// +/// Metadata extracted from the original image. +/// +internal sealed record ImageMetadata(int Width, int Height, string Format, long FileSizeBytes); + +/// +/// Per-branch result type used in the parallel fan-out. +/// Each branch produces one of the specific result types. +/// +internal sealed record ProcessingBranchResult( + string BranchName, + ThumbnailResult? Thumbnail, + WatermarkResult? Watermark, + ImageMetadata? Metadata); + +/// +/// Final aggregated result written to DynamoDB and returned from the workflow. +/// +internal sealed record PipelineResult( + string ImageId, + string SourceBucket, + string SourceKey, + ThumbnailResult Thumbnail, + WatermarkResult Watermark, + ImageMetadata Metadata, + DateTime CompletedAt); diff --git a/cdk-dotnet-durablefunction-imageprocessing/src/ImageProcessor/ResultsWriter.cs b/cdk-dotnet-durablefunction-imageprocessing/src/ImageProcessor/ResultsWriter.cs new file mode 100644 index 000000000..7b5591dd0 --- /dev/null +++ b/cdk-dotnet-durablefunction-imageprocessing/src/ImageProcessor/ResultsWriter.cs @@ -0,0 +1,45 @@ +using System.Globalization; +using Amazon.DynamoDBv2; +using Amazon.DynamoDBv2.Model; + +namespace ImageProcessor; + +/// +/// Writes the aggregated pipeline results to DynamoDB. +/// +internal sealed class ResultsWriter(IAmazonDynamoDB dynamoDb, string tableName) +{ + private readonly IAmazonDynamoDB _dynamoDb = dynamoDb; + private readonly string _tableName = tableName; + + /// + /// Persists the final pipeline result to DynamoDB. + /// This operation is idempotent — writing the same ImageId overwrites the previous entry. + /// + public async Task WriteResultAsync(PipelineResult result, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(result); + + var item = new Dictionary + { + ["ImageId"] = new() { S = result.ImageId }, + ["SourceBucket"] = new() { S = result.SourceBucket }, + ["SourceKey"] = new() { S = result.SourceKey }, + ["ThumbnailKey"] = new() { S = result.Thumbnail.OutputKey }, + ["ThumbnailWidth"] = new() { N = result.Thumbnail.Width.ToString(CultureInfo.InvariantCulture) }, + ["ThumbnailHeight"] = new() { N = result.Thumbnail.Height.ToString(CultureInfo.InvariantCulture) }, + ["WatermarkKey"] = new() { S = result.Watermark.OutputKey }, + ["OriginalWidth"] = new() { N = result.Metadata.Width.ToString(CultureInfo.InvariantCulture) }, + ["OriginalHeight"] = new() { N = result.Metadata.Height.ToString(CultureInfo.InvariantCulture) }, + ["Format"] = new() { S = result.Metadata.Format }, + ["FileSizeBytes"] = new() { N = result.Metadata.FileSizeBytes.ToString(CultureInfo.InvariantCulture) }, + ["CompletedAt"] = new() { S = result.CompletedAt.ToString("O", CultureInfo.InvariantCulture) } + }; + + await _dynamoDb.PutItemAsync(new PutItemRequest + { + TableName = _tableName, + Item = item + }, ct); + } +} diff --git a/cdk-dotnet-durablefunction-imageprocessing/src/ImageProcessor/S3TriggerHandler.cs b/cdk-dotnet-durablefunction-imageprocessing/src/ImageProcessor/S3TriggerHandler.cs new file mode 100644 index 000000000..b7ad13c0b --- /dev/null +++ b/cdk-dotnet-durablefunction-imageprocessing/src/ImageProcessor/S3TriggerHandler.cs @@ -0,0 +1,42 @@ +using System.Text.Json; +using Amazon.Lambda; +using Amazon.Lambda.Core; +using Amazon.Lambda.Model; +using Amazon.Lambda.S3Events; + +namespace ImageProcessor; + +/// +/// Thin trigger function invoked by S3 event notifications. +/// Extracts bucket/key from the S3 event and invokes the durable image processor. +/// +internal sealed class S3TriggerHandler +{ + private static readonly IAmazonLambda LambdaClient = new AmazonLambdaClient(); + + private static readonly string ImageProcessorFunctionArn = + System.Environment.GetEnvironmentVariable("IMAGE_PROCESSOR_FUNCTION_ARN") + ?? throw new InvalidOperationException("IMAGE_PROCESSOR_FUNCTION_ARN environment variable is not set."); + + public async Task Handler(S3Event s3Event, ILambdaContext context) + { + foreach (var record in s3Event.Records) + { + var bucket = record.S3.Bucket.Name; + var key = record.S3.Object.Key; + + context.Logger.LogInformation($"Processing S3 event: s3://{bucket}/{key}"); + + var payload = JsonSerializer.Serialize(new { Bucket = bucket, Key = key }); + + await LambdaClient.InvokeAsync(new InvokeRequest + { + FunctionName = $"{ImageProcessorFunctionArn}:$LATEST", + InvocationType = InvocationType.Event, + Payload = payload + }); + + context.Logger.LogInformation($"Durable execution started for s3://{bucket}/{key}"); + } + } +} diff --git a/cdk-dotnet-durablefunction-imageprocessing/src/Infra/GlobalSuppressions.cs b/cdk-dotnet-durablefunction-imageprocessing/src/Infra/GlobalSuppressions.cs new file mode 100644 index 000000000..26233fcb5 --- /dev/null +++ b/cdk-dotnet-durablefunction-imageprocessing/src/Infra/GlobalSuppressions.cs @@ -0,0 +1 @@ +[assembly: System.Diagnostics.CodeAnalysis.SuppressMessage("Potential Code Quality Issues", "RECS0026:Possible unassigned object created by 'new'", Justification = "Constructs add themselves to the scope in which they are created")] diff --git a/cdk-dotnet-durablefunction-imageprocessing/src/Infra/Infra.csproj b/cdk-dotnet-durablefunction-imageprocessing/src/Infra/Infra.csproj new file mode 100644 index 000000000..3790e10e0 --- /dev/null +++ b/cdk-dotnet-durablefunction-imageprocessing/src/Infra/Infra.csproj @@ -0,0 +1,17 @@ + + + + Exe + net10.0 + enable + enable + Infra + + + + + + + + + diff --git a/cdk-dotnet-durablefunction-imageprocessing/src/Infra/InfraStack.cs b/cdk-dotnet-durablefunction-imageprocessing/src/Infra/InfraStack.cs new file mode 100644 index 000000000..092740bf0 --- /dev/null +++ b/cdk-dotnet-durablefunction-imageprocessing/src/Infra/InfraStack.cs @@ -0,0 +1,167 @@ +using System.Diagnostics; +using Amazon.CDK; +using Amazon.CDK.AWS.DynamoDB; +using Amazon.CDK.AWS.Lambda; +using Amazon.CDK.AWS.S3; +using Amazon.CDK.AWS.S3.Notifications; +using Constructs; + +namespace Infra; + +internal sealed class InfraStack : Stack +{ + internal InfraStack(Construct scope, string id, IStackProps? props = null) + : base(scope, id, props) + { + // ────────────────────────────────────────────────────────────────── + // S3 Bucket — stores source images and processed outputs + // ────────────────────────────────────────────────────────────────── + var imagesBucket = new Bucket(this, "ImagesBucket", new BucketProps + { + RemovalPolicy = RemovalPolicy.DESTROY, + AutoDeleteObjects = true, + BlockPublicAccess = BlockPublicAccess.BLOCK_ALL, + Encryption = BucketEncryption.S3_MANAGED, + LifecycleRules = + [ + new LifecycleRule + { + Id = "ExpireProcessedImages", + Prefix = "thumbnails/", + Expiration = Duration.Days(90) + }, + new LifecycleRule + { + Id = "ExpireWatermarked", + Prefix = "watermarked/", + Expiration = Duration.Days(90) + } + ] + }); + + // ────────────────────────────────────────────────────────────────── + // DynamoDB Table — stores aggregated pipeline results + // ────────────────────────────────────────────────────────────────── + var resultsTable = new Table(this, "ResultsTable", new TableProps + { + PartitionKey = new Amazon.CDK.AWS.DynamoDB.Attribute + { + Name = "ImageId", + Type = AttributeType.STRING + }, + BillingMode = BillingMode.PAY_PER_REQUEST, + RemovalPolicy = RemovalPolicy.DESTROY, + PointInTimeRecoverySpecification = new PointInTimeRecoverySpecification + { + PointInTimeRecoveryEnabled = true + } + }); + + // ────────────────────────────────────────────────────────────────── + // Lambda Function — Durable image processing workflow + // ────────────────────────────────────────────────────────────────── + var publishOutputPath = PublishLambdaProject("src/ImageProcessor/ImageProcessor.csproj"); + + var imageProcessorFunction = new Function(this, "ImageProcessorFunction", new FunctionProps + { + Runtime = Runtime.DOTNET_10, + Handler = "ImageProcessor::ImageProcessor.ImageProcessorHandler::Handler", + Code = Code.FromAsset(publishOutputPath), + MemorySize = 1024, + Timeout = Duration.Seconds(900), + DurableConfig = new DurableConfig + { + ExecutionTimeout = Duration.Minutes(15), + RetentionPeriod = Duration.Days(7) + }, + Environment = new Dictionary + { + ["RESULTS_TABLE_NAME"] = resultsTable.TableName + }, + Description = "Durable Function: Image processing pipeline with fan-out/fan-in" + }); + + // Grant the Lambda function permissions + imagesBucket.GrantReadWrite(imageProcessorFunction); + resultsTable.GrantWriteData(imageProcessorFunction); + + // ────────────────────────────────────────────────────────────────── + // S3 Trigger Function — invoked on upload, starts durable execution + // ────────────────────────────────────────────────────────────────── + var s3TriggerFunction = new Function(this, "S3TriggerFunction", new FunctionProps + { + Runtime = Runtime.DOTNET_10, + Handler = "ImageProcessor::ImageProcessor.S3TriggerHandler::Handler", + Code = Code.FromAsset(publishOutputPath), + MemorySize = 256, + Timeout = Duration.Seconds(30), + Environment = new Dictionary + { + ["IMAGE_PROCESSOR_FUNCTION_ARN"] = imageProcessorFunction.FunctionArn + }, + Description = "S3 trigger: starts durable image processing on upload" + }); + + // Grant the trigger permission to invoke the durable function + imageProcessorFunction.GrantInvoke(s3TriggerFunction); + + // S3 event notification: trigger on object creation in uploads/ prefix + imagesBucket.AddEventNotification( + Amazon.CDK.AWS.S3.EventType.OBJECT_CREATED, + new Amazon.CDK.AWS.S3.Notifications.LambdaDestination(s3TriggerFunction), + new NotificationKeyFilter { Prefix = "uploads/" }); + + // ────────────────────────────────────────────────────────────────── + // Outputs + // ────────────────────────────────────────────────────────────────── + _ = new CfnOutput(this, "ImagesBucketName", new CfnOutputProps + { + Value = imagesBucket.BucketName, + Description = "S3 bucket for uploading source images" + }); + + _ = new CfnOutput(this, "ResultsTableName", new CfnOutputProps + { + Value = resultsTable.TableName, + Description = "DynamoDB table storing pipeline results" + }); + + _ = new CfnOutput(this, "ImageProcessorFunctionArn", new CfnOutputProps + { + Value = imageProcessorFunction.FunctionArn, + Description = "ARN of the image processor Lambda function" + }); + } + + /// + /// Publishes a .NET Lambda project in Release configuration and returns the output path. + /// Called during CDK synthesis so that `cdk deploy` doesn't require a separate publish step. + /// + private static string PublishLambdaProject(string projectPath) + { + var publishDir = Path.Combine( + Path.GetDirectoryName(projectPath)!, + "bin", "Release", "net10.0", "publish"); + + var process = Process.Start(new ProcessStartInfo + { + FileName = "dotnet", + Arguments = $"publish \"{projectPath}\" -c Release -r linux-x64 --self-contained false -o \"{publishDir}\"", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + }) ?? throw new InvalidOperationException("Failed to start dotnet publish process."); + + var stdout = process.StandardOutput.ReadToEnd(); + var stderr = process.StandardError.ReadToEnd(); + process.WaitForExit(); + + if (process.ExitCode != 0) + { + throw new InvalidOperationException( + $"dotnet publish failed for '{projectPath}' (exit code {process.ExitCode}):\n{stderr}\n{stdout}"); + } + + return publishDir; + } +} diff --git a/cdk-dotnet-durablefunction-imageprocessing/src/Infra/Program.cs b/cdk-dotnet-durablefunction-imageprocessing/src/Infra/Program.cs new file mode 100644 index 000000000..bc425f9e6 --- /dev/null +++ b/cdk-dotnet-durablefunction-imageprocessing/src/Infra/Program.cs @@ -0,0 +1,20 @@ +using Amazon.CDK; + +namespace Infra; + +sealed class Program +{ + public static void Main() + { + var app = new App(); + new InfraStack(app, "CdkDotnetDurablefunctionImageprocessingStack", new StackProps + { + Env = new Amazon.CDK.Environment + { + Account = System.Environment.GetEnvironmentVariable("CDK_DEFAULT_ACCOUNT"), + Region = System.Environment.GetEnvironmentVariable("CDK_DEFAULT_REGION"), + } + }); + app.Synth(); + } +}