From d35fadcd5b20b1ae56beb4c657a358a3622f1b0b Mon Sep 17 00:00:00 2001 From: Doug Perkes Date: Fri, 17 Jul 2026 09:23:04 -0500 Subject: [PATCH] New serverless pattern - cdk-dotnet-durablefunction-imageprocessing --- .../.gitignore | 342 ++++++++++++++ .../.kiro/steering.md | 338 ++++++++++++++ .../README.md | 437 ++++++++++++++++++ ...otnet-durablefunction-imageprocessing.json | 63 +++ .../cdk.json | 104 +++++ .../generate-and-upload.sh | 70 +++ .../img/Lambda.png | Bin 0 -> 14499 bytes .../src/.globalconfig | 13 + ...kDotnetDurablefunctionImageprocessing.slnx | 9 + .../src/ImageProcessor/Function.cs | 123 +++++ .../ImageProcessor/ImageProcessingService.cs | 211 +++++++++ .../src/ImageProcessor/ImageProcessor.csproj | 32 ++ .../src/ImageProcessor/Models.cs | 46 ++ .../src/ImageProcessor/ResultsWriter.cs | 45 ++ .../src/ImageProcessor/S3TriggerHandler.cs | 42 ++ .../src/Infra/GlobalSuppressions.cs | 1 + .../src/Infra/Infra.csproj | 17 + .../src/Infra/InfraStack.cs | 167 +++++++ .../src/Infra/Program.cs | 20 + 19 files changed, 2080 insertions(+) create mode 100644 cdk-dotnet-durablefunction-imageprocessing/.gitignore create mode 100644 cdk-dotnet-durablefunction-imageprocessing/.kiro/steering.md create mode 100644 cdk-dotnet-durablefunction-imageprocessing/README.md create mode 100644 cdk-dotnet-durablefunction-imageprocessing/cdk-dotnet-durablefunction-imageprocessing.json create mode 100644 cdk-dotnet-durablefunction-imageprocessing/cdk.json create mode 100755 cdk-dotnet-durablefunction-imageprocessing/generate-and-upload.sh create mode 100644 cdk-dotnet-durablefunction-imageprocessing/img/Lambda.png create mode 100644 cdk-dotnet-durablefunction-imageprocessing/src/.globalconfig create mode 100644 cdk-dotnet-durablefunction-imageprocessing/src/CdkDotnetDurablefunctionImageprocessing.slnx create mode 100644 cdk-dotnet-durablefunction-imageprocessing/src/ImageProcessor/Function.cs create mode 100644 cdk-dotnet-durablefunction-imageprocessing/src/ImageProcessor/ImageProcessingService.cs create mode 100644 cdk-dotnet-durablefunction-imageprocessing/src/ImageProcessor/ImageProcessor.csproj create mode 100644 cdk-dotnet-durablefunction-imageprocessing/src/ImageProcessor/Models.cs create mode 100644 cdk-dotnet-durablefunction-imageprocessing/src/ImageProcessor/ResultsWriter.cs create mode 100644 cdk-dotnet-durablefunction-imageprocessing/src/ImageProcessor/S3TriggerHandler.cs create mode 100644 cdk-dotnet-durablefunction-imageprocessing/src/Infra/GlobalSuppressions.cs create mode 100644 cdk-dotnet-durablefunction-imageprocessing/src/Infra/Infra.csproj create mode 100644 cdk-dotnet-durablefunction-imageprocessing/src/Infra/InfraStack.cs create mode 100644 cdk-dotnet-durablefunction-imageprocessing/src/Infra/Program.cs diff --git a/cdk-dotnet-durablefunction-imageprocessing/.gitignore b/cdk-dotnet-durablefunction-imageprocessing/.gitignore new file mode 100644 index 0000000000..a4609e7587 --- /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 0000000000..cf931434fd --- /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 0000000000..66d38341d0 --- /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 0000000000..4a6549dbbc --- /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 0000000000..f7ec877745 --- /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 0000000000..2a8e05e1c3 --- /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 0000000000000000000000000000000000000000..9b3861894b21ee7b4343ecb437b950ef15bbd8c1 GIT binary patch literal 14499 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 literal 0 HcmV?d00001 diff --git a/cdk-dotnet-durablefunction-imageprocessing/src/.globalconfig b/cdk-dotnet-durablefunction-imageprocessing/src/.globalconfig new file mode 100644 index 0000000000..01268a03a6 --- /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 0000000000..b3dd1ead37 --- /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 0000000000..999eed423b --- /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 0000000000..98c1918604 --- /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 0000000000..2ee8499280 --- /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 0000000000..2640e40878 --- /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 0000000000..7b5591dd09 --- /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 0000000000..b7ad13c0b5 --- /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 0000000000..26233fcb54 --- /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 0000000000..3790e10e02 --- /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 0000000000..092740bf0f --- /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 0000000000..bc425f9e66 --- /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(); + } +}