diff --git a/SKILL.md b/SKILL.md index 6c36f07..3494417 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,6 +1,6 @@ --- name: temporal-developer -description: This skill should be used when the user asks to "create a Temporal workflow", "write a Temporal activity", "debug stuck workflow", "fix non-determinism error", "Temporal Python", "Temporal TypeScript", "workflow replay", "activity timeout", "signal workflow", "query workflow", "worker not starting", "activity keeps retrying", "Temporal heartbeat", "continue-as-new", "child workflow", "saga pattern", "workflow versioning", "durable execution", "reliable distributed systems", or mentions Temporal SDK development. +description: This skill should be used when the user asks to "create a Temporal workflow", "write a Temporal activity", "debug stuck workflow", "fix non-determinism error", "Temporal Python", "Temporal TypeScript", "Temporal .NET", "Temporal C#", "workflow replay", "activity timeout", "signal workflow", "query workflow", "worker not starting", "activity keeps retrying", "Temporal heartbeat", "continue-as-new", "child workflow", "saga pattern", "workflow versioning", "durable execution", "reliable distributed systems", or mentions Temporal SDK development. version: 1.0.0 --- @@ -8,7 +8,7 @@ version: 1.0.0 ## Overview -Temporal is a durable execution platform that makes workflows survive failures automatically. This skill provides guidance for building Temporal applications in Python and TypeScript. +Temporal is a durable execution platform that makes workflows survive failures automatically. This skill provides guidance for building Temporal applications in Python, TypeScript, and .NET. ## Core Architecture @@ -92,6 +92,7 @@ Once you've downloaded the file, extract the downloaded archive and add the temp 1. First, read the getting started guide for the language you are working in: - Python -> read `references/python/python.md` - TypeScript -> read `references/typescript/typescript.md` + - .NET (C#) -> read `references/dotnet/dotnet.md` 2. Second, read appropriate `core` and language-specific references for the task at hand. diff --git a/references/core/determinism.md b/references/core/determinism.md index bf4f1ec..a4f128e 100644 --- a/references/core/determinism.md +++ b/references/core/determinism.md @@ -80,6 +80,7 @@ Each Temporal SDK language provides a protection mechanism to make it easier to - Python: The Python SDK runs workflows in a sandbox that intercepts and aborts non-deterministic calls at runtime. - TypeScript: The TypeScript SDK runs workflows in an isolated V8 sandbox, intercepting many common sources of non-determinism and replacing them automatically with deterministic variants. +- .NET: The .NET SDK has no sandbox. It uses a custom TaskScheduler and a runtime EventListener to detect invalid task scheduling. Developers must use Workflow.* safe alternatives (e.g., Workflow.DelayAsync instead of Task.Delay) and avoid non-deterministic .NET Task APIs. ## Detecting Non-Determinism diff --git a/references/dotnet/advanced-features.md b/references/dotnet/advanced-features.md new file mode 100644 index 0000000..e0d98fc --- /dev/null +++ b/references/dotnet/advanced-features.md @@ -0,0 +1,160 @@ +# .NET SDK Advanced Features + +## Schedules + +Create recurring workflow executions. + +```csharp +using Temporalio.Client.Schedules; + +var scheduleId = "daily-report"; +await client.CreateScheduleAsync( + scheduleId, + new Schedule( + action: ScheduleActionStartWorkflow.Create( + (DailyReportWorkflow wf) => wf.RunAsync(), + new(id: "daily-report", taskQueue: "reports")), + spec: new ScheduleSpec + { + Intervals = new List + { + new(Every: TimeSpan.FromDays(1)), + }, + })); + +// Manage schedules +var handle = client.GetScheduleHandle(scheduleId); +await handle.PauseAsync("Maintenance window"); +await handle.UnpauseAsync(); +await handle.TriggerAsync(); // Run immediately +await handle.DeleteAsync(); +``` + +## Async Activity Completion + +For activities that complete asynchronously (e.g., human tasks, external callbacks). + +**Note:** If the external system can reliably Signal back with the result, consider using **signals** instead. + +```csharp +using Temporalio.Activities; +using Temporalio.Client; + +[Activity] +public async Task RequestApprovalAsync(string requestId) +{ + var taskToken = ActivityExecutionContext.Current.Info.TaskToken; + + // Store task token for later completion (e.g., in database) + await StoreTaskTokenAsync(requestId, taskToken); + + // Mark this activity as waiting for external completion + throw new CompleteAsyncException(); +} + +// Later, complete the activity from another process +public async Task CompleteApprovalAsync(string requestId, bool approved) +{ + var client = await TemporalClient.ConnectAsync(new("localhost:7233")); + var taskToken = await GetTaskTokenAsync(requestId); + + var handle = client.GetAsyncActivityHandle(taskToken); + + if (approved) + await handle.CompleteAsync("approved"); + else + await handle.FailAsync(new ApplicationFailureException("Rejected")); +} +``` + +## Worker Tuning + +Configure worker performance settings. + +```csharp +var worker = new TemporalWorker( + client, + new TemporalWorkerOptions("my-task-queue") + { + // Workflow task concurrency + MaxConcurrentWorkflowTasks = 100, + // Activity task concurrency + MaxConcurrentActivities = 100, + // Graceful shutdown timeout + GracefulShutdownTimeout = TimeSpan.FromSeconds(30), + } + .AddWorkflow() + .AddAllActivities(new MyActivities())); +``` + +## Workflow Failure Exception Types + +Control which exceptions cause workflow failures vs workflow task retries. + +**Default behavior:** Only `ApplicationFailureException` fails a workflow. All other exceptions retry the workflow task forever (treated as bugs to fix with a code deployment). + +**Tip for testing:** Set `WorkflowFailureExceptionTypes` to include `Exception` so any unhandled exception fails the workflow immediately rather than retrying the workflow task forever. This surfaces bugs faster. + +### Worker-Level Configuration + +```csharp +var worker = new TemporalWorker( + client, + new TemporalWorkerOptions("my-task-queue") + { + // These exception types will fail the workflow execution (not just the task) + WorkflowFailureExceptionTypes = new[] { typeof(ArgumentException), typeof(InvalidOperationException) }, + } + .AddWorkflow() + .AddAllActivities(new MyActivities())); +``` + +## Dependency Injection + +The .NET SDK supports dependency injection via the `Temporalio.Extensions.Hosting` package, which integrates with .NET's generic host. + +### Worker as Generic Host + +```csharp +using Temporalio.Extensions.Hosting; + +var host = Host.CreateDefaultBuilder(args) + .ConfigureServices(ctx => + ctx. + AddScoped(). + AddHostedTemporalWorker( + clientTargetHost: "localhost:7233", + clientNamespace: "default", + taskQueue: "my-task-queue"). + AddScopedActivities(). + AddWorkflow()) + .Build(); +await host.RunAsync(); +``` + +### Activity Dependency Injection + +Activities registered with `AddScopedActivities()` or `AddSingletonActivities()` are created via DI, allowing constructor injection: + +```csharp +public class MyActivities +{ + private readonly ILogger _logger; + private readonly IOrderRepository _repository; + + public MyActivities(ILogger logger, IOrderRepository repository) + { + _logger = logger; + _repository = repository; + } + + [Activity] + public async Task GetOrderAsync(string orderId) + { + _logger.LogInformation("Fetching order {OrderId}", orderId); + return await _repository.GetAsync(orderId); + } +} +``` + +**Note:** Dependency injection is NOT available in workflows — workflows must be self-contained for determinism. diff --git a/references/dotnet/data-handling.md b/references/dotnet/data-handling.md new file mode 100644 index 0000000..5f8ae5a --- /dev/null +++ b/references/dotnet/data-handling.md @@ -0,0 +1,217 @@ +# .NET SDK Data Handling + +## Overview + +The .NET SDK uses data converters to serialize/deserialize workflow inputs, outputs, and activity parameters. + +## Default Data Converter + +The default converter handles: +- `null` +- `byte[]` (as binary) +- `Google.Protobuf.IMessage` instances +- Anything that `System.Text.Json` supports +- `IRawValue` as unconverted raw payloads + +## Custom Data Converter + +Customize serialization by extending `DefaultPayloadConverter`. For example, to use camelCase property naming: + +```csharp +using System.Text.Json; +using Temporalio.Client; +using Temporalio.Converters; + +public class CamelCasePayloadConverter : DefaultPayloadConverter +{ + public CamelCasePayloadConverter() + : base(new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }) + { + } +} + +var client = await TemporalClient.ConnectAsync(new() +{ + TargetHost = "localhost:7233", + Namespace = "my-namespace", + DataConverter = DataConverter.Default with + { + PayloadConverter = new CamelCasePayloadConverter(), + }, +}); +``` + +## Protobuf Support + +The default data converter includes built-in support for Protocol Buffer messages via `Google.Protobuf.IMessage`. Protobuf messages are automatically serialized using proto3 JSON. + +```csharp +// Any Google.Protobuf.IMessage is automatically handled +[Workflow] +public class MyWorkflow +{ + [WorkflowRun] + public async Task RunAsync(MyProtoRequest request) + { + // Protobuf messages are serialized/deserialized automatically + return await Workflow.ExecuteActivityAsync( + (MyActivities a) => a.ProcessAsync(request), + new() { StartToCloseTimeout = TimeSpan.FromMinutes(5) }); + } +} +``` + +## Payload Encryption + +Encrypt sensitive workflow data using a custom `IPayloadCodec`: + +```csharp +using Temporalio.Converters; +using Google.Protobuf; + +public class EncryptionCodec : IPayloadCodec +{ + public Task> EncodeAsync( + IReadOnlyCollection payloads) => + Task.FromResult>(payloads.Select(p => + new Payload + { + Metadata = { ["encoding"] = "binary/encrypted" }, + Data = ByteString.CopyFrom(Encrypt(p.ToByteArray())), + }).ToList()); + + public Task> DecodeAsync( + IReadOnlyCollection payloads) => + Task.FromResult>(payloads.Select(p => + { + if (p.Metadata.GetValueOrDefault("encoding") != "binary/encrypted") + return p; + return Payload.Parser.ParseFrom(Decrypt(p.Data.ToByteArray())); + }).ToList()); + + private byte[] Encrypt(byte[] data) => /* your encryption logic */; + private byte[] Decrypt(byte[] data) => /* your decryption logic */; +} + +// Apply encryption codec +var client = await TemporalClient.ConnectAsync(new("localhost:7233") +{ + DataConverter = DataConverter.Default with + { + PayloadCodec = new EncryptionCodec(), + }, +}); +``` + +## Search Attributes + +Custom searchable fields for workflow visibility. These can be set at workflow start: + +```csharp +using Temporalio.Common; + +var handle = await client.StartWorkflowAsync( + (OrderWorkflow wf) => wf.RunAsync(order), + new(id: $"order-{order.Id}", taskQueue: "orders") + { + TypedSearchAttributes = new SearchAttributeCollection.Builder() + .Set(SearchAttributeKey.CreateKeyword("OrderId"), order.Id) + .Set(SearchAttributeKey.CreateKeyword("OrderStatus"), "pending") + .Set(SearchAttributeKey.CreateFloat("OrderTotal"), order.Total) + .Build(), + }); +``` + +Or upserted during workflow execution: + +```csharp +[Workflow] +public class OrderWorkflow +{ + [WorkflowRun] + public async Task RunAsync(Order order) + { + // ... process order ... + + // Update search attribute + Workflow.UpsertTypedSearchAttributes( + SearchAttributeUpdate.ValueSet( + SearchAttributeKey.CreateKeyword("OrderStatus"), "completed")); + return "done"; + } +} +``` + +### Querying Workflows by Search Attributes + +```csharp +await foreach (var wf in client.ListWorkflowsAsync( + "OrderStatus = \"processing\" OR OrderStatus = \"pending\"")) +{ + Console.WriteLine($"Workflow {wf.Id} is still processing"); +} +``` + +## Workflow Memo + +Store arbitrary metadata with workflows (not searchable). + +```csharp +await client.ExecuteWorkflowAsync( + (OrderWorkflow wf) => wf.RunAsync(order), + new(id: $"order-{order.Id}", taskQueue: "orders") + { + Memo = new Dictionary + { + ["customer_name"] = order.CustomerName, + ["notes"] = "Priority customer", + }, + }); +``` + +```csharp +// Read memo from workflow +[Workflow] +public class OrderWorkflow +{ + [WorkflowRun] + public async Task RunAsync(Order order) + { + var notes = Workflow.Memo["notes"]; + // ... + } +} +``` + +## Deterministic APIs for Values + +Use these APIs within workflows for deterministic random values and UUIDs: + +```csharp +[Workflow] +public class MyWorkflow +{ + [WorkflowRun] + public async Task RunAsync() + { + // Deterministic GUID (same on replay) + var uniqueId = Workflow.NewGuid(); + + // Deterministic random (same on replay) + var value = Workflow.Random.Next(1, 100); + + // Deterministic current time + var now = Workflow.UtcNow; + + return uniqueId.ToString(); + } +} +``` + +## Best Practices + +1. Use records or classes with `System.Text.Json` support for input/output +2. Keep payloads small — see `references/core/gotchas.md` for limits +3. Encrypt sensitive data with `IPayloadCodec` +4. Use `Workflow.NewGuid()` and `Workflow.Random` for deterministic values +5. Use camelCase converter if interoperating with other SDKs diff --git a/references/dotnet/determinism-protection.md b/references/dotnet/determinism-protection.md new file mode 100644 index 0000000..56bf5de --- /dev/null +++ b/references/dotnet/determinism-protection.md @@ -0,0 +1,76 @@ +# .NET Determinism Protection + +## Overview + +Unlike Python (module restriction sandbox) and TypeScript (V8 isolate sandbox), the .NET SDK has **no sandbox**. Instead, it relies on: +1. A custom `TaskScheduler` to order workflow tasks deterministically +2. A runtime `EventListener` that detects invalid task scheduling +3. Developer discipline to avoid non-deterministic operations + +## Runtime Task Detection + +By default, the .NET SDK enables an `EventListener` that monitors task events. When workflow code accidentally starts a task on the wrong scheduler (e.g., via `Task.Run`), an `InvalidWorkflowOperationException` is thrown. This "pauses" the workflow by failing the workflow task, which continually retries until the code is fixed. + +```csharp +// This will be detected at runtime and fail the workflow task +[Workflow] +public class BadWorkflow +{ + [WorkflowRun] + public async Task RunAsync() + { + // BAD: Task.Run uses TaskScheduler.Default + await Task.Run(() => DoSomething()); + } +} +``` + +To disable this detection (not recommended): +```csharp +var worker = new TemporalWorker( + client, + new TemporalWorkerOptions("my-task-queue") + { + DisableWorkflowTracingEventListener = true, + } + .AddWorkflow()); +``` + +## .NET Task Determinism Rules + +Many .NET `Task` APIs implicitly use `TaskScheduler.Default`, which breaks determinism. Here are the key rules: + +**Do NOT use:** +- `Task.Run` — uses default scheduler. Use `Workflow.RunTaskAsync`. +- `Task.ConfigureAwait(false)` — leaves current context. Use `ConfigureAwait(true)` or omit. +- `Task.Delay` / `Task.Wait` / timeout-based `CancellationTokenSource` — uses system timers. Use `Workflow.DelayAsync` / `Workflow.WaitConditionAsync`. +- `Task.WhenAny` — use `Workflow.WhenAnyAsync`. +- `Task.WhenAll` — use `Workflow.WhenAllAsync` (technically safe currently, but wrapper is recommended). +- `CancellationTokenSource.CancelAsync` — use `CancellationTokenSource.Cancel`. +- `System.Threading.Semaphore` / `SemaphoreSlim` / `Mutex` — use `Temporalio.Workflows.Semaphore` / `Mutex`. + +**Be wary of:** +- Third-party libraries that implicitly use `TaskScheduler.Default` +- `Dataflow` blocks and similar concurrency libraries with hidden default scheduler usage + +## Workflow .editorconfig + +Since workflows violate some standard .NET analyzer rules, consider an `.editorconfig` for workflow project files: + +```ini +# Workflow-specific analyzer settings +[*.cs] +# Allow async methods without await (some workflow methods are simple) +dotnet_diagnostic.CS1998.severity = none +# Allow getter/setter patterns needed for signal/query attributes +dotnet_diagnostic.CA1024.severity = none +``` + +## Best Practices + +1. **Always use `Workflow.*` alternatives** for Task operations in workflows +2. **Enable the `EventListener`** (default) — it catches mistakes at runtime +3. **Separate workflow and activity code** into different files/projects for clarity +4. **Use `SortedDictionary`** or sort collections before iterating — `Dictionary` iteration order is not guaranteed +5. **Test with replay** to catch non-determinism early +6. **Review third-party library usage** in workflow code for hidden default scheduler usage diff --git a/references/dotnet/determinism.md b/references/dotnet/determinism.md new file mode 100644 index 0000000..99f3049 --- /dev/null +++ b/references/dotnet/determinism.md @@ -0,0 +1,63 @@ +# .NET SDK Determinism + +## Overview + +The .NET SDK has **no sandbox** for workflow code. Determinism is enforced through developer discipline, runtime task detection via an `EventListener`, and safe API alternatives provided by the SDK. + +## Why Determinism Matters: History Replay + +Temporal provides durable execution through **History Replay**. When a Worker needs to restore workflow state (after a crash, cache eviction, or to continue after a long timer), it re-executes the workflow code from the beginning, which requires the workflow code to be **deterministic**. + +## SDK Protection + +The .NET SDK uses a custom `TaskScheduler` to order workflow tasks deterministically. It also enables a runtime `EventListener` that detects when workflow code accidentally uses the default scheduler. When detected, an `InvalidWorkflowOperationException` is thrown, which "pauses" the workflow (fails the workflow task) until the code is fixed. + +This is a **runtime-only** check — there is no compile-time sandbox. See `references/dotnet/determinism-protection.md` for details. + +## Forbidden Operations + +```csharp +// DO NOT do these in workflows: +await Task.Run(() => { }); // Uses default scheduler +await Task.Delay(TimeSpan.FromSeconds(1)); // System timer +var now = DateTime.UtcNow; // System clock +var r = new Random().Next(); // Non-deterministic +var id = Guid.NewGuid(); // Non-deterministic +File.ReadAllText("file.txt"); // I/O +await httpClient.GetAsync("..."); // Network I/O +``` + +Most non-determinism and side effects should be wrapped in Activities. + +## Safe Builtin Alternatives + +| Forbidden | Safe Alternative | +|-----------|------------------| +| `DateTime.Now` / `DateTime.UtcNow` | `Workflow.UtcNow` | +| `Random` | `Workflow.Random` | +| `Guid.NewGuid()` | `Workflow.NewGuid()` | +| `Task.Delay` | `Workflow.DelayAsync` | +| `Thread.Sleep` | `Workflow.DelayAsync` | +| `Task.Run` | `Workflow.RunTaskAsync` | +| `Task.WhenAll` | `Workflow.WhenAllAsync` | +| `Task.WhenAny` | `Workflow.WhenAnyAsync` | +| `System.Threading.Mutex` | `Temporalio.Workflows.Mutex` | +| `System.Threading.Semaphore` | `Temporalio.Workflows.Semaphore` | +| `CancellationTokenSource.CancelAsync` | `CancellationTokenSource.Cancel` | + +## Testing Replay Compatibility + +Use `WorkflowReplayer` to verify your code changes are compatible with existing histories. See the Workflow Replay Testing section of `references/dotnet/testing.md`. + +## Best Practices + +1. Use `Workflow.UtcNow` for all time operations +2. Use `Workflow.Random` for random values +3. Use `Workflow.NewGuid()` for unique identifiers +4. Use `Workflow.DelayAsync` instead of `Task.Delay` +5. Use `Workflow.WhenAllAsync` / `Workflow.WhenAnyAsync` for task combinators +6. Never use `ConfigureAwait(false)` in workflows +7. Use `SortedDictionary` or sort before iterating collections +8. Test with replay to catch non-determinism +9. Keep workflows focused on orchestration, delegate I/O to activities +10. Use `Workflow.Logger` for replay-safe logging diff --git a/references/dotnet/dotnet.md b/references/dotnet/dotnet.md new file mode 100644 index 0000000..c6adc6e --- /dev/null +++ b/references/dotnet/dotnet.md @@ -0,0 +1,159 @@ +# Temporal .NET SDK Reference + +## Overview + +The Temporal .NET SDK provides a high-performance, type-safe approach to building durable workflows using C# and .NET. Workflows use attributes (`[Workflow]`, `[WorkflowRun]`) and lambda expressions for type-safe invocations. .NET 6.0+ required. + +**CRITICAL**: The .NET SDK has **no sandbox**. Developers must be careful to avoid non-deterministic code in workflows. See the Determinism Rules section below and `references/dotnet/determinism.md`. + +## Understanding Replay + +Temporal workflows are durable through history replay. For details on how this works, see `references/core/determinism.md`. + +## Quick Start + +**Add Dependency:** Install the Temporal SDK NuGet package: +```bash +dotnet add package Temporalio +``` + +**Activities.cs** - Activity definitions (separate file for clarity): +```csharp +using Temporalio.Activities; + +public class MyActivities +{ + [Activity] + public string Greet(string name) + { + return $"Hello, {name}!"; + } +} +``` + +**GreetingWorkflow.cs** - Workflow definition: +```csharp +using Temporalio.Workflows; + +[Workflow] +public class GreetingWorkflow +{ + [WorkflowRun] + public async Task RunAsync(string name) + { + return await Workflow.ExecuteActivityAsync( + (MyActivities a) => a.Greet(name), + new() { StartToCloseTimeout = TimeSpan.FromSeconds(30) }); + } +} +``` + +**Worker (Program.cs)** - Worker setup: +```csharp +using Temporalio.Client; +using Temporalio.Worker; + +var client = await TemporalClient.ConnectAsync(new("localhost:7233")); + +using var worker = new TemporalWorker( + client, + new TemporalWorkerOptions("my-task-queue") + .AddWorkflow() + .AddAllActivities(new MyActivities())); + +await worker.ExecuteAsync(); +``` + +**Start the dev server:** Start `temporal server start-dev` in the background. + +**Start the worker:** Run `dotnet run` in the worker project. + +**Starter (Program.cs)** - Start a workflow execution: +```csharp +using Temporalio.Client; + +var client = await TemporalClient.ConnectAsync(new("localhost:7233")); + +var result = await client.ExecuteWorkflowAsync( + (GreetingWorkflow wf) => wf.RunAsync("my name"), + new(id: $"greeting-{Guid.NewGuid()}", taskQueue: "my-task-queue")); + +Console.WriteLine($"Result: {result}"); +``` + +**Run the workflow:** Run `dotnet run` in the starter project. Should output: `Result: Hello, my name!`. + +## Key Concepts + +### Workflow Definition +- Use `[Workflow]` attribute on class +- Use `[WorkflowRun]` on the async entry point method +- Must return `Task` or `Task` +- Use `[WorkflowSignal]`, `[WorkflowQuery]`, `[WorkflowUpdate]` for handlers + +### Activity Definition +- Use `[Activity]` attribute on methods +- Can be sync or async +- Instance methods support dependency injection +- Static methods are also supported + +### Worker Setup +- Connect client, create `TemporalWorker` with workflows and activities +- Use `AddWorkflow()` and `AddAllActivities(instance)` or `AddActivity(method)` + +### Determinism + +**Workflow code must be deterministic!** The .NET SDK has no sandbox. See the Determinism Rules section below and `references/core/determinism.md` and `references/dotnet/determinism.md`. + +## File Organization Best Practice + +**Keep Workflow definitions in separate files from Activity definitions.** While not as critical as Python (no sandbox reloading), separation improves clarity and testability. + +``` +MyTemporalApp/ +├── Workflows/ +│ └── GreetingWorkflow.cs # Only Workflow classes +├── Activities/ +│ └── TranslateActivities.cs # Only Activity classes +├── Models/ +│ └── OrderInput.cs # Shared data models +├── Worker/ +│ └── Program.cs # Worker setup +└── Starter/ + └── Program.cs # Client code to start workflows +``` + +## Determinism Rules + +The .NET SDK has **no sandbox** like Python or TypeScript. Developers must avoid non-deterministic operations manually. Many standard .NET `Task` APIs use `TaskScheduler.Default` implicitly, which breaks determinism. + +See `references/dotnet/determinism.md` for the full list of forbidden operations, safe alternatives, and best practices. See `references/dotnet/determinism-protection.md` for details on the runtime detection mechanism. + +## Common Pitfalls + +1. **Using `Task.Run` in workflows** — Uses default scheduler, breaks determinism. Use `Workflow.RunTaskAsync`. +2. **Using `Task.Delay` in workflows** — Uses system timer. Use `Workflow.DelayAsync`. +3. **`ConfigureAwait(false)` in workflows** — Leaves the deterministic scheduler. Never use in workflows. +4. **Non-`ApplicationFailureException` in workflows** — Other exceptions retry the workflow task forever instead of failing the workflow. +5. **Dictionary iteration in workflows** — `Dictionary` has no guaranteed order. Use `SortedDictionary`. +6. **Forgetting to heartbeat** — Long-running activities need `ActivityExecutionContext.Current.Heartbeat()` calls. +7. **Using `CancellationTokenSource.CancelAsync`** — Use `CancellationTokenSource.Cancel` instead. +8. **Logging with `Console.WriteLine` in workflows** — Use `Workflow.Logger` for replay-safe logging. + +## Writing Tests + +See `references/dotnet/testing.md` for info on writing tests. + +## Additional Resources + +### Reference Files +- **`references/dotnet/patterns.md`** — Signals, queries, child workflows, saga pattern, etc. +- **`references/dotnet/determinism.md`** — Essentials of determinism in .NET +- **`references/dotnet/gotchas.md`** — .NET-specific mistakes and anti-patterns +- **`references/dotnet/error-handling.md`** — ApplicationFailureException, retry policies, non-retryable errors +- **`references/dotnet/observability.md`** — Logging, metrics, tracing +- **`references/dotnet/testing.md`** — WorkflowEnvironment, time-skipping, activity mocking +- **`references/dotnet/advanced-features.md`** — Schedules, worker tuning, dependency injection +- **`references/dotnet/data-handling.md`** — Data converters, payload encryption, etc. +- **`references/dotnet/versioning.md`** — Patching API, workflow type versioning, Worker Versioning +- **`references/dotnet/determinism-protection.md`** — Runtime task detection, .NET Task determinism rules diff --git a/references/dotnet/error-handling.md b/references/dotnet/error-handling.md new file mode 100644 index 0000000..dc9e214 --- /dev/null +++ b/references/dotnet/error-handling.md @@ -0,0 +1,140 @@ +# .NET SDK Error Handling + +## Overview + +The .NET SDK uses `ApplicationFailureException` for application-specific errors and provides comprehensive retry policy configuration. Generally, the following information about errors and retryability applies across activities, child workflows and Nexus operations. + +## Application Failures + +```csharp +using Temporalio.Activities; +using Temporalio.Exceptions; + +[Activity] +public async Task ValidateOrderAsync(Order order) +{ + if (!order.IsValid()) + { + throw new ApplicationFailureException( + "Invalid order", + errorType: "ValidationError"); + } +} +``` + +## Non-Retryable Errors + +```csharp +using Temporalio.Activities; +using Temporalio.Exceptions; + +[Activity] +public async Task ChargeCardAsync(ChargeCardInput input) +{ + if (!IsValidCard(input.CardNumber)) + { + throw new ApplicationFailureException( + "Permanent failure - invalid credit card", + errorType: "PaymentError", + nonRetryable: true); // Will not retry activity + } + return await ProcessPaymentAsync(input.CardNumber, input.Amount); +} +``` + +## Handling Activity Errors in Workflows + +```csharp +using Temporalio.Workflows; +using Temporalio.Exceptions; + +[Workflow] +public class MyWorkflow +{ + [WorkflowRun] + public async Task RunAsync() + { + try + { + return await Workflow.ExecuteActivityAsync( + (MyActivities a) => a.RiskyActivityAsync(), + new() { StartToCloseTimeout = TimeSpan.FromMinutes(5) }); + } + catch (ActivityFailureException ex) + { + Workflow.Logger.LogError(ex, "Activity failed"); + throw new ApplicationFailureException( + "Workflow failed due to activity error"); + } + } +} +``` + +## Retry Configuration + +```csharp +using Temporalio.Common; + +return await Workflow.ExecuteActivityAsync( + (MyActivities a) => a.MyActivityAsync(), + new() + { + StartToCloseTimeout = TimeSpan.FromMinutes(10), + RetryPolicy = new() + { + MaximumInterval = TimeSpan.FromMinutes(1), + MaximumAttempts = 5, + NonRetryableErrorTypes = new[] { "ValidationError", "PaymentError" }, + }, + }); +``` + +Only set options such as MaximumInterval, MaximumAttempts etc. if you have a domain-specific reason to. +If not, prefer to leave them at their defaults. + +## Timeout Configuration + +```csharp +return await Workflow.ExecuteActivityAsync( + (MyActivities a) => a.MyActivityAsync(), + new() + { + StartToCloseTimeout = TimeSpan.FromMinutes(5), // Single attempt + ScheduleToCloseTimeout = TimeSpan.FromMinutes(30), // Including retries + HeartbeatTimeout = TimeSpan.FromMinutes(2), // Between heartbeats + }); +``` + +## Workflow Failure + +**Critical .NET behavior:** Only `ApplicationFailureException` will fail a workflow. All other exceptions (including standard .NET exceptions like `NullReferenceException`, `KeyNotFoundException`, etc.) will **retry the workflow task** indefinitely. This is by design — those are treated as bugs to be fixed with a code deployment, not reasons for the workflow to fail. + +```csharp +[Workflow] +public class MyWorkflow +{ + [WorkflowRun] + public async Task RunAsync() + { + if (someCondition) + { + throw new ApplicationFailureException( + "Cannot process order", + errorType: "BusinessError"); + } + return "success"; + } +} +``` + +**Note:** Do not use `nonRetryable:` with `ApplicationFailureException` inside a workflow (as opposed to an activity). + +## Best Practices + +1. Use specific error types for different failure modes +2. Mark permanent failures as non-retryable in activities +3. Configure appropriate retry policies +4. Log errors before re-raising +5. Use `ActivityFailureException` to catch activity failures in workflows +6. Design code to be idempotent for safe retries (see more at `references/core/patterns.md`) +7. Only throw `ApplicationFailureException` from workflows to fail them — other exceptions will retry the workflow task diff --git a/references/dotnet/gotchas.md b/references/dotnet/gotchas.md new file mode 100644 index 0000000..bec611e --- /dev/null +++ b/references/dotnet/gotchas.md @@ -0,0 +1,251 @@ +# .NET Gotchas + +.NET-specific mistakes and anti-patterns. See also [Common Gotchas](references/core/gotchas.md) for language-agnostic concepts. + +## .NET Task Determinism + +The biggest .NET gotcha. Many `Task` APIs implicitly use `TaskScheduler.Default`, which breaks determinism. The SDK detects some of these at runtime via an `EventListener`, but not all. + +### Task.Run + +```csharp +// BAD: Uses TaskScheduler.Default +await Task.Run(() => DoSomething()); + +// GOOD: Uses current (deterministic) scheduler +await Workflow.RunTaskAsync(() => DoSomething()); +``` + +### Task.Delay / Thread.Sleep + +```csharp +// BAD: Uses system timer +await Task.Delay(TimeSpan.FromMinutes(5)); + +// GOOD: Creates durable timer in event history +await Workflow.DelayAsync(TimeSpan.FromMinutes(5)); +``` + +### ConfigureAwait(false) + +```csharp +// BAD: Leaves the deterministic context +var result = await SomeCallAsync().ConfigureAwait(false); + +// GOOD: Stays on deterministic scheduler (or just omit ConfigureAwait) +var result = await SomeCallAsync().ConfigureAwait(true); +var result = await SomeCallAsync(); // Also fine +``` + +### Task.WhenAll / Task.WhenAny + +```csharp +// BAD: Potential non-determinism +await Task.WhenAll(task1, task2); +await Task.WhenAny(task1, task2); + +// GOOD: Deterministic wrappers +await Workflow.WhenAllAsync(task1, task2); +await Workflow.WhenAnyAsync(task1, task2); +``` + +### Threading Primitives + +```csharp +// BAD: System threading primitives +var mutex = new System.Threading.Mutex(); +var semaphore = new SemaphoreSlim(1); + +// GOOD: Temporal workflow-safe alternatives +var mutex = new Temporalio.Workflows.Mutex(); +var semaphore = new Temporalio.Workflows.Semaphore(1); +``` + +See `references/dotnet/determinism-protection.md` for the complete list. + +## Wrong Retry Classification + +**Example:** Transient network errors should be retried. Authentication errors should not be. +See `references/dotnet/error-handling.md` to understand how to classify errors. + +## Cancellation + +### Not Handling Workflow Cancellation + +```csharp +// BAD: Cleanup doesn't run on cancellation +[Workflow] +public class BadWorkflow +{ + [WorkflowRun] + public async Task RunAsync() + { + await Workflow.ExecuteActivityAsync( + (MyActivities a) => a.AcquireResourceAsync(), + new() { StartToCloseTimeout = TimeSpan.FromMinutes(5) }); + await Workflow.ExecuteActivityAsync( + (MyActivities a) => a.DoWorkAsync(), + new() { StartToCloseTimeout = TimeSpan.FromMinutes(5) }); + await Workflow.ExecuteActivityAsync( + (MyActivities a) => a.ReleaseResourceAsync(), // Never runs if cancelled! + new() { StartToCloseTimeout = TimeSpan.FromMinutes(5) }); + } +} + +// GOOD: Use try/finally for cleanup +[Workflow] +public class GoodWorkflow +{ + [WorkflowRun] + public async Task RunAsync() + { + await Workflow.ExecuteActivityAsync( + (MyActivities a) => a.AcquireResourceAsync(), + new() { StartToCloseTimeout = TimeSpan.FromMinutes(5) }); + try + { + await Workflow.ExecuteActivityAsync( + (MyActivities a) => a.DoWorkAsync(), + new() { StartToCloseTimeout = TimeSpan.FromMinutes(5) }); + } + finally + { + await Workflow.ExecuteActivityAsync( + (MyActivities a) => a.ReleaseResourceAsync(), + new() + { + StartToCloseTimeout = TimeSpan.FromMinutes(5), + CancellationToken = CancellationToken.None, + }); + } + } +} +``` + +### Not Handling Activity Cancellation + +Activities must **opt in** to receive cancellation via heartbeating. + +```csharp +// BAD: Activity ignores cancellation +[Activity] +public async Task LongActivityAsync() +{ + await DoExpensiveWorkAsync(); // Runs to completion even if cancelled +} + +// GOOD: Heartbeat and check cancellation token +[Activity] +public async Task LongActivityAsync() +{ + foreach (var item in items) + { + ActivityExecutionContext.Current.Heartbeat(); + ActivityExecutionContext.Current.CancellationToken.ThrowIfCancellationRequested(); + await ProcessAsync(item); + } +} +``` + +## Heartbeating + +### Forgetting to Heartbeat Long Activities + +```csharp +// BAD: No heartbeat, can't detect stuck activities +[Activity] +public async Task ProcessLargeFileAsync(string path) +{ + foreach (var chunk in ReadChunks(path)) + await ProcessAsync(chunk); // Takes hours, no heartbeat + +// GOOD: Regular heartbeats with progress +[Activity] +public async Task ProcessLargeFileAsync(string path) +{ + var chunks = ReadChunks(path); + for (var i = 0; i < chunks.Count; i++) + { + ActivityExecutionContext.Current.Heartbeat($"Processing chunk {i}"); + await ProcessAsync(chunks[i]); + } +} +``` + +### Heartbeat Timeout Too Short + +```csharp +// BAD: Heartbeat timeout shorter than processing time +await Workflow.ExecuteActivityAsync( + (MyActivities a) => a.ProcessChunkAsync(), + new() + { + StartToCloseTimeout = TimeSpan.FromMinutes(30), + HeartbeatTimeout = TimeSpan.FromSeconds(10), // Too short! + }); + +// GOOD: Heartbeat timeout allows for processing variance +await Workflow.ExecuteActivityAsync( + (MyActivities a) => a.ProcessChunkAsync(), + new() + { + StartToCloseTimeout = TimeSpan.FromMinutes(30), + HeartbeatTimeout = TimeSpan.FromMinutes(2), + }); +``` + +Set heartbeat timeout as high as acceptable for your use case — each heartbeat counts as an action. + +## Testing + +### Not Testing Failures + +It is important to make sure workflows work as expected under failure paths in addition to happy paths. Please see `references/dotnet/testing.md` for more info. + +### Not Testing Replay + +Replay tests help you test that you do not have hidden sources of non-determinism bugs in your workflow code. Please see `references/dotnet/testing.md` for more info. + +## Timers and Sleep + +### Using Task.Delay + +```csharp +// BAD: Task.Delay uses system timer, not deterministic during replay +[Workflow] +public class BadWorkflow +{ + [WorkflowRun] + public async Task RunAsync() + { + await Task.Delay(TimeSpan.FromMinutes(1)); // SDK will detect and fail the task + } +} + +// GOOD: Use Workflow.DelayAsync for deterministic timers +[Workflow] +public class GoodWorkflow +{ + [WorkflowRun] + public async Task RunAsync() + { + await Workflow.DelayAsync(TimeSpan.FromMinutes(1)); // Deterministic + } +} +``` + +**Why this matters:** `Task.Delay` uses the system clock, which differs between original execution and replay. `Workflow.DelayAsync` creates a durable timer in the event history, ensuring consistent behavior during replay. + +## Dictionary Iteration Order + +```csharp +// BAD: Dictionary iteration order is not guaranteed +var dict = new Dictionary { ["b"] = 2, ["a"] = 1 }; +foreach (var kvp in dict) // Order may differ between executions! + await ProcessAsync(kvp.Key, kvp.Value); + +// GOOD: Use SortedDictionary or sort before iterating +var dict = new SortedDictionary { ["b"] = 2, ["a"] = 1 }; +foreach (var kvp in dict) // Always iterates in key order + await ProcessAsync(kvp.Key, kvp.Value); +``` diff --git a/references/dotnet/observability.md b/references/dotnet/observability.md new file mode 100644 index 0000000..820188d --- /dev/null +++ b/references/dotnet/observability.md @@ -0,0 +1,96 @@ +# .NET SDK Observability + +## Overview + +The .NET SDK provides observability through logging, metrics, and tracing using standard .NET patterns. + +## Logging + +### Workflow Logging (Replay-Safe) + +Use `Workflow.Logger` for replay-safe logging that avoids duplicate messages: + +```csharp +[Workflow] +public class MyWorkflow +{ + [WorkflowRun] + public async Task RunAsync(string name) + { + Workflow.Logger.LogInformation("Workflow started for {Name}", name); + + var result = await Workflow.ExecuteActivityAsync( + (MyActivities a) => a.MyActivityAsync(), + new() { StartToCloseTimeout = TimeSpan.FromMinutes(5) }); + + Workflow.Logger.LogInformation("Activity completed with {Result}", result); + return result; + } +} +``` + +The workflow logger automatically: +- Suppresses duplicate logs during replay +- Includes workflow context (workflow ID, run ID, etc.) + +### Activity Logging + +Use `ActivityExecutionContext.Current.Logger` for context-aware activity logging: + +```csharp +[Activity] +public async Task ProcessOrderAsync(string orderId) +{ + var logger = ActivityExecutionContext.Current.Logger; + logger.LogInformation("Processing order {OrderId}", orderId); + + // Perform work... + + logger.LogInformation("Order processed successfully"); + return "completed"; +} +``` + +### Customizing Logger Configuration + +```csharp +using Microsoft.Extensions.Logging; + +var client = await TemporalClient.ConnectAsync(new("localhost:7233") +{ + LoggerFactory = LoggerFactory.Create(builder => + builder + .AddSimpleConsole(options => options.TimestampFormat = "[HH:mm:ss] ") + .SetMinimumLevel(LogLevel.Information)), +}); +``` + +## Metrics + +### Enabling SDK Metrics + +```csharp +using Temporalio.Extensions.OpenTelemetry; +using OpenTelemetry; +using OpenTelemetry.Metrics; + +// Configure OpenTelemetry metrics +var meterProvider = Sdk.CreateMeterProviderBuilder() + .AddTemporalClientInstrumentation() + .AddPrometheusExporter() + .Build(); +``` + +### Key SDK Metrics + +- `temporal_request` — Client requests to server +- `temporal_workflow_task_execution_latency` — Workflow task processing time +- `temporal_activity_execution_latency` — Activity execution time +- `temporal_workflow_task_replay_latency` — Replay duration + +## Best Practices + +1. Use `Workflow.Logger` in workflows, `ActivityExecutionContext.Current.Logger` in activities +2. Don't use `Console.WriteLine` in workflows — it will produce duplicate output on replay +3. Configure metrics for production monitoring +4. Use Search Attributes for business-level visibility (see `references/dotnet/data-handling.md`) diff --git a/references/dotnet/patterns.md b/references/dotnet/patterns.md new file mode 100644 index 0000000..cb0d46d --- /dev/null +++ b/references/dotnet/patterns.md @@ -0,0 +1,482 @@ +# .NET SDK Patterns + +## Signals + +```csharp +[Workflow] +public class OrderWorkflow +{ + private bool _approved; + private readonly List _items = new(); + + [WorkflowSignal] + public async Task ApproveAsync() + { + _approved = true; + } + + [WorkflowSignal] + public async Task AddItemAsync(string item) + { + _items.Add(item); + } + + [WorkflowRun] + public async Task RunAsync() + { + await Workflow.WaitConditionAsync(() => _approved); + return $"Processed {_items.Count} items"; + } +} +``` + +## Dynamic Signal Handlers + +For handling signals with names not known at compile time. Use cases for this pattern are rare — most workflows should use statically defined signal handlers. + +```csharp +[Workflow] +public class DynamicSignalWorkflow +{ + private readonly Dictionary> _signals = new(); + + [WorkflowSignal(Dynamic = true)] + public async Task HandleSignalAsync(string signalName, IRawValue[] args) + { + if (!_signals.ContainsKey(signalName)) + _signals[signalName] = new List(); + var value = Workflow.PayloadConverter.ToValue(args.Single()); + _signals[signalName].Add(value); + } + + [WorkflowRun] + public async Task>> RunAsync() + { + await Workflow.WaitConditionAsync(() => _signals.ContainsKey("done")); + return _signals; + } +} +``` + +## Queries + +**Important:** Queries must NOT modify workflow state or have side effects. + +```csharp +[Workflow] +public class StatusWorkflow +{ + private string _status = "pending"; + private int _progress; + + [WorkflowQuery] + public string GetStatus() => _status; + + [WorkflowQuery] + public int Progress => _progress; + + [WorkflowRun] + public async Task RunAsync() + { + _status = "running"; + for (var i = 0; i < 100; i++) + { + _progress = i; + await Workflow.ExecuteActivityAsync( + (MyActivities a) => a.ProcessItem(i), + new() { StartToCloseTimeout = TimeSpan.FromMinutes(1) }); + } + _status = "completed"; + return "done"; + } +} +``` + +## Dynamic Query Handlers + +For handling queries with names not known at compile time. Use cases for this pattern are rare — most workflows should use statically defined query handlers. + +```csharp +[Workflow] +public class DynamicQueryWorkflow +{ + private readonly SortedDictionary _state = new() + { + ["status"] = "running", + ["progress"] = "0", + }; + + [WorkflowQuery(Dynamic = true)] + public string HandleQuery(string queryName, IRawValue[] args) + { + return _state.GetValueOrDefault(queryName, "unknown"); + } + + [WorkflowRun] + public async Task RunAsync() { /* ... */ } +} +``` + +## Updates + +```csharp +[Workflow] +public class OrderWorkflow +{ + private readonly List _items = new(); + + [WorkflowUpdate] + public async Task AddItemAsync(string item) + { + _items.Add(item); + return _items.Count; + } + + [WorkflowUpdateValidator(nameof(AddItemAsync))] + public void ValidateAddItem(string item) + { + if (string.IsNullOrEmpty(item)) + throw new ArgumentException("Item cannot be empty"); + if (_items.Count >= 100) + throw new InvalidOperationException("Order is full"); + } + + [WorkflowRun] + public async Task RunAsync() + { + await Workflow.WaitConditionAsync(() => _items.Count > 0); + return $"Order with {_items.Count} items"; + } +} +``` + +## Child Workflows + +```csharp +[Workflow] +public class ParentWorkflow +{ + [WorkflowRun] + public async Task> RunAsync(List orders) + { + var results = new List(); + foreach (var order in orders) + { + var result = await Workflow.ExecuteChildWorkflowAsync( + (ProcessOrderWorkflow wf) => wf.RunAsync(order), + new() + { + Id = $"order-{order.Id}", + // Control what happens to child when parent completes + // Terminate (default), Abandon, RequestCancel + ParentClosePolicy = ParentClosePolicy.Abandon, + }); + results.Add(result); + } + return results; + } +} +``` + +## Handles to External Workflows + +```csharp +[Workflow] +public class CoordinatorWorkflow +{ + [WorkflowRun] + public async Task RunAsync(string targetWorkflowId) + { + var handle = Workflow.GetExternalWorkflowHandle(targetWorkflowId); + + // Signal the external workflow + await handle.SignalAsync(wf => wf.DataReadyAsync(new DataPayload())); + + // Or cancel it + await handle.CancelAsync(); + } +} +``` + +## Parallel Execution + +```csharp +[Workflow] +public class ParallelWorkflow +{ + [WorkflowRun] + public async Task RunAsync(string[] items) + { + var tasks = items.Select(item => + Workflow.ExecuteActivityAsync( + (MyActivities a) => a.ProcessItem(item), + new() { StartToCloseTimeout = TimeSpan.FromMinutes(5) })); + + return await Workflow.WhenAllAsync(tasks); + } +} +``` + +## Deterministic Task Alternatives + +.NET `Task` APIs often use `TaskScheduler.Default` implicitly. Use Temporal's deterministic alternatives: + +```csharp +// Instead of Task.WhenAll: +await Workflow.WhenAllAsync(task1, task2, task3); + +// Instead of Task.WhenAny: +await Workflow.WhenAnyAsync(task1, task2); + +// Instead of Task.Run: +await Workflow.RunTaskAsync(() => SomeWork()); + +// Instead of Task.Delay: +await Workflow.DelayAsync(TimeSpan.FromMinutes(5)); + +// Instead of System.Threading.Mutex: +var mutex = new Temporalio.Workflows.Mutex(); +await mutex.WaitOneAsync(); +try { /* critical section */ } +finally { mutex.ReleaseMutex(); } + +// Instead of System.Threading.Semaphore: +var semaphore = new Temporalio.Workflows.Semaphore(3); +await semaphore.WaitAsync(); +try { /* limited concurrency section */ } +finally { semaphore.Release(); } +``` + +## Continue-as-New + +```csharp +[Workflow] +public class LongRunningWorkflow +{ + [WorkflowRun] + public async Task RunAsync(WorkflowState state) + { + while (true) + { + state = await ProcessNextBatch(state); + + if (state.IsComplete) + return "done"; + + if (Workflow.ContinueAsNewSuggested) + throw Workflow.CreateContinueAsNewException( + (LongRunningWorkflow wf) => wf.RunAsync(state)); + } + } +} +``` + +## Saga Pattern (Compensations) + +**Important:** Compensation activities should be idempotent — they may be retried (as with ALL activities). + +```csharp +[Workflow] +public class OrderSagaWorkflow +{ + [WorkflowRun] + public async Task RunAsync(Order order) + { + var compensations = new List>(); + + try + { + // IMPORTANT: Save compensation BEFORE calling the activity. + // If activity fails after completing but before returning, + // compensation must still be registered. + compensations.Add(() => Workflow.ExecuteActivityAsync( + (OrderActivities a) => a.ReleaseInventoryIfReservedAsync(order), + new() { StartToCloseTimeout = TimeSpan.FromMinutes(5) })); + await Workflow.ExecuteActivityAsync( + (OrderActivities a) => a.ReserveInventoryAsync(order), + new() { StartToCloseTimeout = TimeSpan.FromMinutes(5) }); + + compensations.Add(() => Workflow.ExecuteActivityAsync( + (OrderActivities a) => a.RefundPaymentIfChargedAsync(order), + new() { StartToCloseTimeout = TimeSpan.FromMinutes(5) })); + await Workflow.ExecuteActivityAsync( + (OrderActivities a) => a.ChargePaymentAsync(order), + new() { StartToCloseTimeout = TimeSpan.FromMinutes(5) }); + + await Workflow.ExecuteActivityAsync( + (OrderActivities a) => a.ShipOrderAsync(order), + new() { StartToCloseTimeout = TimeSpan.FromMinutes(5) }); + + return "Order completed"; + } + catch (Exception ex) + { + Workflow.Logger.LogError(ex, "Order failed, running compensations"); + compensations.Reverse(); + foreach (var compensate in compensations) + { + try { await compensate(); } + catch (Exception compErr) + { + Workflow.Logger.LogError(compErr, "Compensation failed"); + } + } + throw; + } + } +} +``` + +## Cancellation Handling (CancellationToken) + +.NET uses standard `CancellationToken` for workflow cancellation. + +```csharp +[Workflow] +public class CancellableWorkflow +{ + [WorkflowRun] + public async Task RunAsync() + { + try + { + await Workflow.ExecuteActivityAsync( + (MyActivities a) => a.LongRunningAsync(), + new() { StartToCloseTimeout = TimeSpan.FromHours(1) }); + return "completed"; + } + catch (Exception e) when (TemporalException.IsCanceledException(e)) + { + // Workflow was cancelled — perform cleanup + Workflow.Logger.LogError(e, "Cancellation occurred, performing cleanup"); + // Use a detached cancellation token for cleanup since Workflow.CancellationToken + // is now cancelled + using var detachedCancelSource = new CancellationTokenSource(); + await Workflow.ExecuteActivityAsync( + (MyActivities a) => a.CleanupAsync(), + new() + { + StartToCloseTimeout = TimeSpan.FromMinutes(5), + CancellationToken = detachedCancelSource.Token, + }); + throw; // Re-throw to mark workflow as cancelled + } + } +} +``` + +## Wait Condition with Timeout + +```csharp +[Workflow] +public class ApprovalWorkflow +{ + private bool _approved; + + [WorkflowSignal] + public async Task ApproveAsync() => _approved = true; + + [WorkflowRun] + public async Task RunAsync() + { + // Wait for approval with 24-hour timeout + var gotApproval = await Workflow.WaitConditionAsync( + () => _approved, + TimeSpan.FromHours(24)); + + return gotApproval ? "approved" : "auto-rejected due to timeout"; + } +} +``` + +## Waiting for All Handlers to Finish + +Signal and update handlers should generally be non-async (avoid running activities from them). Otherwise, the workflow may complete before handlers finish their execution. However, making handlers non-async sometimes requires workarounds that add complexity. + +When async handlers are necessary, use `WaitConditionAsync(AllHandlersFinished)` at the end of your workflow (or before continue-as-new) to prevent completion until all pending handlers complete. + +```csharp +[Workflow] +public class HandlerAwareWorkflow +{ + [WorkflowRun] + public async Task RunAsync() + { + // ... main workflow logic ... + + // Before exiting, wait for all handlers to finish + await Workflow.WaitConditionAsync(() => Workflow.AllHandlersFinished); + return "done"; + } +} +``` + +## Activity Heartbeat Details + +### WHY: +- **Support activity cancellation** — Cancellations are delivered via heartbeat; activities that don't heartbeat won't know they've been cancelled +- **Resume progress after worker failure** — Heartbeat details persist across retries + +### WHEN: +- **Cancellable activities** — Any activity that should respond to cancellation +- **Long-running activities** — Track progress for resumability +- **Checkpointing** — Save progress periodically + +```csharp +[Activity] +public async Task ProcessLargeFileAsync(string filePath) +{ + var info = ActivityExecutionContext.Current.Info; + // Get heartbeat details from previous attempt (if any) + var startLine = info.HeartbeatDetails.Count > 0 + ? await info.HeartbeatDetails.ElementAtAsync(0) + : 0; + + var lines = await File.ReadAllLinesAsync(filePath); + for (var i = startLine; i < lines.Length; i++) + { + await ProcessLineAsync(lines[i]); + + // Heartbeat with progress + // If cancelled, CancellationToken will be triggered + ActivityExecutionContext.Current.Heartbeat(i + 1); + ActivityExecutionContext.Current.CancellationToken.ThrowIfCancellationRequested(); + } + + return "completed"; +} +``` + +## Timers + +```csharp +[Workflow] +public class TimerWorkflow +{ + [WorkflowRun] + public async Task RunAsync() + { + await Workflow.DelayAsync(TimeSpan.FromHours(1)); + return "Timer fired"; + } +} +``` + +## Local Activities + +**Purpose**: Reduce latency for short, lightweight operations by skipping the task queue. ONLY use these when necessary for performance. Do NOT use these by default, as they are not durable and distributed. + +```csharp +[Workflow] +public class LocalActivityWorkflow +{ + [WorkflowRun] + public async Task RunAsync() + { + var result = await Workflow.ExecuteLocalActivityAsync( + (MyActivities a) => a.QuickLookup("key"), + new() { StartToCloseTimeout = TimeSpan.FromSeconds(5) }); + return result; + } +} +``` diff --git a/references/dotnet/testing.md b/references/dotnet/testing.md new file mode 100644 index 0000000..ae19322 --- /dev/null +++ b/references/dotnet/testing.md @@ -0,0 +1,176 @@ +# .NET SDK Testing + +## Overview + +You test Temporal .NET Workflows using the `Temporalio.Testing` namespace plus a normal .NET test framework. The .NET SDK is compatible with any testing framework; most samples use xUnit. The SDK provides `WorkflowEnvironment` for testing workflows in a local environment and `ActivityEnvironment` for isolated activity testing. + +## Test Environment Setup + +The core pattern is: + +1. Start a `WorkflowEnvironment` (`WorkflowEnvironment.StartLocalAsync()`). +2. Create a `TemporalWorker` in that environment with your Workflow and Activities registered. +3. Use the environment's client to execute the Workflow, using a fresh GUID for the task queue name and workflow ID. +4. Assert on the result or status. + +```csharp +using Temporalio.Testing; +using Temporalio.Worker; + +[Fact] +public async Task TestWorkflow() +{ + await using var env = await WorkflowEnvironment.StartLocalAsync(); + + using var worker = new TemporalWorker( + env.Client, + new TemporalWorkerOptions($"task-queue-{Guid.NewGuid()}") + .AddWorkflow() + .AddAllActivities(new MyActivities())); + + await worker.ExecuteAsync(async () => + { + var result = await env.Client.ExecuteWorkflowAsync( + (MyWorkflow wf) => wf.RunAsync("input"), + new(id: $"wf-{Guid.NewGuid()}", taskQueue: worker.Options.TaskQueue!)); + Assert.Equal("expected", result); + }); +} +``` + +Conveniently, the local `env` can be shared among tests, e.g. via a fixture class. + +If your workflows / tests involve long durations (such as using Temporal timers / sleeps), then you can use the time-skipping environment, via `WorkflowEnvironment.StartTimeSkippingAsync()`. Only use time-skipping if you must. It is not thread safe and cannot be shared among tests. + +## Activity Mocking + +The .NET SDK provides a straightforward way to mock Activities. Create a mock function with the `[Activity]` attribute and specify the name of the original Activity you want to mock: + +```csharp +[Fact] +public async Task TestWithMockActivity() +{ + await using var env = await WorkflowEnvironment.StartLocalAsync(); + + [Activity("MyActivity")] + static Task MockMyActivity(string input) => + Task.FromResult($"mocked: {input}"); + + using var worker = new TemporalWorker( + env.Client, + new TemporalWorkerOptions($"task-queue-{Guid.NewGuid()}") + .AddWorkflow() + .AddActivity(MockMyActivity)); + + await worker.ExecuteAsync(async () => + { + var result = await env.Client.ExecuteWorkflowAsync( + (MyWorkflow wf) => wf.RunAsync("test"), + new(id: $"wf-{Guid.NewGuid()}", taskQueue: worker.Options.TaskQueue!)); + Assert.Equal("mocked: test", result); + }); +} +``` + +**Note:** If the original activity method name ends with `Async`, the default activity name has `Async` trimmed off. For example, `MyActivityAsync` has default name `MyActivity`. + +## Testing Signals and Queries + +```csharp +[Fact] +public async Task TestSignalsAndQueries() +{ + await using var env = await WorkflowEnvironment.StartLocalAsync(); + + using var worker = new TemporalWorker(/* ... */); + + await worker.ExecuteAsync(async () => + { + var handle = await env.Client.StartWorkflowAsync( + (MyWorkflow wf) => wf.RunAsync(), + new(id: $"wf-{Guid.NewGuid()}", taskQueue: worker.Options.TaskQueue!)); + + // Send signal + await handle.SignalAsync(wf => wf.MySignalAsync("data")); + + // Query state + var status = await handle.QueryAsync(wf => wf.GetStatus()); + Assert.Equal("expected", status); + + // Wait for completion + var result = await handle.GetResultAsync(); + }); +} +``` + +## Testing Failure Cases + +```csharp +[Fact] +public async Task TestActivityFailureHandling() +{ + await using var env = await WorkflowEnvironment.StartLocalAsync(); + + [Activity("RiskyActivity")] + static Task MockFailingActivity() => + throw new ApplicationFailureException("Simulated failure", nonRetryable: true); + + using var worker = new TemporalWorker(/* ... with mock activity */); + + await worker.ExecuteAsync(async () => + { + var ex = await Assert.ThrowsAsync(() => + env.Client.ExecuteWorkflowAsync( + (MyWorkflow wf) => wf.RunAsync(), + new(id: $"wf-{Guid.NewGuid()}", taskQueue: worker.Options.TaskQueue!))); + }); +} +``` + +## Replay Testing + +```csharp +using Temporalio.Worker; + +[Fact] +public async Task TestReplay() +{ + var historyJson = await File.ReadAllTextAsync("example-history.json"); + var replayer = new WorkflowReplayer( + new WorkflowReplayerOptions() + .AddWorkflow()); + + await replayer.ReplayWorkflowAsync( + WorkflowHistory.FromJson("my-workflow-id", historyJson)); +} +``` + +## Activity Testing + +```csharp +using Temporalio.Testing; + +[Fact] +public async Task TestActivity() +{ + var env = new ActivityEnvironment(); + var activities = new MyActivities(); + var result = await env.RunAsync(() => activities.MyActivity("arg1")); + Assert.Equal("expected", result); +} +``` + +The `ActivityEnvironment` provides: +- `Info` — Activity info, defaulted to basic values +- `CancellationTokenSource` — Token source for issuing cancellation +- `Heartbeater` — Callback invoked each heartbeat +- `Logger` — Activity logger + +## Best Practices + +1. Use the `WorkflowEnvironment.StartLocalAsync` environment for most testing +2. Use time-skipping environment for workflows with durable timers / durable sleeps +3. Mock external dependencies in activities +4. Test replay compatibility, especially when changing workflow code +5. Test signal/query handlers explicitly +6. Use unique workflow IDs and task queues per test to avoid conflicts — `Guid.NewGuid()` is easiest diff --git a/references/dotnet/versioning.md b/references/dotnet/versioning.md new file mode 100644 index 0000000..ff47cff --- /dev/null +++ b/references/dotnet/versioning.md @@ -0,0 +1,256 @@ +# .NET SDK Versioning + +For conceptual overview and guidance on choosing an approach, see `references/core/versioning.md`. + +## Patching API + +### The Patched() Method + +The `Workflow.Patched()` method checks whether a Workflow should run new or old code: + +```csharp +[Workflow] +public class ShippingWorkflow +{ + [WorkflowRun] + public async Task RunAsync() + { + if (Workflow.Patched("send-email-instead-of-fax")) + { + // New code path + await Workflow.ExecuteActivityAsync( + (ShippingActivities a) => a.SendEmailAsync(), + new() { StartToCloseTimeout = TimeSpan.FromMinutes(5) }); + } + else + { + // Old code path (for replay of existing workflows) + await Workflow.ExecuteActivityAsync( + (ShippingActivities a) => a.SendFaxAsync(), + new() { StartToCloseTimeout = TimeSpan.FromMinutes(5) }); + } + } +} +``` + +**How it works:** +- For new executions: `Patched()` returns `true` and records a marker in the Workflow history +- For replay with the marker: `Patched()` returns `true` (history includes this patch) +- For replay without the marker: `Patched()` returns `false` (history predates this patch) + +### Three-Step Patching Process + +**Warning:** Failing to follow this process correctly will result in non-determinism errors for in-flight workflows. + +**Step 1: Patch in New Code** + +```csharp +[WorkflowRun] +public async Task RunAsync(Order order) +{ + if (Workflow.Patched("add-fraud-check")) + { + await Workflow.ExecuteActivityAsync( + (OrderActivities a) => a.CheckFraudAsync(order), + new() { StartToCloseTimeout = TimeSpan.FromMinutes(2) }); + } + + return await Workflow.ExecuteActivityAsync( + (OrderActivities a) => a.ProcessPaymentAsync(order), + new() { StartToCloseTimeout = TimeSpan.FromMinutes(5) }); +} +``` + +**Step 2: Deprecate the Patch** + +Once all pre-patch Workflow Executions have completed: + +```csharp +[WorkflowRun] +public async Task RunAsync(Order order) +{ + Workflow.DeprecatePatch("add-fraud-check"); + + await Workflow.ExecuteActivityAsync( + (OrderActivities a) => a.CheckFraudAsync(order), + new() { StartToCloseTimeout = TimeSpan.FromMinutes(2) }); + + return await Workflow.ExecuteActivityAsync( + (OrderActivities a) => a.ProcessPaymentAsync(order), + new() { StartToCloseTimeout = TimeSpan.FromMinutes(5) }); +} +``` + +**Step 3: Remove the Patch** + +After all workflows with the deprecated patch marker have completed, remove the `DeprecatePatch()` call entirely. + +### Query Filters for Finding Workflows by Version + +Use List Filters to find workflows with specific patch versions: + +```bash +# Find running workflows with a specific patch +temporal workflow list --query \ + 'WorkflowType = "OrderWorkflow" AND ExecutionStatus = "Running" AND TemporalChangeVersion = "add-fraud-check"' + +# Find running workflows without any patch (pre-patch versions) +temporal workflow list --query \ + 'WorkflowType = "OrderWorkflow" AND ExecutionStatus = "Running" AND TemporalChangeVersion IS NULL' +``` + +## Workflow Type Versioning + +For incompatible changes, create a new Workflow Type instead of using patches: + +```csharp +[Workflow("PizzaWorkflow")] +public class PizzaWorkflow +{ + [WorkflowRun] + public async Task RunAsync(PizzaOrder order) + { + return await ProcessOrderV1Async(order); + } +} + +[Workflow("PizzaWorkflowV2")] +public class PizzaWorkflowV2 +{ + [WorkflowRun] + public async Task RunAsync(PizzaOrder order) + { + return await ProcessOrderV2Async(order); + } +} +``` + +Register both with the Worker: + +```csharp +var worker = new TemporalWorker( + client, + new TemporalWorkerOptions("pizza-task-queue") + .AddWorkflow() + .AddWorkflow() + .AddAllActivities(new PizzaActivities())); +``` + +Update client code to start new workflows with the new type: + +```csharp +// Old workflows continue on PizzaWorkflow +// New workflows use PizzaWorkflowV2 +var handle = await client.StartWorkflowAsync( + (PizzaWorkflowV2 wf) => wf.RunAsync(order), + new(id: $"pizza-{order.Id}", taskQueue: "pizza-task-queue")); +``` + +Check for open executions before removing the old type: + +```bash +temporal workflow list --query 'WorkflowType = "PizzaWorkflow" AND ExecutionStatus = "Running"' +``` + +## Worker Versioning + +Worker Versioning manages versions at the deployment level, allowing multiple Worker versions to run simultaneously. + +### Key Concepts + +**Worker Deployment**: A logical service grouping similar Workers together (e.g., "loan-processor"). All versions of your code live under this umbrella. + +**Worker Deployment Version**: A specific snapshot of your code identified by a deployment name and Build ID (e.g., "loan-processor:v1.0" or "loan-processor:abc123"). + +### Configuring Workers for Versioning + +```csharp +using Temporalio.Worker; + +var worker = new TemporalWorker( + client, + new TemporalWorkerOptions("my-task-queue") + { + DeploymentOptions = new WorkerDeploymentOptions( + DeploymentName: "my-service", + BuildId: Environment.GetEnvironmentVariable("BUILD_ID") ?? "dev"), + UseWorkerVersioning = true, + } + .AddWorkflow() + .AddAllActivities(new MyActivities())); +``` + +**Configuration parameters:** +- `UseWorkerVersioning`: Enables Worker Versioning +- `DeploymentOptions`: Identifies the Worker Deployment Version (deployment name + build ID) +- Build ID: Typically a git commit hash, version number, or timestamp + +### PINNED vs AUTO_UPGRADE Behaviors + +**PINNED Behavior** + +Workflows stay locked to their original Worker version: + +```csharp +[Workflow(VersioningBehavior = VersioningBehavior.Pinned)] +public class StableWorkflow { /* ... */ } +``` + +**When to use PINNED:** +- Short-running workflows (minutes to hours) +- Consistency is critical (e.g., financial transactions) +- You want to eliminate version compatibility complexity +- Building new applications and want simplest development experience + +**AUTO_UPGRADE Behavior** + +Workflows can move to newer versions: + +```csharp +[Workflow(VersioningBehavior = VersioningBehavior.AutoUpgrade)] +public class UpgradableWorkflow { /* ... */ } +``` + +**When to use AUTO_UPGRADE:** +- Long-running workflows (weeks or months) +- Workflows need to benefit from bug fixes during execution +- Migrating from traditional rolling deployments +- You are already using patching APIs for version transitions + +**Important:** AUTO_UPGRADE workflows still need patching to handle version transitions safely since they can move between Worker versions. + +### Deployment Strategies + +**Blue-Green Deployments** + +Maintain two environments and switch traffic between them: +1. Deploy new code to idle environment +2. Run tests and validation +3. Switch traffic to new environment +4. Keep old environment for instant rollback + +**Rainbow Deployments** + +Multiple versions run simultaneously: +- New workflows use latest version +- Existing workflows complete on their original version +- Add new versions alongside existing ones +- Gradually sunset old versions as workflows complete + +### Querying Workflows by Worker Version + +```bash +# Find workflows on a specific Worker version +temporal workflow list --query \ + 'TemporalWorkerDeploymentVersion = "my-service:v1.0.0" AND ExecutionStatus = "Running"' +``` + +## Best Practices + +1. **Check for open executions** before removing old code paths +2. **Use descriptive patch IDs** that explain the change (e.g., "add-fraud-check" not "patch-1") +3. **Deploy patches incrementally**: patch, deprecate, remove +4. **Use PINNED for short workflows** to simplify version management +5. **Use AUTO_UPGRADE with patching** for long-running workflows that need updates +6. **Generate Build IDs from code** (git hash) to ensure changes produce new versions +7. **Avoid rolling deployments** for high-availability services with long-running workflows