diff --git a/.gitignore b/.gitignore index e135aa0..bd1cf77 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,10 @@ __pycache__/ # MkDocs build output site/ +# .NET build output (from the throwaway C# example compile-check harness) +[Bb]in/ +[Oo]bj/ + # OS files .DS_Store Thumbs.db @@ -20,3 +24,6 @@ Thumbs.db *~ .vscode/ .idea/ + +# C# example compile-check scratch dir +.verify/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index aac78f4..e42f9d8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -32,7 +32,7 @@ Common commands: 1. Create the markdown file in `docs/` 2. Add the page to the nav section in `zensical.toml` -3. Create corresponding code examples in all three languages under `examples/` +3. Create corresponding code examples in all four languages under `examples/` 4. Test locally with `zensical serve` ## Vendored dependencies @@ -152,14 +152,14 @@ Edit documentation in the markdown files under `docs/`. Embed code samples in documentation pages with the `--8<--` snippet syntax with content tabs for multi-language support. -**IMPORTANT**: All code examples MUST include all three languages (Python, TypeScript, Java) and remain functionally equivalent across languages. The tab order must always be TypeScript → Python → Java. +**IMPORTANT**: All code examples MUST include all four languages (TypeScript, Python, Java, C#) and remain functionally equivalent across languages. The tab order must always be TypeScript → Python → Java → C#. #### Steps to add a new code sample: 1. Create example files under `examples/` following the page folder hierarchy -2. Use identical names with hyphens across all languages (e.g., `retry-with-backoff.{py,ts,java}`) +2. Use identical names with hyphens across all languages (e.g., `retry-with-backoff.{ts,py,java,cs}`) 3. Organize by language: `examples/{language}/{section}/{subsection}/{example-name}.{ext}` -4. Ensure all three language versions demonstrate the same functionality +4. Ensure all four language versions demonstrate the same functionality 5. Reference the examples in your documentation using content tabs: ```markdown @@ -180,6 +180,12 @@ Embed code samples in documentation pages with the `--8<--` snippet syntax with ```java --8<-- "examples/java/core/steps/basic-step.java" ``` + +=== "C#" + + ```csharp + --8<-- "examples/csharp/core/steps/basic-step.cs" + ``` ``` #### Example structure: @@ -207,17 +213,28 @@ examples/ │ └── advanced/ │ └── error-handling/ │ └── retry-with-backoff.py -└── java/ +├── java/ +│ ├── getting-started/ +│ │ └── minimal-example.java +│ ├── core/ +│ │ ├── steps/ +│ │ │ └── basic-step.java +│ │ └── parallel/ +│ │ └── parallel-execution.java +│ └── advanced/ +│ └── error-handling/ +│ └── retry-with-backoff.java +└── csharp/ ├── getting-started/ - │ └── minimal-example.java + │ └── minimal-example.cs ├── core/ │ ├── steps/ - │ │ └── basic-step.java + │ │ └── basic-step.cs │ └── parallel/ - │ └── parallel-execution.java + │ └── parallel-execution.cs └── advanced/ └── error-handling/ - └── retry-with-backoff.java + └── retry-with-backoff.cs ``` This approach keeps code samples maintainable, testable, and consistent across all languages. @@ -247,7 +264,7 @@ Example: ``` add custom serdes examples -- Add TypeScript, Python, and Java examples for custom +- Add TypeScript, Python, Java, and C# examples for custom serialization - Include encryption-at-rest pattern for sensitive data - Update serialization doc page with snippet references diff --git a/README.md b/README.md index 42e749a..120f8cb 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ The `docs/` directory contains the Markdown source files that power the documentation site. Code examples are in `examples/`, organized by language -(`typescript/`, `python/`, `java/`) and mirroring the docs folder hierarchy. +(`typescript/`, `python/`, `java/`, `csharp/`) and mirroring the docs folder hierarchy. Examples are embedded into documentation pages using snippet syntax — see the [Contributing Guide](CONTRIBUTING.md) for details. @@ -63,6 +63,7 @@ for details. - [JavaScript SDK Repository](https://github.com/aws/aws-durable-execution-sdk-js) - [Python SDK Repository](https://github.com/aws/aws-durable-execution-sdk-python) - [Java SDK Repository](https://github.com/aws/aws-durable-execution-sdk-java) +- [C# SDK Repository](https://github.com/aws/aws-lambda-dotnet/tree/master/Libraries/src/Amazon.Lambda.DurableExecution) ## Feedback & Support diff --git a/authoring-guide.md b/authoring-guide.md index c28758c..a03dbca 100644 --- a/authoring-guide.md +++ b/authoring-guide.md @@ -3,8 +3,8 @@ This guide covers how to author documentation for AWS Lambda Durable Functions. For setup and contribution workflow, see [CONTRIBUTING.md](CONTRIBUTING.md). -Each SDK reference page must be equally useful to TypeScript, Python, and Java -developers. No language is the implicit default. Language-specific quirks +Each SDK reference page must be equally useful to TypeScript, Python, Java, and +C# developers. No language is the implicit default. Language-specific quirks belong inside the relevant tab, not in shared prose. ## Verify Against SDK Source @@ -26,7 +26,7 @@ For each code example: ### SDK repositories -You will need the SDK source for all three languages. Clone them alongside +You will need the SDK source for all four languages. Clone them alongside the docs repo: | Repository | Source path to read | @@ -34,15 +34,21 @@ the docs repo: | [aws-durable-execution-sdk-js](https://github.com/aws/aws-durable-execution-sdk-js) | `packages/aws-durable-execution-sdk-js/src` | | [aws-durable-execution-sdk-python](https://github.com/aws/aws-durable-execution-sdk-python) | `src/aws_durable_execution_sdk_python` | | [aws-durable-execution-sdk-java](https://github.com/aws/aws-durable-execution-sdk-java) | `sdk/src/main/java/software/amazon/lambda/durable` | +| [aws-lambda-dotnet](https://github.com/aws/aws-lambda-dotnet) | `Libraries/src/Amazon.Lambda.DurableExecution` | Testing SDKs are useful for confirming example code compiles and runs. The -JavaScript and Java testing SDKs live in the same repos as the main SDKs -(under `-testing` package paths). The Python testing SDK lives in a separate -repo: +JavaScript, Java, and C# testing SDKs live in the same repos as the main SDKs +(under `-testing` package paths, or `Amazon.Lambda.DurableExecution.Testing` +for C#). The Python testing SDK lives in a separate repo: | Repository | Source path to read | |---|---| | [aws-durable-execution-sdk-python-testing](https://github.com/aws/aws-durable-execution-sdk-python-testing) | `src/aws_durable_execution_sdk_python_testing` | +| [aws-lambda-dotnet](https://github.com/aws/aws-lambda-dotnet) | `Libraries/src/Amazon.Lambda.DurableExecution.Testing` | + +The C# examples have a local compile-check harness under `scripts/csharp-verify/` +that builds each example against the SDK DLLs. See that directory's project files +and `verify.sh`. It is not part of CI. ### Key source files @@ -53,6 +59,10 @@ repo: `StepConfig` and `StepSemantics`. Check `types.py` for `StepContext`. - **Java**: `DurableContext.java` for the interface. Check `config/StepConfig.java`, `StepContext.java`, `StepSemantics.java`. +- **C#**: `IDurableContext.cs` for the interface (also defines `IStepContext`, + `IExecutionContext`). Check `StepConfig.cs`, `RetryStrategy.cs` (also holds + `StepSemantics` and `JitterStrategy`), `DurableFunction.cs` for `WrapAsync`, and + the per-operation config types (`CallbackConfig.cs`, `MapConfig.cs`, etc.). ## Writing Style @@ -149,7 +159,7 @@ as a whole new page can. ## Language Neutrality -Tab order is always: **TypeScript → Python → Java**. +Tab order is always: **TypeScript → Python → Java → C#**. If a language has a quirk, note it inside that language's tab: @@ -177,6 +187,14 @@ If a language has a quirk, note it inside that language's tab: ```java --8<-- "examples/java/operations/steps/basic-step.java" ``` + +=== "C#" + + The name is optional. Omit it to infer one from the call site. + + ```csharp + --8<-- "examples/csharp/operations/steps/basic-step.cs" + ``` ``` ## Page Structure @@ -185,7 +203,7 @@ SDK reference pages follow this pattern: 1. One or two short paragraphs explaining what the operation does and when to use it -2. A minimal walkthrough example (tabs, all three languages) +2. A minimal walkthrough example (tabs, all four languages) 3. Method signature section with per-language tabs 4. Parameters listed after the tabs (shared where identical, inside tabs where language-specific) @@ -269,7 +287,7 @@ Model tone and structure on these pages: All code examples must: -- Include all three languages (TypeScript, Python, Java) +- Include all four languages (TypeScript, Python, Java, C#) - Be functionally equivalent across languages - Include necessary imports - Be minimal. Show the concept, not a full application. @@ -283,9 +301,10 @@ examples/ typescript/{section}/{subsection}/{example-name}.ts python/{section}/{subsection}/{example-name}.py java/{section}/{subsection}/{example-name}.java + csharp/{section}/{subsection}/{example-name}.cs ``` -Use hyphens in filenames. All three languages must have the same set of files. +Use hyphens in filenames. All four languages must have the same set of files. ### Embedding in Docs @@ -309,6 +328,12 @@ Use the `--8<--` snippet syntax with content tabs: ```java --8<-- "examples/java/operations/steps/basic-step.java" ``` + +=== "C#" + + ```csharp + --8<-- "examples/csharp/operations/steps/basic-step.cs" + ``` ``` ### Method Signatures @@ -411,12 +436,13 @@ graph LR - [ ] All prose is language-neutral (no Python-only concepts described as universal) - [ ] No Table of Contents, no "Back to top" links, no "Back to X" links - [ ] No Terminology, Key Features, Best Practices, or FAQ sections -- [ ] Tab order is TypeScript → Python → Java everywhere +- [ ] Tab order is TypeScript → Python → Java → C# everywhere - [ ] Language-specific notes are inside tabs, not outside -- [ ] All three languages have example files for every `--8<--` reference +- [ ] All four languages have example files for every `--8<--` reference - [ ] Every example verified against the actual SDK source - [ ] Java method signatures show both sync and async variants - [ ] TypeScript signatures show both overloads where they exist +- [ ] C# examples compile via `scripts/csharp-verify/verify.sh` - [ ] No emdash, no hyphen-as-dash - [ ] No passive voice - [ ] Updates are revisions, not tacked-on additions diff --git a/docs/getting-started/key-concepts.md b/docs/getting-started/key-concepts.md index 0cfca51..53c6f4a 100644 --- a/docs/getting-started/key-concepts.md +++ b/docs/getting-started/key-concepts.md @@ -55,6 +55,12 @@ Your durable function receives a `DurableContext` instead of the default Lambda --8<-- "examples/java/getting-started/durable-context.java" ``` +=== "C#" + + ```csharp + --8<-- "examples/csharp/getting-started/durable-context.cs" + ``` + ## Operations Operations are units of work in a durable execution. Each operation type serves a @@ -172,6 +178,12 @@ Let's trace through a simple workflow: --8<-- "examples/java/getting-started/execution-model.java" ``` +=== "C#" + + ```csharp + --8<-- "examples/csharp/getting-started/execution-model.cs" + ``` + **First invocation (t=0s):** 1. You start a durable execution by invoking a durable function diff --git a/docs/getting-started/manage-executions.md b/docs/getting-started/manage-executions.md index 430eb1b..edb43ac 100644 --- a/docs/getting-started/manage-executions.md +++ b/docs/getting-started/manage-executions.md @@ -37,7 +37,7 @@ aws lambda stop-durable-execution \ After updating your code, publish a new version and point your alias to it. -=== "Zip (TypeScript/Python)" +=== "Zip (TypeScript/Python/C#)" ```console aws lambda update-function-code \ diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md index f5149ea..a0d10bc 100644 --- a/docs/getting-started/quickstart.md +++ b/docs/getting-started/quickstart.md @@ -1,7 +1,7 @@ # Quickstart Create and deploy your first durable function using the AWS CLI. This guide covers -TypeScript, Python, and Java. +TypeScript, Python, Java, and C#. !!! note "Adding all your dependencies to the deployment package" @@ -28,6 +28,10 @@ TypeScript, Python, and Java. - Java 17+ and Maven 3.8+ +=== "C#" + + - .NET 10 SDK + ## Create the execution role Create an IAM role that grants your function permission to perform checkpoint @@ -93,6 +97,18 @@ Note the role ARN returned. You'll need it in the next step. --8<-- "examples/java/getting-started/quickstart.java" ``` +=== "C#" + + Save as `Function.cs` + + ```csharp + --8<-- "examples/csharp/getting-started/quickstart.cs" + ``` + + This shows the workflow and handler. For the entry point (`Main` + + `LambdaBootstrap` + serializer), the class-library alternative, and the full + project setup, see the [C# SDK guide](../sdk-reference/languages/csharp/index.md). + The wait here is for 10 seconds just for an easy quick example, but it could as easily be 10 days without incurring extra compute. @@ -190,6 +206,31 @@ execution role you just created. --durable-config '{"ExecutionTimeout": 900, "RetentionPeriodInDays": 1}' ``` +=== "C#" + + Add the SDK to your project, then publish and package the output: + + ```console + dotnet add package Amazon.Lambda.DurableExecution + + dotnet publish -c Release -o publish + cd publish && zip -r ../function.zip . && cd .. + + aws lambda create-function \ + --function-name my-durable-function \ + --runtime dotnet10 \ + --role arn:aws:iam::123456789012:role/durable-function-role \ + --handler MyDurableFunction \ + --zip-file fileb://function.zip \ + --durable-config '{"ExecutionTimeout": 900, "RetentionPeriodInDays": 1}' + ``` + + The `--handler` value depends on your programming model: the assembly name for + the executable model, or `Assembly::Namespace.Type::Method` for a class library. + See the + [C# SDK guide](../sdk-reference/languages/csharp/index.md) for the programming + models, serializer registration, and handler string for each. + ### Publish a version You must invoke a durable functions with a published version or alias to ensure diff --git a/docs/index.md b/docs/index.md index 1b964bc..f579512 100644 --- a/docs/index.md +++ b/docs/index.md @@ -3,7 +3,7 @@ !!! note This guide covers the **AWS Durable Execution SDK**, the client library you use to write - durable functions in TypeScript, Python, and Java. For service-level topics including + durable functions in TypeScript, Python, Java, and C#. For service-level topics including IAM permissions, service quotas, infrastructure-as-code configuration, and monitoring, see [Lambda durable functions](https://docs.aws.amazon.com/lambda/latest/dg/durable-functions.html) @@ -60,6 +60,8 @@ For detailed programming language reference, see [SDK Reference](sdk-reference/) [aws-durable-execution-sdk-python](https://github.com/aws/aws-durable-execution-sdk-python) - :simple-github: [aws-durable-execution-sdk-java](https://github.com/aws/aws-durable-execution-sdk-java) + - :simple-github: + [aws-lambda-dotnet (C#)](https://github.com/aws/aws-lambda-dotnet/tree/master/Libraries/src/Amazon.Lambda.DurableExecution) ## Related documentation diff --git a/docs/patterns/best-practices/code-organization.md b/docs/patterns/best-practices/code-organization.md index 1406954..4deb234 100644 --- a/docs/patterns/best-practices/code-organization.md +++ b/docs/patterns/best-practices/code-organization.md @@ -28,6 +28,12 @@ than inline in the operation. Keep `DurableContext` out of your domain logic. --8<-- "examples/java/patterns/code-organization/separate-logic.java" ``` +=== "C#" + + ```csharp + --8<-- "examples/csharp/patterns/code-organization/separate-logic.cs" + ``` + !!! tip Simplify your unit testing by not referencing `DurableContext` in your domain logic @@ -60,6 +66,12 @@ operations. --8<-- "examples/java/patterns/code-organization/child-context.java" ``` +=== "C#" + + ```csharp + --8<-- "examples/csharp/patterns/code-organization/child-context.cs" + ``` + ## Group related configuration When several steps share the same retry strategy, timeout, or serdes, define the @@ -84,6 +96,12 @@ intent clear. --8<-- "examples/java/patterns/code-organization/group-config.java" ``` +=== "C#" + + ```csharp + --8<-- "examples/csharp/patterns/code-organization/group-config.cs" + ``` + ## Run independent work concurrently Use `parallel` for a fixed number of named branches and `map` to iterate over a @@ -109,6 +127,12 @@ survives Lambda timeouts or sandbox crashes, unlike language-specific constructs --8<-- "examples/java/patterns/code-organization/parallelism.java" ``` +=== "C#" + + ```csharp + --8<-- "examples/csharp/patterns/code-organization/parallelism.cs" + ``` + See the [parallel](../../sdk-reference/operations/parallel.md) and [map](../../sdk-reference/operations/map.md) references for completion policies, concurrency limits, and per-item configuration. diff --git a/docs/patterns/best-practices/determinism.md b/docs/patterns/best-practices/determinism.md index b7e498d..b786a6a 100644 --- a/docs/patterns/best-practices/determinism.md +++ b/docs/patterns/best-practices/determinism.md @@ -54,6 +54,12 @@ code. --8<-- "examples/java/patterns/determinism/non-deterministic-in-step.java" ``` +=== "C#" + + ```csharp + --8<-- "examples/csharp/patterns/determinism/non-deterministic-in-step.cs" + ``` + Because the SDK checkpoints the result of `generate-transaction-id`, every replay sees the same `transactionId` and the charge step receives the same argument. Without the wrapper, `UUID.randomUUID()` would produce a new value on every replay and the @@ -89,6 +95,12 @@ breaks as soon as the workflow replays after a crash or a wait. --8<-- "examples/java/patterns/determinism/return-value-passing.java" ``` +=== "C#" + + ```csharp + --8<-- "examples/csharp/patterns/determinism/return-value-passing.cs" + ``` + For processing a list of independent items, [map](/durable-execution/sdk-reference/operations/map/) is a simpler choice than an explicit loop. It runs the per-item operation in parallel, checkpoints each result, and @@ -126,6 +138,12 @@ non-deterministic decision into a step and branch on the step's return value. --8<-- "examples/java/patterns/determinism/stable-branches.java" ``` +=== "C#" + + ```csharp + --8<-- "examples/csharp/patterns/determinism/stable-branches.cs" + ``` + The same rule applies to reading from external services. Fetching a flag from a database outside a step risks replaying against a changed value. Fetch inside a step, branch on the returned value. diff --git a/docs/patterns/best-practices/idempotency.md b/docs/patterns/best-practices/idempotency.md index 5e25d41..7954d1a 100644 --- a/docs/patterns/best-practices/idempotency.md +++ b/docs/patterns/best-practices/idempotency.md @@ -55,6 +55,12 @@ end-to-end, combine at-most-once with a no-retry strategy. --8<-- "examples/java/patterns/idempotency/choose-semantics.java" ``` +=== "C#" + + ```csharp + --8<-- "examples/csharp/patterns/idempotency/choose-semantics.cs" + ``` + !!! warning At-most-once applies per attempt, not per workflow. Combine it with a no-retry strategy @@ -90,6 +96,12 @@ at-least-once retries are safe. --8<-- "examples/java/patterns/idempotency/idempotency-tokens.java" ``` +=== "C#" + + ```csharp + --8<-- "examples/csharp/patterns/idempotency/idempotency-tokens.cs" + ``` + The same pattern applies to any operation that writes to an external store. Include the key in the write and rely on the store's deduplication. diff --git a/docs/patterns/best-practices/pause-resume.md b/docs/patterns/best-practices/pause-resume.md index 7a5e957..273c357 100644 --- a/docs/patterns/best-practices/pause-resume.md +++ b/docs/patterns/best-practices/pause-resume.md @@ -38,6 +38,12 @@ suspended. --8<-- "examples/java/patterns/pause-resume/wait-vs-sleep.java" ``` +=== "C#" + + ```csharp + --8<-- "examples/csharp/patterns/pause-resume/wait-vs-sleep.cs" + ``` + !!! tip Name every wait. Named waits show up in the operation history and CloudWatch, which @@ -67,6 +73,12 @@ up to the execution timeout, holding the resource slot until an operator interve --8<-- "examples/java/patterns/pause-resume/callback-timeout.java" ``` +=== "C#" + + ```csharp + --8<-- "examples/csharp/patterns/pause-resume/callback-timeout.cs" + ``` + !!! danger Always set a timeout on `waitForCallback` to avoid stalled executions. @@ -101,6 +113,12 @@ is in progress. --8<-- "examples/java/patterns/pause-resume/heartbeat-timeout.java" ``` +=== "C#" + + ```csharp + --8<-- "examples/csharp/patterns/pause-resume/heartbeat-timeout.cs" + ``` + !!! warning A 24-hour timeout means a 24-hour outage when the external worker crashes. Set a @@ -137,6 +155,12 @@ create a retry storm. --8<-- "examples/java/patterns/pause-resume/wait-for-condition.java" ``` +=== "C#" + + ```csharp + --8<-- "examples/csharp/patterns/pause-resume/wait-for-condition.cs" + ``` + !!! tip Durable waits round up to a minimum of one second. Don't use wait for condition to poll diff --git a/docs/patterns/best-practices/state.md b/docs/patterns/best-practices/state.md index 3b17ab2..48a9b8b 100644 --- a/docs/patterns/best-practices/state.md +++ b/docs/patterns/best-practices/state.md @@ -37,6 +37,12 @@ state once you return it from the step. --8<-- "examples/java/patterns/state/durable-vs-local.java" ``` +=== "C#" + + ```csharp + --8<-- "examples/csharp/patterns/state/durable-vs-local.cs" + ``` + ## Store references, not payloads A common pattern is to do the fetch inside the step, extract the identifier, and return @@ -67,6 +73,12 @@ store inside the first step and pass the key or version ID to the next step. --8<-- "examples/java/patterns/state/store-references.java" ``` +=== "C#" + + ```csharp + --8<-- "examples/csharp/patterns/state/store-references.cs" + ``` + ## Keep the handler input small The handler input is stored once at the start of the execution and read from execution @@ -124,6 +136,12 @@ Keep per-item state small: --8<-- "examples/java/patterns/state/batch-result-pointers.java" ``` +=== "C#" + + ```csharp + --8<-- "examples/csharp/patterns/state/batch-result-pointers.cs" + ``` + !!! tip Treat the per-item result like a step return. If it is more than a few hundred bytes, diff --git a/docs/patterns/best-practices/step-design.md b/docs/patterns/best-practices/step-design.md index 2bef5b4..015a9d5 100644 --- a/docs/patterns/best-practices/step-design.md +++ b/docs/patterns/best-practices/step-design.md @@ -34,6 +34,12 @@ different name on subsequent invocations. --8<-- "examples/java/patterns/step-design/step-names.java" ``` +=== "C#" + + ```csharp + --8<-- "examples/csharp/patterns/step-design/step-names.cs" + ``` + !!! warning A step name with a timestamp or random value resolves to a different name on replay. @@ -75,6 +81,12 @@ re-run. --8<-- "examples/java/patterns/step-design/one-thing-per-step.java" ``` +=== "C#" + + ```csharp + --8<-- "examples/csharp/patterns/step-design/one-thing-per-step.cs" + ``` + ## Reuse step logic Define a reusable step function once and reference it repeatedly from the workflow. @@ -104,6 +116,14 @@ Define a reusable step function once and reference it repeatedly from the workfl --8<-- "examples/java/patterns/step-design/reusable-step.java" ``` +=== "C#" + + Define a method returning `Task` and reference it as the step body. + + ```csharp + --8<-- "examples/csharp/patterns/step-design/reusable-step.cs" + ``` + ## Step nesting A step receives a `StepContext`, not the full `DurableContext`. A step is the atomic @@ -129,6 +149,12 @@ unit the SDK checkpoints. You cannot call other durable operations such as `step --8<-- "examples/java/patterns/step-design/step-boundary.java" ``` +=== "C#" + + ```csharp + --8<-- "examples/csharp/patterns/step-design/step-boundary.cs" + ``` + ## Handle errors explicitly Code inside a step runs under the step's retry strategy. An unhandled error triggers the @@ -170,6 +196,14 @@ options. --8<-- "examples/java/patterns/step-design/handle-errors-in-step.java" ``` +=== "C#" + + List the retryable exception types in the retry strategy configuration. + + ```csharp + --8<-- "examples/csharp/patterns/step-design/handle-errors-in-step.cs" + ``` + !!! warning Swallowing an exception inside a step hides the failure from the retry strategy and the diff --git a/docs/sdk-reference/configuration/custom-lambda-client.md b/docs/sdk-reference/configuration/custom-lambda-client.md index 7d1e962..ec2e507 100644 --- a/docs/sdk-reference/configuration/custom-lambda-client.md +++ b/docs/sdk-reference/configuration/custom-lambda-client.md @@ -30,3 +30,13 @@ custom client to control the region, retry settings, credentials, or other optio ```java --8<-- "examples/java/configuration/custom-client.java" ``` + +=== "C#" + + Build an `AmazonLambdaConfig` (region, retry settings, and so on), construct an + `AmazonLambdaClient` from it, and pass that `IAmazonLambda` as the fourth argument to + `DurableFunction.WrapAsync`. + + ```csharp + --8<-- "examples/csharp/configuration/custom-client.cs" + ``` diff --git a/docs/sdk-reference/error-handling/errors.md b/docs/sdk-reference/error-handling/errors.md index 348318b..9489005 100644 --- a/docs/sdk-reference/error-handling/errors.md +++ b/docs/sdk-reference/error-handling/errors.md @@ -39,6 +39,12 @@ it to your handler. You can catch it there and handle it as required. --8<-- "examples/java/sdk-reference/error-handling/basic-error-handling.java" ``` +=== "C#" + + ```csharp + --8<-- "examples/csharp/sdk-reference/error-handling/basic-error-handling.cs" + ``` + ## Replay throws the same error When the SDK replays a completed operation from its checkpoint, it returns the @@ -159,6 +165,53 @@ error and the operation details. interrupted before the SDK checkpointed the result. See [Step interrupted](#step-interrupted) below. +=== "C#" + + ```mermaid + graph TD + DEE[DurableExecutionException] + DEE --> NonDeterministicExecutionException + DEE --> StepException + StepException --> StepInterruptedException + DEE --> ChildContextException + DEE --> InvokeException + InvokeException --> InvokeFailedException + InvokeException --> InvokeTimedOutException + InvokeException --> InvokeStoppedException + DEE --> CallbackException + CallbackException --> CallbackFailedException + CallbackException --> CallbackTimeoutException + CallbackException --> CallbackSubmitterException + DEE --> ParallelException + DEE --> MapException + DEE --> WaitForConditionException + OCE["OperationCanceledException (System)"] + ``` + + ```csharp + --8<-- "examples/csharp/sdk-reference/error-handling/exception-hierarchy.cs" + ``` + + `DurableExecutionException` is the base class for all SDK exceptions. + + `StepException` is thrown when a step exhausts all retry attempts. Its `ErrorType`, + `ErrorData`, and `OriginalStackTrace` properties carry the details of the original + exception. `StepInterruptedException` is a subclass thrown when an at-most-once step + started but Lambda was interrupted before the SDK checkpointed the result. See + [Step interrupted](#step-interrupted) below. + + `CallbackException` is the parent class for callback failures. `CallbackFailedException` + reports a failure the external system sent, `CallbackTimeoutException` signals a + callback (or heartbeat) timeout, and `CallbackSubmitterException` wraps a failure raised + inside the submitter delegate. `InvokeException` (and its `InvokeFailedException`, + `InvokeTimedOutException`, and `InvokeStoppedException` subclasses) reports chained-invoke + failures. + + `OperationCanceledException` is a standard .NET exception, not a + `DurableExecutionException`. The SDK surfaces it when the linked `CancellationToken` + trips (workflow shutdown or cooperative short-circuit); it is treated as cancellation, + not a step failure. + ## Validation errors The SDK does not retry validation errors. The SDK throws validation errors when you pass @@ -189,6 +242,15 @@ name. --8<-- "examples/java/sdk-reference/error-handling/validation-error.java" ``` +=== "C#" + + The SDK throws `ArgumentException` (or `ArgumentOutOfRangeException`) for invalid + configuration values. + + ```csharp + --8<-- "examples/csharp/sdk-reference/error-handling/validation-error.cs" + ``` + ## Step interrupted When you [configure a step](../operations/step.md#stepconfig) with at-most-once @@ -220,6 +282,12 @@ succeeded before deciding how to proceed. --8<-- "examples/java/sdk-reference/error-handling/step-interrupted.java" ``` +=== "C#" + + ```csharp + --8<-- "examples/csharp/sdk-reference/error-handling/step-interrupted.cs" + ``` + ## Serialization errors The SDK serializes step results to checkpoint storage. The default serializer handles @@ -254,6 +322,17 @@ exception type when they encounter a value they cannot handle. --8<-- "examples/java/sdk-reference/error-handling/serdes-error.java" ``` +=== "C#" + + The step result is serialized with the `ILambdaSerializer` registered on + `ILambdaContext.Serializer`. When serialization or deserialization fails, the SDK + surfaces the failure as an unhandled exception, returns a `FAILED` status, and does + not retry. + + ```csharp + --8<-- "examples/csharp/sdk-reference/error-handling/serdes-error.cs" + ``` + ## See also - [Retries](retries.md) Configure retry strategies and backoff diff --git a/docs/sdk-reference/error-handling/retries.md b/docs/sdk-reference/error-handling/retries.md index 8a38663..158d685 100644 --- a/docs/sdk-reference/error-handling/retries.md +++ b/docs/sdk-reference/error-handling/retries.md @@ -51,6 +51,15 @@ and linear backoff. --8<-- "examples/java/sdk-reference/error-handling/exponential-backoff.java" ``` +=== "C#" + + Use `RetryStrategy.Exponential(...)` to build an `IRetryStrategy`, then set it on + `StepConfig.RetryStrategy`. + + ```csharp + --8<-- "examples/csharp/sdk-reference/error-handling/exponential-backoff.cs" + ``` + #### RetryStrategyConfig signature === "TypeScript" @@ -111,6 +120,28 @@ and linear backoff. Java does not have built-in error type filtering. Filter by error type manually inside the `RetryStrategy` lambda. See [Retrying specific errors](#retry-only-specific-errors). +=== "C#" + + ```csharp + --8<-- "examples/csharp/sdk-reference/error-handling/retry-strategy-config-signature.cs" + ``` + + **Parameters:** + + - `maxAttempts` (optional) Total attempts including the initial attempt. Default: `3`. + - `initialDelay` (optional) A `TimeSpan`. Must be positive. Default: + `TimeSpan.FromSeconds(5)`. + - `maxDelay` (optional) A `TimeSpan`. Must be positive and `>= initialDelay`. Default: + `TimeSpan.FromSeconds(300)`. + - `backoffRate` (optional) Multiplier applied to the delay on each retry. Must be + `>= 1.0`. Default: `2.0`. + - `jitter` (optional) A `JitterStrategy` value. Default: `JitterStrategy.Full`. + - `retryableExceptions` (optional) An array of exception `Type`s. The SDK retries only + exceptions assignable to one of these types. + - `retryableMessagePatterns` (optional) An array of regex strings matched against the + exception message. When you set neither filter, the SDK retries all exceptions. + When you set both, the SDK retries an exception if it matches either (OR logic). + #### JitterStrategy === "TypeScript" @@ -131,6 +162,12 @@ and linear backoff. --8<-- "examples/java/sdk-reference/error-handling/jitter-strategy-signature.java" ``` +=== "C#" + + ```csharp + --8<-- "examples/csharp/sdk-reference/error-handling/jitter-strategy-signature.cs" + ``` + #### Delay calculation The SDK calculates the delay before each retry using exponential backoff with jitter: @@ -179,6 +216,15 @@ retries rather than the rapid expansion of exponential backoff. --8<-- "examples/java/sdk-reference/error-handling/linear-retry-strategy.java" ``` +=== "C#" + + The .NET SDK ships no built-in linear strategy. Use `RetryStrategy.FromDelegate` and + compute a linearly growing delay yourself, then set it on `StepConfig.RetryStrategy`. + + ```csharp + --8<-- "examples/csharp/sdk-reference/error-handling/linear-retry-strategy.cs" + ``` + #### LinearRetryStrategyConfig signature === "TypeScript" @@ -239,6 +285,19 @@ retries rather than the rapid expansion of exponential backoff. - `jitter` A `JitterStrategy` value. The three-argument overload omits both `maxDelay` and `jitter`. +=== "C#" + + There is no linear config type in .NET. Build the delay from `RetryStrategy.FromDelegate`: + + ```csharp + --8<-- "examples/csharp/sdk-reference/error-handling/linear-retry-strategy-config-signature.cs" + ``` + + The delegate receives the failing exception and the 1-based attempt number and returns + a `RetryDecision`. Return `RetryDecision.DoNotRetry()` to stop, or + `RetryDecision.RetryAfter(delay)` to retry after a computed delay + (`initialDelay + increment * (attempt - 1)`, capped at your own `maxDelay`). + #### Delay calculation Linear backoff calculates the delay before each retry as: @@ -275,6 +334,12 @@ current attempt number after each failure. The attempt number is one-indexed. --8<-- "examples/java/sdk-reference/error-handling/retry-strategy-signature.java" ``` +=== "C#" + + ```csharp + --8<-- "examples/csharp/sdk-reference/error-handling/retry-strategy-signature.cs" + ``` + #### Example === "TypeScript" @@ -302,6 +367,15 @@ current attempt number after each failure. The attempt number is one-indexed. --8<-- "examples/java/sdk-reference/error-handling/custom-retry-strategy.java" ``` +=== "C#" + + Use `RetryStrategy.FromDelegate((error, attempt) => ...)`. Return + `RetryDecision.DoNotRetry()` to stop, or `RetryDecision.RetryAfter(delay)` to retry. + + ```csharp + --8<-- "examples/csharp/sdk-reference/error-handling/custom-retry-strategy.cs" + ``` + ## Retry presets The SDK ships with preset strategies for common cases: @@ -359,6 +433,20 @@ The SDK ships with preset strategies for common cases: **`RetryStrategies.Presets.NO_RETRY`** Fails immediately on first error. +=== "C#" + + ```csharp + --8<-- "examples/csharp/sdk-reference/error-handling/retry-presets.cs" + ``` + + **`RetryStrategy.Default`** 6 attempts, 5s initial delay, 60s max, 2x backoff, full + jitter. + + **`RetryStrategy.Transient`** 3 attempts, 1s initial delay, 5s max, 2x backoff, half + jitter. + + **`RetryStrategy.None`** 1 attempt, fails immediately on error. + ## Retry any durable operation Use the `withRetry` helper to wrap any durable operation in a replay-safe retry loop. @@ -399,6 +487,17 @@ available to `step` to other operations, such as `invoke`, `waitForCallback`, an --8<-- "examples/java/sdk-reference/error-handling/with-retry-helper.java" ``` +=== "C#" + + The .NET SDK has no separate `withRetry` helper, and `InvokeConfig` does not accept a + retry strategy. To retry another durable operation, wrap it in a `step` and set the + retry strategy on `StepConfig.RetryStrategy`. The step retries the wrapped operation + with backoff between failed attempts. + + ```csharp + --8<-- "examples/csharp/sdk-reference/error-handling/with-retry-helper.cs" + ``` + The `withRetry` helper wraps the retry loop in a child context and uses `context.wait` between attempts to suspend the invocation while waiting for the retry interval. The child context, the wait operations, and any operations inside each attempt count toward @@ -434,6 +533,15 @@ You can retry only certain error types and fail immediately on others. --8<-- "examples/java/sdk-reference/error-handling/retry-specific-errors.java" ``` +=== "C#" + + Pass `retryableExceptions` to `RetryStrategy.Exponential(...)` to retry only those + exception types (and their subclasses). Every other exception fails immediately. + + ```csharp + --8<-- "examples/csharp/sdk-reference/error-handling/retry-specific-errors.cs" + ``` + ## See also - [Errors](errors.md) diff --git a/docs/sdk-reference/index.md b/docs/sdk-reference/index.md index 0227ee5..56a183b 100644 --- a/docs/sdk-reference/index.md +++ b/docs/sdk-reference/index.md @@ -42,3 +42,4 @@ Language-specific installation and configuration: - [TypeScript](languages/typescript/index.md) - [Python](languages/python/index.md) - [Java](languages/java/index.md) +- [C#](languages/csharp/index.md) diff --git a/docs/sdk-reference/languages/csharp/index.md b/docs/sdk-reference/languages/csharp/index.md new file mode 100644 index 0000000..da0ceec --- /dev/null +++ b/docs/sdk-reference/languages/csharp/index.md @@ -0,0 +1,196 @@ +# C# SDK + +The .NET SDK (`Amazon.Lambda.DurableExecution`) runs in your Lambda functions and +provides `IDurableContext`, the durable operations, replay-aware logging, and pluggable +serialization. The durable operations and execution model match the other SDKs. What +differs is the host surface: your handler receives the service envelope, you call +`DurableFunction.WrapAsync` yourself, and every operation body takes a `CancellationToken`. + +## Installation + +Add the package to your Lambda function project: + +```console +dotnet add package Amazon.Lambda.DurableExecution +``` + +## Usage + +Your Lambda handler receives a `DurableExecutionInvocationInput` and returns a +`DurableExecutionInvocationOutput`. These are the service envelope: the durable +execution service sends the first, and reads the second to learn whether the workflow +succeeded, failed, or suspended. Delegate to `DurableFunction.WrapAsync`, +which unpacks your input from the envelope, runs your workflow with an `IDurableContext`, +and packs the result back into the envelope. + +```csharp +using Amazon.Lambda.Core; +using Amazon.Lambda.DurableExecution; +using Amazon.Lambda.RuntimeSupport; +using Amazon.Lambda.Serialization.SystemTextJson; + +namespace OrderProcessor; + +public class OrderProcessor +{ + public static async Task Main() + { + var handler = new OrderProcessor(); + 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(Order order, IDurableContext ctx) + { + var reservation = await ctx.StepAsync( + async (_, ct) => await InventoryService.ReserveAsync(order.Items, ct), + name: "reserve-inventory"); + + await ctx.WaitAsync(TimeSpan.FromHours(2), name: "warehouse-processing"); + + var shipment = await ctx.StepAsync( + async (_, ct) => await ShippingService.ShipAsync(reservation, order.Address, ct), + name: "confirm-shipment"); + + return new OrderResult(order.Id, shipment.TrackingNumber); + } +} +``` + +`WrapAsync` has a void overload, `WrapAsync(Func, ...)`, +for workflows that return nothing, and overloads that accept an explicit `IAmazonLambda` +client when you need to customize the client used for the durable-execution service calls +(checkpointing and state hydration). + +Operation names are optional. When you omit `name`, the SDK infers one from the call +site and derives the deterministic operation ID from it. Pass stable, descriptive names +so replay matches new execution to checkpointed state and so operations read clearly in +logs and traces. + +## Programming models + +The managed .NET runtime supports three ways to host a durable function. + +The **executable model** shown above owns its entry point: `Main` builds a +`LambdaBootstrap` and calls `RunAsync()`. + +The **class-library model** drops the `Main` loop. Declare the serializer with an assembly +attribute and deploy with an `Assembly::Namespace.Type::Method` handler string: + +```csharp +using Amazon.Lambda.Core; +using Amazon.Lambda.DurableExecution; +using Amazon.Lambda.Serialization.SystemTextJson; + +[assembly: LambdaSerializer(typeof(DefaultLambdaJsonSerializer))] + +namespace OrderProcessor; + +public class OrderProcessor +{ + public Task Handler( + DurableExecutionInvocationInput input, ILambdaContext context) + => DurableFunction.WrapAsync(Workflow, input, context); + + private async Task Workflow(Order order, IDurableContext ctx) + { + // same workflow body as the executable model + } +} +``` + +**Lambda Annotations** removes the boilerplate. Add +[Amazon.Lambda.Annotations](https://github.com/aws/aws-lambda-dotnet/tree/master/Libraries/src/Amazon.Lambda.Annotations) +and annotate your workflow with `[LambdaFunction]` and `[DurableExecution]`. The source +generator emits the `WrapAsync` wrapper and writes the `DurableConfig` block and +checkpoint-API IAM permissions into the generated `serverless.template`. + +```csharp +using Amazon.Lambda.Annotations; +using Amazon.Lambda.DurableExecution; + +public class OrderProcessor +{ + [LambdaFunction] + [DurableExecution(300, RetentionPeriodInDays = 7)] + public async Task Workflow(Order order, IDurableContext ctx) + { + var reservation = await ctx.StepAsync( + async (_, ct) => await InventoryService.ReserveAsync(order.Items, ct), + name: "reserve-inventory"); + // remaining steps + return new OrderResult(order.Id, reservation.TrackingNumber); + } +} +``` + +Set `[assembly: LambdaGlobalProperties(GenerateMain = true)]` for the executable model, or +omit it for a class library. The generated `serverless.template` handler adapts to each. +The generator validates the `(TInput, IDurableContext) -> Task` or `Task` +signature and Zip packaging, and reports a diagnostic otherwise. + +## Serialization + +`WrapAsync` reads the `ILambdaSerializer` off `ILambdaContext.Serializer` and uses that +single serializer for every payload: your workflow input and output, and each step, +child context, callback, invoke, and condition-check result that gets checkpointed. You +register the serializer once, at the host boundary. In the executable model you pass it +to `HandlerWrapper.GetHandlerWrapper`; in the class-library model you declare it with +`[assembly: LambdaSerializer(...)]`. + +For reflection-based serialization, use `DefaultLambdaJsonSerializer`. For trimmed or +Native AOT functions, register `SourceGeneratorLambdaJsonSerializer` with your +`JsonSerializerContext`. Every operation uses the serializer you register at the host +boundary. + +## Cancellation + +Every user function that `IDurableContext` accepts, `StepAsync`, +`RunInChildContextAsync`, `WaitForCallbackAsync`, `WaitForConditionAsync`, and the +branch and item functions of `ParallelAsync` and `MapAsync`, receives a +`CancellationToken`. The token is a linked source that combines the token you passed to +the operation with an SDK-owned workflow-shutdown signal. The shutdown signal fires when +the workflow is being torn down, for example when a sibling operation suspends or a +checkpoint fails. + +Pass the token to cancellation-aware APIs inside the body so the body unwinds cleanly +when either trigger fires: + +```csharp +var user = await ctx.StepAsync( + async (_, ct) => await httpClient.GetAsync(url, ct), + name: "fetch"); +``` + +When the token fires and an `OperationCanceledException` propagates out of the body, the +SDK treats it as cancellation: it writes no failure checkpoint and consults no retry +strategy. An `OperationCanceledException` thrown for unrelated reasons, when the token +never fired, is a normal step failure that checkpoints and retries per the configured +`RetryStrategy`. The SDK's own writes, checkpoints and the runtime API response, never +observe the shutdown signal, so completed work is never lost to teardown. + +Do not branch workflow logic on `IsCancellationRequested`. Cancellation is a runtime +concern, not a workflow concern, and branching on it makes replay non-deterministic. Do +not catch `OperationCanceledException` and continue. Either let it propagate, or catch +and rethrow. + +## Logging + +`IDurableContext.Logger` is a replay-safe `Microsoft.Extensions.Logging.ILogger`. It +suppresses messages emitted while the workflow re-derives prior operations from +checkpointed state, so a 30-step workflow re-invoked 30 times still emits each line once. +Use it instead of `Console.WriteLine`, which repeats on every replay. Call +`ConfigureLogger(LoggerConfig)` to swap the underlying logger (Serilog, AWS Lambda +Powertools) or set `ModeAware = false` to see every line on every replay while debugging. + +## Source and reference + +The source is in +[aws/aws-lambda-dotnet](https://github.com/aws/aws-lambda-dotnet/tree/master/Libraries/src/Amazon.Lambda.DurableExecution). diff --git a/docs/sdk-reference/languages/index.md b/docs/sdk-reference/languages/index.md index 35ea017..e8dc734 100644 --- a/docs/sdk-reference/languages/index.md +++ b/docs/sdk-reference/languages/index.md @@ -5,3 +5,4 @@ Language-specific installation and setup for the AWS Durable Execution SDK. - [TypeScript](typescript/index.md) - [Python](python/index.md) - [Java](java/index.md) +- [C#](csharp/index.md) diff --git a/docs/sdk-reference/observability/logging.md b/docs/sdk-reference/observability/logging.md index 243bf6b..7faeb78 100644 --- a/docs/sdk-reference/observability/logging.md +++ b/docs/sdk-reference/observability/logging.md @@ -29,6 +29,12 @@ IDs, log sampling, and X-Ray tracing integration. --8<-- "examples/java/sdk-reference/observability/basic-usage.java" ``` +=== "C#" + + ```csharp + --8<-- "examples/csharp/sdk-reference/observability/basic-usage.cs" + ``` + ## Log methods === "TypeScript" @@ -79,6 +85,23 @@ IDs, log sampling, and X-Ray tracing integration. The Java logger uses SLF4J format strings. Pass `{}` placeholders and positional arguments. +=== "C#" + + ```csharp + // context.Logger and stepContext.Logger are Microsoft.Extensions.Logging.ILogger. + // Use the standard ILogger extension methods: + context.Logger.LogTrace(string message, params object?[] args); + context.Logger.LogDebug(string message, params object?[] args); + context.Logger.LogInformation(string message, params object?[] args); + context.Logger.LogWarning(string message, params object?[] args); + context.Logger.LogError(string message, params object?[] args); + context.Logger.LogError(Exception exception, string message, params object?[] args); + context.Logger.LogCritical(string message, params object?[] args); + ``` + + The `ILogger` methods use message templates. Pass `{Name}` placeholders and + positional arguments. + ## Default log format === "TypeScript" @@ -140,6 +163,30 @@ IDs, log sampling, and X-Ray tracing integration. } ``` +=== "C#" + + The SDK writes through `Microsoft.Extensions.Logging.ILogger` and attaches + execution context fields via `ILogger.BeginScope`. When your Lambda function's + log format is set to JSON and your logger provider includes scope fields in its + output, those fields appear as top-level keys in the JSON log record. See + [Using Lambda advanced logging controls with C#](https://docs.aws.amazon.com/lambda/latest/dg/csharp-logging.html#csharp-logging-advanced). + + Field names and structure depend on your logger provider configuration. A JSON + console output might look like: + + ```json + { + "Timestamp": "2025-11-21T18:39:24.743Z", + "LogLevel": "Information", + "Message": "Running step", + "requestId": "72171fff-...", + "executionArn": "arn:aws:lambda:...", + "operationId": "abc123", + "operationName": "process", + "attempt": 1 + } + ``` + ## Execution metadata The SDK automatically enriches log entries with execution metadata. The metadata varies @@ -173,6 +220,11 @@ All DurableContext fields, plus: - `contextId` the operation ID of the child context operation - `contextName` the name given to the child context, when you provide one +=== "C#" + + - `operationId` the deterministic operation ID of the child context operation + - `operationName` the name given to the child context, when you provide one + ### Operation context The following operation contexts support the logger: @@ -212,6 +264,15 @@ instead of DurableContext's logger adds step-specific fields (`operationId`, --8<-- "examples/java/sdk-reference/observability/step-context-logger.java" ``` +=== "C#" + + The C# SDK attaches fields via `ILogger.BeginScope`. Configure your logger + provider to include scope fields in its output. + + ```csharp + --8<-- "examples/csharp/sdk-reference/observability/step-context-logger.cs" + ``` + ## Replay log suppression When the SDK replays, it runs your handler from the start until it reaches the next @@ -251,6 +312,15 @@ Logs inside a retrying step body always emit, because the step has not completed Pass `LoggerConfig.withReplayLogging()` to `DurableConfig` to emit logs on every replay. See [Configure logger](#configure-logger). +=== "C#" + + ```csharp + --8<-- "examples/csharp/sdk-reference/observability/replay-suppression.cs" + ``` + + Call `context.ConfigureLogger(new LoggerConfig { ModeAware = false })` to emit logs + on every replay. See [Configure logger](#configure-logger). + ## Custom logger You can replace the default logger with any logger that implements the SDK's logger @@ -280,6 +350,15 @@ interface. your SLF4J implementation (Logback, Log4j2) or adjust `LoggerConfig` via `DurableConfig`. See [Configure logger](#configure-logger). +=== "C#" + + Any `Microsoft.Extensions.Logging.ILogger` works. Pass it as the `CustomLogger` + on `LoggerConfig` to `context.ConfigureLogger`. + + ```csharp + context.ConfigureLogger(new LoggerConfig { CustomLogger = myLogger }); + ``` + ## Configure logger === "TypeScript" @@ -340,6 +419,31 @@ interface. .build(); ``` +=== "C#" + + Configure the logger on the handler's `IDurableContext`. + + ```csharp + void IDurableContext.ConfigureLogger(LoggerConfig config); + ``` + + **`LoggerConfig`** + + ```csharp + public sealed class LoggerConfig + { + public ILogger? CustomLogger { get; init; } // null = keep current logger + public bool ModeAware { get; init; } = true; + } + ``` + + **`LoggerConfig` parameters:** + + - `CustomLogger` (optional) An `ILogger` to use instead of the SDK default. When + null, the durable context keeps its existing inner logger. + - `ModeAware` (optional) When `true` (default), the SDK suppresses logs during + replay. Set to `false` to emit logs on every replay. + ## Logger interface === "TypeScript" @@ -363,6 +467,12 @@ interface. The Java SDK wraps any SLF4J `Logger` in `DurableLogger`. There is no interface to implement. +=== "C#" + + The C# SDK uses `Microsoft.Extensions.Logging.ILogger` directly. There is no + SDK-specific interface to implement; pass any `ILogger` as the `CustomLogger` on + `LoggerConfig`. + ## Powertools for AWS Lambda [Powertools for AWS Lambda](https://docs.aws.amazon.com/powertools/) provides a @@ -400,6 +510,17 @@ structured logger that works as a drop-in replacement for the SDK's default logg --8<-- "examples/java/sdk-reference/observability/powertools-logger.java" ``` +=== "C#" + + The + [Powertools for AWS Lambda (.NET) logger](https://docs.aws.amazon.com/powertools/dotnet/core/logging/) + is a `Microsoft.Extensions.Logging.ILogger`. Pass it as the `CustomLogger` via + `context.ConfigureLogger`. + + ```csharp + --8<-- "examples/csharp/sdk-reference/observability/powertools-logger.cs" + ``` + ## See also - [Steps](../operations/step.md) diff --git a/docs/sdk-reference/operations/callback.md b/docs/sdk-reference/operations/callback.md index 73b44d2..613bc32 100644 --- a/docs/sdk-reference/operations/callback.md +++ b/docs/sdk-reference/operations/callback.md @@ -49,6 +49,12 @@ You send the callback ID to an external system. The external system uses that ID --8<-- "examples/java/operations/callbacks/basic-example.java" ``` +=== "C#" + + ```csharp + --8<-- "examples/csharp/operations/callbacks/basic-example.cs" + ``` + ### Wait for Callback Wait for Callback combines callback creation, submission, and waiting for the result in @@ -76,6 +82,12 @@ function and then waits for the result. --8<-- "examples/java/operations/callbacks/wait-for-callback-example.java" ``` +=== "C#" + + ```csharp + --8<-- "examples/csharp/operations/callbacks/wait-for-callback-example.cs" + ``` + ### Callback lifecycle A callback spans multiple invocations within a single execution. @@ -166,6 +178,27 @@ sequenceDiagram **Throws:** `CallbackFailedException` if the external system reports failure. `CallbackTimeoutException` if the callback times out. +=== "C#" + + ```csharp + --8<-- "examples/csharp/operations/callbacks/create-callback-signature.cs" + ``` + + **Parameters:** + + - `name` (optional) A name for the callback. Omit it to infer one from the call + site. + - `config` (optional) A `CallbackConfig` object. + - `cancellationToken` (optional) A token to observe for cancellation. + + **Returns:** `Task>`. Access `callback.CallbackId` to get the ID to send + to the external system. Call `callback.GetResultAsync()` to suspend until the external + system calls back. + + **Throws:** `CallbackFailedException` if the external system reports failure. + `CallbackTimeoutException` if the callback times out. Both are thrown from + `GetResultAsync()`, not from `CreateCallbackAsync` itself. + ### Callback Handle The object returned by `createCallback`. @@ -203,6 +236,20 @@ The object returned by `createCallback`. - `get()` Blocks until the external system calls back. Throws `CallbackFailedException` or `CallbackTimeoutException` on failure. +=== "C#" + + ```csharp + public interface ICallback + { + string CallbackId { get; } + Task GetResultAsync(CancellationToken cancellationToken = default); + } + ``` + + - `CallbackId` The unique ID to send to the external system. + - `GetResultAsync()` Suspends until the external system calls back. Throws + `CallbackFailedException` or `CallbackTimeoutException` on failure. + ### CallbackConfig === "TypeScript" @@ -265,6 +312,28 @@ The object returned by `createCallback`. - `serDes` (optional) Custom `SerDes` for the callback result. See [Serialization](../state/serialization.md). +=== "C#" + + ```csharp + public class CallbackConfig + { + public TimeSpan Timeout { get; set; } // default TimeSpan.Zero (no timeout) + public TimeSpan HeartbeatTimeout { get; set; } // default TimeSpan.Zero (no timeout) + } + ``` + + **Parameters:** + + - `Timeout` (optional) Maximum time to wait for the callback result. `TimeSpan.Zero` + (default) disables the overall timeout. Positive values must be at least 1 second. + - `HeartbeatTimeout` (optional) Maximum time between heartbeat signals from the external + system. If the external system does not send a heartbeat within this interval, the + callback fails. `TimeSpan.Zero` (default) disables the heartbeat timeout. + + The callback result is serialized with the `ILambdaSerializer` registered on + `ILambdaContext.Serializer`; there is no per-callback serializer. See + [Serialization](../state/serialization.md). + ### waitForCallback `waitForCallback` is a composite operation that combines `createCallback` with a step @@ -334,6 +403,26 @@ callback ID rather than coding it yourself. `CallbackTimeoutException` if the callback times out. `CallbackSubmitterException` if the submitter step fails after exhausting retries. +=== "C#" + + ```csharp + --8<-- "examples/csharp/operations/callbacks/wait-for-callback-signature.cs" + ``` + + **Parameters:** + + - `submitter` A function that receives the callback ID, an `IWaitForCallbackContext`, + and a `CancellationToken`, and submits the ID to the external system. + - `name` (optional) A name for the operation. Omit it to infer one from the call site. + - `config` (optional) A `WaitForCallbackConfig` object. + - `cancellationToken` (optional) A token to observe for cancellation. + + **Returns:** `Task`. Use `await` to get the callback result. + + **Throws:** `CallbackFailedException` if the external system reports failure. + `CallbackTimeoutException` if the callback times out. `CallbackSubmitterException` if + the submitter step fails after exhausting retries. + ### WaitForCallbackConfig `WaitForCallbackConfig` extends `CallbackConfig` with retry configuration for the @@ -377,6 +466,21 @@ submitter step. strategy. See [Retry strategies](../error-handling/retries.md). - `callbackConfig` (optional) A `CallbackConfig` for the callback wait. +=== "C#" + + ```csharp + public class WaitForCallbackConfig : CallbackConfig + { + public IRetryStrategy? RetryStrategy { get; set; } // null = no retry + } + ``` + + - `RetryStrategy` (optional) An `IRetryStrategy` applied to the submitter step. When + null (default), submitter failures are not retried. Use the `RetryStrategy` factory + (for example `RetryStrategy.Exponential(...)`) to build one. See + [Retry strategies](../error-handling/retries.md). + - Inherits `Timeout` and `HeartbeatTimeout` from `CallbackConfig` for the callback wait. + ## Timeout configuration Set a `timeout` to limit the duration the function waits for the external system. If the @@ -419,6 +523,12 @@ help detect stalled external systems sooner. --8<-- "examples/java/operations/callbacks/callback-config.java" ``` +=== "C#" + + ```csharp + --8<-- "examples/csharp/operations/callbacks/callback-config.cs" + ``` + ## Naming callbacks Name callbacks to make them easier to identify in logs and tests. Use a name that @@ -436,6 +546,10 @@ describes what the callback is waiting for. The name is always the first argument. Pass `null` to omit it. +=== "C#" + + The name is the optional `name` argument. Omit it to infer one from the call site. + ## Send callback results External systems send results back using the diff --git a/docs/sdk-reference/operations/child-context.md b/docs/sdk-reference/operations/child-context.md index 1204221..68c919d 100644 --- a/docs/sdk-reference/operations/child-context.md +++ b/docs/sdk-reference/operations/child-context.md @@ -35,6 +35,12 @@ multiple child contexts concurrently. --8<-- "examples/java/operations/child-contexts/basic-child-context.java" ``` +=== "C#" + + ```csharp + --8<-- "examples/csharp/operations/child-contexts/basic-child-context.cs" + ``` + ## Method signature ### Run in ChildContext @@ -95,6 +101,28 @@ multiple child contexts concurrently. **Throws:** The original exception re-thrown after deserialization if possible, otherwise `ChildContextFailedException`. +=== "C#" + + ```csharp + --8<-- "examples/csharp/operations/child-contexts/run-in-child-context-signature.cs" + ``` + + **Parameters:** + + - `func` A function that receives an `IDurableContext` and a `CancellationToken` + and returns a `Task` (or `Task` for the no-value overload). + - `name` (optional) A name for the child context. Omit it to infer one from the + call site. + - `config` (optional) A `ChildContextConfig` object. + - `cancellationToken` (optional) A token linked with the SDK's workflow-shutdown + signal, forwarded to `func`. + + **Returns:** `Task`, or `Task` for the no-value overload. Use `await` to get the + result. + + **Throws:** `ChildContextException` if the child context function throws. Supply + `ChildContextConfig.ErrorMapping` to remap it into a domain-specific exception. + ### Child Config === "TypeScript" @@ -144,6 +172,23 @@ multiple child contexts concurrently. - `serDes` (optional) Custom `SerDes` for the child context result. See [Serialization](../state/serialization.md). +=== "C#" + + ```csharp + --8<-- "examples/csharp/operations/child-contexts/child-config-signature.cs" + ``` + + **Parameters:** + + - `SubType` (optional) An operation sub-type label for observability. Used + internally by `map` and `parallel`; not needed for direct use. + - `ErrorMapping` (optional) A function that maps exceptions thrown by the child + context (surfaced as `ChildContextException`) into a domain-specific exception. + + The child context result is serialized with the `ILambdaSerializer` registered on + `ILambdaContext.Serializer`; there is no per-context serializer. See + [Serialization](../state/serialization.md). + ## The child context's function The child context function receives a `DurableContext` as its argument. This is the @@ -183,6 +228,15 @@ corrupt execution state and cause non-deterministic behaviour. --8<-- "examples/java/operations/child-contexts/context-function.java" ``` +=== "C#" + + Pass an `async (child, ct) => ...` lambda directly. The function receives its own + `IDurableContext` and must return a `Task`. + + ```csharp + --8<-- "examples/csharp/operations/child-contexts/context-function.cs" + ``` + ### Pass arguments to the child context === "TypeScript" @@ -209,6 +263,14 @@ corrupt execution state and cause non-deterministic behaviour. --8<-- "examples/java/operations/child-contexts/pass-arguments.java" ``` +=== "C#" + + Capture arguments in the closure: + + ```csharp + --8<-- "examples/csharp/operations/child-contexts/pass-arguments.cs" + ``` + ## Naming child contexts Name child contexts to make them easier to identify in logs and tests. @@ -238,6 +300,14 @@ Name child contexts to make them easier to identify in logs and tests. --8<-- "examples/java/operations/child-contexts/named-child-context.java" ``` +=== "C#" + + The name is the optional `name` argument. Omit it to infer one from the call site. + + ```csharp + --8<-- "examples/csharp/operations/child-contexts/named-child-context.cs" + ``` + ## Concurrency !!! note @@ -294,6 +364,14 @@ sequentially in the parent. --8<-- "examples/java/operations/child-contexts/concurrent-child-contexts.java" ``` +=== "C#" + + Don't `await` each child context immediately. Start them all, then await together. + + ```csharp + --8<-- "examples/csharp/operations/child-contexts/concurrent-child-contexts.cs" + ``` + ## Testing The testing SDK records child context operations as `CONTEXT` type operations. Inspect @@ -317,6 +395,12 @@ them to verify the child context ran and produced the expected result. --8<-- "examples/java/operations/child-contexts/test-child-context.java" ``` +=== "C#" + + ```csharp + --8<-- "examples/csharp/operations/child-contexts/test-child-context.cs" + ``` + ## See also - [Steps](step.md) Run a single function with automatic checkpointing diff --git a/docs/sdk-reference/operations/invoke.md b/docs/sdk-reference/operations/invoke.md index 91dff89..542ad49 100644 --- a/docs/sdk-reference/operations/invoke.md +++ b/docs/sdk-reference/operations/invoke.md @@ -72,6 +72,12 @@ sequenceDiagram --8<-- "examples/java/operations/invoke/process-order.java" ``` +=== "C#" + + ```csharp + --8<-- "examples/csharp/operations/invoke/process-order.cs" + ``` + When this function runs: 1. The SDK checkpoints the first invoke operation's start and triggers @@ -146,6 +152,29 @@ When this function runs: - `InvokeTimedOutException` if the invocation times out (Python only, via - `InvokeConfig.timeout`). `InvokeStoppedException` if the invocation was stopped. +=== "C#" + + ```csharp + --8<-- "examples/csharp/operations/invoke/invoke-method-signature.cs" + ``` + + **Parameters:** + + - `functionName` (required) The qualified identifier (version, alias, or `$LATEST`) + of the durable Lambda function to invoke. Unqualified ARNs are rejected. + - `payload` (required) The payload to send to the invoked function. + - `name` (optional) A name for the invoke operation. Omit it to infer one from the + call site. + - `config` (optional) An `InvokeConfig` object. + - `cancellationToken` (optional) A token to observe for cancellation. + + **Returns:** `Task`. Use `await` to get the result. + + **Throws:** `InvokeException` when the invocation reaches a non-success terminal + state: `InvokeFailedException` if the invoked function threw, + `InvokeTimedOutException` on the service-side timeout, and `InvokeStoppedException` + if the invocation was stopped. All extend `InvokeException`. + ### InvokeConfig === "TypeScript" @@ -201,6 +230,23 @@ When this function runs: - `serDes` (optional) Custom `SerDes` for the result. Defaults to JSON serialization. - `tenantId` (optional) Tenant identifier for multi-tenant isolation. +=== "C#" + + ```csharp + public sealed class InvokeConfig + { + public string? TenantId { get; set; } + } + ``` + + **Parameters:** + + - `TenantId` (optional) Tenant identifier for multi-tenant isolation. + + The payload and result are serialized with the `ILambdaSerializer` registered on + `ILambdaContext.Serializer`; there are no per-invoke serializer fields. See + [Serialization](../state/serialization.md). + ## Naming invoke operations Name invoke operations to make them easier to identify in logs and tests. You can use @@ -218,6 +264,10 @@ names to describe what the invocation does rather than which function it calls. The name is always the first argument. Pass `null` to omit it. +=== "C#" + + The name is the optional `name` argument. Omit it to infer one from the call site. + ## Configuration Configure invoke behavior using `InvokeConfig`: @@ -240,6 +290,12 @@ Configure invoke behavior using `InvokeConfig`: --8<-- "examples/java/operations/invoke/invoke-with-config.java" ``` +=== "C#" + + ```csharp + --8<-- "examples/csharp/operations/invoke/invoke-with-config.cs" + ``` + ## Error handling Errors from the invoked function propagate to the calling function. Catch them to handle @@ -263,6 +319,12 @@ failures without letting them terminate the calling function. --8<-- "examples/java/operations/invoke/handle-invocation-error.java" ``` +=== "C#" + + ```csharp + --8<-- "examples/csharp/operations/invoke/handle-invocation-error.cs" + ``` + Java exposes separate exception types for different failure modes: `InvokeFailedException` for function errors, `InvokeTimedOutException` for timeouts (Python only), and `InvokeStoppedException` when the invocation was stopped. All extend diff --git a/docs/sdk-reference/operations/map.md b/docs/sdk-reference/operations/map.md index 89e9098..4c5bd66 100644 --- a/docs/sdk-reference/operations/map.md +++ b/docs/sdk-reference/operations/map.md @@ -29,6 +29,12 @@ Use map to apply the same operation to every item in a collection. Use --8<-- "examples/java/operations/map/simple-map.java" ``` +=== "C#" + + ```csharp + --8<-- "examples/csharp/operations/map/simple-map.cs" + ``` + ## Method signature ### context.map @@ -91,6 +97,28 @@ Use map to apply the same operation to every item in a collection. Use failures. If the SDK cannot reconstruct the original exception, it throws `MapIterationFailedException`. +=== "C#" + + ```csharp + --8<-- "examples/csharp/operations/map/map-signature.cs" + ``` + + **Parameters:** + + - `items` An `IReadOnlyList` of items to process. + - `func` A function called for each item. See [Map Function](#map-function). + - `name` (optional) A name for the map operation. Omit it to infer one from the + call site. + - `config` (optional) A `MapConfig` object. + - `cancellationToken` (optional) A token linked with the SDK's workflow-shutdown + signal, forwarded to `func`. + + **Returns:** `Task>`. Use `await` to get the result. + + **Throws:** Item exceptions are captured in the `IBatchResult`. Inspect `Failed` to + detect failures, or call `ThrowIfError()` to re-throw the first failure. The map + throws `MapException` only when the `CompletionConfig` criteria are violated. + ### Map Function === "TypeScript" @@ -145,6 +173,24 @@ Use map to apply the same operation to every item in a collection. Use **Returns:** `O`. +=== "C#" + + ```csharp + Func, CancellationToken, Task> + ``` + + **Parameters:** + + - `context` The child `IDurableContext` for this item's execution. + - `item` The current item being processed. + - `index` The zero-based index of the item in the input list. + - `items` The full input list. + - `cancellationToken` A token linked with the SDK's workflow-shutdown signal. It is + also tripped when a sibling item satisfies the `CompletionConfig` and the map + short-circuits. + + **Returns:** `Task`. + ### MapConfig === "TypeScript" @@ -210,6 +256,35 @@ Use map to apply the same operation to every item in a collection. Use `CompletionConfig.allCompleted()`. - `serDes` (optional) Custom `SerDes` for item results and the overall result. +=== "C#" + + ```csharp + public sealed class MapConfig + { + public int? MaxConcurrency { get; set; } // null = unlimited + public CompletionConfig CompletionConfig { get; set; } // default AllCompleted() + public NestingType NestingType { get; set; } // default Nested + public Func? ItemNamer { get; set; } + } + ``` + + **Parameters:** + + - `MaxConcurrency` (optional) Maximum items running at once. `null` (default) is + unlimited; must be at least 1 when set. + - `CompletionConfig` (optional) When to stop. Default: `CompletionConfig.AllCompleted()`. + Every item runs regardless of per-item failures. + - `NestingType` (optional) `NestingType.Nested` (default) or `NestingType.Flat`. + `Flat` records per-item results inline on the map operation instead of emitting a + per-item `CONTEXT` checkpoint. + - `ItemNamer` (optional) A function that returns a custom name for each item, given the + item and its zero-based index. Used in logs and traces. When `null` (default), + items are named by index. + + The `BatchResult` and per-item results are serialized with the `ILambdaSerializer` + registered on `ILambdaContext.Serializer`; there is no per-item serializer slot. See + [Serialization](../state/serialization.md). + ### CompletionConfig See [Completion strategies](#completion-strategies) for how `CompletionConfig` affects @@ -246,6 +321,20 @@ execution and the completion status of the result. CompletionConfig.toleratedFailurePercentage(double percentage) ``` +=== "C#" + + Use the static factories or set the properties directly: + + ```csharp + CompletionConfig.AllCompleted() // default for map: every item runs + CompletionConfig.AllSuccessful() // ToleratedFailureCount = 0 + CompletionConfig.FirstSuccessful() // MinSuccessful = 1 + + new CompletionConfig { MinSuccessful = count } + new CompletionConfig { ToleratedFailureCount = count } + new CompletionConfig { ToleratedFailurePercentage = ratio } // ratio in [0.0, 1.0] + ``` + ### Result types === "TypeScript" @@ -390,6 +479,65 @@ execution and the completion status of the result. Items that did not start before the operation reached its completion criteria have status `SKIPPED` (not `STARTED` as in TypeScript and Python). +=== "C#" + + Map returns the same `IBatchResult` type as parallel. It holds per-item + results with individual status, result, and error. + + ```csharp + public interface IBatchResult : IBatchResult + { + IReadOnlyList> All { get; } // one per item, index order + IReadOnlyList> Succeeded { get; } + IReadOnlyList> Failed { get; } + IReadOnlyList> Started { get; } + IReadOnlyList GetResults(); // succeeded results, index order + IReadOnlyList GetErrors(); + void ThrowIfError(); // throws first item error, if any + } + + public interface IBatchResult + { + CompletionReason CompletionReason { get; } + bool HasFailure { get; } + int SuccessCount { get; } + int FailureCount { get; } + int StartedCount { get; } + int TotalCount { get; } + } + ``` + + - **`All`** all `IBatchItem` entries, one per item, in original index order + - **`GetResults()`** results of succeeded items, preserving index order + - **`GetErrors()`** `DurableExecutionException` for failed items, in index order + - **`Succeeded` / `Failed` / `Started`** `IBatchItem` lists filtered by status + - **`SuccessCount` / `FailureCount` / `StartedCount` / `TotalCount`** item counts + - **`CompletionReason`** why the operation completed. See + [Completion strategies](#completion-strategies). + - **`HasFailure`** `true` if any item failed + - **`ThrowIfError()`** throws the first item error, if any + + ```csharp + public interface IBatchItem + { + int Index { get; } + string? Name { get; } + BatchItemStatus Status { get; } + T? Result { get; } // set when Status == Succeeded + DurableExecutionException? Error { get; } // set when Status == Failed + } + + public enum BatchItemStatus + { + Succeeded, + Failed, + Started + } + ``` + + Items that did not start before the operation reached its completion criteria have + status `Started`. + ## The map function The map function can use any durable operation such as steps, waits, or nested map and @@ -414,6 +562,12 @@ state with each other or with the parent context. --8<-- "examples/java/operations/map/map-function.java" ``` +=== "C#" + + ```csharp + --8<-- "examples/csharp/operations/map/map-function.cs" + ``` + ## Naming map operations Name your map operations to make them easier to identify in logs and tests. @@ -451,6 +605,23 @@ Name your map operations to make them easier to identify in logs and tests. The name is always required in Java. The SDK derives each item's name from the operation name: `{name}-iteration-{index}`. +=== "C#" + + ```csharp + --8<-- "examples/csharp/operations/map/named-map.cs" + ``` + + The name is the optional trailing argument. Omit it to infer one from the call site. + + Use `ItemNamer` in `MapConfig` to give each item a custom name: + + ```csharp + var config = new MapConfig + { + ItemNamer = (item, index) => $"order-{((Order)item).Id}", + }; + ``` + ## Configuration Configure map behavior using `MapConfig`: @@ -473,6 +644,12 @@ Configure map behavior using `MapConfig`: --8<-- "examples/java/operations/map/map-config.java" ``` +=== "C#" + + ```csharp + --8<-- "examples/csharp/operations/map/map-config.cs" + ``` + ## Completion strategies `CompletionConfig` controls when the map operation completes. When the operation reaches @@ -526,6 +703,20 @@ abandoned items, but cancellation is not guaranteed. | `toleratedFailureCount(N)` | `FAILURE_TOLERANCE_EXCEEDED` | `ALL_COMPLETED` | | `toleratedFailurePercentage(p)` | `FAILURE_TOLERANCE_EXCEEDED` | `ALL_COMPLETED` | +=== "C#" + + The `IBatchResult`'s `CompletionReason` indicates the stop condition. Items that were + not dispatched before the operation completed have status `Started`. + + | `CompletionConfig` | Early exit `CompletionReason` | Full completion `CompletionReason` | + | ------------------------------------ | ----------------------------- | ---------------------------------- | + | `AllCompleted()` (default) | n/a | `AllCompleted` | + | `AllSuccessful()` | `FailureToleranceExceeded` | `AllCompleted` | + | `FirstSuccessful()` | `MinSuccessfulReached` | `AllCompleted` | + | `MinSuccessful = N` | `MinSuccessfulReached` | `AllCompleted` | + | `ToleratedFailureCount = N` | `FailureToleranceExceeded` | `AllCompleted` | + | `ToleratedFailurePercentage = ratio` | `FailureToleranceExceeded` | `AllCompleted` | + !!! note When using a `minSuccessful` strategy, failures do not trigger early exit. If all items @@ -550,6 +741,12 @@ abandoned items, but cancellation is not guaranteed. --8<-- "examples/java/operations/map/completion-config.java" ``` +=== "C#" + + ```csharp + --8<-- "examples/csharp/operations/map/completion-config.cs" + ``` + ## Error handling When an item throws an error, map captures the error in the result rather than @@ -585,6 +782,16 @@ propagating it immediately. Other items continue running. --8<-- "examples/java/operations/map/error-handling.java" ``` +=== "C#" + + `IBatchResult.HasFailure` is `true` if any item failed. Call `ThrowIfError()` to + propagate the first item error as an exception, or inspect `GetErrors()` (which returns + `DurableExecutionException` objects) to handle errors individually. + + ```csharp + --8<-- "examples/csharp/operations/map/error-handling.cs" + ``` + ## Checkpointing Each item checkpoints its result on completion. Items that have not completed when the @@ -659,6 +866,15 @@ receive no further checkpoint updates. flag. On replay, the SDK re-executes the items to reconstruct the `MapResult` from their individual checkpoints. +=== "C#" + + The `IBatchResult` is reconstructed from the per-item child-context checkpoints. The + aggregate is never stored as a single serialized blob. On replay, the SDK reassembles the + `IBatchResult` from those individual checkpoints without re-executing completed items. + + Each item's result is serialized with the `ILambdaSerializer` registered on + `ILambdaContext.Serializer`; there is no per-item summary generator to configure. + ## Nesting map operations A map function can call `context.map()` or `context.parallel()` to create nested @@ -682,6 +898,12 @@ operations. Each nested map creates its own set of child contexts. --8<-- "examples/java/operations/map/nested-map.java" ``` +=== "C#" + + ```csharp + --8<-- "examples/csharp/operations/map/nested-map.cs" + ``` + ## See also - [Parallel operations](parallel.md) execute different functions concurrently diff --git a/docs/sdk-reference/operations/parallel.md b/docs/sdk-reference/operations/parallel.md index 81941b3..4c92a8f 100644 --- a/docs/sdk-reference/operations/parallel.md +++ b/docs/sdk-reference/operations/parallel.md @@ -29,6 +29,12 @@ execute the same operation concurrently for each item in a collection. --8<-- "examples/java/operations/parallel/simple-parallel.java" ``` +=== "C#" + + ```csharp + --8<-- "examples/csharp/operations/parallel/simple-parallel.cs" + ``` + ## Method signature ### context.parallel @@ -154,6 +160,54 @@ execute the same operation concurrently for each item in a collection. .build() ``` +=== "C#" + + ```csharp + Task> ParallelAsync( + IReadOnlyList>> branches, + string? name = null, + ParallelConfig? config = null, + CancellationToken cancellationToken = default) + + Task> ParallelAsync( + IReadOnlyList> branches, + string? name = null, + ParallelConfig? config = null, + CancellationToken cancellationToken = default) + ``` + + **Parameters:** + + - `branches` The branches to run concurrently, either as plain + `Func>` delegates or as named + `DurableBranch` records. + - `name` (optional) A name for the parallel operation. Omit it to infer one from the + call site. + - `config` (optional) A `ParallelConfig` object. + - `cancellationToken` (optional) A token linked with the SDK's workflow-shutdown + signal, forwarded to each branch. + + **Returns:** `Task>`. Use `await` to get the result. + + **Throws:** Branch exceptions are captured in the `IBatchResult`. A completion-criteria + violation surfaces as `ParallelException` when awaited. Call `ThrowIfError()` to + re-throw the first branch failure explicitly. + + **`DurableBranch`** + + Use the second overload to give each branch a name. `DurableBranch` is a record + pairing a name with the branch function; the name surfaces on `IBatchItem.Name`. + + ```csharp + public sealed record DurableBranch( + string Name, + Func> Func); + ``` + + - `Name` (required) A name for this branch. + - `Func` An async function receiving the branch's `IDurableContext` and a + `CancellationToken`, returning `Task`. + ### ParallelConfig === "TypeScript" @@ -214,6 +268,31 @@ execute the same operation concurrently for each item in a collection. - `completionConfig` (optional) When to stop. Default: `CompletionConfig.allCompleted()`. +=== "C#" + + ```csharp + public sealed class ParallelConfig + { + public int? MaxConcurrency { get; set; } // null = unlimited + public CompletionConfig CompletionConfig { get; set; } // default AllSuccessful() + public NestingType NestingType { get; set; } // default Nested + } + ``` + + **Parameters:** + + - `MaxConcurrency` (optional) Maximum branches running at once. `null` (default) = + unlimited. Must be at least 1 when set. + - `CompletionConfig` (optional) When to stop. Default: + `CompletionConfig.AllSuccessful()`. + - `NestingType` (optional) `NestingType.Nested` (default) or `NestingType.Flat`. + `Flat` records per-branch results inline on the parallel operation instead of + emitting a per-branch `CONTEXT` checkpoint. + + The `IBatchResult` is reconstructed from per-branch checkpoints, which are serialized + with the `ILambdaSerializer` registered on `ILambdaContext.Serializer`; there is no + per-operation serializer slot. + ### CompletionConfig See [Completion strategies](#completion-strategies) for how `CompletionConfig` affects @@ -249,6 +328,23 @@ execution and the completion status of the result. CompletionConfig.toleratedFailureCount(int count) ``` +=== "C#" + + ```csharp + CompletionConfig.AllSuccessful() // tolerate zero failures (the default) + CompletionConfig.AllCompleted() // run every branch; never auto-throws + CompletionConfig.FirstSuccessful() // resolve once one branch succeeds + + // Or set the individual properties directly: + new CompletionConfig { MinSuccessful = 2 } + new CompletionConfig { ToleratedFailureCount = 1 } + new CompletionConfig { ToleratedFailurePercentage = 0.25 } // ratio in [0.0, 1.0] + ``` + + `AllSuccessful()` is equivalent to `ToleratedFailureCount = 0`, and `FirstSuccessful()` + is equivalent to `MinSuccessful = 1`. Multiple criteria combine: the operation resolves + as soon as any criterion is met or violated. + ### Result types === "TypeScript" @@ -413,6 +509,76 @@ execution and the completion status of the result. the `DurableFuture` returned by each `branch()` call and call `.get()` on it after `parallel.get()` returns. Results are available in the order branches were registered. +=== "C#" + + ```csharp + public interface IBatchResult : IBatchResult + { + IReadOnlyList> All { get; } + IReadOnlyList> Succeeded { get; } + IReadOnlyList> Failed { get; } + IReadOnlyList> Started { get; } + IReadOnlyList GetResults(); + IReadOnlyList GetErrors(); + void ThrowIfError(); + } + + public interface IBatchResult + { + CompletionReason CompletionReason { get; } + bool HasFailure { get; } + int SuccessCount { get; } + int FailureCount { get; } + int StartedCount { get; } + int TotalCount { get; } + } + ``` + + - **`All`** all `IBatchItem` entries, one per branch, in original index order. Iterate + with `item.Index` for branch-indexed access when some branches fail. + - **`Succeeded` / `Failed` / `Started`** `IBatchItem` lists filtered by status, in + original index order + - **`GetResults()`** results of succeeded branches, preserving input order. Skips + failed and started items, so it never throws on partial-failure batches + - **`GetErrors()`** `DurableExecutionException` list for failed branches + - **`SuccessCount` / `FailureCount` / `StartedCount` / `TotalCount`** branch counts + - **`HasFailure`** `true` if any branch failed + - **`CompletionReason`** why the operation completed. See + [Completion strategies](#completion-strategies). + - **`ThrowIfError()`** throws the first failed branch's `Error`, if any + + ```csharp + public interface IBatchItem + { + int Index { get; } + string? Name { get; } + BatchItemStatus Status { get; } + T? Result { get; } // set when Status is Succeeded + DurableExecutionException? Error { get; } // set when Status is Failed + } + + public enum BatchItemStatus + { + Succeeded, + Failed, + Started + } + + public enum CompletionReason + { + AllCompleted, + MinSuccessfulReached, + FailureToleranceExceeded + } + ``` + + - **`Index`** zero-based position of this branch in the input list + - **`Name`** the branch name (from `DurableBranch.Name`), if any + - **`Status`** `Succeeded`, `Failed`, or `Started` (not dispatched before the operation + resolved) + - **`Result`** the branch return value, present when `Status` is `Succeeded` + - **`Error`** the captured error, present when `Status` is `Failed` + ## Branch functions Each branch receives a `DurableContext` and can use any durable operation such as steps, @@ -448,6 +614,16 @@ the parent context. --8<-- "examples/java/operations/parallel/named-branches.java" ``` +=== "C#" + + A branch is a plain `Func>`, or a + `DurableBranch` record to give it a name. Use the named overload to surface a name + on `IBatchItem.Name` without defining a named method. + + ```csharp + --8<-- "examples/csharp/operations/parallel/named-branches.cs" + ``` + ### Pass arguments to branches === "TypeScript" @@ -475,6 +651,15 @@ the parent context. --8<-- "examples/java/operations/parallel/pass-arguments.java" ``` +=== "C#" + + Capture arguments in the closure. Copy the loop variable to a local so each branch + captures its own value. + + ```csharp + --8<-- "examples/csharp/operations/parallel/pass-arguments.cs" + ``` + ## Naming parallel operations Name your parallel operations to make them easier to identify in logs and tests. @@ -492,6 +677,11 @@ Name your parallel operations to make them easier to identify in logs and tests. The name is always required. Each `branch()` call also requires a name. Pass `null` to omit it. +=== "C#" + + The name is the optional `name` argument. Omit it to infer one from the call site. Use + the `DurableBranch` overload to name each branch. + ## Configuration Configure parallel behavior using `ParallelConfig`: @@ -514,6 +704,12 @@ Configure parallel behavior using `ParallelConfig`: --8<-- "examples/java/operations/parallel/parallel-config.java" ``` +=== "C#" + + ```csharp + --8<-- "examples/csharp/operations/parallel/parallel-config.cs" + ``` + ## Completion strategies `CompletionConfig` controls when the parallel operation completes. When the operation @@ -574,6 +770,24 @@ ongoing work in abandoned branches, but cancellation is not guaranteed. `ParallelConfig` in Java does not support `toleratedFailurePercentage`. Use `toleratedFailureCount` instead. +=== "C#" + + The `IBatchResult`'s `CompletionReason` indicates the stop condition with which the + parallel operation completed. Branches that were not dispatched before the operation + resolved appear in `result.All` with status `Started`. + + | `CompletionConfig` | Early exit `CompletionReason` | Full completion `CompletionReason` | + | ---------------------------------- | ----------------------------- | ---------------------------------- | + | `AllSuccessful()` (default) | `FailureToleranceExceeded` | `AllCompleted` | + | `AllCompleted()` | n/a | `AllCompleted` | + | `FirstSuccessful()` | `MinSuccessfulReached` | `AllCompleted` | + | `MinSuccessful = N` | `MinSuccessfulReached` | `AllCompleted` | + | `ToleratedFailureCount = N` | `FailureToleranceExceeded` | `AllCompleted` | + | `ToleratedFailurePercentage = N` | `FailureToleranceExceeded` | `AllCompleted` | + + When the operation resolves with `FailureToleranceExceeded`, awaiting it throws + `ParallelException`, which carries the aggregate result on `Result`. + !!! note When using a `minSuccessful` strategy, failures do not trigger early exit. If all @@ -598,6 +812,12 @@ ongoing work in abandoned branches, but cancellation is not guaranteed. --8<-- "examples/java/operations/parallel/completion-config.java" ``` +=== "C#" + + ```csharp + --8<-- "examples/csharp/operations/parallel/completion-config.cs" + ``` + ## Error handling When a branch throws an error, parallel captures the error in the result rather than @@ -634,6 +854,16 @@ propagating it immediately. Other branches continue running. --8<-- "examples/java/operations/parallel/error-handling.java" ``` +=== "C#" + + `IBatchResult.HasFailure` is `true` if any branch failed. Call `ThrowIfError()` to + propagate the first branch error as a `DurableExecutionException`, or inspect + `GetErrors()` to handle errors individually. + + ```csharp + --8<-- "examples/csharp/operations/parallel/error-handling.cs" + ``` + ## Checkpointing Each branch checkpoints its result on completion. Branches that have not completed yet @@ -706,6 +936,13 @@ and will receive no further checkpoint updates. re-executes the branches to reconstruct the `ParallelResult` from their individual checkpoints. +=== "C#" + + Each branch checkpoints its result as it completes. The `IBatchResult` is reconstructed + from those per-branch checkpoints rather than stored as a single aggregate blob, so the + SDK reassembles it on replay from the individual branch results. Per-branch payloads are + serialized with the `ILambdaSerializer` registered on `ILambdaContext.Serializer`. + ## Nesting parallel operations A branch function can call `context.parallel()` to create nested parallel operations. @@ -729,6 +966,12 @@ Each nested parallel creates its own set of child contexts. --8<-- "examples/java/operations/parallel/nested-parallel.java" ``` +=== "C#" + + ```csharp + --8<-- "examples/csharp/operations/parallel/nested-parallel.cs" + ``` + ## See also - [Map operations](map.md) run the same function concurrently on a collection diff --git a/docs/sdk-reference/operations/step.md b/docs/sdk-reference/operations/step.md index df0ac0b..59085a6 100644 --- a/docs/sdk-reference/operations/step.md +++ b/docs/sdk-reference/operations/step.md @@ -37,6 +37,12 @@ The step will checkpoint the last error after exhausting all retry attempts. --8<-- "examples/java/operations/steps/add-numbers.java" ``` +=== "C#" + + ```csharp + --8<-- "examples/csharp/operations/steps/add-numbers.cs" + ``` + ## Method signature ### step @@ -97,6 +103,28 @@ The step will checkpoint the last error after exhausting all retry attempts. otherwise `StepFailedException`. `StepInterruptedException` if an at-most-once step was interrupted. +=== "C#" + + ```csharp + --8<-- "examples/csharp/operations/steps/step-signature.cs" + ``` + + **Parameters:** + + - `func` A function that receives an `IStepContext` and a `CancellationToken` and + returns a `Task` (or `Task` for the no-value overload). + - `name` (optional) A name for the step. Omit it to infer one from the call site. + - `config` (optional) A `StepConfig` object. + - `cancellationToken` (optional) A token linked with the SDK's workflow-shutdown + signal, forwarded to `func`. + + **Returns:** `Task`, or `Task` for the no-value overload. Use `await` to get the + result. + + **Throws:** The exception thrown by the step body after retries are exhausted. An + `OperationCanceledException` that propagates via the linked token is treated as + cancellation, not a step failure. See [Cancellation](../languages/csharp/index.md#cancellation). + ### StepConfig === "TypeScript" @@ -159,6 +187,29 @@ The step will checkpoint the last error after exhausting all retry attempts. - `serDes` (optional) Custom `SerDes` for the step result. See [Serialization](../state/serialization.md). +=== "C#" + + ```csharp + public sealed class StepConfig + { + public IRetryStrategy? RetryStrategy { get; set; } // null = no retry + public StepSemantics Semantics { get; set; } // default AtLeastOncePerRetry + } + ``` + + **Parameters:** + + - `RetryStrategy` (optional) An `IRetryStrategy`. When null (default), failures are + not retried. Use the `RetryStrategy` factory (for example + `RetryStrategy.Exponential(...)`) to build one. See + [Retry strategies](../error-handling/retries.md). + - `Semantics` (optional) `StepSemantics.AtLeastOncePerRetry` (default) or + `StepSemantics.AtMostOncePerRetry`. + + The step result is serialized with the `ILambdaSerializer` registered on + `ILambdaContext.Serializer`; there is no per-step serializer. See + [Serialization](../state/serialization.md). + ### StepContext === "TypeScript" @@ -198,6 +249,22 @@ The step will checkpoint the last error after exhausting all retry attempts. - `getAttempt()` The current retry attempt number (0-based). - `isReplaying()` Whether the function is currently replaying from a checkpoint. +=== "C#" + + ```csharp + public interface IStepContext + { + ILogger Logger { get; } + int AttemptNumber { get; } // current retry attempt, 1-based + string OperationId { get; } + } + ``` + + - `Logger` A logger enriched with execution context metadata. See + [Logging](../observability/logging.md). + - `AttemptNumber` The current retry attempt number (1-based). + - `OperationId` The deterministic operation ID for this step. + ### StepSemantics === "TypeScript" @@ -245,6 +312,22 @@ The step will checkpoint the last error after exhausting all retry attempts. function replays before the result is checkpointed, the SDK skips the step and throws `StepInterruptedException`. Use for operations with side effects. +=== "C#" + + ```csharp + public enum StepSemantics + { + AtLeastOncePerRetry, + AtMostOncePerRetry + } + ``` + + - `AtLeastOncePerRetry` (default) Re-executes the step if the Lambda is re-invoked + before the result is checkpointed. Safe for idempotent operations. + - `AtMostOncePerRetry` Executes the step at most once per retry attempt. If the Lambda + is re-invoked before the result is checkpointed, the SDK skips re-execution. Use + for operations with side effects. + ## The Step's function A step function receives a `StepContext` as its first parameter. @@ -277,6 +360,15 @@ A step function receives a `StepContext` as its first parameter. --8<-- "examples/java/operations/steps/validate-order.java" ``` +=== "C#" + + Pass an `async (ctx, ct) => ...` lambda directly. Step bodies are async; `await` + the result of `ctx.StepAsync()`. + + ```csharp + --8<-- "examples/csharp/operations/steps/validate-order.cs" + ``` + ### Anonymous step functions You can also use inline lambdas. @@ -302,6 +394,12 @@ You can also use inline lambdas. --8<-- "examples/java/operations/steps/lambda-step-no-name.java" ``` +=== "C#" + + ```csharp + --8<-- "examples/csharp/operations/steps/lambda-step-no-name.cs" + ``` + ### Pass arguments to the step function === "TypeScript" @@ -328,6 +426,14 @@ You can also use inline lambdas. --8<-- "examples/java/operations/steps/multi-argument-step.java" ``` +=== "C#" + + Capture arguments in the closure: + + ```csharp + --8<-- "examples/csharp/operations/steps/multi-argument-step.cs" + ``` + ## Naming steps Name your steps so they're easy to identify in logs and tests. Use descriptive names @@ -347,6 +453,10 @@ debugging easier. The name is always the first argument. Pass `null` for no name. +=== "C#" + + The name is the optional `name` argument. Omit it to infer one from the call site. + ## Configuration Configure step behavior using `StepConfig`: @@ -369,6 +479,12 @@ Configure step behavior using `StepConfig`: --8<-- "examples/java/operations/steps/process-data.java" ``` +=== "C#" + + ```csharp + --8<-- "examples/csharp/operations/steps/process-data.cs" + ``` + ## Pass data between steps Pass data between steps through return values. Do not use shared variables or closure @@ -395,6 +511,12 @@ lost. --8<-- "examples/java/operations/steps/passing-data-wrong.java" ``` +=== "C#" + + ```csharp + --8<-- "examples/csharp/operations/steps/passing-data-wrong.cs" + ``` + ### correct way to pass data between steps === "TypeScript" @@ -415,6 +537,12 @@ lost. --8<-- "examples/java/operations/steps/passing-data-correct.java" ``` +=== "C#" + + ```csharp + --8<-- "examples/csharp/operations/steps/passing-data-correct.cs" + ``` + ## Nesting steps You cannot nest steps. Do not attempt to invoke another step from inside a step. If you diff --git a/docs/sdk-reference/operations/wait-for-condition.md b/docs/sdk-reference/operations/wait-for-condition.md index a36ca43..70d9aef 100644 --- a/docs/sdk-reference/operations/wait-for-condition.md +++ b/docs/sdk-reference/operations/wait-for-condition.md @@ -41,6 +41,12 @@ Here's a simple example that polls until a job completes: --8<-- "examples/java/core/wait/wait-for-condition.java" ``` +=== "C#" + + ```csharp + --8<-- "examples/csharp/core/wait/wait-for-condition.cs" + ``` + ```mermaid graph TD A[Start with initial state] --> B["check (step)"] @@ -70,6 +76,12 @@ graph TD --8<-- "examples/java/core/wait/wait-for-condition-signature.java" ``` +=== "C#" + + ```csharp + --8<-- "examples/csharp/core/wait/wait-for-condition-signature.cs" + ``` + **Parameters:** - `name` (optional) - Only used for display, debugging and testing. @@ -109,6 +121,12 @@ provide. --8<-- "examples/java/core/wait/wait-strategy-signature.java" ``` +=== "C#" + + ```csharp + --8<-- "examples/csharp/core/wait/wait-strategy-signature.cs" + ``` + ### Custom strategy Write your own strategy function for full control over polling behavior. The function @@ -136,6 +154,17 @@ continue polling or not. --8<-- "examples/java/core/wait/custom-strategy.java" ``` +=== "C#" + + Implement `IWaitStrategy` or wrap a delegate with + `WaitStrategy.FromDelegate`. The strategy returns a `WaitDecision`: + `WaitDecision.Stop()` to stop polling or `WaitDecision.ContinueAfter(delay)` + to poll again. + + ```csharp + --8<-- "examples/csharp/core/wait/custom-strategy.cs" + ``` + To stop polling and signal an error, throw an exception from the strategy or the check function. @@ -163,6 +192,12 @@ re-use common wait strategy logic without having to code your own function. --8<-- "examples/java/core/wait/strategy-helper.java" ``` +=== "C#" + + ```csharp + --8<-- "examples/csharp/core/wait/strategy-helper.cs" + ``` + **Helper parameters:** - `shouldContinuePolling` - Predicate that returns `true` to keep polling or `false` to diff --git a/docs/sdk-reference/operations/wait.md b/docs/sdk-reference/operations/wait.md index aa96ec3..4aa163c 100644 --- a/docs/sdk-reference/operations/wait.md +++ b/docs/sdk-reference/operations/wait.md @@ -57,6 +57,12 @@ Here's a simple example of using a wait operation: --8<-- "examples/java/core/wait/basic-wait.java" ``` +=== "C#" + + ```csharp + --8<-- "examples/csharp/core/wait/basic-wait.cs" + ``` + When this function runs: 1. The SDK checkpoints the wait operation with a scheduled end time @@ -86,6 +92,14 @@ When this function runs: Set `name` to `null` to omit it. +=== "C#" + + ```csharp + --8<-- "examples/csharp/core/wait/wait-signature.cs" + ``` + + Omit `name` to infer one from the call site. + **Parameters:** - `duration` (required) - How long to wait. Must be at least 1 second. See @@ -108,6 +122,10 @@ When this function runs: `DurableFuture` (async) +=== "C#" + + `Task` + **Raises/Throws:** === "TypeScript" @@ -122,6 +140,10 @@ When this function runs: `IllegalArgumentException` +=== "C#" + + `ArgumentOutOfRangeException` + ## Duration ### Duration signature @@ -144,6 +166,12 @@ When this function runs: --8<-- "examples/java/core/wait/duration-signature.java" ``` +=== "C#" + + ```csharp + --8<-- "examples/csharp/core/wait/duration-signature.cs" + ``` + ### Duration usage === "TypeScript" @@ -164,6 +192,12 @@ When this function runs: --8<-- "examples/java/core/wait/duration-helpers.java" ``` +=== "C#" + + ```csharp + --8<-- "examples/csharp/core/wait/duration-helpers.cs" + ``` + ## Named wait operations Name wait operations to make them easier to identify in logs and tests. @@ -186,6 +220,12 @@ Name wait operations to make them easier to identify in logs and tests. --8<-- "examples/java/core/wait/named-wait.java" ``` +=== "C#" + + ```csharp + --8<-- "examples/csharp/core/wait/named-wait.cs" + ``` + ## Scheduled end timestamp Each wait operation has a scheduled end timestamp that indicates when it completes. This @@ -236,6 +276,15 @@ while a step runs in parallel. --8<-- "examples/java/core/wait/async-wait.java" ``` +=== "C#" + + Don't `await` the wait immediately. Capture the `Task` and use `Task.WhenAll` to run + it alongside other operations. + + ```csharp + --8<-- "examples/csharp/core/wait/async-wait.cs" + ``` + ## Testing You can verify wait operations in your tests by inspecting the operations list: @@ -258,6 +307,12 @@ You can verify wait operations in your tests by inspecting the operations list: --8<-- "examples/java/core/wait/test-multiple-waits.java" ``` +=== "C#" + + ```csharp + --8<-- "examples/csharp/core/wait/test-multiple-waits.cs" + ``` + ## See also - [Steps](step.md) - Execute business logic with automatic checkpointing diff --git a/docs/sdk-reference/state/serialization.md b/docs/sdk-reference/state/serialization.md index d1ef899..c9a0142 100644 --- a/docs/sdk-reference/state/serialization.md +++ b/docs/sdk-reference/state/serialization.md @@ -32,6 +32,12 @@ the step result automatically. --8<-- "examples/java/sdk-reference/serialization/Walkthrough.java" ``` +=== "C#" + + ```csharp + --8<-- "examples/csharp/sdk-reference/serialization/Walkthrough.cs" + ``` + ## Lambda handler serialization The Durable Execution SDK SerDes only applies to durable operation results. It does not @@ -58,6 +64,12 @@ separately. configuration applies to both step results and handler return values when you use `DurableHandler`. +=== "C#" + + The registered `ILambdaSerializer` serializes everything: durable operation results + and the handler's final return value. There is no separate serializer for operation + results, so the same configuration always applies to both. + ## Default serialization Each SDK uses a default SerDes when you do not provide one. @@ -85,6 +97,15 @@ Each SDK uses a default SerDes when you do not provide one. Pass a custom `ObjectMapper` to the `JacksonSerDes` constructor to override the default configuration. +=== "C#" + + There is no per-operation default SerDes. Register a single `ILambdaSerializer` at the + host boundary and the SDK uses it for every durable operation result. + + Use `DefaultLambdaJsonSerializer` (from `Amazon.Lambda.Serialization.SystemTextJson`) + for reflection-based serialization. For AOT or trim-friendly functions, use + `SourceGeneratorLambdaJsonSerializer` and register your `JsonSerializerContext`. + ## SerDes interface definition === "TypeScript" @@ -140,6 +161,25 @@ Each SDK uses a default SerDes when you do not provide one. Use `TypeToken` to capture generic type information that Java erases at runtime. For example: `new TypeToken>() {}`. +=== "C#" + + .NET has no per-operation SerDes interface. Serialization is controlled by the single + `ILambdaSerializer` registered on `ILambdaContext.Serializer`. + + ```csharp + --8<-- "examples/csharp/sdk-reference/serialization/SerdesInterface.cs" + ``` + + **Methods:** + + - `Serialize(T response, Stream responseStream)` Writes the serialized form of + `response` to `responseStream`. + - `Deserialize(Stream requestStream)` Reads `requestStream` and returns the + deserialized value of type `T`. + + The same `ILambdaSerializer` handles every durable operation result and the handler's + return value. + ## Custom SerDes example Implement the SerDes interface when the default cannot handle your types, or when you @@ -163,6 +203,15 @@ need special behavior such as encryption or compression. --8<-- "examples/java/sdk-reference/serialization/OrderSerDes.java" ``` +=== "C#" + + Implement `ILambdaSerializer` and register it at the host boundary. The custom + serializer applies to every durable operation result, not a single operation. + + ```csharp + --8<-- "examples/csharp/sdk-reference/serialization/OrderSerDes.cs" + ``` + ## Custom SerDes on durable operations Pass a SerDes instance in the configuration object for the operation you want to @@ -189,6 +238,16 @@ same handler continue to use the default. --8<-- "examples/java/sdk-reference/serialization/StepConfigExample.java" ``` +=== "C#" + + `StepConfig` does not expose a serializer. The step result is serialized with the + `ILambdaSerializer` registered at the host boundary. Register a custom + `ILambdaSerializer` there to change how step results are serialized. + + ```csharp + --8<-- "examples/csharp/sdk-reference/serialization/StepConfigExample.cs" + ``` + ### CallbackConfig The callback SerDes controls how the SDK deserializes the payload that an external @@ -215,6 +274,16 @@ system sends when it completes the callback. --8<-- "examples/java/sdk-reference/serialization/CallbackConfigExample.java" ``` +=== "C#" + + `CallbackConfig` does not expose a serializer. The payload the external system delivers + is deserialized with the `ILambdaSerializer` registered at the host boundary. Register a + custom `ILambdaSerializer` there to change how the callback payload is deserialized. + + ```csharp + --8<-- "examples/csharp/sdk-reference/serialization/CallbackConfigExample.cs" + ``` + ### MapConfig & ParallelConfig Map and parallel perations support two SerDes fields that apply at different levels. @@ -249,6 +318,16 @@ Map and parallel perations support two SerDes fields that apply at different lev - `serDes` applies to each item result. - Java `ParallelConfig` does not have a `serDes` field. +=== "C#" + + `MapConfig` does not expose a serializer, and there is no separate item-level + serializer. Each item result is serialized with the `ILambdaSerializer` registered at + the host boundary. `ParallelConfig` likewise has no serializer field. + + ```csharp + --8<-- "examples/csharp/sdk-reference/serialization/MapConfigExample.cs" + ``` + ## Built-in SerDes helpers === "TypeScript" @@ -281,6 +360,16 @@ Map and parallel perations support two SerDes fields that apply at different lev --8<-- "examples/java/sdk-reference/serialization/PassThroughSerdesExample.java" ``` +=== "C#" + + `DefaultLambdaJsonSerializer` handles class and record instances via System.Text.Json. + You can create a pass-through serializer that stores string values as-is by implementing + `ILambdaSerializer` and registering it at the host boundary: + + ```csharp + --8<-- "examples/csharp/sdk-reference/serialization/PassThroughSerdesExample.cs" + ``` + ## FileSystem serdes The FileSystem serdes stores data on the filesystem and keeps a file pointer in @@ -326,6 +415,12 @@ that every Lambda execution environment can read. In AWS Lambda, this means: Coming soon. See [aws-durable-execution-sdk-java#463](https://github.com/aws/aws-durable-execution-sdk-java/issues/463). +=== "C#" + + Not available. The .NET SDK has no FileSystem serdes. To keep large payloads out of the + checkpoint, write them to a persistent store (such as Amazon S3) inside a step and + checkpoint a pointer instead of the payload. + ### createFileSystemSerdes signature === "TypeScript" @@ -351,6 +446,10 @@ that every Lambda execution environment can read. In AWS Lambda, this means: Coming soon. +=== "C#" + + Not available. + ### FileSystemSerdesConfig === "TypeScript" @@ -378,6 +477,10 @@ that every Lambda execution environment can read. In AWS Lambda, this means: Coming soon. +=== "C#" + + Not available. + ### Storage modes The `storageMode` enumerated field controls when the SDK writes to the filesystem. @@ -404,6 +507,10 @@ execution checkpoint size limit. See Coming soon. +=== "C#" + + Not available. + ### Path encoding The `pathEncoding` field controls how the durable execution ARN and the entity ID @@ -435,6 +542,10 @@ enough to exceed the name-length limit. Coming soon. +=== "C#" + + Not available. + ### Preview and PII masking When the FileSystem serdes writes to a file, the checkpoint envelope only contains @@ -484,6 +595,10 @@ default. Coming soon. +=== "C#" + + Not available. + ### Set as the default for the handler When you want every step result, child-context result, invoke result, and @@ -506,6 +621,11 @@ once with `configureSerdes`. Coming soon. See [aws-durable-execution-sdk-java#463](https://github.com/aws/aws-durable-execution-sdk-java/issues/463). +=== "C#" + + Not available. The single `ILambdaSerializer` you register at the host boundary is + already applied to every durable operation result in the handler. + ## Serialization errors When serialization or deserialization fails, each SDK raises or throws a specific diff --git a/docs/testing/api-reference.md b/docs/testing/api-reference.md index b692189..9228b36 100644 --- a/docs/testing/api-reference.md +++ b/docs/testing/api-reference.md @@ -79,6 +79,33 @@ CI. - `handler` (optional) A `DurableHandler` instance. The runner extracts its configuration automatically. +=== "C#" + + The local runner type is `DurableTestRunner`. Construct it with the + workflow delegate and dispose it with `await using`: + + ```csharp + DurableTestRunner( + Func> handler, + TestRunnerOptions? options = null) + ``` + + ```csharp + await using var runner = new DurableTestRunner( + Workflow, + new TestRunnerOptions { SkipTime = true }); + ``` + + There is no static setup/teardown. The `await using` declaration and `DisposeAsync()` + replace the TypeScript `setupTestEnvironment`/`teardownTestEnvironment` lifecycle. + + **Constructor parameters:** + + - `handler` (required) A `Func>`, either a method + group or an inline `(input, ctx) => ...` async lambda. + - `options` (optional) A [`TestRunnerOptions`](#localdurabletestrunnersetupparameters) + record. Defaults to a new instance with `SkipTime = true`. + ### Run the handler === "TypeScript" @@ -139,6 +166,32 @@ CI. **Returns:** `TestResult` +=== "C#" + + ```csharp + Task> RunAsync( + TInput input, + TimeSpan? timeout = null, + CancellationToken cancellationToken = default) + ``` + + `RunAsync` drives the full replay loop until the workflow reaches a terminal state. It + throws `InvalidOperationException` if the workflow suspends on a callback. Use the + `StartAsync` + `WaitForCallbackAsync` two-call pattern for callback workflows. + + ```csharp + TestResult result = await runner.RunAsync(null); + ``` + + **Parameters:** + + - `input` (required) The handler input. + - `timeout` (optional) Wall-clock timeout for the call. Defaults to + `TestRunnerOptions.DefaultTimeout` (30 seconds). + - `cancellationToken` (optional) A `CancellationToken`. + + **Returns:** `Task>` + ### Run asynchronously === "TypeScript" @@ -167,6 +220,25 @@ CI. `runUntilComplete()` drives the full loop. The cloud runner exposes `startAsync()`, see [CloudDurableTestRunner](#clouddurabletestrunner). +=== "C#" + + The local runner supports the async pattern, primarily for callback workflows: + + ```csharp + // Start the workflow and return the durable execution ARN + Task StartAsync( + TInput input, TimeSpan? timeout = null, CancellationToken cancellationToken = default) + + // Drive a started workflow to a terminal state + Task> WaitForResultAsync( + string durableExecutionArn, TimeSpan? timeout = null, CancellationToken cancellationToken = default) + ``` + + `StartAsync` drives the workflow to its first suspension point and returns the ARN. + `WaitForResultAsync` must be preceded by a `StartAsync` on the same runner; it throws + `InvalidOperationException` otherwise. Use these together with `WaitForCallbackAsync` + and the `SendCallback*` methods to drive callback workflows. + ### Inspect operations === "TypeScript" @@ -207,6 +279,23 @@ CI. **Returns:** `TestOperation`, or `null` if not found. +=== "C#" + + The .NET runner does not expose operation lookups; inspect operations through the + returned [`TestResult`](#testresult) instead: + + ```csharp + TestStep GetStep(string name) // throws if not found + TestStep? FindStep(string name) // null if not found + IReadOnlyList GetSteps(string name) + TestStep GetStepById(string operationId) + IReadOnlyList GetStepsByStatus(string status) + IReadOnlyList GetChildren(TestStep parent) + ``` + + `result.Steps` also exposes the full list of recorded operations (excluding the + top-level execution). See [TestResult](#testresult). + ### Drive callbacks === "TypeScript" @@ -247,6 +336,33 @@ CI. void timeoutCallback(String callbackId) ``` +=== "C#" + + Callbacks are driven from the runner. After `StartAsync`, wait for the callback ID, then + send success, failure, or a heartbeat: + + ```csharp + // Wait for a pending callback and return its ID + Task WaitForCallbackAsync( + string durableExecutionArn, string? name = null, + TimeSpan? timeout = null, CancellationToken cancellationToken = default) + + // Complete a callback with a success result + Task SendCallbackSuccessAsync( + string callbackId, TResult result, CancellationToken cancellationToken = default) + + // Fail a callback + Task SendCallbackFailureAsync( + string callbackId, ErrorObject? error = null, CancellationToken cancellationToken = default) + + // Keep a callback alive + Task SendCallbackHeartbeatAsync( + string callbackId, CancellationToken cancellationToken = default) + ``` + + There is no separate "time out" method: to simulate a timeout, fail the callback with an + `ErrorObject` or let the configured callback timeout elapse. + ### Drive chained invokes === "TypeScript" @@ -275,6 +391,13 @@ CI. void stopChainedInvoke(String name, ErrorObject error) ``` +=== "C#" + + The .NET SDK does not expose per-invoke completion methods. Instead, register the + invoked function's handler on the runner and let the real invoke path run against it. See + [Register mock handlers for invoke](#register-mock-handlers-for-invoke). To make an + invoke fail, throw from the registered handler. + ### Register mock handlers for invoke === "TypeScript" @@ -297,6 +420,22 @@ CI. Not applicable. +=== "C#" + + ```csharp + // Register a durable sibling that the workflow invokes with context.InvokeAsync() + DurableTestRunner RegisterDurableFunction( + string functionNameOrArn, + Func> handler) + + // Register a plain (non-durable) Lambda sibling, also invoked with context.InvokeAsync() + DurableTestRunner RegisterFunction( + string functionNameOrArn, + Func> handler) + ``` + + Both methods return the runner for chaining. + ### Simulate failures === "TypeScript" @@ -317,6 +456,12 @@ CI. void simulateFireAndForgetCheckpointLoss(String stepName) ``` +=== "C#" + + The .NET SDK does not expose checkpoint-manipulation methods. To exercise failure and + retry paths, throw from inside a step and assert on `TestStep.Attempt` and the recorded + operation status. See the retry example in [Authoring](authoring.md). + ### Control time === "TypeScript" @@ -341,6 +486,21 @@ CI. directly, call `advanceTime()` yourself between invocations. `advanceTime()` marks PENDING step retries as READY and completes STARTED waits without real-time sleeps. +=== "C#" + + Set `SkipTime` on `TestRunnerOptions`. When true (the default), both `WaitAsync` delays + and step/`WaitForConditionAsync` retry backoffs complete immediately instead of sleeping + for real wall-clock time. There is no manual `advanceTime()` call. `RunAsync` advances + time internally. + + ```csharp + await using var runner = new DurableTestRunner( + Workflow, + new TestRunnerOptions { SkipTime = true }); + ``` + + Set `SkipTime = false` to assert on real wait durations. + See [Authoring: Skip time in tests](authoring.md#skip-time-in-tests) for an overview. ### Reset between runs @@ -362,6 +522,11 @@ See [Authoring: Skip time in tests](authoring.md#skip-time-in-tests) for an over Create a new `LocalDurableTestRunner` instance per test. +=== "C#" + + There is no `reset()`. A runner is single-use and not thread-safe: create a new + `DurableTestRunner` per test and dispose it with `await using`. + ### Reference types #### LocalDurableTestRunnerSetupParameters @@ -392,6 +557,36 @@ See [Authoring: Skip time in tests](authoring.md#skip-time-in-tests) for an over Not applicable. Configure with `withDurableConfig`, `withOutputType`, and `advanceTime()` on the runner instance. +=== "C#" + + Configuration lives in the `TestRunnerOptions` record passed to the constructor: + + ```csharp + public sealed record TestRunnerOptions + { + public bool SkipTime { get; init; } = true; + public int MaxInvocations { get; init; } = 100; + public TimeSpan DefaultTimeout { get; init; } = TimeSpan.FromSeconds(30); + public ILambdaSerializer? Serializer { get; init; } + public ILoggerFactory? LoggerFactory { get; init; } + public string DurableExecutionArn { get; init; } // synthetic test ARN by default + } + ``` + + **Fields:** + + - `SkipTime` (optional) Complete waits and retry backoffs instantly. Defaults to `true` + (the opposite of the JavaScript SDK, where time-skipping is opt-in). + - `MaxInvocations` (optional) Maximum handler invocations before throwing + `TestExecutionLimitException`. Defaults to `100`. + - `DefaultTimeout` (optional) Wall-clock timeout per `RunAsync`/`WaitForResultAsync` + call. Defaults to 30 seconds. + - `Serializer` (optional) An `ILambdaSerializer` for result deserialization. Defaults to + `DefaultLambdaJsonSerializer`. + - `LoggerFactory` (optional) An `ILoggerFactory` for runtime logging. + - `DurableExecutionArn` (optional) The ARN used in the test context. Defaults to a + synthetic test ARN. + ## TestResult The object returned by `run()`. Exposes the execution status, the final result, any @@ -425,6 +620,18 @@ error, and the full operation history. **Returns:** `ExecutionStatus` (`SUCCEEDED`, `FAILED`, `PENDING`). +=== "C#" + + ```csharp + InvocationStatus Status { get; } + bool IsSucceeded { get; } // Status == InvocationStatus.Succeeded + bool IsFailed { get; } // Status == InvocationStatus.Failed + ``` + + **Type:** `InvocationStatus` (`Succeeded`, `Failed`, `Pending`). There is no separate + execution-status enum in .NET; the cloud runner maps the service's finer terminal states + (`FAILED`, `TIMED_OUT`, `STOPPED`) onto `InvocationStatus.Failed`. + ### Result === "TypeScript" @@ -457,6 +664,22 @@ error, and the full operation history. **Throws:** `IllegalStateException` if the execution did not succeed. +=== "C#" + + ```csharp + TOutput? Result { get; } + ``` + + The deserialized execution output when `Status` is `InvocationStatus.Succeeded`; + otherwise the default value for `TOutput`. The result type is fixed at the runner's + `TOutput` type parameter, so no per-call type argument is needed. Call + `EnsureSucceeded()` first if you want a failed run to throw before you read `Result`. + + ```csharp + result.EnsureSucceeded(); + Assert.Equal("hello", result.Result); + ``` + ### Error === "TypeScript" @@ -481,6 +704,21 @@ error, and the full operation history. Optional getError() ``` +=== "C#" + + ```csharp + ErrorObject? Error { get; } + ``` + + The error when `Status` is `InvocationStatus.Failed`; otherwise `null`. `ErrorObject` + comes from `Amazon.Lambda.DurableExecution` and exposes `ErrorType`, `ErrorMessage`, + `ErrorData`, and `StackTrace`. + + ```csharp + Assert.True(result.IsFailed); + Assert.NotNull(result.Error); + ``` + ### Operations === "TypeScript" @@ -524,6 +762,23 @@ error, and the full operation history. `getOperation(name)` returns `null` if not found. +=== "C#" + + ```csharp + IReadOnlyList Steps { get; } // all operations except the top-level execution + + TestStep GetStep(string name) // throws if not found + TestStep? FindStep(string name) // null if not found + IReadOnlyList GetSteps(string name) // all matches (parallel branches, map items) + TestStep GetStepById(string operationId) + IReadOnlyList GetStepsByStatus(string status) // pass OperationStatus constants + IReadOnlyList GetChildren(TestStep parent) + ``` + + Filter `Steps` by kind with LINQ, e.g. + `result.Steps.Where(s => s.Kind == OperationKind.Step)`. Use the `OperationStatus` + string constants (e.g. `OperationStatus.Succeeded`) with `GetStepsByStatus`. + ### History events === "TypeScript" @@ -543,6 +798,13 @@ error, and the full operation history. List getEventsForOperation(String operationName) ``` +=== "C#" + + The .NET `TestResult` does not expose raw history events. Assert on the folded + operations through `Steps` and the `TestStep` accessors instead. (Internally the cloud + runner reconstructs operations from the history event stream, but that stream is not + surfaced on the result.) + ### Invocations === "TypeScript" @@ -561,6 +823,16 @@ error, and the full operation history. Not available on the test result. +=== "C#" + + ```csharp + int? InvocationCount { get; } + ``` + + The number of handler invocations the local runner used to drive the workflow. It is + `null` when not tracked. The cloud runner never tracks it, so do not assert on + `InvocationCount` in tests intended to run against both backends. + ### Pretty-print === "TypeScript" @@ -579,6 +851,11 @@ error, and the full operation history. Not applicable. +=== "C#" + + The .NET SDK does not expose a pretty-print method on the result. Iterate `result.Steps` + and format them yourself, e.g. with a `foreach` over `Name`, `Kind`, and `Status`. + ### Reference types {#testresult-reference-types} #### TestResultError @@ -614,6 +891,20 @@ error, and the full operation history. ErrorObject.errorType() // String ``` +=== "C#" + + Uses `ErrorObject` from `Amazon.Lambda.DurableExecution`: + + ```csharp + public sealed class ErrorObject + { + public string? ErrorType { get; set; } + public string? ErrorMessage { get; set; } + public string? ErrorData { get; set; } + public IReadOnlyList? StackTrace { get; set; } + } + ``` + ## Operation Represents a single entry in the operation history. Each type of operation exposes its @@ -668,6 +959,24 @@ shared set of accessors. String getId() ``` +=== "C#" + + The docs "Operation" type maps to `TestStep` in .NET. Common accessors: + + ```csharp + string Id { get; } + string? Name { get; } + string? ParentId { get; } + OperationKind Kind { get; } // Step, Wait, Callback, ChainedInvoke, Context, Execution + string? SubKind { get; } // e.g. "Parallel", "Map", "WaitForCallback" + string Status { get; } // compare against OperationStatus constants + int Attempt { get; } // 1-based for steps, 0 for other kinds + DateTimeOffset? StartedAt { get; } + DateTimeOffset? EndedAt { get; } + TimeSpan? Duration { get; } + IReadOnlyList Children { get; } + ``` + ### Step details === "TypeScript" @@ -702,6 +1011,20 @@ shared set of accessors. int getAttempt() ``` +=== "C#" + + `TestStep` exposes step details through kind-aware accessors rather than a separate + details object: + + ```csharp + T? GetResult() // deserializes the step result + ErrorObject? GetError() // the step error, or null + int Attempt { get; } // retry attempt (1-based) + ``` + + `GetResult()` routes to the right details block based on `Kind`, so it also works for + chained-invoke, context, and callback operations. + ### Wait details === "TypeScript" @@ -726,6 +1049,14 @@ shared set of accessors. `WaitDetails.scheduledEndTimestamp()` returns an `Instant`. +=== "C#" + + ```csharp + DateTimeOffset? GetWaitEndsAt() + ``` + + Returns the scheduled end time for a wait operation, or `null` for non-wait kinds. + ### Callback details === "TypeScript" @@ -749,6 +1080,14 @@ shared set of accessors. CallbackDetails getCallbackDetails() ``` +=== "C#" + + ```csharp + string? GetCallbackId() // the callback ID for a callback operation, or null + T? GetResult() // the delivered callback result + ErrorObject? GetError() // the callback error, or null + ``` + ### Chained invoke details === "TypeScript" @@ -770,6 +1109,14 @@ shared set of accessors. ChainedInvokeDetails getChainedInvokeDetails() ``` +=== "C#" + + ```csharp + string? GetChainedInvokeFunctionName() // invoked function name, or null + T? GetResult() // the invoke result + ErrorObject? GetError() // the invoke error, or null + ``` + ### Context details === "TypeScript" @@ -799,6 +1146,17 @@ shared set of accessors. ContextDetails getContextDetails() ``` +=== "C#" + + A child context is a `TestStep` with `Kind == OperationKind.Context`. Read its result + and children through the shared accessors: + + ```csharp + T? GetResult() // the context result + ErrorObject? GetError() // the context error, or null + IReadOnlyList Children { get; } // steps recorded inside the context + ``` + ### Execution details === "TypeScript" @@ -815,6 +1173,12 @@ shared set of accessors. ExecutionDetails getExecutionDetails() ``` +=== "C#" + + The .NET SDK does not expose execution details on a step. The top-level execution + operation is excluded from `result.Steps`, and the execution-level outcome is surfaced + on the result itself via `Status`, `Result`, and `Error`. + ### Drive a callback from an operation === "TypeScript" @@ -834,6 +1198,12 @@ shared set of accessors. Driven from the runner. See [LocalDurableTestRunner: Drive callbacks](#drive-callbacks). +=== "C#" + + Driven from the runner, not from the `TestStep`. Use `WaitForCallbackAsync` to get the + callback ID and the `SendCallback*` methods to deliver a result. See + [LocalDurableTestRunner: Drive callbacks](#drive-callbacks). + ## Enums ### Execution status @@ -874,6 +1244,19 @@ The terminal status of a durable execution. | `FAILED` | Execution failed | | `PENDING` | Execution is waiting | +=== "C#" + + `InvocationStatus` from `Amazon.Lambda.DurableExecution`: + + | Value | Meaning | + | ----------- | -------------------------------- | + | `Succeeded` | Execution completed successfully | + | `Failed` | Execution failed | + | `Pending` | Execution is waiting | + + There is no separate execution-status enum. The cloud runner maps the service's + `TIMED_OUT` and `STOPPED` terminal states onto `Failed`. + ### Operation status The status of an individual operation. @@ -902,6 +1285,26 @@ The status of an individual operation. `OperationStatus` from `software.amazon.awssdk.services.lambda.model`. Same values as TypeScript. +=== "C#" + + `OperationStatus` from `Amazon.Lambda.DurableExecution.Testing` is a static class of + string constants (compare against `TestStep.Status`), not an enum: + + | Constant | Meaning | + | ------------------------- | -------------------------------- | + | `OperationStatus.Started` | Operation is running | + | `OperationStatus.Succeeded` | Operation completed successfully | + | `OperationStatus.Failed` | Operation failed | + | `OperationStatus.Pending` | Operation is queued | + | `OperationStatus.Cancelled` | Operation was cancelled | + | `OperationStatus.TimedOut` | Operation exceeded its timeout | + | `OperationStatus.Stopped` | Operation was stopped | + | `OperationStatus.Ready` | Operation is ready to resume | + + ```csharp + Assert.Equal(OperationStatus.Succeeded, result.GetStep("step-1").Status); + ``` + ### Operation type === "TypeScript" @@ -927,6 +1330,23 @@ The status of an individual operation. `OperationType` from `software.amazon.awssdk.services.lambda.model`. Same values as TypeScript. +=== "C#" + + `OperationKind` from `Amazon.Lambda.DurableExecution.Testing` (read via `TestStep.Kind`): + + | Value | Meaning | + | --------------------------- | ---------------------------- | + | `OperationKind.Step` | A step operation | + | `OperationKind.Wait` | A wait operation | + | `OperationKind.Callback` | A callback operation | + | `OperationKind.ChainedInvoke` | An invoke operation | + | `OperationKind.Context` | A child context | + | `OperationKind.Execution` | The root execution operation | + + ```csharp + var stepOps = result.Steps.Where(s => s.Kind == OperationKind.Step).ToList(); + ``` + ### Waiting operation status === "TypeScript" @@ -948,6 +1368,12 @@ The status of an individual operation. Not applicable. Use `runner.getCallbackId()` after `run()` returns `PENDING`. +=== "C#" + + The .NET SDK does not expose a waiting-operation-status enum. After `StartAsync`, call + `WaitForCallbackAsync(arn, name)` to obtain the callback ID; the `OperationStatus` + constants cover the operation lifecycle states. + ## CloudDurableTestRunner Invokes a deployed Lambda function, polls for completion, and retrieves the full @@ -1024,6 +1450,33 @@ types, so tests written against the local runner run unchanged against the cloud - `lambdaClient` (optional) A configured `LambdaClient`. Defaults to a client using `DefaultCredentialsProvider`. +=== "C#" + + ```csharp + CloudDurableTestRunner( + string functionArn, + IAmazonLambda? lambdaClient = null, + CloudTestRunnerOptions? options = null) + ``` + + ```csharp + await using var runner = new CloudDurableTestRunner( + "arn:aws:lambda:us-east-1:123456789012:function:my-fn:live"); + ``` + + **Parameters:** + + - `functionArn` (required) The qualified function ARN (with alias, version, or + `$LATEST`). A qualifier is required for durable functions. + - `lambdaClient` (optional) An `IAmazonLambda` client. When null, the runner creates and + owns an `AmazonLambdaClient` using the default credential chain and disposes it on + `DisposeAsync`. + - `options` (optional) A [`CloudTestRunnerOptions`](#clouddurabletestrunnerconfig) + record for poll intervals, timeout, and serializer. + + The input and output types are the runner's type parameters, so no separate `inputType` + or `outputType` argument is needed. + ### Run the handler {#cloud-run-the-handler} === "TypeScript" @@ -1067,6 +1520,29 @@ types, so tests written against the local runner run unchanged against the cloud **Returns:** `TestResult` +=== "C#" + + ```csharp + Task> RunAsync( + TInput input, + TimeSpan? timeout = null, + CancellationToken cancellationToken = default) + ``` + + Invokes the deployed function (Event invocation), polls the durable-execution history + until the execution reaches a terminal state, and returns the folded result. This is the + same signature as the local runner, so tests written against + `IDurableTestRunner` run unchanged on either backend. + + **Parameters:** + + - `input` (required) The handler input. + - `timeout` (optional) Wall-clock timeout. Defaults to + `CloudTestRunnerOptions.DefaultTimeout` (5 minutes). + - `cancellationToken` (optional) A `CancellationToken`. + + **Returns:** `Task>` + ### Run asynchronously {#cloud-run-asynchronously} === "TypeScript" @@ -1094,6 +1570,23 @@ types, so tests written against the local runner run unchanged against the cloud `getOperations()`, `getStatus()`, `getExecutionArn()`, `completeCallback()`, `failCallback()`, and `heartbeatCallback()`. +=== "C#" + + The .NET SDK does not return a dedicated async-execution handle. Use the same + `StartAsync` + `WaitForResultAsync` pair as the local runner: + + ```csharp + Task StartAsync( + TInput input, TimeSpan? timeout = null, CancellationToken cancellationToken = default) + + Task> WaitForResultAsync( + string durableExecutionArn, TimeSpan? timeout = null, CancellationToken cancellationToken = default) + ``` + + `StartAsync` fires an Event invocation and resolves the durable execution ARN by + listing executions. Drive callbacks with `WaitForCallbackAsync` and the `SendCallback*` + methods between the two calls. + ### Inspect operations {#cloud-inspect-operations} === "TypeScript" @@ -1115,6 +1608,12 @@ types, so tests written against the local runner run unchanged against the cloud TestOperation getOperation(String name) ``` +=== "C#" + + Inspect operations through the returned [`TestResult`](#testresult). The cloud + result exposes the same `Steps` list and `GetStep`/`FindStep`/`GetSteps` accessors as the + local runner. See [TestResult](#testresult). + ### Drive callbacks {#cloud-drive-callbacks} === "TypeScript" @@ -1137,6 +1636,13 @@ types, so tests written against the local runner run unchanged against the cloud `getCallbackId()`, `completeCallback()`, `failCallback()`, and `heartbeatCallback()` on the async handle. +=== "C#" + + Callbacks are driven from the runner, same as the local runner. After `StartAsync`, call + `WaitForCallbackAsync` and then `SendCallbackSuccessAsync`, `SendCallbackFailureAsync`, + or `SendCallbackHeartbeatAsync`. On the cloud runner these issue the corresponding + `SendDurableExecutionCallback*` Lambda API calls. + ### Configure polling and timeouts === "TypeScript" @@ -1154,6 +1660,25 @@ types, so tests written against the local runner run unchanged against the cloud Use `withPollInterval(Duration)`, `withTimeout(Duration)`, and `withInvocationType(InvocationType)` on the runner. +=== "C#" + + Configure polling and timeout through the `CloudTestRunnerOptions` record passed to the + constructor. Per-call timeouts can also be passed to `RunAsync`/`WaitForResultAsync`: + + ```csharp + await using var runner = new CloudDurableTestRunner( + functionArn, + options: new CloudTestRunnerOptions + { + InitialPollInterval = TimeSpan.FromMilliseconds(200), + PollInterval = TimeSpan.FromSeconds(2), + DefaultTimeout = TimeSpan.FromMinutes(5), + }); + ``` + + The invocation type is fixed to `Event` (fire-and-forget) so callback workflows do not + deadlock; there is no `invocationType` knob. + ### Reset between runs {#cloud-reset-between-runs} === "TypeScript" @@ -1170,6 +1695,11 @@ types, so tests written against the local runner run unchanged against the cloud Create a new runner instance per test. +=== "C#" + + There is no `reset()`. Create a new `CloudDurableTestRunner` per test + and dispose it with `await using` (which disposes a runner-owned Lambda client). + ### Reference types {#cloud-reference-types} #### CloudDurableTestRunnerConfig @@ -1197,6 +1727,30 @@ types, so tests written against the local runner run unchanged against the cloud Not a separate config object. Use the `with*` builder methods on the runner. +=== "C#" + + The .NET equivalent is the `CloudTestRunnerOptions` record: + + ```csharp + public sealed record CloudTestRunnerOptions + { + public TimeSpan InitialPollInterval { get; init; } = TimeSpan.FromMilliseconds(200); + public TimeSpan PollInterval { get; init; } = TimeSpan.FromSeconds(2); + public TimeSpan DefaultTimeout { get; init; } = TimeSpan.FromMinutes(5); + public ILambdaSerializer? Serializer { get; init; } + } + ``` + + **Fields:** + + - `InitialPollInterval` (optional) Delay before the first poll retry; subsequent delays + grow exponentially up to `PollInterval`. Defaults to 200 milliseconds. + - `PollInterval` (optional) Maximum steady-state interval between polls. Defaults to + 2 seconds. + - `DefaultTimeout` (optional) Wall-clock timeout for polling. Defaults to 5 minutes. + - `Serializer` (optional) An `ILambdaSerializer` for payload and result serialization. + Defaults to `DefaultLambdaJsonSerializer`. + ## See also - [Authoring](authoring.md) Set up the local runner and write your first test. diff --git a/docs/testing/assertions.md b/docs/testing/assertions.md index 297b507..61777ac 100644 --- a/docs/testing/assertions.md +++ b/docs/testing/assertions.md @@ -35,6 +35,15 @@ Look up a step by name and check its status and result. --8<-- "examples/java/testing/assertions/assert-step.java" ``` +=== "C#" + + Use `result.GetStep(name)` to get the `TestStep`, then call `GetResult()` to + deserialize the result. + + ```csharp + --8<-- "examples/csharp/testing/assertions/assert-step.cs" + ``` + ## Assert on a wait Look up a wait operation and check that it was scheduled with the expected duration. @@ -65,6 +74,15 @@ Look up a wait operation and check that it was scheduled with the expected durat --8<-- "examples/java/testing/assertions/assert-wait.java" ``` +=== "C#" + + `GetWaitEndsAt()` returns the scheduled end time as a `DateTimeOffset?`. With the + default `SkipTime` on, the wait completes immediately. + + ```csharp + --8<-- "examples/csharp/testing/assertions/assert-wait.cs" + ``` + ## Assert on a callback For callbacks, the test drives the execution to the point where the callback is waiting, @@ -98,6 +116,16 @@ then completes it from the test. --8<-- "examples/java/testing/assertions/assert-callback.java" ``` +=== "C#" + + Use `StartAsync()` to drive the workflow to the callback, `WaitForCallbackAsync()` to + get the callback ID, `SendCallbackSuccessAsync()` to complete it, then + `WaitForResultAsync()` to finish the execution. + + ```csharp + --8<-- "examples/csharp/testing/assertions/assert-callback.cs" + ``` + ## Assert on a child context Child contexts appear as `CONTEXT` operations in the result. You can walk their child @@ -121,6 +149,15 @@ operations to assert on what ran inside the context. --8<-- "examples/java/testing/assertions/assert-child-context.java" ``` +=== "C#" + + Child contexts appear as `OperationKind.Context` steps. Walk `TestStep.Children` to + assert on the operations that ran inside the context. + + ```csharp + --8<-- "examples/csharp/testing/assertions/assert-child-context.cs" + ``` + ## Filter operations by status When a step retries, the operation history contains one entry per attempt. Filter by @@ -152,6 +189,16 @@ status to count failures and successes separately. --8<-- "examples/java/testing/assertions/filter-by-status.java" ``` +=== "C#" + + A retried step reuses a single operation record, so the history holds one entry per + operation with its terminal status. Pass an `OperationStatus` constant to + `result.GetStepsByStatus(...)` to filter. + + ```csharp + --8<-- "examples/csharp/testing/assertions/filter-by-status.cs" + ``` + ## See also - [Authoring](authoring.md) Set up the test runner and write your first test. diff --git a/docs/testing/authoring.md b/docs/testing/authoring.md index a8176c1..91332b8 100644 --- a/docs/testing/authoring.md +++ b/docs/testing/authoring.md @@ -26,6 +26,14 @@ ``` +=== "C#" + + Add the testing package to your test project: + + ```bash + dotnet add package Amazon.Lambda.DurableExecution.Testing + ``` + ## Write a minimal test Create a runner with your handler, call `run()`, and assert on the result. @@ -59,6 +67,16 @@ Create a runner with your handler, call `run()`, and assert on the result. --8<-- "examples/java/testing/authoring/minimal-test.java" ``` +=== "C#" + + Construct `DurableTestRunner` with your workflow delegate and + `new TestRunnerOptions { SkipTime = true }`, call `RunAsync()`, then + `EnsureSucceeded()` and assert on `Result`. + + ```csharp + --8<-- "examples/csharp/testing/authoring/minimal-test.cs" + ``` + ## Test a failed execution When your handler throws outside a step, or a step exhausts all retries, the execution @@ -82,6 +100,12 @@ fails. Assert on the status and inspect the error. --8<-- "examples/java/testing/authoring/test-failure.java" ``` +=== "C#" + + ```csharp + --8<-- "examples/csharp/testing/authoring/test-failure.cs" + ``` + ## Test retries The test runner drives the full retry loop via replay. Configure a retry strategy on the @@ -105,6 +129,12 @@ step, and the runner re-invokes the handler as many times as needed. --8<-- "examples/java/testing/authoring/test-retries.java" ``` +=== "C#" + + ```csharp + --8<-- "examples/csharp/testing/authoring/test-retries.cs" + ``` + ## Skip time in tests Retry backoffs and `context.wait()` durations use real time in production. The test @@ -144,6 +174,18 @@ runner collapses these delays so tests finish in milliseconds. runner.runUntilComplete(input); // advanceTime() runs after each invocation ``` +=== "C#" + + `TestRunnerOptions.SkipTime` defaults to `true`, so `RunAsync()` completes both + `WaitAsync` timers and step retry backoffs immediately without real-time sleeps. + Set it to `false` to assert on real wait durations. + + ```csharp + await using var runner = new DurableTestRunner( + Workflow, new TestRunnerOptions { SkipTime = true }); + await runner.RunAsync(input); // waits and retry backoffs complete instantly + ``` + See [Workflow patterns: Long waits](workflow-patterns.md#long-waits) for a worked example. @@ -170,6 +212,12 @@ own runner instance. --8<-- "examples/java/testing/authoring/test-branching.java" ``` +=== "C#" + + ```csharp + --8<-- "examples/csharp/testing/authoring/test-branching.cs" + ``` + ## See also - [API Reference](api-reference.md) Full reference for the runner, result, and operation diff --git a/docs/testing/cloud-runner.md b/docs/testing/cloud-runner.md index 2637853..26ecfb7 100644 --- a/docs/testing/cloud-runner.md +++ b/docs/testing/cloud-runner.md @@ -29,6 +29,12 @@ until the execution completes. --8<-- "examples/java/testing/cloud-runner/cloud-runner.java" ``` +=== "C#" + + ```csharp + --8<-- "examples/csharp/testing/cloud-runner/cloud-runner.cs" + ``` + ### Deploy before running Cloud mode requires a deployed function. Deploy with your preferred tool before running @@ -76,6 +82,14 @@ completion. --8<-- "examples/java/testing/cloud-runner/cloud-runner-timeout.java" ``` +=== "C#" + + Set `DefaultTimeout` on `CloudTestRunnerOptions`, or pass a `timeout` to `RunAsync`: + + ```csharp + --8<-- "examples/csharp/testing/cloud-runner/cloud-runner-timeout.cs" + ``` + ### Required IAM permissions Cloud mode requires AWS credentials in the environment. The runner uses the default diff --git a/docs/testing/workflow-patterns.md b/docs/testing/workflow-patterns.md index e9fcb2e..1124ddd 100644 --- a/docs/testing/workflow-patterns.md +++ b/docs/testing/workflow-patterns.md @@ -28,6 +28,12 @@ that all steps ran and that the final result reflects the full chain. --8<-- "examples/java/testing/examples/sequential-workflow.java" ``` +=== "C#" + + ```csharp + --8<-- "examples/csharp/testing/examples/sequential-workflow.cs" + ``` + ## Child contexts Child contexts group operations under a named scope. The test result exposes the child @@ -52,6 +58,12 @@ operations. --8<-- "examples/java/testing/examples/child-context.java" ``` +=== "C#" + + ```csharp + --8<-- "examples/csharp/testing/examples/child-context.cs" + ``` + ## Parallel operations Parallel branches execute concurrently. Assert on the final result to verify all @@ -75,6 +87,12 @@ branches completed. --8<-- "examples/java/testing/examples/parallel-workflow.java" ``` +=== "C#" + + ```csharp + --8<-- "examples/csharp/testing/examples/parallel-workflow.cs" + ``` + ## Partial failures When a step fails after earlier steps have succeeded, the execution fails but the @@ -99,6 +117,12 @@ steps to verify which ones ran before the failure. --8<-- "examples/java/testing/examples/partial-failures.java" ``` +=== "C#" + + ```csharp + --8<-- "examples/csharp/testing/examples/partial-failures.cs" + ``` + ## Long waits Workflows with long waits (hours or days) would make tests impractical without time @@ -132,6 +156,16 @@ skipping. Each SDK handles this differently. --8<-- "examples/java/testing/examples/long-waits.java" ``` +=== "C#" + + `TestRunnerOptions.SkipTime` defaults to `true`, so the runner completes STARTED + waits immediately without waiting for real time. The day-long wait resolves in + milliseconds. + + ```csharp + --8<-- "examples/csharp/testing/examples/long-waits.cs" + ``` + ## Polling with waitForCondition `waitForCondition` polls a check function until it signals done. The test runner drives @@ -155,6 +189,12 @@ the polling loop the same way it drives retries. --8<-- "examples/java/testing/examples/polling.java" ``` +=== "C#" + + ```csharp + --8<-- "examples/csharp/testing/examples/polling.cs" + ``` + ## See also - [Authoring](authoring.md) Set up the test runner and write your first test. diff --git a/examples/csharp/configuration/custom-client.cs b/examples/csharp/configuration/custom-client.cs new file mode 100644 index 0000000..7957ae9 --- /dev/null +++ b/examples/csharp/configuration/custom-client.cs @@ -0,0 +1,29 @@ +using Amazon; +using Amazon.Lambda; +using Amazon.Lambda.Core; +using Amazon.Lambda.DurableExecution; + +public class CustomLambdaClientExample +{ + // Create a custom Lambda client with specific region and retry configuration. + private static readonly IAmazonLambda CustomClient = new AmazonLambdaClient( + new AmazonLambdaConfig + { + RegionEndpoint = RegionEndpoint.USWest2, + MaxErrorRetry = 5, + RetryMode = Amazon.Runtime.RequestRetryMode.Adaptive, + }); + + // Pass the custom client as the fourth argument to DurableFunction.WrapAsync. + public Task Handler( + DurableExecutionInvocationInput input, ILambdaContext context) + => DurableFunction.WrapAsync(Workflow, input, context, CustomClient); + + private Task Workflow(object input, IDurableContext ctx) + { + // Your durable function logic + return Task.FromResult(new Result("success")); + } + + public record Result(string Status); +} diff --git a/examples/csharp/core/wait/async-wait.cs b/examples/csharp/core/wait/async-wait.cs new file mode 100644 index 0000000..6f5c06a --- /dev/null +++ b/examples/csharp/core/wait/async-wait.cs @@ -0,0 +1,25 @@ +using Amazon.Lambda.Core; +using Amazon.Lambda.DurableExecution; + +public class AsyncWaitExample +{ + public Task Handler( + DurableExecutionInvocationInput input, ILambdaContext context) + => DurableFunction.WrapAsync(Workflow, input, context); + + private async Task Workflow(object input, IDurableContext ctx) + { + // Start the wait and the step but don't await yet — WaitAsync returns a Task + Task waitTask = ctx.WaitAsync(TimeSpan.FromSeconds(5), name: "min-delay"); + Task stepTask = ctx.StepAsync( + async (_, _) => ProcessData(input), + name: "process"); + + // Await both — guarantees at least 5 seconds elapsed + await Task.WhenAll(waitTask, stepTask); + + return await stepTask; + } + + private static string ProcessData(object input) => "processed"; +} diff --git a/examples/csharp/core/wait/basic-wait.cs b/examples/csharp/core/wait/basic-wait.cs new file mode 100644 index 0000000..998b195 --- /dev/null +++ b/examples/csharp/core/wait/basic-wait.cs @@ -0,0 +1,16 @@ +using Amazon.Lambda.Core; +using Amazon.Lambda.DurableExecution; + +public class BasicWaitExample +{ + public Task Handler( + DurableExecutionInvocationInput input, ILambdaContext context) + => DurableFunction.WrapAsync(Workflow, input, context); + + private async Task Workflow(object input, IDurableContext ctx) + { + // Wait for 5 seconds + await ctx.WaitAsync(TimeSpan.FromSeconds(5)); + return "Wait completed"; + } +} diff --git a/examples/csharp/core/wait/custom-strategy.cs b/examples/csharp/core/wait/custom-strategy.cs new file mode 100644 index 0000000..263f95d --- /dev/null +++ b/examples/csharp/core/wait/custom-strategy.cs @@ -0,0 +1,22 @@ +using Amazon.Lambda.DurableExecution; + +public record JobState(string Status); + +public static class CustomWaitStrategy +{ + // Full control over polling: the strategy decides whether to continue or + // stop, and how long to wait before the next attempt. + public static IWaitStrategy Create() => + WaitStrategy.FromDelegate((state, attempt) => + { + if (state.Status == "COMPLETED") + { + return WaitDecision.Stop(); + } + if (attempt >= 10) + { + throw new WaitForConditionException("Max attempts exceeded"); + } + return WaitDecision.ContinueAfter(TimeSpan.FromSeconds(attempt * 5)); + }); +} diff --git a/examples/csharp/core/wait/duration-helpers.cs b/examples/csharp/core/wait/duration-helpers.cs new file mode 100644 index 0000000..3c7f379 --- /dev/null +++ b/examples/csharp/core/wait/duration-helpers.cs @@ -0,0 +1,11 @@ +// Wait for 30 seconds +await ctx.WaitAsync(TimeSpan.FromSeconds(30), name: "wait30"); + +// Wait for 5 minutes +await ctx.WaitAsync(TimeSpan.FromMinutes(5), name: "wait5m"); + +// Wait for 2 hours +await ctx.WaitAsync(TimeSpan.FromHours(2), name: "wait2h"); + +// Wait for 1 day +await ctx.WaitAsync(TimeSpan.FromDays(1), name: "wait1d"); diff --git a/examples/csharp/core/wait/duration-signature.cs b/examples/csharp/core/wait/duration-signature.cs new file mode 100644 index 0000000..2085f54 --- /dev/null +++ b/examples/csharp/core/wait/duration-signature.cs @@ -0,0 +1,5 @@ +// System.TimeSpan (standard library) +TimeSpan.FromSeconds(double seconds); +TimeSpan.FromMinutes(double minutes); +TimeSpan.FromHours(double hours); +TimeSpan.FromDays(double days); diff --git a/examples/csharp/core/wait/named-wait.cs b/examples/csharp/core/wait/named-wait.cs new file mode 100644 index 0000000..ca75e70 --- /dev/null +++ b/examples/csharp/core/wait/named-wait.cs @@ -0,0 +1,16 @@ +using Amazon.Lambda.Core; +using Amazon.Lambda.DurableExecution; + +public class NamedWaitExample +{ + public Task Handler( + DurableExecutionInvocationInput input, ILambdaContext context) + => DurableFunction.WrapAsync(Workflow, input, context); + + private async Task Workflow(object input, IDurableContext ctx) + { + // Wait with explicit name + await ctx.WaitAsync(TimeSpan.FromSeconds(2), name: "custom_wait"); + return "Wait with name completed"; + } +} diff --git a/examples/csharp/core/wait/strategy-helper.cs b/examples/csharp/core/wait/strategy-helper.cs new file mode 100644 index 0000000..2b1d3ae --- /dev/null +++ b/examples/csharp/core/wait/strategy-helper.cs @@ -0,0 +1,17 @@ +using Amazon.Lambda.DurableExecution; + +public record JobState(string Status); + +public static class StrategyHelper +{ + // Build an exponential-backoff strategy from common parameters. The + // isDone predicate stops polling once the latest state satisfies it. + public static IWaitStrategy Create() => + WaitStrategy.Exponential( + maxAttempts: 10, + initialDelay: TimeSpan.FromSeconds(5), + maxDelay: TimeSpan.FromMinutes(5), + backoffRate: 2.0, + jitter: JitterStrategy.Full, + isDone: state => state.Status == "COMPLETED"); +} diff --git a/examples/csharp/core/wait/test-multiple-waits.cs b/examples/csharp/core/wait/test-multiple-waits.cs new file mode 100644 index 0000000..8403cb0 --- /dev/null +++ b/examples/csharp/core/wait/test-multiple-waits.cs @@ -0,0 +1,39 @@ +using System.Linq; +using Amazon.Lambda.DurableExecution; +using Amazon.Lambda.DurableExecution.Testing; +using Xunit; + +public class MultipleWaitsTests +{ + [Fact] + public async Task HandlesMultipleSequentialWaits() + { + // SkipTime fast-forwards waits so the test runs instantly. + await using var runner = new DurableTestRunner( + handler: Workflow, + options: new TestRunnerOptions { SkipTime = true }); + + var result = await runner.RunAsync("test"); + result.EnsureSucceeded(); + + Assert.Equal(2, result.Result!.CompletedWaits); + Assert.Equal("done", result.Result!.FinalStep); + + // Both waits are recorded with their names. + var waitOps = result.Steps.Where(s => s.Kind == OperationKind.Wait).ToList(); + Assert.Equal(2, waitOps.Count); + + var waitNames = waitOps.Select(w => w.Name).ToList(); + Assert.Contains("wait-1", waitNames); + Assert.Contains("wait-2", waitNames); + } + + private static async Task Workflow(object input, IDurableContext ctx) + { + await ctx.WaitAsync(TimeSpan.FromMinutes(5), name: "wait-1"); + await ctx.WaitAsync(TimeSpan.FromHours(1), name: "wait-2"); + return new MultipleWaitsResult(CompletedWaits: 2, FinalStep: "done"); + } +} + +public record MultipleWaitsResult(int CompletedWaits, string FinalStep); diff --git a/examples/csharp/core/wait/wait-for-condition-signature.cs b/examples/csharp/core/wait/wait-for-condition-signature.cs new file mode 100644 index 0000000..67b541c --- /dev/null +++ b/examples/csharp/core/wait/wait-for-condition-signature.cs @@ -0,0 +1,23 @@ +// ctx.WaitForConditionAsync() +Task WaitForConditionAsync( + Func> check, + WaitForConditionConfig config, + string? name = null, + CancellationToken cancellationToken = default); + +// Check function +Func> + +// IConditionCheckContext — provides a logger and the 1-based attempt number +public interface IConditionCheckContext +{ + ILogger Logger { get; } + int AttemptNumber { get; } +} + +// Config (required; generic on the state type) +public sealed class WaitForConditionConfig +{ + public required TState InitialState { get; set; } + public required IWaitStrategy WaitStrategy { get; set; } +} diff --git a/examples/csharp/core/wait/wait-for-condition.cs b/examples/csharp/core/wait/wait-for-condition.cs new file mode 100644 index 0000000..72ff318 --- /dev/null +++ b/examples/csharp/core/wait/wait-for-condition.cs @@ -0,0 +1,38 @@ +using Amazon.Lambda.Core; +using Amazon.Lambda.DurableExecution; + +public class WaitForConditionExample +{ + public Task Handler( + DurableExecutionInvocationInput input, ILambdaContext context) + => DurableFunction.WrapAsync(Workflow, input, context); + + private async Task Workflow(object input, IDurableContext ctx) + { + // Poll until the job completes. + var result = await ctx.WaitForConditionAsync( + check: async (state, checkCtx, ct) => + { + var status = await GetJobStatus(state.JobId, ct); + return state with { Status = status, Done = status == "COMPLETED" }; + }, + config: new WaitForConditionConfig + { + InitialState = new JobState("job-123", "pending", Done: false), + WaitStrategy = WaitStrategy.Exponential( + maxAttempts: 60, + initialDelay: TimeSpan.FromSeconds(5), + maxDelay: TimeSpan.FromMinutes(5), + backoffRate: 2.0, + jitter: JitterStrategy.None, + isDone: state => state.Done), + }, + name: "wait_for_job"); + return result; + } + + private static Task GetJobStatus(string jobId, CancellationToken ct) + => Task.FromResult("COMPLETED"); +} + +public record JobState(string JobId, string Status, bool Done); diff --git a/examples/csharp/core/wait/wait-signature.cs b/examples/csharp/core/wait/wait-signature.cs new file mode 100644 index 0000000..bdba554 --- /dev/null +++ b/examples/csharp/core/wait/wait-signature.cs @@ -0,0 +1,4 @@ +Task WaitAsync( + TimeSpan duration, + string? name = null, + CancellationToken cancellationToken = default); diff --git a/examples/csharp/core/wait/wait-strategy-signature.cs b/examples/csharp/core/wait/wait-strategy-signature.cs new file mode 100644 index 0000000..946a93a --- /dev/null +++ b/examples/csharp/core/wait/wait-strategy-signature.cs @@ -0,0 +1,15 @@ +// Wait strategy — decides, per poll, whether to continue and how long to wait +public interface IWaitStrategy +{ + WaitDecision Decide(TState state, int attemptNumber); +} + +// Decision +public readonly record struct WaitDecision +{ + public bool ShouldContinue { get; } + public TimeSpan Delay { get; } + + public static WaitDecision Stop(); // condition met + public static WaitDecision ContinueAfter(TimeSpan delay); // poll again after delay +} diff --git a/examples/csharp/getting-started/durable-context.cs b/examples/csharp/getting-started/durable-context.cs new file mode 100644 index 0000000..5058f42 --- /dev/null +++ b/examples/csharp/getting-started/durable-context.cs @@ -0,0 +1,23 @@ +using Amazon.Lambda.Core; +using Amazon.Lambda.DurableExecution; +using Microsoft.Extensions.Logging; + +public class DurableContextExample +{ + public Task Handler( + DurableExecutionInvocationInput input, ILambdaContext context) + => DurableFunction.WrapAsync(Workflow, input, context); + + private async Task Workflow(object input, IDurableContext ctx) + { + // Your workflow receives IDurableContext instead of the Lambda context. + // Use it to access durable operations (ctx.StepAsync, ctx.WaitAsync, ...), + // the replay-safe logger, and metadata about the current execution. + ctx.Logger.LogInformation( + "Running execution {Arn}", ctx.ExecutionContext.DurableExecutionArn); + + return await ctx.StepAsync( + async (_, _) => "step completed", + name: "my-step"); + } +} diff --git a/examples/csharp/getting-started/execution-model.cs b/examples/csharp/getting-started/execution-model.cs new file mode 100644 index 0000000..e08bf0f --- /dev/null +++ b/examples/csharp/getting-started/execution-model.cs @@ -0,0 +1,32 @@ +using Amazon.Lambda.Core; +using Amazon.Lambda.DurableExecution; + +public class ExecutionModelExample +{ + public Task Handler( + DurableExecutionInvocationInput input, ILambdaContext context) + => DurableFunction.WrapAsync(Workflow, input, context); + + private async Task Workflow(FetchEvent input, IDurableContext ctx) + { + // Step 1: Fetch data — result is checkpointed + string data = await ctx.StepAsync( + async (_, _) => FetchData(input.Id), + name: "fetch-data"); + + // Step 2: Wait 30 seconds without consuming compute resources + await ctx.WaitAsync(TimeSpan.FromSeconds(30), name: "wait-30s"); + + // Step 3: Process the data — only runs after the wait completes + string result = await ctx.StepAsync( + async (_, _) => ProcessData(data), + name: "process-data"); + + return result; + } + + private static string FetchData(string id) => $"data-for-{id}"; + private static string ProcessData(string data) => $"processed-{data}"; +} + +public record FetchEvent(string Id); diff --git a/examples/csharp/getting-started/quickstart.cs b/examples/csharp/getting-started/quickstart.cs new file mode 100644 index 0000000..25b925f --- /dev/null +++ b/examples/csharp/getting-started/quickstart.cs @@ -0,0 +1,31 @@ +using Amazon.Lambda.Core; +using Amazon.Lambda.DurableExecution; +using Microsoft.Extensions.Logging; + +public class QuickstartFunction +{ + public Task Handler( + DurableExecutionInvocationInput input, ILambdaContext context) + => DurableFunction.WrapAsync(Workflow, input, context); + + private async Task Workflow(object input, IDurableContext ctx) + { + string message = await ctx.StepAsync( + async (stepCtx, _) => + { + stepCtx.Logger.LogInformation("Hello from step-1"); + return "Hello from Durable Lambda!"; + }, + name: "step-1"); + + // Pause for 10 seconds without consuming CPU or incurring usage charges + await ctx.WaitAsync(TimeSpan.FromSeconds(10), name: "wait-10s"); + + // Replay-aware: logs once even though the function replays after the wait + ctx.Logger.LogInformation("Resumed after wait"); + + return new Response(200, message); + } +} + +public record Response(int StatusCode, string Body); diff --git a/examples/csharp/operations/callbacks/basic-example.cs b/examples/csharp/operations/callbacks/basic-example.cs new file mode 100644 index 0000000..3465d2f --- /dev/null +++ b/examples/csharp/operations/callbacks/basic-example.cs @@ -0,0 +1,27 @@ +using Amazon.Lambda.Core; +using Amazon.Lambda.DurableExecution; + +public class BasicCallbackExample +{ + public Task Handler( + DurableExecutionInvocationInput input, ILambdaContext context) + => DurableFunction.WrapAsync(Workflow, input, context); + + private async Task Workflow(RequestEvent input, IDurableContext ctx) + { + ICallback callback = await ctx.CreateCallbackAsync("wait-for-approval"); + + // Send callback.CallbackId to the external system that will resume this function. + await SendApprovalRequestAsync(callback.CallbackId, input.RequestId); + + // Execution suspends here until the external system calls back. + string result = await callback.GetResultAsync(); + return new ApprovalResult(Approved: true, Result: result); + } + + private static Task SendApprovalRequestAsync(string callbackId, string requestId) + => Task.CompletedTask; +} + +public record RequestEvent(string RequestId); +public record ApprovalResult(bool Approved, string Result); diff --git a/examples/csharp/operations/callbacks/callback-config.cs b/examples/csharp/operations/callbacks/callback-config.cs new file mode 100644 index 0000000..dbeddf7 --- /dev/null +++ b/examples/csharp/operations/callbacks/callback-config.cs @@ -0,0 +1,29 @@ +using Amazon.Lambda.Core; +using Amazon.Lambda.DurableExecution; + +public class CallbackConfigExample +{ + public Task Handler( + DurableExecutionInvocationInput input, ILambdaContext context) + => DurableFunction.WrapAsync(Workflow, input, context); + + private async Task Workflow(PaymentEvent input, IDurableContext ctx) + { + var config = new CallbackConfig + { + Timeout = TimeSpan.FromHours(24), + HeartbeatTimeout = TimeSpan.FromMinutes(30), + }; + + ICallback callback = await ctx.CreateCallbackAsync( + "wait-for-payment", config); + + await SubmitPaymentRequestAsync(callback.CallbackId, input.Amount); + return await callback.GetResultAsync(); + } + + private static Task SubmitPaymentRequestAsync(string callbackId, decimal amount) + => Task.CompletedTask; +} + +public record PaymentEvent(decimal Amount); diff --git a/examples/csharp/operations/callbacks/create-callback-signature.cs b/examples/csharp/operations/callbacks/create-callback-signature.cs new file mode 100644 index 0000000..9b2a4b7 --- /dev/null +++ b/examples/csharp/operations/callbacks/create-callback-signature.cs @@ -0,0 +1,4 @@ +Task> CreateCallbackAsync( + string? name = null, + CallbackConfig? config = null, + CancellationToken cancellationToken = default); diff --git a/examples/csharp/operations/callbacks/wait-for-callback-example.cs b/examples/csharp/operations/callbacks/wait-for-callback-example.cs new file mode 100644 index 0000000..9be7cae --- /dev/null +++ b/examples/csharp/operations/callbacks/wait-for-callback-example.cs @@ -0,0 +1,23 @@ +using Amazon.Lambda.Core; +using Amazon.Lambda.DurableExecution; + +public class WaitForCallbackExample +{ + public Task Handler( + DurableExecutionInvocationInput input, ILambdaContext context) + => DurableFunction.WrapAsync(Workflow, input, context); + + private async Task Workflow(RequestEvent input, IDurableContext ctx) + { + string result = await ctx.WaitForCallbackAsync( + async (callbackId, _, _) => await SendApprovalRequestAsync(callbackId, input.RequestId), + name: "wait-for-approval"); + return new ApprovalResult(Approved: true, Result: result); + } + + private static Task SendApprovalRequestAsync(string callbackId, string requestId) + => Task.CompletedTask; +} + +public record RequestEvent(string RequestId); +public record ApprovalResult(bool Approved, string Result); diff --git a/examples/csharp/operations/callbacks/wait-for-callback-signature.cs b/examples/csharp/operations/callbacks/wait-for-callback-signature.cs new file mode 100644 index 0000000..cfb0d7a --- /dev/null +++ b/examples/csharp/operations/callbacks/wait-for-callback-signature.cs @@ -0,0 +1,5 @@ +Task WaitForCallbackAsync( + Func submitter, + string? name = null, + WaitForCallbackConfig? config = null, + CancellationToken cancellationToken = default); diff --git a/examples/csharp/operations/child-contexts/basic-child-context.cs b/examples/csharp/operations/child-contexts/basic-child-context.cs new file mode 100644 index 0000000..e265444 --- /dev/null +++ b/examples/csharp/operations/child-contexts/basic-child-context.cs @@ -0,0 +1,31 @@ +using Amazon.Lambda.Core; +using Amazon.Lambda.DurableExecution; + +public class BasicChildContextExample +{ + public Task Handler( + DurableExecutionInvocationInput input, ILambdaContext context) + => DurableFunction.WrapAsync(Workflow, input, context); + + public async Task Workflow(OrderEvent input, IDurableContext ctx) + { + return await ctx.RunInChildContextAsync( + async (child, ct) => + { + Order validated = await child.StepAsync( + async (_, _) => Validate(input.OrderId), + name: "validate"); + return await child.StepAsync( + async (_, _) => Charge(validated), + name: "charge"); + }, + name: "process-order"); + } + + private static Order Validate(string orderId) => new(orderId, Valid: true); + private static ChargedOrder Charge(Order order) => new(order.OrderId, order.Valid, Charged: true); +} + +public record OrderEvent(string OrderId); +public record Order(string OrderId, bool Valid); +public record ChargedOrder(string OrderId, bool Valid, bool Charged); diff --git a/examples/csharp/operations/child-contexts/child-config-signature.cs b/examples/csharp/operations/child-contexts/child-config-signature.cs new file mode 100644 index 0000000..3df060c --- /dev/null +++ b/examples/csharp/operations/child-contexts/child-config-signature.cs @@ -0,0 +1,5 @@ +public sealed class ChildContextConfig +{ + public string? SubType { get; set; } + public Func? ErrorMapping { get; set; } +} diff --git a/examples/csharp/operations/child-contexts/concurrent-child-contexts.cs b/examples/csharp/operations/child-contexts/concurrent-child-contexts.cs new file mode 100644 index 0000000..1c21119 --- /dev/null +++ b/examples/csharp/operations/child-contexts/concurrent-child-contexts.cs @@ -0,0 +1,37 @@ +using Amazon.Lambda.Core; +using Amazon.Lambda.DurableExecution; + +public class ConcurrentChildContextsExample +{ + public Task Handler( + DurableExecutionInvocationInput input, ILambdaContext context) + => DurableFunction.WrapAsync(Workflow, input, context); + + // WRONG - a and b share the parent counter, so their IDs depend on which task + // completes first. On replay, if the order differs, a gets b's checkpointed result + // and b gets a's. + private async Task WrongWorkflow(object input, IDurableContext ctx) + { + Task a = ctx.StepAsync(async (_, _) => FetchA(), name: "fetch-a"); + Task b = ctx.StepAsync(async (_, _) => FetchB(), name: "fetch-b"); + return new Results(await a, await b); + } + + // CORRECT - each branch has its own isolated counter, so IDs are stable regardless + // of completion order. Start both child contexts, then await them together. + private async Task Workflow(object input, IDurableContext ctx) + { + Task a = ctx.RunInChildContextAsync( + async (child, _) => await child.StepAsync(async (_, _) => FetchA(), name: "fetch-a"), + name: "branch-a"); + Task b = ctx.RunInChildContextAsync( + async (child, _) => await child.StepAsync(async (_, _) => FetchB(), name: "fetch-b"), + name: "branch-b"); + return new Results(await a, await b); + } + + private static string FetchA() => "result-a"; + private static string FetchB() => "result-b"; +} + +public record Results(string A, string B); diff --git a/examples/csharp/operations/child-contexts/context-function.cs b/examples/csharp/operations/child-contexts/context-function.cs new file mode 100644 index 0000000..29ea019 --- /dev/null +++ b/examples/csharp/operations/child-contexts/context-function.cs @@ -0,0 +1,33 @@ +using Amazon.Lambda.Core; +using Amazon.Lambda.DurableExecution; + +public class ContextFunctionExample +{ + public Task Handler( + DurableExecutionInvocationInput input, ILambdaContext context) + => DurableFunction.WrapAsync(Workflow, input, context); + + private async Task Workflow(OrderEvent input, IDurableContext ctx) + { + // Pass an async (child, ct) => ... lambda as the context function. The + // function receives its own IDurableContext and returns a Task. + return await ctx.RunInChildContextAsync( + async (child, ct) => + { + Order validated = await child.StepAsync( + async (_, _) => Validate(input.OrderId), + name: "validate"); + return await child.StepAsync( + async (_, _) => Charge(validated), + name: "charge"); + }, + name: "process-order"); + } + + private static Order Validate(string orderId) => new(orderId, Valid: true); + private static ChargedOrder Charge(Order order) => new(order.OrderId, order.Valid, Charged: true); +} + +public record OrderEvent(string OrderId); +public record Order(string OrderId, bool Valid); +public record ChargedOrder(string OrderId, bool Valid, bool Charged); diff --git a/examples/csharp/operations/child-contexts/named-child-context.cs b/examples/csharp/operations/child-contexts/named-child-context.cs new file mode 100644 index 0000000..e46d94e --- /dev/null +++ b/examples/csharp/operations/child-contexts/named-child-context.cs @@ -0,0 +1,29 @@ +using Amazon.Lambda.Core; +using Amazon.Lambda.DurableExecution; + +public class NamedChildContextExample +{ + public Task Handler( + DurableExecutionInvocationInput input, ILambdaContext context) + => DurableFunction.WrapAsync(Workflow, input, context); + + private async Task Workflow(OrderEvent input, IDurableContext ctx) + { + // Omit the name to infer one from the call site + string unnamed = await ctx.RunInChildContextAsync( + async (child, _) => await child.StepAsync( + async (_, _) => $"{input.OrderId}:processed", + name: "process")); + + // Pass a name as the name argument + string named = await ctx.RunInChildContextAsync( + async (child, _) => await child.StepAsync( + async (_, _) => $"{input.OrderId}:processed", + name: "process"), + name: "process-order"); + + return $"{unnamed} | {named}"; + } +} + +public record OrderEvent(string OrderId); diff --git a/examples/csharp/operations/child-contexts/pass-arguments.cs b/examples/csharp/operations/child-contexts/pass-arguments.cs new file mode 100644 index 0000000..ebca5a1 --- /dev/null +++ b/examples/csharp/operations/child-contexts/pass-arguments.cs @@ -0,0 +1,34 @@ +using Amazon.Lambda.Core; +using Amazon.Lambda.DurableExecution; + +public class PassArgumentsExample +{ + public Task Handler( + DurableExecutionInvocationInput input, ILambdaContext context) + => DurableFunction.WrapAsync(Workflow, input, context); + + private async Task Workflow(OrderEvent input, IDurableContext ctx) + { + string userId = "user-123"; + + // Capture arguments in the closure + return await ctx.RunInChildContextAsync( + async (child, ct) => + { + ValidatedOrder validated = await child.StepAsync( + async (_, _) => Validate(input.OrderId, userId), + name: "validate"); + return await child.StepAsync( + async (_, _) => Charge(validated), + name: "charge"); + }, + name: "process-order"); + } + + private static ValidatedOrder Validate(string orderId, string userId) => new(orderId, userId); + private static ChargedOrder Charge(ValidatedOrder order) => new(order.OrderId, order.UserId, Charged: true); +} + +public record OrderEvent(string OrderId); +public record ValidatedOrder(string OrderId, string UserId); +public record ChargedOrder(string OrderId, string UserId, bool Charged); diff --git a/examples/csharp/operations/child-contexts/run-in-child-context-signature.cs b/examples/csharp/operations/child-contexts/run-in-child-context-signature.cs new file mode 100644 index 0000000..c7bf995 --- /dev/null +++ b/examples/csharp/operations/child-contexts/run-in-child-context-signature.cs @@ -0,0 +1,13 @@ +// Returns a value +Task RunInChildContextAsync( + Func> func, + string? name = null, + ChildContextConfig? config = null, + CancellationToken cancellationToken = default); + +// Returns no value +Task RunInChildContextAsync( + Func func, + string? name = null, + ChildContextConfig? config = null, + CancellationToken cancellationToken = default); diff --git a/examples/csharp/operations/child-contexts/test-child-context.cs b/examples/csharp/operations/child-contexts/test-child-context.cs new file mode 100644 index 0000000..42fd5e8 --- /dev/null +++ b/examples/csharp/operations/child-contexts/test-child-context.cs @@ -0,0 +1,33 @@ +using System.Linq; +using Amazon.Lambda.DurableExecution.Testing; +using Xunit; + +public class TestChildContext +{ + [Fact] + public async Task ChildContextSucceeds() + { + await using var runner = new DurableTestRunner( + new BasicChildContextExample().Workflow); + + TestResult result = await runner.RunAsync(new OrderEvent("order-1")); + + Assert.True(result.IsSucceeded); + Assert.Equal("order-1", result.Result!.OrderId); + Assert.True(result.Result.Charged); + } + + [Fact] + public async Task RecordsContextOperation() + { + await using var runner = new DurableTestRunner( + new BasicChildContextExample().Workflow); + + TestResult result = await runner.RunAsync(new OrderEvent("order-1")); + + // The testing SDK records child context operations as CONTEXT-kind steps. + var contextOps = result.Steps.Where(s => s.Kind == OperationKind.Context).ToList(); + Assert.NotEmpty(contextOps); + Assert.Equal(OperationStatus.Succeeded, contextOps[0].Status); + } +} diff --git a/examples/csharp/operations/invoke/handle-invocation-error.cs b/examples/csharp/operations/invoke/handle-invocation-error.cs new file mode 100644 index 0000000..f11a877 --- /dev/null +++ b/examples/csharp/operations/invoke/handle-invocation-error.cs @@ -0,0 +1,33 @@ +using Amazon.Lambda.Core; +using Amazon.Lambda.DurableExecution; + +public class HandleInvocationErrorExample +{ + public Task Handler( + DurableExecutionInvocationInput input, ILambdaContext context) + => DurableFunction.WrapAsync(Workflow, input, context); + + private async Task Workflow(OrderEvent input, IDurableContext ctx) + { + try + { + PaymentResult payment = await ctx.InvokeAsync( + "payment-processor-function:live", + input, + name: "process-payment"); + return new OrderResult(Status: "success", Reason: null, TransactionId: payment.TransactionId); + } + catch (InvokeTimedOutException) + { + return new OrderResult(Status: "failed", Reason: "payment timed out", TransactionId: null); + } + catch (InvokeFailedException e) + { + return new OrderResult(Status: "failed", Reason: e.Message, TransactionId: null); + } + } +} + +public record OrderEvent(string OrderId); +public record PaymentResult(string TransactionId); +public record OrderResult(string Status, string? Reason, string? TransactionId); diff --git a/examples/csharp/operations/invoke/invoke-method-signature.cs b/examples/csharp/operations/invoke/invoke-method-signature.cs new file mode 100644 index 0000000..331ea16 --- /dev/null +++ b/examples/csharp/operations/invoke/invoke-method-signature.cs @@ -0,0 +1,6 @@ +Task InvokeAsync( + string functionName, + TPayload payload, + string? name = null, + InvokeConfig? config = null, + CancellationToken cancellationToken = default); diff --git a/examples/csharp/operations/invoke/invoke-with-config.cs b/examples/csharp/operations/invoke/invoke-with-config.cs new file mode 100644 index 0000000..cce0340 --- /dev/null +++ b/examples/csharp/operations/invoke/invoke-with-config.cs @@ -0,0 +1,28 @@ +using Amazon.Lambda.Core; +using Amazon.Lambda.DurableExecution; + +public class InvokeWithConfigExample +{ + public Task Handler( + DurableExecutionInvocationInput input, ILambdaContext context) + => DurableFunction.WrapAsync(Workflow, input, context); + + private async Task Workflow(OrderEvent input, IDurableContext ctx) + { + var config = new InvokeConfig + { + TenantId = input.TenantId, + }; + + OrderResult result = await ctx.InvokeAsync( + "order-processor-function:live", + input, + name: "process-order", + config: config); + + return result; + } +} + +public record OrderEvent(string OrderId, string TenantId); +public record OrderResult(string Status); diff --git a/examples/csharp/operations/invoke/process-order.cs b/examples/csharp/operations/invoke/process-order.cs new file mode 100644 index 0000000..34286a0 --- /dev/null +++ b/examples/csharp/operations/invoke/process-order.cs @@ -0,0 +1,34 @@ +using Amazon.Lambda.Core; +using Amazon.Lambda.DurableExecution; + +public class ProcessOrderExample +{ + public Task Handler( + DurableExecutionInvocationInput input, ILambdaContext context) + => DurableFunction.WrapAsync(Workflow, input, context); + + private async Task Workflow(OrderEvent input, IDurableContext ctx) + { + ValidationResult validation = await ctx.InvokeAsync( + "validate-order-function:live", + input, + name: "validate-order"); + + if (!validation.Valid) + { + return new OrderResult(Status: "rejected", Reason: validation.Reason, TransactionId: null); + } + + PaymentResult payment = await ctx.InvokeAsync( + "payment-processor-function:live", + input, + name: "process-payment"); + + return new OrderResult(Status: "completed", Reason: null, TransactionId: payment.TransactionId); + } +} + +public record OrderEvent(string OrderId, decimal Amount); +public record ValidationResult(bool Valid, string? Reason); +public record PaymentResult(string TransactionId); +public record OrderResult(string Status, string? Reason, string? TransactionId); diff --git a/examples/csharp/operations/map/completion-config.cs b/examples/csharp/operations/map/completion-config.cs new file mode 100644 index 0000000..78ea3f8 --- /dev/null +++ b/examples/csharp/operations/map/completion-config.cs @@ -0,0 +1,32 @@ +using Amazon.Lambda.Core; +using Amazon.Lambda.DurableExecution; + +public class CompletionConfigExample +{ + public Task Handler( + DurableExecutionInvocationInput input, ILambdaContext context) + => DurableFunction.WrapAsync>( + Workflow, input, context); + + private async Task> Workflow( + ItemEvent input, IDurableContext ctx) + { + var config = new MapConfig + { + CompletionConfig = new CompletionConfig { MinSuccessful = 3 }, + }; + + IBatchResult result = await ctx.MapAsync( + input.Items, + async (itemCtx, item, index, items, ct) => + await itemCtx.StepAsync( + async (_, _) => item.ToUpperInvariant(), + name: $"process-{index}"), + name: "process-items", + config: config); + + return result.GetResults(); + } +} + +public record ItemEvent(IReadOnlyList Items); diff --git a/examples/csharp/operations/map/error-handling.cs b/examples/csharp/operations/map/error-handling.cs new file mode 100644 index 0000000..815f0c5 --- /dev/null +++ b/examples/csharp/operations/map/error-handling.cs @@ -0,0 +1,38 @@ +using Amazon.Lambda.Core; +using Amazon.Lambda.DurableExecution; + +public class ErrorHandlingExample +{ + public Task Handler( + DurableExecutionInvocationInput input, ILambdaContext context) + => DurableFunction.WrapAsync(Workflow, input, context); + + private async Task Workflow(ItemEvent input, IDurableContext ctx) + { + IBatchResult result = await ctx.MapAsync( + input.Items, + async (itemCtx, item, index, items, ct) => + await itemCtx.StepAsync(async (_, _) => + { + if (item == "bad") throw new InvalidOperationException("bad item"); + return item.ToUpperInvariant(); + }, name: $"process-{index}"), + name: "process-items"); + + // BatchResult captures per-item failures rather than throwing. Inspect + // HasFailure/GetErrors to handle them, or call ThrowIfError to propagate + // the first failure. + IReadOnlyList errors = + result.HasFailure ? result.GetErrors() : Array.Empty(); + IReadOnlyList successes = result.GetResults(); + + return new Summary(result.SuccessCount, result.FailureCount, successes, errors); + } +} + +public record ItemEvent(IReadOnlyList Items); +public record Summary( + int Succeeded, + int Failed, + IReadOnlyList Results, + IReadOnlyList Errors); diff --git a/examples/csharp/operations/map/map-config.cs b/examples/csharp/operations/map/map-config.cs new file mode 100644 index 0000000..c5de19e --- /dev/null +++ b/examples/csharp/operations/map/map-config.cs @@ -0,0 +1,35 @@ +using Amazon.Lambda.Core; +using Amazon.Lambda.DurableExecution; + +public class MapConfigExample +{ + private static readonly HttpClient Http = new(); + + public Task Handler( + DurableExecutionInvocationInput input, ILambdaContext context) + => DurableFunction.WrapAsync>( + Workflow, input, context); + + private async Task> Workflow( + UrlEvent input, IDurableContext ctx) + { + var config = new MapConfig + { + MaxConcurrency = 5, + CompletionConfig = new CompletionConfig { ToleratedFailureCount = 2 }, + }; + + IBatchResult result = await ctx.MapAsync( + input.Urls, + async (itemCtx, url, index, urls, ct) => + await itemCtx.StepAsync( + async (_, stepCt) => await Http.GetStringAsync(url, stepCt), + name: $"fetch-{index}"), + name: "fetch-urls", + config: config); + + return result.GetResults(); + } +} + +public record UrlEvent(IReadOnlyList Urls); diff --git a/examples/csharp/operations/map/map-function.cs b/examples/csharp/operations/map/map-function.cs new file mode 100644 index 0000000..9098fc8 --- /dev/null +++ b/examples/csharp/operations/map/map-function.cs @@ -0,0 +1,38 @@ +using Amazon.Lambda.Core; +using Amazon.Lambda.DurableExecution; + +public class MapFunctionExample +{ + public Task Handler( + DurableExecutionInvocationInput input, ILambdaContext context) + => DurableFunction.WrapAsync, IReadOnlyList>( + Workflow, input, context); + + private async Task> Workflow( + IReadOnlyList orders, IDurableContext ctx) + { + IBatchResult result = await ctx.MapAsync( + orders, ProcessOrder, name: "process-orders"); + return result.GetResults(); + } + + // The map function: receives (ctx, item, index, allItems, cancellationToken) + private static async Task ProcessOrder( + IDurableContext ctx, Order order, int index, + IReadOnlyList orders, CancellationToken ct) + { + Order validated = await ctx.StepAsync(async (_, _) => + { + if (order.Amount <= 0) throw new ArgumentException("Invalid amount"); + return order; + }, name: "validate"); + + decimal charged = await ctx.StepAsync( + async (_, _) => validated.Amount, name: "charge"); + + return new Receipt(validated.Id, charged); + } +} + +public record Order(string Id, decimal Amount); +public record Receipt(string OrderId, decimal Charged); diff --git a/examples/csharp/operations/map/map-signature.cs b/examples/csharp/operations/map/map-signature.cs new file mode 100644 index 0000000..39103a2 --- /dev/null +++ b/examples/csharp/operations/map/map-signature.cs @@ -0,0 +1,6 @@ +Task> MapAsync( + IReadOnlyList items, + Func, CancellationToken, Task> func, + string? name = null, + MapConfig? config = null, + CancellationToken cancellationToken = default); diff --git a/examples/csharp/operations/map/named-map.cs b/examples/csharp/operations/map/named-map.cs new file mode 100644 index 0000000..be048e6 --- /dev/null +++ b/examples/csharp/operations/map/named-map.cs @@ -0,0 +1,27 @@ +using Amazon.Lambda.Core; +using Amazon.Lambda.DurableExecution; + +public class NamedMapExample +{ + public Task Handler( + DurableExecutionInvocationInput input, ILambdaContext context) + => DurableFunction.WrapAsync>( + Workflow, input, context); + + private async Task> Workflow( + UserEvent input, IDurableContext ctx) + { + // The name is the optional trailing argument; omit it to leave the map unnamed + IBatchResult result = await ctx.MapAsync( + input.UserIds, + async (itemCtx, userId, index, userIds, ct) => + await itemCtx.StepAsync( + async (_, _) => $"processed-{userId}", + name: $"process-{index}"), + name: "process-users"); + + return result.GetResults(); + } +} + +public record UserEvent(IReadOnlyList UserIds); diff --git a/examples/csharp/operations/map/nested-map.cs b/examples/csharp/operations/map/nested-map.cs new file mode 100644 index 0000000..aa4b1aa --- /dev/null +++ b/examples/csharp/operations/map/nested-map.cs @@ -0,0 +1,34 @@ +using Amazon.Lambda.Core; +using Amazon.Lambda.DurableExecution; + +public class NestedMapExample +{ + public Task Handler( + DurableExecutionInvocationInput input, ILambdaContext context) + => DurableFunction.WrapAsync>>( + Workflow, input, context); + + private async Task>> Workflow( + RegionEvent input, IDurableContext ctx) + { + IBatchResult> result = await ctx.MapAsync( + input.Regions, + async (regionCtx, region, index, regions, ct) => + { + IBatchResult inner = await regionCtx.MapAsync( + region.Items, + async (itemCtx, item, i, items, innerCt) => + await itemCtx.StepAsync( + async (_, _) => item.ToUpperInvariant(), + name: $"item-{i}"), + name: $"process-{region.Name}"); + return inner.GetResults(); + }, + name: "process-regions"); + + return result.GetResults(); + } +} + +public record Region(string Name, IReadOnlyList Items); +public record RegionEvent(IReadOnlyList Regions); diff --git a/examples/csharp/operations/map/simple-map.cs b/examples/csharp/operations/map/simple-map.cs new file mode 100644 index 0000000..ec342cf --- /dev/null +++ b/examples/csharp/operations/map/simple-map.cs @@ -0,0 +1,22 @@ +using Amazon.Lambda.Core; +using Amazon.Lambda.DurableExecution; + +public class SimpleMapExample +{ + public Task Handler( + DurableExecutionInvocationInput input, ILambdaContext context) + => DurableFunction.WrapAsync>(Workflow, input, context); + + private async Task> Workflow(object input, IDurableContext ctx) + { + IBatchResult result = await ctx.MapAsync( + new[] { 1, 2, 3, 4, 5 }, + async (itemCtx, item, index, items, ct) => + await itemCtx.StepAsync( + async (_, _) => item * item, + name: $"square-{index}"), + name: "square-numbers"); + + return result.GetResults(); + } +} diff --git a/examples/csharp/operations/parallel/completion-config.cs b/examples/csharp/operations/parallel/completion-config.cs new file mode 100644 index 0000000..eb58e02 --- /dev/null +++ b/examples/csharp/operations/parallel/completion-config.cs @@ -0,0 +1,33 @@ +using Amazon.Lambda.Core; +using Amazon.Lambda.DurableExecution; + +public class CompletionConfigExample +{ + public Task Handler( + DurableExecutionInvocationInput input, ILambdaContext context) + => DurableFunction.WrapAsync(Workflow, input, context); + + private async Task Workflow(object input, IDurableContext ctx) + { + var config = new ParallelConfig + { + // Complete as soon as one branch succeeds + CompletionConfig = CompletionConfig.FirstSuccessful(), + }; + + IBatchResult result = await ctx.ParallelAsync( + new Func>[] + { + async (branch, ct) => await branch.StepAsync( + async (_, _) => "result from a", name: "source-a"), + async (branch, ct) => await branch.StepAsync( + async (_, _) => "result from b", name: "source-b"), + async (branch, ct) => await branch.StepAsync( + async (_, _) => "result from c", name: "source-c"), + }, + name: "race", + config: config); + + return result.GetResults().FirstOrDefault(); + } +} diff --git a/examples/csharp/operations/parallel/error-handling.cs b/examples/csharp/operations/parallel/error-handling.cs new file mode 100644 index 0000000..b9f384c --- /dev/null +++ b/examples/csharp/operations/parallel/error-handling.cs @@ -0,0 +1,45 @@ +using Amazon.Lambda.Core; +using Amazon.Lambda.DurableExecution; + +public class ErrorHandlingExample +{ + public Task Handler( + DurableExecutionInvocationInput input, ILambdaContext context) + => DurableFunction.WrapAsync(Workflow, input, context); + + private async Task Workflow(object input, IDurableContext ctx) + { + var config = new ParallelConfig + { + CompletionConfig = new CompletionConfig { ToleratedFailureCount = 1 }, + }; + + IBatchResult result = await ctx.ParallelAsync( + new Func>[] + { + async (branch, ct) => await branch.StepAsync( + async (_, _) => "ok", name: "task-1"), + async (branch, ct) => await branch.StepAsync( + (_, _) => throw new InvalidOperationException("task 2 failed"), + name: "task-2"), + async (branch, ct) => await branch.StepAsync( + async (_, _) => "ok", name: "task-3"), + }, + name: "tasks", + config: config); + + // BatchResult captures branch failures instead of throwing. Inspect + // GetErrors() to handle them, or call ThrowIfError() to propagate. + return new TaskSummary( + Succeeded: result.SuccessCount, + Failed: result.FailureCount, + Results: result.GetResults(), + Errors: result.GetErrors().Select(e => e.Message).ToList()); + } +} + +public record TaskSummary( + int Succeeded, + int Failed, + IReadOnlyList Results, + IReadOnlyList Errors); diff --git a/examples/csharp/operations/parallel/named-branches.cs b/examples/csharp/operations/parallel/named-branches.cs new file mode 100644 index 0000000..32ddc85 --- /dev/null +++ b/examples/csharp/operations/parallel/named-branches.cs @@ -0,0 +1,25 @@ +using Amazon.Lambda.Core; +using Amazon.Lambda.DurableExecution; + +public class NamedBranchesExample +{ + public Task Handler( + DurableExecutionInvocationInput input, ILambdaContext context) + => DurableFunction.WrapAsync>(Workflow, input, context); + + private async Task> Workflow(object input, IDurableContext ctx) + { + // Each DurableBranch carries a name surfaced on IBatchItem.Name. + IBatchResult result = await ctx.ParallelAsync( + new[] + { + new DurableBranch("task-a", async (branch, ct) => + await branch.StepAsync(async (_, _) => "a done", name: "run-a")), + new DurableBranch("task-b", async (branch, ct) => + await branch.StepAsync(async (_, _) => "b done", name: "run-b")), + }, + name: "process"); + + return result.GetResults(); + } +} diff --git a/examples/csharp/operations/parallel/nested-parallel.cs b/examples/csharp/operations/parallel/nested-parallel.cs new file mode 100644 index 0000000..3ae01ab --- /dev/null +++ b/examples/csharp/operations/parallel/nested-parallel.cs @@ -0,0 +1,45 @@ +using Amazon.Lambda.Core; +using Amazon.Lambda.DurableExecution; + +public class NestedParallelExample +{ + public Task Handler( + DurableExecutionInvocationInput input, ILambdaContext context) + => DurableFunction.WrapAsync>>( + Workflow, input, context); + + private async Task>> Workflow( + object input, IDurableContext ctx) + { + IBatchResult> outer = await ctx.ParallelAsync( + new Func>>[] + { + async (branch, ct) => + { + // A branch can call ParallelAsync to nest parallel operations. + IBatchResult inner = await branch.ParallelAsync( + new Func>[] + { + async (c, t) => await c.StepAsync(async (_, _) => "a1", name: "a1"), + async (c, t) => await c.StepAsync(async (_, _) => "a2", name: "a2"), + }, + name: "inner-a"); + return inner.GetResults(); + }, + async (branch, ct) => + { + IBatchResult inner = await branch.ParallelAsync( + new Func>[] + { + async (c, t) => await c.StepAsync(async (_, _) => "b1", name: "b1"), + async (c, t) => await c.StepAsync(async (_, _) => "b2", name: "b2"), + }, + name: "inner-b"); + return inner.GetResults(); + }, + }, + name: "outer"); + + return outer.GetResults(); + } +} diff --git a/examples/csharp/operations/parallel/parallel-config.cs b/examples/csharp/operations/parallel/parallel-config.cs new file mode 100644 index 0000000..7ddba47 --- /dev/null +++ b/examples/csharp/operations/parallel/parallel-config.cs @@ -0,0 +1,33 @@ +using Amazon.Lambda.Core; +using Amazon.Lambda.DurableExecution; + +public class ParallelConfigExample +{ + public Task Handler( + DurableExecutionInvocationInput input, ILambdaContext context) + => DurableFunction.WrapAsync(Workflow, input, context); + + private async Task Workflow(object input, IDurableContext ctx) + { + var config = new ParallelConfig + { + MaxConcurrency = 2, + CompletionConfig = CompletionConfig.FirstSuccessful(), + }; + + IBatchResult result = await ctx.ParallelAsync( + new Func>[] + { + async (branch, ct) => await branch.StepAsync( + async (_, _) => "primary result", name: "primary"), + async (branch, ct) => await branch.StepAsync( + async (_, _) => "secondary result", name: "secondary"), + async (branch, ct) => await branch.StepAsync( + async (_, _) => "cache result", name: "cache"), + }, + name: "fetch-data", + config: config); + + return result.GetResults().FirstOrDefault(); + } +} diff --git a/examples/csharp/operations/parallel/pass-arguments.cs b/examples/csharp/operations/parallel/pass-arguments.cs new file mode 100644 index 0000000..014524c --- /dev/null +++ b/examples/csharp/operations/parallel/pass-arguments.cs @@ -0,0 +1,29 @@ +using Amazon.Lambda.Core; +using Amazon.Lambda.DurableExecution; + +public class PassArgumentsExample +{ + public Task Handler( + DurableExecutionInvocationInput input, ILambdaContext context) + => DurableFunction.WrapAsync>(Workflow, input, context); + + private async Task> Workflow(object input, IDurableContext ctx) + { + var items = new[] { "a", "b", "c" }; + + // Capture each item in the closure. Copy the loop variable to a local + // so every branch captures its own value. + var branches = new List>(); + foreach (var item in items) + { + var captured = item; + branches.Add(new DurableBranch($"process-{captured}", + async (branch, ct) => await branch.StepAsync( + async (_, _) => $"processed {captured}", name: $"run-{captured}"))); + } + + IBatchResult result = await ctx.ParallelAsync(branches, name: "process-items"); + + return result.GetResults(); + } +} diff --git a/examples/csharp/operations/parallel/simple-parallel.cs b/examples/csharp/operations/parallel/simple-parallel.cs new file mode 100644 index 0000000..f08fdd8 --- /dev/null +++ b/examples/csharp/operations/parallel/simple-parallel.cs @@ -0,0 +1,26 @@ +using Amazon.Lambda.Core; +using Amazon.Lambda.DurableExecution; + +public class SimpleParallelExample +{ + public Task Handler( + DurableExecutionInvocationInput input, ILambdaContext context) + => DurableFunction.WrapAsync>(Workflow, input, context); + + private async Task> Workflow(object input, IDurableContext ctx) + { + IBatchResult result = await ctx.ParallelAsync( + new Func>[] + { + async (branch, ct) => await branch.StepAsync( + async (_, _) => "inventory ok", name: "check-inventory"), + async (branch, ct) => await branch.StepAsync( + async (_, _) => "payment ok", name: "check-payment"), + async (branch, ct) => await branch.StepAsync( + async (_, _) => "shipping ok", name: "check-shipping"), + }, + name: "check-services"); + + return result.GetResults(); + } +} diff --git a/examples/csharp/operations/steps/add-numbers.cs b/examples/csharp/operations/steps/add-numbers.cs new file mode 100644 index 0000000..05d9d24 --- /dev/null +++ b/examples/csharp/operations/steps/add-numbers.cs @@ -0,0 +1,19 @@ +using Amazon.Lambda.Core; +using Amazon.Lambda.DurableExecution; + +public class AddNumbersExample +{ + public Task Handler( + DurableExecutionInvocationInput input, ILambdaContext context) + => DurableFunction.WrapAsync(Workflow, input, context); + + private async Task Workflow(object input, IDurableContext ctx) + { + int result = await ctx.StepAsync( + async (_, _) => AddNumbers(5, 3), + name: "add_numbers"); + return result; + } + + private static int AddNumbers(int a, int b) => a + b; +} diff --git a/examples/csharp/operations/steps/lambda-step-no-name.cs b/examples/csharp/operations/steps/lambda-step-no-name.cs new file mode 100644 index 0000000..4fa8605 --- /dev/null +++ b/examples/csharp/operations/steps/lambda-step-no-name.cs @@ -0,0 +1,16 @@ +using Amazon.Lambda.Core; +using Amazon.Lambda.DurableExecution; + +public class LambdaStepNoNameExample +{ + public Task Handler( + DurableExecutionInvocationInput input, ILambdaContext context) + => DurableFunction.WrapAsync(Workflow, input, context); + + private async Task Workflow(object input, IDurableContext ctx) + { + // Omit name — the SDK infers one from the call site + string result = await ctx.StepAsync(async (_, _) => "some value"); + return result; + } +} diff --git a/examples/csharp/operations/steps/multi-argument-step.cs b/examples/csharp/operations/steps/multi-argument-step.cs new file mode 100644 index 0000000..de5af36 --- /dev/null +++ b/examples/csharp/operations/steps/multi-argument-step.cs @@ -0,0 +1,23 @@ +using Amazon.Lambda.Core; +using Amazon.Lambda.DurableExecution; + +public class MultiArgumentStepExample +{ + public Task Handler( + DurableExecutionInvocationInput input, ILambdaContext context) + => DurableFunction.WrapAsync(Workflow, input, context); + + private async Task Workflow(object input, IDurableContext ctx) + { + string arg1 = "value"; + int arg2 = 42; + + // Capture arguments in the closure + string result = await ctx.StepAsync( + async (_, _) => MyStep(arg1, arg2), + name: "my_step"); + return result; + } + + private static string MyStep(string arg1, int arg2) => $"{arg1}: {arg2}"; +} diff --git a/examples/csharp/operations/steps/passing-data-correct.cs b/examples/csharp/operations/steps/passing-data-correct.cs new file mode 100644 index 0000000..59d2e4c --- /dev/null +++ b/examples/csharp/operations/steps/passing-data-correct.cs @@ -0,0 +1,32 @@ +using Amazon.Lambda.Core; +using Amazon.Lambda.DurableExecution; + +public class PassingDataCorrectExample +{ + public Task Handler( + DurableExecutionInvocationInput input, ILambdaContext context) + => DurableFunction.WrapAsync(Workflow, input, context); + + // CORRECT: userId is returned from the step and restored from checkpoint on replay + private async Task Workflow(UserEvent input, IDurableContext ctx) + { + string userId = await ctx.StepAsync( + async (_, _) => RegisterUser(input.Email), + name: "register-user"); + + await ctx.WaitAsync(TimeSpan.FromMinutes(10), name: "follow-up-delay"); + + await ctx.StepAsync( + async (_, _) => SendFollowUpEmail(userId), // userId restored from checkpoint + name: "send-follow-up-email"); + } + + private static string RegisterUser(string email) => $"user-{email}"; + + private static void SendFollowUpEmail(string userId) + { + // send email to user + } +} + +public record UserEvent(string Email); diff --git a/examples/csharp/operations/steps/passing-data-wrong.cs b/examples/csharp/operations/steps/passing-data-wrong.cs new file mode 100644 index 0000000..469efed --- /dev/null +++ b/examples/csharp/operations/steps/passing-data-wrong.cs @@ -0,0 +1,34 @@ +using Amazon.Lambda.Core; +using Amazon.Lambda.DurableExecution; + +public class PassingDataWrongExample +{ + public Task Handler( + DurableExecutionInvocationInput input, ILambdaContext context) + => DurableFunction.WrapAsync(Workflow, input, context); + + // WRONG: userId mutation is lost on replay after the wait + private async Task Workflow(UserEvent input, IDurableContext ctx) + { + string userId = ""; + + await ctx.StepAsync( + async (_, _) => { userId = RegisterUser(input.Email); }, // Lost on replay! + name: "register-user"); + + await ctx.WaitAsync(TimeSpan.FromMinutes(10), name: "follow-up-delay"); + + await ctx.StepAsync( + async (_, _) => SendFollowUpEmail(userId), // userId is "" on replay + name: "send-follow-up-email"); + } + + private static string RegisterUser(string email) => $"user-{email}"; + + private static void SendFollowUpEmail(string userId) + { + // send email to user + } +} + +public record UserEvent(string Email); diff --git a/examples/csharp/operations/steps/process-data.cs b/examples/csharp/operations/steps/process-data.cs new file mode 100644 index 0000000..7758be7 --- /dev/null +++ b/examples/csharp/operations/steps/process-data.cs @@ -0,0 +1,29 @@ +using Amazon.Lambda.Core; +using Amazon.Lambda.DurableExecution; + +public class ProcessDataExample +{ + public Task Handler( + DurableExecutionInvocationInput input, ILambdaContext context) + => DurableFunction.WrapAsync(Workflow, input, context); + + private async Task Workflow(DataEvent input, IDurableContext ctx) + { + var config = new StepConfig + { + RetryStrategy = RetryStrategy.Exponential(maxAttempts: 3), + Semantics = StepSemantics.AtLeastOncePerRetry, + }; + + Processed result = await ctx.StepAsync( + async (_, _) => ProcessData(input.Data), + name: "process_data", + config: config); + return result; + } + + private static Processed ProcessData(string data) => new(data, Status: "completed"); +} + +public record DataEvent(string Data); +public record Processed(string Data, string Status); diff --git a/examples/csharp/operations/steps/step-signature.cs b/examples/csharp/operations/steps/step-signature.cs new file mode 100644 index 0000000..9909844 --- /dev/null +++ b/examples/csharp/operations/steps/step-signature.cs @@ -0,0 +1,13 @@ +// Returns a value +Task StepAsync( + Func> func, + string? name = null, + StepConfig? config = null, + CancellationToken cancellationToken = default); + +// Returns no value +Task StepAsync( + Func func, + string? name = null, + StepConfig? config = null, + CancellationToken cancellationToken = default); diff --git a/examples/csharp/operations/steps/validate-order.cs b/examples/csharp/operations/steps/validate-order.cs new file mode 100644 index 0000000..0b9d943 --- /dev/null +++ b/examples/csharp/operations/steps/validate-order.cs @@ -0,0 +1,22 @@ +using Amazon.Lambda.Core; +using Amazon.Lambda.DurableExecution; + +public class ValidateOrderExample +{ + public Task Handler( + DurableExecutionInvocationInput input, ILambdaContext context) + => DurableFunction.WrapAsync(Workflow, input, context); + + private async Task Workflow(OrderEvent input, IDurableContext ctx) + { + Validation validation = await ctx.StepAsync( + async (_, _) => ValidateOrder(input.OrderId), + name: "validate_order"); + return validation; + } + + private static Validation ValidateOrder(string orderId) => new(orderId, Valid: true); +} + +public record OrderEvent(string OrderId); +public record Validation(string OrderId, bool Valid); diff --git a/examples/csharp/patterns/code-organization/child-context.cs b/examples/csharp/patterns/code-organization/child-context.cs new file mode 100644 index 0000000..154d5d4 --- /dev/null +++ b/examples/csharp/patterns/code-organization/child-context.cs @@ -0,0 +1,37 @@ +using Amazon.Lambda.Core; +using Amazon.Lambda.DurableExecution; + +public class ChildContextExample +{ + public Task Handler( + DurableExecutionInvocationInput input, ILambdaContext context) + => DurableFunction.WrapAsync(Workflow, input, context); + + private async Task Workflow(Order order, IDurableContext ctx) + { + // Group the order-processing operations into one child context. + return await ctx.RunInChildContextAsync( + async (child, ct) => + { + await child.StepAsync( + async (_, _) => Validate(order), + name: "validate"); + Receipt receipt = await child.StepAsync( + async (_, _) => Charge(order), + name: "charge"); + await child.StepAsync( + async (_, _) => Schedule(order, receipt), + name: "schedule"); + return "ok"; + }, + name: "process-order"); + } + + private static ValidationResult Validate(Order order) => new(order.Id, Valid: true); + private static Receipt Charge(Order order) => new(order.Id, order.Total); + private static string Schedule(Order order, Receipt receipt) => $"ship-{order.Id}"; +} + +public record Order(string Id, decimal Total); +public record ValidationResult(string OrderId, bool Valid); +public record Receipt(string OrderId, decimal Amount); diff --git a/examples/csharp/patterns/code-organization/group-config.cs b/examples/csharp/patterns/code-organization/group-config.cs new file mode 100644 index 0000000..7b37c3c --- /dev/null +++ b/examples/csharp/patterns/code-organization/group-config.cs @@ -0,0 +1,48 @@ +using Amazon.Lambda.Core; +using Amazon.Lambda.DurableExecution; + +public class GroupConfigExample +{ + // Define shared configuration once and reuse it. The name makes the intent clear. + private static readonly StepConfig PaymentConfig = new() + { + Semantics = StepSemantics.AtMostOncePerRetry, + RetryStrategy = RetryStrategy.None, + }; + + private static readonly StepConfig IdempotentConfig = new() + { + RetryStrategy = RetryStrategy.Default, + }; + + public Task Handler( + DurableExecutionInvocationInput input, ILambdaContext context) + => DurableFunction.WrapAsync(Workflow, input, context); + + private async Task Workflow(OrderEvent input, IDurableContext ctx) + { + await ctx.StepAsync( + async (_, _) => Charge(input.Order), + name: "charge", + config: PaymentConfig); + + await ctx.StepAsync( + async (_, _) => Refund(input.Order), + name: "refund", + config: PaymentConfig); + + return await ctx.StepAsync( + async (_, _) => GetUser(input.UserId), + name: "fetch-user", + config: IdempotentConfig); + } + + private static Receipt Charge(Order order) => new(order.Id, order.Total); + private static Receipt Refund(Order order) => new(order.Id, -order.Total); + private static User GetUser(string id) => new(id, "Ada"); +} + +public record Order(string Id, decimal Total); +public record OrderEvent(Order Order, string UserId); +public record Receipt(string OrderId, decimal Amount); +public record User(string Id, string Name); diff --git a/examples/csharp/patterns/code-organization/parallelism.cs b/examples/csharp/patterns/code-organization/parallelism.cs new file mode 100644 index 0000000..c0aee9e --- /dev/null +++ b/examples/csharp/patterns/code-organization/parallelism.cs @@ -0,0 +1,43 @@ +using Amazon.Lambda.Core; +using Amazon.Lambda.DurableExecution; + +public class ParallelismExample +{ + public Task Handler( + DurableExecutionInvocationInput input, ILambdaContext context) + => DurableFunction.WrapAsync>( + Workflow, input, context); + + private async Task> Workflow(ItemsEvent input, IDurableContext ctx) + { + // A small number of fixed, named branches. + IBatchResult enrich = await ctx.ParallelAsync( + new[] + { + new DurableBranch("fx", async (branch, ct) => + await branch.StepAsync(async (_, _) => FxRatesLatest(), name: "fx")), + new DurableBranch("weather", async (branch, ct) => + await branch.StepAsync(async (_, _) => WeatherApiGet(), name: "weather")), + new DurableBranch("quote", async (branch, ct) => + await branch.StepAsync(async (_, _) => QuoteApiGet(), name: "quote")), + }, + name: "enrich"); + + // A variable number of items. + IBatchResult processed = await ctx.MapAsync( + input.Items, + async (itemCtx, item, index, items, ct) => + await itemCtx.StepAsync(async (_, _) => Process(item), name: $"process-{index}"), + name: "process-items", + config: new MapConfig { MaxConcurrency = 10 }); + + return enrich.GetResults().Concat(processed.GetResults()).ToList(); + } + + private static string FxRatesLatest() => "fx"; + private static string WeatherApiGet() => "weather"; + private static string QuoteApiGet() => "quote"; + private static string Process(string item) => $"processed-{item}"; +} + +public record ItemsEvent(IReadOnlyList Items); diff --git a/examples/csharp/patterns/code-organization/separate-logic.cs b/examples/csharp/patterns/code-organization/separate-logic.cs new file mode 100644 index 0000000..052ec41 --- /dev/null +++ b/examples/csharp/patterns/code-organization/separate-logic.cs @@ -0,0 +1,42 @@ +using Amazon.Lambda.Core; +using Amazon.Lambda.DurableExecution; + +// Business logic: no IDurableContext, pure work. +public static class OrderLogic +{ + public static ValidationResult ValidateOrder(Order order) => new(order.Id, Valid: true); + + public static Receipt ChargePayment(Order order) => new(order.Id, order.Total); + + public static string ScheduleShipment(Order order) => $"ship-{order.Id}"; +} + +// Orchestration: the workflow reads as a sequence of intent. +public class SeparateLogicExample +{ + public Task Handler( + DurableExecutionInvocationInput input, ILambdaContext context) + => DurableFunction.WrapAsync(Workflow, input, context); + + private async Task Workflow(Order order, IDurableContext ctx) + { + await ctx.StepAsync( + async (_, _) => OrderLogic.ValidateOrder(order), + name: "validate"); + + Receipt receipt = await ctx.StepAsync( + async (_, _) => OrderLogic.ChargePayment(order), + name: "charge"); + + string shipmentId = await ctx.StepAsync( + async (_, _) => OrderLogic.ScheduleShipment(order), + name: "schedule"); + + return new OrderResult(receipt, shipmentId); + } +} + +public record Order(string Id, decimal Total, string Address, string CardToken); +public record ValidationResult(string OrderId, bool Valid); +public record Receipt(string OrderId, decimal Amount); +public record OrderResult(Receipt Receipt, string ShipmentId); diff --git a/examples/csharp/patterns/determinism/non-deterministic-in-step.cs b/examples/csharp/patterns/determinism/non-deterministic-in-step.cs new file mode 100644 index 0000000..40e8e74 --- /dev/null +++ b/examples/csharp/patterns/determinism/non-deterministic-in-step.cs @@ -0,0 +1,30 @@ +using Amazon.Lambda.Core; +using Amazon.Lambda.DurableExecution; + +public class NonDeterministicInStepExample +{ + public Task Handler( + DurableExecutionInvocationInput input, ILambdaContext context) + => DurableFunction.WrapAsync(Workflow, input, context); + + private async Task Workflow(ChargeInput input, IDurableContext ctx) + { + // Wrap the non-deterministic call in a step so the value is checkpointed. + string transactionId = await ctx.StepAsync( + async (_, _) => Guid.NewGuid().ToString(), + name: "generate-transaction-id"); + + Receipt receipt = await ctx.StepAsync( + async (_, _) => Charge(input.Amount, transactionId), + name: "charge"); + + return new ChargeResult(transactionId, receipt); + } + + private static Receipt Charge(decimal amount, string transactionId) + => new(transactionId, amount); +} + +public record ChargeInput(decimal Amount); +public record Receipt(string TransactionId, decimal Amount); +public record ChargeResult(string TransactionId, Receipt Receipt); diff --git a/examples/csharp/patterns/determinism/return-value-passing.cs b/examples/csharp/patterns/determinism/return-value-passing.cs new file mode 100644 index 0000000..b39ecf8 --- /dev/null +++ b/examples/csharp/patterns/determinism/return-value-passing.cs @@ -0,0 +1,48 @@ +using Amazon.Lambda.Core; +using Amazon.Lambda.DurableExecution; + +public class ReturnValuePassingExample +{ + public Task Handler( + DurableExecutionInvocationInput input, ILambdaContext context) + => DurableFunction.WrapAsync(Workflow, input, context); + + // Right: each step returns the new running total, restored from checkpoint on replay. + private async Task Workflow(OrderBatch input, IDurableContext ctx) + { + decimal total = 0m; + foreach (Item item in input.Items) + { + decimal running = total; + total = await ctx.StepAsync( + async (_, _) => + { + SaveItem(item); + return running + item.Price; + }, + name: $"save-{item.Id}"); + } + return new BatchTotal(total); + } + + // Wrong: total mutates outside the step, replay restarts it at 0. + private async Task BrokenWorkflow(OrderBatch input, IDurableContext ctx) + { + decimal total = 0m; + foreach (Item item in input.Items) + { + await ctx.StepAsync(async (_, _) => SaveItem(item), name: $"save-{item.Id}"); + total += item.Price; // Lost on replay! + } + return new BatchTotal(total); + } + + private static void SaveItem(Item item) + { + // persist item + } +} + +public record Item(string Id, decimal Price); +public record OrderBatch(IReadOnlyList Items); +public record BatchTotal(decimal Total); diff --git a/examples/csharp/patterns/determinism/stable-branches.cs b/examples/csharp/patterns/determinism/stable-branches.cs new file mode 100644 index 0000000..6037392 --- /dev/null +++ b/examples/csharp/patterns/determinism/stable-branches.cs @@ -0,0 +1,49 @@ +using Amazon.Lambda.Core; +using Amazon.Lambda.DurableExecution; + +public class StableBranchesExample +{ + public Task Handler( + DurableExecutionInvocationInput input, ILambdaContext context) + => DurableFunction.WrapAsync(Workflow, input, context); + + // Right: the SDK checkpoints the decision, so replay walks the same branch. + private async Task Workflow(object input, IDurableContext ctx) + { + string shift = await ctx.StepAsync( + async (_, _) => DateTime.UtcNow.Hour < 12 ? "morning" : "afternoon", + name: "pick-shift"); + + if (shift == "morning") + { + await ctx.StepAsync(async (_, _) => RunMorning(), name: "morning-work"); + } + else + { + await ctx.StepAsync(async (_, _) => RunAfternoon(), name: "afternoon-work"); + } + } + + // Wrong: replay may see a different value of DateTime.UtcNow. + private async Task BrokenWorkflow(object input, IDurableContext ctx) + { + if (DateTime.UtcNow.Hour < 12) + { + await ctx.StepAsync(async (_, _) => RunMorning(), name: "morning-work"); + } + else + { + await ctx.StepAsync(async (_, _) => RunAfternoon(), name: "afternoon-work"); + } + } + + private static void RunMorning() + { + // morning workload + } + + private static void RunAfternoon() + { + // afternoon workload + } +} diff --git a/examples/csharp/patterns/idempotency/choose-semantics.cs b/examples/csharp/patterns/idempotency/choose-semantics.cs new file mode 100644 index 0000000..6162c9e --- /dev/null +++ b/examples/csharp/patterns/idempotency/choose-semantics.cs @@ -0,0 +1,44 @@ +using Amazon.Lambda.Core; +using Amazon.Lambda.DurableExecution; + +public class ChooseSemanticsExample +{ + public Task Handler( + DurableExecutionInvocationInput input, ILambdaContext context) + => DurableFunction.WrapAsync(Workflow, input, context); + + private async Task Workflow(PaymentEvent input, IDurableContext ctx) + { + // At-least-once (default) for a retryable idempotent write. + await ctx.StepAsync( + async (_, ct) => await UserStore.UpsertAsync(input.User, ct), + name: "upsert-user"); + + // At-most-once for a side-effecting call, with retries disabled. + var critical = new StepConfig + { + Semantics = StepSemantics.AtMostOncePerRetry, + RetryStrategy = RetryStrategy.None, + }; + + return await ctx.StepAsync( + async (_, ct) => await PaymentService.ChargeAsync(input.Amount, input.CardToken, ct), + name: "charge-payment", + config: critical); + } +} + +public record PaymentEvent(User User, decimal Amount, string CardToken); +public record User(string Id); +public record Receipt(string TransactionId); + +public static class UserStore +{ + public static Task UpsertAsync(User user, CancellationToken ct) => Task.FromResult(user); +} + +public static class PaymentService +{ + public static Task ChargeAsync(decimal amount, string cardToken, CancellationToken ct) + => Task.FromResult(new Receipt("txn-1")); +} diff --git a/examples/csharp/patterns/idempotency/idempotency-tokens.cs b/examples/csharp/patterns/idempotency/idempotency-tokens.cs new file mode 100644 index 0000000..3148e1c --- /dev/null +++ b/examples/csharp/patterns/idempotency/idempotency-tokens.cs @@ -0,0 +1,33 @@ +using Amazon.Lambda.Core; +using Amazon.Lambda.DurableExecution; + +public class IdempotencyTokensExample +{ + public Task Handler( + DurableExecutionInvocationInput input, ILambdaContext context) + => DurableFunction.WrapAsync(Workflow, input, context); + + private async Task Workflow(PaymentEvent input, IDurableContext ctx) + { + // Derive a stable idempotency key inside a step. The step's OperationId is + // deterministic across replays, so the key stays the same on every attempt. + string idempotencyKey = await ctx.StepAsync( + async (stepCtx, _) => stepCtx.OperationId, + name: "idempotency-key"); + + return await ctx.StepAsync( + async (_, ct) => await PaymentService.ChargeAsync( + input.Amount, input.CardToken, idempotencyKey, ct), + name: "charge"); + } +} + +public record PaymentEvent(decimal Amount, string CardToken); +public record Receipt(string TransactionId); + +public static class PaymentService +{ + public static Task ChargeAsync( + decimal amount, string cardToken, string idempotencyKey, CancellationToken ct) + => Task.FromResult(new Receipt(idempotencyKey)); +} diff --git a/examples/csharp/patterns/pause-resume/callback-timeout.cs b/examples/csharp/patterns/pause-resume/callback-timeout.cs new file mode 100644 index 0000000..9c83f9c --- /dev/null +++ b/examples/csharp/patterns/pause-resume/callback-timeout.cs @@ -0,0 +1,40 @@ +using Amazon.Lambda.Core; +using Amazon.Lambda.DurableExecution; + +public class CallbackTimeoutExample +{ + public Task Handler( + DurableExecutionInvocationInput input, ILambdaContext context) + => DurableFunction.WrapAsync(Workflow, input, context); + + private async Task Workflow(OrderEvent input, IDurableContext ctx) + { + // Always set a timeout on a callback. Without one the execution waits up + // to the execution timeout, holding the resource slot until an operator + // intervenes. + var config = new WaitForCallbackConfig + { + Timeout = TimeSpan.FromHours(24), + }; + + // The submitter hands the service-allocated callbackId to the external + // system. The external system later calls the SDK's callback success or + // failure endpoint with that id. + Approval outcome = await ctx.WaitForCallbackAsync( + async (callbackId, _, ct) => + await ApprovalsService.RequestAsync(input.OrderId, callbackId, ct), + name: "wait-for-approval", + config: config); + + return outcome; + } +} + +public record OrderEvent(string OrderId); +public record Approval(bool Approved); + +public static class ApprovalsService +{ + public static Task RequestAsync(string orderId, string callbackId, CancellationToken ct) + => Task.CompletedTask; +} diff --git a/examples/csharp/patterns/pause-resume/heartbeat-timeout.cs b/examples/csharp/patterns/pause-resume/heartbeat-timeout.cs new file mode 100644 index 0000000..b1ee28e --- /dev/null +++ b/examples/csharp/patterns/pause-resume/heartbeat-timeout.cs @@ -0,0 +1,41 @@ +using Amazon.Lambda.Core; +using Amazon.Lambda.DurableExecution; + +public class HeartbeatTimeoutExample +{ + public Task Handler( + DurableExecutionInvocationInput input, ILambdaContext context) + => DurableFunction.WrapAsync(Workflow, input, context); + + private async Task Workflow(JobEvent input, IDurableContext ctx) + { + // HeartbeatTimeout fails the callback if the external worker stops + // checking in, even before the overall Timeout elapses. Set it + // comfortably longer than the expected interval between heartbeats but + // shorter than the overall operation timeout. + var config = new WaitForCallbackConfig + { + Timeout = TimeSpan.FromHours(24), + HeartbeatTimeout = TimeSpan.FromMinutes(10), + }; + + // The external system must call the SDK's heartbeat endpoint periodically + // while the work is in progress, and the success/failure endpoint when done. + JobResult outcome = await ctx.WaitForCallbackAsync( + async (callbackId, _, ct) => + await JobService.StartAsync(input.JobId, callbackId, ct), + name: "long-running-job", + config: config); + + return outcome; + } +} + +public record JobEvent(string JobId); +public record JobResult(string Status); + +public static class JobService +{ + public static Task StartAsync(string jobId, string callbackId, CancellationToken ct) + => Task.CompletedTask; +} diff --git a/examples/csharp/patterns/pause-resume/wait-for-condition.cs b/examples/csharp/patterns/pause-resume/wait-for-condition.cs new file mode 100644 index 0000000..3fd627c --- /dev/null +++ b/examples/csharp/patterns/pause-resume/wait-for-condition.cs @@ -0,0 +1,52 @@ +using Amazon.Lambda.Core; +using Amazon.Lambda.DurableExecution; + +public class WaitForConditionExample +{ + public Task Handler( + DurableExecutionInvocationInput input, ILambdaContext context) + => DurableFunction.WrapAsync(Workflow, input, context); + + private async Task Workflow(JobEvent input, IDurableContext ctx) + { + // Use an exponential-backoff wait strategy so an unresponsive downstream + // system does not create a retry storm. The isDone predicate stops + // polling once the state satisfies the condition. + var strategy = WaitStrategy.Exponential( + maxAttempts: 60, + initialDelay: TimeSpan.FromSeconds(5), + maxDelay: TimeSpan.FromMinutes(1), + backoffRate: 2.0, + jitter: JitterStrategy.Full, + isDone: state => state.Status == "completed"); + + var config = new WaitForConditionConfig + { + InitialState = new JobState(input.JobId, "pending"), + WaitStrategy = strategy, + }; + + // The SDK runs the check on each poll, applies the wait strategy between + // polls (suspending the execution — no compute charge), and resumes when + // the strategy stops. Each poll is a step. + JobState finalState = await ctx.WaitForConditionAsync( + async (state, _, ct) => + { + string status = await JobService.GetStatusAsync(state.JobId, ct); + return state with { Status = status }; + }, + config, + name: "wait-for-job"); + + return finalState; + } +} + +public record JobEvent(string JobId); +public record JobState(string JobId, string Status); + +public static class JobService +{ + public static Task GetStatusAsync(string jobId, CancellationToken ct) + => Task.FromResult("completed"); +} diff --git a/examples/csharp/patterns/pause-resume/wait-vs-sleep.cs b/examples/csharp/patterns/pause-resume/wait-vs-sleep.cs new file mode 100644 index 0000000..ae49efa --- /dev/null +++ b/examples/csharp/patterns/pause-resume/wait-vs-sleep.cs @@ -0,0 +1,28 @@ +using Amazon.Lambda.Core; +using Amazon.Lambda.DurableExecution; +using Microsoft.Extensions.Logging; + +public class WaitVsSleepExample +{ + public Task Handler( + DurableExecutionInvocationInput input, ILambdaContext context) + => DurableFunction.WrapAsync(Workflow, input, context); + + private async Task Workflow(CoolOffEvent input, IDurableContext ctx) + { + // Do NOT use Thread.Sleep or Task.Delay to pause a durable function. + // They keep the invocation running (billing compute) and reset to zero + // on replay: + // await Task.Delay(TimeSpan.FromHours(24)); // wrong + + // Use WaitAsync instead: the SDK suspends the execution, checkpoints the + // wait, and re-invokes the handler when it elapses. No compute charge + // while suspended. Name every wait so it reads clearly in logs and tests. + await ctx.WaitAsync(TimeSpan.FromHours(24), name: "cool-off"); + + ctx.Logger.LogInformation("Cool-off complete for {OrderId}", input.OrderId); + return "done"; + } +} + +public record CoolOffEvent(string OrderId); diff --git a/examples/csharp/patterns/state/batch-result-pointers.cs b/examples/csharp/patterns/state/batch-result-pointers.cs new file mode 100644 index 0000000..43dd5ee --- /dev/null +++ b/examples/csharp/patterns/state/batch-result-pointers.cs @@ -0,0 +1,35 @@ +using Amazon.Lambda.Core; +using Amazon.Lambda.DurableExecution; + +public class BatchResultPointersExample +{ + public Task Handler( + DurableExecutionInvocationInput input, ILambdaContext context) + => DurableFunction.WrapAsync, IReadOnlyList>( + Workflow, input, context); + + private async Task> Workflow( + IReadOnlyList items, IDurableContext ctx) + { + IBatchResult results = await ctx.MapAsync( + items, + async (itemCtx, item, index, allItems, ct) => + await itemCtx.StepAsync( + async (_, _) => + { + Output output = ProcessItem(item); + return StoreOutput(output); // returns an S3 key, not the output itself + }, + name: $"process-{index}"), + name: "process-items"); + + // `results` carries pointers, not payloads. + return results.GetResults(); + } + + private static Output ProcessItem(Item item) => new Output(item.Id); + private static string StoreOutput(Output output) => $"s3://outputs/{output.Id}"; +} + +public record Item(string Id); +public record Output(string Id); diff --git a/examples/csharp/patterns/state/durable-vs-local.cs b/examples/csharp/patterns/state/durable-vs-local.cs new file mode 100644 index 0000000..83a43dc --- /dev/null +++ b/examples/csharp/patterns/state/durable-vs-local.cs @@ -0,0 +1,28 @@ +using Amazon.Lambda.Core; +using Amazon.Lambda.DurableExecution; + +public class DurableVsLocalExample +{ + public Task Handler( + DurableExecutionInvocationInput input, ILambdaContext context) + => DurableFunction.WrapAsync(Workflow, input, context); + + private async Task Workflow(object input, IDurableContext ctx) + { + // The step return value is checkpointed as durable state. + Value value = await ctx.StepAsync(async (_, _) => FetchValue(), name: "fetch"); + + // Assigning or copying the local variable does NOT add to durable state. + Value alias = value; + Value copy = value with { }; // record copy + + // Returning the local variable from the handler DOES add it to durable state + // (the handler output is serialized and stored). + return new Result(value); + } + + private static Value FetchValue() => new Value("payload"); +} + +public record Value(string Data); +public record Result(Value Value); diff --git a/examples/csharp/patterns/state/store-references.cs b/examples/csharp/patterns/state/store-references.cs new file mode 100644 index 0000000..279edb2 --- /dev/null +++ b/examples/csharp/patterns/state/store-references.cs @@ -0,0 +1,53 @@ +using Amazon.Lambda.Core; +using Amazon.Lambda.DurableExecution; + +public class StoreReferencesExample +{ + public Task Handler( + DurableExecutionInvocationInput input, ILambdaContext context) + => DurableFunction.WrapAsync(Workflow, input, context); + + private async Task Workflow(DocumentEvent input, IDurableContext ctx) + { + // Wrong: full document returned and checkpointed. + Document document = await ctx.StepAsync( + async (_, ct) => await S3.GetObjectAsync("docs", input.Key, ct), + name: "fetch-document"); + await ctx.StepAsync( + async (_, _) => Summarize(document), + name: "summarize-wrong"); + + // Right: only the reference flows between steps. + DocumentRef reference = await ctx.StepAsync( + async (_, ct) => + { + Document data = await S3.GetObjectAsync("docs", input.Key, ct); + string stagedKey = StageForProcessing(data); + return new DocumentRef("processing", stagedKey); + }, + name: "stage-document"); + + string summary = await ctx.StepAsync( + async (_, ct) => + { + Document data = await S3.GetObjectAsync(reference.Bucket, reference.Key, ct); + return Summarize(data); + }, + name: "summarize"); + + return summary; + } + + private static string Summarize(Document data) => $"summary-of-{data.Body}"; + private static string StageForProcessing(Document data) => $"staged/{data.Body}"; +} + +public record DocumentEvent(string Key); +public record Document(string Body); +public record DocumentRef(string Bucket, string Key); + +internal static class S3 +{ + public static Task GetObjectAsync(string bucket, string key, CancellationToken ct) => + Task.FromResult(new Document($"{bucket}/{key}")); +} diff --git a/examples/csharp/patterns/step-design/handle-errors-in-step.cs b/examples/csharp/patterns/step-design/handle-errors-in-step.cs new file mode 100644 index 0000000..20a1466 --- /dev/null +++ b/examples/csharp/patterns/step-design/handle-errors-in-step.cs @@ -0,0 +1,39 @@ +using Amazon.Lambda.Core; +using Amazon.Lambda.DurableExecution; + +public class HandleErrorsInStepExample +{ + public Task Handler( + DurableExecutionInvocationInput input, ILambdaContext context) + => DurableFunction.WrapAsync(Workflow, input, context); + + private async Task Workflow(ApiEvent input, IDurableContext ctx) + { + // Retry only transient errors. Anything else (bad input, not-found) fails immediately. + var config = new StepConfig + { + RetryStrategy = RetryStrategy.Exponential( + maxAttempts: 5, + initialDelay: TimeSpan.FromSeconds(2), + maxDelay: TimeSpan.FromMinutes(1), + retryableExceptions: new[] { typeof(TransientApiException), typeof(RateLimitException) }), + }; + + return await ctx.StepAsync( + async (_, ct) => await ExternalApi.Get(input.Id, ct), + name: "call-api", + config: config); + } +} + +public class TransientApiException : Exception { } +public class RateLimitException : Exception { } + +public record ApiEvent(string Id); +public record Record(string Id, string Data); + +public static class ExternalApi +{ + public static Task Get(string id, CancellationToken ct) + => Task.FromResult(new Record(id, "data")); +} diff --git a/examples/csharp/patterns/step-design/one-thing-per-step.cs b/examples/csharp/patterns/step-design/one-thing-per-step.cs new file mode 100644 index 0000000..b268b8f --- /dev/null +++ b/examples/csharp/patterns/step-design/one-thing-per-step.cs @@ -0,0 +1,32 @@ +using Amazon.Lambda.Core; +using Amazon.Lambda.DurableExecution; + +public class OneThingPerStepExample +{ + public Task Handler( + DurableExecutionInvocationInput input, ILambdaContext context) + => DurableFunction.WrapAsync(Workflow, input, context); + + private async Task Workflow(Order order, IDurableContext ctx) + { + // Right: each side effect gets its own step. + Receipt receipt = await ctx.StepAsync( + async (_, ct) => await ChargePayment(order, ct), + name: "charge-payment"); + await ctx.StepAsync( + async (_, ct) => await SendConfirmationEmail(order, ct), + name: "send-confirmation"); + await ctx.StepAsync( + async (_, ct) => await UpdateInventory(order, ct), + name: "update-inventory"); + return receipt; + } + + private static Task ChargePayment(Order order, CancellationToken ct) + => Task.FromResult(new Receipt(order.OrderId, Charged: true)); + private static Task SendConfirmationEmail(Order order, CancellationToken ct) => Task.CompletedTask; + private static Task UpdateInventory(Order order, CancellationToken ct) => Task.CompletedTask; +} + +public record Order(string OrderId); +public record Receipt(string OrderId, bool Charged); diff --git a/examples/csharp/patterns/step-design/reusable-step.cs b/examples/csharp/patterns/step-design/reusable-step.cs new file mode 100644 index 0000000..db53b14 --- /dev/null +++ b/examples/csharp/patterns/step-design/reusable-step.cs @@ -0,0 +1,24 @@ +using Amazon.Lambda.Core; +using Amazon.Lambda.DurableExecution; + +public class ReusableStepExample +{ + public Task Handler( + DurableExecutionInvocationInput input, ILambdaContext context) + => DurableFunction.WrapAsync(Workflow, input, context); + + private async Task Workflow(Order order, IDurableContext ctx) + { + // Reference the reusable method as the step body. + return await ctx.StepAsync( + async (_, ct) => await ValidateOrder(order, ct), + name: "validate-order"); + } + + // Define a reusable step method once and reference it repeatedly. + private static Task ValidateOrder(Order order, CancellationToken ct) + => Task.FromResult(new ValidationResult(order.OrderId, Valid: true)); +} + +public record Order(string OrderId); +public record ValidationResult(string OrderId, bool Valid); diff --git a/examples/csharp/patterns/step-design/step-boundary.cs b/examples/csharp/patterns/step-design/step-boundary.cs new file mode 100644 index 0000000..a138b3b --- /dev/null +++ b/examples/csharp/patterns/step-design/step-boundary.cs @@ -0,0 +1,30 @@ +using Amazon.Lambda.Core; +using Amazon.Lambda.DurableExecution; + +public class StepBoundaryExample +{ + public Task Handler( + DurableExecutionInvocationInput input, ILambdaContext context) + => DurableFunction.WrapAsync(Workflow, input, context); + + private async Task Workflow(object input, IDurableContext ctx) + { + // Wrong: you cannot call another durable operation inside a step body. + // The step body receives an IStepContext, not an IDurableContext, so there + // is no StepAsync to call. Grouping durable operations belongs in a child + // context instead. + + // Right: group durable operations in a child context. + return await ctx.RunInChildContextAsync( + async (child, ct) => + { + await child.StepAsync(async (_, _) => Validate(), name: "validate"); + await child.StepAsync(async (_, _) => Charge(), name: "charge"); + return "done"; + }, + name: "order-pipeline"); + } + + private static bool Validate() => true; + private static bool Charge() => true; +} diff --git a/examples/csharp/patterns/step-design/step-names.cs b/examples/csharp/patterns/step-design/step-names.cs new file mode 100644 index 0000000..baad330 --- /dev/null +++ b/examples/csharp/patterns/step-design/step-names.cs @@ -0,0 +1,31 @@ +using Amazon.Lambda.Core; +using Amazon.Lambda.DurableExecution; + +public class StepNamesExample +{ + public Task Handler( + DurableExecutionInvocationInput input, ILambdaContext context) + => DurableFunction.WrapAsync(Workflow, input, context); + + private async Task Workflow(OrderEvent input, IDurableContext ctx) + { + // Stable, descriptive name. + ValidationResult validation = await ctx.StepAsync( + async (_, _) => ValidateOrder(input), + name: "validate-order"); + + // Dynamic but deterministic: include the item ID from the input. + await ctx.StepAsync( + async (_, _) => SaveItem(input.Item), + name: $"save-item-{input.Item.Id}"); + + return validation.OrderId; + } + + private static ValidationResult ValidateOrder(OrderEvent order) => new(order.Item.Id, Valid: true); + private static void SaveItem(Item item) { /* persist item */ } +} + +public record OrderEvent(Item Item); +public record Item(string Id); +public record ValidationResult(string OrderId, bool Valid); diff --git a/examples/csharp/sdk-reference/error-handling/basic-error-handling.cs b/examples/csharp/sdk-reference/error-handling/basic-error-handling.cs new file mode 100644 index 0000000..662776e --- /dev/null +++ b/examples/csharp/sdk-reference/error-handling/basic-error-handling.cs @@ -0,0 +1,38 @@ +using Amazon.Lambda.Core; +using Amazon.Lambda.DurableExecution; +using Microsoft.Extensions.Logging; + +public class BasicErrorHandlingExample +{ + public Task Handler( + DurableExecutionInvocationInput input, ILambdaContext context) + => DurableFunction.WrapAsync(Workflow, input, context); + + private async Task Workflow(OrderEvent input, IDurableContext ctx) + { + try + { + OrderResult result = await ctx.StepAsync( + async (_, _) => + { + if (string.IsNullOrEmpty(input.OrderId)) + { + throw new InvalidOperationException("orderId is required"); + } + return new OrderResult(input.OrderId, Status: "processed"); + }, + name: "process-order"); + return result; + } + catch (StepException e) + { + // The step exhausted its retries; the SDK checkpointed the final + // error and threw it here. + ctx.Logger.LogError("Step failed: {Message}", e.Message); + return new OrderResult(input.OrderId, Status: "error", Error: e.Message); + } + } +} + +public record OrderEvent(string OrderId); +public record OrderResult(string OrderId, string Status, string? Error = null); diff --git a/examples/csharp/sdk-reference/error-handling/custom-retry-strategy.cs b/examples/csharp/sdk-reference/error-handling/custom-retry-strategy.cs new file mode 100644 index 0000000..c32f0ca --- /dev/null +++ b/examples/csharp/sdk-reference/error-handling/custom-retry-strategy.cs @@ -0,0 +1,34 @@ +using Amazon.Lambda.Core; +using Amazon.Lambda.DurableExecution; + +public class CustomRetryStrategyExample +{ + public Task Handler( + DurableExecutionInvocationInput input, ILambdaContext context) + => DurableFunction.WrapAsync(Workflow, input, context); + + // RetryStrategy.FromDelegate takes (Exception error, int attempt) => RetryDecision. + // attempt is 1-based: 1 on the first retry, 2 on the second, etc. + private static readonly IRetryStrategy CustomStrategy = RetryStrategy.FromDelegate((error, attempt) => + { + if (attempt >= 4) + { + return RetryDecision.DoNotRetry(); + } + + // Fixed 2-second delay regardless of attempt number. + return RetryDecision.RetryAfter(TimeSpan.FromSeconds(2)); + }); + + private async Task Workflow(object input, IDurableContext ctx) + { + var config = new StepConfig { RetryStrategy = CustomStrategy }; + + return await ctx.StepAsync( + async (_, _) => CallApi(), + name: "call-api", + config: config); + } + + private static string CallApi() => "ok"; +} diff --git a/examples/csharp/sdk-reference/error-handling/exception-hierarchy.cs b/examples/csharp/sdk-reference/error-handling/exception-hierarchy.cs new file mode 100644 index 0000000..171a964 --- /dev/null +++ b/examples/csharp/sdk-reference/error-handling/exception-hierarchy.cs @@ -0,0 +1,22 @@ +using Amazon.Lambda.DurableExecution; + +// DurableExecutionException — base class for all SDK exceptions +// NonDeterministicExecutionException — non-deterministic replay detected +// StepException — step failed after retries exhausted +// StepInterruptedException — at-most-once step interrupted mid-attempt +// ChildContextException — child context's user function failed +// InvokeException — chained invoke failed +// InvokeFailedException — invoked function ran and threw (FAILED) +// InvokeTimedOutException — invoked function reached TIMED_OUT +// InvokeStoppedException — invocation stopped administratively +// CallbackException — parent class for callback failures +// CallbackFailedException — external system reported a failure +// CallbackTimeoutException — callback (or heartbeat) timeout elapsed +// CallbackSubmitterException — submitter step failed after retries +// ParallelException — parallel exceeded failure tolerance +// MapException — map exceeded failure tolerance +// WaitForConditionException — wait-for-condition hit max attempts +// +// OperationCanceledException (System) — NOT a DurableExecutionException. Thrown +// when the linked CancellationToken trips (workflow shutdown or cooperative +// short-circuit). Treated as cancellation, not a step failure. diff --git a/examples/csharp/sdk-reference/error-handling/exponential-backoff.cs b/examples/csharp/sdk-reference/error-handling/exponential-backoff.cs new file mode 100644 index 0000000..253b9f5 --- /dev/null +++ b/examples/csharp/sdk-reference/error-handling/exponential-backoff.cs @@ -0,0 +1,29 @@ +using Amazon.Lambda.Core; +using Amazon.Lambda.DurableExecution; + +public class ExponentialBackoffExample +{ + public Task Handler( + DurableExecutionInvocationInput input, ILambdaContext context) + => DurableFunction.WrapAsync(Workflow, input, context); + + private async Task Workflow(object input, IDurableContext ctx) + { + var config = new StepConfig + { + RetryStrategy = RetryStrategy.Exponential( + maxAttempts: 3, + initialDelay: TimeSpan.FromSeconds(1), + maxDelay: TimeSpan.FromSeconds(10), + backoffRate: 2.0, + jitter: JitterStrategy.Full), + }; + + string result = await ctx.StepAsync( + async (_, _) => "Step with exponential backoff", + name: "retry_step", + config: config); + + return $"Result: {result}"; + } +} diff --git a/examples/csharp/sdk-reference/error-handling/jitter-strategy-signature.cs b/examples/csharp/sdk-reference/error-handling/jitter-strategy-signature.cs new file mode 100644 index 0000000..1aa5b06 --- /dev/null +++ b/examples/csharp/sdk-reference/error-handling/jitter-strategy-signature.cs @@ -0,0 +1,6 @@ +public enum JitterStrategy +{ + None, // exact calculated delay + Full, // random between 0 and base_delay + Half // random between 50% and 100% of base_delay +} diff --git a/examples/csharp/sdk-reference/error-handling/linear-retry-strategy-config-signature.cs b/examples/csharp/sdk-reference/error-handling/linear-retry-strategy-config-signature.cs new file mode 100644 index 0000000..ac6c818 --- /dev/null +++ b/examples/csharp/sdk-reference/error-handling/linear-retry-strategy-config-signature.cs @@ -0,0 +1,8 @@ +// .NET ships no linear strategy. Build one with RetryStrategy.FromDelegate and +// compute a constant or linearly growing delay yourself. +public static IRetryStrategy RetryStrategy.FromDelegate( + Func strategy); + +// The delegate returns a RetryDecision: +public static RetryDecision RetryDecision.RetryAfter(TimeSpan delay); +public static RetryDecision RetryDecision.DoNotRetry(); diff --git a/examples/csharp/sdk-reference/error-handling/linear-retry-strategy.cs b/examples/csharp/sdk-reference/error-handling/linear-retry-strategy.cs new file mode 100644 index 0000000..1160e46 --- /dev/null +++ b/examples/csharp/sdk-reference/error-handling/linear-retry-strategy.cs @@ -0,0 +1,44 @@ +using Amazon.Lambda.Core; +using Amazon.Lambda.DurableExecution; + +public class LinearRetryStrategyExample +{ + public Task Handler( + DurableExecutionInvocationInput input, ILambdaContext context) + => DurableFunction.WrapAsync(Workflow, input, context); + + private async Task Workflow(object input, IDurableContext ctx) + { + // .NET has no built-in linear strategy. Build one with RetryStrategy.FromDelegate: + // the delay grows by a fixed increment on each attempt, capped at maxDelay. + var maxAttempts = 5; + var initialDelay = TimeSpan.FromSeconds(2); + var increment = TimeSpan.FromSeconds(3); + var maxDelay = TimeSpan.FromSeconds(30); + + var config = new StepConfig + { + RetryStrategy = RetryStrategy.FromDelegate((error, attempt) => + { + if (attempt >= maxAttempts) + { + return RetryDecision.DoNotRetry(); + } + + // attempt is 1-based: initialDelay + increment * (attempt - 1), capped at maxDelay + var delay = initialDelay + TimeSpan.FromTicks(increment.Ticks * (attempt - 1)); + if (delay > maxDelay) + { + delay = maxDelay; + } + + return RetryDecision.RetryAfter(delay); + }), + }; + + return await ctx.StepAsync( + async (_, _) => "ok", + name: "call-external-api", + config: config); + } +} diff --git a/examples/csharp/sdk-reference/error-handling/retry-presets.cs b/examples/csharp/sdk-reference/error-handling/retry-presets.cs new file mode 100644 index 0000000..c206a9e --- /dev/null +++ b/examples/csharp/sdk-reference/error-handling/retry-presets.cs @@ -0,0 +1,38 @@ +using Amazon.Lambda.Core; +using Amazon.Lambda.DurableExecution; + +public class RetryPresetsExample +{ + public Task Handler( + DurableExecutionInvocationInput input, ILambdaContext context) + => DurableFunction.WrapAsync(Workflow, input, context); + + private async Task Workflow(object input, IDurableContext ctx) + { + // Default: 6 attempts, 5s initial delay, 60s max, 2x backoff, full jitter. + string result = await ctx.StepAsync( + async (_, _) => CallApi(), + name: "call-api", + config: new StepConfig { RetryStrategy = RetryStrategy.Default }); + + // Transient: 3 attempts, 1s initial delay, 5s max, 2x backoff, half jitter. + string audit = await ctx.StepAsync( + async (_, _) => WriteAuditLog(), + name: "audit-log", + config: new StepConfig { RetryStrategy = RetryStrategy.Transient }); + + // None: 1 attempt, fail immediately on first error. + string critical = await ctx.StepAsync( + async (_, _) => ChargePayment(), + name: "charge-payment", + config: new StepConfig { RetryStrategy = RetryStrategy.None }); + + return new Results(result, audit, critical); + } + + private static string CallApi() => "ok"; + private static string WriteAuditLog() => "logged"; + private static string ChargePayment() => "charged"; +} + +public record Results(string Result, string Audit, string Critical); diff --git a/examples/csharp/sdk-reference/error-handling/retry-specific-errors.cs b/examples/csharp/sdk-reference/error-handling/retry-specific-errors.cs new file mode 100644 index 0000000..0fd2507 --- /dev/null +++ b/examples/csharp/sdk-reference/error-handling/retry-specific-errors.cs @@ -0,0 +1,46 @@ +using Amazon.Lambda.Core; +using Amazon.Lambda.DurableExecution; + +public class RetrySpecificErrorsExample +{ + public Task Handler( + DurableExecutionInvocationInput input, ILambdaContext context) + => DurableFunction.WrapAsync(Workflow, input, context); + + private async Task Workflow(object input, IDurableContext ctx) + { + // Pass retryableExceptions to retry only these exception types (and subclasses). + // Every other exception fails immediately. + var config = new StepConfig + { + RetryStrategy = RetryStrategy.Exponential( + maxAttempts: 5, + initialDelay: TimeSpan.FromSeconds(2), + maxDelay: TimeSpan.FromMinutes(1), + backoffRate: 2.0, + jitter: JitterStrategy.Full, + retryableExceptions: new[] + { + typeof(RateLimitException), + typeof(ServiceUnavailableException), + }), + }; + + return await ctx.StepAsync( + async (_, _) => CallApi(), + name: "call-api", + config: config); + } + + private static string CallApi() => "ok"; +} + +public class RateLimitException : Exception +{ + public RateLimitException(string message) : base(message) { } +} + +public class ServiceUnavailableException : Exception +{ + public ServiceUnavailableException(string message) : base(message) { } +} diff --git a/examples/csharp/sdk-reference/error-handling/retry-strategy-config-signature.cs b/examples/csharp/sdk-reference/error-handling/retry-strategy-config-signature.cs new file mode 100644 index 0000000..d922408 --- /dev/null +++ b/examples/csharp/sdk-reference/error-handling/retry-strategy-config-signature.cs @@ -0,0 +1,9 @@ +// .NET has no config object; build strategies with the RetryStrategy factory. +public static IRetryStrategy RetryStrategy.Exponential( + int maxAttempts = 3, + TimeSpan? initialDelay = null, // default 5s + TimeSpan? maxDelay = null, // default 300s + double backoffRate = 2.0, + JitterStrategy jitter = JitterStrategy.Full, + Type[]? retryableExceptions = null, + string[]? retryableMessagePatterns = null); diff --git a/examples/csharp/sdk-reference/error-handling/retry-strategy-signature.cs b/examples/csharp/sdk-reference/error-handling/retry-strategy-signature.cs new file mode 100644 index 0000000..430f5d9 --- /dev/null +++ b/examples/csharp/sdk-reference/error-handling/retry-strategy-signature.cs @@ -0,0 +1,9 @@ +public interface IRetryStrategy +{ + // attempt is 1-based: 1 on the first retry, 2 on the second, etc. + RetryDecision ShouldRetry(Exception exception, int attemptNumber); +} + +// Build one from a delegate without implementing the interface: +public static IRetryStrategy RetryStrategy.FromDelegate( + Func strategy); diff --git a/examples/csharp/sdk-reference/error-handling/serdes-error.cs b/examples/csharp/sdk-reference/error-handling/serdes-error.cs new file mode 100644 index 0000000..c1723ed --- /dev/null +++ b/examples/csharp/sdk-reference/error-handling/serdes-error.cs @@ -0,0 +1,32 @@ +using Amazon.Lambda.Core; +using Amazon.Lambda.DurableExecution; +using Microsoft.Extensions.Logging; + +public class SerdesErrorExample +{ + public Task Handler( + DurableExecutionInvocationInput input, ILambdaContext context) + => DurableFunction.WrapAsync(Workflow, input, context); + + private async Task Workflow(object input, IDurableContext ctx) + { + // The step result is serialized to checkpoint storage with the + // ILambdaSerializer registered on ILambdaContext.Serializer. When + // serialization or deserialization fails, the SDK surfaces the failure as + // an unhandled exception, returns a FAILED status, and does not retry. + try + { + ResultData result = await ctx.StepAsync( + async (_, _) => new ResultData("hello"), + name: "build-result"); + return result; + } + catch (Exception e) + { + ctx.Logger.LogError("Serialization failed: {Message}", e.Message); + throw; + } + } +} + +public record ResultData(string Message); diff --git a/examples/csharp/sdk-reference/error-handling/step-interrupted.cs b/examples/csharp/sdk-reference/error-handling/step-interrupted.cs new file mode 100644 index 0000000..1d22485 --- /dev/null +++ b/examples/csharp/sdk-reference/error-handling/step-interrupted.cs @@ -0,0 +1,40 @@ +using Amazon.Lambda.Core; +using Amazon.Lambda.DurableExecution; +using Microsoft.Extensions.Logging; + +public class StepInterruptedExample +{ + public Task Handler( + DurableExecutionInvocationInput input, ILambdaContext context) + => DurableFunction.WrapAsync(Workflow, input, context); + + private async Task Workflow(PaymentEvent input, IDurableContext ctx) + { + var config = new StepConfig + { + Semantics = StepSemantics.AtMostOncePerRetry, + }; + try + { + Charge result = await ctx.StepAsync( + async (_, _) => ChargePayment(input.Amount), + name: "charge-payment", + config: config); + return new PaymentResult("charged", result); + } + catch (StepInterruptedException) + { + // The step started but Lambda was interrupted before the result was + // checkpointed. The SDK will not re-run the step on the next invocation. + // Inspect your payment system to determine whether the charge succeeded. + ctx.Logger.LogWarning("Payment step interrupted — check payment system"); + return new PaymentResult("unknown", null); + } + } + + private static Charge ChargePayment(double amount) => new(Charged: amount); +} + +public record PaymentEvent(double Amount); +public record Charge(double Charged); +public record PaymentResult(string Status, Charge? Result); diff --git a/examples/csharp/sdk-reference/error-handling/validation-error.cs b/examples/csharp/sdk-reference/error-handling/validation-error.cs new file mode 100644 index 0000000..9e664b7 --- /dev/null +++ b/examples/csharp/sdk-reference/error-handling/validation-error.cs @@ -0,0 +1,29 @@ +using Amazon.Lambda.Core; +using Amazon.Lambda.DurableExecution; +using Microsoft.Extensions.Logging; + +public class ValidationErrorExample +{ + public Task Handler( + DurableExecutionInvocationInput input, ILambdaContext context) + => DurableFunction.WrapAsync(Workflow, input, context); + + private async Task Workflow(object input, IDurableContext ctx) + { + // The SDK throws ArgumentOutOfRangeException for invalid configuration + // values. For example, passing a duration shorter than 1 second to + // WaitAsync: + try + { + await ctx.WaitAsync(TimeSpan.FromMilliseconds(500), name: "short-wait"); // invalid: less than 1 second + } + catch (ArgumentOutOfRangeException e) + { + ctx.Logger.LogError("Invalid SDK configuration: {Message}", e.Message); + return new ValidationResult("InvalidConfiguration", e.Message); + } + return new ValidationResult("ok", null); + } +} + +public record ValidationResult(string Status, string? Message); diff --git a/examples/csharp/sdk-reference/error-handling/with-retry-helper.cs b/examples/csharp/sdk-reference/error-handling/with-retry-helper.cs new file mode 100644 index 0000000..906aa7d --- /dev/null +++ b/examples/csharp/sdk-reference/error-handling/with-retry-helper.cs @@ -0,0 +1,37 @@ +using Amazon.Lambda.Core; +using Amazon.Lambda.DurableExecution; + +public class WithRetryHelperExample +{ + public Task Handler( + DurableExecutionInvocationInput input, ILambdaContext context) + => DurableFunction.WrapAsync(Workflow, input, context); + + private async Task Workflow(OrderEvent input, IDurableContext ctx) + { + // .NET has no separate withRetry helper. InvokeConfig does not accept a retry + // strategy, so wrap the invoke in a step and set the retry strategy on StepConfig. + // The step retries the invocation with backoff between failed attempts. + var config = new StepConfig + { + RetryStrategy = RetryStrategy.Exponential( + maxAttempts: 3, + initialDelay: TimeSpan.FromSeconds(2), + maxDelay: TimeSpan.FromMinutes(1), + backoffRate: 2.0, + jitter: JitterStrategy.Full), + }; + + return await ctx.StepAsync( + async (_, ct) => await ctx.InvokeAsync( + "process-payment", + new PaymentRequest(input.OrderId), + name: "charge", + cancellationToken: ct), + name: "charge-payment", + config: config); + } +} + +public record OrderEvent(string OrderId); +public record PaymentRequest(string OrderId); diff --git a/examples/csharp/sdk-reference/observability/basic-usage.cs b/examples/csharp/sdk-reference/observability/basic-usage.cs new file mode 100644 index 0000000..bc07ff2 --- /dev/null +++ b/examples/csharp/sdk-reference/observability/basic-usage.cs @@ -0,0 +1,22 @@ +using Amazon.Lambda.Core; +using Amazon.Lambda.DurableExecution; +using Microsoft.Extensions.Logging; + +public class BasicUsageExample +{ + public Task Handler( + DurableExecutionInvocationInput input, ILambdaContext context) + => DurableFunction.WrapAsync(Workflow, input, context); + + private async Task Workflow(object input, IDurableContext ctx) + { + ctx.Logger.LogInformation("Starting workflow"); + + string result = await ctx.StepAsync( + async (_, _) => "done", + name: "process"); + + ctx.Logger.LogInformation("Workflow complete: {Result}", result); + return result; + } +} diff --git a/examples/csharp/sdk-reference/observability/powertools-logger.cs b/examples/csharp/sdk-reference/observability/powertools-logger.cs new file mode 100644 index 0000000..2677632 --- /dev/null +++ b/examples/csharp/sdk-reference/observability/powertools-logger.cs @@ -0,0 +1,24 @@ +using Amazon.Lambda.Core; +using Amazon.Lambda.DurableExecution; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +public class PowertoolsLoggerExample +{ + // The Powertools for AWS Lambda (.NET) logger is an ILogger, so it plugs in + // as the CustomLogger. Replace this placeholder with the Powertools logger, + // for example the ILogger produced by services.AddPowertoolsLogging(...). + private static readonly ILogger PowertoolsLogger = NullLogger.Instance; + + public Task Handler( + DurableExecutionInvocationInput input, ILambdaContext context) + => DurableFunction.WrapAsync(Workflow, input, context); + + private async Task Workflow(object input, IDurableContext ctx) + { + ctx.ConfigureLogger(new LoggerConfig { CustomLogger = PowertoolsLogger }); + ctx.Logger.LogInformation("Running handler"); + + return await Task.FromResult("ok"); + } +} diff --git a/examples/csharp/sdk-reference/observability/replay-suppression.cs b/examples/csharp/sdk-reference/observability/replay-suppression.cs new file mode 100644 index 0000000..e7de672 --- /dev/null +++ b/examples/csharp/sdk-reference/observability/replay-suppression.cs @@ -0,0 +1,24 @@ +using Amazon.Lambda.Core; +using Amazon.Lambda.DurableExecution; +using Microsoft.Extensions.Logging; + +public class ReplaySuppressionExample +{ + public Task Handler( + DurableExecutionInvocationInput input, ILambdaContext context) + => DurableFunction.WrapAsync(Workflow, input, context); + + private async Task Workflow(object input, IDurableContext ctx) + { + // ctx.Logger suppresses duplicate logs during replay by default. + // Logs from completed operations do not repeat when the SDK replays. + ctx.Logger.LogInformation("Step 1 starting"); + + string result = await ctx.StepAsync( + async (_, _) => "result", + name: "step-1"); + + ctx.Logger.LogInformation("Step 1 complete: {Result}", result); + return result; + } +} diff --git a/examples/csharp/sdk-reference/observability/step-context-logger.cs b/examples/csharp/sdk-reference/observability/step-context-logger.cs new file mode 100644 index 0000000..84e1e71 --- /dev/null +++ b/examples/csharp/sdk-reference/observability/step-context-logger.cs @@ -0,0 +1,23 @@ +using Amazon.Lambda.Core; +using Amazon.Lambda.DurableExecution; +using Microsoft.Extensions.Logging; + +public class StepContextLoggerExample +{ + public Task Handler( + DurableExecutionInvocationInput input, ILambdaContext context) + => DurableFunction.WrapAsync(Workflow, input, context); + + private async Task Workflow(object input, IDurableContext ctx) + { + return await ctx.StepAsync( + async (stepCtx, _) => + { + // stepCtx.Logger includes operationId, operationName, and attempt + // in every log entry from this step. + stepCtx.Logger.LogInformation("Running step"); + return "done"; + }, + name: "process"); + } +} diff --git a/examples/csharp/sdk-reference/serialization/CallbackConfigExample.cs b/examples/csharp/sdk-reference/serialization/CallbackConfigExample.cs new file mode 100644 index 0000000..4e0fcef --- /dev/null +++ b/examples/csharp/sdk-reference/serialization/CallbackConfigExample.cs @@ -0,0 +1,29 @@ +using Amazon.Lambda.Core; +using Amazon.Lambda.DurableExecution; + +public class CallbackConfigExample +{ + public Task Handler( + DurableExecutionInvocationInput input, ILambdaContext context) + => DurableFunction.WrapAsync(Workflow, input, context); + + private async Task Workflow(object input, IDurableContext ctx) + { + // CallbackConfig has no serializer slot. The callback payload delivered by + // the external system is deserialized with the ILambdaSerializer registered + // on ILambdaContext.Serializer. To customize deserialization, register a + // custom ILambdaSerializer at the host boundary. + var config = new CallbackConfig + { + Timeout = TimeSpan.FromHours(1), + }; + + ICallback callback = + await ctx.CreateCallbackAsync("await-approval", config); + + // Send callback.CallbackId to the external system here. + return await callback.GetResultAsync(); + } +} + +public record ApprovalResult(bool Approved, string Reason); diff --git a/examples/csharp/sdk-reference/serialization/MapConfigExample.cs b/examples/csharp/sdk-reference/serialization/MapConfigExample.cs new file mode 100644 index 0000000..4c67d3b --- /dev/null +++ b/examples/csharp/sdk-reference/serialization/MapConfigExample.cs @@ -0,0 +1,33 @@ +using Amazon.Lambda.Core; +using Amazon.Lambda.DurableExecution; + +public class MapConfigExample +{ + public Task Handler( + DurableExecutionInvocationInput input, ILambdaContext context) + => DurableFunction.WrapAsync>(Workflow, input, context); + + private async Task> Workflow(object input, IDurableContext ctx) + { + // MapConfig has no serializer slot. Each item result is serialized with the + // ILambdaSerializer registered on ILambdaContext.Serializer. To customize + // serialization, register a custom ILambdaSerializer at the host boundary. + var config = new MapConfig + { + MaxConcurrency = 3, + }; + + var items = new[] { "a", "b", "c" }; + IBatchResult result = await ctx.MapAsync( + items, + async (itemCtx, item, index, all, ct) => + await itemCtx.StepAsync( + async (_, _) => new ProcessedItem(item, "done"), + name: $"process-{index}"), + name: "process-items", + config: config); + return result.GetResults(); + } +} + +public record ProcessedItem(string Id, string Status); diff --git a/examples/csharp/sdk-reference/serialization/OrderSerDes.cs b/examples/csharp/sdk-reference/serialization/OrderSerDes.cs new file mode 100644 index 0000000..630861d --- /dev/null +++ b/examples/csharp/sdk-reference/serialization/OrderSerDes.cs @@ -0,0 +1,29 @@ +using System.Text.Json; +using Amazon.Lambda.Core; + +// .NET has no per-operation SerDes. Serialization is controlled by the single +// ILambdaSerializer registered on ILambdaContext.Serializer. To customize how +// durable operation results are serialized, implement ILambdaSerializer and +// register it at the host boundary instead of passing a SerDes per operation. +public class OrderLambdaSerializer : ILambdaSerializer +{ + private static readonly JsonSerializerOptions Options = new() + { + PropertyNameCaseInsensitive = true, + }; + + public T Deserialize(Stream requestStream) + { + using var reader = new StreamReader(requestStream); + string json = reader.ReadToEnd(); + return JsonSerializer.Deserialize(json, Options)!; + } + + public void Serialize(T response, Stream responseStream) + { + string json = JsonSerializer.Serialize(response, Options); + using var writer = new StreamWriter(responseStream, leaveOpen: true); + writer.Write(json); + writer.Flush(); + } +} diff --git a/examples/csharp/sdk-reference/serialization/PassThroughSerdesExample.cs b/examples/csharp/sdk-reference/serialization/PassThroughSerdesExample.cs new file mode 100644 index 0000000..1a083bc --- /dev/null +++ b/examples/csharp/sdk-reference/serialization/PassThroughSerdesExample.cs @@ -0,0 +1,26 @@ +using System.Text; +using Amazon.Lambda.Core; + +// A pass-through serializer stores string values as-is (already a JSON string) +// instead of re-encoding them as a JSON string literal. .NET has no per-operation +// SerDes, so this behavior is expressed as a custom ILambdaSerializer registered +// on ILambdaContext.Serializer at the host boundary. It applies to every durable +// operation result in the handler. +public class PassThroughLambdaSerializer : ILambdaSerializer +{ + public T Deserialize(Stream requestStream) + { + using var reader = new StreamReader(requestStream); + string data = reader.ReadToEnd(); + // Return the raw payload unchanged when T is string. + return (T)(object)data; + } + + public void Serialize(T response, Stream responseStream) + { + // Write the value as-is; the value is expected to already be a JSON string. + string data = (string)(object)response!; + var bytes = Encoding.UTF8.GetBytes(data); + responseStream.Write(bytes, 0, bytes.Length); + } +} diff --git a/examples/csharp/sdk-reference/serialization/SerdesInterface.cs b/examples/csharp/sdk-reference/serialization/SerdesInterface.cs new file mode 100644 index 0000000..256ea96 --- /dev/null +++ b/examples/csharp/sdk-reference/serialization/SerdesInterface.cs @@ -0,0 +1,9 @@ +// .NET has no per-operation SerDes interface. Serialization for every durable +// operation is handled by the single ILambdaSerializer registered on +// ILambdaContext.Serializer. Implement this interface to customize serialization. +public interface ILambdaSerializer +{ + T Deserialize(Stream requestStream); + + void Serialize(T response, Stream responseStream); +} diff --git a/examples/csharp/sdk-reference/serialization/StepConfigExample.cs b/examples/csharp/sdk-reference/serialization/StepConfigExample.cs new file mode 100644 index 0000000..0f24bf0 --- /dev/null +++ b/examples/csharp/sdk-reference/serialization/StepConfigExample.cs @@ -0,0 +1,29 @@ +using Amazon.Lambda.Core; +using Amazon.Lambda.DurableExecution; + +public class StepConfigExample +{ + public Task Handler( + DurableExecutionInvocationInput input, ILambdaContext context) + => DurableFunction.WrapAsync(Workflow, input, context); + + private async Task Workflow(object input, IDurableContext ctx) + { + // StepConfig has no serializer slot. The step result is serialized with + // the ILambdaSerializer registered on ILambdaContext.Serializer. To + // customize serialization, register a custom ILambdaSerializer at the + // host boundary instead of setting a per-step SerDes. + var config = new StepConfig + { + RetryStrategy = RetryStrategy.Exponential(maxAttempts: 3), + }; + + Order order = await ctx.StepAsync( + async (_, _) => new Order("order-123", "99.99"), + name: "fetch-order", + config: config); + return order; + } +} + +public record Order(string Id, string Total); diff --git a/examples/csharp/sdk-reference/serialization/Walkthrough.cs b/examples/csharp/sdk-reference/serialization/Walkthrough.cs new file mode 100644 index 0000000..c2927e3 --- /dev/null +++ b/examples/csharp/sdk-reference/serialization/Walkthrough.cs @@ -0,0 +1,22 @@ +using Amazon.Lambda.Core; +using Amazon.Lambda.DurableExecution; + +public class WalkthroughExample +{ + public Task Handler( + DurableExecutionInvocationInput input, ILambdaContext context) + => DurableFunction.WrapAsync(Workflow, input, context); + + private async Task Workflow(object input, IDurableContext ctx) + { + // No serializer configuration on the operation — the SDK serializes and + // deserializes the result with the ILambdaSerializer registered on + // ILambdaContext.Serializer. + Order order = await ctx.StepAsync( + async (_, _) => new Order("order-123", "99.99"), + name: "fetch-order"); + return order; + } +} + +public record Order(string Id, string Total); diff --git a/examples/csharp/testing/assertions/assert-callback.cs b/examples/csharp/testing/assertions/assert-callback.cs new file mode 100644 index 0000000..6c9f49e --- /dev/null +++ b/examples/csharp/testing/assertions/assert-callback.cs @@ -0,0 +1,33 @@ +using Amazon.Lambda.DurableExecution; +using Amazon.Lambda.DurableExecution.Testing; +using Xunit; + +public class AssertCallbackTest +{ + private static async Task Workflow(object input, IDurableContext ctx) + { + return await ctx.WaitForCallbackAsync( + // In production this submitter hands the callback ID to an external system. + async (callbackId, _, _) => await Task.CompletedTask, + name: "approval"); + } + + [Fact] + public async Task CompletesCallbackFromTest() + { + await using var runner = new DurableTestRunner(Workflow); + + // Start drives the workflow to the point where the callback is waiting. + string arn = await runner.StartAsync(new object()); + + // Get the pending callback ID, then complete it from the test. + string callbackId = await runner.WaitForCallbackAsync(arn, name: "approval"); + await runner.SendCallbackSuccessAsync(callbackId, "approved"); + + // Drive the workflow the rest of the way to a terminal result. + TestResult result = await runner.WaitForResultAsync(arn); + + Assert.True(result.IsSucceeded); + Assert.Equal("approved", result.Result); + } +} diff --git a/examples/csharp/testing/assertions/assert-child-context.cs b/examples/csharp/testing/assertions/assert-child-context.cs new file mode 100644 index 0000000..0f11582 --- /dev/null +++ b/examples/csharp/testing/assertions/assert-child-context.cs @@ -0,0 +1,33 @@ +using System.Linq; +using Amazon.Lambda.DurableExecution; +using Amazon.Lambda.DurableExecution.Testing; +using Xunit; + +public class AssertChildContextTest +{ + private static Task Workflow(object input, IDurableContext ctx) + => ctx.RunInChildContextAsync( + async (child, _) => await child.StepAsync(async (_, _) => 42, name: "compute"), + name: "process"); + + [Fact] + public async Task AssertsOnChildContextAndItsOperations() + { + await using var runner = new DurableTestRunner(Workflow); + + TestResult result = await runner.RunAsync(new object()); + + Assert.True(result.IsSucceeded); + Assert.Equal(42, result.Result); + + // The child context is recorded as a CONTEXT-kind operation. + TestStep context = result.GetStep("process"); + Assert.Equal(OperationKind.Context, context.Kind); + Assert.Equal(OperationStatus.Succeeded, context.Status); + + // Walk the child operations to assert on what ran inside the context. + TestStep compute = context.Children.Single(c => c.Name == "compute"); + Assert.Equal(OperationKind.Step, compute.Kind); + Assert.Equal(42, compute.GetResult()); + } +} diff --git a/examples/csharp/testing/assertions/assert-step.cs b/examples/csharp/testing/assertions/assert-step.cs new file mode 100644 index 0000000..0a1ffb6 --- /dev/null +++ b/examples/csharp/testing/assertions/assert-step.cs @@ -0,0 +1,25 @@ +using Amazon.Lambda.DurableExecution; +using Amazon.Lambda.DurableExecution.Testing; +using Xunit; + +public class AssertStepTest +{ + // Look up a step by name, then check its kind, status, and typed result. + private static Task Workflow(object input, IDurableContext ctx) + => ctx.StepAsync(async (_, _) => 42, name: "compute"); + + [Fact] + public async Task AssertsOnStepOperation() + { + await using var runner = new DurableTestRunner(Workflow); + + TestResult result = await runner.RunAsync(new object()); + + Assert.True(result.IsSucceeded); + + TestStep step = result.GetStep("compute"); + Assert.Equal(OperationKind.Step, step.Kind); + Assert.Equal(OperationStatus.Succeeded, step.Status); + Assert.Equal(42, step.GetResult()); + } +} diff --git a/examples/csharp/testing/assertions/assert-wait.cs b/examples/csharp/testing/assertions/assert-wait.cs new file mode 100644 index 0000000..8a4cd39 --- /dev/null +++ b/examples/csharp/testing/assertions/assert-wait.cs @@ -0,0 +1,29 @@ +using Amazon.Lambda.DurableExecution; +using Amazon.Lambda.DurableExecution.Testing; +using Xunit; + +public class AssertWaitTest +{ + private static async Task Workflow(object input, IDurableContext ctx) + { + await ctx.WaitAsync(TimeSpan.FromSeconds(30), name: "my-wait"); + return "done"; + } + + [Fact] + public async Task AssertsOnWaitOperation() + { + // SkipTime is on by default, so the wait completes immediately and the + // runner records a scheduled end timestamp for it. + await using var runner = new DurableTestRunner(Workflow); + + TestResult result = await runner.RunAsync(new object()); + + Assert.True(result.IsSucceeded); + + TestStep wait = result.GetStep("my-wait"); + Assert.Equal(OperationKind.Wait, wait.Kind); + Assert.Equal(OperationStatus.Succeeded, wait.Status); + Assert.NotNull(wait.GetWaitEndsAt()); + } +} diff --git a/examples/csharp/testing/assertions/filter-by-status.cs b/examples/csharp/testing/assertions/filter-by-status.cs new file mode 100644 index 0000000..3670cc3 --- /dev/null +++ b/examples/csharp/testing/assertions/filter-by-status.cs @@ -0,0 +1,37 @@ +using Amazon.Lambda.DurableExecution; +using Amazon.Lambda.DurableExecution.Testing; +using Xunit; + +public class FilterByStatusTest +{ + // A retried step reuses a single operation record, so the history holds one + // entry per operation with its terminal status. Filter by status to separate + // the step that succeeded from the one that failed. + private static async Task Workflow(object input, IDurableContext ctx) + { + await ctx.StepAsync(async (_, _) => "ok", name: "succeeds"); + await ctx.StepAsync( + async (_, _) => throw new InvalidOperationException("boom"), + name: "fails", + config: new StepConfig { RetryStrategy = RetryStrategy.None }); + return "done"; + } + + [Fact] + public async Task FiltersOperationsByStatus() + { + await using var runner = new DurableTestRunner(Workflow); + + TestResult result = await runner.RunAsync(new object()); + + Assert.True(result.IsFailed); + + var succeeded = result.GetStepsByStatus(OperationStatus.Succeeded); + Assert.Single(succeeded); + Assert.Equal("succeeds", succeeded[0].Name); + + var failed = result.GetStepsByStatus(OperationStatus.Failed); + Assert.Single(failed); + Assert.Equal("fails", failed[0].Name); + } +} diff --git a/examples/csharp/testing/authoring/minimal-test.cs b/examples/csharp/testing/authoring/minimal-test.cs new file mode 100644 index 0000000..c710cc4 --- /dev/null +++ b/examples/csharp/testing/authoring/minimal-test.cs @@ -0,0 +1,22 @@ +using Amazon.Lambda.DurableExecution; +using Amazon.Lambda.DurableExecution.Testing; +using Xunit; + +public class MinimalTest +{ + private static Task Workflow(object? input, IDurableContext ctx) + => ctx.StepAsync(async (_, _) => "hello", name: "greet"); + + [Fact] + public async Task ReturnsExpectedResult() + { + await using var runner = new DurableTestRunner( + Workflow, + new TestRunnerOptions { SkipTime = true }); + + TestResult result = await runner.RunAsync(null); + + result.EnsureSucceeded(); + Assert.Equal("hello", result.Result); + } +} diff --git a/examples/csharp/testing/authoring/test-branching.cs b/examples/csharp/testing/authoring/test-branching.cs new file mode 100644 index 0000000..983f836 --- /dev/null +++ b/examples/csharp/testing/authoring/test-branching.cs @@ -0,0 +1,42 @@ +using Amazon.Lambda.DurableExecution; +using Amazon.Lambda.DurableExecution.Testing; +using Xunit; + +public class TestBranchingTest +{ + private static Task Workflow(BranchInput input, IDurableContext ctx) + { + if (input.Premium) + { + return ctx.StepAsync(async (_, _) => "premium", name: "premium-path"); + } + return ctx.StepAsync(async (_, _) => "standard", name: "standard-path"); + } + + private static DurableTestRunner CreateRunner() + => new(Workflow, new TestRunnerOptions { SkipTime = true }); + + [Fact] + public async Task TakesPremiumPath() + { + await using var runner = CreateRunner(); + + TestResult result = await runner.RunAsync(new BranchInput(Premium: true)); + + result.EnsureSucceeded(); + Assert.Equal("premium", result.Result); + } + + [Fact] + public async Task TakesStandardPath() + { + await using var runner = CreateRunner(); + + TestResult result = await runner.RunAsync(new BranchInput(Premium: false)); + + result.EnsureSucceeded(); + Assert.Equal("standard", result.Result); + } +} + +public record BranchInput(bool Premium); diff --git a/examples/csharp/testing/authoring/test-failure.cs b/examples/csharp/testing/authoring/test-failure.cs new file mode 100644 index 0000000..a41b9f4 --- /dev/null +++ b/examples/csharp/testing/authoring/test-failure.cs @@ -0,0 +1,30 @@ +using Amazon.Lambda.DurableExecution; +using Amazon.Lambda.DurableExecution.Testing; +using Xunit; + +public class TestFailureTest +{ + private static async Task Workflow(FailureInput input, IDurableContext ctx) + { + if (input.Fail) + { + throw new InvalidOperationException("intentional failure"); + } + return await Task.FromResult("ok"); + } + + [Fact] + public async Task ReportsFailedExecution() + { + await using var runner = new DurableTestRunner( + Workflow, + new TestRunnerOptions { SkipTime = true }); + + TestResult result = await runner.RunAsync(new FailureInput(Fail: true)); + + Assert.True(result.IsFailed); + Assert.NotNull(result.Error); + } +} + +public record FailureInput(bool Fail); diff --git a/examples/csharp/testing/authoring/test-retries.cs b/examples/csharp/testing/authoring/test-retries.cs new file mode 100644 index 0000000..b0175fa --- /dev/null +++ b/examples/csharp/testing/authoring/test-retries.cs @@ -0,0 +1,44 @@ +using Amazon.Lambda.DurableExecution; +using Amazon.Lambda.DurableExecution.Testing; +using Xunit; + +public class TestRetriesTest +{ + [Fact] + public async Task RetriesAndEventuallySucceeds() + { + int attempts = 0; + + Task Workflow(object? input, IDurableContext ctx) + { + var config = new StepConfig + { + RetryStrategy = RetryStrategy.Exponential( + maxAttempts: 3, + initialDelay: TimeSpan.FromMilliseconds(1)), + }; + + return ctx.StepAsync( + async (_, _) => + { + if (++attempts < 3) + { + throw new InvalidOperationException("transient error"); + } + return "done"; + }, + name: "flaky", + config: config); + } + + await using var runner = new DurableTestRunner( + Workflow, + new TestRunnerOptions { SkipTime = true }); + + TestResult result = await runner.RunAsync(null); + + result.EnsureSucceeded(); + Assert.Equal("done", result.Result); + Assert.Equal(3, result.GetStep("flaky").Attempt); + } +} diff --git a/examples/csharp/testing/cloud-runner/cloud-runner-timeout.cs b/examples/csharp/testing/cloud-runner/cloud-runner-timeout.cs new file mode 100644 index 0000000..ac35f1e --- /dev/null +++ b/examples/csharp/testing/cloud-runner/cloud-runner-timeout.cs @@ -0,0 +1,39 @@ +using Amazon.Lambda.DurableExecution; +using Amazon.Lambda.DurableExecution.Testing; +using Xunit; + +public class CloudRunnerTimeoutTest +{ + private static async Task Workflow(GreetInput input, IDurableContext ctx) + { + return await ctx.StepAsync( + async (_, _) => $"hello {input.Name}", + name: "greet"); + } + + [Fact] + public async Task RunsWithCustomTimeout() + { + // Set the default wall-clock timeout for polling via CloudTestRunnerOptions, + // and tune how frequently the runner polls for completion. + var options = new CloudTestRunnerOptions + { + InitialPollInterval = TimeSpan.FromMilliseconds(200), + PollInterval = TimeSpan.FromSeconds(2), + DefaultTimeout = TimeSpan.FromSeconds(60), + }; + + await using var runner = new CloudDurableTestRunner( + "arn:aws:lambda:us-east-1:123456789012:function:MyFunction:$LATEST", + options: options); + + // Or override the timeout per call by passing it to RunAsync. + TestResult result = await runner.RunAsync( + new GreetInput("world"), + timeout: TimeSpan.FromSeconds(60)); + + Assert.True(result.IsSucceeded); + } +} + +public record GreetInput(string Name); diff --git a/examples/csharp/testing/cloud-runner/cloud-runner.cs b/examples/csharp/testing/cloud-runner/cloud-runner.cs new file mode 100644 index 0000000..d4d5646 --- /dev/null +++ b/examples/csharp/testing/cloud-runner/cloud-runner.cs @@ -0,0 +1,29 @@ +using Amazon.Lambda.DurableExecution; +using Amazon.Lambda.DurableExecution.Testing; +using Xunit; + +public class CloudRunnerTest +{ + // The workflow under test. In cloud mode it runs in the deployed function; + // it is shown here so the example is self-contained. + private static async Task Workflow(GreetInput input, IDurableContext ctx) + { + return await ctx.StepAsync( + async (_, _) => $"hello {input.Name}", + name: "greet"); + } + + [Fact] + public async Task RunsAgainstDeployedFunction() + { + await using var runner = new CloudDurableTestRunner( + "arn:aws:lambda:us-east-1:123456789012:function:MyFunction:$LATEST"); + + TestResult result = await runner.RunAsync(new GreetInput("world")); + + Assert.True(result.IsSucceeded); + Assert.Equal("hello world", result.Result); + } +} + +public record GreetInput(string Name); diff --git a/examples/csharp/testing/examples/child-context.cs b/examples/csharp/testing/examples/child-context.cs new file mode 100644 index 0000000..4ee0bb6 --- /dev/null +++ b/examples/csharp/testing/examples/child-context.cs @@ -0,0 +1,34 @@ +using System.Linq; +using Amazon.Lambda.DurableExecution; +using Amazon.Lambda.DurableExecution.Testing; +using Xunit; + +public class ChildContextTest +{ + private static async Task Workflow(object input, IDurableContext ctx) + { + return await ctx.RunInChildContextAsync( + async (child, _) => + { + string a = await child.StepAsync(async (_, _) => "result-a", name: "step-a"); + string b = await child.StepAsync(async (_, _) => "result-b", name: "step-b"); + return $"{a}:{b}"; + }, + name: "process"); + } + + [Fact] + public async Task ExecutesStepsInsideChildContext() + { + await using var runner = new DurableTestRunner(Workflow); + + TestResult result = await runner.RunAsync(new object()); + + Assert.True(result.IsSucceeded); + Assert.Equal("result-a:result-b", result.Result); + + var contextOps = result.Steps.Where(s => s.Kind == OperationKind.Context).ToList(); + Assert.NotEmpty(contextOps); + Assert.Equal("process", contextOps[0].Name); + } +} diff --git a/examples/csharp/testing/examples/long-waits.cs b/examples/csharp/testing/examples/long-waits.cs new file mode 100644 index 0000000..7ff0f5f --- /dev/null +++ b/examples/csharp/testing/examples/long-waits.cs @@ -0,0 +1,29 @@ +using System.Linq; +using Amazon.Lambda.DurableExecution; +using Amazon.Lambda.DurableExecution.Testing; +using Xunit; + +public class LongWaitsTest +{ + private static async Task Workflow(object input, IDurableContext ctx) + { + await ctx.WaitAsync(TimeSpan.FromHours(24), name: "cooling-off"); + return await ctx.StepAsync(async (_, _) => "done", name: "after-wait"); + } + + [Fact] + public async Task CompletesWithLongWait() + { + // SkipTime defaults to true, so the day-long WaitAsync completes instantly. + await using var runner = new DurableTestRunner(Workflow); + + TestResult result = await runner.RunAsync(new object()); + + Assert.True(result.IsSucceeded); + Assert.Equal("done", result.Result); + + var waitOps = result.Steps.Where(s => s.Kind == OperationKind.Wait).ToList(); + Assert.Single(waitOps); + Assert.Equal("cooling-off", waitOps[0].Name); + } +} diff --git a/examples/csharp/testing/examples/parallel-workflow.cs b/examples/csharp/testing/examples/parallel-workflow.cs new file mode 100644 index 0000000..700e3e8 --- /dev/null +++ b/examples/csharp/testing/examples/parallel-workflow.cs @@ -0,0 +1,41 @@ +using System.Collections.Generic; +using System.Linq; +using Amazon.Lambda.DurableExecution; +using Amazon.Lambda.DurableExecution.Testing; +using Xunit; + +public class ParallelWorkflowTest +{ + private static async Task> Workflow(object input, IDurableContext ctx) + { + IBatchResult results = await ctx.ParallelAsync( + new[] + { + new DurableBranch("fetch-a", + async (branch, _) => await branch.StepAsync(async (_, _) => "data-a", name: "fetch-a")), + new DurableBranch("fetch-b", + async (branch, _) => await branch.StepAsync(async (_, _) => "data-b", name: "fetch-b")), + new DurableBranch("fetch-c", + async (branch, _) => await branch.StepAsync(async (_, _) => "data-c", name: "fetch-c")), + }, + name: "fetch-all"); + + results.ThrowIfError(); + return results.GetResults(); + } + + [Fact] + public async Task ExecutesBranchesInParallel() + { + await using var runner = new DurableTestRunner>(Workflow); + + TestResult> result = await runner.RunAsync(new object()); + + Assert.True(result.IsSucceeded); + Assert.Equal(new[] { "data-a", "data-b", "data-c" }, result.Result!.ToArray()); + + // Each parallel branch runs inside its own child context, recorded as a CONTEXT operation. + var contextOps = result.Steps.Where(s => s.Kind == OperationKind.Context).ToList(); + Assert.NotEmpty(contextOps); + } +} diff --git a/examples/csharp/testing/examples/partial-failures.cs b/examples/csharp/testing/examples/partial-failures.cs new file mode 100644 index 0000000..9f524d8 --- /dev/null +++ b/examples/csharp/testing/examples/partial-failures.cs @@ -0,0 +1,30 @@ +using System.Linq; +using Amazon.Lambda.DurableExecution; +using Amazon.Lambda.DurableExecution.Testing; +using Xunit; + +public class PartialFailuresTest +{ + private static async Task Workflow(object input, IDurableContext ctx) + { + await ctx.StepAsync(async (_, _) => "ok", name: "step-1"); + await ctx.StepAsync(async (_, _) => "ok", name: "step-2"); + return await ctx.StepAsync( + async (_, _) => throw new InvalidOperationException("step-3 failed"), + name: "step-3"); + } + + [Fact] + public async Task RecordsWhichStepsSucceededBeforeFailure() + { + await using var runner = new DurableTestRunner(Workflow); + + TestResult result = await runner.RunAsync(new object()); + + Assert.True(result.IsFailed); + + Assert.Equal(OperationStatus.Succeeded, result.GetStep("step-1").Status); + Assert.Equal(OperationStatus.Succeeded, result.GetStep("step-2").Status); + Assert.NotNull(result.Error); + } +} diff --git a/examples/csharp/testing/examples/polling.cs b/examples/csharp/testing/examples/polling.cs new file mode 100644 index 0000000..ece7ca5 --- /dev/null +++ b/examples/csharp/testing/examples/polling.cs @@ -0,0 +1,33 @@ +using Amazon.Lambda.DurableExecution; +using Amazon.Lambda.DurableExecution.Testing; +using Xunit; + +public class PollingTest +{ + public record PollState(int Attempts, bool Done); + + private static async Task Workflow(object input, IDurableContext ctx) + { + return await ctx.WaitForConditionAsync( + async (state, _, _) => new PollState(state.Attempts + 1, state.Attempts >= 2), + new WaitForConditionConfig + { + InitialState = new PollState(0, false), + WaitStrategy = WaitStrategy.Fixed( + TimeSpan.FromSeconds(1), + isDone: state => state.Done), + }, + name: "poll-job"); + } + + [Fact] + public async Task PollsUntilConditionIsMet() + { + await using var runner = new DurableTestRunner(Workflow); + + TestResult result = await runner.RunAsync(new object()); + + Assert.True(result.IsSucceeded); + Assert.True(result.Result!.Done); + } +} diff --git a/examples/csharp/testing/examples/sequential-workflow.cs b/examples/csharp/testing/examples/sequential-workflow.cs new file mode 100644 index 0000000..19f0a20 --- /dev/null +++ b/examples/csharp/testing/examples/sequential-workflow.cs @@ -0,0 +1,38 @@ +using System.Linq; +using Amazon.Lambda.DurableExecution; +using Amazon.Lambda.DurableExecution.Testing; +using Xunit; + +public class SequentialWorkflowTest +{ + public record Order(string OrderId, string? Status = null, string? Payment = null, string? Fulfillment = null); + + private static async Task Workflow(Order input, IDurableContext ctx) + { + Order validated = await ctx.StepAsync( + async (_, _) => input with { Status = "validated" }, + name: "validate"); + Order paid = await ctx.StepAsync( + async (_, _) => validated with { Payment = "completed" }, + name: "payment"); + return await ctx.StepAsync( + async (_, _) => paid with { Fulfillment = "shipped" }, + name: "fulfillment"); + } + + [Fact] + public async Task ExecutesAllStepsInOrder() + { + await using var runner = new DurableTestRunner(Workflow); + + TestResult result = await runner.RunAsync(new Order("order-123")); + + Assert.True(result.IsSucceeded); + + var stepOps = result.Steps.Where(s => s.Kind == OperationKind.Step).ToList(); + Assert.Equal(3, stepOps.Count); + Assert.Equal( + new[] { "validate", "payment", "fulfillment" }, + stepOps.Select(s => s.Name).ToArray()); + } +} diff --git a/scripts/csharp-verify/add-csharp-tabs-apiref.workflow.js b/scripts/csharp-verify/add-csharp-tabs-apiref.workflow.js new file mode 100644 index 0000000..32885a0 --- /dev/null +++ b/scripts/csharp-verify/add-csharp-tabs-apiref.workflow.js @@ -0,0 +1,176 @@ +export const meta = { + name: 'add-csharp-tabs-apiref', + description: 'Add C# tabs to the testing API-reference page (inline snippets, testing SDK)', + phases: [ + { title: 'Author', detail: 'one agent authors all 39 C# tabs on the single page' }, + { title: 'Verify', detail: 'independent adversarial check of every C# snippet against testing-SDK source' }, + ], +} + +const REPO = 'C:/dev/repos/aws-durable-execution-docs' +const TESTING_SRC = 'C:/dev/repos/aws-lambda-dotnet/Libraries/src/Amazon.Lambda.DurableExecution.Testing' +const MD = 'docs/testing/api-reference.md' + +const API = ` +VERIFIED .NET testing-SDK surface (read these files in ${TESTING_SRC} to confirm before writing): + +DurableTestRunner (DurableTestRunner.cs) — the LOCAL runner. Note the name is + DurableTestRunner, NOT "LocalDurableTestRunner" (that is the Java/TS name). Members: + - ctor: DurableTestRunner(Func> handler, TestRunnerOptions? options = null) + - RegisterFunction(string functionNameOrArn, Func> ...) -> runner (chainable) + - RegisterDurableFunction(...) -> runner (chainable) + - Task> RunAsync(TInput input, TimeSpan? timeout = null, CancellationToken = default) + - Task StartAsync(TInput input, TimeSpan? timeout = null, CancellationToken = default) + - Task WaitForCallbackAsync(string durableExecutionArn, string? name = null, TimeSpan? timeout = null, CancellationToken = default) + - Task SendCallbackSuccessAsync(string callbackId, TResult result, CancellationToken = default) + - Task SendCallbackFailureAsync(string callbackId, ErrorObject? error = null, CancellationToken = default) + - Task SendCallbackHeartbeatAsync(string callbackId, CancellationToken = default) + - Task> WaitForResultAsync(string durableExecutionArn, TimeSpan? timeout = null, CancellationToken = default) + - ValueTask DisposeAsync() (use "await using var runner = ...") + +TestRunnerOptions (TestRunnerOptions.cs, a record): SkipTime (bool, default true), MaxInvocations + (int, default 100), DefaultTimeout (TimeSpan, default 30s), Serializer (ILambdaSerializer?), + LoggerFactory (ILoggerFactory?), DurableExecutionArn (string, default synthetic). + +TestResult (TestResult.cs): Status (InvocationStatus), IsSucceeded, IsFailed, Result (TOutput?), + Error (ErrorObject?), DurableExecutionArn (string), InvocationCount (int?), Steps (IReadOnlyList), + GetStep(name)->TestStep, FindStep(name)->TestStep?, GetSteps(name)->list, GetStepById(id)->TestStep, + GetChildren(TestStep)->list, GetStepsByStatus(string status)->list, EnsureSucceeded(). + NOTE: InvocationStatus is {Succeeded, Failed, Pending} (from the core SDK). There is NO separate + "ExecutionStatus" enum in .NET. + +TestStep (TestStep.cs): Id, Name (string?), ParentId (string?), Kind (OperationKind), SubKind (string?), + Status (string — compare against OperationStatus constants), Attempt (int), StartedAt/EndedAt + (DateTimeOffset?), Duration (TimeSpan?), Children (IReadOnlyList), GetResult()->T?, + GetError()->ErrorObject?, GetWaitEndsAt()->DateTimeOffset?, GetCallbackId()->string?, + GetChainedInvokeFunctionName()->string?. The docs "Operation" type maps to TestStep in .NET. + +OperationKind (OperationKind.cs, enum): Step, Wait, Callback, ChainedInvoke, Context, Execution. +OperationStatus (OperationStatus.cs, static string-const class): Started, Succeeded, Failed, Pending, + TimedOut, Cancelled, Stopped, Ready. + +CloudDurableTestRunner (CloudDurableTestRunner.cs) — the CLOUD runner: + - ctor: CloudDurableTestRunner(string functionName, CloudTestRunnerOptions? options = null, IAmazonLambda? lambdaClient = null) + (READ the ctor to confirm exact parameters before writing.) + - Same RunAsync/StartAsync/WaitForCallbackAsync/SendCallback*/WaitForResultAsync/DisposeAsync shape as local. +CloudTestRunnerOptions (CloudTestRunnerOptions.cs, record): InitialPollInterval (TimeSpan, 200ms), + PollInterval (TimeSpan, 2s), DefaultTimeout (TimeSpan, 5min), Serializer (ILambdaSerializer?). + +CONCEPTS ON THIS PAGE THAT MAY NOT EXIST IN .NET — do NOT fabricate. Verify each against source; +if there is no .NET equivalent, write a short honest C# tab saying the .NET SDK does not expose it +and pointing to the nearest real API, rather than inventing a member: + - "setupTestEnvironment / teardownTestEnvironment" and "LocalDurableTestRunnerSetupParameters" + (TS-specific lifecycle) — .NET uses "await using" + the ctor; no static setup/teardown. + - "History events" and "Invocations" sections — check whether TestResult exposes these. It exposes + InvocationCount and Steps; if there is no history-events API, say so. + - "Waiting operation status" enum — check for a .NET equivalent; OperationStatus covers statuses. + - "Register mock handlers for invoke" — maps to RegisterFunction / RegisterDurableFunction. + - "CloudDurableTestRunnerConfig" — the .NET type is CloudTestRunnerOptions. +` + +const AUTHOR_SCHEMA = { + type: 'object', additionalProperties: false, + required: ['tabGroupsTotal', 'tabGroupsWithCsharp', 'compilableSnippets', 'compiledOk', 'divergences', 'notes'], + properties: { + tabGroupsTotal: { type: 'integer' }, + tabGroupsWithCsharp: { type: 'integer' }, + compilableSnippets: { type: 'integer', description: 'how many C# snippets were standalone-compilable and thus compile-checked' }, + compiledOk: { type: 'boolean', description: 'true only if every compile-checked snippet built green' }, + divergences: { type: 'array', items: { type: 'string' }, description: 'sections where .NET has no equivalent and the C# tab says so instead of inventing API' }, + notes: { type: 'string' }, + }, +} + +const VERDICT_SCHEMA = { + type: 'object', additionalProperties: false, + required: ['tabsComplete', 'noFabricatedApi', 'compileClean', 'issues', 'verdict'], + properties: { + tabsComplete: { type: 'boolean', description: 'every tab group ends with 4 tabs in order TS, Python, Java, C#' }, + noFabricatedApi: { type: 'boolean', description: 'every C# type/member used actually exists in the testing-SDK source' }, + compileClean: { type: 'boolean', description: 'every standalone-compilable C# snippet builds green' }, + issues: { type: 'array', items: { type: 'string' } }, + verdict: { type: 'string', enum: ['PASS', 'FAIL'] }, + }, +} + +phase('Author') + +const author = await agent( + `You are extending an AWS documentation page that shows the durable-execution TESTING API in +content tabs ordered TypeScript, Python, Java. Add a fourth "C#" tab to EVERY tab group on the +page. The page uses INLINE code + prose in each tab (NOT --8<-- includes), so you edit the +markdown directly; you do not create example files. + +PAGE: ${MD} (edit in place) +It has 39 "=== \\"Java\\"" tab groups across these sections: LocalDurableTestRunner (Create a +runner, Run the handler, Run asynchronously, Inspect operations, Drive callbacks, Drive chained +invokes, Register mock handlers for invoke, Simulate failures, Control time, Reset between runs, +Reference types), TestResult (Status, Result, Error, Operations, History events, Invocations, +Pretty-print, Reference types), Operation (Common accessors, Step/Wait/Callback/Chained invoke/ +Context/Execution details, Drive a callback from an operation), Enums (Execution/Operation/ +Operation type/Waiting operation status), CloudDurableTestRunner (Create a runner, Run the handler, +Run asynchronously, Inspect operations, Drive callbacks, Configure polling and timeouts, Reset +between runs, Reference types). + +HARD RULES: +- Tab order ALWAYS TypeScript, Python, Java, C#. Add the C# block LAST in each group, right after Java, + matching the Java block's indentation and mix of code + explanatory prose. +- Match the Java tab's FORMAT per group: if Java shows a code signature, show the C# signature; if Java + shows prose + a snippet, mirror that. +- VERIFY EVERY MEMBER against source before writing. Read the files under ${TESTING_SRC}. +- DO NOT FABRICATE. Where a documented concept has no .NET equivalent, write a brief honest C# tab + ("The .NET SDK does not expose X; use Y instead.") and record it in divergences. A truthful "no + equivalent" note is REQUIRED over an invented member. +- The .NET local runner type is DurableTestRunner (not LocalDurableTestRunner). Use + "await using var runner = new DurableTestRunner<...>(workflow, new TestRunnerOptions { SkipTime = true });". + Never invent members like WorkflowForTest — pass the workflow delegate or an inline async (input,ctx)=>... . + +${API} + +GROUND TRUTH TO IMITATE: the committed C# testing examples + ${REPO}/examples/csharp/testing/authoring/*.cs and .../testing/examples/*.cs +show the exact idiom (runner ctor, RunAsync, TestResult/TestStep/OperationKind/OperationStatus usage). +Read a few before writing. + +COMPILE-CHECK: for any C# snippet that is a COMPLETE compilable unit (a full [Fact] test or a class), +write it to a temp file and build it to confirm, from ${REPO}: + dotnet build scripts/csharp-verify/dll-verify-testing.csproj -p:ExampleFile="" -p:BaseIntermediateOutputPath=".verify/apiref-/obj/" -p:BaseOutputPath=".verify/apiref-/bin/" -v q --nologo +(unique .verify dir per snippet, forward slashes). Single-line signatures / interface listings / enum +listings are pseudo-signatures — do not compile those. Fix any snippet that fails to compile. + +Return the manifest. tabGroupsWithCsharp must equal tabGroupsTotal (39).`, + { label: 'author:api-reference', phase: 'Author', schema: AUTHOR_SCHEMA, effort: 'high' }, +) + +phase('Verify') + +const verdict = author == null ? null : await agent( + `Independently and adversarially VERIFY the C# tabs just added to ${MD}. Assume the author invented +API and mis-ordered tabs. + +SOURCE: ${TESTING_SRC} (DurableTestRunner.cs, CloudDurableTestRunner.cs, TestRunnerOptions.cs, +CloudTestRunnerOptions.cs, TestResult.cs, TestStep.cs, OperationKind.cs, OperationStatus.cs) +AUTHOR MANIFEST: ${JSON.stringify(author)} + +Checks: +1. TABS: read ${MD}. Every content-tab group must end with exactly 4 tabs in order TypeScript, Python, + Java, C#. Report any group with != 4 tabs or wrong order (quote the nearest heading). tabsComplete. +2. NO FABRICATED API: for EVERY C# type and member referenced in the new tabs, confirm it exists in the + source with that exact name/signature (DurableTestRunner, CloudDurableTestRunner, TestResult, TestStep, + OperationKind, OperationStatus, TestRunnerOptions, CloudTestRunnerOptions, and their members). Any + invented member (e.g. WorkflowForTest, LocalDurableTestRunner, a non-existent enum) is a CONFIRMED + issue. Honest "no .NET equivalent" notes are acceptable and NOT issues. noFabricatedApi reflects this. +3. COMPILE: for each C# snippet that is a complete compilable unit, write it to a temp file and build via + scripts/csharp-verify/dll-verify-testing.csproj with fresh unique .verify dirs (forward slashes). + compileClean = all such snippets build. Report failures with errors. Do not compile pseudo-signatures. + +verdict = PASS only if tabsComplete && noFabricatedApi && compileClean.`, + { label: 'verify:api-reference', phase: 'Verify', schema: VERDICT_SCHEMA, effort: 'high' }, +) + +return { + page: MD, + author, + verdict, + passed: verdict?.verdict === 'PASS', +} diff --git a/scripts/csharp-verify/add-csharp-tabs-batch2.workflow.js b/scripts/csharp-verify/add-csharp-tabs-batch2.workflow.js new file mode 100644 index 0000000..25d9cd2 --- /dev/null +++ b/scripts/csharp-verify/add-csharp-tabs-batch2.workflow.js @@ -0,0 +1,215 @@ +export const meta = { + name: 'add-csharp-tabs-batch2', + description: 'Add verified C# tabs to getting-started, patterns, and testing pages', + phases: [ + { title: 'Author', detail: 'one agent per page: write C# examples + add === "C#" tabs, self-compile' }, + { title: 'Verify', detail: 'independent agent per page: recompile + check each .cs against SDK source' }, + ], +} + +const REPO = 'C:/dev/repos/aws-durable-execution-docs' +const SDK_SRC = 'C:/dev/repos/aws-lambda-dotnet/Libraries/src/Amazon.Lambda.DurableExecution' +const TESTING_SRC = 'C:/dev/repos/aws-lambda-dotnet/Libraries/src/Amazon.Lambda.DurableExecution.Testing' + +// kind: 'main' = uses the core SDK (dll-verify.csproj); 'testing' = uses the testing SDK +// (dll-verify-testing.csproj, references Amazon.Lambda.DurableExecution.Testing + xunit). +const PAGES = [ + { + slug: 'key-concepts', md: 'docs/getting-started/key-concepts.md', kind: 'main', + bases: ['getting-started/durable-context', 'getting-started/execution-model'], + note: 'Conceptual page. C# examples mirror the TS/Py/Java shape: an envelope Handler delegating to DurableFunction.WrapAsync, and a Workflow(TIn, IDurableContext ctx) method. durable-context shows accessing ctx (Logger, ExecutionContext); execution-model shows a small multi-step workflow.', + }, + { + slug: 'quickstart', md: 'docs/getting-started/quickstart.md', kind: 'main', + bases: ['getting-started/quickstart'], + note: 'DEPLOY WALKTHROUGH — high divergence. .NET packaging differs structurally. The C# quickstart should show the executable programming model (Main + LambdaBootstrap + HandlerWrapper + DefaultLambdaJsonSerializer) on the dotnet10 runtime, OR the class-library model, matching what the existing docs/sdk-reference/languages/csharp/index.md already documents. Reuse that page\'s verified quickstart code as the source of truth. Prose in the C# tab may legitimately differ from other languages (link to languages/csharp for full setup). Do not invent CLI/deploy commands — keep the C# tab to the code + a pointer to the C# language guide.', + }, + { + slug: 'quickstart-container', md: 'docs/getting-started/quickstart-container-image.md', kind: 'main', + bases: ['getting-started/quickstart'], + note: 'DEPLOY WALKTHROUGH (container image) — high divergence. The C# tab reuses the same quickstart workflow code as getting-started/quickstart. Do NOT fabricate a Dockerfile or base-image tags; if the surrounding prose shows container build steps per language and you cannot verify the .NET container flow from a real source, keep the C# tab to the workflow code plus a pointer to the C# language guide, and note in the manifest that container-deploy prose was not fabricated.', + }, + { + slug: 'code-organization', md: 'docs/patterns/best-practices/code-organization.md', kind: 'main', + bases: ['patterns/code-organization/child-context', 'patterns/code-organization/group-config', 'patterns/code-organization/parallelism', 'patterns/code-organization/separate-logic'], + note: 'Mirror the TS/Py/Java examples using RunInChildContextAsync, ParallelAsync, and plain method extraction. Verify signatures against IDurableContext.cs.', + }, + { + slug: 'determinism', md: 'docs/patterns/best-practices/determinism.md', kind: 'main', + bases: ['patterns/determinism/non-deterministic-in-step', 'patterns/determinism/return-value-passing', 'patterns/determinism/stable-branches'], + note: 'non-deterministic-in-step: wrap DateTime.UtcNow/Guid.NewGuid/random in a StepAsync. return-value-passing: return from step, do not mutate clos: mirror examples/csharp/operations/steps/passing-data-*.cs. stable-branches: keep branch structure deterministic across replays.', + }, + { + slug: 'idempotency', md: 'docs/patterns/best-practices/idempotency.md', kind: 'main', + bases: ['patterns/idempotency/choose-semantics', 'patterns/idempotency/idempotency-tokens'], + note: 'choose-semantics: StepConfig { Semantics = StepSemantics.AtMostOncePerRetry } for non-idempotent ops, AtLeastOncePerRetry (default) otherwise. Verify against StepConfig.cs / RetryStrategy.cs. idempotency-tokens: derive a stable token (e.g. from IStepContext.OperationId) and pass to the downstream call.', + }, + { + slug: 'pause-resume', md: 'docs/patterns/best-practices/pause-resume.md', kind: 'main', + bases: ['patterns/pause-resume/callback-timeout', 'patterns/pause-resume/heartbeat-timeout', 'patterns/pause-resume/wait-for-condition', 'patterns/pause-resume/wait-vs-sleep'], + note: 'callback-timeout/heartbeat-timeout: CallbackConfig / WaitForCallbackConfig timeout options (read CallbackConfig.cs, WaitForCallbackConfig.cs). wait-for-condition: WaitForConditionAsync with a WaitForConditionConfig (read WaitForConditionConfig.cs, IWaitStrategy.cs). wait-vs-sleep: ctx.WaitAsync(TimeSpan) vs Thread.Sleep/Task.Delay (the point: use WaitAsync so no compute charge).', + }, + { + slug: 'state', md: 'docs/patterns/best-practices/state.md', kind: 'main', + bases: ['patterns/state/batch-result-pointers', 'patterns/state/durable-vs-local', 'patterns/state/store-references'], + note: 'batch-result-pointers: work with IBatchResult from Parallel/Map (read IBatchResult.cs, IBatchItem.cs). durable-vs-local: durable state comes from step return values, not local fields (mutations lost on replay). store-references: checkpoint a reference/key, not large blobs.', + }, + { + slug: 'step-design', md: 'docs/patterns/best-practices/step-design.md', kind: 'main', + bases: ['patterns/step-design/handle-errors-in-step', 'patterns/step-design/one-thing-per-step', 'patterns/step-design/reusable-step', 'patterns/step-design/step-boundary', 'patterns/step-design/step-names'], + note: 'Mirror TS/Py/Java. handle-errors-in-step: try/catch inside the step body or rely on RetryStrategy. reusable-step: extract a method returning Task used as the step body. step-names: the name arg to StepAsync (optional in .NET; inferred from call site when omitted).', + }, + { + slug: 'testing-assertions', md: 'docs/testing/assertions.md', kind: 'testing', + bases: ['testing/assertions/assert-callback', 'testing/assertions/assert-child-context', 'testing/assertions/assert-step', 'testing/assertions/assert-wait', 'testing/assertions/filter-by-status'], + note: 'Use DurableTestRunner + TestResult + TestStep + OperationKind + OperationStatus (all in Amazon.Lambda.DurableExecution.Testing). Read DurableTestRunner.cs, TestResult.cs, TestStep.cs, OperationKind.cs, OperationStatus.cs. TestResult accessors: GetStep(name), FindStep, GetSteps, GetStepsByStatus, Steps, EnsureSucceeded(), IsSucceeded, Result, Error. TestStep: Kind, Status, Name, Attempt, GetResult(), GetError(), GetCallbackId(). OperationKind {Step,Wait,Callback,ChainedInvoke,Context,Execution}. OperationStatus is a static string-const class {Started,Succeeded,Failed,Pending,TimedOut,Cancelled,Stopped,Ready}. filter-by-status uses GetStepsByStatus(OperationStatus.X). Use xunit [Fact]/Assert. Each test file is self-contained OR references a companion example class: if it references a workflow class, DEFINE that workflow inside the test file (do not invent WorkflowForTest — pass the workflow method or an inline async (input,ctx)=>... to the runner ctor).', + }, + { + slug: 'testing-authoring', md: 'docs/testing/authoring.md', kind: 'testing', + bases: ['testing/authoring/minimal-test', 'testing/authoring/test-branching', 'testing/authoring/test-failure', 'testing/authoring/test-retries'], + note: 'DurableTestRunner(handler, new TestRunnerOptions { SkipTime = true }) then await runner.RunAsync(input). Read DurableTestRunner.cs + TestRunnerOptions.cs. minimal-test: construct runner, RunAsync, EnsureSucceeded/assert Result. test-failure: assert result.IsFailed / result.Error. test-retries: assert a step\'s Attempt via result.GetStep(name).Attempt. Make each file self-contained: define the workflow under test in the same file (inline lambda or a static method), pass it to the runner ctor. Use xunit. Do NOT invent WorkflowForTest.', + }, + { + slug: 'testing-cloud-runner', md: 'docs/testing/cloud-runner.md', kind: 'testing', + bases: ['testing/cloud-runner/cloud-runner', 'testing/cloud-runner/cloud-runner-timeout'], + note: 'Use CloudDurableTestRunner + CloudTestRunnerOptions (read CloudDurableTestRunner.cs, CloudTestRunnerOptions.cs). CloudTestRunnerOptions: InitialPollInterval, PollInterval, DefaultTimeout, Serializer. Ctor and RunAsync/StartAsync/WaitForResultAsync mirror the local runner. cloud-runner-timeout: set DefaultTimeout or pass a timeout to RunAsync. Self-contained files; define the workflow inline. Use xunit.', + }, + { + slug: 'testing-workflow-patterns', md: 'docs/testing/workflow-patterns.md', kind: 'testing', + bases: ['testing/examples/child-context', 'testing/examples/long-waits', 'testing/examples/parallel-workflow', 'testing/examples/partial-failures', 'testing/examples/polling', 'testing/examples/sequential-workflow'], + note: 'End-to-end test examples using DurableTestRunner. long-waits: rely on TestRunnerOptions.SkipTime=true (default) so day-long WaitAsync completes instantly. parallel-workflow/partial-failures: ParallelAsync + IBatchResult (inspect .Failed / ThrowIfError, and OperationKind.Context steps). polling: WaitForConditionAsync. Each file self-contained: define the workflow in the file, pass to runner ctor. Use xunit. Read DurableTestRunner.cs, TestResult.cs, TestStep.cs, and the operation config source as needed.', + }, +] + +const AUTHOR_SCHEMA = { + type: 'object', additionalProperties: false, + required: ['page', 'compilableFiles', 'signatureFiles', 'tabGroupsTotal', 'tabGroupsWithCsharp', 'allCompiled', 'notes'], + properties: { + page: { type: 'string' }, + compilableFiles: { type: 'array', items: { type: 'string' } }, + signatureFiles: { type: 'array', items: { type: 'string' }, description: 'non-compilable fragment/pseudo-signature files' }, + tabGroupsTotal: { type: 'integer' }, + tabGroupsWithCsharp: { type: 'integer' }, + allCompiled: { type: 'boolean' }, + notes: { type: 'string' }, + }, +} + +const VERDICT_SCHEMA = { + type: 'object', additionalProperties: false, + required: ['page', 'compileClean', 'tabsComplete', 'signaturesMatchSource', 'issues', 'verdict'], + properties: { + page: { type: 'string' }, + compileClean: { type: 'boolean' }, + tabsComplete: { type: 'boolean' }, + signaturesMatchSource: { type: 'boolean' }, + issues: { type: 'array', items: { type: 'string' } }, + verdict: { type: 'string', enum: ['PASS', 'FAIL'] }, + }, +} + +function commonFor(kind) { + const harness = kind === 'testing' + ? `scripts/csharp-verify/dll-verify-testing.csproj (references the testing SDK + xunit)` + : `scripts/csharp-verify/dll-verify.csproj (references the core SDK + AWSSDK.Lambda)` + const srcNote = kind === 'testing' + ? `Testing SDK source: ${TESTING_SRC} (DurableTestRunner.cs, CloudDurableTestRunner.cs, TestRunnerOptions.cs, CloudTestRunnerOptions.cs, TestResult.cs, TestStep.cs, OperationKind.cs, OperationStatus.cs). Core SDK source: ${SDK_SRC}.` + : `Core SDK source: ${SDK_SRC} (IDurableContext.cs, StepConfig.cs, RetryStrategy.cs, and the config types).` + return ` +You are extending an AWS documentation site that shows every SDK example in content tabs +ordered TypeScript, Python, Java. Add a fourth "C#" tab (the .NET SDK) to every tab group +on ONE page, and create the backing C# example files the tabs include. + +HARD RULES: +- Tab order is ALWAYS TypeScript, Python, Java, C#. Add C# LAST in each group, right after Java. +- Every "=== \\"Java\\"" block gets a matching "=== \\"C#\\"" block. Do not skip any group. +- Snippet includes use exactly: --8<-- "examples/csharp/.cs" in a \`\`\`csharp fence, + indented 4 spaces, mirroring the Java block. If the Java tab shows inline code (no --8<--), + write the C# inline too (no file). +- The C# example must be FUNCTIONALLY EQUIVALENT to the TS/Python/Java trio for that base-path. +- VERIFY EVERY API DETAIL AGAINST SOURCE before writing. Do not guess a method name, parameter + order, type, or return type. Recurse into referenced types. + +GROUND TRUTH TO READ FIRST: +1. Exemplar committed pages that already have all 4 tabs: docs/sdk-reference/operations/step.md + and the C# example files under examples/csharp/operations/steps/*.cs and + examples/csharp/operations/child-contexts/*.cs (incl. test-child-context.cs for the testing pattern). + Also docs/sdk-reference/languages/csharp/index.md for the handler/serializer/programming-model story. +2. ${srcNote} +3. The existing examples/{typescript,python,java}/.{ts,py,java} for each base-path. + +C# FILE CONVENTIONS (match the committed examples/csharp files): +- Standalone, self-contained. Non-test example: a public class with a Handler returning + Task delegating to DurableFunction.WrapAsync(Workflow, input, context), + and a Workflow(TIn, IDurableContext ctx). Define any record types in the file. +- Step/branch/child bodies are async lambdas: async (_, ct) => ... . Use ctx.Logger, never Console.WriteLine. +- Test example (testing pages): xunit [Fact], construct the runner with the workflow method or an + inline async (input,ctx)=>... . NEVER invent members like WorkflowForTest; pass the actual delegate. + If the test needs a workflow class, define it in the same file. +- Each file compiles in isolation, so duplicate record/class names ACROSS files are fine. +- Pseudo-signature / fragment files (mirroring a Java/TS file that just shows a signature, an + interface, or a bare statement list with no class) are NOT compilable: list them under + signatureFiles. Name them to match the base (keep an existing -signature suffix). + +SELF-COMPILE each compilable file before returning, from ${REPO}, using a UNIQUE .verify dir per file: + dotnet build ${harness} -p:ExampleFile="${REPO}/examples/csharp/.cs" -p:BaseIntermediateOutputPath=".verify/-/obj/" -p:BaseOutputPath=".verify/-/bin/" -v q --nologo +Exit 0 = pass. Fix and recompile until green. Do NOT set allCompiled=true unless every compilable file exits 0. +Note: .verify dir paths must use forward slashes.` +} + +phase('Author') + +const results = await pipeline( + PAGES, + (p) => agent( + `${commonFor(p.kind)} + +YOUR PAGE: ${p.md} (kind: ${p.kind}) +SLUG (for .verify dirs): ${p.slug} +C# BASE-PATHS TO CREATE (mirror the existing TS/Py/Java files; write examples/csharp/.cs): +${p.bases.map((b) => ' - ' + b).join('\n')} +PAGE NOTE: ${p.note} + +Steps: read the exemplar + source + existing examples; write the .cs files (or inline C# for +inline groups); edit ${p.md} to add a C# tab after every Java block; compile every compilable +file until green; return the manifest. tabGroupsWithCsharp must equal tabGroupsTotal.`, + { label: `author:${p.slug}`, phase: 'Author', schema: AUTHOR_SCHEMA, effort: 'high' }, + ), + (author, p) => author == null ? null : agent( + `Independently and adversarially VERIFY the C# tab work on ONE page. Assume the author erred. + +PAGE: ${p.md} (kind: ${p.kind}) +AUTHOR MANIFEST: ${JSON.stringify(author)} +HARNESS: ${p.kind === 'testing' ? 'scripts/csharp-verify/dll-verify-testing.csproj' : 'scripts/csharp-verify/dll-verify.csproj'} +SOURCE: core=${SDK_SRC}${p.kind === 'testing' ? ', testing=' + TESTING_SRC : ''} + +Checks: +1. COMPILE every file in compilableFiles independently, fresh unique .verify dirs (forward slashes), + from ${REPO}: + dotnet build -p:ExampleFile="${REPO}/" -p:BaseIntermediateOutputPath=".verify/verify-${p.slug}-/obj/" -p:BaseOutputPath=".verify/verify-${p.slug}-/bin/" -v q --nologo + compileClean = all exit 0. Report any failing file with its error. If a test file references a + companion workflow class in another file, compile them together (both in one temp project) and say so. +2. TABS: read ${p.md}. Every content-tab group must end with exactly 4 tabs in order + TypeScript, Python, Java, C#. tabsComplete reflects this; report any wrong group (quote nearest heading). +3. SOURCE FIDELITY: open the relevant source and confirm each C# snippet's method names, parameter + order/names/types, generic args, and return types match EXACTLY. For testing pages, confirm the + runner/TestResult/TestStep/enum members actually exist (no invented members like WorkflowForTest). + signaturesMatchSource reflects this; report each CONFIRMED mismatch. +4. Do NOT compile signatureFiles. + +verdict = PASS only if compileClean && tabsComplete && signaturesMatchSource.`, + { label: `verify:${p.slug}`, phase: 'Verify', schema: VERDICT_SCHEMA, effort: 'high' }, + ), +) + +const clean = results.filter(Boolean) +const passed = clean.filter((r) => r.verdict === 'PASS') +const failed = clean.filter((r) => r.verdict === 'FAIL') +log(`Verified ${clean.length}/${PAGES.length}: ${passed.length} PASS, ${failed.length} FAIL`) + +return { + summary: `${passed.length}/${PAGES.length} pages passed`, + passed: passed.map((r) => r.page), + failed: failed.map((r) => ({ page: r.page, issues: r.issues })), + missing: PAGES.filter((p) => !clean.some((r) => r.page && r.page.includes(p.slug))).map((p) => p.md), +} diff --git a/scripts/csharp-verify/add-csharp-tabs.workflow.js b/scripts/csharp-verify/add-csharp-tabs.workflow.js new file mode 100644 index 0000000..f1c7368 --- /dev/null +++ b/scripts/csharp-verify/add-csharp-tabs.workflow.js @@ -0,0 +1,250 @@ +export const meta = { + name: 'add-csharp-tabs', + description: 'Add a verified C# (.NET) tab to every SDK-reference operation and concept page', + phases: [ + { title: 'Author', detail: 'one agent per page: write C# examples + add === "C#" tabs, self-compile' }, + { title: 'Verify', detail: 'independent agent per page: recompile + check each .cs against IDurableContext.cs' }, + ], +} + +// --------------------------------------------------------------------------- +// Ground truth (all verified earlier against source): +// SDK source dir : C:/dev/repos/aws-lambda-dotnet/Libraries/src/Amazon.Lambda.DurableExecution +// SDK docs/core : /docs/core/{steps,wait,wait-for-condition,callbacks,child-contexts,parallel,cancellation}.md +// Exemplar page : docs/sdk-reference/operations/step.md (already has all 4 tabs) +// Exemplar C# : examples/csharp/operations/steps/*.cs (already compile-verified) +// Harness : scripts/csharp-verify/dll-verify.csproj (references prebuilt SDK DLLs, parallel-safe) +// --------------------------------------------------------------------------- + +const SDK_SRC = 'C:/dev/repos/aws-lambda-dotnet/Libraries/src/Amazon.Lambda.DurableExecution' + +// Per page: the markdown file, the examples// base-paths (lang-stripped, no +// extension) that already exist for TS/Py/Java and that C# must mirror 1:1, plus any +// SDK source files especially relevant to that page and a divergence note. +const PAGES = [ + { + slug: 'wait', + md: 'docs/sdk-reference/operations/wait.md', + bases: ['core/wait/basic-wait','core/wait/wait-signature','core/wait/duration-signature','core/wait/duration-helpers','core/wait/named-wait','core/wait/async-wait','core/wait/test-multiple-waits'], + source: ['IDurableContext.cs (WaitAsync)'], + note: 'WaitAsync(TimeSpan duration, string? name = null, CancellationToken = default). Minimum 1 second. .NET has no separate "duration helpers" type; use TimeSpan.FromMinutes/Hours/etc. The duration-signature / duration-helpers files should show TimeSpan usage.', + }, + { + slug: 'wait-for-condition', + md: 'docs/sdk-reference/operations/wait-for-condition.md', + bases: ['core/wait/wait-for-condition','core/wait/wait-for-condition-signature','core/wait/wait-strategy-signature','core/wait/custom-strategy','core/wait/strategy-helper'], + source: ['IDurableContext.cs (WaitForConditionAsync)','WaitForConditionConfig.cs','IWaitStrategy.cs','WaitStrategy.cs','IConditionCheckContext.cs','WaitForConditionException.cs','docs/core/wait-for-condition.md'], + note: 'WaitForConditionAsync(Func> check, WaitForConditionConfig config, string? name = null, CancellationToken = default). config is REQUIRED and generic on TState (carries InitialState + IWaitStrategy).', + }, + { + slug: 'callback', + md: 'docs/sdk-reference/operations/callback.md', + bases: ['operations/callbacks/basic-example','operations/callbacks/wait-for-callback-example','operations/callbacks/create-callback-signature','operations/callbacks/wait-for-callback-signature','operations/callbacks/callback-config'], + source: ['IDurableContext.cs (CreateCallbackAsync, WaitForCallbackAsync)','ICallback.cs','CallbackConfig.cs','WaitForCallbackConfig.cs','IWaitForCallbackContext.cs','CallbackException.cs','docs/core/callbacks.md'], + note: 'CreateCallbackAsync(string? name, CallbackConfig? config, CancellationToken) returns Task>; ICallback exposes CallbackId and GetResultAsync(ct). WaitForCallbackAsync(Func submitter, string? name, WaitForCallbackConfig? config, CancellationToken).', + }, + { + slug: 'invoke', + md: 'docs/sdk-reference/operations/invoke.md', + bases: ['operations/invoke/process-order','operations/invoke/invoke-method-signature','operations/invoke/invoke-with-config','operations/invoke/handle-invocation-error'], + source: ['IDurableContext.cs (InvokeAsync)','InvokeConfig.cs','InvokeException.cs'], + note: 'InvokeAsync(string functionName, TPayload payload, string? name, InvokeConfig? config, CancellationToken). functionName must be qualified (version/alias/$LATEST).', + }, + { + slug: 'parallel', + md: 'docs/sdk-reference/operations/parallel.md', + bases: ['operations/parallel/simple-parallel','operations/parallel/named-branches','operations/parallel/pass-arguments','operations/parallel/parallel-config','operations/parallel/completion-config','operations/parallel/error-handling','operations/parallel/nested-parallel'], + source: ['IDurableContext.cs (ParallelAsync x2)','ParallelConfig.cs','DurableBranch.cs','IBatchResult.cs','IBatchItem.cs','CompletionConfig.cs','CompletionReason.cs','docs/core/parallel.md'], + note: 'Two overloads: ParallelAsync(IReadOnlyList>> branches, ...) and ParallelAsync(IReadOnlyList> branches, ...). Returns Task>. Use IBatchResult.ThrowIfError for strict success.', + }, + { + slug: 'map', + md: 'docs/sdk-reference/operations/map.md', + bases: ['operations/map/simple-map','operations/map/map-signature','operations/map/map-function','operations/map/named-map','operations/map/map-config','operations/map/completion-config','operations/map/error-handling','operations/map/nested-map'], + source: ['IDurableContext.cs (MapAsync)','MapConfig.cs','IBatchResult.cs','IBatchItem.cs','CompletionConfig.cs','CompletionReason.cs'], + note: 'MapAsync(IReadOnlyList items, Func,CancellationToken,Task> func, string? name, MapConfig? config, CancellationToken). The func receives (ctx, item, index, allItems, ct). There is no docs/core/map.md — rely on source + XML docs.', + }, + { + slug: 'child-context', + md: 'docs/sdk-reference/operations/child-context.md', + bases: ['operations/child-contexts/basic-child-context','operations/child-contexts/run-in-child-context-signature','operations/child-contexts/child-config-signature','operations/child-contexts/context-function','operations/child-contexts/pass-arguments','operations/child-contexts/named-child-context','operations/child-contexts/concurrent-child-contexts','operations/child-contexts/test-child-context'], + source: ['IDurableContext.cs (RunInChildContextAsync x2)','ChildContextConfig.cs','DurableExecutionException.cs (ChildContextException)','docs/core/child-contexts.md'], + note: 'RunInChildContextAsync(Func> func, string? name, ChildContextConfig? config, CancellationToken) and a void overload. On failure surfaces ChildContextException; ChildContextConfig.ErrorMapping remaps it.', + }, + { + slug: 'errors', + md: 'docs/sdk-reference/error-handling/errors.md', + bases: ['sdk-reference/error-handling/basic-error-handling','sdk-reference/error-handling/exception-hierarchy','sdk-reference/error-handling/validation-error','sdk-reference/error-handling/step-interrupted','sdk-reference/error-handling/serdes-error'], + source: ['DurableExecutionException.cs','CallbackException.cs','InvokeException.cs','WaitForConditionException.cs','ErrorObject.cs'], + note: 'The exception-hierarchy file is typically a pseudo/tree listing (non-compilable) — mirror it as such. Map each language exception to its .NET equivalent by reading the *Exception.cs files. .NET uses OperationCanceledException for token-driven cancellation (not a step failure).', + }, + { + slug: 'retries', + md: 'docs/sdk-reference/error-handling/retries.md', + bases: ['sdk-reference/error-handling/exponential-backoff','sdk-reference/error-handling/retry-strategy-config-signature','sdk-reference/error-handling/jitter-strategy-signature','sdk-reference/error-handling/linear-retry-strategy','sdk-reference/error-handling/linear-retry-strategy-config-signature','sdk-reference/error-handling/retry-strategy-signature','sdk-reference/error-handling/custom-retry-strategy','sdk-reference/error-handling/retry-presets','sdk-reference/error-handling/with-retry-helper','sdk-reference/error-handling/retry-specific-errors'], + source: ['RetryStrategy.cs','IRetryStrategy.cs','StepConfig.cs','docs/core/steps.md (retry section)'], + note: 'HIGH DIVERGENCE. .NET retry API differs from the others: static factory RetryStrategy.{Default,Transient,None,Exponential(...),FromDelegate(...)} returning IRetryStrategy; RetryDecision.{DoNotRetry(),RetryAfter(delay)}; enum JitterStrategy {None,Full,Half}. Exponential(maxAttempts, initialDelay, maxDelay, backoffRate, jitter, retryableExceptions, retryableMessagePatterns). There is NO built-in "linear" strategy — for the linear-* bases, show RetryStrategy.FromDelegate implementing a constant/linear delay. "presets" map to Default/Transient/None. "with-retry helper" maps to setting StepConfig.RetryStrategy. "retry-specific-errors" maps to the retryableExceptions param. The C# prose in these tabs will legitimately differ from other languages.', + }, + { + slug: 'serialization', + md: 'docs/sdk-reference/state/serialization.md', + bases: ['sdk-reference/serialization/Walkthrough','sdk-reference/serialization/SerdesInterface','sdk-reference/serialization/OrderSerDes','sdk-reference/serialization/StepConfigExample','sdk-reference/serialization/CallbackConfigExample','sdk-reference/serialization/MapConfigExample','sdk-reference/serialization/PassThroughSerdesExample'], + source: ['DurableFunction.cs (ILambdaSerializer story)','StepConfig.cs','CallbackConfig.cs','MapConfig.cs','README.md (AOT section)'], + note: 'HIGH DIVERGENCE. .NET has NO per-operation serdes: one ILambdaSerializer is registered on ILambdaContext.Serializer and used for everything. StepConfig/CallbackConfig/MapConfig do NOT expose a serdes property (verify in source). The C# tabs must explain the single-serializer model: DefaultLambdaJsonSerializer for reflection, SourceGeneratorLambdaJsonSerializer for AOT/trim. SerdesInterface/OrderSerDes/PassThrough bases: show how to register a custom ILambdaSerializer at the host boundary instead of a per-step SerDes. Keep C# prose accurate even though it diverges structurally.', + }, + { + slug: 'logging', + md: 'docs/sdk-reference/observability/logging.md', + bases: ['sdk-reference/observability/basic-usage','sdk-reference/observability/step-context-logger','sdk-reference/observability/replay-suppression','sdk-reference/observability/powertools-logger'], + source: ['IDurableContext.cs (Logger, ConfigureLogger, IStepContext.Logger)','LoggerConfig.cs'], + note: 'ctx.Logger is a Microsoft.Extensions.Logging.ILogger, replay-safe by default. ConfigureLogger(new LoggerConfig { CustomLogger = ..., ModeAware = false }) swaps logger / disables replay suppression. IStepContext.Logger is the step-scoped logger. Powertools: register via ConfigureLogger CustomLogger.', + }, + { + slug: 'custom-lambda-client', + md: 'docs/sdk-reference/configuration/custom-lambda-client.md', + bases: ['configuration/custom-client'], + source: ['DurableFunction.cs (WrapAsync overloads taking IAmazonLambda lambdaClient)'], + note: 'The custom client is passed as the 4th arg to DurableFunction.WrapAsync(workflow, input, context, lambdaClient) where lambdaClient is an IAmazonLambda. No DurableConfig object like Java; the client is a WrapAsync parameter.', + }, +] + +const AUTHOR_SCHEMA = { + type: 'object', + additionalProperties: false, + required: ['page', 'compilableFiles', 'signatureFiles', 'tabGroupsTotal', 'tabGroupsWithCsharp', 'allCompiled', 'notes'], + properties: { + page: { type: 'string' }, + compilableFiles: { type: 'array', items: { type: 'string' }, description: 'examples/csharp/... paths written that must compile' }, + signatureFiles: { type: 'array', items: { type: 'string' }, description: 'pseudo-signature / non-compilable example paths written' }, + tabGroupsTotal: { type: 'integer', description: 'number of content-tab groups on the page' }, + tabGroupsWithCsharp: { type: 'integer', description: 'how many now have a C# tab (should equal tabGroupsTotal)' }, + allCompiled: { type: 'boolean', description: 'true only if every compilableFile built green via the harness' }, + notes: { type: 'string', description: 'divergences, anything the reviewer should scrutinize' }, + }, +} + +const VERDICT_SCHEMA = { + type: 'object', + additionalProperties: false, + required: ['page', 'compileClean', 'tabsComplete', 'signaturesMatchSource', 'issues', 'verdict'], + properties: { + page: { type: 'string' }, + compileClean: { type: 'boolean', description: 'every compilable C# file for this page built green in an independent run' }, + tabsComplete: { type: 'boolean', description: 'every tab group ends with exactly 4 tabs in order TS, Python, Java, C#' }, + signaturesMatchSource: { type: 'boolean', description: 'every C# API usage matches IDurableContext.cs / config source exactly (param order, names, types, return types)' }, + issues: { type: 'array', items: { type: 'string' }, description: 'CONFIRMED problems, most severe first; empty if clean' }, + verdict: { type: 'string', enum: ['PASS', 'FAIL'] }, + }, +} + +const COMMON = ` +You are extending an AWS documentation site that shows every SDK example in content tabs +ordered TypeScript, Python, Java. Your job for ONE page is to add a fourth "C#" tab +(the .NET SDK, Amazon.Lambda.DurableExecution) to every tab group, and to create the +backing C# example files the tabs include. + +HARD RULES (from the repo's authoring guide): +- Tab order is ALWAYS TypeScript, then Python, then Java, then C#. Add C# LAST in each group. +- Every "=== \\"Java\\"" block in the page must gain a matching "=== \\"C#\\"" block right after it + (after the Java code fence / prose). Do not skip any group. +- The C# example must be FUNCTIONALLY EQUIVALENT to the existing TS/Python/Java trio for the + same base-path: same scenario, same values, idiomatic .NET. +- Snippet includes use exactly: --8<-- "examples/csharp/.cs" inside a \`\`\`csharp fence, + indented 4 spaces under the "=== \\"C#\\"" line, mirroring the Java block's structure. +- Some existing examples are inline code (no --8<-- include) or pseudo-signatures. Match whatever + the Java tab does for that group: if Java uses --8<--, you use --8<-- and create the file; if Java + shows an inline signature/interface, write the C# equivalent inline in the tab (no file). +- VERIFY EVERY API DETAIL AGAINST SOURCE. Read the SDK source files before writing. Do not guess a + method name, parameter order, type, or return type. Recurse into referenced config types. + +GROUND TRUTH TO READ FIRST (in this order): +1. The exemplar page docs/sdk-reference/operations/step.md — it already has all 4 tabs; copy its + tab structure, indentation, and the shape of its C# prose exactly. +2. The exemplar C# files examples/csharp/operations/steps/*.cs — copy their file style (usings, + handler shape via DurableFunction.WrapAsync, record types, async lambdas (_, ct) => ...). +3. The SDK source in ${SDK_SRC} — read IDurableContext.cs plus the page-specific files listed below. +4. The existing examples/{typescript,python,java}/.{ts,py,java} for each base-path, to match scenario/values. + +C# EXAMPLE FILE CONVENTIONS (match the existing csharp/operations/steps files): +- Standalone, self-contained: usings, a public class with a Handler method that returns + Task and delegates to DurableFunction.WrapAsync(Workflow, input, context), + and a private Workflow(TIn input, IDurableContext ctx) method. Define any record types the file needs + IN THE FILE (each file compiles in isolation, so duplicate record names ACROSS files are fine). +- Step/branch/child bodies are async lambdas: async (ctx, ct) => ... or async (_, ct) => ... +- Use ctx.Logger, never Console.WriteLine. +- Pseudo-signature files (mirroring a Java/TS file that just shows a method signature or interface) + are NOT compilable: give them a name ending in -signature.cs when the base already ends in -signature, + and list them under signatureFiles so they are skipped by the compiler. If a base does NOT end in + -signature but its Java/TS counterpart is still non-compilable (e.g. an interface listing or an + exception-hierarchy tree), also treat it as a signature file and note that. + +SELF-COMPILE EACH COMPILABLE FILE before returning. From the repo root +C:/dev/repos/aws-durable-execution-docs run, for each compilable file: + dotnet build scripts/csharp-verify/dll-verify.csproj -p:ExampleFile="C:/dev/repos/aws-durable-execution-docs/examples/csharp/.cs" -p:BaseIntermediateOutputPath=".verify/-/obj/" -p:BaseOutputPath=".verify/-/bin/" -v q --nologo +Use a UNIQUE .verify/-/ dir per file (never reuse a dir across concurrent builds). +Exit code 0 = pass. If it fails, read the error, fix the .cs (or the SDK usage), recompile until green. +Do NOT mark allCompiled=true unless every compilable file exits 0. +` + +phase('Author') + +const results = await pipeline( + PAGES, + (p) => agent( + `${COMMON} + +YOUR PAGE: ${p.md} +SLUG (for .verify dirs): ${p.slug} +C# BASE-PATHS TO CREATE (mirror the existing TS/Py/Java files for each; write examples/csharp/.cs): +${p.bases.map((b) => ' - ' + b).join('\n')} +PAGE-SPECIFIC SOURCE TO READ: ${p.source.join(', ')} +DIVERGENCE NOTE: ${p.note} + +Steps: +1. Read the exemplar, the SDK source files listed, and the existing examples for each base-path. +2. Write examples/csharp/.cs for every base whose Java counterpart uses --8<--. + For inline/pseudo-signature groups, prepare the inline C# instead. +3. Edit ${p.md}: after every "=== \\"Java\\"" block, insert the "=== \\"C#\\"" block (include or inline to match). +4. Compile every compilable file via the harness until all are green. +5. Return the manifest. Set tabGroupsWithCsharp = the number of groups you added C# to; it must equal tabGroupsTotal.`, + { label: `author:${p.slug}`, phase: 'Author', schema: AUTHOR_SCHEMA, effort: 'high' }, + ), + (author, p) => author == null ? null : agent( + `Independently VERIFY the C# tab work on ONE documentation page. Be adversarial: assume the author +made mistakes. Confirm each issue before reporting it. + +PAGE: ${p.md} +AUTHOR MANIFEST: ${JSON.stringify(author)} +SDK SOURCE DIR: ${SDK_SRC} + +Checks: +1. COMPILE: independently run the harness on EVERY file the author listed in compilableFiles, using + fresh unique .verify dirs (e.g. .verify/verify-${p.slug}-/). From C:/dev/repos/aws-durable-execution-docs: + dotnet build scripts/csharp-verify/dll-verify.csproj -p:ExampleFile="C:/dev/repos/aws-durable-execution-docs/" -p:BaseIntermediateOutputPath=".verify/verify-${p.slug}-/obj/" -p:BaseOutputPath=".verify/verify-${p.slug}-/bin/" -v q --nologo + compileClean = true only if all exit 0. Report any file that fails with its error. +2. TABS: read ${p.md}. Every content-tab group must end with exactly 4 tabs in the order + TypeScript, Python, Java, C#. No group left with only 3. No duplicate/misordered tabs. + tabsComplete reflects this. Report any group that is wrong (quote the nearest heading). +3. SOURCE FIDELITY: for each C# file/inline snippet, open the relevant source in the SDK dir and confirm + the method name, parameter order, parameter names, types, generic args, and return type match EXACTLY + (IDurableContext.cs and the page's config types). Report any mismatch as a CONFIRMED issue. + signaturesMatchSource reflects this. +4. Do NOT try to compile signatureFiles. + +Return the verdict. verdict = PASS only if compileClean && tabsComplete && signaturesMatchSource.`, + { label: `verify:${p.slug}`, phase: 'Verify', schema: VERDICT_SCHEMA, effort: 'high' }, + ), +) + +const clean = results.filter(Boolean) +const passed = clean.filter((r) => r.verdict === 'PASS') +const failed = clean.filter((r) => r.verdict === 'FAIL') + +log(`Verified ${clean.length}/${PAGES.length} pages: ${passed.length} PASS, ${failed.length} FAIL`) + +return { + summary: `${passed.length}/${PAGES.length} pages passed`, + passed: passed.map((r) => r.page), + failed: failed.map((r) => ({ page: r.page, issues: r.issues })), + missing: PAGES.filter((p) => !clean.some((r) => r.page && r.page.includes(p.slug))).map((p) => p.md), +} diff --git a/scripts/csharp-verify/csharp-verify.csproj b/scripts/csharp-verify/csharp-verify.csproj new file mode 100644 index 0000000..4a14255 --- /dev/null +++ b/scripts/csharp-verify/csharp-verify.csproj @@ -0,0 +1,43 @@ + + + + + + net10.0 + enable + enable + + Library + false + + $(NoWarn) + + + + ..\..\..\aws-lambda-dotnet\Libraries\src\Amazon.Lambda.DurableExecution\Amazon.Lambda.DurableExecution.csproj + + + + + + + + + + + + diff --git a/scripts/csharp-verify/dll-verify-testing.csproj b/scripts/csharp-verify/dll-verify-testing.csproj new file mode 100644 index 0000000..8061fb3 --- /dev/null +++ b/scripts/csharp-verify/dll-verify-testing.csproj @@ -0,0 +1,41 @@ + + + + + + net10.0 + enable + enable + Library + false + ..\..\..\aws-lambda-dotnet\Libraries\src\Amazon.Lambda.DurableExecution.Testing\bin\Release\net10.0 + + + + + $(TestingBin)\Amazon.Lambda.Core.dll + + + $(TestingBin)\Amazon.Lambda.DurableExecution.dll + + + $(TestingBin)\Amazon.Lambda.DurableExecution.Testing.dll + + + + + + + + + + + diff --git a/scripts/csharp-verify/dll-verify.csproj b/scripts/csharp-verify/dll-verify.csproj new file mode 100644 index 0000000..9dd5f97 --- /dev/null +++ b/scripts/csharp-verify/dll-verify.csproj @@ -0,0 +1,46 @@ + + + + + + net10.0 + enable + enable + Library + false + ..\..\..\aws-lambda-dotnet\Libraries\src\Amazon.Lambda.DurableExecution\bin\Release\net10.0 + + + + + $(SdkBin)\Amazon.Lambda.Core.dll + + + $(SdkBin)\Amazon.Lambda.DurableExecution.dll + + + $(SdkBin)\Microsoft.Extensions.Logging.Abstractions.dll + + + + + + + + + + diff --git a/scripts/csharp-verify/extract-inline.py b/scripts/csharp-verify/extract-inline.py new file mode 100644 index 0000000..4408a9a --- /dev/null +++ b/scripts/csharp-verify/extract-inline.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +""" +Extract every inline C# code block (NOT --8<-- includes) from the C# tabs of the +docs and dump each to a file under a scratch dir, tagged by source page + line. +These are the snippets the compile harness never sees (they live only in markdown). + +Usage: python scripts/csharp-verify/extract-inline.py +Prints a manifest: \t:\t +kind = 'type' (declares interface/enum/class/record -> verify members vs SDK, do not compile) + | 'code' (has await/lambda/Fact -> try to compile) + | 'frag' (bare statements/signatures -> neither) +""" +import re, glob, os, sys + +out = sys.argv[1] if len(sys.argv) > 1 else '.verify/inline' +os.makedirs(out, exist_ok=True) + +def classify(body): + if re.search(r'\b(interface|enum)\s+\w', body): + return 'type' + if re.search(r'\b(class|record)\s+\w', body) and 'await' not in body and '=>' not in body and 'return ' not in body: + return 'type' + if 'await' in body or '=>' in body or '[Fact]' in body: + return 'code' + return 'frag' + +manifest = [] +n = 0 +for fn in sorted(glob.glob('docs/**/*.md', recursive=True)): + lines = open(fn, encoding='utf-8').read().split('\n') + incs = False; inblock = False; buf = []; startln = 0 + for i, ln in enumerate(lines, 1): + s = ln.strip() + if re.match(r'=== "C#"', s): incs = True; continue + if re.match(r'=== "', s): incs = False + if re.match(r'^#', s): incs = False + if incs and s.startswith('```csharp'): + inblock = True; buf = []; startln = i; continue + if inblock and s.startswith('```'): + inblock = False + body = '\n'.join(buf) + if '--8<--' in body or not body.strip(): + continue + kind = classify(body) + n += 1 + outfn = os.path.join(out, f'block_{n:03d}.cs') + open(outfn, 'w', encoding='utf-8').write(body + '\n') + manifest.append(f'{outfn}\t{fn}:{startln}\t{kind}') + continue + if inblock: + # de-indent 4 spaces (tab body indentation) + buf.append(ln[4:] if ln.startswith(' ') else ln) + +open(os.path.join(out, 'manifest.tsv'), 'w', encoding='utf-8').write('\n'.join(manifest) + '\n') +for m in manifest: + print(m) +print(f'\n# {n} inline literal C# blocks extracted to {out}', file=sys.stderr) diff --git a/scripts/csharp-verify/testing-pair.csproj.in b/scripts/csharp-verify/testing-pair.csproj.in new file mode 100644 index 0000000..bcc3bc0 --- /dev/null +++ b/scripts/csharp-verify/testing-pair.csproj.in @@ -0,0 +1,29 @@ + + + + + + net10.0 + enable + enable + Library + + + + + __TB__/Amazon.Lambda.Core.dll + + + __TB__/Amazon.Lambda.DurableExecution.dll + + + __TB__/Amazon.Lambda.DurableExecution.Testing.dll + + + + + diff --git a/scripts/csharp-verify/verify-all.sh b/scripts/csharp-verify/verify-all.sh new file mode 100644 index 0000000..3a26a48 --- /dev/null +++ b/scripts/csharp-verify/verify-all.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +# One-command verification of ALL C# in the docs against the real SDK source. +# NOT part of CI. Run from the repo root: bash scripts/csharp-verify/verify-all.sh +# +# It performs four checks and prints a single PASS/FAIL summary: +# 1. Build the core + testing SDKs (Release, net10.0) so the harness has fresh DLLs. +# 2. Compile every examples/csharp/**/*.cs file against the SDK (verify.sh). +# 3. Verify inline type-mirror blocks in the docs (types the docs redeclare) — +# every declared type/member must exist in the SDK source (verify-types.py). +# 4. Verify inline signature/fragment blocks — every SDK-ish identifier must +# exist in the SDK source (verify-frags.py). +# +# Prereqs: dotnet SDK (net10.0), python3, and the aws-lambda-dotnet repo checked out +# as a sibling of this repo (../aws-lambda-dotnet). Override with LAMBDA_DOTNET=. +set -u + +repo_root="$(cd "$(dirname "$0")/../.." && pwd)" +cd "$repo_root" +LAMBDA_DOTNET="${LAMBDA_DOTNET:-$repo_root/../aws-lambda-dotnet}" +CORE="$LAMBDA_DOTNET/Libraries/src/Amazon.Lambda.DurableExecution" +TESTING="$LAMBDA_DOTNET/Libraries/src/Amazon.Lambda.DurableExecution.Testing" +PY="$(command -v python || command -v python3)" + +fail=0 +step() { echo ""; echo "======================================================================"; echo "$1"; echo "======================================================================"; } + +if [ ! -d "$CORE" ]; then + echo "ERROR: aws-lambda-dotnet not found at $LAMBDA_DOTNET (set LAMBDA_DOTNET=)"; exit 2 +fi + +step "1/4 Build SDKs (Release net10.0)" +dotnet build "$CORE/Amazon.Lambda.DurableExecution.csproj" -c Release -f net10.0 -v q --nologo \ + && echo " core SDK OK" || { echo " core SDK BUILD FAILED"; fail=1; } +dotnet build "$TESTING/Amazon.Lambda.DurableExecution.Testing.csproj" -c Release -f net10.0 -v q --nologo \ + && echo " testing SDK OK" || { echo " testing SDK BUILD FAILED"; fail=1; } + +step "2/4 Compile every examples/csharp file against the SDK" +if bash "$repo_root/scripts/csharp-verify/verify.sh"; then + echo " example files OK" +else + echo " EXAMPLE FILE COMPILE FAILURES (see above)"; fail=1 +fi + +step "3/4 Verify inline type-mirror blocks vs SDK source" +"$PY" "$repo_root/scripts/csharp-verify/extract-inline.py" .verify/inline >/dev/null +if "$PY" "$repo_root/scripts/csharp-verify/verify-types.py" .verify/inline/manifest.tsv "$CORE" "$TESTING"; then + echo " type mirrors OK" +else + echo " TYPE-MIRROR MISMATCHES (see above)"; fail=1 +fi + +step "4/4 Verify inline signature/fragment identifiers vs SDK source" +if "$PY" "$repo_root/scripts/csharp-verify/verify-frags.py" .verify/inline/manifest.tsv "$CORE" "$TESTING"; then + echo " fragments OK" +else + echo " FRAGMENT IDENTIFIER MISMATCHES (see above)"; fail=1 +fi + +rm -rf "$repo_root/.verify" +echo "" +echo "======================================================================" +if [ "$fail" -eq 0 ]; then + echo "RESULT: PASS — all C# in the docs verified against the SDK." +else + echo "RESULT: FAIL — see sections above." +fi +echo "======================================================================" +exit "$fail" diff --git a/scripts/csharp-verify/verify-frags.py b/scripts/csharp-verify/verify-frags.py new file mode 100644 index 0000000..f08d873 --- /dev/null +++ b/scripts/csharp-verify/verify-frags.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +"""Cross-check every SDK-ish identifier in 'frag' inline blocks against SDK source.""" +import re, glob, os, sys + +manifest = sys.argv[1] +src_dirs = sys.argv[2:] + +hay = [] +for d in src_dirs: + for fn in glob.glob(os.path.join(d, '**', '*.cs'), recursive=True): + p = fn.replace('\\', '/') + if '/obj/' in p or '/bin/' in p: + continue + try: + hay.append(open(fn, encoding='utf-8').read()) + except Exception: + pass +HAY = '\n'.join(hay) + +def has(n): + return re.search(r'\b' + re.escape(n) + r'\b', HAY) is not None + +ignore = set('''Task Func IReadOnlyList CancellationToken String Int Void Params Object Exception +ILogger TimeSpan Boolean Double LogTrace LogDebug LogInformation LogWarning LogError LogCritical +Where ToList Select FromSeconds FromMilliseconds FromMinutes System Threading Tasks Microsoft +Extensions Logging Amazon Lambda Core BeginScope Dictionary +Assert Equal NotNull True False Contains Empty Throws ThrowsAsync +Drive Complete Keep Start Register Set Use Both Configuration Running Not Running'''.split()) + +# Words that only ever appear inside // comments are not API; strip comment lines first. + +rows = [l.split('\t') for l in open(manifest, encoding='utf-8').read().splitlines() if l.strip()] +frags = [(f, s) for f, s, k in rows if k == 'frag'] +prob = [] +checked = 0 +for f, src in frags: + body = open(f, encoding='utf-8').read() + # drop // comment tails so prose words in comments aren't treated as API + body = '\n'.join(re.sub(r'//.*$', '', ln) for ln in body.split('\n')) + for name in set(re.findall(r'\b([A-Z][A-Za-z0-9_]{2,})\b', body)): + if name in ignore: + continue + checked += 1 + if not has(name): + prob.append(f'{src}\t{name}') + +print(f'# checked {checked} identifiers across {len(frags)} frag blocks') +if prob: + for p in sorted(set(prob)): + print('MISSING:', p) + sys.exit(1) +print('ALL frag identifiers exist in SDK source') diff --git a/scripts/csharp-verify/verify-types.py b/scripts/csharp-verify/verify-types.py new file mode 100644 index 0000000..232ae5f --- /dev/null +++ b/scripts/csharp-verify/verify-types.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +""" +For each inline 'type' block (a doc snippet that redeclares an SDK public type), +verify every declared type and member name exists in the real SDK source. +These blocks never compile (they duplicate SDK types), so member-existence is the +check. Reports any member named in the docs that is absent from source. + +Usage: python scripts/csharp-verify/verify-types.py [ ...] +""" +import re, sys, glob, os + +manifest = sys.argv[1] +src_dirs = sys.argv[2:] + +# Build a haystack of all SDK source text (public API). +hay = [] +for d in src_dirs: + for fn in glob.glob(os.path.join(d, '**', '*.cs'), recursive=True): + if '/obj/' in fn.replace('\\', '/') or '/bin/' in fn.replace('\\', '/'): + continue + try: + hay.append(open(fn, encoding='utf-8').read()) + except Exception: + pass +HAY = '\n'.join(hay) + +def in_src(name): + return re.search(r'\b' + re.escape(name) + r'\b', HAY) is not None + +rows = [l.split('\t') for l in open(manifest, encoding='utf-8').read().splitlines() if l.strip()] +type_blocks = [(f, s) for f, s, k in rows if k == 'type'] + +problems = [] +checked = 0 +for fn, src in type_blocks: + body = open(fn, encoding='utf-8').read() + # declared type names + types = re.findall(r'\b(?:class|interface|enum|record)\s+([A-Za-z_]\w*)', body) + # declared members: properties/methods (PascalCase identifier followed by { or ( ) + members = set(re.findall(r'\b([A-Z][A-Za-z0-9_]*)\s*(?:\{|\()', body)) + # enum values: lines that are a bare Identifier, (with optional trailing comma) + enum_vals = set() + if re.search(r'\benum\b', body): + for m in re.findall(r'^\s*([A-Z][A-Za-z0-9_]*)\s*,?\s*$', body, re.M): + enum_vals.add(m) + names = set(types) | members | enum_vals + # ignore common BCL/framework identifiers not owned by the SDK + ignore = {'Task','TimeSpan','Func','ILogger','ILoggerFactory','ILambdaSerializer', + 'IReadOnlyList','CancellationToken','FromSeconds','FromMilliseconds','FromMinutes', + 'GetResults','GetErrors'} # GetResults/GetErrors verified separately below + for name in sorted(names): + if name in ignore: + continue + checked += 1 + if not in_src(name): + problems.append(f'{src}\tMISSING IN SDK: {name}') + +# explicit method checks that the generic regex ignores +for fn, src in type_blocks: + body = open(fn, encoding='utf-8').read() + for meth in ['GetResults','GetErrors','ThrowIfError','GetResultAsync']: + if meth in body and not in_src(meth): + problems.append(f'{src}\tMISSING METHOD IN SDK: {meth}') + +print(f'# checked {checked} identifiers across {len(type_blocks)} type blocks') +if problems: + print('\n'.join(sorted(set(problems)))) + sys.exit(1) +print('ALL type-mirror identifiers exist in SDK source') diff --git a/scripts/csharp-verify/verify.sh b/scripts/csharp-verify/verify.sh new file mode 100644 index 0000000..6ed60a7 --- /dev/null +++ b/scripts/csharp-verify/verify.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +# Compile-check the C# documentation examples against the local SDK. NOT part of CI. +# +# Categorizes each examples/csharp/**/*.cs file: +# - signature/fragment (*-signature.cs, plus known illustrative fragments) -> SKIP (non-compilable by design) +# - test file (references DurableExecution.Testing) -> compile with its companion via the testing harness +# - everything else -> compile standalone via dll-verify.csproj +# +# Prereqs (build once): +# dotnet build /Libraries/src/Amazon.Lambda.DurableExecution/*.csproj -c Release -f net10.0 +# dotnet build /Libraries/src/Amazon.Lambda.DurableExecution.Testing/*.csproj -c Release -f net10.0 +# +# Usage (from repo root): bash scripts/csharp-verify/verify.sh +set -u + +repo_root="$(cd "$(dirname "$0")/../.." && pwd)" +proj="$repo_root/scripts/csharp-verify/dll-verify.csproj" +tproj="$repo_root/scripts/csharp-verify/dll-verify-testing.csproj" +examples_dir="$repo_root/examples/csharp" + +# Illustrative fragments (no class, mirroring the other languages' snippets). Not compilable. +is_fragment() { + case "$1" in + *-signature.cs) return 0 ;; + */core/wait/duration-helpers.cs) return 0 ;; + */sdk-reference/error-handling/exception-hierarchy.cs) return 0 ;; + esac + return 1 +} + +# Map a test file to the companion that defines the workflow class it references. +companion_for() { + case "$1" in + */operations/child-contexts/test-child-context.cs) echo "$examples_dir/operations/child-contexts/basic-child-context.cs" ;; + *) echo "" ;; # self-contained test + esac +} + +pass=0; fail=0; skip=0; n=0 +failed_files=() + +while IFS= read -r f; do + n=$((n+1)) + if is_fragment "$f"; then + echo "SKIP ${f#"$repo_root/"} (fragment)" + skip=$((skip+1)) + continue + fi + + obj=".verify/v$n/obj/"; bin=".verify/v$n/bin/" + if grep -q "DurableExecution.Testing" "$f"; then + companion="$(companion_for "$f")" + if [ -n "$companion" ]; then + # Compile test + companion together in an isolated temp project dir. + # Use an absolute, normalized testing-bin path (HintPath in the copied csproj + # would otherwise resolve relative to the temp dir). + testing_bin="$(cd "$repo_root/../aws-lambda-dotnet/Libraries/src/Amazon.Lambda.DurableExecution.Testing/bin/Release/net10.0" && pwd)" + # dotnet on Windows needs a drive-qualified path in HintPath; -m gives C:/... with + # forward slashes (MSBuild-safe, and no backslash-escaping headaches in sed). + command -v cygpath >/dev/null 2>&1 && testing_bin="$(cygpath -m "$testing_bin")" + td="$(mktemp -d)" + cp "$f" "$companion" "$td/" + sed "s#__TB__#$testing_bin#" \ + "$repo_root/scripts/csharp-verify/testing-pair.csproj.in" > "$td/pair.csproj" + if dotnet build "$td/pair.csproj" -v q --nologo >/tmp/csharp-verify.log 2>&1; then + echo "PASS ${f#"$repo_root/"} (+ companion)"; pass=$((pass+1)) + else + echo "FAIL ${f#"$repo_root/"} (+ companion)"; grep -E "error " /tmp/csharp-verify.log | head -5; fail=$((fail+1)); failed_files+=("$f") + fi + rm -rf "$td" + else + if dotnet build "$tproj" -p:ExampleFile="$f" -p:BaseIntermediateOutputPath="$obj" -p:BaseOutputPath="$bin" -v q --nologo >/tmp/csharp-verify.log 2>&1; then + echo "PASS ${f#"$repo_root/"} (testing)"; pass=$((pass+1)) + else + echo "FAIL ${f#"$repo_root/"} (testing)"; grep -E "error " /tmp/csharp-verify.log | head -5; fail=$((fail+1)); failed_files+=("$f") + fi + fi + else + if dotnet build "$proj" -p:ExampleFile="$f" -p:BaseIntermediateOutputPath="$obj" -p:BaseOutputPath="$bin" -v q --nologo >/tmp/csharp-verify.log 2>&1; then + echo "PASS ${f#"$repo_root/"}"; pass=$((pass+1)) + else + echo "FAIL ${f#"$repo_root/"}"; grep -E "error " /tmp/csharp-verify.log | head -5; fail=$((fail+1)); failed_files+=("$f") + fi + fi +done < <(find "$examples_dir" -name '*.cs' | sort) + +rm -rf "$repo_root/.verify" +echo "" +echo "=== $pass passed, $fail failed, $skip skipped (of $n files) ===" +if [ "$fail" -gt 0 ]; then + printf ' FAIL %s\n' "${failed_files[@]}" + exit 1 +fi diff --git a/zensical.toml b/zensical.toml index fbcbbaa..5c503b7 100644 --- a/zensical.toml +++ b/zensical.toml @@ -91,6 +91,7 @@ nav = [ { "TypeScript" = "sdk-reference/languages/typescript/index.md" }, { "Python" = "sdk-reference/languages/python/index.md" }, { "Java" = "sdk-reference/languages/java/index.md" }, + { "C#" = "sdk-reference/languages/csharp/index.md" }, ]}, ]}, { "Testing" = [