From 5243c4a310665f28b62b2a8bc21fb110837763ab Mon Sep 17 00:00:00 2001 From: Donald Pinckney Date: Mon, 16 Mar 2026 18:22:51 -0400 Subject: [PATCH 01/16] Add .NET reference files for temporal-developer skill Created 11 .NET reference files covering: dotnet.md (overview/quick start), patterns.md, determinism.md, determinism-protection.md, error-handling.md, testing.md, versioning.md, observability.md, data-handling.md, gotchas.md, and advanced-features.md. Follows Python/TypeScript patterns with .NET-specific content for Task determinism, CancellationToken, dependency injection, etc. Co-Authored-By: Claude Opus 4.6 (1M context) --- references/dotnet/advanced-features.md | 160 +++++++ references/dotnet/data-handling.md | 217 +++++++++ references/dotnet/determinism-protection.md | 76 ++++ references/dotnet/determinism.md | 63 +++ references/dotnet/dotnet.md | 171 +++++++ references/dotnet/error-handling.md | 140 ++++++ references/dotnet/gotchas.md | 227 ++++++++++ references/dotnet/observability.md | 96 ++++ references/dotnet/patterns.md | 474 ++++++++++++++++++++ references/dotnet/testing.md | 176 ++++++++ references/dotnet/versioning.md | 171 +++++++ 11 files changed, 1971 insertions(+) create mode 100644 references/dotnet/advanced-features.md create mode 100644 references/dotnet/data-handling.md create mode 100644 references/dotnet/determinism-protection.md create mode 100644 references/dotnet/determinism.md create mode 100644 references/dotnet/dotnet.md create mode 100644 references/dotnet/error-handling.md create mode 100644 references/dotnet/gotchas.md create mode 100644 references/dotnet/observability.md create mode 100644 references/dotnet/patterns.md create mode 100644 references/dotnet/testing.md create mode 100644 references/dotnet/versioning.md diff --git a/references/dotnet/advanced-features.md b/references/dotnet/advanced-features.md new file mode 100644 index 0000000..34f2e70 --- /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. + +```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 builder = Host.CreateApplicationBuilder(args); + +builder.Services.AddTemporalClient(options => +{ + options.TargetHost = "localhost:7233"; + options.Namespace = "default"; +}); + +builder.Services.AddHostedTemporalWorker("my-task-queue") + .AddWorkflow() + .AddScopedActivities(); + +var host = builder.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..3203a77 --- /dev/null +++ b/references/dotnet/dotnet.md @@ -0,0 +1,171 @@ +# 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. + +**Do NOT use in workflows:** +- `Task.Run` — use `Workflow.RunTaskAsync` +- `Task.Delay` / `Thread.Sleep` — use `Workflow.DelayAsync` +- `Task.WhenAny` — use `Workflow.WhenAnyAsync` +- `Task.WhenAll` — use `Workflow.WhenAllAsync` +- `ConfigureAwait(false)` — use `ConfigureAwait(true)` or omit +- `DateTime.Now` / `DateTime.UtcNow` — use `Workflow.UtcNow` +- `Random` — use `Workflow.Random` +- `Guid.NewGuid()` — use `Workflow.NewGuid()` +- `System.Threading.Mutex` / `Semaphore` — use `Temporalio.Workflows.Mutex` / `Semaphore` +- Iterating `Dictionary` (unordered) — use `SortedDictionary` or sort first + +See `references/dotnet/determinism.md` and `references/dotnet/determinism-protection.md` for detailed rules. + +## 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..f3813ec --- /dev/null +++ b/references/dotnet/gotchas.md @@ -0,0 +1,227 @@ +# .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]); + } +} +``` + +## 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..5885e7f --- /dev/null +++ b/references/dotnet/patterns.md @@ -0,0 +1,474 @@ +# .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}" }); + 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 (OperationCanceledException) when (Workflow.CancellationToken.IsCancellationRequested) + { + // Workflow was cancelled — perform cleanup + Workflow.Logger.LogInformation("Workflow cancelled, running cleanup"); + await Workflow.ExecuteActivityAsync( + (MyActivities a) => a.CleanupAsync(), + new() + { + StartToCloseTimeout = TimeSpan.FromMinutes(5), + // Use a non-cancelled token for cleanup + CancellationToken = CancellationToken.None, + }); + 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..d030c40 --- /dev/null +++ b/references/dotnet/versioning.md @@ -0,0 +1,171 @@ +# .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. + +## 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())); +``` + +## Worker Versioning + +Worker Versioning manages versions at the deployment level, allowing multiple Worker versions to run simultaneously. + +### 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())); +``` + +### PINNED vs AUTO_UPGRADE Behaviors + +**PINNED**: Workflows stay locked to their original Worker version. + +```csharp +[Workflow(VersioningBehavior = VersioningBehavior.Pinned)] +public class StableWorkflow { /* ... */ } +``` + +**AUTO_UPGRADE**: Workflows can move to newer versions. Still needs patching for compatibility. + +```csharp +[Workflow(VersioningBehavior = VersioningBehavior.AutoUpgrade)] +public class UpgradableWorkflow { /* ... */ } +``` + +## 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 From 6f5e7dceffb562b1e32d36e9826cb4451da3b9ec Mon Sep 17 00:00:00 2001 From: Donald Pinckney Date: Mon, 16 Mar 2026 18:27:04 -0400 Subject: [PATCH 02/16] Fix .NET alignment issues from self-review - dotnet.md: Reduce Determinism Rules section to brief cross-reference (was duplicating determinism.md content) - patterns.md: Add ParentClosePolicy to Child Workflows example - gotchas.md: Add missing "Heartbeat Timeout Too Short" subsection - versioning.md: Add missing Key Concepts, Deployment Strategies, Query Filters, PINNED/AUTO_UPGRADE guidance, CLI examples - advanced-features.md: Add worker-level heading for exception types Co-Authored-By: Claude Opus 4.6 (1M context) --- references/dotnet/advanced-features.md | 2 + references/dotnet/dotnet.md | 18 +----- references/dotnet/gotchas.md | 24 +++++++ references/dotnet/patterns.md | 8 ++- references/dotnet/versioning.md | 89 +++++++++++++++++++++++++- 5 files changed, 123 insertions(+), 18 deletions(-) diff --git a/references/dotnet/advanced-features.md b/references/dotnet/advanced-features.md index 34f2e70..2fb04af 100644 --- a/references/dotnet/advanced-features.md +++ b/references/dotnet/advanced-features.md @@ -95,6 +95,8 @@ Control which exceptions cause workflow failures vs workflow task retries. **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, diff --git a/references/dotnet/dotnet.md b/references/dotnet/dotnet.md index 3203a77..c6adc6e 100644 --- a/references/dotnet/dotnet.md +++ b/references/dotnet/dotnet.md @@ -125,21 +125,9 @@ MyTemporalApp/ ## Determinism Rules -The .NET SDK has **no sandbox** like Python or TypeScript. Developers must avoid non-deterministic operations manually. - -**Do NOT use in workflows:** -- `Task.Run` — use `Workflow.RunTaskAsync` -- `Task.Delay` / `Thread.Sleep` — use `Workflow.DelayAsync` -- `Task.WhenAny` — use `Workflow.WhenAnyAsync` -- `Task.WhenAll` — use `Workflow.WhenAllAsync` -- `ConfigureAwait(false)` — use `ConfigureAwait(true)` or omit -- `DateTime.Now` / `DateTime.UtcNow` — use `Workflow.UtcNow` -- `Random` — use `Workflow.Random` -- `Guid.NewGuid()` — use `Workflow.NewGuid()` -- `System.Threading.Mutex` / `Semaphore` — use `Temporalio.Workflows.Mutex` / `Semaphore` -- Iterating `Dictionary` (unordered) — use `SortedDictionary` or sort first - -See `references/dotnet/determinism.md` and `references/dotnet/determinism-protection.md` for detailed 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 diff --git a/references/dotnet/gotchas.md b/references/dotnet/gotchas.md index f3813ec..bec611e 100644 --- a/references/dotnet/gotchas.md +++ b/references/dotnet/gotchas.md @@ -172,6 +172,30 @@ public async Task ProcessLargeFileAsync(string path) } ``` +### 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 diff --git a/references/dotnet/patterns.md b/references/dotnet/patterns.md index 5885e7f..d6f9f8c 100644 --- a/references/dotnet/patterns.md +++ b/references/dotnet/patterns.md @@ -164,7 +164,13 @@ public class ParentWorkflow { var result = await Workflow.ExecuteChildWorkflowAsync( (ProcessOrderWorkflow wf) => wf.RunAsync(order), - new() { Id = $"order-{order.Id}" }); + 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; diff --git a/references/dotnet/versioning.md b/references/dotnet/versioning.md index d030c40..ff47cff 100644 --- a/references/dotnet/versioning.md +++ b/references/dotnet/versioning.md @@ -85,6 +85,20 @@ public async Task RunAsync(Order order) 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: @@ -122,10 +136,32 @@ var worker = new TemporalWorker( .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 @@ -144,22 +180,71 @@ var worker = new TemporalWorker( .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**: Workflows stay locked to their original Worker version. +**PINNED Behavior** + +Workflows stay locked to their original Worker version: ```csharp [Workflow(VersioningBehavior = VersioningBehavior.Pinned)] public class StableWorkflow { /* ... */ } ``` -**AUTO_UPGRADE**: Workflows can move to newer versions. Still needs patching for compatibility. +**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 From d3382136ed42464df6dcbedc28bdef12ac76938a Mon Sep 17 00:00:00 2001 From: Donald Pinckney Date: Mon, 16 Mar 2026 18:36:20 -0400 Subject: [PATCH 03/16] Fix .NET correctness issues from verification pass - patterns.md: Fix cancellation pattern to use official TemporalException.IsCanceledException(e) with detached CancellationTokenSource - advanced-features.md: Fix DI hosting example to use official AddHostedTemporalWorker(clientTargetHost:, clientNamespace:, taskQueue:) pattern Verified against official SDK README, API docs, and temporal-docs. Co-Authored-By: Claude Opus 4.6 (1M context) --- references/dotnet/advanced-features.md | 24 +++++++++++------------- references/dotnet/patterns.md | 10 ++++++---- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/references/dotnet/advanced-features.md b/references/dotnet/advanced-features.md index 2fb04af..e0d98fc 100644 --- a/references/dotnet/advanced-features.md +++ b/references/dotnet/advanced-features.md @@ -118,19 +118,17 @@ The .NET SDK supports dependency injection via the `Temporalio.Extensions.Hostin ```csharp using Temporalio.Extensions.Hosting; -var builder = Host.CreateApplicationBuilder(args); - -builder.Services.AddTemporalClient(options => -{ - options.TargetHost = "localhost:7233"; - options.Namespace = "default"; -}); - -builder.Services.AddHostedTemporalWorker("my-task-queue") - .AddWorkflow() - .AddScopedActivities(); - -var host = builder.Build(); +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(); ``` diff --git a/references/dotnet/patterns.md b/references/dotnet/patterns.md index d6f9f8c..cb0d46d 100644 --- a/references/dotnet/patterns.md +++ b/references/dotnet/patterns.md @@ -345,17 +345,19 @@ public class CancellableWorkflow new() { StartToCloseTimeout = TimeSpan.FromHours(1) }); return "completed"; } - catch (OperationCanceledException) when (Workflow.CancellationToken.IsCancellationRequested) + catch (Exception e) when (TemporalException.IsCanceledException(e)) { // Workflow was cancelled — perform cleanup - Workflow.Logger.LogInformation("Workflow cancelled, running 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), - // Use a non-cancelled token for cleanup - CancellationToken = CancellationToken.None, + CancellationToken = detachedCancelSource.Token, }); throw; // Re-throw to mark workflow as cancelled } From d80bd357db86843c4b064cef0ccf5d47e12e0c38 Mon Sep 17 00:00:00 2001 From: Donald Pinckney Date: Mon, 16 Mar 2026 18:38:07 -0400 Subject: [PATCH 04/16] Update supported language references to include .NET - SKILL.md: Add "Temporal .NET" and "Temporal C#" trigger phrases, update overview to mention .NET, add .NET entry in getting started - core/determinism.md: Add .NET entry in SDK Protection Mechanisms Co-Authored-By: Claude Opus 4.6 (1M context) --- SKILL.md | 5 +++-- references/core/determinism.md | 1 + 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/SKILL.md b/SKILL.md index 1874d20..920e8c1 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", "Temporal Go", "Temporal Golang", "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 Go", "Temporal Golang", "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: 0.1.0 --- @@ -8,7 +8,7 @@ version: 0.1.0 ## Overview -Temporal is a durable execution platform that makes workflows survive failures automatically. This skill provides guidance for building Temporal applications in Python, TypeScript, and Go. +Temporal is a durable execution platform that makes workflows survive failures automatically. This skill provides guidance for building Temporal applications in Python, TypeScript, Go and .NET. ## Core Architecture @@ -93,6 +93,7 @@ Once you've downloaded the file, extract the downloaded archive and add the temp - Python -> read `references/python/python.md` - TypeScript -> read `references/typescript/typescript.md` - Go -> read `references/go/go.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 af824d2..ee3b878 100644 --- a/references/core/determinism.md +++ b/references/core/determinism.md @@ -81,6 +81,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 early 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. - Go: The Go SDK has no runtime sandbox. Therefore, non-determinism bugs will never be immediately appararent, and are usually only observable during replay. The optional `workflowcheck` static analysis tool can be used to check for many sources of non-determinism at compile time. +- .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. Regardless of which SDK you are using, it is your responsibility to ensure that workflow code does not contain sources of non-determinism. Use SDK-specific tools as well as replay tests for doing so. From 0114238d11f5c8567f52d080d4af264eb7303ff9 Mon Sep 17 00:00:00 2001 From: Donald Pinckney Date: Wed, 1 Apr 2026 15:58:18 -0400 Subject: [PATCH 05/16] Edits to advanced features --- references/dotnet/advanced-features.md | 68 +++++++++++++++++++++----- references/dotnet/dotnet.md | 1 + 2 files changed, 56 insertions(+), 13 deletions(-) diff --git a/references/dotnet/advanced-features.md b/references/dotnet/advanced-features.md index e0d98fc..8189581 100644 --- a/references/dotnet/advanced-features.md +++ b/references/dotnet/advanced-features.md @@ -33,8 +33,10 @@ await handle.DeleteAsync(); ## Async Activity Completion For activities that complete asynchronously (e.g., human tasks, external callbacks). +If you configure a `HeartbeatTimeout` on this activity, the external completer is responsible for sending heartbeats via the async handle. +If you do NOT set a `HeartbeatTimeout`, no heartbeats are required. -**Note:** If the external system can reliably Signal back with the result, consider using **signals** instead. +**Note:** If the external system that completes the asynchronous action can reliably be trusted to do the task and Signal back with the result, and it doesn't need to Heartbeat or receive Cancellation, then consider using **signals** instead. ```csharp using Temporalio.Activities; @@ -60,9 +62,13 @@ public async Task CompleteApprovalAsync(string requestId, bool approved) var handle = client.GetAsyncActivityHandle(taskToken); + // Optional: if a HeartbeatTimeout was set, you can periodically: + // await handle.HeartbeatAsync(progressDetails); + if (approved) await handle.CompleteAsync("approved"); else + // You can also fail or report cancellation via the handle await handle.FailAsync(new ApplicationFailureException("Rejected")); } ``` @@ -87,6 +93,36 @@ var worker = new TemporalWorker( .AddAllActivities(new MyActivities())); ``` +## Workflow Init Attribute + +Use `[WorkflowInit]` on a constructor to run initialization code when a workflow is first created. + +**Purpose:** Execute some setup code before signal/update happens or run is invoked. + +```csharp +[Workflow] +public class MyWorkflow +{ + private readonly string _initialValue; + private readonly List _items = new(); + + [WorkflowInit] + public MyWorkflow(string initialValue) + { + _initialValue = initialValue; + } + + [WorkflowRun] + public async Task RunAsync(string initialValue) + { + // _initialValue and _items are already initialized + return _initialValue; + } +} +``` + +Constructor and `[WorkflowRun]` method must have the same parameters with the same types. You cannot make blocking calls (activities, sleeps, etc.) from the constructor. + ## Workflow Failure Exception Types Control which exceptions cause workflow failures vs workflow task retries. @@ -118,18 +154,24 @@ The .NET SDK supports dependency injection via the `Temporalio.Extensions.Hostin ```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(); +public class Program +{ + public static async Task Main(string[] args) + { + 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 diff --git a/references/dotnet/dotnet.md b/references/dotnet/dotnet.md index c6adc6e..3f67834 100644 --- a/references/dotnet/dotnet.md +++ b/references/dotnet/dotnet.md @@ -87,6 +87,7 @@ Console.WriteLine($"Result: {result}"); ### Workflow Definition - Use `[Workflow]` attribute on class +- Put any state initialization logic in the constructor of your workflow class to guarantee that it happens before signals/updates arrive. If your state initialization logic requires the workflow parameters, then add the `[WorkflowInit]` attribute and parameters to your constructor. - Use `[WorkflowRun]` on the async entry point method - Must return `Task` or `Task` - Use `[WorkflowSignal]`, `[WorkflowQuery]`, `[WorkflowUpdate]` for handlers From 2ee9c02c9131c8edebd4db1d7f6da221d5d6855b Mon Sep 17 00:00:00 2001 From: Donald Pinckney Date: Wed, 1 Apr 2026 16:46:25 -0400 Subject: [PATCH 06/16] edits to determinism protection, and move the .editorconfig section --- references/dotnet/determinism-protection.md | 29 +------------- references/dotnet/dotnet.md | 44 +++++++++++++++++---- 2 files changed, 38 insertions(+), 35 deletions(-) diff --git a/references/dotnet/determinism-protection.md b/references/dotnet/determinism-protection.md index 56bf5de..d62ed98 100644 --- a/references/dotnet/determinism-protection.md +++ b/references/dotnet/determinism-protection.md @@ -2,10 +2,7 @@ ## 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 +The .NET SDK has no runtime sandbox. Determinism is enforced by **developer convention** and **runtime task detection**. Unlike the Python and TypeScript SDKs, the .NET SDK will not intercept or replace non-deterministic calls at compile time or import time. The SDK does provide a runtime `EventListener` that detects some invalid task scheduling, but catching all non-deterministic code requires following the rules below and testing, in particular replay tests (see `references/dotnet/testing.md`). ## Runtime Task Detection @@ -25,17 +22,6 @@ public class BadWorkflow } ``` -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: @@ -53,19 +39,6 @@ Many .NET `Task` APIs implicitly use `TaskScheduler.Default`, which breaks deter - 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 diff --git a/references/dotnet/dotnet.md b/references/dotnet/dotnet.md index 3f67834..7e7d0fa 100644 --- a/references/dotnet/dotnet.md +++ b/references/dotnet/dotnet.md @@ -31,7 +31,7 @@ public class MyActivities } ``` -**GreetingWorkflow.cs** - Workflow definition: +**GreetingWorkflow.workflow.cs** - Workflow definition: ```csharp using Temporalio.Workflows; @@ -108,20 +108,50 @@ Console.WriteLine($"Result: {result}"); ## 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. +**Keep Workflow definitions in separate files from Activity definitions.** While not as critical as Python (no sandbox reloading), separation improves clarity and testability. Use the `.workflow.cs` extension for workflow files so the `.editorconfig` overrides (see below) apply only to workflow code. ``` MyTemporalApp/ ├── Workflows/ -│ └── GreetingWorkflow.cs # Only Workflow classes +│ └── GreetingWorkflow.workflow.cs # Only Workflow classes ├── Activities/ -│ └── TranslateActivities.cs # Only Activity classes +│ └── TranslateActivities.cs # Only Activity classes ├── Models/ -│ └── OrderInput.cs # Shared data models +│ └── OrderInput.cs # Shared data models ├── Worker/ -│ └── Program.cs # Worker setup +│ └── Program.cs # Worker setup └── Starter/ - └── Program.cs # Client code to start workflows + └── Program.cs # Client code to start workflows +``` + +## Workflow .editorconfig + +Workflow code violates some standard .NET analyzer rules. The recommended approach is to use the `.workflow.cs` file extension for workflow files and scope the overrides to that extension: + +```ini +# Configuration specific for Temporal workflows +[*.workflow.cs] + +# We use getters for queries, they cannot be properties +dotnet_diagnostic.CA1024.severity = none + +# Don't force workflows to have static methods +dotnet_diagnostic.CA1822.severity = none + +# Do not need ConfigureAwait for workflows +dotnet_diagnostic.CA2007.severity = none + +# Do not need task scheduler for workflows +dotnet_diagnostic.CA2008.severity = none + +# Workflow randomness is intentionally deterministic +dotnet_diagnostic.CA5394.severity = none + +# Allow async methods to not have await in them +dotnet_diagnostic.CS1998.severity = none + +# Don't avoid, but rather encourage things using TaskScheduler.Current in workflows +dotnet_diagnostic.VSTHRD105.severity = none ``` ## Determinism Rules From 3709a851e76cb8454e969830f0d4757c6108095e Mon Sep 17 00:00:00 2001 From: Donald Pinckney Date: Wed, 1 Apr 2026 16:48:16 -0400 Subject: [PATCH 07/16] missed one --- references/dotnet/dotnet.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/references/dotnet/dotnet.md b/references/dotnet/dotnet.md index 7e7d0fa..7f327ec 100644 --- a/references/dotnet/dotnet.md +++ b/references/dotnet/dotnet.md @@ -150,6 +150,9 @@ dotnet_diagnostic.CA5394.severity = none # Allow async methods to not have await in them dotnet_diagnostic.CS1998.severity = none +# Don't force workflows to call async methods +dotnet_diagnostic.VSTHRD103.severity = none + # Don't avoid, but rather encourage things using TaskScheduler.Current in workflows dotnet_diagnostic.VSTHRD105.severity = none ``` From b41ecc6c3cb7fc335cc1fafa1f00629700396bbb Mon Sep 17 00:00:00 2001 From: Donald Pinckney Date: Wed, 1 Apr 2026 16:50:18 -0400 Subject: [PATCH 08/16] edit determinism.md --- references/dotnet/determinism.md | 27 +++++++++------------------ 1 file changed, 9 insertions(+), 18 deletions(-) diff --git a/references/dotnet/determinism.md b/references/dotnet/determinism.md index 99f3049..dbb54bd 100644 --- a/references/dotnet/determinism.md +++ b/references/dotnet/determinism.md @@ -2,17 +2,11 @@ ## 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. +The .NET SDK has NO runtime sandbox (unlike Python/TypeScript). Workflows must be deterministic for replay, and determinism is enforced by developer convention and runtime task detection via an `EventListener` (see `references/dotnet/determinism-protection.md`). ## 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. +Temporal provides durable execution through **History Replay**. When a Worker restores workflow state, it re-executes workflow code from the beginning. This requires the code to be **deterministic**. See `references/core/determinism.md` for a deep explanation. ## Forbidden Operations @@ -51,13 +45,10 @@ Use `WorkflowReplayer` to verify your code changes are compatible with existing ## 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 +1. Always use `Workflow.*` APIs instead of standard .NET equivalents (see table above) +2. Never use `ConfigureAwait(false)` in workflows +3. Use `SortedDictionary` or sort before iterating collections +4. Move all I/O operations (network, filesystem, database) into activities +5. Use `Workflow.Logger` instead of `Console.WriteLine` for replay-safe logging +6. Keep workflow code focused on orchestration; delegate non-deterministic work to activities +7. Test with replay after making changes to workflow definitions From 7ae7afb210c8f69a5aa6369d8b20f027ed056578 Mon Sep 17 00:00:00 2001 From: Donald Pinckney Date: Wed, 1 Apr 2026 17:01:52 -0400 Subject: [PATCH 09/16] edit error-handling.md --- references/dotnet/error-handling.md | 53 +++++++++++++++++++---------- 1 file changed, 35 insertions(+), 18 deletions(-) diff --git a/references/dotnet/error-handling.md b/references/dotnet/error-handling.md index dc9e214..c650e1f 100644 --- a/references/dotnet/error-handling.md +++ b/references/dotnet/error-handling.md @@ -74,19 +74,28 @@ public class MyWorkflow ```csharp using Temporalio.Common; +using Temporalio.Workflows; -return await Workflow.ExecuteActivityAsync( - (MyActivities a) => a.MyActivityAsync(), - new() +[Workflow] +public class MyWorkflow +{ + [WorkflowRun] + public async Task RunAsync() { - StartToCloseTimeout = TimeSpan.FromMinutes(10), - RetryPolicy = new() - { - MaximumInterval = TimeSpan.FromMinutes(1), - MaximumAttempts = 5, - NonRetryableErrorTypes = new[] { "ValidationError", "PaymentError" }, - }, - }); + 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. @@ -95,14 +104,22 @@ If not, prefer to leave them at their defaults. ## Timeout Configuration ```csharp -return await Workflow.ExecuteActivityAsync( - (MyActivities a) => a.MyActivityAsync(), - new() +[Workflow] +public class MyWorkflow +{ + [WorkflowRun] + public async Task RunAsync() { - StartToCloseTimeout = TimeSpan.FromMinutes(5), // Single attempt - ScheduleToCloseTimeout = TimeSpan.FromMinutes(30), // Including retries - HeartbeatTimeout = TimeSpan.FromMinutes(2), // Between heartbeats - }); + 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 From 4f19f2567e4eeb51c89562334d8e8e7de65a7f24 Mon Sep 17 00:00:00 2001 From: Donald Pinckney Date: Wed, 1 Apr 2026 17:01:59 -0400 Subject: [PATCH 10/16] edit gotchas.md --- references/dotnet/gotchas.md | 116 +++++++++++++++++++---------------- 1 file changed, 63 insertions(+), 53 deletions(-) diff --git a/references/dotnet/gotchas.md b/references/dotnet/gotchas.md index bec611e..05213c2 100644 --- a/references/dotnet/gotchas.md +++ b/references/dotnet/gotchas.md @@ -68,6 +68,55 @@ See `references/dotnet/determinism-protection.md` for the complete list. **Example:** Transient network errors should be retried. Authentication errors should not be. See `references/dotnet/error-handling.md` to understand how to classify errors. +## 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. + ## Cancellation ### Not Handling Workflow Cancellation @@ -124,7 +173,9 @@ public class GoodWorkflow ### Not Handling Activity Cancellation -Activities must **opt in** to receive cancellation via heartbeating. +Activities must **opt in** to receive cancellation. This requires: +1. **Heartbeating** — Cancellation is delivered via heartbeat +2. **Checking the cancellation token** — Token is triggered when heartbeat detects cancellation ```csharp // BAD: Activity ignores cancellation @@ -134,68 +185,27 @@ public async Task LongActivityAsync() await DoExpensiveWorkAsync(); // Runs to completion even if cancelled } -// GOOD: Heartbeat and check cancellation token +// GOOD: Heartbeat, check cancellation, and handle cleanup [Activity] public async Task LongActivityAsync() { - foreach (var item in items) + try { - ActivityExecutionContext.Current.Heartbeat(); - ActivityExecutionContext.Current.CancellationToken.ThrowIfCancellationRequested(); - await ProcessAsync(item); + 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++) + catch (OperationCanceledException) { - ActivityExecutionContext.Current.Heartbeat($"Processing chunk {i}"); - await ProcessAsync(chunks[i]); + await CleanupAsync(); + throw; } } ``` -### 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 From c51d32b578e05c095baea8e561b24f6d6727db60 Mon Sep 17 00:00:00 2001 From: Donald Pinckney Date: Wed, 1 Apr 2026 17:08:35 -0400 Subject: [PATCH 11/16] edit patterns.md --- references/dotnet/patterns.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/references/dotnet/patterns.md b/references/dotnet/patterns.md index cb0d46d..c8f7178 100644 --- a/references/dotnet/patterns.md +++ b/references/dotnet/patterns.md @@ -150,6 +150,8 @@ public class OrderWorkflow } ``` +**Important:** Validators must NOT mutate workflow state or do anything blocking (no activities, sleeps, or other commands). They are read-only, similar to query handlers. Throw an exception to reject the update; return void to accept. + ## Child Workflows ```csharp From 3aa4ce7dfcf3521d06ab3bd900c02ceac96bdc14 Mon Sep 17 00:00:00 2001 From: Donald Pinckney Date: Wed, 1 Apr 2026 17:23:57 -0400 Subject: [PATCH 12/16] edit versioning.md --- references/dotnet/versioning.md | 85 +++++++++++++++++++++++++-------- 1 file changed, 65 insertions(+), 20 deletions(-) diff --git a/references/dotnet/versioning.md b/references/dotnet/versioning.md index ff47cff..677a64c 100644 --- a/references/dotnet/versioning.md +++ b/references/dotnet/versioning.md @@ -45,19 +45,23 @@ public class ShippingWorkflow **Step 1: Patch in New Code** ```csharp -[WorkflowRun] -public async Task RunAsync(Order order) +[Workflow] +public class OrderWorkflow { - if (Workflow.Patched("add-fraud-check")) + [WorkflowRun] + public async Task RunAsync(Order order) { - await Workflow.ExecuteActivityAsync( - (OrderActivities a) => a.CheckFraudAsync(order), - new() { StartToCloseTimeout = TimeSpan.FromMinutes(2) }); - } + 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) }); + return await Workflow.ExecuteActivityAsync( + (OrderActivities a) => a.ProcessPaymentAsync(order), + new() { StartToCloseTimeout = TimeSpan.FromMinutes(5) }); + } } ``` @@ -66,24 +70,46 @@ public async Task RunAsync(Order order) Once all pre-patch Workflow Executions have completed: ```csharp -[WorkflowRun] -public async Task RunAsync(Order order) +[Workflow] +public class OrderWorkflow { - Workflow.DeprecatePatch("add-fraud-check"); + [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) }); + 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) }); + 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. +After all workflows with the deprecated patch marker have completed, remove the `DeprecatePatch()` call entirely: + +```csharp +[Workflow] +public class OrderWorkflow +{ + [WorkflowRun] + public async Task RunAsync(Order order) + { + 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) }); + } +} +``` ### Query Filters for Finding Workflows by Version @@ -219,6 +245,25 @@ public class UpgradableWorkflow { /* ... */ } **Important:** AUTO_UPGRADE workflows still need patching to handle version transitions safely since they can move between Worker versions. +### Worker Configuration with Default Behavior + +```csharp +var worker = new TemporalWorker( + client, + new TemporalWorkerOptions("my-task-queue") + { + DeploymentOptions = new WorkerDeploymentOptions( + DeploymentName: "order-service", + BuildId: Environment.GetEnvironmentVariable("BUILD_ID") ?? "dev") + { + DefaultVersioningBehavior = VersioningBehavior.Pinned, + }, + UseWorkerVersioning = true, + } + .AddWorkflow() + .AddAllActivities(new OrderActivities())); +``` + ### Deployment Strategies **Blue-Green Deployments** From 2a38c83b47f61eac391163f600d4ffab5ad0afcc Mon Sep 17 00:00:00 2001 From: Donald Pinckney Date: Wed, 1 Apr 2026 17:26:43 -0400 Subject: [PATCH 13/16] edit observability.md --- references/dotnet/observability.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/references/dotnet/observability.md b/references/dotnet/observability.md index 820188d..5e3f343 100644 --- a/references/dotnet/observability.md +++ b/references/dotnet/observability.md @@ -88,9 +88,13 @@ var meterProvider = Sdk.CreateMeterProviderBuilder() - `temporal_activity_execution_latency` — Activity execution time - `temporal_workflow_task_replay_latency` — Replay duration +## Search Attributes (Visibility) + +See the Search Attributes section of `references/dotnet/data-handling.md` + ## 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`) +4. Use Search Attributes for business-level visibility From 93388e5e7136a45df84068fb422cc907800aca67 Mon Sep 17 00:00:00 2001 From: Donald Pinckney Date: Wed, 1 Apr 2026 17:38:20 -0400 Subject: [PATCH 14/16] fix metrics --- references/dotnet/observability.md | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/references/dotnet/observability.md b/references/dotnet/observability.md index 5e3f343..1150f63 100644 --- a/references/dotnet/observability.md +++ b/references/dotnet/observability.md @@ -69,18 +69,25 @@ var client = await TemporalClient.ConnectAsync(new("localhost:7233") ### Enabling SDK Metrics +Metrics are configured on `TemporalRuntime`. Create the runtime globally before any client/worker and set a Prometheus endpoint or custom metric meter. + ```csharp -using Temporalio.Extensions.OpenTelemetry; -using OpenTelemetry; -using OpenTelemetry.Metrics; - -// Configure OpenTelemetry metrics -var meterProvider = Sdk.CreateMeterProviderBuilder() - .AddTemporalClientInstrumentation() - .AddPrometheusExporter() - .Build(); +using Temporalio.Client; +using Temporalio.Runtime; + +// Create runtime with Prometheus endpoint +var runtime = new TemporalRuntime(new() +{ + Telemetry = new() { Metrics = new() { Prometheus = new("0.0.0.0:9000") } }, +}); + +// Use this runtime for all clients +var client = await TemporalClient.ConnectAsync( + new("localhost:7233") { Runtime = runtime }); ``` +Alternatively, use `Temporalio.Extensions.DiagnosticSource` to bridge metrics to a .NET `System.Diagnostics.Metrics.Meter` for integration with OpenTelemetry or other .NET metrics pipelines. + ### Key SDK Metrics - `temporal_request` — Client requests to server From 54ce199881d6081ca527a3fb75057e50f8d41add Mon Sep 17 00:00:00 2001 From: Donald Pinckney Date: Wed, 1 Apr 2026 17:49:08 -0400 Subject: [PATCH 15/16] self-review round 1 --- references/dotnet/advanced-features.md | 4 ++-- references/dotnet/data-handling.md | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/references/dotnet/advanced-features.md b/references/dotnet/advanced-features.md index 8189581..96d73e1 100644 --- a/references/dotnet/advanced-features.md +++ b/references/dotnet/advanced-features.md @@ -11,10 +11,10 @@ var scheduleId = "daily-report"; await client.CreateScheduleAsync( scheduleId, new Schedule( - action: ScheduleActionStartWorkflow.Create( + Action: ScheduleActionStartWorkflow.Create( (DailyReportWorkflow wf) => wf.RunAsync(), new(id: "daily-report", taskQueue: "reports")), - spec: new ScheduleSpec + Spec: new ScheduleSpec { Intervals = new List { diff --git a/references/dotnet/data-handling.md b/references/dotnet/data-handling.md index 5f8ae5a..fc8d308 100644 --- a/references/dotnet/data-handling.md +++ b/references/dotnet/data-handling.md @@ -135,8 +135,7 @@ public class OrderWorkflow // Update search attribute Workflow.UpsertTypedSearchAttributes( - SearchAttributeUpdate.ValueSet( - SearchAttributeKey.CreateKeyword("OrderStatus"), "completed")); + SearchAttributeKey.CreateKeyword("OrderStatus").ValueSet("completed")); return "done"; } } From b4c958ecca04c54e79a1f78e7047179d4f855fae Mon Sep 17 00:00:00 2001 From: Donald Pinckney Date: Thu, 2 Apr 2026 08:21:12 -0400 Subject: [PATCH 16/16] minor correctness fixed --- references/dotnet/testing.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/references/dotnet/testing.md b/references/dotnet/testing.md index ae19322..8bea410 100644 --- a/references/dotnet/testing.md +++ b/references/dotnet/testing.md @@ -72,7 +72,7 @@ public async Task TestWithMockActivity() } ``` -**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`. +**Note:** If the original activity method name ends with `Async` and returns a `Task`, the default activity name has `Async` trimmed off. For example, `MyActivityAsync` has default name `MyActivity`. ## Testing Signals and Queries