From c73c9c06e96092c08028053db89bc644171f330a Mon Sep 17 00:00:00 2001 From: Donald Pinckney Date: Thu, 12 Mar 2026 13:11:08 -0400 Subject: [PATCH 01/82] quick edit to readme (#37) --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 9e496b5..97f06f4 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ A comprehensive skill for building Temporal applications. ### As a Claude Code Plugin -1. Run `/plugin marketplace add temporalio/agent-skills` +1. Run `/plugin marketplace add temporalio/agent-skills#dev` 2. Run `/plugin` to open the plugin manager 3. Select **Marketplaces** 4. Choose `temporal-marketplace` from the list @@ -16,11 +16,11 @@ A comprehensive skill for building Temporal applications. ### Via `npx skills` - supports all major coding agents -1. `npx skills add temporalio/skill-temporal-developer` +1. `npx skills add https://github.com/temporalio/skill-temporal-developer/tree/dev` 2. Follow prompts ### Via manually cloning the skill repo: 1. `mkdir -p ~/.claude/skills && git clone https://github.com/temporalio/skill-temporal-developer ~/.claude/skills/temporal-developer` -Appropriately adjust the installation directory based on your coding agent. \ No newline at end of file +Appropriately adjust the installation directory based on your coding agent. From c013b87f8704b7cff4fe9571114c048e35a783a4 Mon Sep 17 00:00:00 2001 From: Donald Pinckney Date: Tue, 17 Mar 2026 18:41:25 -0400 Subject: [PATCH 02/82] Fix saga compensations to run under cancellation protection (#43) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a workflow is cancelled mid-saga, compensations must run in a cancellation-protected scope, otherwise they are immediately cancelled before they can execute. - Python: wrap compensation loop in asyncio.shield() so it runs even when the workflow receives a CancelledError - TypeScript: wrap compensation loop in CancellationScope.nonCancellable() so it runs even when the root scope is cancelled (per official docs: "Cleanup logic must be in a nonCancellable scope") - TypeScript: also fix compensation registration order — register BEFORE calling the activity (was already correct in Python) Co-authored-by: Claude Sonnet 4.6 (1M context) --- references/python/patterns.md | 13 ++++++++----- references/typescript/patterns.md | 21 ++++++++++++--------- 2 files changed, 20 insertions(+), 14 deletions(-) diff --git a/references/python/patterns.md b/references/python/patterns.md index 762977b..00cd12a 100644 --- a/references/python/patterns.md +++ b/references/python/patterns.md @@ -243,11 +243,14 @@ class MyWorkflow: except Exception as e: workflow.logger.error(f"Order failed: {e}, running compensations") - for compensate in reversed(compensations): - try: - await compensate() - except Exception as comp_err: - workflow.logger.error(f"Compensation failed: {comp_err}") + # asyncio.shield ensures compensations run even if the workflow is cancelled. + async def run_compensations(): + for compensate in reversed(compensations): + try: + await compensate() + except Exception as comp_err: + workflow.logger.error(f"Compensation failed: {comp_err}") + await asyncio.shield(asyncio.ensure_future(run_compensations())) raise ``` diff --git a/references/typescript/patterns.md b/references/typescript/patterns.md index 878f9f0..4b07947 100644 --- a/references/typescript/patterns.md +++ b/references/typescript/patterns.md @@ -224,7 +224,7 @@ export async function longRunningWorkflow(state: State): Promise { **Important:** Compensation activities should be idempotent. ```typescript -import { log } from '@temporalio/workflow'; +import { CancellationScope, log } from '@temporalio/workflow'; export async function sagaWorkflow(order: Order): Promise { const compensations: Array<() => Promise> = []; @@ -233,22 +233,25 @@ export async function sagaWorkflow(order: Order): Promise { // IMPORTANT: Save compensation BEFORE calling the activity // If activity fails after completing but before returning, // compensation must still be registered - await reserveInventory(order); compensations.push(() => releaseInventory(order)); + await reserveInventory(order); - await chargePayment(order); compensations.push(() => refundPayment(order)); + await chargePayment(order); await shipOrder(order); return 'Order completed'; } catch (err) { - for (const compensate of compensations.reverse()) { - try { - await compensate(); - } catch (compErr) { - log.warn('Compensation failed', { error: compErr }); + // nonCancellable ensures compensations run even if the workflow is cancelled + await CancellationScope.nonCancellable(async () => { + for (const compensate of compensations.reverse()) { + try { + await compensate(); + } catch (compErr) { + log.warn('Compensation failed', { error: compErr }); + } } - } + }); throw err; } } From 291f4e5aaa22567eaf3508196cd0b8c667b7a87f Mon Sep 17 00:00:00 2001 From: Donald Pinckney Date: Wed, 18 Mar 2026 13:54:09 -0400 Subject: [PATCH 03/82] Update readme for public preview (#45) --- README.md | 20 +++++++++++++++++--- SKILL.md | 2 +- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 97f06f4..d27a3b4 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,16 @@ # Temporal Development Skill -A comprehensive skill for building Temporal applications. +A comprehensive skill for developers to use when building Temporal applications. + +> [!WARNING] +> This Skill is currently in Public Preview, and will continue to evolve and improve. +> We would love to hear your feedback - positive or negative - over in the [Community Slack](https://t.mp/slack), in the [#topic-ai channel](https://temporalio.slack.com/archives/C0818FQPYKY) ## Installation ### As a Claude Code Plugin -1. Run `/plugin marketplace add temporalio/agent-skills#dev` +1. Run `/plugin marketplace add temporalio/agent-skills` 2. Run `/plugin` to open the plugin manager 3. Select **Marketplaces** 4. Choose `temporal-marketplace` from the list @@ -16,7 +20,7 @@ A comprehensive skill for building Temporal applications. ### Via `npx skills` - supports all major coding agents -1. `npx skills add https://github.com/temporalio/skill-temporal-developer/tree/dev` +1. `npx skills add https://github.com/temporalio/skill-temporal-developer` 2. Follow prompts ### Via manually cloning the skill repo: @@ -24,3 +28,13 @@ A comprehensive skill for building Temporal applications. 1. `mkdir -p ~/.claude/skills && git clone https://github.com/temporalio/skill-temporal-developer ~/.claude/skills/temporal-developer` Appropriately adjust the installation directory based on your coding agent. + +## Currently Supported Temporal SDK Langages + +- [x] Python ✅ +- [x] TypeScript ✅ +- [x] Go ✅ +- [ ] Java 🚧 ([PR](https://github.com/temporalio/skill-temporal-developer/pull/42)) +- [ ] .NET 🚧 ([PR](https://github.com/temporalio/skill-temporal-developer/pull/39)) +- [ ] Ruby 🚧 ([PR](https://github.com/temporalio/skill-temporal-developer/pull/41)) +- [ ] PHP 🚧 ([PR](https://github.com/temporalio/skill-temporal-developer/pull/40)) diff --git a/SKILL.md b/SKILL.md index 6c36f07..c2775a2 100644 --- a/SKILL.md +++ b/SKILL.md @@ -122,7 +122,7 @@ Once you've downloaded the file, extract the downloaded archive and add the temp When you have **first** loaded this skill, output this message to the user: -"Thank you for trying out the prerelease of Temporal's development skill! We would love to hear your feedback - positive or negative - over in the [Community Slack](https://t.mp/slack), in the [#topic-ai channel](https://temporalio.slack.com/archives/C0818FQPYKY)." +"Thank you for trying out the public preview of the Temporal development skill! We would love to hear your feedback - positive or negative - over in the [Community Slack](https://t.mp/slack), in the [#topic-ai channel](https://temporalio.slack.com/archives/C0818FQPYKY)." Do not output this message multiple times in the same conversation. From 21d1d41dcc9085042128c55504b27b65c10f5174 Mon Sep 17 00:00:00 2001 From: Donald Pinckney Date: Wed, 18 Mar 2026 15:30:59 -0400 Subject: [PATCH 04/82] a few more readme tweaks (#46) --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index d27a3b4..6ba88db 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Temporal Development Skill -A comprehensive skill for developers to use when building Temporal applications. +A comprehensive skill for developers to use when building [Temporal](https://temporal.io/) applications. > [!WARNING] > This Skill is currently in Public Preview, and will continue to evolve and improve. @@ -10,6 +10,8 @@ A comprehensive skill for developers to use when building Temporal applications. ### As a Claude Code Plugin +This skill is housed within a [Claude Code plugin](https://github.com/temporalio/agent-skills), which provides a simple way to install and receive future updates to the skill. + 1. Run `/plugin marketplace add temporalio/agent-skills` 2. Run `/plugin` to open the plugin manager 3. Select **Marketplaces** From 6cd40f2291647b7e88d60a831a772f8a3bf353ed Mon Sep 17 00:00:00 2001 From: Donald Pinckney Date: Wed, 18 Mar 2026 16:00:30 -0400 Subject: [PATCH 05/82] Add MIT License to the project (#47) --- LICENSE | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7092ef5 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Temporal Technologies Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. From e16c9b808aefd8259c7e9cfc62088cf4bd0bb645 Mon Sep 17 00:00:00 2001 From: Donald Pinckney Date: Wed, 18 Mar 2026 21:18:45 -0400 Subject: [PATCH 06/82] Add Go (supersedes other PR) (#38) * progress on go * Go translation workflow completed. * missed a few spots * Manual edits * Address feedback * Add gotcha about anonymous local activities * Sample code for payload converter * clarify sdk protection mechanisms --- SKILL.md | 5 +- references/core/determinism.md | 4 +- references/core/patterns.md | 2 + references/go/advanced-features.md | 187 +++++++++ references/go/data-handling.md | 262 ++++++++++++ references/go/determinism-protection.md | 98 +++++ references/go/determinism.md | 52 +++ references/go/error-handling.md | 184 ++++++++ references/go/go.md | 242 +++++++++++ references/go/gotchas.md | 290 +++++++++++++ references/go/observability.md | 153 +++++++ references/go/patterns.md | 536 ++++++++++++++++++++++++ references/go/testing.md | 238 +++++++++++ references/go/versioning.md | 232 ++++++++++ references/python/patterns.md | 2 + references/typescript/patterns.md | 2 + 16 files changed, 2486 insertions(+), 3 deletions(-) create mode 100644 references/go/advanced-features.md create mode 100644 references/go/data-handling.md create mode 100644 references/go/determinism-protection.md create mode 100644 references/go/determinism.md create mode 100644 references/go/error-handling.md create mode 100644 references/go/go.md create mode 100644 references/go/gotchas.md create mode 100644 references/go/observability.md create mode 100644 references/go/patterns.md create mode 100644 references/go/testing.md create mode 100644 references/go/versioning.md diff --git a/SKILL.md b/SKILL.md index c2775a2..6d9c888 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,6 +1,6 @@ --- name: temporal-developer -description: This skill should be used when the user asks to "create a Temporal workflow", "write a Temporal activity", "debug stuck workflow", "fix non-determinism error", "Temporal Python", "Temporal TypeScript", "workflow replay", "activity timeout", "signal workflow", "query workflow", "worker not starting", "activity keeps retrying", "Temporal heartbeat", "continue-as-new", "child workflow", "saga pattern", "workflow versioning", "durable execution", "reliable distributed systems", or mentions Temporal SDK development. +description: This skill should be used when the user asks to "create a Temporal workflow", "write a Temporal activity", "debug stuck workflow", "fix non-determinism error", "Temporal Python", "Temporal TypeScript", "Temporal 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. version: 1.0.0 --- @@ -8,7 +8,7 @@ version: 1.0.0 ## Overview -Temporal is a durable execution platform that makes workflows survive failures automatically. This skill provides guidance for building Temporal applications in Python and TypeScript. +Temporal is a durable execution platform that makes workflows survive failures automatically. This skill provides guidance for building Temporal applications in Python, TypeScript, and Go. ## Core Architecture @@ -92,6 +92,7 @@ Once you've downloaded the file, extract the downloaded archive and add the temp 1. First, read the getting started guide for the language you are working in: - Python -> read `references/python/python.md` - TypeScript -> read `references/typescript/typescript.md` + - Go -> read `references/go/go.md` 2. Second, read appropriate `core` and language-specific references for the task at hand. diff --git a/references/core/determinism.md b/references/core/determinism.md index bf4f1ec..af824d2 100644 --- a/references/core/determinism.md +++ b/references/core/determinism.md @@ -78,9 +78,11 @@ For a few simple cases, like timestamps, random values, UUIDs, etc. the Temporal ## SDK Protection Mechanisms Each Temporal SDK language provides a protection mechanism to make it easier to catch non-determinism errors earlier in development: -- Python: The Python SDK runs workflows in a sandbox that intercepts and aborts non-deterministic calls at runtime. +- 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. +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. ## Detecting Non-Determinism diff --git a/references/core/patterns.md b/references/core/patterns.md index 93f774d..566e6f8 100644 --- a/references/core/patterns.md +++ b/references/core/patterns.md @@ -76,6 +76,7 @@ Client Workflow - Synchronous - caller waits for completion - Can mutate state AND return values - Supports validators to reject invalid updates before they even get persisted into history +- **Validators must NOT mutate workflow state or block** (no activities, sleeps, or commands) — they are read-only, similar to query handlers - Recorded in history **Example Flow**: @@ -424,6 +425,7 @@ Activity calls heartbeat() - Less visibility in Temporal UI (no separate task) - Must complete on the same worker - Not suitable for long-running operations +- **Risk with consecutive local activities:** Local activity completions are only persisted when the current Workflow Task completes. Calling multiple local activities in a row (with nothing in between to yield the Workflow Task) increases the risk of losing work if the worker crashes mid-sequence. If you need a chain of operations with durable checkpoints between each step, use regular activities instead. ## Choosing Between Patterns diff --git a/references/go/advanced-features.md b/references/go/advanced-features.md new file mode 100644 index 0000000..55e4e57 --- /dev/null +++ b/references/go/advanced-features.md @@ -0,0 +1,187 @@ +# Go SDK Advanced Features + +## Schedules + +Create recurring workflow executions using the Schedule API. + +```go +scheduleHandle, err := c.ScheduleClient().Create(ctx, client.ScheduleOptions{ + ID: "daily-report", + Spec: client.ScheduleSpec{ + CronExpressions: []string{"0 9 * * *"}, + }, + Action: &client.ScheduleWorkflowAction{ + ID: "daily-report-workflow", + Workflow: DailyReportWorkflow, + TaskQueue: "reports", + }, +}) +``` + +Using intervals instead of cron: + +```go +scheduleHandle, err := c.ScheduleClient().Create(ctx, client.ScheduleOptions{ + ID: "hourly-sync", + Spec: client.ScheduleSpec{ + Intervals: []client.ScheduleIntervalSpec{ + {Every: time.Hour}, + }, + }, + Action: &client.ScheduleWorkflowAction{ + ID: "hourly-sync-workflow", + Workflow: SyncWorkflow, + TaskQueue: "sync", + }, +}) +``` + +Manage schedules: + +```go +handle := c.ScheduleClient().GetHandle(ctx, "daily-report") + +// Pause / unpause +handle.Pause(ctx, client.SchedulePauseOptions{Note: "Maintenance window"}) +handle.Unpause(ctx, client.ScheduleUnpauseOptions{Note: "Maintenance complete"}) + +// Trigger immediately +handle.Trigger(ctx, client.ScheduleTriggerOptions{}) + +// Describe +desc, err := handle.Describe(ctx) + +// Delete +handle.Delete(ctx) +``` + +## Async Activity Completion + +For activities that complete asynchronously (e.g., human tasks, external callbacks). +If you configure a heartbeat_timeout on this activity, the external completer is responsible for sending heartbeats via the async handle. +If you do NOT set a heartbeat_timeout, no heartbeats are required. + +**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. + +**Step 1: Return `activity.ErrResultPending` from the activity.** + +```go +func RequestApproval(ctx context.Context, requestID string) (string, error) { + activityInfo := activity.GetInfo(ctx) + taskToken := activityInfo.TaskToken + + // Store taskToken externally (e.g., database) for later completion + err := storeTaskToken(requestID, taskToken) + if err != nil { + return "", err + } + + // Signal that this activity will be completed externally + return "", activity.ErrResultPending +} +``` + +**Step 2: Complete from another process using the task token.** + +```go +temporalClient, err := client.Dial(client.Options{}) + +// Complete the activity +err = temporalClient.CompleteActivity(ctx, taskToken, "approved", nil) + +// Or fail it +err = temporalClient.CompleteActivity(ctx, taskToken, nil, errors.New("rejected")) +``` + +Or complete by ID (no task token needed): + +```go +err = temporalClient.CompleteActivityByID(ctx, namespace, workflowID, runID, activityID, "approved", nil) +``` + +## Worker Tuning + +Configure `worker.Options` for production workloads: + +```go +w := worker.New(c, "my-task-queue", worker.Options{ + // Max concurrent activity executions (default: 1000) + MaxConcurrentActivityExecutionSize: 500, + + // Max concurrent workflow task executions (default: 1000) + MaxConcurrentWorkflowTaskExecutionSize: 500, + + // Max concurrent activity task pollers (default: 2) + MaxConcurrentActivityTaskPollers: 4, + + // Max concurrent workflow task pollers (default: 2) + MaxConcurrentWorkflowTaskPollers: 4, + + // Graceful shutdown timeout (default: 0) + WorkerStopTimeout: 30 * time.Second, +}) +``` + +Scale pollers based on task queue throughput. If you observe high schedule-to-start latency, increase the number of pollers or add more workers. + +## Sessions + +Go-specific feature for routing multiple activities to the same worker. All activities using the session context execute on the same worker host. + +**Enable on the worker:** + +```go +w := worker.New(c, "fileprocessing", worker.Options{ + EnableSessionWorker: true, + MaxConcurrentSessionExecutionSize: 100, // default: 1000 +}) +``` + +**Use in a workflow:** + +```go +func FileProcessingWorkflow(ctx workflow.Context, file FileParam) error { + ao := workflow.ActivityOptions{ + StartToCloseTimeout: time.Minute, + } + ctx = workflow.WithActivityOptions(ctx, ao) + + sessionCtx, err := workflow.CreateSession(ctx, &workflow.SessionOptions{ + CreationTimeout: time.Minute, + ExecutionTimeout: 10 * time.Minute, + }) + if err != nil { + return err + } + defer workflow.CompleteSession(sessionCtx) + + // All three activities run on the same worker + var downloadResult string + err = workflow.ExecuteActivity(sessionCtx, DownloadFile, file.URL).Get(sessionCtx, &downloadResult) + if err != nil { + return err + } + + var processResult string + err = workflow.ExecuteActivity(sessionCtx, ProcessFile, downloadResult).Get(sessionCtx, &processResult) + if err != nil { + return err + } + + err = workflow.ExecuteActivity(sessionCtx, UploadFile, processResult).Get(sessionCtx, nil) + return err +} +``` + +Key points: +- `workflow.ErrSessionFailed` is returned if the worker hosting the session dies +- `CompleteSession` releases resources -- always call it (use `defer`) +- Use case: file processing (download, process, upload on same host), GPU workloads, or any pipeline needing local state +- `MaxConcurrentSessionExecutionSize` on `worker.Options` limits how many sessions a single worker can handle + +**Limitations:** +- Sessions do not survive worker process restarts — if the worker dies, the session fails and activities must be retried from the workflow level +- There is no server-side support for sessions — the Go SDK implements them entirely client-side using internal task queue routing +- Session concurrency limiting is per-process, not per-host — only one worker process per host if you rely on this + +**Relationship to worker-specific task queues:** Sessions are essentially a convenience API over the "worker-specific task queue" pattern, where each worker creates a unique task queue and routes activities to it. For simple cases where you don't need separate activities (e.g., download + process + upload can be one unit), consider using a single long-running activity with heartbeating instead. diff --git a/references/go/data-handling.md b/references/go/data-handling.md new file mode 100644 index 0000000..e887e7b --- /dev/null +++ b/references/go/data-handling.md @@ -0,0 +1,262 @@ +# Go SDK Data Handling + +## Overview + +The Go SDK uses the `converter.DataConverter` interface to serialize/deserialize workflow inputs, outputs, and activity parameters. The default converter converts values to JSON. + +## Default Data Converter + +The default `CompositeDataConverter` applies converters in order until one returns a non-nil Payload: + +1. `converter.NewNilPayloadConverter()` -- nil values +2. `converter.NewByteSlicePayloadConverter()` -- `[]byte` +3. `converter.NewProtoJSONPayloadConverter()` -- Protobuf messages as JSON +4. `converter.NewProtoPayloadConverter()` -- Protobuf messages as binary +5. `converter.NewJSONPayloadConverter()` -- anything JSON-serializable + +Structs must have exported fields to be serialized. + +## Custom Data Converter + +In most cases you don't implement the full `DataConverter` interface directly. Instead, implement a **`PayloadConverter`** for your specific type and insert it into a `CompositeDataConverter`. The `PayloadConverter` interface has four methods: + +```go +type PayloadConverter interface { + ToPayload(value interface{}) (*commonpb.Payload, error) // return nil if this type isn't handled + FromPayload(payload *commonpb.Payload, valuePtr interface{}) error + ToString(payload *commonpb.Payload) string + Encoding() string // e.g. "json/msgpack" +} +``` + +**Example — custom msgpack PayloadConverter:** + +```go +import ( + "encoding/json" + "fmt" + + commonpb "go.temporal.io/api/common/v1" + "go.temporal.io/sdk/converter" + "github.com/vmihailenco/msgpack/v5" +) + +const encodingMsgpack = "binary/msgpack" + +type MsgpackPayloadConverter struct{} + +func (c *MsgpackPayloadConverter) Encoding() string { + return encodingMsgpack +} + +func (c *MsgpackPayloadConverter) ToPayload(value interface{}) (*commonpb.Payload, error) { + if value == nil { + return nil, nil + } + data, err := msgpack.Marshal(value) + if err != nil { + return nil, fmt.Errorf("msgpack marshal: %w", err) + } + return &commonpb.Payload{ + Metadata: map[string][]byte{ + converter.MetadataEncoding: []byte(encodingMsgpack), + }, + Data: data, + }, nil +} + +func (c *MsgpackPayloadConverter) FromPayload(payload *commonpb.Payload, valuePtr interface{}) error { + if string(payload.GetMetadata()[converter.MetadataEncoding]) != encodingMsgpack { + return fmt.Errorf("unsupported encoding") + } + return msgpack.Unmarshal(payload.Data, valuePtr) +} + +func (c *MsgpackPayloadConverter) ToString(payload *commonpb.Payload) string { + // Decode to a map for human-readable display + var v interface{} + if err := msgpack.Unmarshal(payload.Data, &v); err != nil { + return fmt.Sprintf("", err) + } + b, _ := json.Marshal(v) + return string(b) +} +``` + +**Register in a CompositeDataConverter and pass to the client:** + +```go +dataConverter := converter.NewCompositeDataConverter( + converter.NewNilPayloadConverter(), + converter.NewByteSlicePayloadConverter(), + &MsgpackPayloadConverter{}, // handles your type; falls through to JSON for everything else + converter.NewJSONPayloadConverter(), +) + +c, err := client.Dial(client.Options{ + DataConverter: dataConverter, +}) +``` + +**Per-activity/child-workflow override** — use a different converter for specific calls: + +```go +actCtx := workflow.WithDataConverter(ctx, mySpecialConverter) +workflow.ExecuteActivity(actCtx, SensitiveActivity, input) +``` + +**Note:** If your converter makes remote calls (e.g., to a KMS for encryption), wrap it with `workflow.DataConverterWithoutDeadlockDetection` to avoid deadlock detection timeouts in workflow code. + +## Composition of Payload Converters + +Use `converter.NewCompositeDataConverter` to chain type-specific converters. The first converter that can handle the type wins. + +```go +dataConverter := converter.NewCompositeDataConverter( + converter.NewNilPayloadConverter(), + converter.NewByteSlicePayloadConverter(), + converter.NewProtoJSONPayloadConverter(), + converter.NewProtoPayloadConverter(), + YourCustomPayloadConverter(), + converter.NewJSONPayloadConverter(), +) +``` + +## Protobuf Support + +Binary protobuf: +```go +converter.NewProtoPayloadConverter() +``` + +JSON protobuf: +```go +converter.NewProtoJSONPayloadConverter() +``` + +Both are included in the default data converter. SDK v1.26.0 (March 2024) migrated from gogo/protobuf to google/protobuf. If you need backward compatibility with older payloads encoded with gogo, use the `LegacyTemporalProtoCompat` option. + +## Payload Encryption + +Implement the `converter.PayloadCodec` interface (`Encode` and `Decode`) and wrap the default data converter: + +```go +// Codec implements converter.PayloadCodec for encryption. +type Codec struct{} + +func (Codec) Encode(payloads []*commonpb.Payload) ([]*commonpb.Payload, error) { + result := make([]*commonpb.Payload, len(payloads)) + for i, p := range payloads { + origBytes, err := p.Marshal() + if err != nil { + return payloads, err + } + encrypted := encrypt(origBytes) // your encryption logic + result[i] = &commonpb.Payload{ + Metadata: map[string][]byte{converter.MetadataEncoding: []byte("binary/encrypted")}, + Data: encrypted, + } + } + return result, nil +} + +func (Codec) Decode(payloads []*commonpb.Payload) ([]*commonpb.Payload, error) { + result := make([]*commonpb.Payload, len(payloads)) + for i, p := range payloads { + if string(p.Metadata[converter.MetadataEncoding]) != "binary/encrypted" { + result[i] = p + continue + } + decrypted := decrypt(p.Data) // your decryption logic + result[i] = &commonpb.Payload{} + err := result[i].Unmarshal(decrypted) + if err != nil { + return payloads, err + } + } + return result, nil +} +``` + +Wrap with `CodecDataConverter` and pass to client: + +```go +var DataConverter = converter.NewCodecDataConverter( + converter.GetDefaultDataConverter(), + &Codec{}, +) + +c, err := client.Dial(client.Options{ + DataConverter: DataConverter, +}) +``` + +## Search Attributes + +Set at workflow start: + +```go +handle, err := c.ExecuteWorkflow(ctx, client.StartWorkflowOptions{ + ID: "order-123", + TaskQueue: "orders", + SearchAttributes: map[string]interface{}{ + "OrderStatus": "pending", + "CustomerId": "cust-456", + }, +}, OrderWorkflow, input) +``` + +Upsert from within a workflow: + +```go +err := workflow.UpsertSearchAttributes(ctx, map[string]interface{}{ + "OrderStatus": "completed", +}) +``` + +Typed search attributes (v1.26.0+, preferred): + +```go +var OrderStatusKey = temporal.NewSearchAttributeKeyKeyword("OrderStatus") + +err := workflow.UpsertTypedSearchAttributes(ctx, OrderStatusKey.ValueSet("completed")) +``` + +Query workflows by search attributes: + +```go +resp, err := c.ListWorkflow(ctx, &workflowservice.ListWorkflowExecutionsRequest{ + Query: `OrderStatus = "pending" AND CustomerId = "cust-456"`, +}) +``` + +## Workflow Memo + +Set in start options: + +```go +handle, err := c.ExecuteWorkflow(ctx, client.StartWorkflowOptions{ + ID: "order-123", + TaskQueue: "orders", + Memo: map[string]interface{}{ + "customerName": "Alice", + "notes": "Priority customer", + }, +}, OrderWorkflow, input) +``` + +Read memo from workflow info. Upsert memo (Go SDK only): + +```go +err := workflow.UpsertMemo(ctx, map[string]interface{}{ + "notes": "Updated notes", +}) +``` + +## Best Practices + +1. Use structs with exported fields for inputs and outputs +2. Prefer JSON for readability during development, protobuf for performance in production +3. Keep payloads small -- see `references/core/gotchas.md` for limits +4. Use `PayloadCodec` for encryption; never store sensitive data unencrypted +5. Configure the same data converter on both client and worker diff --git a/references/go/determinism-protection.md b/references/go/determinism-protection.md new file mode 100644 index 0000000..4a6f5f4 --- /dev/null +++ b/references/go/determinism-protection.md @@ -0,0 +1,98 @@ +# Go Workflow Determinism Protection + +## Overview + +The Go SDK has no runtime sandbox. Determinism is enforced by **developer convention** and **optional static analysis**. Unlike the Python and TypeScript SDKs, the Go SDK will not intercept or replace non-deterministic calls at runtime. The Go SDK does perform a limited runtime command-ordering check, but catching non-deterministic code before deployment requires the `workflowcheck` tool and testing, in particular replay tests (see `references/go/testing`). + +## workflowcheck Static Analysis + +### Install + +```bash +go install go.temporal.io/sdk/contrib/tools/workflowcheck@latest +``` + +### Run + +```bash +workflowcheck ./... +``` + +No output means all registered workflows are deterministic. Non-deterministic code produces hierarchical output showing the call chain to the offending code. + +Use `-show-pos` for exact file positions: + +```bash +workflowcheck -show-pos ./... +``` + +### What It Detects + +**Non-deterministic functions/variables:** +- `time.Now` -- obtaining current time +- `time.Sleep` -- sleeping +- `crypto/rand.Reader` -- crypto random reader +- `math/rand.globalRand` -- global pseudorandom +- `os.Stdin`, `os.Stdout`, `os.Stderr` -- standard I/O streams + +**Non-deterministic Go constructs:** +- Starting a goroutine (`go func()`) +- Sending to a channel +- Receiving from a channel +- Iterating over a channel via `range` +- Iterating over a map via `range` + +### Limitations + +`workflowcheck` cannot catch everything. It does **not** detect: +- Global variable mutation +- Non-determinism via reflection +- Runtime-conditional non-determinism + +### Suppressing False Positives + +Add `//workflowcheck:ignore` on or directly above the offending line: + +```go +now := time.Now() //workflowcheck:ignore +``` + +For broader suppression, use a YAML config file: + +```yaml +# workflowcheck.config.yaml +decls: + path/to/package.MyDeterministicFunc: false +``` + +```bash +workflowcheck -config workflowcheck.config.yaml ./... +``` + +## Determinism Rules + +**You must:** +- Use `workflow.Go(ctx, func(ctx workflow.Context) { ... })` instead of `go` +- Use `workflow.NewChannel(ctx)` instead of `chan` +- Use `workflow.NewSelector(ctx)` instead of `select` +- Use `workflow.Sleep(ctx, duration)` instead of `time.Sleep()` +- Use `workflow.Now(ctx)` instead of `time.Now()` +- Use `workflow.GetLogger(ctx)` instead of `fmt.Println` / `log.Println` +- Sort map keys before iterating, or use `workflow.SideEffect` / an activity + +**You must not:** +- Start native goroutines +- Use native channels or `select` +- Call `time.Now()` or `time.Sleep()` +- Use `math/rand` global functions or `crypto/rand.Reader` +- Access `os.Stdin`, `os.Stdout`, or `os.Stderr` +- Mutate global variables +- Make network calls, file I/O, or database queries (use activities) + +## Best Practices + +1. **Run `workflowcheck` in CI / pre-commit** -- catch non-deterministic code before it reaches production +2. **Keep workflow code thin** -- workflows should orchestrate; delegate all I/O and non-deterministic work to activities +3. **Use struct methods for activities** -- keeps imports clean and avoids pulling non-deterministic dependencies into workflow files +4. **Separate workflow and activity files** -- reduces the surface area that `workflowcheck` needs to analyze and keeps concerns isolated +5. **Test with replay** after any workflow code change to verify backward compatibility diff --git a/references/go/determinism.md b/references/go/determinism.md new file mode 100644 index 0000000..0cff905 --- /dev/null +++ b/references/go/determinism.md @@ -0,0 +1,52 @@ +# Go SDK Determinism + +## Overview + +The Go SDK has NO runtime sandbox (unlike Python/TypeScript). Workflows must be deterministic for replay, and determinism is enforced entirely by developer convention and optional static analysis via the `workflowcheck` tool (see `references/go/determinism-protection.md`). + +## Why Determinism Matters: History Replay + +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 + +Do not use any of the following in workflow code: + +- **Native goroutines** (`go func()`) -- use `workflow.Go()` instead +- **Native channels** (`chan`, send, receive, `range` over channel) -- use `workflow.Channel` instead +- **Native `select`** -- use `workflow.Selector` instead +- **`time.Now()`** -- use `workflow.Now(ctx)` instead +- **`time.Sleep()`** -- use `workflow.Sleep(ctx, duration)` instead +- **`math/rand` global** (e.g., `rand.Intn()`) -- use `workflow.SideEffect` instead +- **`crypto/rand.Reader`** -- use an activity instead +- **`os.Stdin` / `os.Stdout` / `os.Stderr`** -- use `workflow.GetLogger(ctx)` for logging +- **Map range iteration** (`for k, v := range myMap`) -- sort keys first, then iterate +- **Mutating global variables** -- use local state or `workflow.SideEffect` +- **Anonymous functions as local activities** -- the name is derived from the function and will be non-deterministic across replays; always use named functions for local activities + +## Safe Builtin Alternatives + +| Instead of | Use | +|---|---| +| `go func() { ... }()` | `workflow.Go(ctx, func(ctx workflow.Context) { ... })` | +| `chan T` | `workflow.NewChannel(ctx)` / `workflow.NewBufferedChannel(ctx, size)` | +| `select { ... }` | `workflow.NewSelector(ctx)` | +| `time.Now()` | `workflow.Now(ctx)` | +| `time.Sleep(d)` | `workflow.Sleep(ctx, d)` | +| `rand.Intn(100)` | `workflow.SideEffect(ctx, func(ctx workflow.Context) interface{} { return rand.Intn(100) })` | +| `uuid.New()` | `workflow.SideEffect` or pass as activity result | +| `log.Println(...)` | `workflow.GetLogger(ctx).Info(...)` | + +## Testing Replay Compatibility + +Use `worker.WorkflowReplayer` to verify code changes are compatible with existing histories. See the Workflow Replay Testing section of `references/go/testing.md` + +## Best Practices + +1. Run `workflowcheck ./...` in CI to catch non-deterministic code early +2. Always use `workflow.*` APIs instead of native Go concurrency and time primitives +3. Move all I/O operations (network, filesystem, database) into activities +4. Sort map keys before iterating if you must iterate over a map in workflow code +5. Use `workflow.GetLogger(ctx)` instead of `fmt.Println` or `log.Println` 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 diff --git a/references/go/error-handling.md b/references/go/error-handling.md new file mode 100644 index 0000000..92a856b --- /dev/null +++ b/references/go/error-handling.md @@ -0,0 +1,184 @@ +# Go SDK Error Handling + +## Overview + +The Go SDK uses error return values (not exceptions). All Temporal errors implement the `error` interface. Activity errors returned to workflows are wrapped in `*temporal.ActivityError`; use `errors.As` to unwrap them. + +## Application Errors + +```go +import "go.temporal.io/sdk/temporal" + +func ValidateOrder(ctx context.Context, order Order) error { + if !order.IsValid() { + return temporal.NewApplicationError( + "Invalid order", + "ValidationError", + ) + } + return nil +} +``` + +`temporal.NewApplicationError(message, errType, details...)` creates a retryable `*temporal.ApplicationError`. Use `NewApplicationErrorWithCause` to include a wrapped cause. + +## Non-Retryable Errors + +```go +func ChargeCard(ctx context.Context, input ChargeCardInput) (string, error) { + if !isValidCard(input.CardNumber) { + return "", temporal.NewNonRetryableApplicationError( + "Permanent failure - invalid credit card", + "PaymentError", + nil, // cause + ) + } + return processPayment(input.CardNumber, input.Amount) +} +``` + +`temporal.NewNonRetryableApplicationError(message, errType, cause, details...)` is always non-retryable regardless of RetryPolicy. You can also mark error types as non-retryable in the RetryPolicy instead: + +```go +RetryPolicy: &temporal.RetryPolicy{ + NonRetryableErrorTypes: []string{"PaymentError", "ValidationError"}, +}, +``` + +## Handling Activity Errors in Workflows + +```go +import ( + "errors" + + "go.temporal.io/sdk/temporal" + "go.temporal.io/sdk/workflow" +) + +func MyWorkflow(ctx workflow.Context) (string, error) { + var result string + err := workflow.ExecuteActivity(ctx, RiskyActivity).Get(ctx, &result) + if err != nil { + var applicationErr *temporal.ApplicationError + if errors.As(err, &applicationErr) { + switch applicationErr.Type() { + case "ValidationError": + // handle validation error + case "PaymentError": + // handle payment error + default: + // handle unknown error type + } + } + + var timeoutErr *temporal.TimeoutError + if errors.As(err, &timeoutErr) { + switch timeoutErr.TimeoutType() { + case enumspb.TIMEOUT_TYPE_START_TO_CLOSE: + // handle start-to-close timeout + case enumspb.TIMEOUT_TYPE_HEARTBEAT: + // handle heartbeat timeout + } + } + + var canceledErr *temporal.CanceledError + if errors.As(err, &canceledErr) { + // handle cancellation + } + + var panicErr *temporal.PanicError + if errors.As(err, &panicErr) { + // panicErr.Error() and panicErr.StackTrace() + } + + return "", err + } + return result, nil +} +``` + +## Retry Configuration + +```go +import ( + "time" + + "go.temporal.io/sdk/temporal" + "go.temporal.io/sdk/workflow" +) + +func MyWorkflow(ctx workflow.Context) error { + ao := workflow.ActivityOptions{ + StartToCloseTimeout: 10 * time.Minute, + RetryPolicy: &temporal.RetryPolicy{ + InitialInterval: time.Second, + BackoffCoefficient: 2.0, + MaximumInterval: time.Minute, + MaximumAttempts: 5, + NonRetryableErrorTypes: []string{"ValidationError", "PaymentError"}, + }, + } + ctx = workflow.WithActivityOptions(ctx, ao) + return workflow.ExecuteActivity(ctx, MyActivity).Get(ctx, nil) +} +``` + +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 + +```go +ao := workflow.ActivityOptions{ + StartToCloseTimeout: 5 * time.Minute, // Single attempt max duration + ScheduleToCloseTimeout: 30 * time.Minute, // Total time including retries + ScheduleToStartTimeout: 10 * time.Minute, // Time waiting in task queue + HeartbeatTimeout: 2 * time.Minute, // Between heartbeats +} +ctx = workflow.WithActivityOptions(ctx, ao) +``` + +- **StartToCloseTimeout**: Max time for a single Activity Task Execution. Prefer this over ScheduleToCloseTimeout. +- **ScheduleToCloseTimeout**: Total time including retries. +- **ScheduleToStartTimeout**: Time an Activity Task can wait in the Task Queue before a Worker picks it up. Rarely needed. +- **HeartbeatTimeout**: Max time between heartbeats. Required for long-running activities to detect failures. + +Either `StartToCloseTimeout` or `ScheduleToCloseTimeout` must be set. + +## Workflow Failure + +Returning any error from a workflow function fails the execution. Return `nil` for success. + +**Important Go-specific behavior:** In the Go SDK, returning any error from a workflow fails the workflow execution by default — there is no automatic retry. This differs from other SDKs (Python, TypeScript) where non-`ApplicationError` exceptions cause the workflow task to retry indefinitely. In Go, if you want workflow-level retries, you must explicitly set a `RetryPolicy` on the `StartWorkflowOptions`. + +```go +func MyWorkflow(ctx workflow.Context) (string, error) { + if someCondition { + return "", temporal.NewApplicationError( + "Cannot process order", + "BusinessError", + ) + } + return "success", nil +} +``` + +To prevent workflow retry, return a non-retryable error: + +```go +return "", temporal.NewNonRetryableApplicationError( + "Unrecoverable failure", + "FatalError", + nil, +) +``` + +**Note:** If an activity returns a non-retryable error, the workflow receives an `*temporal.ActivityError` wrapping it. To fail the workflow without retry, wrap it in a new `NewNonRetryableApplicationError`. + +## Best Practices + +1. Use specific error types for different failure modes +2. Mark permanent failures as non-retryable +3. Set appropriate timeouts; prefer `StartToCloseTimeout` over `ScheduleToCloseTimeout` +4. Let Temporal handle retries via RetryPolicy rather than implementing retry logic yourself +5. Use `errors.As` to unwrap and inspect specific error types +6. Design activities to be idempotent for safe retries (see `references/core/patterns.md`) diff --git a/references/go/go.md b/references/go/go.md new file mode 100644 index 0000000..cc87a6a --- /dev/null +++ b/references/go/go.md @@ -0,0 +1,242 @@ +# Temporal Go SDK Reference + +## Overview + +The Temporal Go SDK (`go.temporal.io/sdk`) provides a strongly-typed, idiomatic Go approach to building durable workflows. Workflows are regular exported Go functions. The Go SDK does not have an automatic sandbox -- determinism is the developer's responsibility, aided by the `workflowcheck` static analysis tool. + +## Quick Start + +**Add Dependency:** In your Go module, add the Temporal SDK: +```bash +go get go.temporal.io/sdk +``` + +**workflows/greeting.go** - Workflow definition: +```go +package workflows + +import ( + "time" + + "go.temporal.io/sdk/workflow" +) + +func GreetingWorkflow(ctx workflow.Context, name string) (string, error) { + ao := workflow.ActivityOptions{ + StartToCloseTimeout: time.Minute, + } + ctx = workflow.WithActivityOptions(ctx, ao) + + var result string + err := workflow.ExecuteActivity(ctx, "Greet", name).Get(ctx, &result) + if err != nil { + return "", err + } + return result, nil +} +``` + +**activities/greet.go** - Activity definition: +```go +package activities + +import ( + "context" + "fmt" +) + +type Activities struct{} + +func (a *Activities) Greet(ctx context.Context, name string) (string, error) { + return fmt.Sprintf("Hello, %s!", name), nil +} +``` + +**worker/main.go** - Worker setup: +```go +package main + +import ( + "log" + + "yourmodule/activities" + "yourmodule/workflows" + + "go.temporal.io/sdk/client" + "go.temporal.io/sdk/worker" +) + +func main() { + c, err := client.Dial(client.Options{}) + if err != nil { + log.Fatalln("Unable to create client", err) + } + defer c.Close() + + w := worker.New(c, "my-task-queue", worker.Options{}) + + w.RegisterWorkflow(workflows.GreetingWorkflow) + w.RegisterActivity(&activities.Activities{}) + + err = w.Run(worker.InterruptCh()) + if err != nil { + log.Fatalln("Unable to start worker", err) + } +} +``` + +**Start the dev server:** Start `temporal server start-dev` in the background. + +**Start the worker:** Run `go run worker/main.go` in the background. + +**starter/main.go** - Start a workflow execution: +```go +package main + +import ( + "context" + "fmt" + "log" + + "yourmodule/workflows" + + "github.com/google/uuid" + "go.temporal.io/sdk/client" +) + +func main() { + c, err := client.Dial(client.Options{}) + if err != nil { + log.Fatalln("Unable to create client", err) + } + defer c.Close() + + options := client.StartWorkflowOptions{ + ID: uuid.NewString(), + TaskQueue: "my-task-queue", + } + + we, err := c.ExecuteWorkflow(context.Background(), options, workflows.GreetingWorkflow, "my name") + if err != nil { + log.Fatalln("Unable to execute workflow", err) + } + + var result string + err = we.Get(context.Background(), &result) + if err != nil { + log.Fatalln("Unable to get workflow result", err) + } + + fmt.Println("Result:", result) +} +``` + +**Run the workflow:** Run `go run starter/main.go`. Should output: `Result: Hello, my name!`. + +## Key Concepts + +### Workflow Definition +- Exported function with `workflow.Context` as the first parameter +- Returns `(ResultType, error)` or just `error` +- Signature: `func MyWorkflow(ctx workflow.Context, input MyInput) (MyOutput, error)` +- Use `workflow.SetQueryHandler()`, `workflow.SetUpdateHandler()` for handlers +- Register with `w.RegisterWorkflow(MyWorkflow)` + +### Activity Definition +- Regular function or struct methods with `context.Context` as the first parameter +- Struct methods are preferred for dependency injection +- Signature: `func (a *Activities) MyActivity(ctx context.Context, input string) (string, error)` +- Register struct with `w.RegisterActivity(&Activities{})` (registers all exported methods) + +### Worker Setup +- Create client with `client.Dial(client.Options{})` +- Create worker with `worker.New(c, "task-queue", worker.Options{})` +- Register workflows and activities +- Run with `w.Run(worker.InterruptCh())` + +### Determinism + +**Workflow code must be deterministic!** The Go SDK has no sandbox -- determinism is enforced by convention and tooling. + +Use Temporal replacements instead of native Go constructs: +- `workflow.Go()` instead of `go` (goroutines) +- `workflow.Channel` instead of `chan` +- `workflow.Selector` instead of `select` +- `workflow.Sleep()` instead of `time.Sleep()` +- `workflow.Now()` instead of `time.Now()` +- `workflow.GetLogger()` instead of `log` / `fmt.Println` for replay-safe logging + +Use the **`workflowcheck`** static analysis tool to catch non-deterministic code: +```bash +go install go.temporal.io/sdk/contrib/tools/workflowcheck@latest +workflowcheck ./... +``` + +Read `references/core/determinism.md` and `references/go/determinism.md` to understand more. + +## File Organization Best Practice + +**Use separate packages for workflows, activities, and worker.** Activities as struct methods enable dependency injection at the worker level. + +``` +myapp/ +├── workflows/ +│ └── greeting.go # Only Workflow functions +├── activities/ +│ └── greet.go # Activity struct and methods +├── worker/ +│ └── main.go # Worker setup, imports both +└── starter/ + └── main.go # Client code to start workflows +``` + +**Activities as struct methods for dependency injection:** +```go +// activities/greet.go +type Activities struct { + HTTPClient *http.Client + DB *sql.DB +} + +func (a *Activities) FetchData(ctx context.Context, url string) (string, error) { + // Use a.HTTPClient, a.DB, etc. +} +``` + +```go +// worker/main.go - inject dependencies at worker startup +activities := &activities.Activities{ + HTTPClient: http.DefaultClient, + DB: db, +} +w.RegisterActivity(activities) +``` + +## Common Pitfalls + +1. **Using native goroutines/channels/select** - Use `workflow.Go()`, `workflow.Channel`, `workflow.Selector` +2. **Using `time.Sleep` or `time.Now`** - Use `workflow.Sleep()` and `workflow.Now()` +3. **Iterating over maps with `range`** - Map iteration order is non-deterministic; sort keys first +4. **Forgetting to register workflows/activities** - Worker will fail tasks for unregistered types +5. **Registering activity functions instead of struct** - Use `w.RegisterActivity(&Activities{})` not `w.RegisterActivity(a.MyMethod)` +6. **Forgetting to heartbeat** - Long-running activities need `activity.RecordHeartbeat(ctx, details)` +7. **Using `fmt.Println` in workflows** - Use `workflow.GetLogger(ctx)` for replay-safe logging +8. **Not setting Activity timeouts** - `StartToCloseTimeout` or `ScheduleToCloseTimeout` is required in `ActivityOptions` + +## Writing Tests + +See `references/go/testing.md` for info on writing tests. + +## Additional Resources + +### Reference Files +- **`references/go/patterns.md`** - Signals, queries, child workflows, saga pattern, etc. +- **`references/go/determinism.md`** - Determinism rules, workflowcheck tool, safe alternatives +- **`references/go/gotchas.md`** - Go-specific mistakes and anti-patterns +- **`references/go/error-handling.md`** - ApplicationError, retry policies, non-retryable errors +- **`references/go/observability.md`** - Logging, metrics, tracing, Search Attributes +- **`references/go/testing.md`** - TestWorkflowEnvironment, time-skipping, activity mocking +- **`references/go/advanced-features.md`** - Schedules, worker tuning, and more +- **`references/go/data-handling.md`** - Data converters, payload codecs, encryption +- **`references/go/versioning.md`** - Patching API (`workflow.GetVersion`), Worker Versioning +- **`references/python/determinism-protection.md`** - Information on **`workflowcheck`** tool to help statically check for determinism issues. diff --git a/references/go/gotchas.md b/references/go/gotchas.md new file mode 100644 index 0000000..4b7ddf3 --- /dev/null +++ b/references/go/gotchas.md @@ -0,0 +1,290 @@ +# Go Gotchas + +Go-specific mistakes and anti-patterns. See also [Common Gotchas](references/core/gotchas.md) for language-agnostic concepts. + +## Goroutines and Concurrency + +### Using Native Go Concurrency Primitives + +**The Problem**: Native `go`, `chan`, and `select` are non-deterministic and will cause replay failures. + +```go +// BAD - Native goroutine +func MyWorkflow(ctx workflow.Context) error { + go func() { // Non-deterministic! + // do work + }() + return nil +} + +// GOOD - Use workflow.Go +func MyWorkflow(ctx workflow.Context) error { + workflow.Go(ctx, func(gCtx workflow.Context) { + // do work + }) + return nil +} +``` + +```go +// BAD - Native channel +func MyWorkflow(ctx workflow.Context) error { + ch := make(chan string) // Non-deterministic! + return nil +} + +// GOOD - Use workflow.Channel +func MyWorkflow(ctx workflow.Context) error { + ch := workflow.NewChannel(ctx) + return nil +} +``` + +```go +// BAD - Native select +select { +case val := <-ch1: + // handle +case val := <-ch2: + // handle +} + +// GOOD - Use workflow.Selector +selector := workflow.NewSelector(ctx) +selector.AddReceive(ch1, func(c workflow.ReceiveChannel, more bool) { + var val string + c.Receive(ctx, &val) + // handle +}) +selector.AddReceive(ch2, func(c workflow.ReceiveChannel, more bool) { + var val string + c.Receive(ctx, &val) + // handle +}) +selector.Select(ctx) +``` + +## Non-Deterministic Operations + +### Map Iteration + +```go +// BAD - Map range order is randomized +for k, v := range myMap { + // Non-deterministic order! +} + +// GOOD - Sort keys first +keys := make([]string, 0, len(myMap)) +for k := range myMap { + keys = append(keys, k) +} +sort.Strings(keys) +for _, k := range keys { + v := myMap[k] + // Deterministic order +} +``` + +### Time and Randomness + +```go +// BAD +t := time.Now() // System clock, non-deterministic +time.Sleep(time.Second) // Not replay-safe +r := rand.Intn(100) // Non-deterministic + +// GOOD +t := workflow.Now(ctx) // Deterministic +workflow.Sleep(ctx, time.Second) // Durable timer +encoded := workflow.SideEffect(ctx, func(ctx workflow.Context) interface{} { + return rand.Intn(100) +}) +var r int +encoded.Get(&r) +``` + +Use the `workflowcheck` static analysis tool to catch non-deterministic calls. For false positives, annotate with `//workflowcheck:ignore` on the line above. + +### Anonymous Functions as Local Activities + +**The Problem**: The Go SDK derives the local activity name from the function. Anonymous functions get a non-deterministic name that can change across builds, causing replay failures. + +```go +// BAD - anonymous function: name is non-deterministic +workflow.ExecuteLocalActivity(ctx, func(ctx context.Context) (string, error) { + return "result", nil +}) + +// GOOD - named function: stable, deterministic name +func QuickLookup(ctx context.Context) (string, error) { + return "result", nil +} + +workflow.ExecuteLocalActivity(ctx, QuickLookup) +``` + +Always use named functions for local activities (and regular activities). + +## Wrong Retry Classification + +**Example:** Transient network errors should be retried. Authentication errors should not be. +See `references/go/error-handling.md` for detailed guidance on error classification and retry policies. + +## Heartbeating + +### Forgetting to Heartbeat Long Activities + +```go +// BAD - No heartbeat, can't detect stuck activities or receive cancellation +func ProcessLargeFile(ctx context.Context, path string) error { + for _, chunk := range readChunks(path) { + process(chunk) // Takes hours, no heartbeat + } + return nil +} + +// GOOD - Regular heartbeats with progress +func ProcessLargeFile(ctx context.Context, path string) error { + for i, chunk := range readChunks(path) { + activity.RecordHeartbeat(ctx, fmt.Sprintf("Processing chunk %d", i)) + process(chunk) + } + return nil +} +``` + +### Heartbeat Timeout Too Short + +```go +// BAD - Heartbeat timeout shorter than processing time +ao := workflow.ActivityOptions{ + StartToCloseTimeout: 30 * time.Minute, + HeartbeatTimeout: 10 * time.Second, // Too short! +} + +// GOOD - Heartbeat timeout allows for processing variance +ao := workflow.ActivityOptions{ + StartToCloseTimeout: 30 * time.Minute, + HeartbeatTimeout: 2 * time.Minute, +} +``` + +Set heartbeat timeout as high as acceptable for your use case -- each heartbeat counts as an action. + +## Cancellation + +### Not Handling Workflow Cancellation + +```go +// BAD - Cleanup doesn't run on cancellation +func BadWorkflow(ctx workflow.Context) error { + _ = workflow.ExecuteActivity(ctx, AcquireResource).Get(ctx, nil) + _ = workflow.ExecuteActivity(ctx, DoWork).Get(ctx, nil) + _ = workflow.ExecuteActivity(ctx, ReleaseResource).Get(ctx, nil) // Never runs if cancelled! + return nil +} + +// GOOD - Use defer with NewDisconnectedContext for cleanup +func GoodWorkflow(ctx workflow.Context) error { + defer func() { + if !errors.Is(ctx.Err(), workflow.ErrCanceled) { + return + } + newCtx, _ := workflow.NewDisconnectedContext(ctx) + _ = workflow.ExecuteActivity(newCtx, ReleaseResource).Get(newCtx, nil) + }() + + err := workflow.ExecuteActivity(ctx, AcquireResource).Get(ctx, nil) + if err != nil { + return err + } + return workflow.ExecuteActivity(ctx, DoWork).Get(ctx, nil) +} +``` + +### Not Handling Activity Cancellation + +Activities must **opt in** to receive cancellation. This requires: +1. **Heartbeating** - Cancellation is delivered via heartbeat +2. **Checking ctx.Done()** - Detect when cancellation arrives + +```go +// BAD - Activity ignores cancellation +func LongActivity(ctx context.Context) error { + doExpensiveWork() // Runs to completion even if cancelled + return nil +} + +// GOOD - Heartbeat and check ctx.Done() +func LongActivity(ctx context.Context) error { + for i, item := range items { + select { + case <-ctx.Done(): + cleanup() + return ctx.Err() + default: + activity.RecordHeartbeat(ctx, fmt.Sprintf("Processing item %d", i)) + process(item) + } + } + return nil +} +``` + +## 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/go/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, and should be considered in addition to standard testing. Please see `references/go/testing.md` for more info. + +## Timers and Sleep + +### Using time.Sleep Instead of workflow.Sleep + +```go +// BAD: time.Sleep is not deterministic during replay +func BadWorkflow(ctx workflow.Context) error { + time.Sleep(60 * time.Second) // Non-deterministic! + return nil +} + +// GOOD: Use workflow.Sleep for deterministic timers +func GoodWorkflow(ctx workflow.Context) error { + workflow.Sleep(ctx, 60*time.Second) // Deterministic + return nil +} +``` + +### Using time.After Instead of workflow.NewTimer + +```go +// BAD: time.After is not replay-safe +func BadWorkflow(ctx workflow.Context) error { + <-time.After(5 * time.Minute) // Non-deterministic! + return nil +} + +// GOOD: Use workflow.NewTimer for durable timers +func GoodWorkflow(ctx workflow.Context) error { + timer := workflow.NewTimer(ctx, 5*time.Minute) + _ = timer.Get(ctx, nil) // Deterministic, durable + return nil +} +``` + +### Using time.Now() Instead of workflow.Now() + +```go +// BAD: time.Now() differs between execution and replay +deadline := time.Now().Add(24 * time.Hour) + +// GOOD: workflow.Now() is replay-safe +deadline := workflow.Now(ctx).Add(24 * time.Hour) +``` + +**Why this matters:** `time.Now()`, `time.Sleep()`, and `time.After()` use the system clock, which differs between original execution and replay. The `workflow.*` equivalents create durable, deterministic entries in the event history. diff --git a/references/go/observability.md b/references/go/observability.md new file mode 100644 index 0000000..ba55140 --- /dev/null +++ b/references/go/observability.md @@ -0,0 +1,153 @@ +# Go SDK Observability + +## Overview + +The Go SDK provides replay-safe logging via `workflow.GetLogger`, metrics via the Tally library with Prometheus export, and tracing via OpenTelemetry, OpenTracing, or Datadog. + +## Logging / Replay-Aware Logging + +### Workflow Logging + +Use `workflow.GetLogger(ctx)` for replay-safe logging. This logger automatically suppresses duplicate messages during replay. + +```go +func MyWorkflow(ctx workflow.Context, input string) (string, error) { + logger := workflow.GetLogger(ctx) + logger.Info("Workflow started", "input", input) + + var result string + err := workflow.ExecuteActivity(ctx, MyActivity, input).Get(ctx, &result) + if err != nil { + logger.Error("Activity failed", "error", err) + return "", err + } + + logger.Info("Workflow completed", "result", result) + return result, nil +} +``` + +The workflow logger automatically: +- Suppresses duplicate logs during replay +- Includes workflow context (workflow ID, run ID, etc.) + +### Activity Logging + +Use `activity.GetLogger(ctx)` for context-aware activity logging: + +```go +func MyActivity(ctx context.Context, input string) (string, error) { + logger := activity.GetLogger(ctx) + logger.Info("Processing input", "input", input) + // ... + return "done", nil +} +``` + +Activity logger includes: +- Activity ID, type, and task queue +- Workflow ID and run ID +- Attempt number (for retries) + +### Adding Persistent Fields + +Use `log.With` to create a logger with key-value pairs included in every entry: + +```go +logger := log.With(workflow.GetLogger(ctx), "orderId", orderId, "customerId", customerId) +logger.Info("Processing order") // includes orderId and customerId +``` + +## Customizing the Logger + +Set a custom logger via `client.Options{Logger: myLogger}`. Implement the `log.Logger` interface (Debug, Info, Warn, Error methods). + +### Using slog (Go 1.21+) + +```go +import ( + "log/slog" + "os" + + tlog "go.temporal.io/sdk/log" +) + +slogHandler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}) +logger := tlog.NewStructuredLogger(slog.New(slogHandler)) + +c, err := client.Dial(client.Options{ + Logger: logger, +}) +``` + +### Using Third-Party Loggers (Logrus, Zap, etc.) + +Use the [logur](https://github.com/logur/logur) adapter package: + +```go +import ( + "github.com/sirupsen/logrus" + logrusadapter "logur.dev/adapter/logrus" + "logur.dev/logur" +) + +logger := logur.LoggerToKV(logrusadapter.New(logrus.New())) +c, err := client.Dial(client.Options{ + Logger: logger, +}) +``` + +## Metrics + +Use the Tally library (`go.temporal.io/sdk/contrib/tally`) with Prometheus: + +```go +import ( + sdktally "go.temporal.io/sdk/contrib/tally" + "github.com/uber-go/tally/v4" + "github.com/uber-go/tally/v4/prometheus" +) + +func newPrometheusScope(c prometheus.Configuration) tally.Scope { + reporter, err := c.NewReporter( + prometheus.ConfigurationOptions{}, + ) + if err != nil { + log.Fatalln("error creating prometheus reporter", err) + } + scopeOpts := tally.ScopeOptions{ + CacheReporter: reporter, + Separator: "_", + SanitizeOptions: &sdktally.PrometheusSanitizeOptions, + } + scope, _ := tally.NewRootScope(scopeOpts, time.Second) + scope = sdktally.NewPrometheusNamingScope(scope) + return scope +} + +c, err := client.Dial(client.Options{ + MetricsHandler: sdktally.NewMetricsHandler(newPrometheusScope(prometheus.Configuration{ + ListenAddress: "0.0.0.0:9090", + TimerType: "histogram", + })), +}) +``` + +Key SDK metrics: +- `temporal_workflow_task_execution_latency` -- Workflow task processing time +- `temporal_activity_execution_latency` -- Activity execution time +- `temporal_workflow_task_replay_latency` -- Replay duration +- `temporal_request` -- Client requests to server +- `temporal_activity_schedule_to_start_latency` -- Time from scheduling to start + +## Search Attributes (Visibility) + +See the Search Attributes section of `references/go/data-handling.md` + +## Best Practices + +1. Always use `workflow.GetLogger(ctx)` in workflows -- never `fmt.Println` or `log.Println` (they produce duplicates on replay) +2. Use `activity.GetLogger(ctx)` in activities for structured context +3. Set up Prometheus metrics in production +4. Use search attributes for operational visibility and debugging +5. Use `workflow.IsReplaying(ctx)` only for custom side-effect-free logging -- the built-in logger handles replay suppression automatically diff --git a/references/go/patterns.md b/references/go/patterns.md new file mode 100644 index 0000000..732083f --- /dev/null +++ b/references/go/patterns.md @@ -0,0 +1,536 @@ +# Go SDK Patterns + +## Signals + +In Go, signals are received via channels, not handler functions. + +```go +func OrderWorkflow(ctx workflow.Context) (string, error) { + approved := false + var items []string + + approveCh := workflow.GetSignalChannel(ctx, "approve") + addItemCh := workflow.GetSignalChannel(ctx, "add-item") + + // Listen for signals in a goroutine so workflow can proceed + workflow.Go(ctx, func(ctx workflow.Context) { + for { + selector := workflow.NewSelector(ctx) + selector.AddReceive(approveCh, func(c workflow.ReceiveChannel, more bool) { + c.Receive(ctx, &approved) + }) + selector.AddReceive(addItemCh, func(c workflow.ReceiveChannel, more bool) { + var item string + c.Receive(ctx, &item) + items = append(items, item) + }) + selector.Select(ctx) + } + }) + + // Wait for approval + workflow.Await(ctx, func() bool { return approved }) + return fmt.Sprintf("Processed %d items", len(items)), nil +} +``` + +### Blocking receive from a single channel + +When waiting on a single signal, no Selector is needed: + +```go +var approveInput ApproveInput +workflow.GetSignalChannel(ctx, "approve").Receive(ctx, &approveInput) +``` + +## Queries + +**Important:** Queries must NOT modify workflow state. Query handlers run outside workflow context -- do not call `workflow.Go()`, `workflow.NewChannel()`, or any blocking workflow functions. + +```go +func StatusWorkflow(ctx workflow.Context) error { + currentState := "started" + progress := 0 + + err := workflow.SetQueryHandler(ctx, "get-status", func() (string, error) { + return currentState, nil + }) + if err != nil { + return err + } + + err = workflow.SetQueryHandler(ctx, "get-progress", func() (int, error) { + return progress, nil + }) + if err != nil { + return err + } + + // Workflow logic updates currentState and progress as it runs + currentState = "running" + for i := 0; i < 100; i++ { + progress = i + err := workflow.ExecuteActivity( + workflow.WithActivityOptions(ctx, workflow.ActivityOptions{ + StartToCloseTimeout: time.Minute, + }), + ProcessItem, i, + ).Get(ctx, nil) + if err != nil { + currentState = "failed" + return err + } + } + currentState = "done" + return nil +} +``` + +## Updates + +```go +func OrderWorkflow(ctx workflow.Context) (int, error) { + var items []string + + err := workflow.SetUpdateHandlerWithOptions( + ctx, + "add-item", + func(ctx workflow.Context, item string) (int, error) { + // Handler can mutate workflow state and return a value + items = append(items, item) + return len(items), nil + }, + workflow.UpdateHandlerOptions{ + Validator: func(ctx workflow.Context, item string) error { + if item == "" { + return fmt.Errorf("item cannot be empty") + } + if len(items) >= 100 { + return fmt.Errorf("order is full") + } + return nil + }, + }, + ) + if err != nil { + return 0, err + } + + // Block until cancelled + _ = ctx.Done().Receive(ctx, nil) + return len(items), nil +} +``` + +**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. Return an error to reject the update; return `nil` to accept. + +## Child Workflows + +```go +func ParentWorkflow(ctx workflow.Context, orders []Order) ([]string, error) { + cwo := workflow.ChildWorkflowOptions{ + WorkflowExecutionTimeout: 30 * time.Minute, + } + ctx = workflow.WithChildOptions(ctx, cwo) + + var results []string + for _, order := range orders { + var result string + err := workflow.ExecuteChildWorkflow(ctx, ProcessOrderWorkflow, order).Get(ctx, &result) + if err != nil { + return nil, err + } + results = append(results, result) + } + return results, nil +} +``` + +### Child Workflow Options + +```go +import enumspb "go.temporal.io/api/enums/v1" + +cwo := workflow.ChildWorkflowOptions{ + WorkflowID: fmt.Sprintf("child-%s", workflow.GetInfo(ctx).WorkflowExecution.ID), + + // ParentClosePolicy - what happens to child when parent closes + // PARENT_CLOSE_POLICY_TERMINATE (default), PARENT_CLOSE_POLICY_ABANDON, PARENT_CLOSE_POLICY_REQUEST_CANCEL + ParentClosePolicy: enumspb.PARENT_CLOSE_POLICY_ABANDON, + + WorkflowExecutionTimeout: 10 * time.Minute, + WorkflowTaskTimeout: time.Minute, +} +ctx = workflow.WithChildOptions(ctx, cwo) + +future := workflow.ExecuteChildWorkflow(ctx, ChildWorkflow, input) + +// Wait for child to start (important for ABANDON policy) +if err := future.GetChildWorkflowExecution().Get(ctx, nil); err != nil { + return err +} +``` + +## Handles to External Workflows + +```go +func CoordinatorWorkflow(ctx workflow.Context, targetWorkflowID string) error { + // Signal an external workflow + err := workflow.SignalExternalWorkflow(ctx, targetWorkflowID, "", "data-ready", payload).Get(ctx, nil) + if err != nil { + return err + } + + // Cancel an external workflow + err = workflow.RequestCancelExternalWorkflow(ctx, targetWorkflowID, "").Get(ctx, nil) + return err +} +``` + +## Parallel Execution + +Use `workflow.Go` to launch parallel work and `workflow.Selector` to collect results. + +```go +func ParallelWorkflow(ctx workflow.Context, items []string) ([]string, error) { + actCtx := workflow.WithActivityOptions(ctx, workflow.ActivityOptions{ + StartToCloseTimeout: 5 * time.Minute, + }) + + // Launch activities in parallel + futures := make([]workflow.Future, len(items)) + for i, item := range items { + futures[i] = workflow.ExecuteActivity(actCtx, ProcessItem, item) + } + + // Collect all results + results := make([]string, len(items)) + for i, future := range futures { + if err := future.Get(ctx, &results[i]); err != nil { + return nil, err + } + } + return results, nil +} +``` + +### Using workflow.Go for background goroutines + +```go +ch := workflow.NewChannel(ctx) + +workflow.Go(ctx, func(ctx workflow.Context) { + // Background work + var result string + _ = workflow.ExecuteActivity(actCtx, SomeActivity).Get(ctx, &result) + ch.Send(ctx, result) +}) + +var result string +ch.Receive(ctx, &result) +``` + +## Selector Pattern + +`workflow.Selector` replaces Go's native `select` -- required for deterministic workflow execution. Use it to wait on multiple channels, futures, and timers simultaneously. + +```go +func ApprovalWorkflow(ctx workflow.Context) (string, error) { + actCtx := workflow.WithActivityOptions(ctx, workflow.ActivityOptions{ + StartToCloseTimeout: 5 * time.Minute, + }) + + var outcome string + signalCh := workflow.GetSignalChannel(ctx, "approve") + actFuture := workflow.ExecuteActivity(actCtx, AutoReviewActivity) + + // Cancel timer if signal or activity wins + timerCtx, cancelTimer := workflow.WithCancel(ctx) + timer := workflow.NewTimer(timerCtx, 24*time.Hour) + + selector := workflow.NewSelector(ctx) + + // Branch 1: Signal received + selector.AddReceive(signalCh, func(c workflow.ReceiveChannel, more bool) { + var approved bool + c.Receive(ctx, &approved) + cancelTimer() + if approved { + outcome = "approved-by-signal" + } else { + outcome = "rejected-by-signal" + } + }) + + // Branch 2: Activity completed + selector.AddFuture(actFuture, func(f workflow.Future) { + var result string + _ = f.Get(ctx, &result) + cancelTimer() + outcome = result + }) + + // Branch 3: Timeout + selector.AddFuture(timer, func(f workflow.Future) { + if err := f.Get(ctx, nil); err == nil { + outcome = "timed-out" + } + // If timer was cancelled, err is CanceledError -- ignore + }) + + selector.Select(ctx) // Blocks until one branch fires + return outcome, nil +} +``` + +Key points: +- `AddReceive(channel, callback)` -- fires when a channel has a message (must consume with `c.Receive`) +- `AddFuture(future, callback)` -- fires when a future resolves (once per Selector) +- `AddDefault(callback)` -- fires immediately if nothing else is ready +- `Select(ctx)` -- blocks until one branch fires; call multiple times to process multiple events + +## Continue-as-New + +```go +func LongRunningWorkflow(ctx workflow.Context, state WorkflowState) (string, error) { + for { + state = processBatch(ctx, state) + + if state.IsComplete { + return "done", nil + } + + // Check if history is getting large + if workflow.GetInfo(ctx).GetContinueAsNewSuggested() { + return "", workflow.NewContinueAsNewError(ctx, LongRunningWorkflow, state) + } + } +} +``` + +Drain signals before continue-as-new to avoid signal loss: + +```go +for { + var signalVal string + ok := signalChan.ReceiveAsync(&signalVal) + if !ok { + break + } + // process signal +} +return "", workflow.NewContinueAsNewError(ctx, LongRunningWorkflow, state) +``` + +## Cancellation Handling + +Use `ctx.Done()` to detect cancellation and `workflow.NewDisconnectedContext` for cleanup that must run even after cancellation. + +```go +func MyWorkflow(ctx workflow.Context) error { + actCtx := workflow.WithActivityOptions(ctx, workflow.ActivityOptions{ + StartToCloseTimeout: time.Hour, + }) + + err := workflow.ExecuteActivity(actCtx, LongRunningActivity).Get(ctx, nil) + if err != nil && temporal.IsCanceledError(ctx.Err()) { + // Workflow was cancelled -- run cleanup with a disconnected context + workflow.GetLogger(ctx).Info("Workflow cancelled, running cleanup") + disconnectedCtx, _ := workflow.NewDisconnectedContext(ctx) + disconnectedCtx = workflow.WithActivityOptions(disconnectedCtx, workflow.ActivityOptions{ + StartToCloseTimeout: 5 * time.Minute, + }) + _ = workflow.ExecuteActivity(disconnectedCtx, CleanupActivity).Get(disconnectedCtx, nil) + return err // Return CanceledError + } + return err +} +``` + +## Saga Pattern (Compensations) + +**Important:** Compensation activities should be idempotent -- they may be retried (as with ALL activities). + +Use `workflow.NewDisconnectedContext` when running compensations so they execute even if the workflow is cancelled. + +```go +func OrderWorkflow(ctx workflow.Context, order Order) (string, error) { + actCtx := workflow.WithActivityOptions(ctx, workflow.ActivityOptions{ + StartToCloseTimeout: 5 * time.Minute, + }) + + var compensations []func(ctx workflow.Context) error + + // Helper to run all compensations in reverse, using a disconnected context + // so compensations run even if the workflow is cancelled. + runCompensations := func() { + disconnectedCtx, _ := workflow.NewDisconnectedContext(ctx) + compCtx := workflow.WithActivityOptions(disconnectedCtx, workflow.ActivityOptions{ + StartToCloseTimeout: 5 * time.Minute, + }) + for i := len(compensations) - 1; i >= 0; i-- { + if err := compensations[i](compCtx); err != nil { + workflow.GetLogger(ctx).Error("Compensation failed", "error", err) + } + } + } + + // Register compensation BEFORE running the activity. + // If the activity completes the effect but fails on return, + // we still need the compensation. + compensations = append(compensations, func(ctx workflow.Context) error { + return workflow.ExecuteActivity(ctx, ReleaseInventoryIfReserved, order).Get(ctx, nil) + }) + if err := workflow.ExecuteActivity(actCtx, ReserveInventory, order).Get(ctx, nil); err != nil { + runCompensations() + return "", err + } + + compensations = append(compensations, func(ctx workflow.Context) error { + return workflow.ExecuteActivity(ctx, RefundPaymentIfCharged, order).Get(ctx, nil) + }) + if err := workflow.ExecuteActivity(actCtx, ChargePayment, order).Get(ctx, nil); err != nil { + runCompensations() + return "", err + } + + if err := workflow.ExecuteActivity(actCtx, ShipOrder, order).Get(ctx, nil); err != nil { + runCompensations() + return "", err + } + + return "Order completed", nil +} +``` + +## Wait Condition with Timeout + +```go +func ApprovalWorkflow(ctx workflow.Context) (string, error) { + approved := false + + // Set up signal handler + workflow.Go(ctx, func(ctx workflow.Context) { + workflow.GetSignalChannel(ctx, "approve").Receive(ctx, &approved) + }) + + // Wait with 24-hour timeout -- returns (conditionMet, error) + conditionMet, err := workflow.AwaitWithTimeout(ctx, 24*time.Hour, func() bool { + return approved + }) + if err != nil { + return "", err + } + + if conditionMet { + return "approved", nil + } + return "auto-rejected due to timeout", nil +} +``` + +Without timeout: + +```go +err := workflow.Await(ctx, func() bool { return ready }) +``` + +## Waiting for All Handlers to Finish + +Signal and update handlers may run activities asynchronously. Use `workflow.Await` with `workflow.AllHandlersFinished` before completing or continuing-as-new to prevent the workflow from closing while handlers are still running. + +```go +func MyWorkflow(ctx workflow.Context) (string, error) { + // ... register handlers, main workflow logic ... + + // Before exiting, wait for all handlers to finish + err := workflow.Await(ctx, func() bool { + return workflow.AllHandlersFinished(ctx) + }) + if err != nil { + return "", err + } + return "done", nil +} +``` + +## 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 + +```go +func ProcessLargeFile(ctx context.Context, filePath string) (string, error) { + // Recover from previous attempt + startIdx := 0 + if activity.HasHeartbeatDetails(ctx) { + if err := activity.GetHeartbeatDetails(ctx, &startIdx); err == nil { + startIdx++ // Resume from next item + } + } + + lines := readFileLines(filePath) + + for i := startIdx; i < len(lines); i++ { + processLine(lines[i]) + + // Heartbeat with progress -- if cancelled, ctx will be cancelled + activity.RecordHeartbeat(ctx, i) + + if ctx.Err() != nil { + // Activity was cancelled + cleanup() + return "", ctx.Err() + } + } + + return "completed", nil +} +``` + +## Timers + +```go +func TimerWorkflow(ctx workflow.Context) (string, error) { + // Simple sleep + err := workflow.Sleep(ctx, time.Hour) + if err != nil { + return "", err + } + + // Timer as a Future -- for use with Selector + timerCtx, cancelTimer := workflow.WithCancel(ctx) + timer := workflow.NewTimer(timerCtx, 30*time.Minute) + + // Cancel the timer when no longer needed + cancelTimer() + + return "Timer fired", nil +} +``` + +## 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. + +```go +func MyWorkflow(ctx workflow.Context) (string, error) { + lao := workflow.LocalActivityOptions{ + StartToCloseTimeout: 5 * time.Second, + } + ctx = workflow.WithLocalActivityOptions(ctx, lao) + + var result string + err := workflow.ExecuteLocalActivity(ctx, QuickLookup, "key").Get(ctx, &result) + if err != nil { + return "", err + } + return result, nil +} +``` diff --git a/references/go/testing.md b/references/go/testing.md new file mode 100644 index 0000000..ab74bbd --- /dev/null +++ b/references/go/testing.md @@ -0,0 +1,238 @@ +# Go SDK Testing + +## Overview + +The Go SDK provides the `testsuite` package for testing Workflows and Activities. It uses the [testify](https://github.com/stretchr/testify) library for assertions (`assert`/`require`) and mocking (`mock`). The test environment supports automatic time-skipping for Workflows with timers. + +## Test Environment Setup + +Two approaches: struct-based with `suite.Suite` or function-based with `testsuite.NewTestWorkflowEnvironment()`. + +**Approach 1: Struct-based (testify suite)** + +```go +package sample + +import ( + "testing" + + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/suite" + + "go.temporal.io/sdk/testsuite" +) + +type UnitTestSuite struct { + suite.Suite + testsuite.WorkflowTestSuite + + env *testsuite.TestWorkflowEnvironment +} + +func (s *UnitTestSuite) SetupTest() { + s.env = s.NewTestWorkflowEnvironment() +} + +func (s *UnitTestSuite) AfterTest(suiteName, testName string) { + s.env.AssertExpectations(s.T()) +} + +func (s *UnitTestSuite) Test_MyWorkflow_Success() { + s.env.ExecuteWorkflow(MyWorkflow, "input") + + s.True(s.env.IsWorkflowCompleted()) + s.NoError(s.env.GetWorkflowError()) +} + +func TestUnitTestSuite(t *testing.T) { + suite.Run(t, new(UnitTestSuite)) +} +``` + +**Approach 2: Function-based** + +```go +package sample + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "go.temporal.io/sdk/testsuite" +) + +func Test_MyWorkflow(t *testing.T) { + testSuite := &testsuite.WorkflowTestSuite{} + env := testSuite.NewTestWorkflowEnvironment() + env.RegisterActivity(MyActivity) + + env.ExecuteWorkflow(MyWorkflow, "input") + assert.True(t, env.IsWorkflowCompleted()) + assert.NoError(t, env.GetWorkflowError()) + + var result string + assert.NoError(t, env.GetWorkflowResult(&result)) + assert.Equal(t, "expected", result) +} +``` + +You must register all Activity Definitions used by the Workflow with `env.RegisterActivity(ActivityFunc)`. The Workflow itself does not need to be registered. + +## Activity Mocking + +Mock activities with `env.OnActivity()` to test Workflow logic in isolation. + +**Return mock values:** + +```go +env.OnActivity(MyActivity, mock.Anything, mock.Anything).Return("mock_result", nil) +``` + +**Return a function replacement** (for parameter validation or custom logic): + +```go +env.OnActivity(MyActivity, mock.Anything, mock.Anything).Return( + func(ctx context.Context, input string) (string, error) { + // Custom logic, assertions, etc. + return "computed_result", nil + }, +) +``` + +**Match specific arguments:** + +```go +env.OnActivity(MyActivity, mock.Anything, "specific_input").Return("result", nil) +``` + +When using mocks, you do not need to call `env.RegisterActivity()` for that Activity. The mock signature must match the original Activity function signature. + +## Testing Signals and Queries + +Use `RegisterDelayedCallback` to send Signals during Workflow execution. Use `QueryWorkflow` to test query handlers. + +```go +func (s *UnitTestSuite) Test_SignalsAndQueries() { + // Register a delayed callback to send a signal after 5 seconds + s.env.RegisterDelayedCallback(func() { + s.env.SignalWorkflow("approve", SignalData{Approved: true}) + }, time.Second*5) + + s.env.ExecuteWorkflow(ApprovalWorkflow, input) + + s.True(s.env.IsWorkflowCompleted()) + s.NoError(s.env.GetWorkflowError()) +} +``` + +**Query a running Workflow** (must be called inside `RegisterDelayedCallback` or after `ExecuteWorkflow`): + +```go +s.env.RegisterDelayedCallback(func() { + res, err := s.env.QueryWorkflow("getProgress") + s.NoError(err) + + var progress int + err = res.Get(&progress) + s.NoError(err) + s.Equal(50, progress) +}, time.Second*10+time.Millisecond) +``` + +`QueryWorkflow` returns a `converter.EncodedValue`. Use `.Get(&result)` to decode the value. + +For "Signal-With-Start" testing, set the delay to `0`. + +## Testing Failure Cases + +```go +func (s *UnitTestSuite) Test_WorkflowFailure() { + // Mock activity to return an error + s.env.OnActivity(MyActivity, mock.Anything, mock.Anything).Return( + "", errors.New("activity failed")) + + s.env.ExecuteWorkflow(MyWorkflow, "input") + + s.True(s.env.IsWorkflowCompleted()) + + err := s.env.GetWorkflowError() + s.Error(err) + + var applicationErr *temporal.ApplicationError + s.True(errors.As(err, &applicationErr)) + s.Equal("activity failed", applicationErr.Error()) +} +``` + +`env.GetWorkflowError()` returns the Workflow error. Use `errors.As(err, &applicationErr)` to check the error type. Mock activities returning errors to test Workflow error-handling paths. + +## Replay Testing + +Use `worker.NewWorkflowReplayer()` to verify that code changes do not break determinism. Load history from a JSON file exported via the Temporal CLI or Web UI. + +```go +package sample + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "go.temporal.io/sdk/worker" +) + +func Test_ReplayFromFile(t *testing.T) { + replayer := worker.NewWorkflowReplayer() + replayer.RegisterWorkflow(MyWorkflow) + + err := replayer.ReplayWorkflowHistoryFromJSONFile(nil, "my_workflow_history.json") + assert.NoError(t, err) +} +``` + +Export history via CLI: `temporal workflow show --workflow-id --output json > history.json` + +**Replay from a programmatically fetched history:** + +```go +func Test_ReplayFromServer(t *testing.T) { + // Fetch history from the server + hist, err := GetWorkflowHistory(ctx, client, workflowID, runID) + assert.NoError(t, err) + + replayer := worker.NewWorkflowReplayer() + replayer.RegisterWorkflow(MyWorkflow) + + err = replayer.ReplayWorkflowHistory(nil, hist) + assert.NoError(t, err) +} +``` + +## Activity Testing + +Test Activities in isolation using `TestActivityEnvironment`. No Worker or Workflow needed. + +```go +func Test_MyActivity(t *testing.T) { + testSuite := &testsuite.WorkflowTestSuite{} + env := testSuite.NewTestActivityEnvironment() + env.RegisterActivity(MyActivity) + + val, err := env.ExecuteActivity(MyActivity, "input") + assert.NoError(t, err) + + var result string + assert.NoError(t, val.Get(&result)) + assert.Equal(t, "expected_output", result) +} +``` + +`ExecuteActivity` returns `(converter.EncodedValue, error)`. Use `val.Get(&result)` to extract the typed result. The Activity executes synchronously in the calling goroutine. + +## Best Practices + +1. Register all Activities used by the Workflow with `env.RegisterActivity()`, unless you mock them with `env.OnActivity()` +2. Use mocks to isolate Workflow logic from Activity implementations +3. Test failure paths by mocking Activities that return errors +4. Use replay testing before deploying Workflow code changes to catch non-determinism errors +5. Use unique task queues per test when running integration tests +6. Call `env.AssertExpectations(s.T())` in `AfterTest` to verify all mocks were called diff --git a/references/go/versioning.md b/references/go/versioning.md new file mode 100644 index 0000000..b6b6c27 --- /dev/null +++ b/references/go/versioning.md @@ -0,0 +1,232 @@ +# Go SDK Versioning + +For conceptual overview and guidance on choosing an approach, see `references/core/versioning.md`. + +## GetVersion API + +`workflow.GetVersion` safely performs backwards-incompatible changes to Workflow Definitions. It returns the version to branch on, recording the result as a marker in the Event History. + +```go +v := workflow.GetVersion(ctx, "changeID", workflow.DefaultVersion, maxSupported) +``` + +- `changeID`: unique string identifying the change +- `minSupported`: oldest version still supported (`workflow.DefaultVersion` is `-1`) +- `maxSupported`: current/newest version +- Returns `maxSupported` for new executions; returns the recorded version on replay + +### Three-Step Lifecycle + +**Step 1: Add GetVersion with both code paths** + +Original code calls `ActivityA`. You want to replace it with `ActivityC`: + +```go +v := workflow.GetVersion(ctx, "Step1", workflow.DefaultVersion, 1) +if v == workflow.DefaultVersion { + // Old code path (for replay of existing workflows) + err = workflow.ExecuteActivity(ctx, ActivityA, data).Get(ctx, &result1) +} else { + // New code path + err = workflow.ExecuteActivity(ctx, ActivityC, data).Get(ctx, &result1) +} +``` + +For new executions, `GetVersion` returns `1` and records a marker. For replay of pre-change workflows (no marker), it returns `DefaultVersion` (`-1`). + +**Step 2: Remove old branch (increase minSupported)** + +After all `DefaultVersion` Workflow Executions have completed: + +```go +v := workflow.GetVersion(ctx, "Step1", 1, 1) +// Only the new code path remains +err = workflow.ExecuteActivity(ctx, ActivityC, data).Get(ctx, &result1) +``` + +Keep the `GetVersion` call even with a single branch. This ensures: +1. If an older execution replays on this code, it fails fast instead of proceeding incorrectly +2. If you need further changes, you just bump `maxSupported` + +**Step 3: Further changes (bump maxSupported)** + +Later, replace `ActivityC` with `ActivityD`: + +```go +v := workflow.GetVersion(ctx, "Step1", 1, 2) +if v == 1 { + err = workflow.ExecuteActivity(ctx, ActivityC, data).Get(ctx, &result1) +} else { + err = workflow.ExecuteActivity(ctx, ActivityD, data).Get(ctx, &result1) +} +``` + +After all version-1 executions complete, collapse again: + +```go +_ = workflow.GetVersion(ctx, "Step1", 2, 2) +err = workflow.ExecuteActivity(ctx, ActivityD, data).Get(ctx, &result1) +``` + +### Using GetVersion in Loops + +The return value for a given `changeID` is immutable once recorded. In loops, append the iteration number to the `changeID`: + +```go +for i := 0; i < 10; i++ { + v := workflow.GetVersion(ctx, fmt.Sprintf("myChange-%d", i), workflow.DefaultVersion, 1) + if v == workflow.DefaultVersion { + // old path + } else { + // new path + } +} +``` + +## Workflow Type Versioning + +Create a new Workflow Type for incompatible changes: + +```go +// Original +func MyWorkflow(ctx workflow.Context, input Input) (string, error) { + // v1 implementation +} + +// New version +func MyWorkflowV2(ctx workflow.Context, input Input) (string, error) { + // v2 implementation +} +``` + +Register both with the Worker: + +```go +w := worker.New(c, "my-task-queue", worker.Options{}) +w.RegisterWorkflow(MyWorkflow) +w.RegisterWorkflow(MyWorkflowV2) +``` + +Route new executions to the new type. Old workflows continue on the old type. Check for open executions before removing the old type: + +```bash +temporal workflow list --query 'WorkflowType = "MyWorkflow" 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 + +```go +w := worker.New(c, "my-task-queue", worker.Options{ + DeploymentOptions: worker.DeploymentOptions{ + UseVersioning: true, + Version: worker.WorkerDeploymentVersion{ + DeploymentName: "my-service", + BuildId: "v1.0.0", // or git commit hash + }, + DefaultVersioningBehavior: workflow.VersioningBehaviorPinned, + }, +}) +``` + +**Configuration fields:** +- `UseVersioning`: enables Worker Versioning +- `Version`: identifies the Worker Deployment Version (deployment name + build ID) +- `DefaultVersioningBehavior`: `VersioningBehaviorPinned` or `VersioningBehaviorAutoUpgrade` +- Build ID: typically a git commit hash, version number, or timestamp + +### PINNED vs AUTO_UPGRADE Behaviors + +**PINNED Behavior** + +Workflows stay locked to their original Worker version. + +**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. + +**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 GetVersion for version transitions + +**Important:** AUTO_UPGRADE workflows still need GetVersion to handle version transitions safely since they can move between Worker versions. + +### Worker Configuration with Default Behavior + +```go +// For short-running workflows, prefer PINNED +w := worker.New(c, "orders-task-queue", worker.Options{ + DeploymentOptions: worker.DeploymentOptions{ + UseVersioning: true, + Version: worker.WorkerDeploymentVersion{ + DeploymentName: "order-service", + BuildId: os.Getenv("BUILD_ID"), + }, + DefaultVersioningBehavior: workflow.VersioningBehaviorPinned, + }, +}) +``` + +### 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 + +This works well with Kubernetes where you manage multiple ReplicaSets running different Worker versions. + +Deploy a new version, then set it as current: + +```bash +temporal worker deployment set-current-version \ + --deployment-name my-service \ + --build-id v2.0.0 +``` + +### 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. **Keep GetVersion calls** even when only a single branch remains -- it guards against stale replays and simplifies future changes +2. **Use `TemporalChangeVersion` search attribute** to find Workflows running on old versions: + ```bash + temporal workflow list --query \ + 'WorkflowType = "MyWorkflow" AND ExecutionStatus = "Running" AND TemporalChangeVersion = "Step1"' + ``` +3. **Test with replay** before removing old branches to verify determinism is preserved +4. **Prefer Worker Versioning** for large-scale deployments to avoid accumulating patching branches diff --git a/references/python/patterns.md b/references/python/patterns.md index 00cd12a..6843985 100644 --- a/references/python/patterns.md +++ b/references/python/patterns.md @@ -106,6 +106,8 @@ class OrderWorkflow: raise ValueError("Order is full") ``` +**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. Raise an exception to reject the update; return `None` to accept. + ## Child Workflows ```python diff --git a/references/typescript/patterns.md b/references/typescript/patterns.md index 4b07947..3d59e23 100644 --- a/references/typescript/patterns.md +++ b/references/typescript/patterns.md @@ -132,6 +132,8 @@ export async function orderWorkflow(): Promise { } ``` +**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 error to reject the update; return normally to accept. + ## Child Workflows ```typescript From b9a0728b15496fdb1d00f95cd27055e38188cf78 Mon Sep 17 00:00:00 2001 From: Donald Pinckney Date: Thu, 19 Mar 2026 14:54:44 -0400 Subject: [PATCH 07/82] Setup CODEOWNERS to AI SDK team (#48) --- .github/CODEOWNERS | 1 + 1 file changed, 1 insertion(+) create mode 100644 .github/CODEOWNERS diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..872c89b --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @temporalio/ai-sdk From 29e46009c05dd213b1c8cfda51cc170d5a62c4cc Mon Sep 17 00:00:00 2001 From: Donald Pinckney Date: Thu, 19 Mar 2026 16:33:47 -0400 Subject: [PATCH 08/82] Align version number in SKILL.md and plugin.json. (#49) --- SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SKILL.md b/SKILL.md index 6d9c888..1874d20 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,7 +1,7 @@ --- 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. -version: 1.0.0 +version: 0.1.0 --- # Skill: temporal-developer From b5719bc1434d4d5a9bc6b3f2822da9d9142aff22 Mon Sep 17 00:00:00 2001 From: Donald Pinckney Date: Thu, 19 Mar 2026 17:36:15 -0400 Subject: [PATCH 09/82] PR Tracking Initial Release (#4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add initial skill for testing, which is simply Steve's skill (#1) * Add initial skill for testing, which is simply Steve's skill * Rename skill to 'temporal-dev' and update version Updated skill name and version for Temporal Python. * Use claude to merge Steve's, Max's, and Mason's skills. (#2) * Use claude to merge Steve's, Max's, and Mason's skills. Did a review pass using claude's skill devlopment skills * Add missing things from Steve * trigger tweaks * Add in common gotchas from Johann * add simple feedback mechanism (#3) * Change skill name to kebab-case, for compatibility with Amp and Cline (#7) * Clean up references/core/ai-integration.md * Clean up references/core/common-gotchas.md * Clean up references/core/common-gotchas.md * Clean up references/core/determinism.md * Clean up references/core/determinism.md * Update error-reference.md * Update interactive-workflows.md * Clean up patterns.md * Cut shell scripts * Edit troubleshooting.md * remove interceptors for now * remove dynamic workflows * clarify on heartbeating of async activity completions, and prompt it a bit in relation to signals * Improve references/python/advanced-features.md * Use explicit namespace in connect * remove duplicated content from determinism.md, clean up * Improve references/python/data-handling.md * Prefer start_to_close_timeout * don't explicitely provide defaults for retry policies * error-handling.md cleanup * move idempotency patterns to patterns.md * remove multi-param activities * small edits * Unify sandbox stuff into one file * local activities aren't experimental * Clean up references/python/sync-vs-async.md * Cleanup observability.md, remove duplicated search attributes * Cut otel for now * cut a lot of duplicate stuff from python gotchas, address comments * de-duplicate content * Lots of improvements to testing * cleanup to top level of skill (like CLI install instructions), and to top-level of python * Improve patterns.md * clean up ai-patterns.md * Update readme with installation instructions * remove ts directory * De-couple core from python and TypeScript as much as possible * Remove TypeScript hints * add prompting for feedback at startup - wait for ethan on slack channel * shorten url * Update slack channel * Automated pass over on python cleanup & deduplication * Remove multi-patching from Python, since its obvious, dont waste tokens on it. (#34) * Add TypeScript (#31) Adds initial support for TypeScript to the skill --------- Co-authored-by: James Watkins-Harvey Co-authored-by: Chris Olszewski * Fix typos and reference links (#36) * Fix typos and reference links * 2 more typo fixes * quick edit to readme (#37) * Fix saga compensations to run under cancellation protection (#43) When a workflow is cancelled mid-saga, compensations must run in a cancellation-protected scope, otherwise they are immediately cancelled before they can execute. - Python: wrap compensation loop in asyncio.shield() so it runs even when the workflow receives a CancelledError - TypeScript: wrap compensation loop in CancellationScope.nonCancellable() so it runs even when the root scope is cancelled (per official docs: "Cleanup logic must be in a nonCancellable scope") - TypeScript: also fix compensation registration order — register BEFORE calling the activity (was already correct in Python) Co-authored-by: Claude Sonnet 4.6 (1M context) * Update readme for public preview (#45) * a few more readme tweaks (#46) * Add MIT License to the project (#47) * Add Go (supersedes other PR) (#38) * progress on go * Go translation workflow completed. * missed a few spots * Manual edits * Address feedback * Add gotcha about anonymous local activities * Sample code for payload converter * clarify sdk protection mechanisms * Setup CODEOWNERS to AI SDK team (#48) * Align version number in SKILL.md and plugin.json. (#49) --------- Co-authored-by: James Watkins-Harvey Co-authored-by: Chris Olszewski Co-authored-by: Claude Sonnet 4.6 (1M context) --- .github/CODEOWNERS | 1 + LICENSE | 21 + README.md | 43 +- SKILL.md | 132 +++++ references/core/ai-patterns.md | 166 ++++++ references/core/determinism.md | 118 ++++ references/core/dev-management.md | 26 + references/core/error-reference.md | 32 ++ references/core/gotchas.md | 196 +++++++ references/core/interactive-workflows.md | 49 ++ references/core/patterns.md | 443 +++++++++++++++ references/core/troubleshooting.md | 323 +++++++++++ references/core/versioning.md | 174 ++++++ references/go/advanced-features.md | 187 ++++++ references/go/data-handling.md | 262 +++++++++ references/go/determinism-protection.md | 98 ++++ references/go/determinism.md | 52 ++ references/go/error-handling.md | 184 ++++++ references/go/go.md | 242 ++++++++ references/go/gotchas.md | 290 ++++++++++ references/go/observability.md | 153 +++++ references/go/patterns.md | 536 ++++++++++++++++++ references/go/testing.md | 238 ++++++++ references/go/versioning.md | 232 ++++++++ references/python/advanced-features.md | 166 ++++++ references/python/ai-patterns.md | 334 +++++++++++ references/python/data-handling.md | 230 ++++++++ references/python/determinism-protection.md | 233 ++++++++ references/python/determinism.md | 51 ++ references/python/error-handling.md | 138 +++++ references/python/gotchas.md | 280 +++++++++ references/python/observability.md | 105 ++++ references/python/patterns.md | 395 +++++++++++++ references/python/python.md | 175 ++++++ references/python/sync-vs-async.md | 231 ++++++++ references/python/testing.md | 165 ++++++ references/python/versioning.md | 314 ++++++++++ references/typescript/advanced-features.md | 150 +++++ references/typescript/data-handling.md | 253 +++++++++ .../typescript/determinism-protection.md | 56 ++ references/typescript/determinism.md | 51 ++ references/typescript/error-handling.md | 119 ++++ references/typescript/gotchas.md | 312 ++++++++++ references/typescript/observability.md | 109 ++++ references/typescript/patterns.md | 417 ++++++++++++++ references/typescript/testing.md | 222 ++++++++ references/typescript/typescript.md | 172 ++++++ references/typescript/versioning.md | 211 +++++++ 48 files changed, 9086 insertions(+), 1 deletion(-) create mode 100644 .github/CODEOWNERS create mode 100644 LICENSE create mode 100644 SKILL.md create mode 100644 references/core/ai-patterns.md create mode 100644 references/core/determinism.md create mode 100644 references/core/dev-management.md create mode 100644 references/core/error-reference.md create mode 100644 references/core/gotchas.md create mode 100644 references/core/interactive-workflows.md create mode 100644 references/core/patterns.md create mode 100644 references/core/troubleshooting.md create mode 100644 references/core/versioning.md create mode 100644 references/go/advanced-features.md create mode 100644 references/go/data-handling.md create mode 100644 references/go/determinism-protection.md create mode 100644 references/go/determinism.md create mode 100644 references/go/error-handling.md create mode 100644 references/go/go.md create mode 100644 references/go/gotchas.md create mode 100644 references/go/observability.md create mode 100644 references/go/patterns.md create mode 100644 references/go/testing.md create mode 100644 references/go/versioning.md create mode 100644 references/python/advanced-features.md create mode 100644 references/python/ai-patterns.md create mode 100644 references/python/data-handling.md create mode 100644 references/python/determinism-protection.md create mode 100644 references/python/determinism.md create mode 100644 references/python/error-handling.md create mode 100644 references/python/gotchas.md create mode 100644 references/python/observability.md create mode 100644 references/python/patterns.md create mode 100644 references/python/python.md create mode 100644 references/python/sync-vs-async.md create mode 100644 references/python/testing.md create mode 100644 references/python/versioning.md create mode 100644 references/typescript/advanced-features.md create mode 100644 references/typescript/data-handling.md create mode 100644 references/typescript/determinism-protection.md create mode 100644 references/typescript/determinism.md create mode 100644 references/typescript/error-handling.md create mode 100644 references/typescript/gotchas.md create mode 100644 references/typescript/observability.md create mode 100644 references/typescript/patterns.md create mode 100644 references/typescript/testing.md create mode 100644 references/typescript/typescript.md create mode 100644 references/typescript/versioning.md diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..872c89b --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @temporalio/ai-sdk diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7092ef5 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Temporal Technologies Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 922884a..6ba88db 100644 --- a/README.md +++ b/README.md @@ -1 +1,42 @@ -# skill-temporal-dev +# Temporal Development Skill + +A comprehensive skill for developers to use when building [Temporal](https://temporal.io/) applications. + +> [!WARNING] +> This Skill is currently in Public Preview, and will continue to evolve and improve. +> We would love to hear your feedback - positive or negative - over in the [Community Slack](https://t.mp/slack), in the [#topic-ai channel](https://temporalio.slack.com/archives/C0818FQPYKY) + +## Installation + +### As a Claude Code Plugin + +This skill is housed within a [Claude Code plugin](https://github.com/temporalio/agent-skills), which provides a simple way to install and receive future updates to the skill. + +1. Run `/plugin marketplace add temporalio/agent-skills` +2. Run `/plugin` to open the plugin manager +3. Select **Marketplaces** +4. Choose `temporal-marketplace` from the list +5. Select **Enable auto-update** or **Disable auto-update** +6. run `/plugin install temporal-developer@temporalio-agent-skills` +7. Restart Claude Code + +### Via `npx skills` - supports all major coding agents + +1. `npx skills add https://github.com/temporalio/skill-temporal-developer` +2. Follow prompts + +### Via manually cloning the skill repo: + +1. `mkdir -p ~/.claude/skills && git clone https://github.com/temporalio/skill-temporal-developer ~/.claude/skills/temporal-developer` + +Appropriately adjust the installation directory based on your coding agent. + +## Currently Supported Temporal SDK Langages + +- [x] Python ✅ +- [x] TypeScript ✅ +- [x] Go ✅ +- [ ] Java 🚧 ([PR](https://github.com/temporalio/skill-temporal-developer/pull/42)) +- [ ] .NET 🚧 ([PR](https://github.com/temporalio/skill-temporal-developer/pull/39)) +- [ ] Ruby 🚧 ([PR](https://github.com/temporalio/skill-temporal-developer/pull/41)) +- [ ] PHP 🚧 ([PR](https://github.com/temporalio/skill-temporal-developer/pull/40)) diff --git a/SKILL.md b/SKILL.md new file mode 100644 index 0000000..1874d20 --- /dev/null +++ b/SKILL.md @@ -0,0 +1,132 @@ +--- +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. +version: 0.1.0 +--- + +# Skill: temporal-developer + +## 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. + +## Core Architecture + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Temporal Cluster │ +│ ┌─────────────────┐ ┌─────────────────┐ ┌────────────────┐ │ +│ │ Event History │ │ Task Queues │ │ Visibility │ │ +│ │ (Durable Log) │ │ (Work Router) │ │ (Search) │ │ +│ └─────────────────┘ └─────────────────┘ └────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ + ▲ + │ Poll / Complete + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ Worker │ +│ ┌─────────────────────────┐ ┌──────────────────────────────┐ │ +│ │ Workflow Definitions │ │ Activity Implementations │ │ +│ │ (Deterministic) │ │ (Non-deterministic OK) │ │ +│ └─────────────────────────┘ └──────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +**Components:** +- **Workflows** - Durable, deterministic functions that orchestrate activities +- **Activities** - Non-deterministic operations (API calls, I/O) that can fail and retry +- **Workers** - Long-running processes that poll task queues and execute code +- **Task Queues** - Named queues connecting clients to workers + +## History Replay: Why Determinism Matters + +Temporal achieves durability through **history replay**: + +1. **Initial Execution** - Worker runs workflow, generates Commands, stored as Events in history +2. **Recovery** - On restart/failure, Worker re-executes workflow from beginning +3. **Matching** - SDK compares generated Commands against stored Events +4. **Restoration** - Uses stored Activity results instead of re-executing + +**If Commands don't match Events = Non-determinism Error = Workflow blocked** + +| Workflow Code | Command | Event | +|--------------|---------|-------| +| Execute activity | `ScheduleActivityTask` | `ActivityTaskScheduled` | +| Sleep/timer | `StartTimer` | `TimerStarted` | +| Child workflow | `StartChildWorkflowExecution` | `ChildWorkflowExecutionStarted` | + +See `references/core/determinism.md` for detailed explanation. + +## Getting Started + +### Ensure Temporal CLI is installed + +Check if `temporal` CLI is installed. If not, follow these instructions: + +#### macOS + +``` +brew install temporal +``` + +#### Linux + +Check your machine's architecture and download the appropriate archive: + +- [Linux amd64](https://temporal.download/cli/archive/latest?platform=linux&arch=amd64) +- [Linux arm64](https://temporal.download/cli/archive/latest?platform=linux&arch=arm64) + +Once you've downloaded the file, extract the downloaded archive and add the temporal binary to your PATH by copying it to a directory like /usr/local/bin + +#### Windows + +Check your machine's architecture and download the appropriate archive: + +- [Windows amd64](https://temporal.download/cli/archive/latest?platform=windows&arch=amd64) +- [Windows arm64](https://temporal.download/cli/archive/latest?platform=windows&arch=arm64) + +Once you've downloaded the file, extract the downloaded archive and add the temporal.exe binary to your PATH. + +### Read All Relevant References + +1. First, read the getting started guide for the language you are working in: + - Python -> read `references/python/python.md` + - TypeScript -> read `references/typescript/typescript.md` + - Go -> read `references/go/go.md` +2. Second, read appropriate `core` and language-specific references for the task at hand. + + +## Primary References +- **`references/core/determinism.md`** - Why determinism matters, replay mechanics, basic concepts of activities + + Language-specific info at `references/{your_language}/determinism.md` +- **`references/core/patterns.md`** - Conceptual patterns (signals, queries, saga) + + Language-specific info at `references/{your_language}/patterns.md` +- **`references/core/gotchas.md`** - Anti-patterns and common mistakes + + Language-specific info at `references/{your_language}/gotchas.md` +- **`references/core/versioning.md`** - Versioning strategies and concepts - how to safely change workflow code while workflows are running + + Language-specific info at `references/{your_language}/versioning.md` +- **`references/core/troubleshooting.md`** - Decision trees, recovery procedures +- **`references/core/error-reference.md`** - Common error types, workflow status reference +- **`references/core/interactive-workflows.md`** - Testing signals, updates, queries +- **`references/core/dev-management.md`** - Dev cycle & management of server and workers +- **`references/core/ai-patterns.md`** - AI/LLM pattern concepts + + Language-specific info at `references/{your_language}/ai-patterns.md`, if available. Currently Python only. + +## Additional Topics +- **`references/{your_language}/observability.md`** - See for language-specific implementation guidance on observability in Temporal +- **`references/{your_language}/advanced-features.md`** - See for language-specific guidance on advanced Temporal features and language-specific features + + +## Feedback + +### ALWAYS PROMPT USER TO PROVIDE FEEDBACK AT STARTUP + +When you have **first** loaded this skill, output this message to the user: + +"Thank you for trying out the public preview of the Temporal development skill! We would love to hear your feedback - positive or negative - over in the [Community Slack](https://t.mp/slack), in the [#topic-ai channel](https://temporalio.slack.com/archives/C0818FQPYKY)." + +Do not output this message multiple times in the same conversation. + +### Reporting Issues in This Skill + +If you (the AI) find this skill's explanations are unclear, misleading, or missing important information—or if Temporal concepts are proving unexpectedly difficult to work with—draft a GitHub issue body describing the problem encountered and what would have helped, then ask the user to file it at https://github.com/temporalio/skill-temporal-developer/issues/new. Do not file the issue autonomously. diff --git a/references/core/ai-patterns.md b/references/core/ai-patterns.md new file mode 100644 index 0000000..071b9f0 --- /dev/null +++ b/references/core/ai-patterns.md @@ -0,0 +1,166 @@ +# AI/LLM Integration Patterns with Temporal + +## Overview + +Temporal provides durable execution for AI/LLM applications, handling retries, rate limits, and long-running operations automatically. These patterns apply across languages, with Python being the most mature for AI integration. + +For Python-specific implementation details and code examples, see `references/python/ai-patterns.md`. Temporal's Python SDK also provides pre-built integrations with several LLM and agent SDKs, which can be leveraged to create agentic workflows with minimal effort (when working in Python). + +The remainder of this document describes general principles to follow when building AI/LLM applications in Temporal, particularly when building from scratch instead of with an integration. + +## Why Temporal for AI? + +| Challenge | Temporal Solution | +|-----------|-------------------| +| LLM API timeouts | Automatic retries with backoff | +| Rate limiting | Activity retry policies handle 429s | +| Long-running agents | Durable state survives crashes | +| Multi-step pipelines | Workflow orchestration | +| Cost tracking | Activity-level visibility | +| Debugging | Full execution history | + +## Core Patterns + +### Pattern 1: Activities should Wrap LLM Calls + +- activity: call_llm + - inputs: + - model_id -> internally activity can route to different models, so we don't need 1 activity per unique model. + - prompt / chat history + - tools + - etc. + - returns model response, as a typed structured output + +**Benefits**: +- Single activity handles multiple use cases +- Consistent retry handling +- Centralized configuration + +### Pattern 2: Non-deterministic / heavy tools in Activities + +Tools which are non-deterministic and/or heavy actions (file system, hitting APIs, etc.) should be placed in activities: + +``` +Workflow: + ├── Activity: call_llm (get tool selection) + ├── Activity: execute_tool (run selected tool) + └── Activity: call_llm (interpret results) +``` + +**Benefits**: +- Independent retry for each step +- Clear audit trail in history +- Easier testing and mocking +- Failure isolation + +### Pattern 3: Tools that Mutate Agent State can be in the Workflow directly + +Generally, agent state is in bijection with workflow state. Thus, tools which mutate agent state and are deterministic (like TODO tools, just updating a hash map) typically belong in the workflow code rather than an activity. + +``` +Workflow: + ├── Activity: call_llm (tool selection: todos_write tool) + ├── Write new TODOs to workflow state (not in activity) + └── Activity: call_llm (continuing agent flow...) +``` + +### Pattern 4: Centralized Retry Management + +Disable retries in LLM client libraries, let Temporal handle retries. + +- LLM Client Config: + - max_retries = 0 ← Disable client retries at the LLM client level + +Use either the default activity retry policy, or customize it as needed for the situation. + +**Why**: +- Temporal retries are durable (survive crashes) +- Single retry configuration point +- Better visibility into retry attempts +- Consistent backoff behavior + + +### Pattern 5: Multi-Agent Orchestration + +Complex pipelines with multiple specialized agents: + +``` +Deep Research Example: + │ + ├── Planning Agent (Activity) + │ └── Output: subtopics to research + │ + ├── Query Generation Agent (Activity) + │ └── Output: search queries per subtopic + │ + ├── Parallel Web Search (Multiple Activities) + │ └── Output: search results (resilient to partial failures) + │ + └── Synthesis Agent (Activity) + └── Output: final report +``` + +**Key Pattern**: Use parallel execution with `return_exceptions=True` to continue with partial results when some searches fail. + +## Approximate Timeout Recommendations + +| Operation Type | Recommended Timeout | +|----------------|---------------------| +| Simple LLM calls (GPT-4, Claude-3) | 30 seconds | +| Reasoning models (o1, o3, extended thinking) | 300 seconds (5 min) | +| Web searches | 300 seconds (5 min) | +| Simple tool execution | 30-60 seconds | +| Image generation | 120 seconds | +| Document processing | 60-120 seconds | + +**Rationale**: +- Reasoning models need time for complex computation +- Web searches may hit rate limits requiring backoff +- Fast timeouts catch stuck operations +- Longer timeouts prevent premature failures for expensive operations + +## Rate Limit Handling + +### From HTTP Headers + +Parse rate limit info from API responses: + +- Response Headers: + - Retry-After: 30 + - X-RateLimit-Remaining: 0 + +- Activity: + - If rate limited: + - Raise retryable error with a next retry delay + - Temporal handles the delay + +## Error Handling + +### Retryable Errors +- Rate limits (429) +- Timeouts +- Temporary server errors (500, 502, 503) +- Network errors + +### Non-Retryable Errors +- Invalid API key (401) +- Invalid input/prompt +- Content policy violations +- Model not found + +## Best Practices + +1. **Disable client retries** - Let Temporal handle all retries +2. **Set appropriate timeouts** - Based on operation type +3. **Separate activities** - One per logical operation +4. **Use structured outputs** - For type safety and validation +5. **Handle partial failures** - Continue with available results +6. **Monitor costs** - Track LLM calls at activity level +7. **Test with mocks** - Mock LLM responses in tests + +## Observability + +See `references/{your_language}/observability.md` for the language you are working in for documentation on implementing observability in Temporal. It is generally recommended to add observability for: +- Token usage, via activity logging +- any else to help track LLM usage and debug agentic flows, within moderation. + diff --git a/references/core/determinism.md b/references/core/determinism.md new file mode 100644 index 0000000..af824d2 --- /dev/null +++ b/references/core/determinism.md @@ -0,0 +1,118 @@ +# Determinism in Temporal Workflows + +This document provides a conceptual-level overview to determinism in Temporal. Additional language-specific determinism information is available at `references/{your_language}/determinism.md`. + +## Overview + +Temporal workflows must be deterministic because of **history replay** - the mechanism that enables durable execution. + +## Why Determinism Matters + +### The Replay Mechanism + +When a Worker needs to restore workflow state (after crash, cache eviction, or continuing after a long timer), it **re-executes the workflow code from the beginning**. But instead of re-running external actions, it uses results stored in the Event History. + +``` +Initial Execution: + Code runs → Generates Commands → Server stores as Events + +Replay (Recovery): + Code runs again → Generates Commands → SDK compares to Events + If match: Use stored results, continue + If mismatch: NondeterminismError! +``` + +### Commands and Events + +Every workflow operation generates a Command that becomes an Event, here are some examples: + +| Workflow Code | Command Generated | Event Stored | +|--------------|-------------------|--------------| +| Execute activity | `ScheduleActivityTask` | `ActivityTaskScheduled` | +| Sleep/timer | `StartTimer` | `TimerStarted` | +| Child workflow | `StartChildWorkflowExecution` | `ChildWorkflowExecutionStarted` | +| Complete workflow | `CompleteWorkflowExecution` | `WorkflowExecutionCompleted` | + +### Non-Determinism Example + +``` +First Run (11:59 AM): + if datetime.now().hour < 12: → True + execute_activity(morning_task) → Command: ScheduleActivityTask("morning_task") + +Replay (12:01 PM): + if datetime.now().hour < 12: → False + execute_activity(afternoon_task) → Command: ScheduleActivityTask("afternoon_task") + +Result: Commands don't match history → NondeterminismError +``` + +## Sources of Non-Determinism + +### Time-Based Operations +- `datetime.now()`, `time.time()`, `Date.now()` +- Different value on each execution + +### Random Values +- `random.random()`, `Math.random()`, `uuid.uuid4()` +- Different value on each execution + +### External State +- Reading files, environment variables, databases, networking / HTTP calls +- State may change between executions + +### Non-Deterministic Iteration +- Map/dict iteration order (in some languages) +- Set iteration order + +### Threading/Concurrency +- Race conditions produce different outcomes +- Non-deterministic ordering + +## **Central Concept**: Place Non-Determinism within Activities + +In Temporal, activities are the primary mechanism for making non-deterministic code durable and persisted in workflow history. Generally speaking, you should place sources of non-determinism in activities, which provides durability and recording of results, as well as automated retries and more. See `references/{your_language}/{your_language}.md` for the language you are working in for how to do this in practice. + +For a few simple cases, like timestamps, random values, UUIDs, etc. the Temporal SDK in your language may provide durable variants that are simple to use. See `references/{your_language}/determinism.md` for the language you are working in for more info. + +## SDK Protection Mechanisms +Each Temporal SDK language provides a protection mechanism to make it easier to catch non-determinism errors earlier in development: + +- 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. + +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. + +## Detecting Non-Determinism + +### During Execution +- `NondeterminismError` raised when Commands don't match Events +- Workflow becomes blocked until code is fixed + +### Testing with Replay + +Replay tests verify that workflows follow identical code paths when re-run, by attempting to replay recorded executions. See the replay testing section of `references/{your_language}/testing.md` for information on how to write these tests. + +## Recovery from Non-Determinism + +### Accidental Change +If you accidentally introduced non-determinism: +1. Revert code to match what's in history +2. Restart worker +3. Workflow auto-recovers + +### Intentional Change +If you need to change workflow logic: +1. Use the **Patching API** to support both old and new code paths +2. Or terminate old workflows and start new ones with updated code + +See `versioning.md` for patching details. + +## Best Practices + +1. **Use SDK-provided alternatives** for time, random, UUID +2. **Move I/O to activities** - workflows should only orchestrate +3. **Test with replay** before deploying workflow changes +4. **Use patching** for intentional changes to running workflows +5. **Keep workflows focused** - complex logic increases non-determinism risk diff --git a/references/core/dev-management.md b/references/core/dev-management.md new file mode 100644 index 0000000..01faed0 --- /dev/null +++ b/references/core/dev-management.md @@ -0,0 +1,26 @@ +# Development Server and Worker Management + +## Server Management + +Before starting workers or workflows, you MUST start a local dev server, using the Temporal CLI: + +```bash +temporal server start-dev # Start this in the background. +``` + +It is perfectly OK for this process to be shared across multiple projects / left running as you develop your Temporal code. + +## Worker Management Details + +### Starting Workers + +How you start a worker is project-dependent, but generally Temporal code should have a program entrypoint which starts a worker. If your project doesn't, you should define it. + +When you need a new worker, you should start it in the background (and preferrably have it log somewhere you can check), and then remember its PID so you can kill / clean it up later. + +**Best practice**: As far as local development goes, run only ONE worker instance with the latest code. Don't keep stale workers (running old code) around. + + +### Cleanup + +**Always kill workers when done.** Don't leave workers running. diff --git a/references/core/error-reference.md b/references/core/error-reference.md new file mode 100644 index 0000000..a0f905b --- /dev/null +++ b/references/core/error-reference.md @@ -0,0 +1,32 @@ +# Common Error Types Reference + +| Error Type | Error identifier (if any) | Where to Find | What Happened | Recovery | Link to additional info (if any) +|------------|---------------|---------------|---------------|----------|----------| +| **Non-determinism** | TMPRL1100 | `WorkflowTaskFailed` in history | Replay doesn't match history | Analyze error first. **If accidental**: fix code to match history → restart worker. **If intentional v2 change**: terminate → start fresh workflow. | https://github.com/temporalio/rules/blob/main/rules/TMPRL1100.md | +| **Deadlock** | TMPRL1101 | `WorkflowTaskFailed` in history, worker logs | Workflow blocked too long (deadlock detected) | Remove blocking operations from workflow code (no I/O, no sleep, no threading locks). Use Temporal primitives instead. | https://github.com/temporalio/rules/blob/main/rules/TMPRL1101.md | +| **Unfinished handlers** | TMPRL1102 | `WorkflowTaskFailed` in history | Workflow completed while update/signal handlers still running | Ensure all handlers complete before workflow finishes. Use `workflow.wait_condition()` to wait for handler completion. | https://github.com/temporalio/rules/blob/main/rules/TMPRL1102.md | +| **Payload overflow** | TMPRL1103 | `WorkflowTaskFailed` or `ActivityTaskFailed` in history | Payload size limit exceeded (default 2MB) | Reduce payload size. Use external storage (S3, database) for large data and pass references instead. | https://github.com/temporalio/rules/blob/main/rules/TMPRL1103.md | +| **Workflow code bug** | | `WorkflowTaskFailed` in history | Bug in workflow logic | Fix code → Restart worker → Workflow auto-resumes | | +| **Missing workflow** | | Worker logs | Workflow not registered | Add to worker.py → Restart worker | | +| **Missing activity** | | Worker logs | Activity not registered | Add to worker.py → Restart worker | | +| **Activity bug** | | `ActivityTaskFailed` in history | Bug in activity code | Fix code → Restart worker → Auto-retries | | +| **Activity retries** | | `ActivityTaskFailed` (count >2) | Repeated failures | Fix code → Restart worker → Auto-retries | | +| **Sandbox violation** | | Worker logs | Bad imports in workflow | Fix workflow.py imports → Restart worker | | +| **Task queue mismatch** | | Workflow never starts | Different queues in starter/worker | Align task queue names | | +| **Timeout** | | Status = TIMED_OUT | Operation too slow | Increase timeout config | | + +## Workflow Status Reference + +| Status | Meaning | Action | +|--------|---------|--------| +| `RUNNING` | Workflow in progress | Wait, or check if stalled | +| `COMPLETED` | Successfully finished | Get result, verify correctness | +| `FAILED` | Error during execution | Analyze error | +| `CANCELED` | Explicitly canceled | Review reason | +| `TERMINATED` | Force-stopped | Review reason | +| `TIMED_OUT` | Exceeded timeout | Increase timeout | + +## See Also + +- [Common Gotchas](gotchas.md) - Anti-patterns that cause these errors +- [Troubleshooting](troubleshooting.md) - Decision trees for diagnosing issues diff --git a/references/core/gotchas.md b/references/core/gotchas.md new file mode 100644 index 0000000..55b6ddb --- /dev/null +++ b/references/core/gotchas.md @@ -0,0 +1,196 @@ +# Common Temporal Gotchas + +Common mistakes and anti-patterns in Temporal development. Learning from these saves significant debugging time. + +This document provides a general overview of conceptual-level gotchas in Temporal. The exact form that these take and symptoms can vary by SDK language. See `references/{your_language}/gotchas.md` for language-specific info on common mistakes. + +## Non-Idempotent Activities + +**The Problem**: Activities may execute more than once due to retries or Worker failures. If an activity calls an external service without an idempotency key, you may charge a customer twice, send duplicate emails, or create duplicate records. + +**Symptoms**: +- Duplicate side effects (double charges, duplicate notifications) +- Data inconsistencies after retries + +**The Fix**: Always use idempotency keys when calling external services. Use the workflow ID, activity ID, or a domain-specific identifier (like order ID) as the key. + +**Note:** Local Activities skip the task queue for lower latency, but they're still subject to retries. The same idempotency rules apply. + +## Side Effects & Non-Determinism in Workflow Code + +**The Problem**: Code in workflow functions runs on first execution AND on every replay. Any side effect (logging, notifications, metrics, etc.) will happen multiple times and non-deterministic code (IO, current time, random numbers, threading, etc.) won't replay correctly. + +**Symptoms**: +- Non-determinism errors +- Sandbox violations, depending on SDK language +- Duplicate log entries +- Multiple notifications for the same event +- Inflated metrics + +**The Fix**: +- Use Temporal replay-aware managed side effects for common, non-business logic cases: + - Temporal workflow logging + - Temporal date time + - Temporal UUID generation + - Temporal random number generation +- Put all other side effects in Activities + +See `references/core/determinism.md` for more info. + +## Multiple Workers with Different Code + +**The Problem**: If Worker A runs part of a workflow with code v1, then Worker B (with code v2) picks it up, replay may produce different Commands. + +**Symptoms**: +- Non-determinism errors after deploying new code +- Errors mentioning "command mismatch" or "unexpected command" + +**The Fix**: +- Use Worker Versioning for production deployments +- Use patching APIs +- During development: kill old workers before starting new ones +- Ensure all workers run identical code + +**Note:** Workflows started with old code continue running after you change the code, which can then induce the above issues. During development (NOT production), you may want to terminate stale workflows (`temporal workflow terminate --workflow-id `). + +See `references/core/versioning.md` for more info. + +## Failing Activities Too Quickly + +**The Problem**: Using aggressive activity retry policies that give up too easily. + +**Symptoms**: +- Workflows failing on transient errors +- Unnecessary workflow failures during brief outages + +**The Fix**: Use appropriate activity retry policies. Let Temporal handle transient failures with exponential backoff. Reserve `maximum_attempts=1` for truly non-retryable operations. + +## Query Handler & Update Validator Mistakes + +### Modifying State in Queries & Update Validators + +**The Problem**: Queries and update validators are read-only. Modifying state causes non-determinism on replay, and must strictly be avoided. + +**Symptoms**: +- State inconsistencies after workflow replay +- Non-determinism errors + +**The Fix**: Queries and update validators must only read state. Use Updates for operations that need to modify state AND return a result. + +### Blocking in Queries & Update Validators + +**The Problem**: Queries and update validators must return immediately. They cannot await activities, child workflows, timers, or conditions. + +**Symptoms**: +- Query / update validators timeouts +- Deadlocks + +**The Fix**: Queries and update validators must only look at current state. Use Signals or Updates to trigger async operations. + +### Query vs Signal vs Update + +| Operation | Modifies State? | Returns Result? | Can Block? | Use For | +|-----------|-----------------|-----------------|------------|---------| +| **Query** | No | Yes | No | Read current state | +| **Signal** | Yes | No | Yes | Fire-and-forget mutations | +| **Update** | Yes | Yes | Yes | Mutations needing results | + +**Key rule**: Query to peek, Signal to push, Update to pop. + +## File Organization Issues + +Each SDK has specific requirements for how workflow and activity code should be organized. Mixing them incorrectly causes sandbox issues, bundling problems, or performance degradation. + +See language-specific gotchas for details. + +## Testing Mistakes + +### Only Testing Happy Paths + +**The Problem**: Not testing what happens when things go wrong. + +**Questions to answer**: +- What happens when an Activity exhausts all retries? +- What happens when a workflow is cancelled mid-execution? +- What happens during a Worker restart? + +**The Fix**: Test failure scenarios explicitly. Mock activities to fail, test cancellation handling, use replay testing. + +### Not Testing Replay Compatibility + +**The Problem**: Changing workflow code without verifying existing workflows can still replay. + +**Symptoms**: +- Non-determinism errors after deployment +- Stuck workflows that can't make progress + +**The Fix**: Use replay testing against saved histories from production or staging. + +## Error Handling Mistakes + +### Swallowing Errors + +**The Problem**: Catching errors without proper handling hides failures. + +**Symptoms**: +- Silent failures +- Workflows completing "successfully" despite errors +- Difficult debugging + +**The Fix**: Log errors and make deliberate decisions. Either re-raise, use a fallback, or explicitly document why ignoring is safe. + +### Wrong Retry Classification + +**The Problem**: Marking transient errors as non-retryable, or permanent errors as retryable. + +**Symptoms**: +- Workflows failing on temporary network issues (if marked non-retryable) +- Infinite retries on invalid input (if marked retryable) + +**The Fix**: +- **Retryable**: Network errors, timeouts, rate limits, temporary unavailability +- **Non-retryable**: Invalid input, authentication failures, business rule violations, resource not found + +## Cancellation Handling + +### Not Handling Workflow Cancellation + +**The Problem**: When a workflow is cancelled, cleanup code after the cancellation point doesn't run unless explicitly protected. + +**Symptoms**: +- Resources not released after cancellation +- Incomplete compensation/rollback +- Leaked state + +**The Fix**: Use language-specific cancellation scopes or try/finally blocks to ensure cleanup runs even on cancellation. See language-specific gotchas for implementation details. + +### Not Handling Activity Cancellation + +**The Problem**: Activities must opt in to receive cancellation. Without proper handling, a cancelled activity continues running to completion, wasting resources. + +**Requirements for activity cancellation**: +1. **Heartbeating** - Cancellation is delivered via heartbeat. Activities that don't heartbeat won't know they've been cancelled. +2. **Checking for cancellation** - Activity must explicitly check for cancellation or await a cancellation signal. + +**Symptoms**: +- Cancelled activities running to completion +- Wasted compute on work that will be discarded +- Delayed workflow cancellation + +**The Fix**: Heartbeat regularly and check for cancellation. See language-specific gotchas for implementation patterns. + +## Payload Size Limits + +**The Problem**: Temporal has built-in limits on payload sizes. Exceeding them causes workflows to fail. + +**Limits**: +- Max 2MB per individual payload +- Max 4MB per gRPC message +- Max 50MB for complete workflow history (aim for <10MB in practice) + +**Symptoms**: +- Payload too large errors +- gRPC message size exceeded errors +- Workflow history growing unboundedly + +**The Fix**: Store large data externally (S3/GCS) and pass references, use compression codecs, or chunk data across multiple activities. See the Large Data Handling pattern in `references/core/patterns.md`. diff --git a/references/core/interactive-workflows.md b/references/core/interactive-workflows.md new file mode 100644 index 0000000..3b02028 --- /dev/null +++ b/references/core/interactive-workflows.md @@ -0,0 +1,49 @@ +# Interactive Workflows + +Interactive workflows are workflows that use Temporal features such as signals or updates to pause and wait for external input. When testing and debugging these types of workflows you can send them input via the Temporal CLI. + +## Signals + +Fire-and-forget messages to a workflow. + +```bash +# Send signal to workflow +temporal workflow signal \ + --workflow-id \ + --name "signal_name" \ + --input '{"key": "value"}' +``` + +## Updates + +Request-response style interaction (returns a value). + +```bash +# Send update to workflow +temporal workflow update execute \ + --workflow-id \ + --name "update_name" \ + --input '{"approved": true}' +``` + +## Queries + +Read-only inspection of workflow state. + +```bash +# Query workflow state (read-only) +temporal workflow query \ + --workflow-id \ + --name "get_status" +``` + +## Typical Steps for Testing Interactive Workflows + +```bash +# 1. Start worker (command is project dependent) +# 2. Start workflow (command is project dependent) This code should output the workflow ID, if not, modify it to. +temporal workflow signal --workflow-id --name "signal_name" --input '{"key": "value"}' # 3. Send it interactive events, e.g. a signal. +# 4. Wait for workflow to complete (use Temporal CLI to check status) +# 5. Read workflow result, using the Temporal CLI +# 6. Cleanup the worker process if needed. +``` diff --git a/references/core/patterns.md b/references/core/patterns.md new file mode 100644 index 0000000..566e6f8 --- /dev/null +++ b/references/core/patterns.md @@ -0,0 +1,443 @@ +# Temporal Workflow Patterns + +## Overview + +Common patterns for building robust Temporal workflows. +See the language-specific references for the language you are working in: +- `references/{language}/{language}.md` for the root level documentation for that language +- `references/{language}/patterns.md` for language-specific example code of the patterns in this file. + +## Signals + +**Purpose**: Send data to a running workflow asynchronously (fire-and-forget). + +**When to Use**: +- Human approval workflows +- Adding items to a workflow's queue +- Notifying workflow of external events +- Live configuration updates + +**Characteristics**: +- Asynchronous - sender doesn't wait for response +- Can mutate workflow state +- Durable - signals are persisted in history +- Can be sent before workflow starts (signal-with-start) + +**Example Flow**: +``` +Client Workflow + │ │ + │──── signal(approve) ────▶│ + │ │ (updates state) + │ │ + │◀──── (no response) ──────│ +``` + +**Note:** A related but distinct pattern to signals is async activity completion. This is an advanced feature, which you may consider if the external system that would deliver the signal is unreliable and might fail to Signal, or +you want the external process to Heartbeat or receive Cancellation. If this may be the case, look at language-specific advanced features for your SDK language (`references/{your_language}/advanced-features.md`). + +## Queries + +**Purpose**: Read workflow state synchronously without modifying it. + +**When to Use**: +- Building dashboards showing workflow progress +- Health checks and monitoring +- Debugging workflow state +- Exposing current status to external systems + +**Characteristics**: +- Synchronous - caller waits for response +- Read-only - must not modify state +- Not recorded in history +- Executes on the worker, not persisted +- Can run even on completed workflows + +**Example Flow**: +``` +Client Workflow + │ │ + │──── query(status) ──────▶│ + │ │ (reads state) + │◀──── "processing" ───────│ +``` + +## Updates + +**Purpose**: Modify workflow state and receive a response synchronously. + +**When to Use**: +- Operations that need confirmation (add item, return count) +- Validation before accepting changes +- Replace signal+query combinations +- Request-response patterns within workflow + +**Characteristics**: +- Synchronous - caller waits for completion +- Can mutate state AND return values +- Supports validators to reject invalid updates before they even get persisted into history +- **Validators must NOT mutate workflow state or block** (no activities, sleeps, or commands) — they are read-only, similar to query handlers +- Recorded in history + +**Example Flow**: +``` +Client Workflow + │ │ + │──── update(addItem) ────▶│ + │ │ (validates, modifies state) + │◀──── {count: 5} ─────────│ +``` + +## Child Workflows + +**When to Use**: +- Prevent history from growing too large +- Isolate failure domains (child can fail without failing parent) +- Different retry policies for different parts + +**Characteristics**: +- Own history (doesn't bloat parent) +- Independent lifecycle options (ParentClosePolicy) +- Can be cancelled independently +- Results returned to parent + +**Parent Close Policies**: +- `TERMINATE` - Child terminated when parent closes (default) +- `ABANDON` - Child continues running independently +- `REQUEST_CANCEL` - Cancellation requested but not forced + +**Note:** Do not need to use child workflows simply for breaking complex logic down into smaller pieces. Standard programming abstractions within a workflow can already be used for that. + +## Continue-as-New + +**Purpose**: Prevent unbounded history growth by "restarting" with fresh history. + +**When to Use**: +- Long-running workflows (entity workflows, subscriptions) +- Workflows with many iterations +- When history approaches 10,000+ events +- Periodic cleanup of accumulated state + +**How It Works**: +``` +Workflow (history: 10,000 events) + │ + │ continueAsNew(currentState) + ▼ +New Workflow Execution (history: 0 events) + │ (same workflow ID, fresh history) + │ (receives currentState as input) +``` + +**Best Practice**: Check `historyLength` or `continueAsNewSuggested` periodically. + +## Saga Pattern + +**Purpose**: Distributed transactions with compensation for failures. + +**When to Use**: +- Multi-step operations that span services +- Operations requiring rollback on failure +- Financial transactions, order processing +- Booking systems with multiple reservations + +**How It Works**: +``` +Step 1: Reserve inventory + └─ Compensation: Release inventory + +Step 2: Charge payment + └─ Compensation: Refund payment + +Step 3: Ship order + └─ Compensation: Cancel shipment + +On failure at step 3: + Execute: Refund payment (step 2 compensation) + Execute: Release inventory (step 1 compensation) +``` + +**Implementation Pattern**: +1. Track compensation actions as you complete each step +2. On failure, execute compensations in reverse order +3. Handle compensation failures gracefully (log, alert, manual intervention) + +## Parallel Execution + +**Purpose**: Run multiple independent operations concurrently. + +**When to Use**: +- Processing multiple items that don't depend on each other +- Calling multiple APIs simultaneously +- Fan-out/fan-in patterns +- Reducing total workflow duration + +**Patterns**: +- `Promise` / `asyncio` - Use traditional concurrency helpers (e.g. wait for all, wait for first, etc) +- Partial failure handling - Continue with successful results + +## Entity Workflow Pattern + +**Purpose**: Model long-lived entities as workflows that handle events. + +**When to Use**: +- Subscription management +- User sessions +- Shopping carts +- Any stateful entity receiving events over time + +**How It Works**: +``` +Entity Workflow (user-123) + │ + ├── Receives signal: AddItem + │ └── Updates state + │ + ├── Receives signal: UpdateQuantity + │ └── Updates state + │ + ├── Receives query: GetCart + │ └── Returns current state + │ + └── continueAsNew when history grows +``` + +## Timer Patterns + +**Purpose**: Durable delays that survive worker restarts. + +**Use Cases**: +- Scheduled reminders +- Timeout handling +- Delayed actions +- Polling with intervals + +**Characteristics**: +- Timers are durable (persisted in history) +- Can be cancelled + +## Polling Patterns + +### Frequent Polling + +**Purpose**: Frequently (once per second of faster) repeatedly check external state until condition met. + +**Implementation**: + +``` +# Inside Activity (polling_activity): +while not condition_met: + result = await call_external_api() + if result.done: + break + activity.heartbeat("Invoking activity") + await sleep(poll_interval) + + +# In workflow code: +workflow.execute_activity( + polling_activity, + PollingActivityInput(...), + start_to_close_timeout=timedelta(seconds=60), + heartbeat_timeout=timedelta(seconds=2), +) +``` + +To ensure that polling_activity is restarted in a timely manner, we make sure that it heartbeats on every iteration. Note that heartbeating only works if we set the heartbeat_timeout to a shorter value than the Activity start_to_close_timeout timeout + +**Advantage:** Because the polling loop is inside the activity, this does not pollute the workflow history. + +### Infrequent Polling + +**Purpose**: Infrequently (once per minute or slower) repeatedly poll an external service. + +**Implementation**: + +Define an Activty which fails (raises an exception) exactly when polling is not completed. + +The polling loop is accomplised via activity retries, by setting the following Retry options: +- backoff_coefficient: to 1 +- initial_interval: to the polling interval (e.g. 60 seconds) + +This will enable the Activity to be retried exactly on the set interval. + +**Advantage:** Individual Activity retries are not recorded in Workflow History, so this approach can poll for a very long time without affecting the history size. + +## Idempotency Patterns + +**Purpose**: Ensure activities can be safely retried and replayed without causing duplicate side effects. + +**Why It Matters**: Temporal may re-execute activities during retries (on failure) or replay (on worker restart). Without idempotency, this can cause duplicate charges, duplicate emails, duplicate database entries, etc. + +### Using Idempotency Keys + +Pass a unique identifier to external services so they can detect and deduplicate repeated requests: + +``` +Activity: charge_payment(order_id, amount) + │ + └── Call payment API with: + amount: $100 + idempotency_key: "order-{order_id}" + │ + └── Payment provider deduplicates based on key + (second call with same key returns original result) +``` + +**Good idempotency key sources**: +- Workflow ID (unique per workflow execution) +- Business identifier (order ID, transaction ID) +- Workflow ID + activity name + attempt number + +### Check-Before-Act Pattern + +Query the external system's state before making changes: + +``` +Activity: send_welcome_email(user_id) + │ + ├── Check: Has welcome email been sent for user_id? + │ │ + │ ├── YES: Return early (already done) + │ │ + │ └── NO: Send email, mark as sent +``` + +### Designing Idempotent Activities + +1. **Use unique identifiers** as idempotency keys with external APIs +2. **Check before acting**: Query current state before making changes +3. **Make operations repeatable**: Ensure calling twice produces the same result +4. **Record outcomes**: Store transaction IDs or results for verification +5. **Leverage external system features**: Many APIs (Stripe, AWS, etc.) have built-in idempotency key support + +### Tracking State in Workflows + +For complex multi-step operations, track completion status in workflow state: + +``` +Workflow State: + payment_completed: false + shipment_created: false + +Run: + if not payment_completed: + charge_payment(...) + payment_completed = true + + if not shipment_created: + create_shipment(...) + shipment_created = true +``` + +This ensures that on replay, already-completed steps are skipped. + +## Large Data Handling + +**Purpose**: Handle data that exceeds Temporal's payload limits without polluting workflow history. + +**Limits** (see `references/core/gotchas.md` for details): +- Max 2MB per individual payload +- Max 4MB per gRPC message +- Max 50MB for workflow history (aim for <10MB) + +**Key Principle**: Large data should never flow through workflow history. Activities read and write large data directly, passing only small references through the workflow. + +**Wrong Approach**: +``` +Workflow + │ + ├── downloadFromStorage(ref) ──▶ returns large data (enters history) + │ + ├── processData(largeData) ────▶ large data as argument (enters history AGAIN) + │ + └── uploadToStorage(result) ───▶ large data as argument (enters history AGAIN) +``` + +This defeats the purpose—large data enters workflow history multiple times. + +**Correct Approach**: +``` +Workflow + │ + └── processLargeData(inputRef) ──▶ returns outputRef (small string) + │ + └── Activity internally: + download(inputRef) → process → upload → return outputRef +``` + +The workflow only handles references (small strings). The activity does all large data operations internally. + +**Implementation Pattern**: +1. Accept a reference (URL, S3 key, database ID) as activity input +2. Download/fetch the large data inside the activity +3. Process the data inside the activity +4. Upload/store the result inside the activity +5. Return only a reference to the result + +**Other Strategies**: +- **Compression**: Use a PayloadCodec to compress data automatically +- **Chunking**: Split large collections across multiple activities, each handling a subset + +## Activity Heartbeating + +**Purpose**: Enable cancellation delivery and progress tracking for long-running activities. + +**Why Heartbeat**: +1. **Support activity cancellation** - Cancellations are delivered to activities via heartbeat. Activities that don't heartbeat won't know they've been cancelled. +2. **Resume progress after failure** - Heartbeat details persist across retries, allowing activities to resume where they left off. +3. **Detect stuck activities** - If an activity stops heartbeating, Temporal can time it out and retry. + +**How Cancellation Works**: +``` +Workflow requests activity cancellation + │ + ▼ +Temporal Service marks activity for cancellation + │ + ▼ +Activity calls heartbeat() + │ + ├── Not cancelled: heartbeat succeeds, continues + │ + └── Cancelled: heartbeat raises exception + Activity can catch this to perform cleanup +``` + +**Key Point**: If an activity never heartbeats, it will run to completion even if cancelled—it has no way to learn about the cancellation. + +## 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. + +**When to Use**: +- Short operations completing in milliseconds/seconds +- High-frequency calls where task queue overhead is significant +- Low-latency requirements where you can't afford task queue round-trip + +**Characteristics**: +- Executes on the same worker that runs the workflow +- No task queue round-trip (lower latency) +- Still recorded in history +- Should complete quickly (default timeout is short) + +**Trade-offs**: +- Less visibility in Temporal UI (no separate task) +- Must complete on the same worker +- Not suitable for long-running operations +- **Risk with consecutive local activities:** Local activity completions are only persisted when the current Workflow Task completes. Calling multiple local activities in a row (with nothing in between to yield the Workflow Task) increases the risk of losing work if the worker crashes mid-sequence. If you need a chain of operations with durable checkpoints between each step, use regular activities instead. + +## Choosing Between Patterns + +| Need | Pattern | +|------|---------| +| Send data, don't need response | Signal | +| Read state, no modification | Query | +| Modify state, need response | Update | +| Break down large workflow | Child Workflow | +| Prevent history growth | Continue-as-New | +| Rollback on failure | Saga | +| Process items concurrently | Parallel Execution | +| Long-lived stateful entity | Entity Workflow | +| Safe retries/replays | Idempotency | +| Low-latency short operations | Local Activities | diff --git a/references/core/troubleshooting.md b/references/core/troubleshooting.md new file mode 100644 index 0000000..e4ef2cb --- /dev/null +++ b/references/core/troubleshooting.md @@ -0,0 +1,323 @@ +# Temporal Troubleshooting Guide + +## Workflow Diagnosis Decision Tree + +``` +Workflow not behaving as expected? +│ +├─▶ What is the workflow status? +│ │ +│ ├─▶ RUNNING (but no progress) +│ │ └─▶ Go to: "Workflow Stuck" section +│ │ +│ ├─▶ FAILED +│ │ └─▶ Go to: "Workflow Failed" section +│ │ +│ ├─▶ TIMED_OUT +│ │ └─▶ Go to: "Timeout Issues" section +│ │ +│ └─▶ COMPLETED (but wrong result) +│ └─▶ Go to: "Wrong Result" section +``` + +## Workflow Stuck (RUNNING but No Progress) + +### Decision Tree + +``` +Workflow stuck in RUNNING? +│ +├─▶ Is a worker running? +│ │ +│ ├─▶ NO: Start a worker +│ │ └─▶ See references/core/dev-management.md +│ │ +│ └─▶ YES: Is it on the correct task queue? +│ │ +│ ├─▶ NO: Start worker with correct task queue +│ │ +│ └─▶ YES: Check for non-determinism +│ │ +│ ├─▶ NondeterminismError in logs? +│ │ └─▶ Go to: "Non-Determinism" section +│ │ +│ ├─▶ Check history for task failures +│ │ └─▶ Run: `temporal workflow show --workflow-id ` +│ │ │ +│ │ ├─▶ WorkflowTaskFailed event? +│ │ │ └─▶ Check error type in event details +│ │ │ └─▶ Go to relevant section in error-reference.md +│ │ │ +│ │ └─▶ ActivityTaskFailed event? +│ │ └─▶ Go to: "Activity Keeps Retrying" section +│ │ +│ └─▶ No errors in logs or history? +│ └─▶ Check if workflow is waiting for signal/timer +``` + +### Common Causes + +1. **No worker running** + - See references/core/dev-management.md + +2. **Worker on wrong task queue** + - Check: Worker logs for task queue name + - Fix: Start worker with matching task queue + +3. **Worker has stale code** + - Check: Worker startup time vs code changes + - Fix: Restart worker with updated code + +4. **Workflow waiting for signal** + - Check: Workflow history for pending signals + - Fix: Send expected signal or check signal sender + +5. **Activity stuck/timing out** + - Check: Activity retry attempts in history + - Fix: Investigate activity failure, increase timeout + +## Non-Determinism Errors + +### Decision Tree + +``` +NondeterminismError? +│ +├─▶ Was code intentionally changed? +│ │ +│ ├─▶ YES: Do you need to support in-flight workflows? +│ │ │ +│ │ ├─▶ YES (production): Use patching API +│ │ │ └─▶ See: references/core/versioning.md +│ │ │ +│ │ └─▶ NO (local dev/testing): Terminate or reset workflow +│ │ └─▶ `temporal workflow terminate --workflow-id ` +│ │ └─▶ Then start fresh with new code +│ │ +│ └─▶ NO: Accidental change +│ │ +│ ├─▶ Can you identify the change? +│ │ │ +│ │ ├─▶ YES: Revert and restart worker. Note, this doesn't always work if workflow has progressed past the change (may induce other code paths), so may need to reset workflow. +│ │ │ +│ │ └─▶ NO: Compare current code to expected history +│ │ └─▶ Check: Activity names, order, parameters +``` + +### Common Causes + +1. **Changed call order** + ``` + # Before # After (BREAKS) + await activity_a await activity_b + await activity_b await activity_a + ``` + +2. **Changed call name** + ``` + # Before # After (BREAKS) + await process_order(...) await handle_order(...) + ``` + +3. **Added/removed call** + - Adding new activity mid-workflow + - Removing activity that was previously called + +4. **Using non-deterministic code** + - `datetime.now()` in workflow (use `workflow.now()`) + - `random.random()` in workflow (use `workflow.random()`) + +### Recovery + +**Accidental Change:** +1. Identify the change +2. Revert code to match history +3. Restart worker +4. Workflow automatically recovers + +**Intentional Change:** +1. Use patching API for gradual migration +2. Or terminate old workflows, start new ones + +## Workflow Failed + +### Decision Tree + +``` +Workflow status = FAILED? +│ +├─▶ Check workflow error message +│ │ +│ ├─▶ Application error (your code) +│ │ └─▶ Fix the bug, start new workflow +│ │ +│ ├─▶ NondeterminismError +│ │ └─▶ Go to: "Non-Determinism" section +│ │ +│ └─▶ Timeout error +│ └─▶ Go to: "Timeout Issues" section +``` + +### Common Causes + +1. **Unhandled exception in workflow** + - Check error message and stack trace + - Fix bug in workflow code + +2. **Activity exhausted retries** + - All retry attempts failed + - Check activity logs for root cause + +3. **Non-retryable error thrown** + - Error marked as non-retryable + - Intentional failure, check business logic + +## Timeout Issues + +### Timeout Types + +| Timeout | Scope | What It Limits | +|---------|-------|----------------| +| `WorkflowExecutionTimeout` | Entire workflow | Total time including retries and continue-as-new | +| `WorkflowRunTimeout` | Single run | Time for one run (before continue-as-new) | +| `ScheduleToCloseTimeout` | Activity | Total time including retries | +| `StartToCloseTimeout` | Activity | Single attempt time | +| `HeartbeatTimeout` | Activity | Time between heartbeats | + +### Diagnosis + +``` +Timeout error? +│ +├─▶ Which timeout? +│ │ +│ ├─▶ Workflow timeout +│ │ └─▶ Increase timeout or optimize workflow. Better yet, consider removing the workflow timeout, as it is generally discourged unless *necessary* for your use case. +│ │ +│ ├─▶ ScheduleToCloseTimeout +│ │ └─▶ Activity taking too long overall (including retries) +│ │ +│ ├─▶ StartToCloseTimeout +│ │ └─▶ Single activity attempt too slow +│ │ +│ └─▶ HeartbeatTimeout +│ └─▶ Activity not heartbeating frequently enough +│ └─▶ Add heartbeat() calls in long activities +``` + +### Fixes + +1. **Increase timeout** if operation legitimately takes longer +2. **Add heartbeats** to long-running activities +3. **Optimize activity** to complete faster +4. **Break into smaller activities** for better granularity + +## Activity Keeps Retrying + +### Decision Tree + +``` +Activity retrying repeatedly? +│ +├─▶ Check activity error +│ │ +│ ├─▶ Transient error (network, timeout) +│ │ └─▶ Expected behavior, will eventually succeed +│ │ +│ ├─▶ Permanent error (bug, invalid input) +│ │ └─▶ Fix the bug or mark as non-retryable +│ │ +│ └─▶ Resource exhausted +│ └─▶ Add backoff, check rate limits +``` + +### Common Causes + +1. **Bug in activity code** + - Fix the bug + - Consider marking certain errors as non-retryable + +2. **External service down** + - Retries are working as intended + - Monitor service recovery + +3. **Invalid input** + - Validate inputs before activity + - Return non-retryable error for bad input + +## Wrong Result (Completed but Incorrect) + +### Diagnosis + +1. **Check workflow history** for unexpected activity results +2. **Verify activity implementations** produce correct output +3. **Check for race conditions** in parallel execution +4. **Verify signal handling** if signals are involved + +### Common Causes + +1. **Activity bug** - Wrong logic in activity +2. **Stale data** - Activity using outdated information +3. **Signal ordering** - Signals processed in unexpected order +4. **Parallel execution** - Race condition in concurrent operations + +## Worker Issues + +### Worker Not Starting + +``` +Worker won't start? +│ +├─▶ Connection error +│ └─▶ Check Temporal server is running +│ └─▶ `temporal server start-dev` (start in background, see references/core/dev-management.md) +│ +├─▶ Registration error +│ └─▶ Check workflow/activity definitions are valid +│ +└─▶ Other errors (imports, etc.) + └─▶ Debug those errors as usual. +``` + +### Worker Crashing + +1. **Out of memory** - Reduce concurrent tasks, check for leaks +2. **Unhandled exception** - Add error handling +3. **Dependency issue** - Check package versions + +## Useful Commands + +```bash +# Check Temporal server +temporal server start-dev + +# List workflows +temporal workflow list + +# Describe specific workflow +temporal workflow describe --workflow-id + +# Show workflow history +temporal workflow show --workflow-id + +# Terminate stuck workflow +temporal workflow terminate --workflow-id + +# Reset workflow to specific point +temporal workflow reset --workflow-id --event-id +``` + +## Quick Reference: Status → Action + +| Status | First Check | Common Fix | +|--------|-------------|------------| +| RUNNING (stuck) | Worker running? | Start/restart worker | +| FAILED | Error message | Fix bug, handle error | +| TIMED_OUT | Which timeout? | Increase timeout or optimize | +| TERMINATED | Who terminated? | Check audit log | +| CANCELED | Cancellation source | Expected or investigate | + +## See Also + +- [Common Gotchas](gotchas.md) - Anti-patterns that cause these issues +- [Error Reference](error-reference.md) - Quick error type lookup diff --git a/references/core/versioning.md b/references/core/versioning.md new file mode 100644 index 0000000..226bb83 --- /dev/null +++ b/references/core/versioning.md @@ -0,0 +1,174 @@ +# Workflow Versioning Concepts + +This document provides core conceptual explanations of workflow versioning in Temporal. For language-specific implementation details see `references/{your_language}/versioning.md`, for the language you are working in. + +## Overview + +Workflow versioning allows safe deployment of code changes without breaking running workflows. Three approaches available: + +1. **Patching API** - Code-level version branching +2. **Workflow Type Versioning** - New workflow types for incompatible changes +3. **Worker Versioning** - Deployment-level control with Build IDs + +## Why Versioning is Needed + +When workers restart after deployment, they resume open workflows through history replay. If updated code produces different Commands than the original code, it causes non-determinism errors. + +``` +Original Code (recorded in history): + await activity_a() + await activity_b() + +Updated Code (during replay): + await activity_a() + await activity_c() ← Different! NondeterminismError +``` + +## Approach 1: Patching API + +### Concept + +The patching API lets you branch code based on whether a workflow was started before or after a code change. + +``` +if patched("my-change"): + // New code path (for new and replaying new workflows) +else: + // Old code path (for replaying old workflows) +``` + +### Three-Phase Lifecycle + +**Phase 1: Patch In** +- Add both old and new code paths +- New workflows take new path, old workflows take old path + +**Phase 2: Deprecate** +- After all old workflows complete, remove old code +- Keep deprecation marker for history compatibility + +**Phase 3: Remove** +- After all deprecated workflows complete +- Remove patch entirely, only new code remains + +### When to Use + +- Adding, removing, or reordering activities/child workflows +- Changing which activity/child workflow is called +- Any change that alters the Command sequence + +### When NOT to Use + +- Changing activity implementations (activities aren't replayed) +- Changing arguments passed to activities or child workflows +- Changing retry policies +- Changing timer durations +- Adding new signal/query/update handlers (additive changes are safe) +- Bug fixes that don't change Command sequence + +Unnecessary patching adds complexity and can make workflow code unmanageable. + +## Approach 2: Workflow Type Versioning + +### Concept + +Create a new workflow type (e.g., `OrderWorkflowV2`) instead of patching. + +``` +// Old: OrderWorkflow +// New: OrderWorkflowV2 (completely new implementation) +``` + +### When to Use + +- Major incompatible changes +- Complete rewrites +- When patching would be too complex +- When you want clean separation + +### Process + +1. Create new workflow type with new name +2. Register both with worker +3. Start new workflows with new type +4. Wait for old workflows to complete +5. Remove old workflow type + +## Approach 3: Worker Versioning + +### Concept + +Manage versions at deployment level using Build IDs. Multiple worker versions can run simultaneously. + +``` +Worker v1.0 (Build ID: abc123) + └── Handles workflows started on this version + +Worker v2.0 (Build ID: def456) + └── Handles new workflows + └── Can also handle upgraded old workflows +``` + +### Key Concepts + +**Worker Deployment**: Logical service grouping (e.g., "order-service") + +**Build ID**: Specific code version (e.g., git commit hash) + +**Versioning Behaviors**: +- `PINNED` - Workflows stay on original worker version +- `AUTO_UPGRADE` - Workflows can move to newer versions + +### When to Use PINNED + +- Short-running workflows (minutes to hours) +- Consistency is critical +- Want simplest development experience +- Building new applications + +### When to Use AUTO_UPGRADE + +- Long-running workflows (weeks or months) +- Workflows need bug fixes during execution +- Still requires patching for version transitions + +## Choosing an Approach + +| Scenario | Recommended Approach | +|----------|---------------------| +| Small change, few running workflows | Patching API | +| Major rewrite | Workflow Type Versioning | +| Many short workflows, frequent deploys | Worker Versioning (PINNED) | +| Long-running workflows needing updates | Worker Versioning (AUTO_UPGRADE) + Patching | +| Quick fix, can wait for completion | Wait for workflows to complete | + +## Best Practices + +1. **Check for open executions** before removing old code +2. **Use descriptive patch IDs** (e.g., "add-fraud-check" not "patch-1") +3. **Deploy incrementally**: patch → deprecate → remove +4. **Test replay compatibility** before deploying changes +5. **Monitor old workflow counts** during migration + +## Finding Workflows by Version + +```bash +# Find workflows with specific patch +temporal workflow list --query \ + 'WorkflowType = "OrderWorkflow" AND TemporalChangeVersion = "add-fraud-check"' + +# Find pre-patch workflows +temporal workflow list --query \ + 'WorkflowType = "OrderWorkflow" AND TemporalChangeVersion IS NULL' + +# Find workflows on specific worker version +temporal workflow list --query \ + 'TemporalWorkerDeploymentVersion = "my-service:v1.0.0"' +``` + +## Common Mistakes + +1. **Removing old code too early** - Breaks replaying workflows +2. **Not testing with replay** - Catches issues before production +3. **Patching non-Command changes** - Unnecessary complexity +4. **Forgetting to deprecate** - Accumulates dead code diff --git a/references/go/advanced-features.md b/references/go/advanced-features.md new file mode 100644 index 0000000..55e4e57 --- /dev/null +++ b/references/go/advanced-features.md @@ -0,0 +1,187 @@ +# Go SDK Advanced Features + +## Schedules + +Create recurring workflow executions using the Schedule API. + +```go +scheduleHandle, err := c.ScheduleClient().Create(ctx, client.ScheduleOptions{ + ID: "daily-report", + Spec: client.ScheduleSpec{ + CronExpressions: []string{"0 9 * * *"}, + }, + Action: &client.ScheduleWorkflowAction{ + ID: "daily-report-workflow", + Workflow: DailyReportWorkflow, + TaskQueue: "reports", + }, +}) +``` + +Using intervals instead of cron: + +```go +scheduleHandle, err := c.ScheduleClient().Create(ctx, client.ScheduleOptions{ + ID: "hourly-sync", + Spec: client.ScheduleSpec{ + Intervals: []client.ScheduleIntervalSpec{ + {Every: time.Hour}, + }, + }, + Action: &client.ScheduleWorkflowAction{ + ID: "hourly-sync-workflow", + Workflow: SyncWorkflow, + TaskQueue: "sync", + }, +}) +``` + +Manage schedules: + +```go +handle := c.ScheduleClient().GetHandle(ctx, "daily-report") + +// Pause / unpause +handle.Pause(ctx, client.SchedulePauseOptions{Note: "Maintenance window"}) +handle.Unpause(ctx, client.ScheduleUnpauseOptions{Note: "Maintenance complete"}) + +// Trigger immediately +handle.Trigger(ctx, client.ScheduleTriggerOptions{}) + +// Describe +desc, err := handle.Describe(ctx) + +// Delete +handle.Delete(ctx) +``` + +## Async Activity Completion + +For activities that complete asynchronously (e.g., human tasks, external callbacks). +If you configure a heartbeat_timeout on this activity, the external completer is responsible for sending heartbeats via the async handle. +If you do NOT set a heartbeat_timeout, no heartbeats are required. + +**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. + +**Step 1: Return `activity.ErrResultPending` from the activity.** + +```go +func RequestApproval(ctx context.Context, requestID string) (string, error) { + activityInfo := activity.GetInfo(ctx) + taskToken := activityInfo.TaskToken + + // Store taskToken externally (e.g., database) for later completion + err := storeTaskToken(requestID, taskToken) + if err != nil { + return "", err + } + + // Signal that this activity will be completed externally + return "", activity.ErrResultPending +} +``` + +**Step 2: Complete from another process using the task token.** + +```go +temporalClient, err := client.Dial(client.Options{}) + +// Complete the activity +err = temporalClient.CompleteActivity(ctx, taskToken, "approved", nil) + +// Or fail it +err = temporalClient.CompleteActivity(ctx, taskToken, nil, errors.New("rejected")) +``` + +Or complete by ID (no task token needed): + +```go +err = temporalClient.CompleteActivityByID(ctx, namespace, workflowID, runID, activityID, "approved", nil) +``` + +## Worker Tuning + +Configure `worker.Options` for production workloads: + +```go +w := worker.New(c, "my-task-queue", worker.Options{ + // Max concurrent activity executions (default: 1000) + MaxConcurrentActivityExecutionSize: 500, + + // Max concurrent workflow task executions (default: 1000) + MaxConcurrentWorkflowTaskExecutionSize: 500, + + // Max concurrent activity task pollers (default: 2) + MaxConcurrentActivityTaskPollers: 4, + + // Max concurrent workflow task pollers (default: 2) + MaxConcurrentWorkflowTaskPollers: 4, + + // Graceful shutdown timeout (default: 0) + WorkerStopTimeout: 30 * time.Second, +}) +``` + +Scale pollers based on task queue throughput. If you observe high schedule-to-start latency, increase the number of pollers or add more workers. + +## Sessions + +Go-specific feature for routing multiple activities to the same worker. All activities using the session context execute on the same worker host. + +**Enable on the worker:** + +```go +w := worker.New(c, "fileprocessing", worker.Options{ + EnableSessionWorker: true, + MaxConcurrentSessionExecutionSize: 100, // default: 1000 +}) +``` + +**Use in a workflow:** + +```go +func FileProcessingWorkflow(ctx workflow.Context, file FileParam) error { + ao := workflow.ActivityOptions{ + StartToCloseTimeout: time.Minute, + } + ctx = workflow.WithActivityOptions(ctx, ao) + + sessionCtx, err := workflow.CreateSession(ctx, &workflow.SessionOptions{ + CreationTimeout: time.Minute, + ExecutionTimeout: 10 * time.Minute, + }) + if err != nil { + return err + } + defer workflow.CompleteSession(sessionCtx) + + // All three activities run on the same worker + var downloadResult string + err = workflow.ExecuteActivity(sessionCtx, DownloadFile, file.URL).Get(sessionCtx, &downloadResult) + if err != nil { + return err + } + + var processResult string + err = workflow.ExecuteActivity(sessionCtx, ProcessFile, downloadResult).Get(sessionCtx, &processResult) + if err != nil { + return err + } + + err = workflow.ExecuteActivity(sessionCtx, UploadFile, processResult).Get(sessionCtx, nil) + return err +} +``` + +Key points: +- `workflow.ErrSessionFailed` is returned if the worker hosting the session dies +- `CompleteSession` releases resources -- always call it (use `defer`) +- Use case: file processing (download, process, upload on same host), GPU workloads, or any pipeline needing local state +- `MaxConcurrentSessionExecutionSize` on `worker.Options` limits how many sessions a single worker can handle + +**Limitations:** +- Sessions do not survive worker process restarts — if the worker dies, the session fails and activities must be retried from the workflow level +- There is no server-side support for sessions — the Go SDK implements them entirely client-side using internal task queue routing +- Session concurrency limiting is per-process, not per-host — only one worker process per host if you rely on this + +**Relationship to worker-specific task queues:** Sessions are essentially a convenience API over the "worker-specific task queue" pattern, where each worker creates a unique task queue and routes activities to it. For simple cases where you don't need separate activities (e.g., download + process + upload can be one unit), consider using a single long-running activity with heartbeating instead. diff --git a/references/go/data-handling.md b/references/go/data-handling.md new file mode 100644 index 0000000..e887e7b --- /dev/null +++ b/references/go/data-handling.md @@ -0,0 +1,262 @@ +# Go SDK Data Handling + +## Overview + +The Go SDK uses the `converter.DataConverter` interface to serialize/deserialize workflow inputs, outputs, and activity parameters. The default converter converts values to JSON. + +## Default Data Converter + +The default `CompositeDataConverter` applies converters in order until one returns a non-nil Payload: + +1. `converter.NewNilPayloadConverter()` -- nil values +2. `converter.NewByteSlicePayloadConverter()` -- `[]byte` +3. `converter.NewProtoJSONPayloadConverter()` -- Protobuf messages as JSON +4. `converter.NewProtoPayloadConverter()` -- Protobuf messages as binary +5. `converter.NewJSONPayloadConverter()` -- anything JSON-serializable + +Structs must have exported fields to be serialized. + +## Custom Data Converter + +In most cases you don't implement the full `DataConverter` interface directly. Instead, implement a **`PayloadConverter`** for your specific type and insert it into a `CompositeDataConverter`. The `PayloadConverter` interface has four methods: + +```go +type PayloadConverter interface { + ToPayload(value interface{}) (*commonpb.Payload, error) // return nil if this type isn't handled + FromPayload(payload *commonpb.Payload, valuePtr interface{}) error + ToString(payload *commonpb.Payload) string + Encoding() string // e.g. "json/msgpack" +} +``` + +**Example — custom msgpack PayloadConverter:** + +```go +import ( + "encoding/json" + "fmt" + + commonpb "go.temporal.io/api/common/v1" + "go.temporal.io/sdk/converter" + "github.com/vmihailenco/msgpack/v5" +) + +const encodingMsgpack = "binary/msgpack" + +type MsgpackPayloadConverter struct{} + +func (c *MsgpackPayloadConverter) Encoding() string { + return encodingMsgpack +} + +func (c *MsgpackPayloadConverter) ToPayload(value interface{}) (*commonpb.Payload, error) { + if value == nil { + return nil, nil + } + data, err := msgpack.Marshal(value) + if err != nil { + return nil, fmt.Errorf("msgpack marshal: %w", err) + } + return &commonpb.Payload{ + Metadata: map[string][]byte{ + converter.MetadataEncoding: []byte(encodingMsgpack), + }, + Data: data, + }, nil +} + +func (c *MsgpackPayloadConverter) FromPayload(payload *commonpb.Payload, valuePtr interface{}) error { + if string(payload.GetMetadata()[converter.MetadataEncoding]) != encodingMsgpack { + return fmt.Errorf("unsupported encoding") + } + return msgpack.Unmarshal(payload.Data, valuePtr) +} + +func (c *MsgpackPayloadConverter) ToString(payload *commonpb.Payload) string { + // Decode to a map for human-readable display + var v interface{} + if err := msgpack.Unmarshal(payload.Data, &v); err != nil { + return fmt.Sprintf("", err) + } + b, _ := json.Marshal(v) + return string(b) +} +``` + +**Register in a CompositeDataConverter and pass to the client:** + +```go +dataConverter := converter.NewCompositeDataConverter( + converter.NewNilPayloadConverter(), + converter.NewByteSlicePayloadConverter(), + &MsgpackPayloadConverter{}, // handles your type; falls through to JSON for everything else + converter.NewJSONPayloadConverter(), +) + +c, err := client.Dial(client.Options{ + DataConverter: dataConverter, +}) +``` + +**Per-activity/child-workflow override** — use a different converter for specific calls: + +```go +actCtx := workflow.WithDataConverter(ctx, mySpecialConverter) +workflow.ExecuteActivity(actCtx, SensitiveActivity, input) +``` + +**Note:** If your converter makes remote calls (e.g., to a KMS for encryption), wrap it with `workflow.DataConverterWithoutDeadlockDetection` to avoid deadlock detection timeouts in workflow code. + +## Composition of Payload Converters + +Use `converter.NewCompositeDataConverter` to chain type-specific converters. The first converter that can handle the type wins. + +```go +dataConverter := converter.NewCompositeDataConverter( + converter.NewNilPayloadConverter(), + converter.NewByteSlicePayloadConverter(), + converter.NewProtoJSONPayloadConverter(), + converter.NewProtoPayloadConverter(), + YourCustomPayloadConverter(), + converter.NewJSONPayloadConverter(), +) +``` + +## Protobuf Support + +Binary protobuf: +```go +converter.NewProtoPayloadConverter() +``` + +JSON protobuf: +```go +converter.NewProtoJSONPayloadConverter() +``` + +Both are included in the default data converter. SDK v1.26.0 (March 2024) migrated from gogo/protobuf to google/protobuf. If you need backward compatibility with older payloads encoded with gogo, use the `LegacyTemporalProtoCompat` option. + +## Payload Encryption + +Implement the `converter.PayloadCodec` interface (`Encode` and `Decode`) and wrap the default data converter: + +```go +// Codec implements converter.PayloadCodec for encryption. +type Codec struct{} + +func (Codec) Encode(payloads []*commonpb.Payload) ([]*commonpb.Payload, error) { + result := make([]*commonpb.Payload, len(payloads)) + for i, p := range payloads { + origBytes, err := p.Marshal() + if err != nil { + return payloads, err + } + encrypted := encrypt(origBytes) // your encryption logic + result[i] = &commonpb.Payload{ + Metadata: map[string][]byte{converter.MetadataEncoding: []byte("binary/encrypted")}, + Data: encrypted, + } + } + return result, nil +} + +func (Codec) Decode(payloads []*commonpb.Payload) ([]*commonpb.Payload, error) { + result := make([]*commonpb.Payload, len(payloads)) + for i, p := range payloads { + if string(p.Metadata[converter.MetadataEncoding]) != "binary/encrypted" { + result[i] = p + continue + } + decrypted := decrypt(p.Data) // your decryption logic + result[i] = &commonpb.Payload{} + err := result[i].Unmarshal(decrypted) + if err != nil { + return payloads, err + } + } + return result, nil +} +``` + +Wrap with `CodecDataConverter` and pass to client: + +```go +var DataConverter = converter.NewCodecDataConverter( + converter.GetDefaultDataConverter(), + &Codec{}, +) + +c, err := client.Dial(client.Options{ + DataConverter: DataConverter, +}) +``` + +## Search Attributes + +Set at workflow start: + +```go +handle, err := c.ExecuteWorkflow(ctx, client.StartWorkflowOptions{ + ID: "order-123", + TaskQueue: "orders", + SearchAttributes: map[string]interface{}{ + "OrderStatus": "pending", + "CustomerId": "cust-456", + }, +}, OrderWorkflow, input) +``` + +Upsert from within a workflow: + +```go +err := workflow.UpsertSearchAttributes(ctx, map[string]interface{}{ + "OrderStatus": "completed", +}) +``` + +Typed search attributes (v1.26.0+, preferred): + +```go +var OrderStatusKey = temporal.NewSearchAttributeKeyKeyword("OrderStatus") + +err := workflow.UpsertTypedSearchAttributes(ctx, OrderStatusKey.ValueSet("completed")) +``` + +Query workflows by search attributes: + +```go +resp, err := c.ListWorkflow(ctx, &workflowservice.ListWorkflowExecutionsRequest{ + Query: `OrderStatus = "pending" AND CustomerId = "cust-456"`, +}) +``` + +## Workflow Memo + +Set in start options: + +```go +handle, err := c.ExecuteWorkflow(ctx, client.StartWorkflowOptions{ + ID: "order-123", + TaskQueue: "orders", + Memo: map[string]interface{}{ + "customerName": "Alice", + "notes": "Priority customer", + }, +}, OrderWorkflow, input) +``` + +Read memo from workflow info. Upsert memo (Go SDK only): + +```go +err := workflow.UpsertMemo(ctx, map[string]interface{}{ + "notes": "Updated notes", +}) +``` + +## Best Practices + +1. Use structs with exported fields for inputs and outputs +2. Prefer JSON for readability during development, protobuf for performance in production +3. Keep payloads small -- see `references/core/gotchas.md` for limits +4. Use `PayloadCodec` for encryption; never store sensitive data unencrypted +5. Configure the same data converter on both client and worker diff --git a/references/go/determinism-protection.md b/references/go/determinism-protection.md new file mode 100644 index 0000000..4a6f5f4 --- /dev/null +++ b/references/go/determinism-protection.md @@ -0,0 +1,98 @@ +# Go Workflow Determinism Protection + +## Overview + +The Go SDK has no runtime sandbox. Determinism is enforced by **developer convention** and **optional static analysis**. Unlike the Python and TypeScript SDKs, the Go SDK will not intercept or replace non-deterministic calls at runtime. The Go SDK does perform a limited runtime command-ordering check, but catching non-deterministic code before deployment requires the `workflowcheck` tool and testing, in particular replay tests (see `references/go/testing`). + +## workflowcheck Static Analysis + +### Install + +```bash +go install go.temporal.io/sdk/contrib/tools/workflowcheck@latest +``` + +### Run + +```bash +workflowcheck ./... +``` + +No output means all registered workflows are deterministic. Non-deterministic code produces hierarchical output showing the call chain to the offending code. + +Use `-show-pos` for exact file positions: + +```bash +workflowcheck -show-pos ./... +``` + +### What It Detects + +**Non-deterministic functions/variables:** +- `time.Now` -- obtaining current time +- `time.Sleep` -- sleeping +- `crypto/rand.Reader` -- crypto random reader +- `math/rand.globalRand` -- global pseudorandom +- `os.Stdin`, `os.Stdout`, `os.Stderr` -- standard I/O streams + +**Non-deterministic Go constructs:** +- Starting a goroutine (`go func()`) +- Sending to a channel +- Receiving from a channel +- Iterating over a channel via `range` +- Iterating over a map via `range` + +### Limitations + +`workflowcheck` cannot catch everything. It does **not** detect: +- Global variable mutation +- Non-determinism via reflection +- Runtime-conditional non-determinism + +### Suppressing False Positives + +Add `//workflowcheck:ignore` on or directly above the offending line: + +```go +now := time.Now() //workflowcheck:ignore +``` + +For broader suppression, use a YAML config file: + +```yaml +# workflowcheck.config.yaml +decls: + path/to/package.MyDeterministicFunc: false +``` + +```bash +workflowcheck -config workflowcheck.config.yaml ./... +``` + +## Determinism Rules + +**You must:** +- Use `workflow.Go(ctx, func(ctx workflow.Context) { ... })` instead of `go` +- Use `workflow.NewChannel(ctx)` instead of `chan` +- Use `workflow.NewSelector(ctx)` instead of `select` +- Use `workflow.Sleep(ctx, duration)` instead of `time.Sleep()` +- Use `workflow.Now(ctx)` instead of `time.Now()` +- Use `workflow.GetLogger(ctx)` instead of `fmt.Println` / `log.Println` +- Sort map keys before iterating, or use `workflow.SideEffect` / an activity + +**You must not:** +- Start native goroutines +- Use native channels or `select` +- Call `time.Now()` or `time.Sleep()` +- Use `math/rand` global functions or `crypto/rand.Reader` +- Access `os.Stdin`, `os.Stdout`, or `os.Stderr` +- Mutate global variables +- Make network calls, file I/O, or database queries (use activities) + +## Best Practices + +1. **Run `workflowcheck` in CI / pre-commit** -- catch non-deterministic code before it reaches production +2. **Keep workflow code thin** -- workflows should orchestrate; delegate all I/O and non-deterministic work to activities +3. **Use struct methods for activities** -- keeps imports clean and avoids pulling non-deterministic dependencies into workflow files +4. **Separate workflow and activity files** -- reduces the surface area that `workflowcheck` needs to analyze and keeps concerns isolated +5. **Test with replay** after any workflow code change to verify backward compatibility diff --git a/references/go/determinism.md b/references/go/determinism.md new file mode 100644 index 0000000..0cff905 --- /dev/null +++ b/references/go/determinism.md @@ -0,0 +1,52 @@ +# Go SDK Determinism + +## Overview + +The Go SDK has NO runtime sandbox (unlike Python/TypeScript). Workflows must be deterministic for replay, and determinism is enforced entirely by developer convention and optional static analysis via the `workflowcheck` tool (see `references/go/determinism-protection.md`). + +## Why Determinism Matters: History Replay + +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 + +Do not use any of the following in workflow code: + +- **Native goroutines** (`go func()`) -- use `workflow.Go()` instead +- **Native channels** (`chan`, send, receive, `range` over channel) -- use `workflow.Channel` instead +- **Native `select`** -- use `workflow.Selector` instead +- **`time.Now()`** -- use `workflow.Now(ctx)` instead +- **`time.Sleep()`** -- use `workflow.Sleep(ctx, duration)` instead +- **`math/rand` global** (e.g., `rand.Intn()`) -- use `workflow.SideEffect` instead +- **`crypto/rand.Reader`** -- use an activity instead +- **`os.Stdin` / `os.Stdout` / `os.Stderr`** -- use `workflow.GetLogger(ctx)` for logging +- **Map range iteration** (`for k, v := range myMap`) -- sort keys first, then iterate +- **Mutating global variables** -- use local state or `workflow.SideEffect` +- **Anonymous functions as local activities** -- the name is derived from the function and will be non-deterministic across replays; always use named functions for local activities + +## Safe Builtin Alternatives + +| Instead of | Use | +|---|---| +| `go func() { ... }()` | `workflow.Go(ctx, func(ctx workflow.Context) { ... })` | +| `chan T` | `workflow.NewChannel(ctx)` / `workflow.NewBufferedChannel(ctx, size)` | +| `select { ... }` | `workflow.NewSelector(ctx)` | +| `time.Now()` | `workflow.Now(ctx)` | +| `time.Sleep(d)` | `workflow.Sleep(ctx, d)` | +| `rand.Intn(100)` | `workflow.SideEffect(ctx, func(ctx workflow.Context) interface{} { return rand.Intn(100) })` | +| `uuid.New()` | `workflow.SideEffect` or pass as activity result | +| `log.Println(...)` | `workflow.GetLogger(ctx).Info(...)` | + +## Testing Replay Compatibility + +Use `worker.WorkflowReplayer` to verify code changes are compatible with existing histories. See the Workflow Replay Testing section of `references/go/testing.md` + +## Best Practices + +1. Run `workflowcheck ./...` in CI to catch non-deterministic code early +2. Always use `workflow.*` APIs instead of native Go concurrency and time primitives +3. Move all I/O operations (network, filesystem, database) into activities +4. Sort map keys before iterating if you must iterate over a map in workflow code +5. Use `workflow.GetLogger(ctx)` instead of `fmt.Println` or `log.Println` 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 diff --git a/references/go/error-handling.md b/references/go/error-handling.md new file mode 100644 index 0000000..92a856b --- /dev/null +++ b/references/go/error-handling.md @@ -0,0 +1,184 @@ +# Go SDK Error Handling + +## Overview + +The Go SDK uses error return values (not exceptions). All Temporal errors implement the `error` interface. Activity errors returned to workflows are wrapped in `*temporal.ActivityError`; use `errors.As` to unwrap them. + +## Application Errors + +```go +import "go.temporal.io/sdk/temporal" + +func ValidateOrder(ctx context.Context, order Order) error { + if !order.IsValid() { + return temporal.NewApplicationError( + "Invalid order", + "ValidationError", + ) + } + return nil +} +``` + +`temporal.NewApplicationError(message, errType, details...)` creates a retryable `*temporal.ApplicationError`. Use `NewApplicationErrorWithCause` to include a wrapped cause. + +## Non-Retryable Errors + +```go +func ChargeCard(ctx context.Context, input ChargeCardInput) (string, error) { + if !isValidCard(input.CardNumber) { + return "", temporal.NewNonRetryableApplicationError( + "Permanent failure - invalid credit card", + "PaymentError", + nil, // cause + ) + } + return processPayment(input.CardNumber, input.Amount) +} +``` + +`temporal.NewNonRetryableApplicationError(message, errType, cause, details...)` is always non-retryable regardless of RetryPolicy. You can also mark error types as non-retryable in the RetryPolicy instead: + +```go +RetryPolicy: &temporal.RetryPolicy{ + NonRetryableErrorTypes: []string{"PaymentError", "ValidationError"}, +}, +``` + +## Handling Activity Errors in Workflows + +```go +import ( + "errors" + + "go.temporal.io/sdk/temporal" + "go.temporal.io/sdk/workflow" +) + +func MyWorkflow(ctx workflow.Context) (string, error) { + var result string + err := workflow.ExecuteActivity(ctx, RiskyActivity).Get(ctx, &result) + if err != nil { + var applicationErr *temporal.ApplicationError + if errors.As(err, &applicationErr) { + switch applicationErr.Type() { + case "ValidationError": + // handle validation error + case "PaymentError": + // handle payment error + default: + // handle unknown error type + } + } + + var timeoutErr *temporal.TimeoutError + if errors.As(err, &timeoutErr) { + switch timeoutErr.TimeoutType() { + case enumspb.TIMEOUT_TYPE_START_TO_CLOSE: + // handle start-to-close timeout + case enumspb.TIMEOUT_TYPE_HEARTBEAT: + // handle heartbeat timeout + } + } + + var canceledErr *temporal.CanceledError + if errors.As(err, &canceledErr) { + // handle cancellation + } + + var panicErr *temporal.PanicError + if errors.As(err, &panicErr) { + // panicErr.Error() and panicErr.StackTrace() + } + + return "", err + } + return result, nil +} +``` + +## Retry Configuration + +```go +import ( + "time" + + "go.temporal.io/sdk/temporal" + "go.temporal.io/sdk/workflow" +) + +func MyWorkflow(ctx workflow.Context) error { + ao := workflow.ActivityOptions{ + StartToCloseTimeout: 10 * time.Minute, + RetryPolicy: &temporal.RetryPolicy{ + InitialInterval: time.Second, + BackoffCoefficient: 2.0, + MaximumInterval: time.Minute, + MaximumAttempts: 5, + NonRetryableErrorTypes: []string{"ValidationError", "PaymentError"}, + }, + } + ctx = workflow.WithActivityOptions(ctx, ao) + return workflow.ExecuteActivity(ctx, MyActivity).Get(ctx, nil) +} +``` + +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 + +```go +ao := workflow.ActivityOptions{ + StartToCloseTimeout: 5 * time.Minute, // Single attempt max duration + ScheduleToCloseTimeout: 30 * time.Minute, // Total time including retries + ScheduleToStartTimeout: 10 * time.Minute, // Time waiting in task queue + HeartbeatTimeout: 2 * time.Minute, // Between heartbeats +} +ctx = workflow.WithActivityOptions(ctx, ao) +``` + +- **StartToCloseTimeout**: Max time for a single Activity Task Execution. Prefer this over ScheduleToCloseTimeout. +- **ScheduleToCloseTimeout**: Total time including retries. +- **ScheduleToStartTimeout**: Time an Activity Task can wait in the Task Queue before a Worker picks it up. Rarely needed. +- **HeartbeatTimeout**: Max time between heartbeats. Required for long-running activities to detect failures. + +Either `StartToCloseTimeout` or `ScheduleToCloseTimeout` must be set. + +## Workflow Failure + +Returning any error from a workflow function fails the execution. Return `nil` for success. + +**Important Go-specific behavior:** In the Go SDK, returning any error from a workflow fails the workflow execution by default — there is no automatic retry. This differs from other SDKs (Python, TypeScript) where non-`ApplicationError` exceptions cause the workflow task to retry indefinitely. In Go, if you want workflow-level retries, you must explicitly set a `RetryPolicy` on the `StartWorkflowOptions`. + +```go +func MyWorkflow(ctx workflow.Context) (string, error) { + if someCondition { + return "", temporal.NewApplicationError( + "Cannot process order", + "BusinessError", + ) + } + return "success", nil +} +``` + +To prevent workflow retry, return a non-retryable error: + +```go +return "", temporal.NewNonRetryableApplicationError( + "Unrecoverable failure", + "FatalError", + nil, +) +``` + +**Note:** If an activity returns a non-retryable error, the workflow receives an `*temporal.ActivityError` wrapping it. To fail the workflow without retry, wrap it in a new `NewNonRetryableApplicationError`. + +## Best Practices + +1. Use specific error types for different failure modes +2. Mark permanent failures as non-retryable +3. Set appropriate timeouts; prefer `StartToCloseTimeout` over `ScheduleToCloseTimeout` +4. Let Temporal handle retries via RetryPolicy rather than implementing retry logic yourself +5. Use `errors.As` to unwrap and inspect specific error types +6. Design activities to be idempotent for safe retries (see `references/core/patterns.md`) diff --git a/references/go/go.md b/references/go/go.md new file mode 100644 index 0000000..cc87a6a --- /dev/null +++ b/references/go/go.md @@ -0,0 +1,242 @@ +# Temporal Go SDK Reference + +## Overview + +The Temporal Go SDK (`go.temporal.io/sdk`) provides a strongly-typed, idiomatic Go approach to building durable workflows. Workflows are regular exported Go functions. The Go SDK does not have an automatic sandbox -- determinism is the developer's responsibility, aided by the `workflowcheck` static analysis tool. + +## Quick Start + +**Add Dependency:** In your Go module, add the Temporal SDK: +```bash +go get go.temporal.io/sdk +``` + +**workflows/greeting.go** - Workflow definition: +```go +package workflows + +import ( + "time" + + "go.temporal.io/sdk/workflow" +) + +func GreetingWorkflow(ctx workflow.Context, name string) (string, error) { + ao := workflow.ActivityOptions{ + StartToCloseTimeout: time.Minute, + } + ctx = workflow.WithActivityOptions(ctx, ao) + + var result string + err := workflow.ExecuteActivity(ctx, "Greet", name).Get(ctx, &result) + if err != nil { + return "", err + } + return result, nil +} +``` + +**activities/greet.go** - Activity definition: +```go +package activities + +import ( + "context" + "fmt" +) + +type Activities struct{} + +func (a *Activities) Greet(ctx context.Context, name string) (string, error) { + return fmt.Sprintf("Hello, %s!", name), nil +} +``` + +**worker/main.go** - Worker setup: +```go +package main + +import ( + "log" + + "yourmodule/activities" + "yourmodule/workflows" + + "go.temporal.io/sdk/client" + "go.temporal.io/sdk/worker" +) + +func main() { + c, err := client.Dial(client.Options{}) + if err != nil { + log.Fatalln("Unable to create client", err) + } + defer c.Close() + + w := worker.New(c, "my-task-queue", worker.Options{}) + + w.RegisterWorkflow(workflows.GreetingWorkflow) + w.RegisterActivity(&activities.Activities{}) + + err = w.Run(worker.InterruptCh()) + if err != nil { + log.Fatalln("Unable to start worker", err) + } +} +``` + +**Start the dev server:** Start `temporal server start-dev` in the background. + +**Start the worker:** Run `go run worker/main.go` in the background. + +**starter/main.go** - Start a workflow execution: +```go +package main + +import ( + "context" + "fmt" + "log" + + "yourmodule/workflows" + + "github.com/google/uuid" + "go.temporal.io/sdk/client" +) + +func main() { + c, err := client.Dial(client.Options{}) + if err != nil { + log.Fatalln("Unable to create client", err) + } + defer c.Close() + + options := client.StartWorkflowOptions{ + ID: uuid.NewString(), + TaskQueue: "my-task-queue", + } + + we, err := c.ExecuteWorkflow(context.Background(), options, workflows.GreetingWorkflow, "my name") + if err != nil { + log.Fatalln("Unable to execute workflow", err) + } + + var result string + err = we.Get(context.Background(), &result) + if err != nil { + log.Fatalln("Unable to get workflow result", err) + } + + fmt.Println("Result:", result) +} +``` + +**Run the workflow:** Run `go run starter/main.go`. Should output: `Result: Hello, my name!`. + +## Key Concepts + +### Workflow Definition +- Exported function with `workflow.Context` as the first parameter +- Returns `(ResultType, error)` or just `error` +- Signature: `func MyWorkflow(ctx workflow.Context, input MyInput) (MyOutput, error)` +- Use `workflow.SetQueryHandler()`, `workflow.SetUpdateHandler()` for handlers +- Register with `w.RegisterWorkflow(MyWorkflow)` + +### Activity Definition +- Regular function or struct methods with `context.Context` as the first parameter +- Struct methods are preferred for dependency injection +- Signature: `func (a *Activities) MyActivity(ctx context.Context, input string) (string, error)` +- Register struct with `w.RegisterActivity(&Activities{})` (registers all exported methods) + +### Worker Setup +- Create client with `client.Dial(client.Options{})` +- Create worker with `worker.New(c, "task-queue", worker.Options{})` +- Register workflows and activities +- Run with `w.Run(worker.InterruptCh())` + +### Determinism + +**Workflow code must be deterministic!** The Go SDK has no sandbox -- determinism is enforced by convention and tooling. + +Use Temporal replacements instead of native Go constructs: +- `workflow.Go()` instead of `go` (goroutines) +- `workflow.Channel` instead of `chan` +- `workflow.Selector` instead of `select` +- `workflow.Sleep()` instead of `time.Sleep()` +- `workflow.Now()` instead of `time.Now()` +- `workflow.GetLogger()` instead of `log` / `fmt.Println` for replay-safe logging + +Use the **`workflowcheck`** static analysis tool to catch non-deterministic code: +```bash +go install go.temporal.io/sdk/contrib/tools/workflowcheck@latest +workflowcheck ./... +``` + +Read `references/core/determinism.md` and `references/go/determinism.md` to understand more. + +## File Organization Best Practice + +**Use separate packages for workflows, activities, and worker.** Activities as struct methods enable dependency injection at the worker level. + +``` +myapp/ +├── workflows/ +│ └── greeting.go # Only Workflow functions +├── activities/ +│ └── greet.go # Activity struct and methods +├── worker/ +│ └── main.go # Worker setup, imports both +└── starter/ + └── main.go # Client code to start workflows +``` + +**Activities as struct methods for dependency injection:** +```go +// activities/greet.go +type Activities struct { + HTTPClient *http.Client + DB *sql.DB +} + +func (a *Activities) FetchData(ctx context.Context, url string) (string, error) { + // Use a.HTTPClient, a.DB, etc. +} +``` + +```go +// worker/main.go - inject dependencies at worker startup +activities := &activities.Activities{ + HTTPClient: http.DefaultClient, + DB: db, +} +w.RegisterActivity(activities) +``` + +## Common Pitfalls + +1. **Using native goroutines/channels/select** - Use `workflow.Go()`, `workflow.Channel`, `workflow.Selector` +2. **Using `time.Sleep` or `time.Now`** - Use `workflow.Sleep()` and `workflow.Now()` +3. **Iterating over maps with `range`** - Map iteration order is non-deterministic; sort keys first +4. **Forgetting to register workflows/activities** - Worker will fail tasks for unregistered types +5. **Registering activity functions instead of struct** - Use `w.RegisterActivity(&Activities{})` not `w.RegisterActivity(a.MyMethod)` +6. **Forgetting to heartbeat** - Long-running activities need `activity.RecordHeartbeat(ctx, details)` +7. **Using `fmt.Println` in workflows** - Use `workflow.GetLogger(ctx)` for replay-safe logging +8. **Not setting Activity timeouts** - `StartToCloseTimeout` or `ScheduleToCloseTimeout` is required in `ActivityOptions` + +## Writing Tests + +See `references/go/testing.md` for info on writing tests. + +## Additional Resources + +### Reference Files +- **`references/go/patterns.md`** - Signals, queries, child workflows, saga pattern, etc. +- **`references/go/determinism.md`** - Determinism rules, workflowcheck tool, safe alternatives +- **`references/go/gotchas.md`** - Go-specific mistakes and anti-patterns +- **`references/go/error-handling.md`** - ApplicationError, retry policies, non-retryable errors +- **`references/go/observability.md`** - Logging, metrics, tracing, Search Attributes +- **`references/go/testing.md`** - TestWorkflowEnvironment, time-skipping, activity mocking +- **`references/go/advanced-features.md`** - Schedules, worker tuning, and more +- **`references/go/data-handling.md`** - Data converters, payload codecs, encryption +- **`references/go/versioning.md`** - Patching API (`workflow.GetVersion`), Worker Versioning +- **`references/python/determinism-protection.md`** - Information on **`workflowcheck`** tool to help statically check for determinism issues. diff --git a/references/go/gotchas.md b/references/go/gotchas.md new file mode 100644 index 0000000..4b7ddf3 --- /dev/null +++ b/references/go/gotchas.md @@ -0,0 +1,290 @@ +# Go Gotchas + +Go-specific mistakes and anti-patterns. See also [Common Gotchas](references/core/gotchas.md) for language-agnostic concepts. + +## Goroutines and Concurrency + +### Using Native Go Concurrency Primitives + +**The Problem**: Native `go`, `chan`, and `select` are non-deterministic and will cause replay failures. + +```go +// BAD - Native goroutine +func MyWorkflow(ctx workflow.Context) error { + go func() { // Non-deterministic! + // do work + }() + return nil +} + +// GOOD - Use workflow.Go +func MyWorkflow(ctx workflow.Context) error { + workflow.Go(ctx, func(gCtx workflow.Context) { + // do work + }) + return nil +} +``` + +```go +// BAD - Native channel +func MyWorkflow(ctx workflow.Context) error { + ch := make(chan string) // Non-deterministic! + return nil +} + +// GOOD - Use workflow.Channel +func MyWorkflow(ctx workflow.Context) error { + ch := workflow.NewChannel(ctx) + return nil +} +``` + +```go +// BAD - Native select +select { +case val := <-ch1: + // handle +case val := <-ch2: + // handle +} + +// GOOD - Use workflow.Selector +selector := workflow.NewSelector(ctx) +selector.AddReceive(ch1, func(c workflow.ReceiveChannel, more bool) { + var val string + c.Receive(ctx, &val) + // handle +}) +selector.AddReceive(ch2, func(c workflow.ReceiveChannel, more bool) { + var val string + c.Receive(ctx, &val) + // handle +}) +selector.Select(ctx) +``` + +## Non-Deterministic Operations + +### Map Iteration + +```go +// BAD - Map range order is randomized +for k, v := range myMap { + // Non-deterministic order! +} + +// GOOD - Sort keys first +keys := make([]string, 0, len(myMap)) +for k := range myMap { + keys = append(keys, k) +} +sort.Strings(keys) +for _, k := range keys { + v := myMap[k] + // Deterministic order +} +``` + +### Time and Randomness + +```go +// BAD +t := time.Now() // System clock, non-deterministic +time.Sleep(time.Second) // Not replay-safe +r := rand.Intn(100) // Non-deterministic + +// GOOD +t := workflow.Now(ctx) // Deterministic +workflow.Sleep(ctx, time.Second) // Durable timer +encoded := workflow.SideEffect(ctx, func(ctx workflow.Context) interface{} { + return rand.Intn(100) +}) +var r int +encoded.Get(&r) +``` + +Use the `workflowcheck` static analysis tool to catch non-deterministic calls. For false positives, annotate with `//workflowcheck:ignore` on the line above. + +### Anonymous Functions as Local Activities + +**The Problem**: The Go SDK derives the local activity name from the function. Anonymous functions get a non-deterministic name that can change across builds, causing replay failures. + +```go +// BAD - anonymous function: name is non-deterministic +workflow.ExecuteLocalActivity(ctx, func(ctx context.Context) (string, error) { + return "result", nil +}) + +// GOOD - named function: stable, deterministic name +func QuickLookup(ctx context.Context) (string, error) { + return "result", nil +} + +workflow.ExecuteLocalActivity(ctx, QuickLookup) +``` + +Always use named functions for local activities (and regular activities). + +## Wrong Retry Classification + +**Example:** Transient network errors should be retried. Authentication errors should not be. +See `references/go/error-handling.md` for detailed guidance on error classification and retry policies. + +## Heartbeating + +### Forgetting to Heartbeat Long Activities + +```go +// BAD - No heartbeat, can't detect stuck activities or receive cancellation +func ProcessLargeFile(ctx context.Context, path string) error { + for _, chunk := range readChunks(path) { + process(chunk) // Takes hours, no heartbeat + } + return nil +} + +// GOOD - Regular heartbeats with progress +func ProcessLargeFile(ctx context.Context, path string) error { + for i, chunk := range readChunks(path) { + activity.RecordHeartbeat(ctx, fmt.Sprintf("Processing chunk %d", i)) + process(chunk) + } + return nil +} +``` + +### Heartbeat Timeout Too Short + +```go +// BAD - Heartbeat timeout shorter than processing time +ao := workflow.ActivityOptions{ + StartToCloseTimeout: 30 * time.Minute, + HeartbeatTimeout: 10 * time.Second, // Too short! +} + +// GOOD - Heartbeat timeout allows for processing variance +ao := workflow.ActivityOptions{ + StartToCloseTimeout: 30 * time.Minute, + HeartbeatTimeout: 2 * time.Minute, +} +``` + +Set heartbeat timeout as high as acceptable for your use case -- each heartbeat counts as an action. + +## Cancellation + +### Not Handling Workflow Cancellation + +```go +// BAD - Cleanup doesn't run on cancellation +func BadWorkflow(ctx workflow.Context) error { + _ = workflow.ExecuteActivity(ctx, AcquireResource).Get(ctx, nil) + _ = workflow.ExecuteActivity(ctx, DoWork).Get(ctx, nil) + _ = workflow.ExecuteActivity(ctx, ReleaseResource).Get(ctx, nil) // Never runs if cancelled! + return nil +} + +// GOOD - Use defer with NewDisconnectedContext for cleanup +func GoodWorkflow(ctx workflow.Context) error { + defer func() { + if !errors.Is(ctx.Err(), workflow.ErrCanceled) { + return + } + newCtx, _ := workflow.NewDisconnectedContext(ctx) + _ = workflow.ExecuteActivity(newCtx, ReleaseResource).Get(newCtx, nil) + }() + + err := workflow.ExecuteActivity(ctx, AcquireResource).Get(ctx, nil) + if err != nil { + return err + } + return workflow.ExecuteActivity(ctx, DoWork).Get(ctx, nil) +} +``` + +### Not Handling Activity Cancellation + +Activities must **opt in** to receive cancellation. This requires: +1. **Heartbeating** - Cancellation is delivered via heartbeat +2. **Checking ctx.Done()** - Detect when cancellation arrives + +```go +// BAD - Activity ignores cancellation +func LongActivity(ctx context.Context) error { + doExpensiveWork() // Runs to completion even if cancelled + return nil +} + +// GOOD - Heartbeat and check ctx.Done() +func LongActivity(ctx context.Context) error { + for i, item := range items { + select { + case <-ctx.Done(): + cleanup() + return ctx.Err() + default: + activity.RecordHeartbeat(ctx, fmt.Sprintf("Processing item %d", i)) + process(item) + } + } + return nil +} +``` + +## 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/go/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, and should be considered in addition to standard testing. Please see `references/go/testing.md` for more info. + +## Timers and Sleep + +### Using time.Sleep Instead of workflow.Sleep + +```go +// BAD: time.Sleep is not deterministic during replay +func BadWorkflow(ctx workflow.Context) error { + time.Sleep(60 * time.Second) // Non-deterministic! + return nil +} + +// GOOD: Use workflow.Sleep for deterministic timers +func GoodWorkflow(ctx workflow.Context) error { + workflow.Sleep(ctx, 60*time.Second) // Deterministic + return nil +} +``` + +### Using time.After Instead of workflow.NewTimer + +```go +// BAD: time.After is not replay-safe +func BadWorkflow(ctx workflow.Context) error { + <-time.After(5 * time.Minute) // Non-deterministic! + return nil +} + +// GOOD: Use workflow.NewTimer for durable timers +func GoodWorkflow(ctx workflow.Context) error { + timer := workflow.NewTimer(ctx, 5*time.Minute) + _ = timer.Get(ctx, nil) // Deterministic, durable + return nil +} +``` + +### Using time.Now() Instead of workflow.Now() + +```go +// BAD: time.Now() differs between execution and replay +deadline := time.Now().Add(24 * time.Hour) + +// GOOD: workflow.Now() is replay-safe +deadline := workflow.Now(ctx).Add(24 * time.Hour) +``` + +**Why this matters:** `time.Now()`, `time.Sleep()`, and `time.After()` use the system clock, which differs between original execution and replay. The `workflow.*` equivalents create durable, deterministic entries in the event history. diff --git a/references/go/observability.md b/references/go/observability.md new file mode 100644 index 0000000..ba55140 --- /dev/null +++ b/references/go/observability.md @@ -0,0 +1,153 @@ +# Go SDK Observability + +## Overview + +The Go SDK provides replay-safe logging via `workflow.GetLogger`, metrics via the Tally library with Prometheus export, and tracing via OpenTelemetry, OpenTracing, or Datadog. + +## Logging / Replay-Aware Logging + +### Workflow Logging + +Use `workflow.GetLogger(ctx)` for replay-safe logging. This logger automatically suppresses duplicate messages during replay. + +```go +func MyWorkflow(ctx workflow.Context, input string) (string, error) { + logger := workflow.GetLogger(ctx) + logger.Info("Workflow started", "input", input) + + var result string + err := workflow.ExecuteActivity(ctx, MyActivity, input).Get(ctx, &result) + if err != nil { + logger.Error("Activity failed", "error", err) + return "", err + } + + logger.Info("Workflow completed", "result", result) + return result, nil +} +``` + +The workflow logger automatically: +- Suppresses duplicate logs during replay +- Includes workflow context (workflow ID, run ID, etc.) + +### Activity Logging + +Use `activity.GetLogger(ctx)` for context-aware activity logging: + +```go +func MyActivity(ctx context.Context, input string) (string, error) { + logger := activity.GetLogger(ctx) + logger.Info("Processing input", "input", input) + // ... + return "done", nil +} +``` + +Activity logger includes: +- Activity ID, type, and task queue +- Workflow ID and run ID +- Attempt number (for retries) + +### Adding Persistent Fields + +Use `log.With` to create a logger with key-value pairs included in every entry: + +```go +logger := log.With(workflow.GetLogger(ctx), "orderId", orderId, "customerId", customerId) +logger.Info("Processing order") // includes orderId and customerId +``` + +## Customizing the Logger + +Set a custom logger via `client.Options{Logger: myLogger}`. Implement the `log.Logger` interface (Debug, Info, Warn, Error methods). + +### Using slog (Go 1.21+) + +```go +import ( + "log/slog" + "os" + + tlog "go.temporal.io/sdk/log" +) + +slogHandler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}) +logger := tlog.NewStructuredLogger(slog.New(slogHandler)) + +c, err := client.Dial(client.Options{ + Logger: logger, +}) +``` + +### Using Third-Party Loggers (Logrus, Zap, etc.) + +Use the [logur](https://github.com/logur/logur) adapter package: + +```go +import ( + "github.com/sirupsen/logrus" + logrusadapter "logur.dev/adapter/logrus" + "logur.dev/logur" +) + +logger := logur.LoggerToKV(logrusadapter.New(logrus.New())) +c, err := client.Dial(client.Options{ + Logger: logger, +}) +``` + +## Metrics + +Use the Tally library (`go.temporal.io/sdk/contrib/tally`) with Prometheus: + +```go +import ( + sdktally "go.temporal.io/sdk/contrib/tally" + "github.com/uber-go/tally/v4" + "github.com/uber-go/tally/v4/prometheus" +) + +func newPrometheusScope(c prometheus.Configuration) tally.Scope { + reporter, err := c.NewReporter( + prometheus.ConfigurationOptions{}, + ) + if err != nil { + log.Fatalln("error creating prometheus reporter", err) + } + scopeOpts := tally.ScopeOptions{ + CacheReporter: reporter, + Separator: "_", + SanitizeOptions: &sdktally.PrometheusSanitizeOptions, + } + scope, _ := tally.NewRootScope(scopeOpts, time.Second) + scope = sdktally.NewPrometheusNamingScope(scope) + return scope +} + +c, err := client.Dial(client.Options{ + MetricsHandler: sdktally.NewMetricsHandler(newPrometheusScope(prometheus.Configuration{ + ListenAddress: "0.0.0.0:9090", + TimerType: "histogram", + })), +}) +``` + +Key SDK metrics: +- `temporal_workflow_task_execution_latency` -- Workflow task processing time +- `temporal_activity_execution_latency` -- Activity execution time +- `temporal_workflow_task_replay_latency` -- Replay duration +- `temporal_request` -- Client requests to server +- `temporal_activity_schedule_to_start_latency` -- Time from scheduling to start + +## Search Attributes (Visibility) + +See the Search Attributes section of `references/go/data-handling.md` + +## Best Practices + +1. Always use `workflow.GetLogger(ctx)` in workflows -- never `fmt.Println` or `log.Println` (they produce duplicates on replay) +2. Use `activity.GetLogger(ctx)` in activities for structured context +3. Set up Prometheus metrics in production +4. Use search attributes for operational visibility and debugging +5. Use `workflow.IsReplaying(ctx)` only for custom side-effect-free logging -- the built-in logger handles replay suppression automatically diff --git a/references/go/patterns.md b/references/go/patterns.md new file mode 100644 index 0000000..732083f --- /dev/null +++ b/references/go/patterns.md @@ -0,0 +1,536 @@ +# Go SDK Patterns + +## Signals + +In Go, signals are received via channels, not handler functions. + +```go +func OrderWorkflow(ctx workflow.Context) (string, error) { + approved := false + var items []string + + approveCh := workflow.GetSignalChannel(ctx, "approve") + addItemCh := workflow.GetSignalChannel(ctx, "add-item") + + // Listen for signals in a goroutine so workflow can proceed + workflow.Go(ctx, func(ctx workflow.Context) { + for { + selector := workflow.NewSelector(ctx) + selector.AddReceive(approveCh, func(c workflow.ReceiveChannel, more bool) { + c.Receive(ctx, &approved) + }) + selector.AddReceive(addItemCh, func(c workflow.ReceiveChannel, more bool) { + var item string + c.Receive(ctx, &item) + items = append(items, item) + }) + selector.Select(ctx) + } + }) + + // Wait for approval + workflow.Await(ctx, func() bool { return approved }) + return fmt.Sprintf("Processed %d items", len(items)), nil +} +``` + +### Blocking receive from a single channel + +When waiting on a single signal, no Selector is needed: + +```go +var approveInput ApproveInput +workflow.GetSignalChannel(ctx, "approve").Receive(ctx, &approveInput) +``` + +## Queries + +**Important:** Queries must NOT modify workflow state. Query handlers run outside workflow context -- do not call `workflow.Go()`, `workflow.NewChannel()`, or any blocking workflow functions. + +```go +func StatusWorkflow(ctx workflow.Context) error { + currentState := "started" + progress := 0 + + err := workflow.SetQueryHandler(ctx, "get-status", func() (string, error) { + return currentState, nil + }) + if err != nil { + return err + } + + err = workflow.SetQueryHandler(ctx, "get-progress", func() (int, error) { + return progress, nil + }) + if err != nil { + return err + } + + // Workflow logic updates currentState and progress as it runs + currentState = "running" + for i := 0; i < 100; i++ { + progress = i + err := workflow.ExecuteActivity( + workflow.WithActivityOptions(ctx, workflow.ActivityOptions{ + StartToCloseTimeout: time.Minute, + }), + ProcessItem, i, + ).Get(ctx, nil) + if err != nil { + currentState = "failed" + return err + } + } + currentState = "done" + return nil +} +``` + +## Updates + +```go +func OrderWorkflow(ctx workflow.Context) (int, error) { + var items []string + + err := workflow.SetUpdateHandlerWithOptions( + ctx, + "add-item", + func(ctx workflow.Context, item string) (int, error) { + // Handler can mutate workflow state and return a value + items = append(items, item) + return len(items), nil + }, + workflow.UpdateHandlerOptions{ + Validator: func(ctx workflow.Context, item string) error { + if item == "" { + return fmt.Errorf("item cannot be empty") + } + if len(items) >= 100 { + return fmt.Errorf("order is full") + } + return nil + }, + }, + ) + if err != nil { + return 0, err + } + + // Block until cancelled + _ = ctx.Done().Receive(ctx, nil) + return len(items), nil +} +``` + +**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. Return an error to reject the update; return `nil` to accept. + +## Child Workflows + +```go +func ParentWorkflow(ctx workflow.Context, orders []Order) ([]string, error) { + cwo := workflow.ChildWorkflowOptions{ + WorkflowExecutionTimeout: 30 * time.Minute, + } + ctx = workflow.WithChildOptions(ctx, cwo) + + var results []string + for _, order := range orders { + var result string + err := workflow.ExecuteChildWorkflow(ctx, ProcessOrderWorkflow, order).Get(ctx, &result) + if err != nil { + return nil, err + } + results = append(results, result) + } + return results, nil +} +``` + +### Child Workflow Options + +```go +import enumspb "go.temporal.io/api/enums/v1" + +cwo := workflow.ChildWorkflowOptions{ + WorkflowID: fmt.Sprintf("child-%s", workflow.GetInfo(ctx).WorkflowExecution.ID), + + // ParentClosePolicy - what happens to child when parent closes + // PARENT_CLOSE_POLICY_TERMINATE (default), PARENT_CLOSE_POLICY_ABANDON, PARENT_CLOSE_POLICY_REQUEST_CANCEL + ParentClosePolicy: enumspb.PARENT_CLOSE_POLICY_ABANDON, + + WorkflowExecutionTimeout: 10 * time.Minute, + WorkflowTaskTimeout: time.Minute, +} +ctx = workflow.WithChildOptions(ctx, cwo) + +future := workflow.ExecuteChildWorkflow(ctx, ChildWorkflow, input) + +// Wait for child to start (important for ABANDON policy) +if err := future.GetChildWorkflowExecution().Get(ctx, nil); err != nil { + return err +} +``` + +## Handles to External Workflows + +```go +func CoordinatorWorkflow(ctx workflow.Context, targetWorkflowID string) error { + // Signal an external workflow + err := workflow.SignalExternalWorkflow(ctx, targetWorkflowID, "", "data-ready", payload).Get(ctx, nil) + if err != nil { + return err + } + + // Cancel an external workflow + err = workflow.RequestCancelExternalWorkflow(ctx, targetWorkflowID, "").Get(ctx, nil) + return err +} +``` + +## Parallel Execution + +Use `workflow.Go` to launch parallel work and `workflow.Selector` to collect results. + +```go +func ParallelWorkflow(ctx workflow.Context, items []string) ([]string, error) { + actCtx := workflow.WithActivityOptions(ctx, workflow.ActivityOptions{ + StartToCloseTimeout: 5 * time.Minute, + }) + + // Launch activities in parallel + futures := make([]workflow.Future, len(items)) + for i, item := range items { + futures[i] = workflow.ExecuteActivity(actCtx, ProcessItem, item) + } + + // Collect all results + results := make([]string, len(items)) + for i, future := range futures { + if err := future.Get(ctx, &results[i]); err != nil { + return nil, err + } + } + return results, nil +} +``` + +### Using workflow.Go for background goroutines + +```go +ch := workflow.NewChannel(ctx) + +workflow.Go(ctx, func(ctx workflow.Context) { + // Background work + var result string + _ = workflow.ExecuteActivity(actCtx, SomeActivity).Get(ctx, &result) + ch.Send(ctx, result) +}) + +var result string +ch.Receive(ctx, &result) +``` + +## Selector Pattern + +`workflow.Selector` replaces Go's native `select` -- required for deterministic workflow execution. Use it to wait on multiple channels, futures, and timers simultaneously. + +```go +func ApprovalWorkflow(ctx workflow.Context) (string, error) { + actCtx := workflow.WithActivityOptions(ctx, workflow.ActivityOptions{ + StartToCloseTimeout: 5 * time.Minute, + }) + + var outcome string + signalCh := workflow.GetSignalChannel(ctx, "approve") + actFuture := workflow.ExecuteActivity(actCtx, AutoReviewActivity) + + // Cancel timer if signal or activity wins + timerCtx, cancelTimer := workflow.WithCancel(ctx) + timer := workflow.NewTimer(timerCtx, 24*time.Hour) + + selector := workflow.NewSelector(ctx) + + // Branch 1: Signal received + selector.AddReceive(signalCh, func(c workflow.ReceiveChannel, more bool) { + var approved bool + c.Receive(ctx, &approved) + cancelTimer() + if approved { + outcome = "approved-by-signal" + } else { + outcome = "rejected-by-signal" + } + }) + + // Branch 2: Activity completed + selector.AddFuture(actFuture, func(f workflow.Future) { + var result string + _ = f.Get(ctx, &result) + cancelTimer() + outcome = result + }) + + // Branch 3: Timeout + selector.AddFuture(timer, func(f workflow.Future) { + if err := f.Get(ctx, nil); err == nil { + outcome = "timed-out" + } + // If timer was cancelled, err is CanceledError -- ignore + }) + + selector.Select(ctx) // Blocks until one branch fires + return outcome, nil +} +``` + +Key points: +- `AddReceive(channel, callback)` -- fires when a channel has a message (must consume with `c.Receive`) +- `AddFuture(future, callback)` -- fires when a future resolves (once per Selector) +- `AddDefault(callback)` -- fires immediately if nothing else is ready +- `Select(ctx)` -- blocks until one branch fires; call multiple times to process multiple events + +## Continue-as-New + +```go +func LongRunningWorkflow(ctx workflow.Context, state WorkflowState) (string, error) { + for { + state = processBatch(ctx, state) + + if state.IsComplete { + return "done", nil + } + + // Check if history is getting large + if workflow.GetInfo(ctx).GetContinueAsNewSuggested() { + return "", workflow.NewContinueAsNewError(ctx, LongRunningWorkflow, state) + } + } +} +``` + +Drain signals before continue-as-new to avoid signal loss: + +```go +for { + var signalVal string + ok := signalChan.ReceiveAsync(&signalVal) + if !ok { + break + } + // process signal +} +return "", workflow.NewContinueAsNewError(ctx, LongRunningWorkflow, state) +``` + +## Cancellation Handling + +Use `ctx.Done()` to detect cancellation and `workflow.NewDisconnectedContext` for cleanup that must run even after cancellation. + +```go +func MyWorkflow(ctx workflow.Context) error { + actCtx := workflow.WithActivityOptions(ctx, workflow.ActivityOptions{ + StartToCloseTimeout: time.Hour, + }) + + err := workflow.ExecuteActivity(actCtx, LongRunningActivity).Get(ctx, nil) + if err != nil && temporal.IsCanceledError(ctx.Err()) { + // Workflow was cancelled -- run cleanup with a disconnected context + workflow.GetLogger(ctx).Info("Workflow cancelled, running cleanup") + disconnectedCtx, _ := workflow.NewDisconnectedContext(ctx) + disconnectedCtx = workflow.WithActivityOptions(disconnectedCtx, workflow.ActivityOptions{ + StartToCloseTimeout: 5 * time.Minute, + }) + _ = workflow.ExecuteActivity(disconnectedCtx, CleanupActivity).Get(disconnectedCtx, nil) + return err // Return CanceledError + } + return err +} +``` + +## Saga Pattern (Compensations) + +**Important:** Compensation activities should be idempotent -- they may be retried (as with ALL activities). + +Use `workflow.NewDisconnectedContext` when running compensations so they execute even if the workflow is cancelled. + +```go +func OrderWorkflow(ctx workflow.Context, order Order) (string, error) { + actCtx := workflow.WithActivityOptions(ctx, workflow.ActivityOptions{ + StartToCloseTimeout: 5 * time.Minute, + }) + + var compensations []func(ctx workflow.Context) error + + // Helper to run all compensations in reverse, using a disconnected context + // so compensations run even if the workflow is cancelled. + runCompensations := func() { + disconnectedCtx, _ := workflow.NewDisconnectedContext(ctx) + compCtx := workflow.WithActivityOptions(disconnectedCtx, workflow.ActivityOptions{ + StartToCloseTimeout: 5 * time.Minute, + }) + for i := len(compensations) - 1; i >= 0; i-- { + if err := compensations[i](compCtx); err != nil { + workflow.GetLogger(ctx).Error("Compensation failed", "error", err) + } + } + } + + // Register compensation BEFORE running the activity. + // If the activity completes the effect but fails on return, + // we still need the compensation. + compensations = append(compensations, func(ctx workflow.Context) error { + return workflow.ExecuteActivity(ctx, ReleaseInventoryIfReserved, order).Get(ctx, nil) + }) + if err := workflow.ExecuteActivity(actCtx, ReserveInventory, order).Get(ctx, nil); err != nil { + runCompensations() + return "", err + } + + compensations = append(compensations, func(ctx workflow.Context) error { + return workflow.ExecuteActivity(ctx, RefundPaymentIfCharged, order).Get(ctx, nil) + }) + if err := workflow.ExecuteActivity(actCtx, ChargePayment, order).Get(ctx, nil); err != nil { + runCompensations() + return "", err + } + + if err := workflow.ExecuteActivity(actCtx, ShipOrder, order).Get(ctx, nil); err != nil { + runCompensations() + return "", err + } + + return "Order completed", nil +} +``` + +## Wait Condition with Timeout + +```go +func ApprovalWorkflow(ctx workflow.Context) (string, error) { + approved := false + + // Set up signal handler + workflow.Go(ctx, func(ctx workflow.Context) { + workflow.GetSignalChannel(ctx, "approve").Receive(ctx, &approved) + }) + + // Wait with 24-hour timeout -- returns (conditionMet, error) + conditionMet, err := workflow.AwaitWithTimeout(ctx, 24*time.Hour, func() bool { + return approved + }) + if err != nil { + return "", err + } + + if conditionMet { + return "approved", nil + } + return "auto-rejected due to timeout", nil +} +``` + +Without timeout: + +```go +err := workflow.Await(ctx, func() bool { return ready }) +``` + +## Waiting for All Handlers to Finish + +Signal and update handlers may run activities asynchronously. Use `workflow.Await` with `workflow.AllHandlersFinished` before completing or continuing-as-new to prevent the workflow from closing while handlers are still running. + +```go +func MyWorkflow(ctx workflow.Context) (string, error) { + // ... register handlers, main workflow logic ... + + // Before exiting, wait for all handlers to finish + err := workflow.Await(ctx, func() bool { + return workflow.AllHandlersFinished(ctx) + }) + if err != nil { + return "", err + } + return "done", nil +} +``` + +## 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 + +```go +func ProcessLargeFile(ctx context.Context, filePath string) (string, error) { + // Recover from previous attempt + startIdx := 0 + if activity.HasHeartbeatDetails(ctx) { + if err := activity.GetHeartbeatDetails(ctx, &startIdx); err == nil { + startIdx++ // Resume from next item + } + } + + lines := readFileLines(filePath) + + for i := startIdx; i < len(lines); i++ { + processLine(lines[i]) + + // Heartbeat with progress -- if cancelled, ctx will be cancelled + activity.RecordHeartbeat(ctx, i) + + if ctx.Err() != nil { + // Activity was cancelled + cleanup() + return "", ctx.Err() + } + } + + return "completed", nil +} +``` + +## Timers + +```go +func TimerWorkflow(ctx workflow.Context) (string, error) { + // Simple sleep + err := workflow.Sleep(ctx, time.Hour) + if err != nil { + return "", err + } + + // Timer as a Future -- for use with Selector + timerCtx, cancelTimer := workflow.WithCancel(ctx) + timer := workflow.NewTimer(timerCtx, 30*time.Minute) + + // Cancel the timer when no longer needed + cancelTimer() + + return "Timer fired", nil +} +``` + +## 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. + +```go +func MyWorkflow(ctx workflow.Context) (string, error) { + lao := workflow.LocalActivityOptions{ + StartToCloseTimeout: 5 * time.Second, + } + ctx = workflow.WithLocalActivityOptions(ctx, lao) + + var result string + err := workflow.ExecuteLocalActivity(ctx, QuickLookup, "key").Get(ctx, &result) + if err != nil { + return "", err + } + return result, nil +} +``` diff --git a/references/go/testing.md b/references/go/testing.md new file mode 100644 index 0000000..ab74bbd --- /dev/null +++ b/references/go/testing.md @@ -0,0 +1,238 @@ +# Go SDK Testing + +## Overview + +The Go SDK provides the `testsuite` package for testing Workflows and Activities. It uses the [testify](https://github.com/stretchr/testify) library for assertions (`assert`/`require`) and mocking (`mock`). The test environment supports automatic time-skipping for Workflows with timers. + +## Test Environment Setup + +Two approaches: struct-based with `suite.Suite` or function-based with `testsuite.NewTestWorkflowEnvironment()`. + +**Approach 1: Struct-based (testify suite)** + +```go +package sample + +import ( + "testing" + + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/suite" + + "go.temporal.io/sdk/testsuite" +) + +type UnitTestSuite struct { + suite.Suite + testsuite.WorkflowTestSuite + + env *testsuite.TestWorkflowEnvironment +} + +func (s *UnitTestSuite) SetupTest() { + s.env = s.NewTestWorkflowEnvironment() +} + +func (s *UnitTestSuite) AfterTest(suiteName, testName string) { + s.env.AssertExpectations(s.T()) +} + +func (s *UnitTestSuite) Test_MyWorkflow_Success() { + s.env.ExecuteWorkflow(MyWorkflow, "input") + + s.True(s.env.IsWorkflowCompleted()) + s.NoError(s.env.GetWorkflowError()) +} + +func TestUnitTestSuite(t *testing.T) { + suite.Run(t, new(UnitTestSuite)) +} +``` + +**Approach 2: Function-based** + +```go +package sample + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "go.temporal.io/sdk/testsuite" +) + +func Test_MyWorkflow(t *testing.T) { + testSuite := &testsuite.WorkflowTestSuite{} + env := testSuite.NewTestWorkflowEnvironment() + env.RegisterActivity(MyActivity) + + env.ExecuteWorkflow(MyWorkflow, "input") + assert.True(t, env.IsWorkflowCompleted()) + assert.NoError(t, env.GetWorkflowError()) + + var result string + assert.NoError(t, env.GetWorkflowResult(&result)) + assert.Equal(t, "expected", result) +} +``` + +You must register all Activity Definitions used by the Workflow with `env.RegisterActivity(ActivityFunc)`. The Workflow itself does not need to be registered. + +## Activity Mocking + +Mock activities with `env.OnActivity()` to test Workflow logic in isolation. + +**Return mock values:** + +```go +env.OnActivity(MyActivity, mock.Anything, mock.Anything).Return("mock_result", nil) +``` + +**Return a function replacement** (for parameter validation or custom logic): + +```go +env.OnActivity(MyActivity, mock.Anything, mock.Anything).Return( + func(ctx context.Context, input string) (string, error) { + // Custom logic, assertions, etc. + return "computed_result", nil + }, +) +``` + +**Match specific arguments:** + +```go +env.OnActivity(MyActivity, mock.Anything, "specific_input").Return("result", nil) +``` + +When using mocks, you do not need to call `env.RegisterActivity()` for that Activity. The mock signature must match the original Activity function signature. + +## Testing Signals and Queries + +Use `RegisterDelayedCallback` to send Signals during Workflow execution. Use `QueryWorkflow` to test query handlers. + +```go +func (s *UnitTestSuite) Test_SignalsAndQueries() { + // Register a delayed callback to send a signal after 5 seconds + s.env.RegisterDelayedCallback(func() { + s.env.SignalWorkflow("approve", SignalData{Approved: true}) + }, time.Second*5) + + s.env.ExecuteWorkflow(ApprovalWorkflow, input) + + s.True(s.env.IsWorkflowCompleted()) + s.NoError(s.env.GetWorkflowError()) +} +``` + +**Query a running Workflow** (must be called inside `RegisterDelayedCallback` or after `ExecuteWorkflow`): + +```go +s.env.RegisterDelayedCallback(func() { + res, err := s.env.QueryWorkflow("getProgress") + s.NoError(err) + + var progress int + err = res.Get(&progress) + s.NoError(err) + s.Equal(50, progress) +}, time.Second*10+time.Millisecond) +``` + +`QueryWorkflow` returns a `converter.EncodedValue`. Use `.Get(&result)` to decode the value. + +For "Signal-With-Start" testing, set the delay to `0`. + +## Testing Failure Cases + +```go +func (s *UnitTestSuite) Test_WorkflowFailure() { + // Mock activity to return an error + s.env.OnActivity(MyActivity, mock.Anything, mock.Anything).Return( + "", errors.New("activity failed")) + + s.env.ExecuteWorkflow(MyWorkflow, "input") + + s.True(s.env.IsWorkflowCompleted()) + + err := s.env.GetWorkflowError() + s.Error(err) + + var applicationErr *temporal.ApplicationError + s.True(errors.As(err, &applicationErr)) + s.Equal("activity failed", applicationErr.Error()) +} +``` + +`env.GetWorkflowError()` returns the Workflow error. Use `errors.As(err, &applicationErr)` to check the error type. Mock activities returning errors to test Workflow error-handling paths. + +## Replay Testing + +Use `worker.NewWorkflowReplayer()` to verify that code changes do not break determinism. Load history from a JSON file exported via the Temporal CLI or Web UI. + +```go +package sample + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "go.temporal.io/sdk/worker" +) + +func Test_ReplayFromFile(t *testing.T) { + replayer := worker.NewWorkflowReplayer() + replayer.RegisterWorkflow(MyWorkflow) + + err := replayer.ReplayWorkflowHistoryFromJSONFile(nil, "my_workflow_history.json") + assert.NoError(t, err) +} +``` + +Export history via CLI: `temporal workflow show --workflow-id --output json > history.json` + +**Replay from a programmatically fetched history:** + +```go +func Test_ReplayFromServer(t *testing.T) { + // Fetch history from the server + hist, err := GetWorkflowHistory(ctx, client, workflowID, runID) + assert.NoError(t, err) + + replayer := worker.NewWorkflowReplayer() + replayer.RegisterWorkflow(MyWorkflow) + + err = replayer.ReplayWorkflowHistory(nil, hist) + assert.NoError(t, err) +} +``` + +## Activity Testing + +Test Activities in isolation using `TestActivityEnvironment`. No Worker or Workflow needed. + +```go +func Test_MyActivity(t *testing.T) { + testSuite := &testsuite.WorkflowTestSuite{} + env := testSuite.NewTestActivityEnvironment() + env.RegisterActivity(MyActivity) + + val, err := env.ExecuteActivity(MyActivity, "input") + assert.NoError(t, err) + + var result string + assert.NoError(t, val.Get(&result)) + assert.Equal(t, "expected_output", result) +} +``` + +`ExecuteActivity` returns `(converter.EncodedValue, error)`. Use `val.Get(&result)` to extract the typed result. The Activity executes synchronously in the calling goroutine. + +## Best Practices + +1. Register all Activities used by the Workflow with `env.RegisterActivity()`, unless you mock them with `env.OnActivity()` +2. Use mocks to isolate Workflow logic from Activity implementations +3. Test failure paths by mocking Activities that return errors +4. Use replay testing before deploying Workflow code changes to catch non-determinism errors +5. Use unique task queues per test when running integration tests +6. Call `env.AssertExpectations(s.T())` in `AfterTest` to verify all mocks were called diff --git a/references/go/versioning.md b/references/go/versioning.md new file mode 100644 index 0000000..b6b6c27 --- /dev/null +++ b/references/go/versioning.md @@ -0,0 +1,232 @@ +# Go SDK Versioning + +For conceptual overview and guidance on choosing an approach, see `references/core/versioning.md`. + +## GetVersion API + +`workflow.GetVersion` safely performs backwards-incompatible changes to Workflow Definitions. It returns the version to branch on, recording the result as a marker in the Event History. + +```go +v := workflow.GetVersion(ctx, "changeID", workflow.DefaultVersion, maxSupported) +``` + +- `changeID`: unique string identifying the change +- `minSupported`: oldest version still supported (`workflow.DefaultVersion` is `-1`) +- `maxSupported`: current/newest version +- Returns `maxSupported` for new executions; returns the recorded version on replay + +### Three-Step Lifecycle + +**Step 1: Add GetVersion with both code paths** + +Original code calls `ActivityA`. You want to replace it with `ActivityC`: + +```go +v := workflow.GetVersion(ctx, "Step1", workflow.DefaultVersion, 1) +if v == workflow.DefaultVersion { + // Old code path (for replay of existing workflows) + err = workflow.ExecuteActivity(ctx, ActivityA, data).Get(ctx, &result1) +} else { + // New code path + err = workflow.ExecuteActivity(ctx, ActivityC, data).Get(ctx, &result1) +} +``` + +For new executions, `GetVersion` returns `1` and records a marker. For replay of pre-change workflows (no marker), it returns `DefaultVersion` (`-1`). + +**Step 2: Remove old branch (increase minSupported)** + +After all `DefaultVersion` Workflow Executions have completed: + +```go +v := workflow.GetVersion(ctx, "Step1", 1, 1) +// Only the new code path remains +err = workflow.ExecuteActivity(ctx, ActivityC, data).Get(ctx, &result1) +``` + +Keep the `GetVersion` call even with a single branch. This ensures: +1. If an older execution replays on this code, it fails fast instead of proceeding incorrectly +2. If you need further changes, you just bump `maxSupported` + +**Step 3: Further changes (bump maxSupported)** + +Later, replace `ActivityC` with `ActivityD`: + +```go +v := workflow.GetVersion(ctx, "Step1", 1, 2) +if v == 1 { + err = workflow.ExecuteActivity(ctx, ActivityC, data).Get(ctx, &result1) +} else { + err = workflow.ExecuteActivity(ctx, ActivityD, data).Get(ctx, &result1) +} +``` + +After all version-1 executions complete, collapse again: + +```go +_ = workflow.GetVersion(ctx, "Step1", 2, 2) +err = workflow.ExecuteActivity(ctx, ActivityD, data).Get(ctx, &result1) +``` + +### Using GetVersion in Loops + +The return value for a given `changeID` is immutable once recorded. In loops, append the iteration number to the `changeID`: + +```go +for i := 0; i < 10; i++ { + v := workflow.GetVersion(ctx, fmt.Sprintf("myChange-%d", i), workflow.DefaultVersion, 1) + if v == workflow.DefaultVersion { + // old path + } else { + // new path + } +} +``` + +## Workflow Type Versioning + +Create a new Workflow Type for incompatible changes: + +```go +// Original +func MyWorkflow(ctx workflow.Context, input Input) (string, error) { + // v1 implementation +} + +// New version +func MyWorkflowV2(ctx workflow.Context, input Input) (string, error) { + // v2 implementation +} +``` + +Register both with the Worker: + +```go +w := worker.New(c, "my-task-queue", worker.Options{}) +w.RegisterWorkflow(MyWorkflow) +w.RegisterWorkflow(MyWorkflowV2) +``` + +Route new executions to the new type. Old workflows continue on the old type. Check for open executions before removing the old type: + +```bash +temporal workflow list --query 'WorkflowType = "MyWorkflow" 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 + +```go +w := worker.New(c, "my-task-queue", worker.Options{ + DeploymentOptions: worker.DeploymentOptions{ + UseVersioning: true, + Version: worker.WorkerDeploymentVersion{ + DeploymentName: "my-service", + BuildId: "v1.0.0", // or git commit hash + }, + DefaultVersioningBehavior: workflow.VersioningBehaviorPinned, + }, +}) +``` + +**Configuration fields:** +- `UseVersioning`: enables Worker Versioning +- `Version`: identifies the Worker Deployment Version (deployment name + build ID) +- `DefaultVersioningBehavior`: `VersioningBehaviorPinned` or `VersioningBehaviorAutoUpgrade` +- Build ID: typically a git commit hash, version number, or timestamp + +### PINNED vs AUTO_UPGRADE Behaviors + +**PINNED Behavior** + +Workflows stay locked to their original Worker version. + +**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. + +**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 GetVersion for version transitions + +**Important:** AUTO_UPGRADE workflows still need GetVersion to handle version transitions safely since they can move between Worker versions. + +### Worker Configuration with Default Behavior + +```go +// For short-running workflows, prefer PINNED +w := worker.New(c, "orders-task-queue", worker.Options{ + DeploymentOptions: worker.DeploymentOptions{ + UseVersioning: true, + Version: worker.WorkerDeploymentVersion{ + DeploymentName: "order-service", + BuildId: os.Getenv("BUILD_ID"), + }, + DefaultVersioningBehavior: workflow.VersioningBehaviorPinned, + }, +}) +``` + +### 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 + +This works well with Kubernetes where you manage multiple ReplicaSets running different Worker versions. + +Deploy a new version, then set it as current: + +```bash +temporal worker deployment set-current-version \ + --deployment-name my-service \ + --build-id v2.0.0 +``` + +### 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. **Keep GetVersion calls** even when only a single branch remains -- it guards against stale replays and simplifies future changes +2. **Use `TemporalChangeVersion` search attribute** to find Workflows running on old versions: + ```bash + temporal workflow list --query \ + 'WorkflowType = "MyWorkflow" AND ExecutionStatus = "Running" AND TemporalChangeVersion = "Step1"' + ``` +3. **Test with replay** before removing old branches to verify determinism is preserved +4. **Prefer Worker Versioning** for large-scale deployments to avoid accumulating patching branches diff --git a/references/python/advanced-features.md b/references/python/advanced-features.md new file mode 100644 index 0000000..e0d3297 --- /dev/null +++ b/references/python/advanced-features.md @@ -0,0 +1,166 @@ +# Python SDK Advanced Features + +## Schedules + +Create recurring workflow executions. + +```python +from temporalio.client import ( + Schedule, + ScheduleActionStartWorkflow, + ScheduleSpec, + ScheduleIntervalSpec, +) + +# Create a schedule +schedule_id = "daily-report" +await client.create_schedule( + schedule_id, + Schedule( + action=ScheduleActionStartWorkflow( + DailyReportWorkflow.run, + id="daily-report", + task_queue="reports", + ), + spec=ScheduleSpec( + intervals=[ScheduleIntervalSpec(every=timedelta(days=1))], + ), + ), +) + +# Manage schedules +schedule = client.get_schedule_handle(schedule_id) +await schedule.pause("Maintenance window") +await schedule.unpause() +await schedule.trigger() # Run immediately +await schedule.delete() +``` + +## Async Activity Completion + +For activities that complete asynchronously (e.g., human tasks, external callbacks). +If you configure a heartbeat_timeout on this activity, the external completer is responsible for sending heartbeats via the async handle. +If you do NOT set a heartbeat_timeout, no heartbeats are required. + +**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. + +```python +from temporalio import activity +from temporalio.client import Client + +@activity.defn +async def request_approval(request_id: str) -> None: + # Get task token for async completion + task_token = activity.info().task_token + + # Store task token for later completion (e.g., in database) + await store_task_token(request_id, task_token) + + # Mark this activity as waiting for external completion + activity.raise_complete_async() + +# Later, complete the activity from another process +async def complete_approval(request_id: str, approved: bool): + client = await Client.connect("localhost:7233", namespace="default") + task_token = await get_task_token(request_id) + + handle = client.get_async_activity_handle(task_token=task_token) + + # Optional: if a heartbeat_timeout was set, you can periodically: + # await handle.heartbeat(progress_details) + + if approved: + await handle.complete("approved") + else: + # You can also fail or report cancellation via the handle + await handle.fail(ApplicationError("Rejected")) +``` + +## Sandbox Customization + +The Python SDK runs workflows in a sandbox to help you ensure determinism. You can customize sandbox restrictions when needed. See `references/python/determinism-protection.md` + +## Gevent Compatibility Warning + +**The Python SDK is NOT compatible with gevent.** Gevent's monkey patching modifies Python's asyncio event loop in ways that break the SDK's deterministic execution model. + +If your application uses gevent: +- You cannot run Temporal workers in the same process +- Consider running workers in a separate process without gevent +- Use a message queue or HTTP API to communicate between gevent and Temporal processes + +## Worker Tuning + +Configure worker performance settings. + +```python +from concurrent.futures import ThreadPoolExecutor + +worker = Worker( + client, + task_queue="my-queue", + workflows=[MyWorkflow], + activities=[my_activity], + # Workflow task concurrency + max_concurrent_workflow_tasks=100, + # Activity task concurrency + max_concurrent_activities=100, + # Executor for sync activities + activity_executor=ThreadPoolExecutor(max_workers=50), + # Graceful shutdown timeout + graceful_shutdown_timeout=timedelta(seconds=30), +) +``` + +## Workflow Init Decorator + +Use `@workflow.init` to run initialization code when a workflow is first created. + +**Purpose:** Execute some setup code before signal/update happens or run is invoked. + +```python +@workflow.defn +class MyWorkflow: + @workflow.init + def __init__(self, initial_value: str) -> None: + # This runs only on first execution, not replay + self._value = initial_value + self._items: list[str] = [] + + @workflow.run + async def run(self) -> str: + # self._value and self._items are already initialized + return self._value +``` + +## Workflow Failure Exception Types + +Control which exceptions cause workflow task failures vs workflow failures. + +- Special case: if you include temporalio.workflow.NondeterminismError (or a superclass), non-determinism errors will fail the workflow instead of leaving it in a retrying state +- **Tip for testing:** Set to `[Exception]` in tests so any unhandled exception fails the workflow immediately rather than retrying the workflow task forever. This surfaces bugs faster. + +### Per-Workflow Configuration + +```python +@workflow.defn( + # These exception types will fail the workflow execution (not just the task) + failure_exception_types=[ValueError, CustomBusinessError] +) +class MyWorkflow: + @workflow.run + async def run(self) -> str: + raise ValueError("This fails the workflow, not just the task") +``` + +### Worker-Level Configuration + +```python +worker = Worker( + client, + task_queue="my-queue", + workflows=[MyWorkflow], + workflow_failure_exception_types=[ValueError, CustomBusinessError], +) +``` + diff --git a/references/python/ai-patterns.md b/references/python/ai-patterns.md new file mode 100644 index 0000000..a07e30a --- /dev/null +++ b/references/python/ai-patterns.md @@ -0,0 +1,334 @@ +# Python AI/LLM Integration Patterns + +## Overview + +This document provides Python-specific implementation details for integrating LLMs with Temporal. For conceptual patterns, see `references/core/ai-integration.md`. + +## Pydantic Data Converter Setup + +**Required** for handling complex types like OpenAI response objects: + +```python +from temporalio.client import Client +from temporalio.contrib.pydantic import pydantic_data_converter + +client = await Client.connect( + "localhost:7233", + namespace="default", + data_converter=pydantic_data_converter, +) +``` + +## OpenAI Client Configuration + +**Critical**: Disable client retries, let Temporal handle them: + +```python +from openai import AsyncOpenAI + +openai_client = AsyncOpenAI( + api_key=os.getenv("OPENAI_API_KEY"), + max_retries=0, # CRITICAL: Disable client retries + timeout=30.0, +) +``` + +## LiteLLM Configuration + +For multi-model support: + +```python +import litellm + +litellm.num_retries = 0 # Disable LiteLLM retries +``` + +## Generic LLM Activity + +Flexible, reusable activity for LLM calls: + +```python +import openai +from temporalio import activity +from temporalio.exceptions import ApplicationError +from pydantic import BaseModel +from typing import Optional, Any + +class LLMRequest(BaseModel): + model: str + system_prompt: str + user_input: str + tools: Optional[list] = None + response_format: Optional[type] = None + temperature: float = 0.7 + +class LLMResponse(BaseModel): + content: str + tool_calls: Optional[list] = None + usage: dict + +@activity.defn +async def call_llm(request: LLMRequest) -> LLMResponse: + """Generic LLM activity supporting multiple use cases.""" + try: + # As an example, calling OpenAI. This could be any chat API you wish though... + response = await openai_client.chat.completions.create( + model=request.model, + messages=[ + {"role": "system", "content": request.system_prompt}, + {"role": "user", "content": request.user_input}, + ], + tools=request.tools, + temperature=request.temperature, + ) + return LLMResponse( + content=response.choices[0].message.content or "", + tool_calls=response.choices[0].message.tool_calls, + usage=response.usage.model_dump(), + ) + + # Some example error cases to handle. These are not necessarily exhaustive, and depend on the API you are actually calling! + except openai.AuthenticationError as e: + # Invalid API key - permanent failure, don't retry + raise ApplicationError( + f"Invalid API key: {e}", + type="AuthenticationError", + non_retryable=True, + ) + + except openai.RateLimitError as e: + # Rate limited - transient, let Temporal retry with backoff + raise ApplicationError( + f"Rate limited: {e}", + type="RateLimitError", + next_retry_delay=... # parse this from headers + ) + + except openai.APIStatusError as e: + if e.status_code >= 500: + # Server error - transient, retry + raise ApplicationError( + f"OpenAI server error ({e.status_code}): {e}", + type="ServerError", + ) + else: + # Other client errors (400, etc.) - likely permanent + raise ApplicationError( + f"OpenAI client error ({e.status_code}): {e}", + type="ClientError", + non_retryable=True, + ) + + except openai.APIConnectionError as e: + # Network error - transient, retry + raise ApplicationError( + f"Connection error: {e}", + type="ConnectionError", + ) +``` + +## Activity Retry Policy + +Configure retries at the workflow level: + +```python +from datetime import timedelta +from temporalio import workflow +from temporalio.common import RetryPolicy + +with workflow.unsafe.imports_passed_through(): + from activities.llm import call_llm, LLMRequest + +@workflow.defn +class LLMWorkflow: + @workflow.run + async def run(self, prompt: str) -> str: + # Note that because call_llm classfies different types of exceptions as retryable / non-retryable, + # we automatically get correct retry behavior just by calling it. + response = await workflow.execute_activity( + call_llm, + LLMRequest( + model="gpt-4", + system_prompt="You are a helpful assistant.", + user_input=prompt, + ), + start_to_close_timeout=timedelta(seconds=30), + ) + return response.content +``` + +## Tool-Calling Agent Workflow + +```python +from temporalio import workflow +from datetime import timedelta +from pydantic import BaseModel + +with workflow.unsafe.imports_passed_through(): + from activities.llm import call_llm, LLMRequest, LLMResponse + from activities.tools import execute_tool + from models.tools import ToolDefinition + +class AgentWorkflowInput(BaseModel): + user_request: str + tools: list[ToolDefinition] + +@workflow.defn +class AgentWorkflow: + @workflow.run + async def run(self, input: AgentWorkflowInput) -> str: + messages = [] + current_input = input.user_request + + while True: + # Phase 1: Get LLM response with tools + response = await workflow.execute_activity( + call_llm, + LLMRequest( + model="gpt-4", + system_prompt="You are a helpful agent with tools.", + user_input=current_input, + tools=[t.to_openai_format() for t in input.tools], + ), + start_to_close_timeout=timedelta(seconds=30), + ) + + # Check if LLM wants to use a tool + if not response.tool_calls: + return response.content + + # Phase 2: Execute tools + for tool_call in response.tool_calls: + tool_result = await workflow.execute_activity( + execute_tool, + tool_call, + start_to_close_timeout=timedelta(seconds=60), + ) + messages.append({ + "role": "tool", + "tool_call_id": tool_call.id, + "content": tool_result, + }) + + # Phase 3: Continue conversation with tool results + current_input = f"Tool results: {messages}" +``` + +## Structured Outputs + +Using Pydantic for validated responses: + +```python +from pydantic import BaseModel +from temporalio import activity + +class AnalysisResult(BaseModel): + sentiment: str + confidence: float + key_topics: list[str] + summary: str + +@activity.defn +async def analyze_text(text: str) -> AnalysisResult: + response = await openai_client.beta.chat.completions.parse( + model="gpt-4o", + messages=[ + {"role": "system", "content": "Analyze the following text."}, + {"role": "user", "content": text}, + ], + response_format=AnalysisResult, + ) + return response.choices[0].message.parsed +``` + +## Multi-Agent Pipeline (Deep Research) + +```python +from temporalio import workflow +from datetime import timedelta +import asyncio + +with workflow.unsafe.imports_passed_through(): + from activities.research import ( + generate_subtopics, + generate_search_queries, + search_web, + synthesize_report, + ) + +@workflow.defn +class DeepResearchWorkflow: + @workflow.run + async def run(self, topic: str) -> str: + # Phase 1: Planning + subtopics = await workflow.execute_activity( + generate_subtopics, + topic, + start_to_close_timeout=timedelta(seconds=60), + ) + + # Phase 2: Query Generation + queries = await workflow.execute_activity( + generate_search_queries, + subtopics, + start_to_close_timeout=timedelta(seconds=60), + ) + + # Phase 3: Parallel Web Search (resilient to partial failures) + search_tasks = [ + workflow.execute_activity( + search_web, + query, + start_to_close_timeout=timedelta(seconds=300), + schedule_to_close_timeout=timedelta(seconds=900), # We set a schedule to close timeout, so that if one search task repeatadly fails, then it won't hang up all the rest, in the below gather step. + ) + for query in queries + ] + + # Continue with partial results on failure + results = await asyncio.gather(*search_tasks, return_exceptions=True) + successful_results = [r for r in results if not isinstance(r, Exception)] + + # Phase 4: Synthesis + report = await workflow.execute_activity( + synthesize_report, + {"topic": topic, "research": successful_results}, + start_to_close_timeout=timedelta(seconds=300), + ) + + return report +``` + +## OpenAI Agents SDK Integration + +If using the OpenAI Agent SDK to create an agent, use Temporal's OpenAI contrib module to create a Temporal-aware durable agent: + +```python +from temporalio import workflow +from temporalio.contrib.openai import create_workflow_agent +from agents import Agent, Runner + +@workflow.defn +class DurableAgentWorkflow: + @workflow.run + async def run(self, task: str) -> str: + # Create a Temporal-aware agent + agent = create_workflow_agent( + model="gpt-4", + tools=[search_tool, calculator_tool], + ) + # Run it. Under the hood, the automatically dispatches to activities for LLM calls, etc. + result = await agent.run(task) + return result.output +``` + +## Best Practices + +1. **Always use Pydantic data converter** for complex types +2. **Disable retries in LLM clients** (max_retries=0) +3. **Set appropriate timeouts** per operation type +4. **Use structured outputs** for type safety +5. **Handle partial failures** in parallel operations +6. **Mock activities in tests** for fast, deterministic testing +7. **Log token usage** for cost tracking +8. **Version prompts** in code for reproducibility diff --git a/references/python/data-handling.md b/references/python/data-handling.md new file mode 100644 index 0000000..662101e --- /dev/null +++ b/references/python/data-handling.md @@ -0,0 +1,230 @@ +# Python SDK Data Handling + +## Overview + +The Python SDK uses data converters to serialize/deserialize workflow inputs, outputs, and activity parameters. + +## Default Data Converter + +The default converter handles: +- `None` +- `bytes` (as binary) +- Protobuf messages +- JSON-serializable types (dict, list, str, int, float, bool) + +## Pydantic Integration + +Use Pydantic models for validated, typed data. + +In your workflow definition, just use input and result types that subclass `pydantic.BaseModel`: + +```python +from pydantic import BaseModel + +class OrderInput(BaseModel): + order_id: str + items: list[str] + total: float + customer_email: str + +class OrderResult(BaseModel): + order_id: str + status: str + tracking_number: str | None = None + +@workflow.defn +class OrderWorkflow: + @workflow.run + async def run(self, input: OrderInput) -> OrderResult: + # Pydantic validation happens automatically + return OrderResult( + order_id=input.order_id, + status="completed", + tracking_number="TRK123", + ) +``` + +And when you configure the client, pass the `pydantic_data_converter`: + +```python +from temporalio.contrib.pydantic import pydantic_data_converter +# Configure client with Pydantic support +client = await Client.connect( + "localhost:7233", + namespace="default", + data_converter=pydantic_data_converter, +) +``` + +## Custom Data Conversion + +Usually the easiest way to do this is via implementing an EncodingPayloadConverter and CompositePayloadConverter. See: +- https://raw.githubusercontent.com/temporalio/samples-python/refs/heads/main/custom_converter/shared.py +- https://raw.githubusercontent.com/temporalio/samples-python/refs/heads/main/custom_converter/starter.py + +for an extended example. + +## Payload Encryption + +Encrypt sensitive workflow data. + +```python +from temporalio.converter import PayloadCodec +from temporalio.api.common.v1 import Payload +from cryptography.fernet import Fernet +from typing import Sequence + +class EncryptionCodec(PayloadCodec): + def __init__(self, key: bytes): + self._fernet = Fernet(key) + + async def encode(self, payloads: Sequence[Payload]) -> list[Payload]: + return [ + Payload( + metadata={"encoding": b"binary/encrypted"}, + # Since encryption uses C extensions that give up the GIL, we can avoid blocking the async event loop here. + data=await asyncio.to_thread(self._fernet.encrypt, p.SerializeToString()), + ) + for p in payloads + ] + + async def decode(self, payloads: Sequence[Payload]) -> list[Payload]: + result = [] + for p in payloads: + if p.metadata.get("encoding") == b"binary/encrypted": + decrypted = await asyncio.to_thread(self._fernet.decrypt, p.data) + decoded = Payload() + decoded.ParseFromString(decrypted) + result.append(decoded) + else: + result.append(p) + return result + +# Apply encryption codec +client = await Client.connect( + "localhost:7233", + namespace="default", + data_converter=DataConverter( + payload_codec=EncryptionCodec(encryption_key), + ), +) +``` + +## Search Attributes + +Custom searchable fields for workflow visibility. These can be created at workflow start: + +```python +from temporalio.common import ( + SearchAttributeKey, + SearchAttributePair, + TypedSearchAttributes, +) +from datetime import datetime +from datetime import timezone + +ORDER_ID = SearchAttributeKey.for_keyword("OrderId") +ORDER_STATUS = SearchAttributeKey.for_keyword("OrderStatus") +ORDER_TOTAL = SearchAttributeKey.for_float("OrderTotal") +CREATED_AT = SearchAttributeKey.for_datetime("CreatedAt") + +# At workflow start +handle = await client.start_workflow( + OrderWorkflow.run, + order, + id=f"order-{order.id}", + task_queue="orders", + search_attributes=TypedSearchAttributes([ + SearchAttributePair(ORDER_ID, order.id), + SearchAttributePair(ORDER_STATUS, "pending"), + SearchAttributePair(ORDER_TOTAL, order.total), + SearchAttributePair(CREATED_AT, datetime.now(timezone.utc)), + ]), +) +``` + +Or upserted during workflow execution: + +```python +from temporalio import workflow +from temporalio.common import SearchAttributeKey, SearchAttributePair, TypedSearchAttributes + +ORDER_STATUS = SearchAttributeKey.for_keyword("OrderStatus") + +@workflow.defn +class OrderWorkflow: + @workflow.run + async def run(self, order: Order) -> str: + # ... process order ... + + # Update search attribute + workflow.upsert_search_attributes(TypedSearchAttributes([ + SearchAttributePair(ORDER_STATUS, "completed"), + ])) + return "done" +``` + +### Querying Workflows by Search Attributes + +```python +# List workflows using search attributes +async for workflow in client.list_workflows( + 'OrderStatus = "processing" OR OrderStatus = "pending"' +): + print(f"Workflow {workflow.id} is still processing") +``` + +## Workflow Memo + +Store arbitrary metadata with workflows (not searchable). + +```python +# Set memo at workflow start +await client.execute_workflow( + OrderWorkflow.run, + order, + id=f"order-{order.id}", + task_queue="orders", + memo={ + "customer_name": order.customer_name, + "notes": "Priority customer", + }, +) +``` + +```python +# Read memo from workflow +@workflow.defn +class OrderWorkflow: + @workflow.run + async def run(self, order: Order) -> str: + notes: str = workflow.memo_value("notes", type_hint=str) + ... +``` + +## Deterministic APIs for Values + +Use these APIs within workflows for deterministic random values and UUIDs: + +```python +@workflow.defn +class MyWorkflow: + @workflow.run + async def run(self) -> str: + # Deterministic UUID (same on replay) + unique_id = workflow.uuid4() + + # Deterministic random (same on replay) + rng = workflow.random() + value = rng.randint(1, 100) + + return str(unique_id) +``` + +## Best Practices + +1. Use Pydantic for input/output validation +2. Keep payloads small—see `references/core/gotchas.md` for limits +3. Encrypt sensitive data with PayloadCodec +4. Use dataclasses for simple data structures +5. Use `workflow.uuid4()` and `workflow.random()` for deterministic values diff --git a/references/python/determinism-protection.md b/references/python/determinism-protection.md new file mode 100644 index 0000000..1376ced --- /dev/null +++ b/references/python/determinism-protection.md @@ -0,0 +1,233 @@ +# Python Workflow Sandbox + +## Overview + +The Python SDK runs workflows in a sandbox that provides automatic protection against non-deterministic operations. This is unique to the Python SDK. + +## How the Sandbox Works + +The sandbox: +- Isolates global state via `exec` compilation +- Restricts non-deterministic library calls via proxy objects +- Passes through standard library with restrictions +- Reloads workflow files on each execution + +## Forbidden Operations + +These operations will fail in the sandbox: + +- **Direct I/O**: Network calls, file reads/writes +- **Threading**: `threading` module operations +- **Subprocess**: `subprocess` calls +- **Global state**: Modifying mutable global variables +- **Blocking sleep**: `time.sleep()` (use `workflow.sleep(timedelta(...))`) + +## Pass-Through Pattern + +Third-party libraries that aren't sandbox-aware need explicit pass-through: + +```python +from temporalio import workflow + +with workflow.unsafe.imports_passed_through(): + import pydantic + from my_module import my_dataclass +``` + +**When to use pass-through:** +- Data classes and models (Pydantic, dataclasses) +- Serialization libraries +- Type definitions +- Any library that doesn't do I/O or non-deterministic operations +- Performance, as many non-passthrough imports can be slower + +**Note:** The imports, even when using `imports_passed_through`, should all be at the top of the file. Runtime imports are an anti-pattern. + +## Importing Activities + +Activities should be imported through pass-through since they're defined outside the sandbox: + +```python +# workflows/order.py +from temporalio import workflow + +with workflow.unsafe.imports_passed_through(): + from activities.payment import process_payment + from activities.shipping import ship_order + +@workflow.defn +class OrderWorkflow: + @workflow.run + async def run(self, order_id: str) -> str: + await workflow.execute_activity( + process_payment, + order_id, + start_to_close_timeout=timedelta(minutes=5), + ) + return await workflow.execute_activity( + ship_order, + order_id, + start_to_close_timeout=timedelta(minutes=10), + ) +``` + +## Disabling the Sandbox + +```python +@workflow.defn +class MyWorkflow: + @workflow.run + async def run(self) -> str: + with workflow.unsafe.sandbox_unrestricted(): + # Unrestricted code block + pass + return "result" +``` + +- Per‑block escape hatch from runtime restrictions; imports unchanged. +- Use when: You need to call something the sandbox would normally block (e.g., a restricted stdlib call) in a very small, controlled section. +- **IMPORTANT:** Use it sparingly; you lose determinism checks inside the block +- Genuinely non-deterministic code still *MUST* go into activities. + +## Customizing Invalid Module Members + +`invalid_module_members` includes modules that cannot be accessed. + +Checks are compared against the fully qualified path to the item. + +```python +import dataclasses +from temporalio.worker import Worker +from temporalio.worker.workflow_sandbox import ( + SandboxedWorkflowRunner, + SandboxMatcher, + SandboxRestrictions, +) + +# Example 1: Remove a restriction on datetime.date.today(): +restrictions = dataclasses.replace( + SandboxRestrictions.default, + invalid_module_members=SandboxRestrictions.invalid_module_members_default.with_child_unrestricted( + "datetime", "date", "today", + ), +) + +# Example 2: Restrict the datetime.date class from being used +restrictions = dataclasses.replace( + SandboxRestrictions.default, + invalid_module_members=SandboxRestrictions.invalid_module_members_default | SandboxMatcher( + children={"datetime": SandboxMatcher(use={"date"})}, + ), +) + +worker = Worker( + ..., + workflow_runner=SandboxedWorkflowRunner(restrictions=restrictions), +) +``` + +## Import Notification Policy + +Control warnings/errors for sandbox import issues. Recommended for catching potential problems: + +```python +from temporalio import workflow +from temporalio.worker.workflow_sandbox import SandboxedWorkflowRunner, SandboxRestrictions + +restrictions = SandboxRestrictions.default.with_import_notification_policy( + workflow.SandboxImportNotificationPolicy.WARN_ON_DYNAMIC_IMPORT + | workflow.SandboxImportNotificationPolicy.WARN_ON_UNINTENTIONAL_PASSTHROUGH +) + +worker = Worker( + ..., + workflow_runner=SandboxedWorkflowRunner(restrictions=restrictions), +) +``` + +- `WARN_ON_DYNAMIC_IMPORT` (default) - warns on imports after initial workflow load +- `WARN_ON_UNINTENTIONAL_PASSTHROUGH` - warns when modules are imported into sandbox without explicit passthrough (not default, but highly recommended for catching missing passthroughs) +- `RAISE_ON_UNINTENTIONAL_PASSTHROUGH` - raise instead of warn + +Override per-import with the context manager: + +```python +with workflow.unsafe.sandbox_import_notification_policy( + workflow.SandboxImportNotificationPolicy.SILENT +): + import pydantic # No warning for this import +``` + +## Disable Lazy sys.modules Passthrough + +By default, passthrough modules are lazily added to the sandbox's `sys.modules` when accessed. To require explicit imports: + +```python +import dataclasses +from temporalio.worker.workflow_sandbox import SandboxedWorkflowRunner, SandboxRestrictions + +restrictions = dataclasses.replace( + SandboxRestrictions.default, + disable_lazy_sys_module_passthrough=True, +) + +worker = Worker( + ..., + workflow_runner=SandboxedWorkflowRunner(restrictions=restrictions), +) +``` + +When `True`, passthrough modules must be explicitly imported to appear in the sandbox's `sys.modules`. + +## File Organization + +**Critical**: Keep workflow definitions in separate files from activity definitions. + +The sandbox reloads workflow definition files on every execution. Minimizing file contents improves Worker performance. + +``` +my_temporal_app/ +├── workflows/ +│ └── order.py # Only workflow classes +├── activities/ +│ └── payment.py # Only activity functions +├── models/ +│ └── order.py # Shared data models +├── worker.py # Worker setup, imports both +└── starter.py # Client code +``` + +## Common Issues + +### Import Errors + +``` +Error: Cannot import 'pydantic' in sandbox +``` + +**Fix**: Use pass-through: + +```python +with workflow.unsafe.imports_passed_through(): + import pydantic +``` + +### Non-Determinism from Libraries + +Some libraries do internal caching or use current time: + +```python +# May cause non-determinism +import some_library +result = some_library.cached_operation() # Cache changes between replays +``` + +**Fix**: Move to activity or use pass-through with caution. + +## Best Practices + +1. **Separate workflow and activity files** for performance +2. **Use pass-through explicitly** for third-party libraries +3. **Keep workflow files small** to minimize reload time +4. **Move I/O to activities** always +5. **Test with replay** to catch sandbox issues early diff --git a/references/python/determinism.md b/references/python/determinism.md new file mode 100644 index 0000000..7276360 --- /dev/null +++ b/references/python/determinism.md @@ -0,0 +1,51 @@ +# Python SDK Determinism + +## Overview + +The Python SDK runs workflows in a sandbox that provides automatic protection against many non-deterministic operations. + +## 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**. + +## Forbidden Operations + +- Direct I/O (network, filesystem) +- Threading operations +- `subprocess` calls +- Global mutable state modification +- `time.sleep()` (use `workflow.sleep(timedelta(...))`) +- and so on + +## Safe Builtin Alternatives to Common Non Deterministic Things + +| Forbidden | Safe Alternative | +|-----------|------------------| +| `datetime.now()` | `workflow.now()` | +| `datetime.utcnow()` | `workflow.now()` | +| `random.random()` | `rng = workflow.new_random() ; rng.randint(1, 100)` | +| `uuid.uuid4()` | `workflow.uuid4()` | +| `time.time()` | `workflow.now().timestamp()` | + +## Testing Replay Compatibility + +Use the `Replayer` class to verify your code changes are compatible with existing histories. See the Workflow Replay Testing section of `references/python/testing.md`. + +## Sandbox Behavior + +The sandbox: +- Isolates global state via `exec` compilation +- Restricts non-deterministic library calls via proxy objects +- Passes through standard library with restrictions + +See more info at `references/python/determinism-protection.md` + +## Best Practices + +1. Use `workflow.now()` for all time operations +2. Use `workflow.random()` for random values +3. Use `workflow.uuid4()` for unique identifiers +4. Pass through third-party libraries explicitly +5. Test with replay to catch non-determinism +6. Keep workflows focused on orchestration, delegate I/O to activities +7. Use `workflow.logger` instead of print() for replay-safe logging diff --git a/references/python/error-handling.md b/references/python/error-handling.md new file mode 100644 index 0000000..19460cb --- /dev/null +++ b/references/python/error-handling.md @@ -0,0 +1,138 @@ +# Python SDK Error Handling + +## Overview + +The Python SDK uses `ApplicationError` 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 Errors + +```python +from temporalio import activity +from temporalio.exceptions import ApplicationError + +@activity.defn +async def validate_order(order: Order) -> None: + if not order.is_valid(): + raise ApplicationError( + "Invalid order", + type="ValidationError", + ) +``` + +## Non-Retryable Errors + +```python +from dataclasses import dataclass +from temporalio import activity +from temporalio.exceptions import ApplicationError + +@dataclass +class ChargeCardInput: + card_number: str + amount: float + +@activity.defn +async def charge_card(input: ChargeCardInput) -> str: + if not is_valid_card(input.card_number): + raise ApplicationError( + "Permanent failure - invalid credit card", + type="PaymentError", + non_retryable=True, # Will not retry activity + ) + return await process_payment(input.card_number, input.amount) +``` + +## Handling Activity Errors + +```python +from datetime import timedelta +from temporalio import workflow +from temporalio.exceptions import ActivityError, ApplicationError + +@workflow.defn +class MyWorkflow: + @workflow.run + async def run(self) -> str: + try: + return await workflow.execute_activity( + risky_activity, + start_to_close_timeout=timedelta(minutes=5), + ) + except ActivityError as e: + workflow.logger.error(f"Activity failed: {e}") + # Handle or re-raise + raise ApplicationError("Workflow failed due to activity error") +``` + +## Retry Policy Configuration + +```python +from datetime import timedelta +from temporalio import workflow +from temporalio.common import RetryPolicy + +@workflow.defn +class MyWorkflow: + @workflow.run + async def run(self) -> str: + result = await workflow.execute_activity( + my_activity, + start_to_close_timeout=timedelta(minutes=10), + retry_policy=RetryPolicy( + maximum_interval=timedelta(minutes=1), + maximum_attempts=5, + non_retryable_error_types=["ValidationError", "PaymentError"], + ), + ) + return result +``` + +Only set options such as maximum_interval, maximum_attempts etc. if you have a domain-specific reason to. +If not, prefer to leave them at their defaults. + +## Timeout Configuration + +```python +from datetime import timedelta +from temporalio import workflow + +@workflow.defn +class MyWorkflow: + @workflow.run + async def run(self) -> str: + return await workflow.execute_activity( + my_activity, + start_to_close_timeout=timedelta(minutes=5), # Single attempt + schedule_to_close_timeout=timedelta(minutes=30), # Including retries + heartbeat_timeout=timedelta(minutes=2), # Between heartbeats + ) +``` + +## Workflow Failure + +```python +from temporalio import workflow +from temporalio.exceptions import ApplicationError + +@workflow.defn +class MyWorkflow: + @workflow.run + async def run(self) -> str: + if some_condition: + raise ApplicationError( + "Cannot process order", + type="BusinessError", + ) + return "success" +``` + +**Note:** Do not use `non_retryable=` with `ApplicationError` inside a worklow (as opposed to an activity). + +## Best Practices + +1. Use specific error types for different failure modes +2. Mark permanent failures as non-retryable +3. Configure appropriate retry policies +4. Log errors before re-raising +5. Use `ActivityError` to catch activity failures in workflows +6. Design code to be idempotent for safe retries (see more at `references/core/patterns.md`) diff --git a/references/python/gotchas.md b/references/python/gotchas.md new file mode 100644 index 0000000..95ebe8a --- /dev/null +++ b/references/python/gotchas.md @@ -0,0 +1,280 @@ +# Python Gotchas + +Python-specific mistakes and anti-patterns. See also [Common Gotchas](references/core/gotchas.md) for language-agnostic concepts. + +## File Organization + +### Importing Activities into Workflow Files + +**The Problem**: The Python sandbox reloads workflow files on every task. Importing heavy activity modules slows down workers. + +```python +# BAD - activities.py gets reloaded constantly +# workflows.py +from activities import my_activity + +@workflow.defn +class MyWorkflow: + pass + +# GOOD - Pass-through import +# workflows.py +from temporalio import workflow + +with workflow.unsafe.imports_passed_through(): + from activities import my_activity + +@workflow.defn +class MyWorkflow: + pass +``` + +`references/python/determinism-protection.md` contains more info about the Python sandbox. + +### Mixing Workflows and Activities + +```python +# BAD - Everything in one file +# app.py +@workflow.defn +class MyWorkflow: + @workflow.run + async def run(self): + await workflow.execute_activity(my_activity, ...) + +@activity.defn +async def my_activity(): + # Heavy imports, I/O, etc. + pass + +# GOOD - Separate files +# workflows.py +@workflow.defn +class MyWorkflow: + @workflow.run + async def run(self): + await workflow.execute_activity(my_activity, ...) + +# activities.py +@activity.defn +async def my_activity(): + pass +``` + +## Async vs Sync Activities + +The Temporal Python SDK supports both async and sync activities. See `references/python/sync-vs-async.md` to understand which to choose. Below are important anti-patterns for both aysnc and sync activities. + +### Blocking in Async Activities + +```python +# BAD - Blocks the event loop +@activity.defn +async def process_file(path: str) -> str: + with open(path) as f: # Blocking I/O in async! + return f.read() + +# GOOD Option 1 - Use sync activity with executor +@activity.defn +def process_file(path: str) -> str: + with open(path) as f: + return f.read() + +# Register with executor in worker +Worker( + client, + task_queue="my-queue", + activities=[process_file], + activity_executor=ThreadPoolExecutor(max_workers=10), +) + +# GOOD Option 2 - Use async I/O +@activity.defn +async def process_file(path: str) -> str: + async with aiofiles.open(path) as f: + return await f.read() +``` + +### Missing Executor for Sync Activities + +```python +# BAD - Sync activity REQUIRES executor +@activity.defn +def slow_computation(data: str) -> str: + return heavy_cpu_work(data) + +Worker( + client, + task_queue="my-queue", + activities=[slow_computation], + # Missing activity_executor! --> THIS IMMEDIATELY RAISES AN EXCEPTION! +) + +# GOOD - Provide executor +Worker( + client, + task_queue="my-queue", + activities=[slow_computation], + activity_executor=ThreadPoolExecutor(max_workers=10), +) +``` + +## Wrong Retry Classification + +**Example:** Transient networks errors should be retried. Authentication errors should not be. +See `references/python/error-handling.md` to understand how to classify errors. + +## Heartbeating + +### Forgetting to Heartbeat Long Activities + +```python +# BAD - No heartbeat, can't detect stuck activities +@activity.defn +async def process_large_file(path: str): + async for chunk in read_chunks(path): + process(chunk) # Takes hours, no heartbeat + +# GOOD - Regular heartbeats with progress +@activity.defn +async def process_large_file(path: str): + async for i, chunk in enumerate(read_chunks(path)): + activity.heartbeat(f"Processing chunk {i}") + process(chunk) +``` + +### Heartbeat Timeout Too Short + +```python +# BAD - Heartbeat timeout shorter than processing time +await workflow.execute_activity( + process_chunk, + start_to_close_timeout=timedelta(minutes=30), + heartbeat_timeout=timedelta(seconds=10), # Too short! +) + +# GOOD - Heartbeat timeout allows for processing variance +await workflow.execute_activity( + process_chunk, + start_to_close_timeout=timedelta(minutes=30), + heartbeat_timeout=timedelta(minutes=2), +) +``` + +Set heartbeat timeout as high as acceptable for your use case — each heartbeat counts as an action. + +## Cancellation + +### Not Handling Workflow Cancellation + +```python +# BAD - Cleanup doesn't run on cancellation +@workflow.defn +class BadWorkflow: + @workflow.run + async def run(self) -> None: + await workflow.execute_activity( + acquire_resource, + start_to_close_timeout=timedelta(minutes=5), + ) + await workflow.execute_activity( + do_work, + start_to_close_timeout=timedelta(minutes=5), + ) + await workflow.execute_activity( + release_resource, # Never runs if cancelled! + start_to_close_timeout=timedelta(minutes=5), + ) + +# GOOD - Use try/finally for cleanup +@workflow.defn +class GoodWorkflow: + @workflow.run + async def run(self) -> None: + await workflow.execute_activity( + acquire_resource, + start_to_close_timeout=timedelta(minutes=5), + ) + try: + await workflow.execute_activity( + do_work, + start_to_close_timeout=timedelta(minutes=5), + ) + finally: + # Runs even on cancellation + await workflow.execute_activity( + release_resource, + start_to_close_timeout=timedelta(minutes=5), + ) +``` + +### Not Handling Activity Cancellation + +Activities must **opt in** to receive cancellation. This requires: +1. **Heartbeating** - Cancellation is delivered via heartbeat +2. **Catching the cancellation exception** - Exception is raised when heartbeat detects cancellation + +**Cancellation exceptions:** +- Async activities: `asyncio.CancelledError` +- Sync threaded activities: `temporalio.exceptions.CancelledError` + +```python +# BAD - Activity ignores cancellation +@activity.defn +async def long_activity() -> None: + await do_expensive_work() # Runs to completion even if cancelled +``` + +```python +# GOOD - Heartbeat and catch cancellation +@activity.defn +async def long_activity() -> None: + try: + for item in items: + activity.heartbeat() + await process(item) + except asyncio.CancelledError: + await cleanup() + raise +``` + +## 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/python/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, and should be considered in addition to standard testing. Please see `references/python/testing.md` for more info. + +## Timers and Sleep + +### Using asyncio.sleep + +```python +# BAD: asyncio.sleep is not deterministic during replay +import asyncio + +@workflow.defn +class BadWorkflow: + @workflow.run + async def run(self) -> None: + await asyncio.sleep(60) # Non-deterministic! +``` + +```python +# GOOD: Use workflow.sleep for deterministic timers +from temporalio import workflow +from datetime import timedelta + +@workflow.defn +class GoodWorkflow: + @workflow.run + async def run(self) -> None: + await workflow.sleep(timedelta(seconds=60)) # Deterministic + # Or with string duration: + await workflow.sleep("1 minute") +``` + +**Why this matters:** `asyncio.sleep` uses the system clock, which differs between original execution and replay. `workflow.sleep` creates a durable timer in the event history, ensuring consistent behavior during replay. diff --git a/references/python/observability.md b/references/python/observability.md new file mode 100644 index 0000000..26296c3 --- /dev/null +++ b/references/python/observability.md @@ -0,0 +1,105 @@ +# Python SDK Observability + +## Overview + +The Python SDK provides comprehensive observability through logging, metrics, tracing, and visibility (Search Attributes). + +## Logging + +### Workflow Logging (Replay-Safe) + +Use `workflow.logger` for replay-safe logging that avoids duplicate messages: + +```python +@workflow.defn +class MyWorkflow: + @workflow.run + async def run(self, name: str) -> str: + workflow.logger.info("Workflow started", extra={"name": name}) + + result = await workflow.execute_activity( + my_activity, + start_to_close_timeout=timedelta(minutes=5), + ) + + workflow.logger.info("Activity completed", extra={"result": result}) + return result +``` + +The workflow logger automatically: +- Suppresses duplicate logs during replay +- Includes workflow context (workflow ID, run ID, etc.) + +### Activity Logging + +Use `activity.logger` for context-aware activity logging: + +```python +@activity.defn +async def process_order(order_id: str) -> str: + activity.logger.info(f"Processing order {order_id}") + + # Perform work... + + activity.logger.info("Order processed successfully") + return "completed" +``` + +Activity logger includes: +- Activity ID, type, and task queue +- Workflow ID and run ID +- Attempt number (for retries) + +### Customizing Logger Configuration + +```python +import logging + +# Applies to temporalio.workflow.logger and temporalio.activity.logger, as Temporal inherits the default logger +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", +) +``` + +## Metrics + +### Enabling SDK Metrics + +```python +from temporalio.client import Client +from temporalio.runtime import Runtime, TelemetryConfig, PrometheusConfig + +# Create a custom runtime +runtime = Runtime( + telemetry=TelemetryConfig( + metrics=PrometheusConfig(bind_address="0.0.0.0:9000") + ) +) + +# Set it as the global default BEFORE any Client/Worker is created +# Do this only ONCE. +Runtime.set_default(runtime, error_if_already_set=True) +# error_if_already_set can be False if you want to overwrite an existing default without raising. + +# ...elsewhere, client = ... as usual +``` + +### 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 + + +## Search Attributes (Visibility) + +See the Search Attributes section of `references/python/data-handling.md` + +## Best Practices + +1. Use `workflow.logger` in workflows, `activity.logger` in activities +2. Don't use print() in workflows - it will produce duplicate output on replay +3. Configure metrics for production monitoring +4. Use Search Attributes for business-level visibility diff --git a/references/python/patterns.md b/references/python/patterns.md new file mode 100644 index 0000000..6843985 --- /dev/null +++ b/references/python/patterns.md @@ -0,0 +1,395 @@ +# Python SDK Patterns + +## Signals + +```python +@workflow.defn +class OrderWorkflow: + def __init__(self): + self._approved = False + self._items = [] + + @workflow.signal + async def approve(self) -> None: + self._approved = True + + @workflow.signal + async def add_item(self, item: str) -> None: + self._items.append(item) + + @workflow.run + async def run(self) -> str: + # Wait for approval + await workflow.wait_condition(lambda: self._approved) + return f"Processed {len(self._items)} 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. + +```python +@workflow.defn +class DynamicSignalWorkflow: + def __init__(self): + self._signals: dict[str, list[Any]] = {} + + @workflow.signal(dynamic=True) + async def handle_signal(self, name: str, args: Sequence[RawValue]) -> None: + if name not in self._signals: + self._signals[name] = [] + self._signals[name].append(workflow.payload_converter().from_payload(args[0])) +``` + +## Queries + +**Important:** Queries must NOT modify workflow state or have side effects. + +```python +@workflow.defn +class StatusWorkflow: + def __init__(self): + self._status = "pending" + self._progress = 0 + + @workflow.query + def get_status(self) -> str: + return self._status + + @workflow.query + def get_progress(self) -> int: + return self._progress + + @workflow.run + async def run(self) -> str: + self._status = "running" + for i in range(100): + self._progress = i + await workflow.execute_activity( + process_item, i, + start_to_close_timeout=timedelta(minutes=1) + ) + self._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. + +```python +@workflow.query(dynamic=True) +def handle_query(self, name: str, args: Sequence[RawValue]) -> Any: + if name == "get_field": + field_name = workflow.payload_converter().from_payload(args[0]) + return getattr(self, f"_{field_name}", None) +``` + +## Updates + +```python +@workflow.defn +class OrderWorkflow: + def __init__(self): + self._items: list[str] = [] + + @workflow.update + async def add_item(self, item: str) -> int: + self._items.append(item) + return len(self._items) # Returns new count to caller + + @add_item.validator + def validate_add_item(self, item: str) -> None: + if not item: + raise ValueError("Item cannot be empty") + if len(self._items) >= 100: + raise ValueError("Order is full") +``` + +**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. Raise an exception to reject the update; return `None` to accept. + +## Child Workflows + +```python +@workflow.defn +class MyWorkflow: + @workflow.run + async def run(self, orders: list[Order]) -> list[str]: + results = [] + for order in orders: + result = await workflow.execute_child_workflow( + ProcessOrderWorkflow.run, + order, + id=f"order-{order.id}", + # Control what happens to child when parent completes + parent_close_policy=workflow.ParentClosePolicy.ABANDON, + ) + results.append(result) + return results +``` + +## Handles to External Workflows + +```python +@workflow.defn +class MyWorkflow: + @workflow.run + async def run(self, target_workflow_id: str) -> None: + # Get handle to external workflow + handle = workflow.get_external_workflow_handle(target_workflow_id) + + # Signal the external workflow + await handle.signal(TargetWorkflow.data_ready, data_payload) + + # Or cancel it + await handle.cancel() +``` + +## Parallel Execution + +```python +@workflow.defn +class MyWorkflow: + @workflow.run + async def run(self, items: list[str]) -> list[str]: + # Execute activities in parallel + tasks = [ + workflow.execute_activity( + process_item, item, + start_to_close_timeout=timedelta(minutes=5) + ) + for item in items + ] + return await asyncio.gather(*tasks) +``` + +### Deterministic Alternatives to asyncio + +Generally, asyncio is OK to use in Temoral workflows. But some asyncio calls are non-deterministic. Use Temporal's deterministic alternatives for safer concurrent operations: + +```python +# workflow.wait() - like asyncio.wait() +done, pending = await workflow.wait( + futures, + return_when=workflow.WaitConditionResult.FIRST_COMPLETED +) + +# workflow.as_completed() - like asyncio.as_completed() +async for future in workflow.as_completed(futures): + result = await future + # Process each result as it completes +``` + +## Continue-as-New + +```python +@workflow.defn +class MyWorkflow: + @workflow.run + async def run(self, state: WorkflowState) -> str: + while True: + state = await process_batch(state) + + if state.is_complete: + return "done" + + # Continue with fresh history before hitting limits + if workflow.info().is_continue_as_new_suggested(): + workflow.continue_as_new(args=[state]) +``` + +## Saga Pattern (Compensations) + +**Important:** Compensation activities should be idempotent - they may be retried (as with ALL activities). + +```python +@workflow.defn +class MyWorkflow: + @workflow.run + async def run(self, order: Order) -> str: + compensations: list[Callable[[], Awaitable[None]]] = [] + + try: + # Note - we save the compensation before running the activity, + # because the following could happen: + # 1. reserve_inventory starts running + # 2. it does successfully reserve inventory + # 3. but then fails for some other reason (timeout, reporting metrics, etc.) + # 4. in that case, the activity would have failed, but we still did the effect of reserving inventory + # So, we need to make sure we have a compensation already on the stack to handle that. + # This means the compensation needs to handle both the cases of reserved or unreserved inventory. + compensations.append(lambda: workflow.execute_activity( + release_inventory_if_reserved, order, + start_to_close_timeout=timedelta(minutes=5) + )) + await workflow.execute_activity( + reserve_inventory, order, + start_to_close_timeout=timedelta(minutes=5) + ) + + compensations.append(lambda: workflow.execute_activity( + refund_payment_if_charged, order, + start_to_close_timeout=timedelta(minutes=5) + )) + await workflow.execute_activity( + charge_payment, order, + start_to_close_timeout=timedelta(minutes=5) + ) + + await workflow.execute_activity( + ship_order, order, + start_to_close_timeout=timedelta(minutes=5) + ) + + return "Order completed" + + except Exception as e: + workflow.logger.error(f"Order failed: {e}, running compensations") + # asyncio.shield ensures compensations run even if the workflow is cancelled. + async def run_compensations(): + for compensate in reversed(compensations): + try: + await compensate() + except Exception as comp_err: + workflow.logger.error(f"Compensation failed: {comp_err}") + await asyncio.shield(asyncio.ensure_future(run_compensations())) + raise +``` + +## Cancellation Handling - leverages standard asyncio cancellation + +```python +@workflow.defn +class MyWorkflow: + @workflow.run + async def run(self) -> str: + try: + await workflow.execute_activity( + long_running_activity, + start_to_close_timeout=timedelta(hours=1), + ) + return "completed" + except asyncio.CancelledError: + # Workflow was cancelled - perform cleanup + workflow.logger.info("Workflow cancelled, running cleanup") + # Cleanup activities still run even after cancellation + await workflow.execute_activity( + cleanup_activity, + start_to_close_timeout=timedelta(minutes=5), + ) + raise # Re-raise to mark workflow as cancelled +``` + +## Wait Condition with Timeout + +```python +@workflow.defn +class MyWorkflow: + @workflow.run + async def run(self) -> str: + self._approved = False + + # Wait for approval with 24-hour timeout + try: + await workflow.wait_condition( + lambda: self._approved, + timeout=timedelta(hours=24) + ) + return "approved" + except asyncio.TimeoutError: + return "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 `wait_condition(all_handlers_finished)` at the end of your workflow (or before continue-as-new) to prevent completion until all pending handlers complete. + +```python +@workflow.defn +class MyWorkflow: + @workflow.run + async def run(self) -> str: + # ... main workflow logic ... + + # Before exiting, wait for all handlers to finish + await workflow.wait_condition(workflow.all_handlers_finished) + 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 + +**Cancellation exceptions:** +- Async activities: `asyncio.CancelledError` +- Sync threaded activities: `temporalio.exceptions.CancelledError` + +### WHEN: +- **Cancellable activities** - Any activity that should respond to cancellation +- **Long-running activities** - Track progress for resumability +- **Checkpointing** - Save progress periodically + +```python +from temporalio.exceptions import CancelledError + +@activity.defn +def process_large_file(file_path: str) -> str: + # Get heartbeat details from previous attempt (if any) + heartbeat_details = activity.info().heartbeat_details + start_line = heartbeat_details[0] if heartbeat_details else 0 + + try: + with open(file_path) as f: + for i, line in enumerate(f): + if i < start_line: + continue # Skip already processed lines + + process_line(line) + + # Heartbeat with progress + # If cancelled, heartbeat() raises CancelledError + activity.heartbeat(i + 1) + + return "completed" + except CancelledError: + # Perform cleanup on cancellation + cleanup() + raise +``` + +## Timers + +```python +@workflow.defn +class MyWorkflow: + @workflow.run + async def run(self) -> str: + await workflow.sleep(timedelta(hours=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. + +```python +@workflow.defn +class MyWorkflow: + @workflow.run + async def run(self) -> str: + result = await workflow.execute_local_activity( + quick_lookup, + "key", + start_to_close_timeout=timedelta(seconds=5), + ) + return result +``` + +## Using Pydantic Models + +See `references/python/data-handling.md`. diff --git a/references/python/python.md b/references/python/python.md new file mode 100644 index 0000000..130b1eb --- /dev/null +++ b/references/python/python.md @@ -0,0 +1,175 @@ +# Temporal Python SDK Reference + +## Overview + +The Temporal Python SDK (`temporalio`) provides a fully async, type-safe approach to building durable workflows. Python 3.9+ required. Workflows run in a sandbox by default for determinism protection. + +## Quick Demo of Temporal + +**Add Dependency on Temporal:** In the package management system of the Python project you are working on, add a dependency on `temporalio`. + +**activities/greet.py** - Activity definitions (separate file for performance): +```python +from temporalio import activity + +@activity.defn +def greet(name: str) -> str: + return f"Hello, {name}!" +``` + +**workflows/greeting.py** - Workflow definition (import activities through sandbox): +```python +from datetime import timedelta +from temporalio import workflow + +with workflow.unsafe.imports_passed_through(): + from activities.greet import greet + +@workflow.defn +class GreetingWorkflow: + @workflow.run + async def run(self, name: str) -> str: + return await workflow.execute_activity( + greet, name, start_to_close_timeout=timedelta(seconds=30) + ) +``` + +**worker.py** - Worker setup (imports activity and workflow, runs indefinitely and processes tasks): +```python +import asyncio +import concurrent.futures +from temporalio.client import Client +from temporalio.worker import Worker + +# Import the activity and workflow from our other files +from activities.greet import greet +from workflows.greeting import GreetingWorkflow + +async def main(): + # Create client connected to server at the given address + # This is the default port for `temporal server start-dev` + client = await Client.connect("localhost:7233") + + # Run the worker + with concurrent.futures.ThreadPoolExecutor(max_workers=100) as activity_executor: + worker = Worker( + client, + task_queue="my-task-queue", + workflows=[GreetingWorkflow], + activities=[greet], + activity_executor=activity_executor, + ) + await worker.run() + +if __name__ == "__main__": + asyncio.run(main()) +``` + +**Start the dev server:** Start `temporal server start-dev` in the background. + +**Start the worker:** Start `python worker.py` in the background (appropriately adjust command for your project, like `uv run python worker.py`) + +**starter.py** - Start a workflow execution: +```python +import asyncio +from temporalio.client import Client +import uuid + +# Import the workflow from the previous code +from workflows.greeting import GreetingWorkflow + +async def main(): + # Create client connected to server at the given address + client = await Client.connect("localhost:7233") + + # Execute a workflow + result = await client.execute_workflow(GreetingWorkflow.run, "my name", id=str(uuid.uuid4()), task_queue="my-task-queue") + + print(f"Result: {result}") + +if __name__ == "__main__": + asyncio.run(main()) +``` + +**Run the workflow:** Run `python starter.py` (or uv run, etc.). Should output: `Result: Hello, my-name!`. + + +## Key Concepts + +### Workflow Definition +- Use `@workflow.defn` decorator on class +- Use `@workflow.run` on the entry point method +- Must be async (`async def`) +- Use `@workflow.signal`, `@workflow.query`, `@workflow.update` for handlers + +### Activity Definition +- Use `@activity.defn` decorator +- Can be sync or async functions +- **Default to sync activities** - safer and easier to debug +- Sync activities need `activity_executor` (ThreadPoolExecutor) +- Async activities require async-safe libraries throughout (e.g., `aiohttp` not `requests`) + +See `sync-vs-async.md` for detailed guidance on choosing between sync and async. + +### Worker Setup +- Connect client, create Worker with workflows and activities +- Run the worker +- Activities can specify custom executor + +### Determinism + +**Workflow code must be deterministic!**. All sources of non-determinism should either use Temporal-provided actions or (primarily) be defined in Activities. Read `references/core/determinism.md` and `references/python/determinism.md` to understand more. + +## File Organization Best Practice + +**Keep Workflow definitions in separate files from Activity definitions.** The Python SDK sandbox reloads Workflow definition files on every execution for determinism protection. Minimizing file contents improves Worker performance. + +``` +my_temporal_app/ +├── workflows/ +│ └── greeting.py # Only Workflow classes +├── activities/ +│ └── translate.py # Only Activity functions/classes +├── worker.py # Worker setup, imports both +└── starter.py # Client code to start workflows +``` + +**In the Workflow file, import Activities through the sandbox:** +```python +# workflows/greeting.py +from temporalio import workflow + +with workflow.unsafe.imports_passed_through(): + from activities.translate import TranslateActivities +``` + +## Common Pitfalls + +1. **Non-deterministic code in workflows** - Use activities for all non-deterministic and/or fallible code +2. **Blocking in async activities** - Use sync activities or async-safe libraries only +3. **Missing executor for sync activities** - Add `activity_executor=ThreadPoolExecutor()` +4. **Forgetting to heartbeat** - Long activities need `activity.heartbeat()` +5. **Using gevent** - Incompatible with SDK +6. **Using `print()` in workflows** - Use `workflow.logger` instead for replay-safe logging +7. **Mixing Workflows and Activities in same file** - Causes unnecessary reloads, hurts performance, bad structure +8. **Forgetting to wait on activity calls** - `workflow.execute_activity()` is async; you must eventually await it (directly or via `asyncio.gather()` for parallel execution) + +## Writing Tests + +See `references/python/testing.md` for info on writing tests. + +## Additional Resources + +### Reference Files +- **`references/python/patterns.md`** - Signals, queries, child workflows, saga pattern, etc. +- **`references/python/determinism.md`** - Sandbox behavior, safe alternatives, pass-through pattern, history replay +- **`references/python/gotchas.md`** - Python-specific mistakes and anti-patterns +- **`references/python/error-handling.md`** - ApplicationError, retry policies, non-retryable errors, idempotency +- **`references/python/observability.md`** - Logging, metrics, tracing, Search Attributes +- **`references/python/testing.md`** - WorkflowEnvironment, time-skipping, activity mocking +- **`references/python/sync-vs-async.md`** - Sync vs async activities, event loop blocking, executor configuration +- **`references/python/advanced-features.md`** - Schedules, worker tuning, and more +- **`references/python/data-handling.md`** - Data converters, Pydantic, payload encryption +- **`references/python/versioning.md`** - Patching API, workflow type versioning, Worker Versioning +- **`references/python/determinism-protection.md`** - Python sandbox specifics, forbidden operations, pass-through imports +- **`references/python/ai-patterns.md`** - LLM integration, Pydantic data converter, AI workflow patterns diff --git a/references/python/sync-vs-async.md b/references/python/sync-vs-async.md new file mode 100644 index 0000000..7875582 --- /dev/null +++ b/references/python/sync-vs-async.md @@ -0,0 +1,231 @@ +# Python SDK: Sync vs Async Activities + +## Overview + +The Temporal Python SDK supports multiple ways of implementing Activities: + +- **Asynchronous** using `asyncio` +- **Synchronous multithreaded** using `concurrent.futures.ThreadPoolExecutor` +- **Synchronous multiprocess** using `concurrent.futures.ProcessPoolExecutor` + +Choosing the correct approach is critical—incorrect usage can cause sporadic failures and difficult-to-diagnose bugs. + +## Recommendation: Default to Synchronous + +Activities should be synchronous by default. Use async only when certain the code doesn't block the event loop. + +## The Event Loop Problem + +The Python async event loop runs in a single thread. When any task runs, no other tasks can execute until an `await` is reached. If code makes a blocking call (file I/O, synchronous HTTP, etc.), the entire event loop freezes. + +**Consequences of blocking the event loop:** +- Worker cannot communicate with Temporal Server +- Workflow progress blocks across the worker +- Potential deadlocks and unpredictable behavior +- Difficult-to-diagnose bugs + +## How the SDK Handles Each Type + +### Synchronous Activities + +- Run in the `activity_executor`, which you must provide +- Protected from accidentally blocking the global event loop +- Multiple activities run in parallel via OS thread scheduling +- Thread pool provides preemptive switching between tasks + +```python +from concurrent.futures import ThreadPoolExecutor +from temporalio.worker import Worker + +with ThreadPoolExecutor(max_workers=100) as executor: + worker = Worker( + client, + task_queue="my-queue", + workflows=[MyWorkflow], + activities=[my_sync_activity], + activity_executor=executor, + ) + await worker.run() +``` + +### Asynchronous Activities + +- Share the default asyncio event loop with the Temporal worker +- Any blocking call freezes the entire loop +- Require async-safe libraries throughout + +```python +@activity.defn +async def my_async_activity(name: str) -> str: + # Must use async-safe libraries only + async with aiohttp.ClientSession() as session: + async with session.get(f"http://api.example.com/{name}") as response: + return await response.text() +``` + +## HTTP Libraries: A Critical Choice + +| Library | Type | Safe in Async Activity? | +|---------|------|------------------------| +| `requests` | Blocking | No - blocks event loop | +| `urllib3` | Blocking | No - blocks event loop | +| `aiohttp` | Async | Yes | +| `httpx` | Both | Yes (use async mode) | + +**Example: Wrong way (blocks event loop)** +```python +@activity.defn +async def bad_activity(url: str) -> str: + import requests + response = requests.get(url) # BLOCKS the event loop! + return response.text +``` + +**Example: Correct way (async-safe)** +```python +@activity.defn +async def good_activity(url: str) -> str: + async with aiohttp.ClientSession() as session: + async with session.get(url) as response: + return await response.text() +``` + +## Running Blocking Code in Async Activities + +If blocking code must run in an async activity, offload it to a thread: + +```python +import asyncio + +@activity.defn +async def activity_with_blocking_call() -> str: + # Run blocking code in a thread pool + loop = asyncio.get_event_loop() + result = await loop.run_in_executor(None, blocking_function) + return result + +# Or use asyncio.to_thread (Python 3.9+) +@activity.defn +async def activity_with_blocking_call_v2() -> str: + result = await asyncio.to_thread(blocking_function) + return result +``` + +## When to Use Async Activities + +Use async activities only when: + +1. All code paths are async-safe (no blocking calls) +2. Using async-native libraries (aiohttp, asyncpg, motor, etc.) +3. Performance benefits are needed for I/O-bound operations +4. The team understands async constraints + +## When to Use Sync Activities + +Use sync activities when: + +1. Making HTTP calls with `requests` or similar blocking libraries +2. Performing file I/O operations +3. Using database drivers that aren't async-native +4. Uncertain whether code is async-safe +5. Integrating with legacy or third-party synchronous code + +## Debugging Tip + +If experiencing sporadic bugs, hangs, or timeouts: + +1. Convert async activities to sync +2. Test thoroughly +3. If bugs disappear, the original async activity had blocking calls + +## Threading Considerations + +### Multi-Core Usage + +For CPU-bound work and multi-core usage: + +- Prefer multiple worker processes and/or threaded synchronous activities. +- Use ProcessPoolExecutor for synchronous activities only if you understand and accept the extra complexity and different cancellation semantics. + +### Separate Workers for Workflows vs Activities + +Some teams deploy: +- Workflow-only workers (CPU-bound, need deadlock detection) +- Activity-only workers (I/O-bound, may need more parallelism) + +This prevents resource contention and allows independent scaling. + +## Complete Example: Sync Activity with ThreadPoolExecutor + +```python +import urllib.parse +import requests +from concurrent.futures import ThreadPoolExecutor +from temporalio import activity +from temporalio.client import Client +from temporalio.worker import Worker + +@activity.defn +def greet_in_spanish(name: str) -> str: + """Synchronous activity using requests library.""" + url = f"http://localhost:9999/get-spanish-greeting?name={urllib.parse.quote(name)}" + response = requests.get(url) + return response.text + +async def main(): + client = await Client.connect("localhost:7233", namespace="default") + + with ThreadPoolExecutor(max_workers=100) as executor: + worker = Worker( + client, + task_queue="greeting-tasks", + workflows=[GreetingWorkflow], + activities=[greet_in_spanish], + activity_executor=executor, + ) + await worker.run() +``` + +## Complete Example: Async Activity with aiohttp + +```python +import aiohttp +import urllib.parse +from temporalio import activity +from temporalio.client import Client +from temporalio.worker import Worker + +class TranslateActivities: + def __init__(self, session: aiohttp.ClientSession): + self.session = session + + @activity.defn + async def greet_in_spanish(self, name: str) -> str: + """Async activity using aiohttp - safe for event loop.""" + url = f"http://localhost:9999/get-spanish-greeting?name={urllib.parse.quote(name)}" + async with self.session.get(url) as response: + return await response.text() + +async def main(): + client = await Client.connect("localhost:7233", namespace="default") + + async with aiohttp.ClientSession() as session: + activities = TranslateActivities(session) + worker = Worker( + client, + task_queue="greeting-tasks", + workflows=[GreetingWorkflow], + activities=[activities.greet_in_spanish], + ) + await worker.run() +``` + +## Summary + +| Aspect | Sync Activities | Async Activities | +|--------|-----------------|------------------| +| Default choice | Yes | Only when certain | +| Blocking calls | Safe (runs in thread pool) | Dangerous (blocks event loop) | +| HTTP library | `requests`, `httpx` | `aiohttp`, `httpx` (async) | +| Executor needed | Yes (`ThreadPoolExecutor`) | No | +| Debugging | Easier | Harder (timing issues) | diff --git a/references/python/testing.md b/references/python/testing.md new file mode 100644 index 0000000..63a0d14 --- /dev/null +++ b/references/python/testing.md @@ -0,0 +1,165 @@ +# Python SDK Testing + +## Overview + +You test Temporal Python Workflows using the Temporal testing package plus a normal Python test framework like pytest. The Temporal Python SDK provides `WorkflowEnvironment` for testing workflows in a local environment and `ActivityEnvironment` for isolated activity testing. + +## Workflow Test Environment + +The core pattern is: + +1. Start a test WorkflowEnvironment (`WorkflowEnvironment.start_local()`). +2. Start a Worker in that environment with your Workflow and Activities registered. +3. Use the environment’s client to execute the Workflow, using a fresh UUID for the task queue name and workflow ID. +4. Assert on the result or status. + +`WorkflowEnvironment.start_local` configures a ready-to-go local environment for running and testing workflows: + +```python +import uuid +import pytest + +from temporalio.testing import WorkflowEnvironment +from temporalio.worker import Worker + +from activities import my_activity +from workflows import MyWorkflow + +@pytest.mark.asyncio +async def test_workflow(): + task_queue_name = str(uuid.uuid4()) + async with await WorkflowEnvironment.start_local() as env: + async with Worker( + env.client, + task_queue=task_queue_name, + workflows=[MyWorkflow], + activities=[my_activity], + ): + result = await env.client.execute_workflow( + MyWorkflow.run, + "input", + id=str(uuid.uuid4()), + task_queue=task_queue_name, + ) +``` + +Conveniently, the local `env` can be shared among tests, e.g. via a pytest fixture. + +If your workflows / tests involve long durations (such as using Temporal timers / sleeps), then you can use the time-skipping environment, via `WorkflowEnvironment.start_time_skipping()`. +Only use time-skipping if you must. It can *not* be shared among tests. + +## Mocking Activities + +```python +import uuid +import pytest + +from temporalio import activity +from temporalio.testing import WorkflowEnvironment +from temporalio.worker import Worker + +from workflows import MyWorkflow + +@activity.defn(name="compose_greeting") +async def compose_greeting_mocked(input: str) -> str: + return "mocked result" + +@pytest.mark.asyncio +async def test_with_mock(): + task_queue_name = str(uuid.uuid4()) + async with await WorkflowEnvironment.start_local() as env: + async with Worker( + env.client, + task_queue=task_queue_name, + workflows=[MyWorkflow], + activities=[compose_greeting_mocked], + ): + result = await env.client.execute_workflow(...) +``` + +## Testing Signals and Queries + +```python +@pytest.mark.asyncio +async def test_signals(): + async with await WorkflowEnvironment.start_local() as env: + async with Worker(...): + handle = await env.client.start_workflow(...) # same arguments as to execute_workflow + + # Send signal + await handle.signal(MyWorkflow.my_signal, "data") + + # Query state + status = await handle.query(MyWorkflow.get_status) + assert status == "expected" + + # Wait for completion + result = await handle.result() +``` + +## Testing Failure Cases + +Below shows an example of how to test failure cases: + +```python +# Test failure scenarios +@pytest.mark.asyncio +async def test_activity_failure_handling(): + async with await WorkflowEnvironment.start_local() as env: + # An example activity that always fails + @activity.defn + async def failing_activity() -> str: + raise ApplicationError("Simulated failure", non_retryable=True) + + async with Worker(...): + with pytest.raises(WorkflowFailureError): + await env.client.execute_workflow(...) +``` + +## Workflow Replay Testing + +```python +import json +import pytest +import uuid +from temporalio.client import WorkflowHistory +from temporalio.worker import Replayer + +from workflows import MyWorkflow + +@pytest.mark.asyncio +async def test_replay(): + with open("example-history.json", "r") as f: + history_json = json.load(f) + + replayer = Replayer(workflows=[MyWorkflow]) + + # From JSON file + await replayer.replay_workflow( + WorkflowHistory.from_json(workflow_id=str(uuid.uuid4()), history_json) + ) +``` + + +## Activity Testing + +```python +import pytest + +from temporalio.testing import ActivityEnvironment + +@pytest.mark.asyncio +async def test_activity(): + env = ActivityEnvironment() + result = await env.run(my_activity, "arg1", "arg2") + assert result == "expected" +``` + +## Best Practices + +1. Use the `WorkflowEnvironment.start_local` 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. Easiest is a `uuid.uuid4()` diff --git a/references/python/versioning.md b/references/python/versioning.md new file mode 100644 index 0000000..abd4445 --- /dev/null +++ b/references/python/versioning.md @@ -0,0 +1,314 @@ +# Python SDK Versioning + +For conceptual overview and guidance on choosing an approach, see `references/core/versioning.md`. + +## Patching API + +### The patched() Function + +The `patched()` function checks whether a Workflow should run new or old code: + +```python +from temporalio import workflow + +@workflow.defn +class ShippingWorkflow: + @workflow.run + async def run(self) -> None: + if workflow.patched("send-email-instead-of-fax"): + # New code path + await workflow.execute_activity( + send_email, + start_to_close_timeout=timedelta(minutes=5), + ) + else: + # Old code path (for replay of existing workflows) + await workflow.execute_activity( + send_fax, + start_to_close_timeout=timedelta(minutes=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) + +**Python-specific behavior:** The `patched()` return value is memoized on first call. This means you cannot reliably use `patched()` in loops—it will return the same value every iteration. Workaround: append a sequence number to the patch ID for each iteration (e.g., `f"my-change-{i}"`). + +### Three-Step Patching Process + +Patching is a three-step process for safely deploying changes. + +**Warning:** Failing to follow this process correctly will result in non-determinism errors for in-flight workflows. + +**Step 1: Patch in New Code** + +Add the patch with both old and new code paths: + +```python +@workflow.defn +class OrderWorkflow: + @workflow.run + async def run(self, order: Order) -> str: + if workflow.patched("add-fraud-check"): + # New: Run fraud check before payment + await workflow.execute_activity( + check_fraud, + order, + start_to_close_timeout=timedelta(minutes=2), + ) + + # Original payment logic runs for both paths + return await workflow.execute_activity( + process_payment, + order, + start_to_close_timeout=timedelta(minutes=5), + ) +``` + +**Step 2: Deprecate the Patch** + +Once all pre-patch Workflow Executions have completed, remove the old code and use `deprecate_patch()`: + +```python +@workflow.defn +class OrderWorkflow: + @workflow.run + async def run(self, order: Order) -> str: + workflow.deprecate_patch("add-fraud-check") + + # Only new code remains + await workflow.execute_activity( + check_fraud, + order, + start_to_close_timeout=timedelta(minutes=2), + ) + + return await workflow.execute_activity( + process_payment, + order, + start_to_close_timeout=timedelta(minutes=5), + ) +``` + +**Step 3: Remove the Patch** + +After all workflows with the deprecated patch marker have completed, remove the `deprecate_patch()` call entirely: + +```python +@workflow.defn +class OrderWorkflow: + @workflow.run + async def run(self, order: Order) -> str: + await workflow.execute_activity( + check_fraud, + order, + start_to_close_timeout=timedelta(minutes=2), + ) + + return await workflow.execute_activity( + process_payment, + order, + start_to_close_timeout=timedelta(minutes=5), + ) +``` + +### 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: + +```python +@workflow.defn(name="PizzaWorkflow") +class PizzaWorkflow: + @workflow.run + async def run(self, order: PizzaOrder) -> str: + # Original implementation + return await self._process_order_v1(order) + +@workflow.defn(name="PizzaWorkflowV2") +class PizzaWorkflowV2: + @workflow.run + async def run(self, order: PizzaOrder) -> str: + # New implementation with incompatible changes + return await self._process_order_v2(order) +``` + +Register both with the Worker: + +```python +worker = Worker( + client, + task_queue="pizza-task-queue", + workflows=[PizzaWorkflow, PizzaWorkflowV2], + activities=[make_pizza, deliver_pizza], +) +``` + +Update client code to start new workflows with the new type: + +```python +# Old workflows continue on PizzaWorkflow +# New workflows use PizzaWorkflowV2 +handle = await client.start_workflow( + PizzaWorkflowV2.run, + order, + id=f"pizza-{order.id}", + task_queue="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 + +```python +from temporalio.worker import Worker +from temporalio.worker.deployment_config import ( + WorkerDeploymentConfig, + WorkerDeploymentVersion, +) + +worker = Worker( + client, + task_queue="my-task-queue", + workflows=[MyWorkflow], + activities=[my_activity], + deployment_config=WorkerDeploymentConfig( + version=WorkerDeploymentVersion( + deployment_name="my-service", + build_id="v1.0.0", # or git commit hash + ), + use_worker_versioning=True, + ), +) +``` + +**Configuration parameters:** +- `use_worker_versioning`: Enables Worker Versioning +- `version`: Identifies the Worker Deployment Version (deployment name + build ID) +- Build ID: Typically a git commit hash, version number, or timestamp + +### PINNED vs AUTO_UPGRADE Behaviors + +**PINNED Behavior** + +Workflows stay locked to their original Worker version: + +```python +from temporalio.workflow import VersioningBehavior + +@workflow.defn +class StableWorkflow: + @workflow.run + async def run(self) -> str: + # This workflow will always run on its assigned version + return await workflow.execute_activity( + process_order, + start_to_close_timeout=timedelta(minutes=5), + ) +``` + +**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: + +**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. + +### Worker Configuration with Default Behavior + +```python +# For short-running workflows, prefer PINNED +worker = Worker( + client, + task_queue="orders-task-queue", + workflows=[OrderWorkflow], + activities=[process_order], + deployment_config=WorkerDeploymentConfig( + version=WorkerDeploymentVersion( + deployment_name="order-service", + build_id=os.environ["BUILD_ID"], + ), + use_worker_versioning=True, + # default_versioning_behavior=VersioningBehavior.PINNED, + ), +) +``` + +### 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 + +This works well with Kubernetes where you manage multiple ReplicaSets running different Worker versions. + +### Querying Workflows by Worker Version + +```bash +# Find workflows on a specific Worker version +temporal workflow list --query \ + 'TemporalWorkerDeploymentVersion = "my-service:v1.0.0" AND ExecutionStatus = "Running"' +``` + +## Best Practices + +1. **Check for open executions** before removing old code paths +2. **Use descriptive patch IDs** that explain the change (e.g., "add-fraud-check" not "patch-1") +3. **Deploy patches incrementally**: patch, deprecate, remove +4. **Use PINNED for short workflows** to simplify version management +5. **Use AUTO_UPGRADE with patching** for long-running workflows that need updates +6. **Generate Build IDs from code** (git hash) to ensure changes produce new versions +7. **Avoid rolling deployments** for high-availability services with long-running workflows diff --git a/references/typescript/advanced-features.md b/references/typescript/advanced-features.md new file mode 100644 index 0000000..17b7e61 --- /dev/null +++ b/references/typescript/advanced-features.md @@ -0,0 +1,150 @@ +# TypeScript SDK Advanced Features + +## Schedules + +Create recurring workflow executions. + +```typescript +import { Client, ScheduleOverlapPolicy } from '@temporalio/client'; + +const client = new Client(); + +// Create a schedule +const schedule = await client.schedule.create({ + scheduleId: 'daily-report', + spec: { + intervals: [{ every: '1 day' }], + }, + action: { + type: 'startWorkflow', + workflowType: 'dailyReportWorkflow', + taskQueue: 'reports', + args: [], + }, + policies: { + overlap: ScheduleOverlapPolicy.SKIP, + }, +}); + +// Manage schedules +const handle = client.schedule.getHandle('daily-report'); +await handle.pause('Maintenance window'); +await handle.unpause(); +await handle.trigger(); // Run immediately +await handle.delete(); +``` + +## Async Activity Completion + +Complete an activity asynchronously from outside the activity function. Useful when the activity needs to wait for an external event. + +**In the activity - return the task token:** +```typescript +import { CompleteAsyncError, activityInfo } from '@temporalio/activity'; + +export async function doSomethingAsync(): Promise { + const taskToken: Uint8Array = activityInfo().taskToken; + setTimeout(() => doSomeWork(taskToken), 1000); + throw new CompleteAsyncError(); +} +``` + +**External completion (from another process, machine, etc.):** +```typescript +import { Client } from '@temporalio/client'; + +async function doSomeWork(taskToken: Uint8Array): Promise { + const client = new Client(); + // does some work... + await client.activity.complete(taskToken, "Job's done!"); +} +``` + +**When to use:** +- Waiting for human approval +- Waiting for external webhook callback +- Long-polling external systems + +## Worker Tuning + +Configure worker capacity for production workloads: + +```typescript +import { Worker, NativeConnection } from '@temporalio/worker'; + +const worker = await Worker.create({ + connection: await NativeConnection.connect({ address: 'temporal:7233' }), + taskQueue: 'my-queue', + workflowBundle: { codePath: require.resolve('./workflow-bundle.js') }, // Pre-bundled for production + activities, + + // Workflow execution concurrency (default: 40) + maxConcurrentWorkflowTaskExecutions: 100, + + // Activity execution concurrency (default: 100) + maxConcurrentActivityTaskExecutions: 200, + + // Graceful shutdown timeout (default: 0) + shutdownGraceTime: '30 seconds', + + // Max cached workflows (memory vs latency tradeoff) + maxCachedWorkflows: 1000, +}); +``` + +**Key settings:** +- `maxConcurrentWorkflowTaskExecutions`: Max workflows running simultaneously (default: 40) +- `maxConcurrentActivityTaskExecutions`: Max activities running simultaneously (default: 100) +- `shutdownGraceTime`: Time to wait for in-progress work before forced shutdown +- `maxCachedWorkflows`: Number of workflows to keep in cache (reduces replay on cache hit) + +## Sinks + +Sinks allow workflows to emit events for side effects (logging, metrics). + +```typescript +import { proxySinks, Sinks } from '@temporalio/workflow'; + +// Define sink interface +export interface LoggerSinks extends Sinks { + logger: { + info(message: string, attrs: Record): void; + error(message: string, attrs: Record): void; + }; +} + +// Use in workflow +const { logger } = proxySinks(); + +export async function myWorkflow(input: string): Promise { + logger.info('Workflow started', { input }); + + const result = await someActivity(input); + + logger.info('Workflow completed', { result }); + return result; +} + +// Implement sink in worker +const worker = await Worker.create({ + workflowsPath: require.resolve('./workflows'), // Use workflowBundle for production + activities, + taskQueue: 'my-queue', + sinks: { + logger: { + info: { + fn(workflowInfo, message, attrs) { + console.log(`[${workflowInfo.workflowId}] ${message}`, attrs); + }, + callDuringReplay: false, // Don't log during replay + }, + error: { + fn(workflowInfo, message, attrs) { + console.error(`[${workflowInfo.workflowId}] ${message}`, attrs); + }, + callDuringReplay: false, + }, + }, + }, +}); +``` diff --git a/references/typescript/data-handling.md b/references/typescript/data-handling.md new file mode 100644 index 0000000..bfd4925 --- /dev/null +++ b/references/typescript/data-handling.md @@ -0,0 +1,253 @@ +# TypeScript SDK Data Handling + +## Overview + +The TypeScript SDK uses data converters to serialize/deserialize workflow inputs, outputs, and activity parameters. + +## Default Data Converter + +The default converter handles: +- `undefined` and `null` +- `Uint8Array` (as binary) +- JSON-serializable types + +Note: Protobuf support requires using a data converter (`DefaultPayloadConverterWithProtobufs`). See the Protobuf Support section below. + +## Custom Data Converter + +Create custom converters for special serialization needs. + +```typescript +// payload-converter.ts +import { + PayloadConverter, + Payload, + defaultPayloadConverter, +} from '@temporalio/common'; + +class CustomPayloadConverter implements PayloadConverter { + toPayload(value: T): Payload | undefined { + // Custom serialization logic + return defaultPayloadConverter.toPayload(value); + } + + fromPayload(payload: Payload): T { + // Custom deserialization logic + return defaultPayloadConverter.fromPayload(payload); + } +} + +export const payloadConverter = new CustomPayloadConverter(); +``` + +```typescript +// client.ts +import { Client } from '@temporalio/client'; + +const client = new Client({ + dataConverter: { + payloadConverterPath: require.resolve('./payload-converter'), + }, +}); +``` + +```typescript +// worker.ts +import { Worker } from '@temporalio/worker'; + +const worker = await Worker.create({ + dataConverter: { + payloadConverterPath: require.resolve('./payload-converter'), + }, + // ... +}); +``` + +## Composition of Payload Converters + +```typescript +import { CompositePayloadConverter } from '@temporalio/common'; + +// The order matters — converters are tried in sequence until one returns a non-null Payload +export const payloadConverter = new CompositePayloadConverter( + new PayloadConverterFoo(), + new PayloadConverterBar(), +); +``` + +## Protobuf Support + +Using Protocol Buffers for type-safe serialization. + +**Note:** JSON serialization (the default) is preferred for TypeScript applications—it's simpler and more performant. Use Protobuf only when interoperating with services that require it. + +```typescript +import { DefaultPayloadConverterWithProtobufs } from '@temporalio/common/lib/protobufs'; + +const dataConverter: DataConverter = { + payloadConverter: new DefaultPayloadConverterWithProtobufs({ + protobufRoot: myProtobufRoot, + }), +}; +``` + +## Payload Codec (Encryption) + +Encrypt sensitive workflow data. + +```typescript +import { PayloadCodec, Payload } from '@temporalio/common'; + +class EncryptionCodec implements PayloadCodec { + private readonly encryptionKey: Uint8Array; + + constructor(key: Uint8Array) { + this.encryptionKey = key; + } + + async encode(payloads: Payload[]): Promise { + return Promise.all( + payloads.map(async (payload) => ({ + metadata: { + encoding: 'binary/encrypted', + }, + data: await this.encrypt(payload.data ?? new Uint8Array()), + })) + ); + } + + async decode(payloads: Payload[]): Promise { + return Promise.all( + payloads.map(async (payload) => { + if (payload.metadata?.encoding === 'binary/encrypted') { + return { + ...payload, + data: await this.decrypt(payload.data ?? new Uint8Array()), + }; + } + return payload; + }) + ); + } + + private async encrypt(data: Uint8Array): Promise { + // Implement encryption (e.g., using Web Crypto API) + return data; + } + + private async decrypt(data: Uint8Array): Promise { + // Implement decryption + return data; + } +} + +// Apply codec +const dataConverter: DataConverter = { + payloadCodecs: [new EncryptionCodec(encryptionKey)], +}; +``` + +## Search Attributes + +Custom searchable fields for workflow visibility. + +### Setting Search Attributes at Start + +```typescript +import { Client } from '@temporalio/client'; + +const client = new Client(); + +await client.workflow.start('orderWorkflow', { + taskQueue: 'orders', + workflowId: `order-${orderId}`, + args: [order], + searchAttributes: { + OrderId: [orderId], + CustomerType: ['premium'], + OrderTotal: [99.99], + CreatedAt: [new Date()], + }, +}); +``` + +### Upserting Search Attributes from Workflow + +```typescript +import { upsertSearchAttributes, workflowInfo } from '@temporalio/workflow'; + +export async function orderWorkflow(order: Order): Promise { + // Update status as workflow progresses + upsertSearchAttributes({ + OrderStatus: ['processing'], + }); + + await processOrder(order); + + upsertSearchAttributes({ + OrderStatus: ['completed'], + }); + + return 'done'; +} +``` + +### Reading Search Attributes + +```typescript +import { workflowInfo } from '@temporalio/workflow'; + +export async function orderWorkflow(): Promise { + const info = workflowInfo(); + const searchAttrs = info.searchAttributes; + const orderId = searchAttrs?.OrderId?.[0]; + // ... +} +``` + +### Querying Workflows by Search Attributes + +```typescript +const client = new Client(); + +// List workflows using search attributes +for await (const workflow of client.workflow.list({ + query: 'OrderStatus = "processing" AND CustomerType = "premium"', +})) { + console.log(`Workflow ${workflow.workflowId} is still processing`); +} +``` + +## Workflow Memo + +Store arbitrary metadata with workflows (not searchable). + +```typescript +// Set memo at workflow start +await client.workflow.start('orderWorkflow', { + taskQueue: 'orders', + workflowId: `order-${orderId}`, + args: [order], + memo: { + customerName: order.customerName, + notes: 'Priority customer', + }, +}); + +// Read memo from workflow +import { workflowInfo } from '@temporalio/workflow'; + +export async function orderWorkflow(): Promise { + const info = workflowInfo(); + const customerName = info.memo?.customerName; + // ... +} +``` + +## Best Practices + +1. Keep payloads small—see `references/core/gotchas.md` for limits +2. Use search attributes for business-level visibility and filtering +3. Encrypt sensitive data with PayloadCodec +4. Use memo for non-searchable metadata +5. Configure the same data converter on both client and worker diff --git a/references/typescript/determinism-protection.md b/references/typescript/determinism-protection.md new file mode 100644 index 0000000..54303ba --- /dev/null +++ b/references/typescript/determinism-protection.md @@ -0,0 +1,56 @@ +# TypeScript Workflow V8 Sandboxing + +## Overview + +The TypeScript SDK runs workflows in a V8 sandbox that provides automatic protection against non-deterministic operations, and replaces common non-deterministic function calls with deterministic variants. + +## Import Blocking + +The sandbox blocks imports of `fs`, `https` modules, and any Node/DOM APIs. Otherwise, workflow code can import any package as long as it does not reference Node.js or DOM APIs. + +**Note**: If you must use a library that references a Node.js or DOM API and you are certain that those APIs are not used at runtime, add that module to the `ignoreModules` list: + +```ts +const worker = await Worker.create({ + workflowsPath: require.resolve('./workflows'), // bundlerOptions only apply with workflowsPath + activities: require('./activities'), + taskQueue: 'my-task-queue', + bundlerOptions: { + // These modules may be imported (directly or transitively), + // but will be excluded from the Workflow bundle. + ignoreModules: ['fs', 'http', 'crypto'], + }, +}); +``` + +**Important**: Excluded modules are completely unavailable at runtime. Any attempt to call functions from these modules will throw an error. Only exclude modules when you are certain the code paths using them will never execute during workflow execution. + +**Note**: Modules with the `node:` prefix (e.g., `node:fs`) require additional webpack configuration to ignore. You may need to configure the bundler's `externals` or use webpack `resolve.alias` to handle these imports. + +Use this with *extreme caution*. + + +## Function Replacement + +Functions like `Math.random()`, `Date`, and `setTimeout()` are replaced by deterministic versions. + +Date-related functions return the timestamp at which the current workflow task was initially executed. That timestamp remains the same when the workflow task is replayed, and only advances when a durable operation occurs (like `sleep()`). For example: + +```ts +import { sleep } from '@temporalio/workflow'; + +// this prints the *exact* same timestamp repeatedly +for (let x = 0; x < 10; ++x) { + console.log(Date.now()); +} + +// this prints timestamps increasing roughly 1s each iteration +for (let x = 0; x < 10; ++x) { + await sleep('1 second'); + console.log(Date.now()); +} +``` + +Generally, this is the behavior you want. + +Additionally, `FinalizationRegistry` and `WeakRef` are removed because v8's garbage collector is not deterministic. diff --git a/references/typescript/determinism.md b/references/typescript/determinism.md new file mode 100644 index 0000000..47f8948 --- /dev/null +++ b/references/typescript/determinism.md @@ -0,0 +1,51 @@ +# TypeScript SDK Determinism + +## Overview + +The TypeScript SDK runs workflows in an isolated V8 sandbox that automatically provides determinism. + +## Why Determinism Matters + +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**. + +## Temporal's V8 Sandbox + +The Temporal TypeScript SDK executes all workflow code in sandbox, which (among other things), replaces common non-deterministic functions with deterministic variants. As an example, consider the code below: + +```ts +export async function myWorkflow(): Promise { + await importData(); + + if (Math.random() > 0.5) { + await sleep('30 minutes'); + } + + return await sendReport(); +} +``` + +The Temporal workflow sandbox will use the same random seed when replaying a workflow, so the above code will **deterministically** generate pseudo-random numbers. For UUIDs, use `uuid4()` from `@temporalio/workflow` which also uses the seeded PRNG. + +See `references/typescript/determinism-protection.md` for more information about the sandbox. + +## Forbidden Operations + +```typescript +// DO NOT do these in workflows: +import fs from 'fs'; // Node.js modules +fetch('https://...'); // Network I/O +``` + +Most non-determinism and side effects, such as the above, should be wrapped in Activities. + +## Testing Replay Compatibility + +Use `Worker.runReplayHistory()` to verify your code changes are compatible with existing histories. See the Workflow Replay Testing section of `references/typescript/testing.md`. + +## Best Practices + +1. Use type-only imports for activities in workflow files +2. Match all @temporalio package versions +3. Prefer `sleep()` from workflow package — `setTimeout` works but `sleep()` handles cancellation scopes more clearly +4. Keep workflows focused on orchestration +5. Test with replay to verify determinism diff --git a/references/typescript/error-handling.md b/references/typescript/error-handling.md new file mode 100644 index 0000000..7072fbd --- /dev/null +++ b/references/typescript/error-handling.md @@ -0,0 +1,119 @@ +# TypeScript SDK Error Handling + +## Overview + +The TypeScript SDK uses `ApplicationFailure` for application errors with support for non-retryable marking. + +## Application Failures + +```typescript +import { ApplicationFailure } from '@temporalio/workflow'; + +export async function myWorkflow(): Promise { + throw ApplicationFailure.create({ + message: 'Invalid input', + type: 'ValidationError', + nonRetryable: true, + }); +} +``` + +## Activity Errors + +```typescript +import { ApplicationFailure } from '@temporalio/activity'; + +export async function validateActivity(input: string): Promise { + if (!isValid(input)) { + throw ApplicationFailure.create({ + message: `Invalid input: ${input}`, + type: 'ValidationError', + nonRetryable: true, + }); + } +} +``` + +## Handling Errors in Workflows + +```typescript +import { proxyActivities, ApplicationFailure, log } from '@temporalio/workflow'; +import type * as activities from './activities'; + +const { riskyActivity } = proxyActivities({ + startToCloseTimeout: '5 minutes', +}); + +export async function workflowWithErrorHandling(): Promise { + try { + return await riskyActivity(); + } catch (err) { + if (err instanceof ApplicationFailure) { + log.warn('Activity failed', { type: err.type, message: err.message }); + } + throw err; + } +} +``` + +## Retry Configuration + +```typescript +const { myActivity } = proxyActivities({ + startToCloseTimeout: '10 minutes', + retry: { + initialInterval: '1s', + backoffCoefficient: 2, + maximumInterval: '1m', + maximumAttempts: 5, + nonRetryableErrorTypes: ['ValidationError', 'PaymentError'], + }, +}); +``` + +**Note:** Only set retry options if you have a domain-specific reason to. The defaults are suitable for most use cases. + +## Timeout Configuration + +```typescript +const { myActivity } = proxyActivities({ + startToCloseTimeout: '5 minutes', // Single attempt + scheduleToCloseTimeout: '30 minutes', // Including retries + heartbeatTimeout: '30 seconds', // Between heartbeats +}); +``` + +## Workflow Failure + +Workflows can throw errors to indicate failure: + +```typescript +import { ApplicationFailure } from '@temporalio/workflow'; + +export async function myWorkflow(): Promise { + if (someCondition) { + throw ApplicationFailure.create({ + message: 'Workflow failed due to invalid state', + type: 'InvalidStateError', + }); + } + return 'success'; +} +``` + +**Warning:** Do NOT use `nonRetryable: true` for workflow failures in most cases. Unlike activities, workflow retries are controlled by the caller, not retry policies. Use `nonRetryable` only for errors that are truly unrecoverable (e.g., invalid input that will never be valid). + +## Idempotency + +For idempotency patterns (using keys, making activities granular), see `core/patterns.md`. + +## Best Practices + +1. Use specific error types for different failure modes +2. Set `nonRetryable: true` for permanent failures in activities +3. Configure `nonRetryableErrorTypes` in retry policy +4. Log errors before re-raising +5. Use `ApplicationFailure` to catch activity failures in workflows +6. Use the appropriate `log` import for your context: + - In workflows: `import { log } from '@temporalio/workflow'` (replay-safe) + - In activities: `import { log } from '@temporalio/activity'` diff --git a/references/typescript/gotchas.md b/references/typescript/gotchas.md new file mode 100644 index 0000000..d234f74 --- /dev/null +++ b/references/typescript/gotchas.md @@ -0,0 +1,312 @@ +# TypeScript Gotchas + +TypeScript-specific mistakes and anti-patterns. See also [Common Gotchas](../core/gotchas.md) for language-agnostic concepts. + +## Activity Imports + +### Importing Implementations Instead of Types + +**The Problem**: Importing activity implementations brings Node.js code into the V8 workflow sandbox, causing bundling errors or runtime failures. + +```typescript +// BAD - Brings actual code into workflow sandbox +import * as activities from './activities'; + +const { greet } = proxyActivities({ + startToCloseTimeout: '1 minute', +}); + +// GOOD - Type-only import +import type * as activities from './activities'; + +const { greet } = proxyActivities({ + startToCloseTimeout: '1 minute', +}); +``` + +### Importing Node.js Modules in Workflows + +```typescript +// BAD - fs is not available in workflow sandbox +import * as fs from 'fs'; + +export async function myWorkflow(): Promise { + const data = fs.readFileSync('file.txt'); // Will fail! +} + +// GOOD - File I/O belongs in activities +export async function myWorkflow(): Promise { + const data = await activities.readFile('file.txt'); +} +``` + +## Bundling Issues + +### Using workflowsPath in Production + +`workflowsPath` runs the bundler at Worker startup, which is slow and not suitable for production. Use `workflowBundle` with pre-bundled code instead. + +```typescript +// OK for development/testing, BAD for production - bundles at startup +const worker = await Worker.create({ + workflowsPath: require.resolve('./workflows'), + // ... +}); + +// GOOD for production - use pre-bundled code +import { bundleWorkflowCode } from '@temporalio/worker'; + +// Build step (run once at build time) +const bundle = await bundleWorkflowCode({ + workflowsPath: require.resolve('./workflows'), +}); +await fs.promises.writeFile('./workflow-bundle.js', bundle.code); + +// Worker startup (fast, no bundling) +const worker = await Worker.create({ + workflowBundle: { + codePath: require.resolve('./workflow-bundle.js'), + }, + // ... +}); +``` + +### Missing Dependencies in Workflow Bundle + +```typescript +// If using external packages in workflows, ensure they're bundled + +// worker.ts +const worker = await Worker.create({ + workflowsPath: require.resolve('./workflows'), + bundlerOptions: { + // Exclude Node.js-only packages that cause bundling errors + // WARNING: Modules listed here will be completely unavailable + // at workflow runtime - any imports will fail + ignoreModules: ['some-node-only-package'], + }, +}); +``` + +### Package Version Mismatches + +All `@temporalio/*` packages must have the same version. This can be verified by running `npm ls` or the appropriate command for your package manager. + +### Package Version Constraints - Prod vs. Non-Prod + +For production apps, you should use ~ version constraints (bug fixes only) on Temporal packages. For non-production apps, you may use ^ constraints (the npm default) instead. + +## Wrong Retry Classification + +A common mistake is treating transient errors as permanent (or vice versa): + +- **Transient errors** (retry): network timeouts, temporary service unavailability, rate limits +- **Permanent errors** (don't retry): invalid input, authentication failure, resource not found + +```typescript +// BAD: Retrying a permanent error +throw ApplicationFailure.create({ message: 'User not found' }); +// This will retry indefinitely! + +// GOOD: Mark permanent errors as non-retryable +throw ApplicationFailure.nonRetryable('User not found'); +``` + +For detailed guidance on error classification and retry policies, see `error-handling.md`. + +## Cancellation + +### Not Handling Workflow Cancellation + +```typescript +// BAD - Cleanup doesn't run on cancellation +export async function workflowWithCleanup(): Promise { + await activities.acquireResource(); + await activities.doWork(); + await activities.releaseResource(); // Never runs if cancelled! +} + +// GOOD - Use CancellationScope for cleanup +import { CancellationScope } from '@temporalio/workflow'; + +export async function workflowWithCleanup(): Promise { + await activities.acquireResource(); + try { + await activities.doWork(); + } finally { + // Run cleanup even on cancellation + await CancellationScope.nonCancellable(async () => { + await activities.releaseResource(); + }); + } +} +``` + +### Not Handling Activity Cancellation + +Activities must **opt in** to receive cancellation. This requires: +1. **Heartbeating** - Cancellation is delivered via heartbeat +2. **Checking for cancellation** - Either await `Context.current().cancelled` or use `cancellationSignal()` + +```typescript +// BAD - Activity ignores cancellation +export async function longActivity(): Promise { + await doExpensiveWork(); // Runs to completion even if cancelled +} +``` + +```typescript +// GOOD - Heartbeat in background and race work against cancellation promise +import { Context, CancelledFailure } from '@temporalio/activity'; + +export async function longActivity(): Promise { + // Heartbeat in background so cancellation can be delivered + let heartbeatEnabled = true; + (async () => { + while (heartbeatEnabled) { + await Context.current().sleep(5000); + Context.current().heartbeat(); + } + })().catch(() => {}); + + try { + await Promise.race([ + Context.current().cancelled, // Rejects with CancelledFailure + doExpensiveWork(), + ]); + } catch (err) { + if (err instanceof CancelledFailure) { + await cleanup(); + } + throw err; + } finally { + heartbeatEnabled = false; + } +} +``` + +```typescript +// GOOD - Use AbortSignal with libraries that support it +import fetch from 'node-fetch'; +import { cancellationSignal, heartbeat } from '@temporalio/activity'; +import type { AbortSignal as FetchAbortSignal } from 'node-fetch/externals'; + +export async function cancellableFetch(url: string): Promise { + const response = await fetch(url, { signal: cancellationSignal() as FetchAbortSignal }); + + const contentLength = parseInt(response.headers.get('Content-Length')!); + let bytesRead = 0; + const chunks: Buffer[] = []; + + for await (const chunk of response.body) { + if (!(chunk instanceof Buffer)) throw new TypeError('Expected Buffer'); + bytesRead += chunk.length; + chunks.push(chunk); + heartbeat(bytesRead / contentLength); // Heartbeat to keep cancellation delivery alive + } + return Buffer.concat(chunks); +} +``` + +**Note:** `Promise.race` doesn't stop the losing promise—it continues running. Use `cancellationSignal()` or explicitly abort sub-operations when cleanup requires stopping in-flight work. + +## Heartbeating + +### Forgetting to Heartbeat Long Activities + +```typescript +// BAD - No heartbeat, can't detect stuck activities +export async function processLargeFile(path: string): Promise { + for await (const chunk of readChunks(path)) { + await processChunk(chunk); // Takes hours, no heartbeat + } +} + +// GOOD - Regular heartbeats with progress +import { heartbeat } from '@temporalio/activity'; + +export async function processLargeFile(path: string): Promise { + let i = 0; + for await (const chunk of readChunks(path)) { + heartbeat(`Processing chunk ${i++}`); + await processChunk(chunk); + } +} +``` + +### Heartbeat Timeout Too Short + +```typescript +// BAD - Heartbeat timeout shorter than processing time +const { processChunk } = proxyActivities({ + startToCloseTimeout: '30 minutes', + heartbeatTimeout: '10 seconds', // Too short! +}); + +// GOOD - Heartbeat timeout allows for processing variance +const { processChunk } = proxyActivities({ + startToCloseTimeout: '30 minutes', + heartbeatTimeout: '2 minutes', +}); +``` + +Set heartbeat timeout as high as acceptable for your use case — each heartbeat counts as an action. + +## Testing + +### Not Testing Failures + +```typescript +import { TestWorkflowEnvironment } from '@temporalio/testing'; +import { Worker } from '@temporalio/worker'; + +test('handles activity failure', async () => { + const env = await TestWorkflowEnvironment.createTimeSkipping(); + + const worker = await Worker.create({ + connection: env.nativeConnection, + taskQueue: 'test', + workflowsPath: require.resolve('./workflows'), + activities: { + // Activity that always fails + riskyOperation: async () => { + throw ApplicationFailure.nonRetryable('Simulated failure'); + }, + }, + }); + + await worker.runUntil(async () => { + await expect( + env.client.workflow.execute(riskyWorkflow, { + workflowId: 'test-failure', + taskQueue: 'test', + }) + ).rejects.toThrow('Simulated failure'); + }); + + await env.teardown(); +}); +``` + +### Not Testing Replay + +```typescript +import { Worker } from '@temporalio/worker'; +import * as fs from 'fs'; + +test('replay compatibility', async () => { + const history = JSON.parse(await fs.promises.readFile('./fixtures/workflow_history.json', 'utf8')); + + // Fails if current code is incompatible with history + await Worker.runReplayHistory( + { + workflowsPath: require.resolve('./workflows'), + }, + history, + ); +}); +``` + +## Timers and Sleep + +`setTimeout` works in workflows (the SDK mocks it), but `sleep()` from `@temporalio/workflow` is preferred because its interaction with cancellation scopes is more intuitive. See Timers in `references/typescript/patterns.md`. diff --git a/references/typescript/observability.md b/references/typescript/observability.md new file mode 100644 index 0000000..10244d7 --- /dev/null +++ b/references/typescript/observability.md @@ -0,0 +1,109 @@ +# TypeScript SDK Observability + +## Overview + +The TypeScript SDK provides replay-aware logging, metrics, and integrations for production observability. + +## Replay-Aware Logging + +Temporal's logger automatically suppresses duplicate messages during replay, preventing log spam when workflows recover state. + +### Workflow Logging + +Workflows run in a sandboxed environment and cannot use regular Node.js loggers directly. Since SDK 1.8.0, the `@temporalio/workflow` package exports a `log` object that provides replay-aware logging. Internally, it uses Sinks to funnel messages to the Runtime's logger. + +```typescript +import { log } from '@temporalio/workflow'; + +export async function orderWorkflow(orderId: string): Promise { + log.info('Processing order', { orderId }); + + const result = await processPayment(orderId); + log.debug('Payment processed', { orderId, result }); + + return result; +} +``` + +**Log levels**: `log.debug()`, `log.info()`, `log.warn()`, `log.error()` + +The workflow logger automatically suppresses duplicate messages during replay and includes workflow context metadata (workflowId, runId, etc.) on every log entry. + +### Activity Logging + +```typescript +import { log } from '@temporalio/activity'; + +export async function processPayment(orderId: string): Promise { + log.info('Processing payment', { orderId }); + return 'payment-id-123'; +} +``` + +The activity logger adds contextual metadata (activity ID, type, namespace) and funnels messages to the runtime's logger for consistent collection. + +## Customizing the Logger + +### Basic Configuration + +```typescript +import { DefaultLogger, Runtime } from '@temporalio/worker'; + +const logger = new DefaultLogger('DEBUG', ({ level, message }) => { + console.log(`Custom logger: ${level} - ${message}`); +}); +Runtime.install({ logger }); +``` + +### Winston Integration + +```typescript +import winston from 'winston'; +import { DefaultLogger, Runtime } from '@temporalio/worker'; + +const winstonLogger = winston.createLogger({ + level: 'debug', + format: winston.format.json(), + transports: [ + new winston.transports.File({ filename: 'temporal.log' }) + ], +}); + +const logger = new DefaultLogger('DEBUG', (entry) => { + winstonLogger.log({ + label: entry.meta?.activityId ? 'activity' : entry.meta?.workflowId ? 'workflow' : 'worker', + level: entry.level.toLowerCase(), + message: entry.message, + timestamp: Number(entry.timestampNanos / 1_000_000n), + ...entry.meta, + }); +}); + +Runtime.install({ logger }); +``` + +## Metrics + +### Prometheus Metrics + +```typescript +import { Runtime } from '@temporalio/worker'; + +Runtime.install({ + telemetryOptions: { + metrics: { + prometheus: { + bindAddress: '127.0.0.1:9091', + }, + }, + }, +}); +``` + +## Best Practices + +1. Use `log` from `@temporalio/workflow` for production observability. For temporary print debugging, `console.log()` is fine—it's direct and immediate, whereas `log` goes through sinks which may lose messages on workflow errors +2. Include correlation IDs (orderId, customerId) in log messages +3. Configure Winston or similar for production log aggregation +4. Monitor Prometheus metrics for worker health +5. Use Event History for debugging workflow issues diff --git a/references/typescript/patterns.md b/references/typescript/patterns.md new file mode 100644 index 0000000..3d59e23 --- /dev/null +++ b/references/typescript/patterns.md @@ -0,0 +1,417 @@ +# TypeScript SDK Patterns + +## Signals + +```typescript +import { defineSignal, setHandler, condition } from '@temporalio/workflow'; + +const approveSignal = defineSignal<[boolean]>('approve'); +const addItemSignal = defineSignal<[string]>('addItem'); + +export async function orderWorkflow(): Promise { + let approved = false; + const items: string[] = []; + + setHandler(approveSignal, (value) => { + approved = value; + }); + + setHandler(addItemSignal, (item) => { + items.push(item); + }); + + await condition(() => approved); + return `Processed ${items.length} 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. + +```typescript +import { setDefaultSignalHandler, condition } from '@temporalio/workflow'; + +export async function dynamicSignalWorkflow(): Promise> { + const signals: Record = {}; + + setDefaultSignalHandler((signalName: string, ...args: unknown[]) => { + if (!signals[signalName]) { + signals[signalName] = []; + } + signals[signalName].push(args); + }); + + await condition(() => signals['done'] !== undefined); + return signals; +} +``` + +## Queries + +**Important:** Queries must NOT modify workflow state or have side effects. + +```typescript +import { defineQuery, setHandler } from '@temporalio/workflow'; + +const statusQuery = defineQuery('status'); +const progressQuery = defineQuery('progress'); + +export async function progressWorkflow(): Promise { + let status = 'running'; + let progress = 0; + + setHandler(statusQuery, () => status); + setHandler(progressQuery, () => progress); + + for (let i = 0; i < 100; i++) { + progress = i; + await doWork(); + } + status = 'completed'; +} +``` + +## 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. + +```typescript +import { setDefaultQueryHandler } from '@temporalio/workflow'; + +export async function dynamicQueryWorkflow(): Promise { + const state: Record = { + status: 'running', + progress: 0, + }; + + setDefaultQueryHandler((queryName: string) => { + return state[queryName]; + }); + + // ... workflow logic +} +``` + +## Updates + +```typescript +import { defineUpdate, setHandler, condition } from '@temporalio/workflow'; + +// Define the update - specify return type and argument types +export const addItemUpdate = defineUpdate('addItem'); +export const addItemValidatedUpdate = defineUpdate('addItemValidated'); + +export async function orderWorkflow(): Promise { + const items: string[] = []; + let completed = false; + + // Simple update handler - returns new item count + setHandler(addItemUpdate, (item: string) => { + items.push(item); + return items.length; + }); + + // Update handler with validator - rejects invalid input before execution + setHandler( + addItemValidatedUpdate, + (item: string) => { + items.push(item); + return items.length; + }, + { + validator: (item: string) => { + if (!item) throw new Error('Item cannot be empty'); + if (items.length >= 100) throw new Error('Order is full'); + }, + } + ); + + await condition(() => completed); + return `Order with ${items.length} items completed`; +} +``` + +**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 error to reject the update; return normally to accept. + +## Child Workflows + +```typescript +import { executeChild } from '@temporalio/workflow'; + +export async function parentWorkflow(orders: Order[]): Promise { + const results: string[] = []; + + for (const order of orders) { + const result = await executeChild(processOrderWorkflow, { + args: [order], + workflowId: `order-${order.id}`, + }); + results.push(result); + } + + return results; +} +``` + +### Child Workflow Options + +```typescript +import { executeChild, ParentClosePolicy, ChildWorkflowCancellationType } from '@temporalio/workflow'; + +const result = await executeChild(childWorkflow, { + args: [input], + workflowId: `child-${workflowInfo().workflowId}`, + + // ParentClosePolicy - what happens to child when parent closes + // TERMINATE (default), ABANDON, REQUEST_CANCEL + parentClosePolicy: ParentClosePolicy.TERMINATE, + + // ChildWorkflowCancellationType - how cancellation is handled + // WAIT_CANCELLATION_COMPLETED (default), WAIT_CANCELLATION_REQUESTED, TRY_CANCEL, ABANDON + cancellationType: ChildWorkflowCancellationType.WAIT_CANCELLATION_COMPLETED, +}); +``` + +## Handles to External Workflows + +```typescript +import { getExternalWorkflowHandle } from '@temporalio/workflow'; +import { mySignal } from './other-workflows'; + +export async function coordinatorWorkflow(targetWorkflowId: string): Promise { + const handle = getExternalWorkflowHandle(targetWorkflowId); + + // Signal the external workflow + await handle.signal(mySignal, { data: 'payload' }); + + // Or cancel it + await handle.cancel(); +} +``` + +## Parallel Execution + +```typescript +export async function parallelWorkflow(items: string[]): Promise { + return await Promise.all( + items.map((item) => processItem(item)) + ); +} +``` + +## Continue-as-New + +```typescript +import { continueAsNew, workflowInfo } from '@temporalio/workflow'; + +export async function longRunningWorkflow(state: State): Promise { + while (true) { + state = await processNextBatch(state); + + if (state.isComplete) { + return 'done'; + } + + const info = workflowInfo(); + if (info.continueAsNewSuggested || info.historyLength > 10000) { + await continueAsNew(state); + } + } +} +``` + +## Saga Pattern + +**Important:** Compensation activities should be idempotent. + +```typescript +import { CancellationScope, log } from '@temporalio/workflow'; + +export async function sagaWorkflow(order: Order): Promise { + const compensations: Array<() => Promise> = []; + + try { + // IMPORTANT: Save compensation BEFORE calling the activity + // If activity fails after completing but before returning, + // compensation must still be registered + compensations.push(() => releaseInventory(order)); + await reserveInventory(order); + + compensations.push(() => refundPayment(order)); + await chargePayment(order); + + await shipOrder(order); + return 'Order completed'; + } catch (err) { + // nonCancellable ensures compensations run even if the workflow is cancelled + await CancellationScope.nonCancellable(async () => { + for (const compensate of compensations.reverse()) { + try { + await compensate(); + } catch (compErr) { + log.warn('Compensation failed', { error: compErr }); + } + } + }); + throw err; + } +} +``` + +## Cancellation Scopes + +Cancellation scopes control how cancellation propagates to activities and child workflows. Use them for cleanup logic, timeouts, and manual cancellation. + +```typescript +import { CancellationScope, sleep } from '@temporalio/workflow'; + +export async function scopedWorkflow(): Promise { + // Non-cancellable scope - runs even if workflow cancelled + await CancellationScope.nonCancellable(async () => { + await cleanupActivity(); + }); + + // Timeout scope + await CancellationScope.withTimeout('5 minutes', async () => { + await longRunningActivity(); + }); + + // Manual cancellation + const scope = new CancellationScope(); + const promise = scope.run(() => someActivity()); + scope.cancel(); +} +``` + +## Triggers (Promise-like Signals) + +**WHY**: Triggers provide a one-shot promise that resolves when a signal is received. Cleaner than condition() for single-value signals. + +**WHEN to use**: +- Waiting for a single response (approval, completion notification) +- Converting signal-based events into awaitable promises + +```typescript +import { Trigger } from '@temporalio/workflow'; + +export async function triggerWorkflow(): Promise { + const approvalTrigger = new Trigger(); + + setHandler(approveSignal, (approved) => { + approvalTrigger.resolve(approved); + }); + + const approved = await approvalTrigger; + return approved ? 'Approved' : 'Rejected'; +} +``` + +## Wait Condition with Timeout + +```typescript +import { condition, CancelledFailure } from '@temporalio/workflow'; + +export async function approvalWorkflow(): Promise { + let approved = false; + + setHandler(approveSignal, () => { + approved = true; + }); + + // Wait for approval with 24-hour timeout + const gotApproval = await condition(() => approved, '24 hours'); + + if (gotApproval) { + return 'approved'; + } else { + return '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 `condition(allHandlersFinished)` at the end of your workflow (or before continue-as-new) to prevent completion until all pending handlers complete. + +```typescript +import { condition, allHandlersFinished } from '@temporalio/workflow'; + +export async function handlerAwareWorkflow(): Promise { + // ... main workflow logic ... + + // Before exiting, wait for all handlers to finish + await condition(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 + +```typescript +import { heartbeat, activityInfo, CancelledFailure } from '@temporalio/activity'; + +export async function processLargeFile(filePath: string): Promise { + const info = activityInfo(); + // Get heartbeat details from previous attempt (if any) + const startLine: number = info.heartbeatDetails ?? 0; + + const lines = await readFileLines(filePath); + + try { + for (let i = startLine; i < lines.length; i++) { + await processLine(lines[i]); + // Heartbeat with progress + // If activity is cancelled, heartbeat() throws CancelledFailure + heartbeat(i + 1); + } + return 'completed'; + } catch (e) { + if (e instanceof CancelledFailure) { + // Perform cleanup on cancellation + await cleanup(); + } + throw e; + } +} +``` + +## Timers + +```typescript +import { sleep } from '@temporalio/workflow'; + +export async function timerWorkflow(): Promise { + await sleep('1 hour'); + 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. + +```typescript +import { proxyLocalActivities } from '@temporalio/workflow'; +import type * as activities from './activities'; + +const { quickLookup } = proxyLocalActivities({ + startToCloseTimeout: '5 seconds', +}); + +export async function localActivityWorkflow(): Promise { + const result = await quickLookup('key'); + return result; +} +``` diff --git a/references/typescript/testing.md b/references/typescript/testing.md new file mode 100644 index 0000000..e945ed8 --- /dev/null +++ b/references/typescript/testing.md @@ -0,0 +1,222 @@ +# TypeScript SDK Testing + +## Overview + +The TypeScript SDK provides `TestWorkflowEnvironment` for testing workflows with time-skipping and activity mocking support. Use `createTimeSkipping()` for automatic time advancement when testing workflows with timers, or `createLocal()` for a full local server without time-skipping. + +**Note:** Prefer to use `createLocal()` for full-featured support. Only use `createTimeSkipping()` if you genuinely need time skipping for testing your workflow. + +## Test Environment Setup + +```typescript +import { TestWorkflowEnvironment } from '@temporalio/testing'; +import { Worker } from '@temporalio/worker'; + +describe('Workflow', () => { + let testEnv: TestWorkflowEnvironment; + + before(async () => { + testEnv = await TestWorkflowEnvironment.createLocal(); + }); + + after(async () => { + await testEnv?.teardown(); + }); + + it('runs workflow', async () => { + const { client, nativeConnection } = testEnv; + + const worker = await Worker.create({ + connection: nativeConnection, + taskQueue: 'test', + workflowsPath: require.resolve('./workflows'), + activities: require('./activities'), + }); + + await worker.runUntil(async () => { + const result = await client.workflow.execute(greetingWorkflow, { + taskQueue: 'test', + workflowId: 'test-workflow', + args: ['World'], + }); + expect(result).toEqual('Hello, World!'); + }); + }); +}); +``` + +## Activity Mocking + +```typescript +const worker = await Worker.create({ + connection: nativeConnection, + taskQueue: 'test', + workflowsPath: require.resolve('./workflows'), + activities: { + // Mock activity implementation + greet: async (name: string) => `Mocked: ${name}`, + }, +}); +``` + +## Testing Signals and Queries + +```typescript +import { defineQuery, defineSignal } from '@temporalio/workflow'; + +// Define query and signal (typically in a shared file) +const getStatusQuery = defineQuery('getStatus'); +const approveSignal = defineSignal('approve'); + +it('handles signals and queries', async () => { + await worker.runUntil(async () => { + const handle = await client.workflow.start(approvalWorkflow, { + taskQueue: 'test', + workflowId: 'approval-test', + }); + + // Query current state + const status = await handle.query(getStatusQuery); + expect(status).toEqual('pending'); + + // Send signal + await handle.signal(approveSignal); + + // Wait for completion + const result = await handle.result(); + expect(result).toEqual('Approved!'); + }); +}); +``` + +## Testing Failure Cases + +Test that workflows handle errors correctly: + +```typescript +import { TestWorkflowEnvironment } from '@temporalio/testing'; +import { Worker } from '@temporalio/worker'; +import { WorkflowFailedError } from '@temporalio/client'; +import assert from 'assert'; + +describe('Failure handling', () => { + let testEnv: TestWorkflowEnvironment; + + before(async () => { + testEnv = await TestWorkflowEnvironment.createLocal(); + }); + + after(async () => { + await testEnv?.teardown(); + }); + + it('handles activity failure', async () => { + const { client, nativeConnection } = testEnv; + + const worker = await Worker.create({ + connection: nativeConnection, + taskQueue: 'test', + workflowsPath: require.resolve('./workflows'), + activities: { + // Mock activity that always fails + myActivity: async () => { + throw new Error('Activity failed'); + }, + }, + }); + + await worker.runUntil(async () => { + try { + await client.workflow.execute(myWorkflow, { + workflowId: 'test-failure', + taskQueue: 'test', + }); + assert.fail('Expected workflow to fail'); + } catch (err) { + assert(err instanceof WorkflowFailedError); + } + }); + }); +}); +``` + +## Replay Testing + +```typescript +import { Worker } from '@temporalio/worker'; +import { Client, Connection } from '@temporalio/client'; +import fs from 'fs'; + +describe('Replay', () => { + it('replays workflow history from JSON file', async () => { + // Load history from a JSON file (exported from Web UI or Temporal CLI) + const filePath = './history_file.json'; + const history = JSON.parse(await fs.promises.readFile(filePath, 'utf8')); + + await Worker.runReplayHistory( + { + workflowsPath: require.resolve('./workflows'), + }, + history, + 'my-workflow-id' // Optional: provide workflowId if your workflow depends on it + ); + }); + + it('replays workflow history from server', async () => { + // Fetch history programmatically using the client + const connection = await Connection.connect({ address: 'localhost:7233' }); + const client = new Client({ connection, namespace: 'default' }); + const handle = client.workflow.getHandle('my-workflow-id'); + const history = await handle.fetchHistory(); + + await Worker.runReplayHistory( + { + workflowsPath: require.resolve('./workflows'), + }, + history, + 'my-workflow-id' + ); + }); +}); +``` + +## Activity Testing + +Test activities in isolation without running a workflow: + +```typescript +import { MockActivityEnvironment } from '@temporalio/testing'; +import { CancelledFailure } from '@temporalio/activity'; +import { myActivity } from './activities'; +import assert from 'assert'; + +describe('Activity tests', () => { + it('completes successfully', async () => { + const env = new MockActivityEnvironment(); + const result = await env.run(myActivity, 'input'); + assert.equal(result, 'expected output'); + }); + + it('handles cancellation', async () => { + const env = new MockActivityEnvironment(); + // Cancel the activity after a short delay + setTimeout(() => env.cancel(), 100); + try { + await env.run(longRunningActivity, 'input'); + assert.fail('Expected cancellation'); + } catch (err) { + assert(err instanceof CancelledFailure); + } + }); +}); +``` + +**Note:** `MockActivityEnvironment` provides `heartbeat()` and cancellation support for testing activity behavior. + +## Best Practices + +1. Use time-skipping for workflows with timers +2. Mock external dependencies in activities +3. Test replay compatibility when changing workflow code +4. Use unique workflow IDs per test +5. Clean up test environment after tests diff --git a/references/typescript/typescript.md b/references/typescript/typescript.md new file mode 100644 index 0000000..9918ee7 --- /dev/null +++ b/references/typescript/typescript.md @@ -0,0 +1,172 @@ +# Temporal TypeScript SDK Reference + +## Overview + +The Temporal TypeScript SDK provides a modern Promise based approach to building durable workflows. Workflows are bundled and run in an isolated runtime with automatic replacements for determinism protection. + +**CRITICAL**: All `@temporalio/*` packages must have the same version number. + +## Understanding Replay + +Temporal workflows are durable through history replay. For details on how this works, see `references/core/determinism.md`. + +## Quick Start + +**Add Dependencies:** Install the Temporal SDK packages (use the package manager appropriate for your project): +```bash +npm install @temporalio/client @temporalio/worker @temporalio/workflow @temporalio/activity +``` + +Note: if you are working in production, it is strongly advised to use ~ version constraints, i.e. `npm install ... --save-prefix='~'` if using NPM. + +**activities.ts** - Activity definitions (separate file to distinguish workflow vs activity code): +```typescript +export async function greet(name: string): Promise { + return `Hello, ${name}!`; +} +``` + +**workflows.ts** - Workflow definition (use type-only imports for activities): +```typescript +import { proxyActivities } from '@temporalio/workflow'; +import type * as activities from './activities'; + +const { greet } = proxyActivities({ + startToCloseTimeout: '1 minute', +}); + +export async function greetingWorkflow(name: string): Promise { + return await greet(name); +} +``` + +**worker.ts** - Worker setup (imports activities and workflows, runs indefinitely): +```typescript +import { Worker } from '@temporalio/worker'; +import * as activities from './activities'; + +async function run() { + const worker = await Worker.create({ + workflowsPath: require.resolve('./workflows'), // For production, use workflowBundle instead + activities, + taskQueue: 'greeting-queue', + }); + await worker.run(); +} + +run().catch(console.error); +``` + +**Start the dev server:** Start `temporal server start-dev` in the background. + +**Start the worker:** Run `npx ts-node worker.ts` in the background. + +**client.ts** - Start a workflow execution: +```typescript +import { Client } from '@temporalio/client'; +import { greetingWorkflow } from './workflows'; +import { v4 as uuid } from 'uuid'; + +async function run() { + const client = new Client(); + + const result = await client.workflow.execute(greetingWorkflow, { + workflowId: uuid(), + taskQueue: 'greeting-queue', + args: ['my name'], + }); + + console.log(`Result: ${result}`); +} + +run().catch(console.error); +``` + +**Run the workflow:** Run `npx ts-node client.ts`. Should output: `Result: Hello, my name!`. + +## Key Concepts + +### Workflow Definition +- Async functions exported from workflow file +- Use `proxyActivities()` with type-only imports +- Use `defineSignal()`, `defineQuery()`, `defineUpdate()`, `setHandler()` for handlers + +### Activity Definition +- Regular async functions +- Can perform I/O, network calls, etc. +- Use `heartbeat()` for long operations + +### Worker Setup +- Use `Worker.create()` with `workflowsPath` (dev) or `workflowBundle` (production) - see `references/typescript/gotchas.md` +- Import activities directly (not via proxy) + +## File Organization Best Practice + +**Keep Workflow definitions in separate files from Activity definitions.** The TypeScript SDK bundles workflow files separately. Minimizing workflow file contents improves Worker startup time. + +``` +my_temporal_app/ +├── workflows/ +│ └── greeting.ts # Only Workflow functions +├── activities/ +│ └── translate.ts # Only Activity functions +├── worker.ts # Worker setup, imports both +└── client.ts # Client code to start workflows +``` + +**In the Workflow file, use type-only imports for activities:** +```typescript +// workflows/greeting.ts +import { proxyActivities } from '@temporalio/workflow'; +import type * as activities from '../activities/translate'; + +const { translate } = proxyActivities({ + startToCloseTimeout: '1 minute', +}); +``` + +## Determinism Rules + +The TypeScript SDK runs workflows in an isolated V8 sandbox. + +**Automatic replacements:** +- `Math.random()` → deterministic seeded PRNG +- `Date.now()` → workflow start time +- `setTimeout` → deterministic timer + +**Safe to use:** +- `sleep()` from `@temporalio/workflow` +- `condition()` for waiting +- Standard JavaScript operations + +See `references/typescript/determinism.md` for detailed rules. + +## Common Pitfalls + +1. **Importing activities without `type`** - Use `import type * as activities` +2. **Version mismatch** - All @temporalio packages must match +3. **Direct I/O in workflows** - Use activities for external calls +4. **Missing `proxyActivities`** - Required to call activities from workflows +5. **Forgetting to bundle workflows** - Worker needs `workflowsPath` or `workflowBundle` +6. **Using workflowsPath in production** - Use `workflowBundle` for production (see `references/typescript/gotchas.md`) +7. **Forgetting to heartbeat** - Long-running activities need `heartbeat()` calls +8. **Logging in workflows** - For observability, use `import { log } from '@temporalio/workflow'` (routes through sinks). For temporary print debugging, `console.log()` is fine—it's direct and immediate, whereas `log` may lose messages on workflow errors. +9. **Forgetting to wait on activity calls** - Activity calls return Promises; you must eventually await them (directly or via `Promise.all()` for parallel execution) + +## Writing Tests + +See `references/typescript/testing.md` for info on writing tests. + +## Additional Resources + +### Reference Files +- **`references/typescript/patterns.md`** - Signals, queries, child workflows, saga pattern, etc. +- **`references/typescript/determinism.md`** - Essentials of determinism in TypeScript +- **`references/typescript/gotchas.md`** - TypeScript-specific mistakes and anti-patterns +- **`references/typescript/error-handling.md`** - ApplicationFailure, retry policies, non-retryable errors +- **`references/typescript/observability.md`** - Logging, metrics, tracing +- **`references/typescript/testing.md`** - TestWorkflowEnvironment, time-skipping, activity mocking +- **`references/typescript/advanced-features.md`** - Schedules, worker tuning, and more +- **`references/typescript/data-handling.md`** - Data converters, payload encryption, etc. +- **`references/typescript/versioning.md`** - Patching API, workflow type versioning, Worker Versioning +- **`references/typescript/determinism-protection.md`** - V8 sandbox and bundling diff --git a/references/typescript/versioning.md b/references/typescript/versioning.md new file mode 100644 index 0000000..a9f57a2 --- /dev/null +++ b/references/typescript/versioning.md @@ -0,0 +1,211 @@ +# TypeScript SDK Versioning + +For conceptual overview and guidance on choosing an approach, see `references/core/versioning.md`. + +## Patching API + +The Patching API lets you change Workflow Definitions without causing non-deterministic behavior in running Workflows. + +### The patched() Function + +The `patched()` function takes a `patchId` string and returns a boolean: + +```typescript +import { patched } from '@temporalio/workflow'; + +export async function myWorkflow(): Promise { + if (patched('my-change-id')) { + // New code path + await newImplementation(); + } else { + // Old code path (for replay of existing executions) + await oldImplementation(); + } +} +``` + +**How it works:** +- If the Workflow is running for the first time, `patched()` returns `true` and inserts a marker into the Event History +- During replay, if the history contains a marker with the same `patchId`, `patched()` returns `true` +- During replay, if no matching marker exists, `patched()` returns `false` + +**TypeScript-specific behavior:** Unlike Python/.NET/Ruby, `patched()` is not memoized when it returns `false`. This means you can use `patched()` in loops. However, if a single patch requires coordinated behavioral changes at different points in your workflow, you may need to manually memoize the result: + +```typescript +const useNewBehavior = patched('my-change'); +// Use useNewBehavior at multiple points in workflow +``` + +### Three-Step Patching Process + +Patching is a three-step process for safely deploying changes. + +**Warning:** Failing to follow this process correctly will result in non-determinism errors for in-flight workflows. + +#### Step 1: Patch in New Code + +Add the patch alongside the old code: + +```typescript +import { patched } from '@temporalio/workflow'; + +// Original code sent fax notifications +export async function shippingConfirmation(): Promise { + if (patched('changedNotificationType')) { + await sendEmail(); // New code + } else { + await sendFax(); // Old code for replay + } + await sleep('1 day'); +} +``` + +#### Step 2: Deprecate the Patch + +Once all Workflows using the old code have completed, deprecate the patch: + +```typescript +import { deprecatePatch } from '@temporalio/workflow'; + +export async function shippingConfirmation(): Promise { + deprecatePatch('changedNotificationType'); + await sendEmail(); + await sleep('1 day'); +} +``` + +The `deprecatePatch()` function records a marker that does not fail replay when Workflow code does not emit it, allowing a transition period. + +#### Step 3: Remove the Patch + +After all Workflows using `deprecatePatch` have completed, remove it entirely: + +```typescript +export async function shippingConfirmation(): Promise { + await sendEmail(); + await sleep('1 day'); +} +``` + +### Query Filters for Versioned Workflows + +Use List Filters to find Workflows by version: + +``` +# Find running Workflows with a specific patch +WorkflowType = "shippingConfirmation" AND ExecutionStatus = "Running" AND TemporalChangeVersion = "changedNotificationType" + +# Find running Workflows without the patch (started before patching) +WorkflowType = "shippingConfirmation" AND ExecutionStatus = "Running" AND TemporalChangeVersion IS NULL +``` + +## Workflow Type Versioning + +An alternative to patching is creating new Workflow functions for incompatible changes: + +```typescript +// Original Workflow +export async function pizzaWorkflow(order: PizzaOrder): Promise { + // Original implementation +} + +// New version with incompatible changes +export async function pizzaWorkflowV2(order: PizzaOrder): Promise { + // Updated implementation +} +``` + +Register both Workflows with the Worker: + +```typescript +const worker = await Worker.create({ + workflowsPath: require.resolve('./workflows'), // Use workflowBundle for production + taskQueue: 'pizza-queue', +}); +``` + +Update client code to start new Workflows with the new type: + +```typescript +// Start new executions with V2 +await client.workflow.start(pizzaWorkflowV2, { + workflowId: 'order-123', + taskQueue: 'pizza-queue', + args: [order], +}); +``` + +Use List Filters to check for remaining V1 executions: + +``` +WorkflowType = "pizzaWorkflow" AND ExecutionStatus = "Running" +``` + +After all V1 executions complete, remove the old Workflow function. + +## Worker Versioning + +Worker Versioning allows multiple Worker versions to run simultaneously, routing Workflows to specific versions without code-level patching. Workflows are pinned to the Worker Deployment Version they started on. + +> **Note:** Worker Versioning is currently in Public Preview. The legacy Worker Versioning API (before 2025) will be removed from Temporal Server in March 2026. + +### Key Concepts + +- **Worker Deployment**: A logical name for your application (e.g., "order-service") +- **Worker Deployment Version**: A specific build identified by deployment name + Build ID +- **Workflow Pinning**: Workflows complete on the Worker Deployment Version they started on + +### Configuring Workers for Versioning + +```typescript +import { Worker, NativeConnection } from '@temporalio/worker'; + +const worker = await Worker.create({ + workflowsPath: require.resolve('./workflows'), // Use workflowBundle for production + taskQueue: 'my-queue', + connection: await NativeConnection.connect({ address: 'temporal:7233' }), + workerDeploymentOptions: { + useWorkerVersioning: true, + version: { + deploymentName: 'order-service', + buildId: '1.0.0', // Git hash, semver, build number, etc. + }, + }, +}); +``` + +**Configuration options:** +- `useWorkerVersioning`: Enables Worker Versioning +- `version.deploymentName`: Logical name for your service (consistent across versions) +- `version.buildId`: Unique identifier for this build + +### Deployment Workflow + +1. Deploy new Worker version with a new `buildId` +2. Use the Temporal CLI to set the new version as current: + ```bash + temporal worker deployment set-current-version \ + --deployment-name order-service \ + --build-id 2.0.0 + ``` +3. New Workflows start on the new version +4. Existing Workflows continue on their original version until completion +5. Decommission old Workers once all their Workflows complete + +### When to Use Worker Versioning + +Worker Versioning is best suited for: +- **Short-running Workflows**: Old Workers only need to run briefly during deployment transitions +- **Frequent deployments**: Eliminates the need for code-level patching on every change +- **Blue-green deployments**: Run old and new versions simultaneously with traffic control + +For long-running Workflows, consider combining Worker Versioning with the Patching API, or use Continue-as-New to move Workflows to newer versions. + +## Best Practices + +1. Use descriptive `patchId` names that explain the change +2. Follow the three-step patching process completely before removing patches +3. Use List Filters to verify no running Workflows before removing version support +4. Keep Worker Deployment names consistent across all versions +5. Use unique, traceable Build IDs (git hashes, semver, timestamps) +6. Test version transitions with replay tests before deploying From 6137e38346042541bbf28a0171918c08bccd3f76 Mon Sep 17 00:00:00 2001 From: Donald Pinckney Date: Thu, 19 Mar 2026 18:15:14 -0400 Subject: [PATCH 10/82] hotfix: clean up skills.sh instruction (#51) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 6ba88db..5f395b5 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ This skill is housed within a [Claude Code plugin](https://github.com/temporalio ### Via `npx skills` - supports all major coding agents -1. `npx skills add https://github.com/temporalio/skill-temporal-developer` +1. `npx skills add temporalio/skill-temporal-developer` 2. Follow prompts ### Via manually cloning the skill repo: From e4afb09941b5032524f7cc81115cadf3bb08980a Mon Sep 17 00:00:00 2001 From: Mason Egger Date: Wed, 25 Mar 2026 14:09:37 -0500 Subject: [PATCH 11/82] Add packaging workflow to release a public version and support Claude.ai uploads The skill works great as a Claude Code plugin where SKILL.md and references are auto-discovered, butusers may want to upload it to Claude.ai projects instead. You currently do this by uploaded a .zip file to your Claude.ai UI. This adds a GitHub Actions workflow that packages the skill and references into a ZIP on every push to main, and creates a GitHub Release when the version in SKILL.md increases. Users can grab the ZIP from the release and upload it directly to a Claude.ai project without needing to clone the repo. --- .github/workflows/package-skill.yml | 59 +++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 .github/workflows/package-skill.yml diff --git a/.github/workflows/package-skill.yml b/.github/workflows/package-skill.yml new file mode 100644 index 0000000..69c48f5 --- /dev/null +++ b/.github/workflows/package-skill.yml @@ -0,0 +1,59 @@ +# ABOUTME: GitHub Actions workflow that packages the skill for upload to Claude.ai. +# ABOUTME: Creates a ZIP artifact on every push to main and a GitHub Release when the version in SKILL.md increases. + +name: Package Skill + +on: + push: + branches: [main] + workflow_dispatch: + +jobs: + package: + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Read version from SKILL.md + id: version + run: | + version=$(grep '^version:' SKILL.md | sed 's/version:[[:space:]]*//') + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "tag=v$version" >> "$GITHUB_OUTPUT" + + - name: Check if tag exists + id: tag_check + run: | + if git rev-parse "refs/tags/${{ steps.version.outputs.tag }}" >/dev/null 2>&1; then + echo "exists=true" >> "$GITHUB_OUTPUT" + else + echo "exists=false" >> "$GITHUB_OUTPUT" + fi + + - name: Package skill + run: | + zip -r temporal-developer-skill.zip \ + SKILL.md \ + references/ \ + -x '*.DS_Store' + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: temporal-developer-skill + path: temporal-developer-skill.zip + + - name: Create release + if: steps.tag_check.outputs.exists == 'false' + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ steps.version.outputs.tag }} + name: ${{ steps.version.outputs.tag }} + files: temporal-developer-skill.zip + generate_release_notes: true From 68ebe14ff540a5b508aed897d0fdc2dc401cdb1f Mon Sep 17 00:00:00 2001 From: Jackson Lo <4205685+jacksonlo@users.noreply.github.com> Date: Tue, 31 Mar 2026 14:20:51 -0400 Subject: [PATCH 12/82] Fix typos and broken references across skill docs (#56) - Fix missing trailing pipe in error-reference.md table header - Fix wrong reference path in go.md (python -> go determinism-protection) - Add missing .md extension to testing reference in go/determinism-protection.md - Fix typos: "Activty" -> "Activity", "accomplised" -> "accomplished", "discourged" -> "discouraged" --- references/core/error-reference.md | 2 +- references/core/patterns.md | 4 ++-- references/core/troubleshooting.md | 2 +- references/go/determinism-protection.md | 2 +- references/go/go.md | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/references/core/error-reference.md b/references/core/error-reference.md index a0f905b..74570ae 100644 --- a/references/core/error-reference.md +++ b/references/core/error-reference.md @@ -1,6 +1,6 @@ # Common Error Types Reference -| Error Type | Error identifier (if any) | Where to Find | What Happened | Recovery | Link to additional info (if any) +| Error Type | Error identifier (if any) | Where to Find | What Happened | Recovery | Link to additional info (if any) | |------------|---------------|---------------|---------------|----------|----------| | **Non-determinism** | TMPRL1100 | `WorkflowTaskFailed` in history | Replay doesn't match history | Analyze error first. **If accidental**: fix code to match history → restart worker. **If intentional v2 change**: terminate → start fresh workflow. | https://github.com/temporalio/rules/blob/main/rules/TMPRL1100.md | | **Deadlock** | TMPRL1101 | `WorkflowTaskFailed` in history, worker logs | Workflow blocked too long (deadlock detected) | Remove blocking operations from workflow code (no I/O, no sleep, no threading locks). Use Temporal primitives instead. | https://github.com/temporalio/rules/blob/main/rules/TMPRL1101.md | diff --git a/references/core/patterns.md b/references/core/patterns.md index 566e6f8..2ab5b72 100644 --- a/references/core/patterns.md +++ b/references/core/patterns.md @@ -253,9 +253,9 @@ To ensure that polling_activity is restarted in a timely manner, we make sure th **Implementation**: -Define an Activty which fails (raises an exception) exactly when polling is not completed. +Define an Activity which fails (raises an exception) exactly when polling is not completed. -The polling loop is accomplised via activity retries, by setting the following Retry options: +The polling loop is accomplished via activity retries, by setting the following Retry options: - backoff_coefficient: to 1 - initial_interval: to the polling interval (e.g. 60 seconds) diff --git a/references/core/troubleshooting.md b/references/core/troubleshooting.md index e4ef2cb..952d4e2 100644 --- a/references/core/troubleshooting.md +++ b/references/core/troubleshooting.md @@ -192,7 +192,7 @@ Timeout error? ├─▶ Which timeout? │ │ │ ├─▶ Workflow timeout -│ │ └─▶ Increase timeout or optimize workflow. Better yet, consider removing the workflow timeout, as it is generally discourged unless *necessary* for your use case. +│ │ └─▶ Increase timeout or optimize workflow. Better yet, consider removing the workflow timeout, as it is generally discouraged unless *necessary* for your use case. │ │ │ ├─▶ ScheduleToCloseTimeout │ │ └─▶ Activity taking too long overall (including retries) diff --git a/references/go/determinism-protection.md b/references/go/determinism-protection.md index 4a6f5f4..cc8d8f5 100644 --- a/references/go/determinism-protection.md +++ b/references/go/determinism-protection.md @@ -2,7 +2,7 @@ ## Overview -The Go SDK has no runtime sandbox. Determinism is enforced by **developer convention** and **optional static analysis**. Unlike the Python and TypeScript SDKs, the Go SDK will not intercept or replace non-deterministic calls at runtime. The Go SDK does perform a limited runtime command-ordering check, but catching non-deterministic code before deployment requires the `workflowcheck` tool and testing, in particular replay tests (see `references/go/testing`). +The Go SDK has no runtime sandbox. Determinism is enforced by **developer convention** and **optional static analysis**. Unlike the Python and TypeScript SDKs, the Go SDK will not intercept or replace non-deterministic calls at runtime. The Go SDK does perform a limited runtime command-ordering check, but catching non-deterministic code before deployment requires the `workflowcheck` tool and testing, in particular replay tests (see `references/go/testing.md`). ## workflowcheck Static Analysis diff --git a/references/go/go.md b/references/go/go.md index cc87a6a..974ee7c 100644 --- a/references/go/go.md +++ b/references/go/go.md @@ -239,4 +239,4 @@ See `references/go/testing.md` for info on writing tests. - **`references/go/advanced-features.md`** - Schedules, worker tuning, and more - **`references/go/data-handling.md`** - Data converters, payload codecs, encryption - **`references/go/versioning.md`** - Patching API (`workflow.GetVersion`), Worker Versioning -- **`references/python/determinism-protection.md`** - Information on **`workflowcheck`** tool to help statically check for determinism issues. +- **`references/go/determinism-protection.md`** - Information on **`workflowcheck`** tool to help statically check for determinism issues. From b040ae10b4a9ca03fbb432af4bd22561fd43ce64 Mon Sep 17 00:00:00 2001 From: "Trevor J. Yao" <55645157+trevoryao@users.noreply.github.com> Date: Tue, 31 Mar 2026 14:58:57 -0400 Subject: [PATCH 13/82] Fix Python reference bugs: incorrect API name, syntax error, broken cross-ref, misleading comment (#55) --- references/python/ai-patterns.md | 2 +- references/python/determinism.md | 2 +- references/python/testing.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/references/python/ai-patterns.md b/references/python/ai-patterns.md index a07e30a..6a45272 100644 --- a/references/python/ai-patterns.md +++ b/references/python/ai-patterns.md @@ -2,7 +2,7 @@ ## Overview -This document provides Python-specific implementation details for integrating LLMs with Temporal. For conceptual patterns, see `references/core/ai-integration.md`. +This document provides Python-specific implementation details for integrating LLMs with Temporal. For conceptual patterns, see `references/core/ai-patterns.md`. ## Pydantic Data Converter Setup diff --git a/references/python/determinism.md b/references/python/determinism.md index 7276360..e925f7c 100644 --- a/references/python/determinism.md +++ b/references/python/determinism.md @@ -23,7 +23,7 @@ Temporal provides durable execution through **History Replay**. When a Worker ne |-----------|------------------| | `datetime.now()` | `workflow.now()` | | `datetime.utcnow()` | `workflow.now()` | -| `random.random()` | `rng = workflow.new_random() ; rng.randint(1, 100)` | +| `random.random()` | `rng = workflow.random() ; rng.randint(1, 100)` | | `uuid.uuid4()` | `workflow.uuid4()` | | `time.time()` | `workflow.now().timestamp()` | diff --git a/references/python/testing.md b/references/python/testing.md index 63a0d14..e4a7823 100644 --- a/references/python/testing.md +++ b/references/python/testing.md @@ -136,7 +136,7 @@ async def test_replay(): # From JSON file await replayer.replay_workflow( - WorkflowHistory.from_json(workflow_id=str(uuid.uuid4()), history_json) + WorkflowHistory.from_json(str(uuid.uuid4()), history_json) ) ``` From 57c08ef74b44378231b624594da1d276ddcf9e15 Mon Sep 17 00:00:00 2001 From: Brian Strauch Date: Tue, 31 Mar 2026 14:16:11 -0700 Subject: [PATCH 14/82] Add `@workflow.init` decorator to python.md Key Concepts (#57) * docs: add @workflow.init decorator to python.md Key Concepts Co-Authored-By: Claude Sonnet 4.6 * Apply suggestion from @brianstrauch * Update references/python/python.md --------- Co-authored-by: Claude Sonnet 4.6 --- references/python/python.md | 1 + 1 file changed, 1 insertion(+) diff --git a/references/python/python.md b/references/python/python.md index 130b1eb..2c56843 100644 --- a/references/python/python.md +++ b/references/python/python.md @@ -98,6 +98,7 @@ if __name__ == "__main__": ### Workflow Definition - Use `@workflow.defn` decorator on class +- Put any state initialization logic in the `__init__` 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 `@workflow.init` decorator and parameters to your `__init__`. - Use `@workflow.run` on the entry point method - Must be async (`async def`) - Use `@workflow.signal`, `@workflow.query`, `@workflow.update` for handlers From 8369c659d060602d553c94ff9a2bb6360c69fa33 Mon Sep 17 00:00:00 2001 From: Donald Pinckney Date: Thu, 2 Apr 2026 10:22:27 -0400 Subject: [PATCH 15/82] Remove ASCII diagram, replace with prose. (#66) --- SKILL.md | 35 +++++++++++------------------------ 1 file changed, 11 insertions(+), 24 deletions(-) diff --git a/SKILL.md b/SKILL.md index 1874d20..325709f 100644 --- a/SKILL.md +++ b/SKILL.md @@ -12,31 +12,18 @@ Temporal is a durable execution platform that makes workflows survive failures a ## Core Architecture -``` -┌─────────────────────────────────────────────────────────────────┐ -│ Temporal Cluster │ -│ ┌─────────────────┐ ┌─────────────────┐ ┌────────────────┐ │ -│ │ Event History │ │ Task Queues │ │ Visibility │ │ -│ │ (Durable Log) │ │ (Work Router) │ │ (Search) │ │ -│ └─────────────────┘ └─────────────────┘ └────────────────┘ │ -└─────────────────────────────────────────────────────────────────┘ - ▲ - │ Poll / Complete - ▼ -┌─────────────────────────────────────────────────────────────────┐ -│ Worker │ -│ ┌─────────────────────────┐ ┌──────────────────────────────┐ │ -│ │ Workflow Definitions │ │ Activity Implementations │ │ -│ │ (Deterministic) │ │ (Non-deterministic OK) │ │ -│ └─────────────────────────┘ └──────────────────────────────┘ │ -└─────────────────────────────────────────────────────────────────┘ -``` +The **Temporal Cluster** is the central orchestration backend. It maintains three key subsystems: the **Event History** (a durable log of all workflow state), **Task Queues** (which route work to the right workers), and a **Visibility** store (for searching and listing workflows). There are three ways to run a Cluster: + +- **Temporal CLI dev server** — a local, single-process server started with `temporal server start-dev`. Suitable for development and testing only, not production. +- **Self-hosted** — you deploy and manage the Temporal server and its dependencies (e.g., database) in your own infrastructure for production use. +- **Temporal Cloud** — a fully managed production service operated by Temporal. No cluster infrastructure to manage. + +**Workers** are long-running processes that you run and manage. They poll Task Queues for work and execute your code. You might run a single Worker process on one machine during development, or run many Worker processes across a large fleet of machines in production. Each Worker hosts two types of code: + +- **Workflow Definitions** — durable, deterministic functions that orchestrate work. These must not have side effects. +- **Activity Implementations** — non-deterministic operations (API calls, file I/O, etc.) that can fail and be retried. -**Components:** -- **Workflows** - Durable, deterministic functions that orchestrate activities -- **Activities** - Non-deterministic operations (API calls, I/O) that can fail and retry -- **Workers** - Long-running processes that poll task queues and execute code -- **Task Queues** - Named queues connecting clients to workers +Workers communicate with the Cluster via a poll/complete loop: they poll a Task Queue for tasks, execute the corresponding Workflow or Activity code, and report results back. ## History Replay: Why Determinism Matters From 9b97ceebd07ba362a063baaec36939545190af5d Mon Sep 17 00:00:00 2001 From: Donald Pinckney Date: Thu, 2 Apr 2026 14:12:24 -0400 Subject: [PATCH 16/82] [fix] Add missing section to TS's observability (#65) --- references/typescript/observability.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/references/typescript/observability.md b/references/typescript/observability.md index 10244d7..211fbc6 100644 --- a/references/typescript/observability.md +++ b/references/typescript/observability.md @@ -100,6 +100,10 @@ Runtime.install({ }); ``` +## Search Attributes (Visibility) + +See the Search Attributes section of `references/typescript/data-handling.md` + ## Best Practices 1. Use `log` from `@temporalio/workflow` for production observability. For temporary print debugging, `console.log()` is fine—it's direct and immediate, whereas `log` goes through sinks which may lose messages on workflow errors From 0c8586b4c232696757ef02b5aed0a9fdc4ff6f32 Mon Sep 17 00:00:00 2001 From: Donald Pinckney Date: Thu, 2 Apr 2026 17:07:33 -0400 Subject: [PATCH 17/82] Add Java SDK support (#42) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add Java SDK reference files (11 files) Create complete Java reference documentation covering: - java.md: Entry point with quick start tutorial, key concepts - patterns.md: 17 patterns (signals, queries, updates, child workflows, saga, cancellation scopes, heartbeating, etc.) - determinism.md: Safe alternatives table, forbidden operations - determinism-protection.md: Convention-based enforcement (no sandbox) - error-handling.md: ApplicationFailure, retry/timeout config - gotchas.md: Non-deterministic operations, cancellation, heartbeating - testing.md: TestWorkflowEnvironment, Mockito mocking, replay testing - versioning.md: Workflow.getVersion(), worker versioning - data-handling.md: Jackson, PayloadConverter, encryption, search attributes - observability.md: SLF4J logging, Micrometer metrics - advanced-features.md: Schedules, async completion, worker tuning Co-Authored-By: Claude Opus 4.6 (1M context) * Fix Java alignment issues from self-review - Reduce gotchas.md Non-Deterministic Operations from ~94 lines to ~12 (reference determinism.md instead of duplicating) - Remove Workflow Failure Exception Types duplication from error-handling.md (keep only in advanced-features.md) - Expand versioning.md Worker Versioning with Key Concepts, PINNED vs AUTO_UPGRADE, Deployment Strategies subsections - Fix section names to match Python reference style: Activity Heartbeat Details, Handling Activity Errors, Retry Policy Configuration, Workflow Test Environment, Mocking Activities, Workflow Replay Testing - Reduce data-handling.md Payload Encryption verbosity - Reduce observability.md Logger Customization verbosity - Reduce testing.md to single approach per section - Rename determinism.md "Convention-Based Enforcement" to "SDK Protection" - Fix handler guidance in patterns.md to match Python Co-Authored-By: Claude Opus 4.6 (1M context) * Fix correctness issues in Java reference files - patterns.md: Fix Queries section — ActivityStub → typed interface (Workflow.newActivityStub returns the typed interface, not ActivityStub) - data-handling.md: Add missing ProtobufPayloadConverter to default converter chain (4th of 5 converters) Co-Authored-By: Claude Opus 4.6 (1M context) * Add Java to SKILL.md and core/determinism.md - SKILL.md: Add "Temporal Java" trigger phrase, update Overview to list Java, add Java entry to Getting Started references - core/determinism.md: Add Java entry to SDK Protection Mechanisms (no sandbox, convention-based, NonDeterministicException at replay) Co-Authored-By: Claude Opus 4.6 (1M context) * Apply manual editorial fixes to Java references - java.md: Remove "Understanding Replay" section (covered by Overview), simplify File Organization note (no sandbox rationale) - gotchas.md: Move Heartbeating before Cancellation, make Wrong Retry Classification brief with reference (not inline examples) - error-handling.md: Remove editorializing from Workflow Failure note - determinism-protection.md: Remove cross-language comparison paragraph (state Java's approach on its own terms) Co-Authored-By: Claude Opus 4.6 (1M context) * Add temporal-workflowcheck static analysis to Java determinism docs - determinism-protection.md: Add "Static Analysis with temporal-workflowcheck" section with Gradle/Maven setup, manual run, and suppression instructions. Beta warning included. - determinism.md: Update overview and SDK Protection to reference workflowcheck - core/determinism.md: Update Java entry in SDK Protection Mechanisms Co-Authored-By: Claude Opus 4.6 (1M context) * Integrate feedback from Go PR into Java patterns - Updates: Add validator note — validators must not mutate state or block (matches note added to Python, TypeScript, Go, and core) - Saga Pattern: Use Workflow.newDetachedCancellationScope() for compensations so they execute even if the workflow is cancelled (mirrors Go's workflow.NewDisconnectedContext pattern) Co-Authored-By: Claude Sonnet 4.6 (1M context) * docs: add @WorkflowInit description to java.md Key Concepts Co-Authored-By: Claude Sonnet 4.6 * mark java as supported * Apply suggestions from code review Co-authored-by: Brian Strauch * strongly recommend java 21+ * Softened stance on static checker and replay testing. * address python/typescript sandboxing comment --------- Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: Brian Strauch Co-authored-by: Brian Strauch --- README.md | 2 +- SKILL.md | 5 +- references/core/determinism.md | 3 +- references/java/advanced-features.md | 167 +++++++ references/java/data-handling.md | 288 ++++++++++++ references/java/determinism-protection.md | 83 ++++ references/java/determinism.md | 55 +++ references/java/error-handling.md | 188 ++++++++ references/java/gotchas.md | 177 ++++++++ references/java/java.md | 249 +++++++++++ references/java/observability.md | 134 ++++++ references/java/patterns.md | 509 ++++++++++++++++++++++ references/java/testing.md | 184 ++++++++ references/java/versioning.md | 281 ++++++++++++ 14 files changed, 2321 insertions(+), 4 deletions(-) create mode 100644 references/java/advanced-features.md create mode 100644 references/java/data-handling.md create mode 100644 references/java/determinism-protection.md create mode 100644 references/java/determinism.md create mode 100644 references/java/error-handling.md create mode 100644 references/java/gotchas.md create mode 100644 references/java/java.md create mode 100644 references/java/observability.md create mode 100644 references/java/patterns.md create mode 100644 references/java/testing.md create mode 100644 references/java/versioning.md diff --git a/README.md b/README.md index 5f395b5..124b367 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ Appropriately adjust the installation directory based on your coding agent. - [x] Python ✅ - [x] TypeScript ✅ - [x] Go ✅ -- [ ] Java 🚧 ([PR](https://github.com/temporalio/skill-temporal-developer/pull/42)) +- [x] Java ✅ - [ ] .NET 🚧 ([PR](https://github.com/temporalio/skill-temporal-developer/pull/39)) - [ ] Ruby 🚧 ([PR](https://github.com/temporalio/skill-temporal-developer/pull/41)) - [ ] PHP 🚧 ([PR](https://github.com/temporalio/skill-temporal-developer/pull/40)) diff --git a/SKILL.md b/SKILL.md index 325709f..38c2185 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 Java", "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 Java. ## Core Architecture @@ -79,6 +79,7 @@ Once you've downloaded the file, extract the downloaded archive and add the temp 1. First, read the getting started guide for the language you are working in: - Python -> read `references/python/python.md` - TypeScript -> read `references/typescript/typescript.md` + - Java -> read `references/java/java.md` - Go -> read `references/go/go.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..16f04db 100644 --- a/references/core/determinism.md +++ b/references/core/determinism.md @@ -76,10 +76,11 @@ In Temporal, activities are the primary mechanism for making non-deterministic c For a few simple cases, like timestamps, random values, UUIDs, etc. the Temporal SDK in your language may provide durable variants that are simple to use. See `references/{your_language}/determinism.md` for the language you are working in for more info. ## SDK Protection Mechanisms -Each Temporal SDK language provides a protection mechanism to make it easier to catch non-determinism errors earlier in development: +Each Temporal SDK language provides a different level of protection against non-determinism: - 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. +- Java: The Java SDK has no sandbox. Determinism is enforced by developer conventions — the SDK provides `Workflow.*` APIs as safe alternatives (e.g., `Workflow.sleep()` instead of `Thread.sleep()`), and non-determinism is only detected at replay time via `NonDeterministicException`. A static analysis tool (`temporal-workflowcheck`, beta) can catch violations at build time. Cooperative threading under a global lock eliminates the need for synchronization. - 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. 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. diff --git a/references/java/advanced-features.md b/references/java/advanced-features.md new file mode 100644 index 0000000..e897bb1 --- /dev/null +++ b/references/java/advanced-features.md @@ -0,0 +1,167 @@ +# Java SDK Advanced Features + +## Schedules + +Create recurring workflow executions. + +```java +import io.temporal.client.schedules.*; + +ScheduleClient scheduleClient = ScheduleClient.newInstance(service); + +// Create a schedule +String scheduleId = "daily-report"; +ScheduleHandle handle = scheduleClient.createSchedule( + scheduleId, + Schedule.newBuilder() + .setAction( + ScheduleActionStartWorkflow.newBuilder() + .setWorkflowType(DailyReportWorkflow.class) + .setOptions( + WorkflowOptions.newBuilder() + .setWorkflowId("daily-report") + .setTaskQueue("reports") + .build() + ) + .build() + ) + .setSpec( + ScheduleSpec.newBuilder() + .setIntervals( + List.of(new ScheduleIntervalSpec(Duration.ofDays(1))) + ) + .build() + ) + .build(), + ScheduleOptions.newBuilder().build() +); + +// Manage schedules +ScheduleHandle scheduleHandle = scheduleClient.getHandle(scheduleId); +scheduleHandle.pause("Maintenance window"); +scheduleHandle.unpause(); +scheduleHandle.trigger(); // Run immediately +scheduleHandle.delete(); +``` + +## Async Activity Completion + +For activities that complete asynchronously (e.g., human tasks, external callbacks). +If you configure a heartbeat timeout on this activity, the external completer is responsible for sending heartbeats via the async handle. + +**Note:** If the external system can reliably Signal back with the result and doesn't need to Heartbeat or receive Cancellation, consider using **signals** instead. + +```java +public class ApprovalActivitiesImpl implements ApprovalActivities { + @Override + public String requestApproval(String requestId) { + ActivityExecutionContext ctx = Activity.getExecutionContext(); + + // Get task token for async completion + byte[] taskToken = ctx.getTaskToken(); + + // Store task token for later completion (e.g., in database) + storeTaskToken(requestId, taskToken); + + // Mark this activity as waiting for external completion + ctx.doNotCompleteOnReturn(); + + return null; // Return value is ignored + } +} + +// Later, complete the activity from another process +public void completeApproval(String requestId, boolean approved) { + WorkflowServiceStubs service = WorkflowServiceStubs.newLocalServiceStubs(); + WorkflowClient client = WorkflowClient.newInstance(service); + + ActivityCompletionClient completionClient = client.newActivityCompletionClient(); + + byte[] taskToken = getTaskToken(requestId); + + if (approved) { + completionClient.complete(taskToken, "approved"); + } else { + completionClient.completeExceptionally( + taskToken, + new RuntimeException("Rejected") + ); + } +} +``` + +## Worker Tuning + +Configure worker performance settings. + +```java +WorkerOptions workerOptions = WorkerOptions.newBuilder() + // Max concurrent workflow task executions (default: 200) + .setMaxConcurrentWorkflowTaskExecutionSize(200) + // Max concurrent activity executions (default: 200) + .setMaxConcurrentActivityExecutionSize(200) + // Max concurrent local activity executions (default: 200) + .setMaxConcurrentLocalActivityExecutionSize(200) + // Max workflow task pollers (default: 5) + .setMaxConcurrentWorkflowTaskPollers(5) + // Max activity task pollers (default: 5) + .setMaxConcurrentActivityTaskPollers(5) + .build(); + +WorkerFactory factory = WorkerFactory.newInstance(client); +Worker worker = factory.newWorker("my-queue", workerOptions); +worker.registerWorkflowImplementationTypes(MyWorkflowImpl.class); +worker.registerActivitiesImplementations(new MyActivitiesImpl()); +factory.start(); +``` + +## Workflow Failure Exception Types + +Control which exceptions cause workflow failures vs workflow task failures. + +By default, only `ApplicationFailure` (and its subclasses) fail the workflow execution. All other exceptions fail the **workflow task**, causing the task to retry indefinitely until the code is fixed or the workflow is terminated. + +### Per-Workflow Configuration + +Use `WorkflowImplementationOptions` to specify which exception types should fail the workflow: + +```java +Worker worker = factory.newWorker("my-queue"); +worker.registerWorkflowImplementationTypes( + WorkflowImplementationOptions.newBuilder() + .setFailWorkflowExceptionTypes( + IllegalArgumentException.class, + CustomBusinessException.class + ) + .build(), + MyWorkflowImpl.class +); +``` + +With this configuration, `IllegalArgumentException` and `CustomBusinessException` thrown from the workflow will fail the workflow execution instead of just the workflow task. + +### Worker-Level Configuration + +Apply to all workflows registered on the worker: + +```java +WorkerFactoryOptions factoryOptions = WorkerFactoryOptions.newBuilder() + .setWorkflowHostLocalTaskQueueScheduleToStartTimeout(Duration.ofSeconds(10)) + .build(); +WorkerFactory factory = WorkerFactory.newInstance(client, factoryOptions); + +Worker worker = factory.newWorker("my-queue"); +// Register each workflow type with its own failure exception types +worker.registerWorkflowImplementationTypes( + WorkflowImplementationOptions.newBuilder() + .setFailWorkflowExceptionTypes( + IllegalArgumentException.class, + CustomBusinessException.class + ) + .build(), + MyWorkflowImpl.class, + AnotherWorkflowImpl.class +); +``` + +- **Tip for testing:** Set `setFailWorkflowExceptionTypes(Throwable.class)` so any unhandled exception fails the workflow immediately rather than retrying the workflow task forever. This surfaces bugs faster. diff --git a/references/java/data-handling.md b/references/java/data-handling.md new file mode 100644 index 0000000..2ef1891 --- /dev/null +++ b/references/java/data-handling.md @@ -0,0 +1,288 @@ +# Java SDK Data Handling + +## Overview + +The Java SDK uses data converters to serialize/deserialize workflow inputs, outputs, and activity parameters. The `DataConverter` interface controls how values are converted to and from Temporal `Payload` protobufs. + +## Default Data Converter + +`DefaultDataConverter` applies converters in order, using the first that accepts the value: + +1. `NullPayloadConverter` — `null` values +2. `ByteArrayPayloadConverter` — `byte[]` as raw binary +3. `ProtobufJsonPayloadConverter` — Protobuf `Message` instances as JSON +4. `ProtobufPayloadConverter` — Protobuf `Message` instances as binary +5. `JacksonJsonPayloadConverter` — Everything else via Jackson `ObjectMapper` + +## Jackson Integration + +Use `JacksonJsonPayloadConverter` with a custom `ObjectMapper` for advanced serialization (e.g., Java 8 time module, custom serializers): + +```java +ObjectMapper mapper = new ObjectMapper() + .registerModule(new JavaTimeModule()) + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + +DefaultDataConverter converter = DefaultDataConverter.newDefaultInstance() + .withPayloadConverterOverrides( + new JacksonJsonPayloadConverter(mapper) + ); + +WorkflowServiceStubs service = WorkflowServiceStubs.newLocalServiceStubs(); +WorkflowClient client = WorkflowClient.newInstance( + service, + WorkflowClientOptions.newBuilder() + .setDataConverter(converter) + .build() +); +``` + +## Custom Data Converter + +Implement `PayloadConverter` for custom serialization: + +```java +public class MyCustomPayloadConverter implements PayloadConverter { + @Override + public String getEncodingType() { + return "json/my-custom"; + } + + @Override + public Optional toData(Object value) throws DataConverterException { + // Return Optional.empty() if this converter doesn't handle the type + if (!(value instanceof MyCustomType)) { + return Optional.empty(); + } + // Serialize to Payload + byte[] data = serialize(value); + return Optional.of( + Payload.newBuilder() + .putMetadata("encoding", ByteString.copyFromUtf8(getEncodingType())) + .setData(ByteString.copyFrom(data)) + .build() + ); + } + + @Override + public T fromData(Payload content, Class valueClass, Type valueType) + throws DataConverterException { + // Deserialize from Payload + return deserialize(content.getData().toByteArray(), valueClass); + } +} +``` + +Override specific converters in the default chain: + +```java +DefaultDataConverter converter = DefaultDataConverter.newDefaultInstance() + .withPayloadConverterOverrides(new MyCustomPayloadConverter()); +``` + +## Composition of Payload Converters + +`DefaultDataConverter` holds a list of `PayloadConverter` instances tried in order. The first converter whose `toData()` returns a non-empty `Optional` wins. When using `withPayloadConverterOverrides()`, converters with matching encoding types replace existing ones. + +```java +DefaultDataConverter converter = DefaultDataConverter.newDefaultInstance() + .withPayloadConverterOverrides( + new MyCustomPayloadConverter(), // encoding: "json/my-custom" + new JacksonJsonPayloadConverter(mapper) // replaces default Jackson converter + ); +``` + +## Protobuf Support + +Protobuf messages are handled by `ProtobufJsonPayloadConverter` (enabled by default). It serializes `com.google.protobuf.Message` instances as JSON for human readability in the Temporal UI. + +```java +// Protobuf messages work out of the box as workflow/activity params +@WorkflowInterface +public interface MyWorkflow { + @WorkflowMethod + MyProtoResult run(MyProtoInput input); +} +``` + +For binary protobuf encoding instead of JSON, use `ProtobufPayloadConverter`: + +```java +DefaultDataConverter converter = DefaultDataConverter.newDefaultInstance() + .withPayloadConverterOverrides(new ProtobufPayloadConverter()); +``` + +## Payload Encryption + +Use `PayloadCodec` with `CodecDataConverter` to encrypt/compress payloads: + +```java +public class EncryptionCodec implements PayloadCodec { + private final SecretKey key; + + public EncryptionCodec(SecretKey key) { + this.key = key; + } + + @Override + public List encode(List payloads) { + return payloads.stream().map(payload -> { + // Encrypt payload.toByteArray() using your chosen algorithm (e.g., AES/GCM) + byte[] encrypted = encryptBytes(payload.toByteArray(), key); + return Payload.newBuilder() + .putMetadata("encoding", ByteString.copyFromUtf8("binary/encrypted")) + .setData(ByteString.copyFrom(encrypted)) + .build(); + }).collect(Collectors.toList()); + } + + @Override + public List decode(List payloads) { + return payloads.stream().map(payload -> { + String encoding = payload.getMetadataOrDefault( + "encoding", ByteString.EMPTY).toStringUtf8(); + if (!"binary/encrypted".equals(encoding)) return payload; + // Decrypt and reconstruct the original Payload + byte[] decrypted = decryptBytes(payload.getData().toByteArray(), key); + return Payload.parseFrom(decrypted); + }).collect(Collectors.toList()); + } +} +``` + +Apply the codec to the client: + +```java +CodecDataConverter codecDataConverter = new CodecDataConverter( + DefaultDataConverter.newDefaultInstance(), + Collections.singletonList(new EncryptionCodec(secretKey)) +); + +WorkflowClient client = WorkflowClient.newInstance( + service, + WorkflowClientOptions.newBuilder() + .setDataConverter(codecDataConverter) + .build() +); +``` + +## Search Attributes + +Custom searchable fields for workflow visibility. + +```java +import io.temporal.common.SearchAttributeKey; +import io.temporal.common.SearchAttributes; + +// Define typed search attribute keys +static final SearchAttributeKey ORDER_ID = + SearchAttributeKey.forKeyword("OrderId"); +static final SearchAttributeKey ORDER_STATUS = + SearchAttributeKey.forKeyword("OrderStatus"); +static final SearchAttributeKey ORDER_TOTAL = + SearchAttributeKey.forDouble("OrderTotal"); +static final SearchAttributeKey CREATED_AT = + SearchAttributeKey.forOffsetDateTime("CreatedAt"); + +// Set at workflow start +WorkflowOptions options = WorkflowOptions.newBuilder() + .setWorkflowId("order-" + orderId) + .setTaskQueue("orders") + .setTypedSearchAttributes( + SearchAttributes.newBuilder() + .set(ORDER_ID, orderId) + .set(ORDER_STATUS, "pending") + .set(ORDER_TOTAL, 99.99) + .set(CREATED_AT, OffsetDateTime.now()) + .build() + ) + .build(); +``` + +Upsert during workflow execution: + +```java +@WorkflowInterface +public interface OrderWorkflow { + @WorkflowMethod + String run(Order order); +} + +public class OrderWorkflowImpl implements OrderWorkflow { + static final SearchAttributeKey ORDER_STATUS = + SearchAttributeKey.forKeyword("OrderStatus"); + + @Override + public String run(Order order) { + // ... process order ... + + Workflow.upsertTypedSearchAttributes( + ORDER_STATUS.valueSet("completed") + ); + return "done"; + } +} +``` + +### Querying Workflows by Search Attributes + +```java +ListWorkflowExecutionsRequest request = ListWorkflowExecutionsRequest.newBuilder() + .setNamespace("default") + .setQuery("OrderStatus = 'processing' OR OrderStatus = 'pending'") + .build(); +``` + +## Workflow Memo + +Store arbitrary metadata with workflows (not searchable). + +```java +// Set memo at workflow start +WorkflowOptions options = WorkflowOptions.newBuilder() + .setWorkflowId("order-" + orderId) + .setTaskQueue("orders") + .setMemo(Map.of( + "customer_name", order.getCustomerName(), + "notes", "Priority customer" + )) + .build(); +``` + +```java +// Read memo from workflow +@Override +public String run(Order order) { + String notes = Workflow.getMemo("notes", String.class); + // ... +} +``` + +## Deterministic APIs for Values + +Use these APIs within workflows for deterministic values: + +```java +@Override +public String run() { + // Deterministic UUID (same on replay) + String uniqueId = Workflow.randomUUID().toString(); + + // Deterministic random (same on replay) + Random rng = Workflow.newRandom(); + int value = rng.nextInt(100); + + // Deterministic current time (same on replay) + long now = Workflow.currentTimeMillis(); + + return uniqueId; +} +``` + +## Best Practices + +1. Use Jackson `ObjectMapper` customization for complex serialization needs +2. Keep payloads small — see `references/core/gotchas.md` for limits +3. Encrypt sensitive data with `PayloadCodec` and `CodecDataConverter` +4. Use POJOs or Protobuf messages for workflow/activity parameters +5. Use `Workflow.randomUUID()`, `Workflow.newRandom()`, and `Workflow.currentTimeMillis()` for deterministic values diff --git a/references/java/determinism-protection.md b/references/java/determinism-protection.md new file mode 100644 index 0000000..78c4446 --- /dev/null +++ b/references/java/determinism-protection.md @@ -0,0 +1,83 @@ +# Java Determinism Protection + +## Overview + +The Java SDK has **no sandbox** (only Python and TypeScript have sandboxing). Java relies on developer conventions and runtime replay detection to enforce determinism. A static analysis tool (`temporal-workflowcheck`) is available in beta. + +## Forbidden Operations + +```java +// BAD: Non-deterministic operations in workflow code +Thread.sleep(1000); +UUID id = UUID.randomUUID(); +double val = Math.random(); +long now = System.currentTimeMillis(); +new Thread(() -> doWork()).start(); +CompletableFuture.supplyAsync(() -> compute()); + +// GOOD: Deterministic Workflow.* alternatives +Workflow.sleep(Duration.ofSeconds(1)); +String id = Workflow.randomUUID().toString(); +int val = Workflow.newRandom().nextInt(); +long now = Workflow.currentTimeMillis(); +Promise promise = Async.procedure(() -> doWork()); +CompletablePromise promise = Workflow.newPromise(); +``` + +## Static Analysis with `temporal-workflowcheck` + +**Warning:** This tool is in beta. + +`temporal-workflowcheck` scans compiled bytecode to detect non-deterministic operations in workflow code. It catches threading, I/O, randomization, system time access, and non-final static field access — including transitive violations through call chains. + +### Setup (Gradle) + +Add the dependency as a compile-only check: + +```groovy +dependencies { + implementation 'io.temporal:temporal-sdk:1.+' + compileOnly 'io.temporal:temporal-workflowcheck:1.+' +} +``` + +See the [Gradle sample](https://github.com/temporalio/sdk-java/tree/master/temporal-workflowcheck/samples/gradle) for full task configuration. + +### Setup (Maven) + +See the [Maven sample](https://github.com/temporalio/sdk-java/tree/master/temporal-workflowcheck/samples/maven) for POM configuration. + +### Running Manually + +Download the `-all.jar` from Maven Central (`io.temporal:temporal-workflowcheck`) and run: + +```bash +java -jar temporal-workflowcheck--all.jar check +``` + +### Suppressing False Positives + +Use the `@WorkflowCheck.SuppressWarnings` annotation on methods: + +```java +@WorkflowCheck.SuppressWarnings(invalidMembers = "currentTimeMillis") +public long getCurrentMillis() { + return System.currentTimeMillis(); +} +``` + +Or use a `.properties` configuration file with `--config ` for third-party library false positives. + +## Convention-Based Enforcement + +Java workflow code runs in a cooperative threading model where only one workflow thread executes at a time under a global lock. The SDK does not intercept or block non-deterministic calls. Instead, non-determinism is detected at **replay time**: if replayed code produces results that differ from the recorded history, the SDK throws a `NonDeterministicException`. + +Use both `temporal-workflowcheck` (static, pre-deploy) and `WorkflowReplayer` (replay testing) to catch non-determinism before production. + +## Best Practices + +1. Run `temporal-workflowcheck` in CI to catch non-deterministic code statically +2. Always use `Workflow.*` APIs instead of standard Java equivalents for time, randomness, UUIDs, sleeping, and threading +3. Test all workflow code changes with `WorkflowReplayer` against recorded histories +4. Keep workflows focused on orchestration logic; move all I/O and side effects into activities +5. Avoid mutable static state shared across workflow instances diff --git a/references/java/determinism.md b/references/java/determinism.md new file mode 100644 index 0000000..1981d00 --- /dev/null +++ b/references/java/determinism.md @@ -0,0 +1,55 @@ +# Java SDK Determinism + +## Overview + +The Java SDK has **no sandbox** (only Python and TypeScript have sandboxing). The Java SDK relies on developer conventions to enforce determinism. The SDK provides `Workflow.*` APIs as safe replacements for common non-deterministic operations. A static analysis tool (`temporal-workflowcheck`, beta) can catch violations at build time — see `references/java/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 + +Java workflow code runs in a cooperative threading model where only one workflow thread executes at a time under a global lock. The SDK does not intercept or block non-deterministic calls at runtime. If you call a forbidden operation, it will silently succeed during the initial execution but cause a `NonDeterministicException` when the workflow is replayed. + +`temporal-workflowcheck` (static analysis, beta) and `WorkflowReplayer` (replay testing) can help uncover some violations, but they are not exhaustive — careful code review and adherence to the rules below remain essential. + +## Forbidden Operations + +- `Thread.sleep()` — blocks the real thread, bypasses Temporal timers +- `new Thread()` or thread pools — breaks the cooperative threading model +- `synchronized` blocks and explicit locks — can deadlock with the workflow executor +- `UUID.randomUUID()` — non-deterministic across replays +- `Math.random()` or `new Random()` — non-deterministic across replays +- `System.currentTimeMillis()` or `Instant.now()` — non-deterministic across replays +- Direct I/O (network, filesystem, database) — side effects must run in activities +- Mutable global/static state — shared state breaks isolation between workflow instances +- `CompletableFuture` — bypasses the workflow scheduler; use `Promise` instead + +## Safe Builtin Alternatives + +| Forbidden | Safe Alternative | +|-----------|------------------| +| `Thread.sleep(millis)` | `Workflow.sleep(Duration.ofMillis(millis))` | +| `UUID.randomUUID()` | `Workflow.randomUUID()` | +| `Math.random()` | `Workflow.newRandom().nextInt()` | +| `System.currentTimeMillis()` | `Workflow.currentTimeMillis()` | +| `new Thread(runnable)` | `Async.function(func)` / `Async.procedure(proc)` | +| `CompletableFuture` | `Promise` / `CompletablePromise` | +| `BlockingQueue` | `WorkflowQueue` | +| `Future` | `Promise` | + +## Testing Replay Compatibility + +Use the `WorkflowReplayer` class to verify your code changes are compatible with existing histories. See the Workflow Replay Testing section of `references/java/testing.md`. + +## Best Practices + +1. Use `Workflow.currentTimeMillis()` for all time operations +2. Use `Workflow.newRandom()` for random values +3. Use `Workflow.randomUUID()` for unique identifiers +4. Use `Async.function()` / `Async.procedure()` instead of raw threads +5. Use `Promise` and `CompletablePromise` instead of `CompletableFuture` +6. Test with `WorkflowReplayer` to catch non-determinism +7. Keep workflows focused on orchestration, delegate I/O to activities +8. Use `Workflow.getLogger()` for replay-safe logging diff --git a/references/java/error-handling.md b/references/java/error-handling.md new file mode 100644 index 0000000..97d4cea --- /dev/null +++ b/references/java/error-handling.md @@ -0,0 +1,188 @@ +# Java SDK Error Handling + +## Overview + +The Java SDK uses `ApplicationFailure` for application-specific errors and `RetryOptions` for retry configuration. Generally, the following information about errors and retryability applies across activities, child workflows and Nexus operations. + +## Application Errors + +```java +import io.temporal.activity.ActivityInterface; +import io.temporal.activity.ActivityMethod; +import io.temporal.failure.ApplicationFailure; + +@ActivityInterface +public interface OrderActivities { + @ActivityMethod + void validateOrder(Order order); +} + +public class OrderActivitiesImpl implements OrderActivities { + @Override + public void validateOrder(Order order) { + if (!order.isValid()) { + throw ApplicationFailure.newFailure( + "Invalid order", + "ValidationError" + ); + } + } +} +``` + +Any exception that is not an `ApplicationFailure` is automatically converted to one, with the fully qualified class name as the type. For example, throwing `new NullPointerException("msg")` is equivalent to `ApplicationFailure.newFailure("msg", "java.lang.NullPointerException")`. + +## Non-Retryable Errors + +```java +import io.temporal.failure.ApplicationFailure; + +public class PaymentActivitiesImpl implements PaymentActivities { + @Override + public String chargeCard(String cardNumber, double amount) { + if (!isValidCard(cardNumber)) { + throw ApplicationFailure.newNonRetryableFailure( + "Permanent failure - invalid credit card", + "PaymentError" + ); + } + return processPayment(cardNumber, amount); + } +} +``` + +You can also mark error types as non-retryable via `RetryOptions.setDoNotRetry()`: + +```java +RetryOptions retryOptions = RetryOptions.newBuilder() + .setDoNotRetry( + CreditCardProcessingException.class.getName(), + "ValidationError" + ) + .build(); +``` + +Use `newNonRetryableFailure()` when the **activity implementer** knows the error is permanent. Use `setDoNotRetry()` when the **caller** wants to control retryability. + +## Activity Errors + +Activity failures are always wrapped in `ActivityFailure`. The original exception becomes the `cause`: + +- `ActivityFailure` → `ApplicationFailure` (application error) +- `ActivityFailure` → `TimeoutFailure` (timeout) +- `ActivityFailure` → `CanceledFailure` (cancellation) + +## Handling Activity Errors + +```java +import io.temporal.failure.ActivityFailure; +import io.temporal.failure.ApplicationFailure; +import io.temporal.failure.TimeoutFailure; +import io.temporal.workflow.Workflow; + +public class MyWorkflowImpl implements MyWorkflow { + @Override + public String run() { + try { + return activities.riskyOperation(); + } catch (ActivityFailure af) { + if (af.getCause() instanceof ApplicationFailure) { + ApplicationFailure appFailure = (ApplicationFailure) af.getCause(); + String type = appFailure.getType(); + // Handle based on error type + } else if (af.getCause() instanceof TimeoutFailure) { + // Handle timeout + } + throw ApplicationFailure.newFailure( + "Workflow failed due to activity error", + "WorkflowError" + ); + } + } +} +``` + +## Retry Policy Configuration + +```java +import io.temporal.activity.ActivityOptions; +import io.temporal.common.RetryOptions; +import io.temporal.workflow.Workflow; + +import java.time.Duration; + +public class MyWorkflowImpl implements MyWorkflow { + + private final MyActivities activities = Workflow.newActivityStub( + MyActivities.class, + ActivityOptions.newBuilder() + .setStartToCloseTimeout(Duration.ofMinutes(10)) + .setRetryOptions(RetryOptions.newBuilder() + .setMaximumInterval(Duration.ofMinutes(1)) + .setMaximumAttempts(5) + .setDoNotRetry("ValidationError", "PaymentError") + .build()) + .build() + ); + + @Override + public String run() { + return activities.myActivity(); + } +} +``` + +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 + +```java +ActivityOptions options = ActivityOptions.newBuilder() + .setStartToCloseTimeout(Duration.ofMinutes(5)) // Single attempt + .setScheduleToCloseTimeout(Duration.ofMinutes(30)) // Including retries + .setHeartbeatTimeout(Duration.ofMinutes(2)) // Between heartbeats + .build(); +``` + +## Workflow Failure + +**IMPORTANT:** Only `ApplicationFailure` causes a workflow to fail. Any other exception thrown from workflow code causes the workflow task to retry indefinitely, not the workflow itself. + +```java +import io.temporal.failure.ApplicationFailure; + +public class MyWorkflowImpl implements MyWorkflow { + @Override + public String run() { + if (someCondition) { + throw ApplicationFailure.newFailure( + "Cannot process order", + "BusinessError" + ); + } + return "success"; + } +} +``` + +To allow other exception types to fail the workflow instead of causing infinite task retries, see `references/java/advanced-features.md` for configuring `setFailWorkflowExceptionTypes()`. + +Use checked exceptions with `Workflow.wrap()` to rethrow them as unchecked: + +```java +try { + return someCall(); +} catch (Exception e) { + throw Workflow.wrap(e); +} +``` + +## Best Practices + +1. Use specific error types for different failure modes +2. Mark permanent failures as non-retryable +3. Configure appropriate retry policies +4. Log errors before re-raising +5. Catch `ActivityFailure` (not `ApplicationFailure`) for activity failures in workflows +6. Design code to be idempotent for safe retries (see more at `references/core/patterns.md`) +7. Use `ApplicationFailure.newFailure()` to fail workflows — other exceptions cause infinite task retries diff --git a/references/java/gotchas.md b/references/java/gotchas.md new file mode 100644 index 0000000..567fb64 --- /dev/null +++ b/references/java/gotchas.md @@ -0,0 +1,177 @@ +# Java Gotchas + +Java-specific mistakes and anti-patterns. See also [Common Gotchas](../core/gotchas.md) for language-agnostic concepts. + +## Non-Deterministic Operations + +**Critical: The Java SDK has NO sandbox.** Unlike Python (which uses a sandbox) or TypeScript (which uses V8 isolation), the Java SDK relies entirely on developer conventions. Non-deterministic calls silently succeed during initial execution but cause `NonDeterministicException` on replay. + +Forbidden in workflow code — use the Temporal `Workflow.*` equivalents instead: +- `Thread.sleep` → `Workflow.sleep` +- `UUID.randomUUID` → `Workflow.randomUUID` +- `Math.random` → `Workflow.newRandom` +- `System.currentTimeMillis` → `Workflow.currentTimeMillis` +- `new Thread` → `Async.function` +- `synchronized` blocks → unnecessary (workflow code runs under a global lock) + +See `references/java/determinism.md` for the full table of forbidden operations, safe alternatives, and detailed examples. + +## Wrong Retry Classification + +**Example:** Transient networks errors should be retried. Authentication errors should not be. +See `references/java/error-handling.md` to understand how to classify errors. + +## Heartbeating + +### Forgetting to Heartbeat Long Activities + +```java +// BAD - No heartbeat, can't detect stuck activities +@Override +public void processLargeFile(String path) { + for (String chunk : readChunks(path)) { + process(chunk); // Takes hours, no heartbeat + } +} + +// GOOD - Regular heartbeats with progress +@Override +public void processLargeFile(String path) { + int i = 0; + for (String chunk : readChunks(path)) { + Activity.getExecutionContext().heartbeat("Processing chunk " + i++); + process(chunk); + } +} +``` + +### Heartbeat Timeout Too Short + +```java +// BAD - Heartbeat timeout shorter than processing time +ActivityOptions options = ActivityOptions.newBuilder() + .setStartToCloseTimeout(Duration.ofMinutes(30)) + .setHeartbeatTimeout(Duration.ofSeconds(10)) // Too short! + .build(); + +// GOOD - Heartbeat timeout allows for processing variance +ActivityOptions options = ActivityOptions.newBuilder() + .setStartToCloseTimeout(Duration.ofMinutes(30)) + .setHeartbeatTimeout(Duration.ofMinutes(2)) + .build(); +``` + +Set heartbeat timeout as high as acceptable for your use case — each heartbeat counts as an action. + +## Cancellation + +### Not Handling Workflow Cancellation + +```java +// BAD - Cleanup doesn't run on cancellation +public class BadWorkflow implements MyWorkflow { + @Override + public void run() { + activities.acquireResource(); + activities.doWork(); + activities.releaseResource(); // Never runs if cancelled! + } +} +``` + +```java +// GOOD - Use try/finally with CancellationScope.nonCancellable +import io.temporal.workflow.CancellationScope; +import io.temporal.workflow.Workflow; + +public class GoodWorkflow implements MyWorkflow { + @Override + public void run() { + activities.acquireResource(); + try { + activities.doWork(); + } finally { + CancellationScope scope = Workflow.newDetachedCancellationScope( + () -> activities.releaseResource() + ); + scope.run(); + } + } +} +``` + +### Not Handling Activity Cancellation + +Activities must **opt in** to receive cancellation. This requires: +1. **Heartbeating** - Cancellation is delivered via heartbeat +2. **Catching CanceledFailure** - Thrown when heartbeat detects cancellation + +```java +// BAD - Activity ignores cancellation +@Override +public void longActivity() { + doExpensiveWork(); // Runs to completion even if cancelled +} +``` + +```java +// GOOD - Heartbeat and catch cancellation +import io.temporal.activity.Activity; +import io.temporal.failure.CanceledFailure; + +@Override +public void longActivity() { + try { + for (int i = 0; i < items.size(); i++) { + Activity.getExecutionContext().heartbeat(i); + process(items.get(i)); + } + } catch (CanceledFailure e) { + cleanup(); + throw e; + } +} +``` + +## 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/java/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, and should be considered in addition to standard testing. This is especially critical in Java since there is no sandbox. Please see `references/java/testing.md` for more info. + +## Timers and Sleep + +### Using Thread.sleep + +```java +// BAD - Thread.sleep is not deterministic during replay +public class BadWorkflow implements MyWorkflow { + @Override + public void run() { + try { + Thread.sleep(60000); // Non-deterministic! + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } +} +``` + +```java +// GOOD - Use Workflow.sleep for deterministic timers +import io.temporal.workflow.Workflow; +import java.time.Duration; + +public class GoodWorkflow implements MyWorkflow { + @Override + public void run() { + Workflow.sleep(Duration.ofSeconds(60)); // Deterministic + } +} +``` + +**Why this matters:** `Thread.sleep` uses the system clock, which differs between original execution and replay. `Workflow.sleep` creates a durable timer in the event history, ensuring consistent behavior during replay. Unlike Python and TypeScript, there is no sandbox to catch this — the call silently succeeds and only fails on replay. diff --git a/references/java/java.md b/references/java/java.md new file mode 100644 index 0000000..e18d723 --- /dev/null +++ b/references/java/java.md @@ -0,0 +1,249 @@ +# Temporal Java SDK Reference + +## Overview + +The Temporal Java SDK (`io.temporal:temporal-sdk`) uses an interface + implementation pattern for both Workflows and Activities. Java 8+ required; Java 21+ strongly recommended for virtual thread support. + +## Quick Start + +**Add Dependencies:** + +Gradle: +```groovy +implementation 'io.temporal:temporal-sdk:1.+' +``` + +Maven: +```xml + + io.temporal + temporal-sdk + [1.0,) + +``` + +**GreetActivities.java** - Activity interface: +```java +package greetingapp; + +import io.temporal.activity.ActivityInterface; +import io.temporal.activity.ActivityMethod; + +@ActivityInterface +public interface GreetActivities { + + @ActivityMethod + String greet(String name); +} +``` + +**GreetActivitiesImpl.java** - Activity implementation: +```java +package greetingapp; + +public class GreetActivitiesImpl implements GreetActivities { + + @Override + public String greet(String name) { + return "Hello, " + name + "!"; + } +} +``` + +**GreetingWorkflow.java** - Workflow interface: +```java +package greetingapp; + +import io.temporal.workflow.WorkflowInterface; +import io.temporal.workflow.WorkflowMethod; + +@WorkflowInterface +public interface GreetingWorkflow { + + @WorkflowMethod + String greet(String name); +} +``` + +**GreetingWorkflowImpl.java** - Workflow implementation: +```java +package greetingapp; + +import io.temporal.activity.ActivityOptions; +import io.temporal.workflow.Workflow; + +import java.time.Duration; + +public class GreetingWorkflowImpl implements GreetingWorkflow { + + private final GreetActivities activities = Workflow.newActivityStub( + GreetActivities.class, + ActivityOptions.newBuilder() + .setStartToCloseTimeout(Duration.ofSeconds(30)) + .build() + ); + + @Override + public String greet(String name) { + return activities.greet(name); + } +} +``` + +**GreetingWorker.java** - Worker setup: +```java +package greetingapp; + +import io.temporal.client.WorkflowClient; +import io.temporal.serviceclient.WorkflowServiceStubs; +import io.temporal.worker.Worker; +import io.temporal.worker.WorkerFactory; + +public class GreetingWorker { + + public static void main(String[] args) { + // Create gRPC stubs for local dev server (localhost:7233) + WorkflowServiceStubs service = WorkflowServiceStubs.newLocalServiceStubs(); + + // Create client + WorkflowClient client = WorkflowClient.newInstance(service); + + // Create factory and worker + WorkerFactory factory = WorkerFactory.newInstance(client); + Worker worker = factory.newWorker("greeting-queue"); + + // Register workflow and activity implementations + worker.registerWorkflowImplementationTypes(GreetingWorkflowImpl.class); + worker.registerActivitiesImplementations(new GreetActivitiesImpl()); + + // Start polling + factory.start(); + } +} +``` + +**Start the dev server:** Start `temporal server start-dev` in the background. + +**Start the worker:** Run `GreetingWorker.main()` (e.g., `./gradlew run` or `mvn compile exec:java -Dexec.mainClass="greetingapp.GreetingWorker"`). + +**Starter.java** - Start a workflow execution: +```java +package greetingapp; + +import io.temporal.client.WorkflowClient; +import io.temporal.client.WorkflowOptions; +import io.temporal.serviceclient.WorkflowServiceStubs; + +import java.util.UUID; + +public class Starter { + + public static void main(String[] args) { + WorkflowServiceStubs service = WorkflowServiceStubs.newLocalServiceStubs(); + WorkflowClient client = WorkflowClient.newInstance(service); + + GreetingWorkflow workflow = client.newWorkflowStub( + GreetingWorkflow.class, + WorkflowOptions.newBuilder() + .setWorkflowId(UUID.randomUUID().toString()) + .setTaskQueue("greeting-queue") + .build() + ); + + String result = workflow.greet("my name"); + System.out.println("Result: " + result); + } +} +``` + +**Run the workflow:** Run `Starter.main()`. Should output: `Result: Hello, my name!`. + +## Key Concepts + +### Workflow Definition +- Annotate interface with `@WorkflowInterface` +- Put any state initialization logic in the workflow constructor to guarantee that it happens before signals/updates arrive. If your state initialization logic requires the workflow parameters, then add the `@WorkflowInit` decorator and parameters to your constructor. +- Annotate entry point method with `@WorkflowMethod` (exactly one per interface) +- Use `@SignalMethod` for signal handlers +- Use `@QueryMethod` for query handlers +- Use `@UpdateMethod` for update handlers +- Implementation class implements the interface + +### Activity Definition +- Annotate interface with `@ActivityInterface` +- Optionally annotate methods with `@ActivityMethod` (for custom names) +- Implementation class can throw any exception +- Call from workflow via `Workflow.newActivityStub()` + +### Worker Setup +- `WorkflowServiceStubs` -- gRPC connection to Temporal Server +- `WorkflowClient` -- client used by worker to communicate with server +- `WorkerFactory` -- creates Worker instances +- `Worker` -- polls a single Task Queue, register workflows and activities on it +- Call `factory.start()` to begin polling + +## File Organization Best Practice + +**Keep Workflow and Activity definitions in separate files.** Separating them is good practice for clarity and maintainability. + +``` +greetingapp/ +├── GreetActivities.java # Activity interface +├── GreetActivitiesImpl.java # Activity implementation +├── GreetingWorkflow.java # Workflow interface +├── GreetingWorkflowImpl.java # Workflow implementation +├── GreetingWorker.java # Worker setup +└── Starter.java # Client code to start workflows +``` + +## Determinism Rules + +The Java SDK has **no sandbox**. The developer is fully responsible for writing deterministic workflow code. All non-deterministic operations must happen in Activities. + +**Do not use in workflow code:** +- `Thread` / `new Thread()` -- use `Workflow.newTimer()` or `Async.function()` +- `synchronized` / `Lock` -- workflow code is single-threaded +- `UUID.randomUUID()` -- use `Workflow.randomUUID()` +- `Math.random()` -- use `Workflow.newRandom()` +- `System.currentTimeMillis()` / `Instant.now()` -- use `Workflow.currentTimeMillis()` +- File I/O, network calls, database access -- use Activities +- `Thread.sleep()` -- use `Workflow.sleep()` +- Mutable static fields -- workflow instances must not share state + +**Use Workflow.* APIs instead:** +- `Workflow.sleep()` for timers +- `Workflow.currentTimeMillis()` for current time +- `Workflow.randomUUID()` for UUIDs +- `Workflow.newRandom()` for random numbers +- `Workflow.getLogger()` for replay-safe logging + +See `references/core/determinism.md` for detailed determinism rules. + +## Common Pitfalls + +1. **Non-deterministic code in workflows** - Use `Workflow.*` APIs instead of standard Java APIs; perform I/O in Activities +2. **Forgetting `@WorkflowInterface` or `@ActivityInterface`** - Annotations are required on interfaces for registration +3. **Multiple `@WorkflowMethod` on one interface** - Only one `@WorkflowMethod` is allowed per `@WorkflowInterface` +4. **Using `Thread.sleep()` in workflows** - Use `Workflow.sleep()` for deterministic timers +5. **Forgetting to heartbeat** - Long-running activities need `Activity.getExecutionContext().heartbeat()` +6. **Using `System.out.println()` in workflows** - Use `Workflow.getLogger()` for replay-safe logging +7. **Not registering activities as instances** - `registerActivitiesImplementations()` takes object instances (`new MyActivitiesImpl()`), not classes +8. **Blocking the workflow thread** - Never perform I/O or long computations in workflow code; use Activities +9. **Sharing mutable state between workflow instances** - Each workflow execution must be independent + +## Writing Tests + +See `references/java/testing.md` for info on writing tests. + +## Additional Resources + +### Reference Files +- **`references/java/patterns.md`** - Signals, queries, child workflows, saga pattern, etc. +- **`references/java/determinism.md`** - Determinism rules and safe alternatives for Java +- **`references/java/gotchas.md`** - Java-specific mistakes and anti-patterns +- **`references/java/error-handling.md`** - ApplicationFailure, retry policies, non-retryable errors +- **`references/java/observability.md`** - Logging, metrics, tracing, Search Attributes +- **`references/java/testing.md`** - TestWorkflowEnvironment, time-skipping, activity mocking +- **`references/java/advanced-features.md`** - Schedules, worker tuning, and more +- **`references/java/data-handling.md`** - Data converters, Jackson, payload encryption +- **`references/java/versioning.md`** - Patching API, workflow type versioning, Worker Versioning diff --git a/references/java/observability.md b/references/java/observability.md new file mode 100644 index 0000000..d7d9528 --- /dev/null +++ b/references/java/observability.md @@ -0,0 +1,134 @@ +# Java SDK Observability + +## Overview + +The Java SDK provides observability through replay-safe logging, Micrometer-based metrics, and visibility (Search Attributes). + +## Logging + +### Workflow Logging (Replay-Safe) + +Use `Workflow.getLogger()` for replay-safe logging that suppresses duplicate messages during replay: + +```java +public class OrderWorkflowImpl implements OrderWorkflow { + private static final Logger logger = Workflow.getLogger(OrderWorkflowImpl.class); + + @Override + public String run(Order order) { + logger.info("Workflow started for order {}", order.getId()); + + String result = Workflow.newActivityStub(OrderActivities.class, + ActivityOptions.newBuilder() + .setStartToCloseTimeout(Duration.ofMinutes(5)) + .build() + ).processOrder(order); + + logger.info("Activity completed with result {}", result); + return result; + } +} +``` + +The workflow logger automatically: +- Suppresses duplicate logs during replay +- Includes workflow context (workflow ID, run ID, etc.) +- Uses SLF4J under the hood + +### Activity Logging + +Use standard SLF4J loggers in activities. Activity context is available via `Activity.getExecutionContext()`: + +```java +public class OrderActivitiesImpl implements OrderActivities { + private static final Logger logger = + LoggerFactory.getLogger(OrderActivitiesImpl.class); + + @Override + public String processOrder(Order order) { + logger.info("Processing order {}", order.getId()); + + // Access activity context for metadata + ActivityExecutionContext ctx = Activity.getExecutionContext(); + logger.info("Activity ID: {}, attempt: {}", + ctx.getInfo().getActivityId(), + ctx.getInfo().getAttempt()); + + // Perform work... + logger.info("Order processed successfully"); + return "completed"; + } +} +``` + +## Customizing the Logger + +The Java SDK uses SLF4J. Configure your preferred backend: + +### Logback (logback.xml) + +```xml + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + + +``` + +Log4j2 is also supported as an SLF4J backend with equivalent configuration. + +## Metrics + +### Micrometer with Prometheus + +The Java SDK uses Micrometer for metrics collection. Configure with `MicrometerClientStatsReporter`: + +```java +import io.micrometer.prometheus.PrometheusConfig; +import io.micrometer.prometheus.PrometheusMeterRegistry; +import io.temporal.common.reporter.MicrometerClientStatsReporter; +import com.uber.m3.tally.RootScopeBuilder; +import com.uber.m3.tally.Scope; +import com.uber.m3.util.Duration; + +// Set up Prometheus registry +PrometheusMeterRegistry registry = new PrometheusMeterRegistry(PrometheusConfig.DEFAULT); + +// Create the Temporal metrics scope +Scope scope = new RootScopeBuilder() + .reporter(new MicrometerClientStatsReporter(registry)) + .reportEvery(Duration.ofSeconds(10)); + +// Apply to service stubs +WorkflowServiceStubs service = WorkflowServiceStubs.newServiceStubs( + WorkflowServiceStubsOptions.newBuilder() + .setMetricsScope(scope) + .build() +); + +// Expose Prometheus endpoint (e.g., via HTTP server) +// registry.scrape() returns the metrics in Prometheus format +``` + +### 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.getLogger()` in workflows, standard SLF4J loggers in activities +2. Do not use `System.out.println()` in workflows — it produces duplicate output on replay +3. Configure Micrometer metrics for production monitoring +4. Use Search Attributes for business-level visibility — see `references/java/data-handling.md` diff --git a/references/java/patterns.md b/references/java/patterns.md new file mode 100644 index 0000000..ed2fb37 --- /dev/null +++ b/references/java/patterns.md @@ -0,0 +1,509 @@ +# Java SDK Patterns + +## Signals + +```java +@WorkflowInterface +public interface OrderWorkflow { + @WorkflowMethod + String run(); + + @SignalMethod + void approve(); + + @SignalMethod + void addItem(String item); +} + +public class OrderWorkflowImpl implements OrderWorkflow { + private boolean approved = false; + private final List items = new ArrayList<>(); + + @Override + public void approve() { + this.approved = true; + } + + @Override + public void addItem(String item) { + this.items.add(item); + } + + @Override + public String run() { + Workflow.await(() -> this.approved); + return "Processed " + this.items.size() + " 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. + +```java +public class DynamicSignalWorkflowImpl implements DynamicSignalWorkflow { + private final Map> signals = new HashMap<>(); + + @Override + public String run() { + Workflow.registerListener( + (DynamicSignalHandler) (signalName, encodedArgs) -> { + signals.computeIfAbsent(signalName, k -> new ArrayList<>()) + .add(encodedArgs.get(0, String.class)); + }); + // ... workflow logic ... + } +} +``` + +## Queries + +**Important:** Queries must NOT modify workflow state or have side effects. + +```java +@WorkflowInterface +public interface StatusWorkflow { + @WorkflowMethod + String run(); + + @QueryMethod + String getStatus(); + + @QueryMethod + int getProgress(); +} + +public class StatusWorkflowImpl implements StatusWorkflow { + private String status = "pending"; + private int progress = 0; + + @Override + public String getStatus() { + return this.status; + } + + @Override + public int getProgress() { + return this.progress; + } + + @Override + public String run() { + MyActivities activities = Workflow.newActivityStub( + MyActivities.class, + ActivityOptions.newBuilder() + .setStartToCloseTimeout(Duration.ofMinutes(1)) + .build()); + + this.status = "running"; + for (int i = 0; i < 100; i++) { + this.progress = i; + activities.processItem(i); + } + this.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. + +```java +Workflow.registerListener( + (DynamicQueryHandler) (queryName, encodedArgs) -> { + if (queryName.equals("getField")) { + String fieldName = encodedArgs.get(0, String.class); + return fields.get(fieldName); + } + return null; + }); +``` + +## Updates + +```java +@WorkflowInterface +public interface OrderWorkflow { + @WorkflowMethod + String run(); + + @UpdateMethod + int addItem(String item); + + @UpdateValidatorMethod(updateName = "addItem") + void validateAddItem(String item); +} + +public class OrderWorkflowImpl implements OrderWorkflow { + private final List items = new ArrayList<>(); + + @Override + public int addItem(String item) { + this.items.add(item); + return this.items.size(); // Returns new count to caller + } + + @Override + public void validateAddItem(String item) { + if (item == null || item.isEmpty()) { + throw new IllegalArgumentException("Item cannot be empty"); + } + if (this.items.size() >= 100) { + throw new IllegalArgumentException("Order is full"); + } + } + + // ... run() ... +} +``` + +**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 normally to accept. + +## Child Workflows + +```java +public class MyWorkflowImpl implements MyWorkflow { + @Override + public List run(List orders) { + List results = new ArrayList<>(); + for (Order order : orders) { + ProcessOrderWorkflow child = Workflow.newChildWorkflowStub( + ProcessOrderWorkflow.class, + ChildWorkflowOptions.newBuilder() + .setWorkflowId("order-" + order.getId()) + .build()); + results.add(child.run(order)); + } + return results; + } +} +``` + +## Child Workflow Options + +```java +ChildWorkflowOptions options = ChildWorkflowOptions.newBuilder() + .setWorkflowId("child-workflow-id") + // Control what happens to child when parent closes + .setParentClosePolicy(ParentClosePolicy.PARENT_CLOSE_POLICY_ABANDON) + // Control what happens to child when parent is cancelled + .setCancellationType(ChildWorkflowCancellationType.WAIT_CANCELLATION_COMPLETED) + .setWorkflowExecutionTimeout(Duration.ofMinutes(10)) + .build(); + +ProcessOrderWorkflow child = Workflow.newChildWorkflowStub( + ProcessOrderWorkflow.class, options); +``` + +## Handles to External Workflows + +```java +public class MyWorkflowImpl implements MyWorkflow { + @Override + public void run(String targetWorkflowId) { + // Get handle to external workflow + TargetWorkflow external = Workflow.newExternalWorkflowStub( + TargetWorkflow.class, targetWorkflowId); + + // Signal the external workflow + external.dataReady(dataPayload); + + // Or cancel it using untyped stub + ExternalWorkflowStub untypedExternal = + Workflow.newUntypedExternalWorkflowStub(targetWorkflowId); + untypedExternal.cancel(); + } +} +``` + +## Parallel Execution + +```java +public class MyWorkflowImpl implements MyWorkflow { + @Override + public List run(List items) { + MyActivities activities = Workflow.newActivityStub( + MyActivities.class, + ActivityOptions.newBuilder() + .setStartToCloseTimeout(Duration.ofMinutes(5)) + .build()); + + // Execute activities in parallel + List> promises = new ArrayList<>(); + for (String item : items) { + promises.add(Async.function(activities::processItem, item)); + } + + // Wait for all to complete + Promise.allOf(promises).get(); + + // Collect results + List results = new ArrayList<>(); + for (Promise promise : promises) { + results.add(promise.get()); + } + return results; + } +} +``` + +## Continue-as-New + +```java +public class MyWorkflowImpl implements MyWorkflow { + @Override + public String run(WorkflowState state) { + while (true) { + state = processBatch(state); + + if (state.isComplete()) { + return "done"; + } + + // Continue with fresh history before hitting limits + if (Workflow.getInfo().isContinueAsNewSuggested()) { + Workflow.continueAsNew(state); + } + } + } +} +``` + +## Saga Pattern (Compensations) + +**Important:** Compensation activities should be idempotent — they may be retried (as with ALL activities). + +```java +public class MyWorkflowImpl implements MyWorkflow { + @Override + public String run(Order order) { + MyActivities activities = Workflow.newActivityStub( + MyActivities.class, + ActivityOptions.newBuilder() + .setStartToCloseTimeout(Duration.ofMinutes(5)) + .build()); + + List compensations = new ArrayList<>(); + + try { + // Note - we save the compensation BEFORE running the activity, + // because the following could happen: + // 1. reserveInventory starts running + // 2. it does successfully reserve inventory + // 3. but then fails for some other reason (timeout, reporting metrics, etc.) + // 4. in that case, the activity would have failed, but the effect still happened + // So, the compensation needs to handle both reserved and unreserved states. + compensations.add(() -> activities.releaseInventoryIfReserved(order)); + activities.reserveInventory(order); + + compensations.add(() -> activities.refundPaymentIfCharged(order)); + activities.chargePayment(order); + + activities.shipOrder(order); + + return "Order completed"; + + } catch (Exception e) { + Workflow.getLogger(MyWorkflowImpl.class) + .error("Order failed, running compensations", e); + // Use a detached cancellation scope so compensations run even if + // the workflow itself was cancelled. + CancellationScope compensationScope = Workflow.newDetachedCancellationScope(() -> { + Collections.reverse(compensations); + for (Runnable compensate : compensations) { + try { + compensate.run(); + } catch (Exception compErr) { + Workflow.getLogger(MyWorkflowImpl.class) + .error("Compensation failed", compErr); + } + } + }); + compensationScope.run(); + throw Workflow.wrap(e); + } + } +} +``` + +## Cancellation Scopes + +```java +public class MyWorkflowImpl implements MyWorkflow { + @Override + public String run() { + try { + MyActivities activities = Workflow.newActivityStub( + MyActivities.class, + ActivityOptions.newBuilder() + .setStartToCloseTimeout(Duration.ofHours(1)) + .build()); + + activities.longRunningActivity(); + return "completed"; + + } catch (CanceledFailure e) { + // Workflow was cancelled - perform cleanup + Workflow.getLogger(MyWorkflowImpl.class) + .info("Workflow cancelled, running cleanup"); + + // Use nonCancellable scope so cleanup activities still run + CancellationScope cleanupScope = Workflow.newDetachedCancellationScope( + () -> { + MyActivities activities = Workflow.newActivityStub( + MyActivities.class, + ActivityOptions.newBuilder() + .setStartToCloseTimeout(Duration.ofMinutes(5)) + .build()); + activities.cleanupActivity(); + }); + cleanupScope.run(); + throw e; // Re-throw to mark workflow as cancelled + } + } +} +``` + +Timeout scope: + +```java +CancellationScope timeoutScope = Workflow.newCancellationScope( + () -> { + // This scope will be cancelled after 30 minutes + activities.longRunningActivity(); + }); +timeoutScope.run(); +// Cancel after timeout +Workflow.newTimer(Duration.ofMinutes(30)).thenApply(r -> { + timeoutScope.cancel(); + return null; +}); +``` + +## Wait Condition with Timeout + +```java +public class MyWorkflowImpl implements MyWorkflow { + private boolean approved = false; + + @Override + public String run() { + // Wait for approval with 24-hour timeout + boolean received = Workflow.await(Duration.ofHours(24), () -> this.approved); + if (received) { + return "approved"; + } + return "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 handlers do run async operations, call `Workflow.await(() -> Workflow.isEveryHandlerFinished())` at the end of your workflow (or before continue-as-new) to prevent completion until all pending handlers complete. + +```java +public class MyWorkflowImpl implements MyWorkflow { + @Override + public String run() { + // ... main workflow logic ... + + // Before exiting, wait for all handlers to finish + Workflow.await(() -> Workflow.isEveryHandlerFinished()); + 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 + +```java +@ActivityInterface +public interface MyActivities { + @ActivityMethod + String processLargeFile(String filePath); +} + +public class MyActivitiesImpl implements MyActivities { + @Override + public String processLargeFile(String filePath) { + ActivityExecutionContext ctx = Activity.getExecutionContext(); + + // Get heartbeat details from previous attempt (if any) + Optional lastLine = ctx.getHeartbeatDetails(Integer.class); + int startLine = lastLine.orElse(0); + + try { + List lines = readFile(filePath); + for (int i = startLine; i < lines.size(); i++) { + processLine(lines.get(i)); + + // Heartbeat with progress + // If cancelled, heartbeat() throws CanceledFailure + ctx.heartbeat(i + 1); + } + return "completed"; + } catch (ActivityCompletionException e) { + // CanceledFailure extends ActivityCompletionException + cleanup(); + throw e; + } + } +} +``` + +Set `heartbeatTimeout` in `ActivityOptions` to enable heartbeat-based failure detection: + +```java +ActivityOptions options = ActivityOptions.newBuilder() + .setStartToCloseTimeout(Duration.ofHours(1)) + .setHeartbeatTimeout(Duration.ofSeconds(30)) + .build(); +``` + +## Timers + +```java +public class MyWorkflowImpl implements MyWorkflow { + @Override + public String run() { + Workflow.sleep(Duration.ofHours(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. + +```java +public class MyWorkflowImpl implements MyWorkflow { + @Override + public String run() { + MyActivities localActivities = Workflow.newLocalActivityStub( + MyActivities.class, + LocalActivityOptions.newBuilder() + .setStartToCloseTimeout(Duration.ofSeconds(5)) + .build()); + + String result = localActivities.quickLookup("key"); + return result; + } +} +``` diff --git a/references/java/testing.md b/references/java/testing.md new file mode 100644 index 0000000..80ed9b2 --- /dev/null +++ b/references/java/testing.md @@ -0,0 +1,184 @@ +# Java SDK Testing + +## Overview + +You test Temporal Java Workflows using `TestWorkflowEnvironment` (manual setup) or `TestWorkflowExtension` (JUnit 5). Activity mocking uses Mockito. The SDK provides `WorkflowReplayer` for replay-based compatibility testing. + +## Workflow Test Environment + +```java +import io.temporal.testing.TestWorkflowExtension; +import io.temporal.testing.TestWorkflowEnvironment; +import io.temporal.client.WorkflowClient; +import io.temporal.client.WorkflowOptions; +import io.temporal.worker.Worker; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class MyWorkflowTest { + + @RegisterExtension + public static final TestWorkflowExtension testWorkflowExtension = + TestWorkflowExtension.newBuilder() + .setWorkflowTypes(MyWorkflowImpl.class) + .setDoNotStart(true) + .build(); + + @Test + void testWorkflow(TestWorkflowEnvironment env, Worker worker, WorkflowClient client) { + worker.registerActivitiesImplementations(new MyActivitiesImpl()); + env.start(); + + MyWorkflow workflow = client.newWorkflowStub( + MyWorkflow.class, + WorkflowOptions.newBuilder() + .setTaskQueue(worker.getTaskQueue()) + .build()); + + String result = workflow.run("input"); + assertEquals("expected", result); + } +} +``` + +For manual lifecycle control (e.g., JUnit 4 or custom setups), use `TestWorkflowEnvironment` directly with `@BeforeEach`/`@AfterEach`. + +## Mocking Activities + +```java +import static org.mockito.Mockito.*; + +@Test +void testWithMockedActivities( + TestWorkflowEnvironment env, + Worker worker, + WorkflowClient client) { + // withoutAnnotations() prevents Mockito from copying Temporal annotations + MyActivities activities = mock(MyActivities.class, withSettings().withoutAnnotations()); + when(activities.composeGreeting("Hello", "World")).thenReturn("mocked result"); + + worker.registerActivitiesImplementations(activities); + env.start(); + + MyWorkflow workflow = client.newWorkflowStub( + MyWorkflow.class, + WorkflowOptions.newBuilder() + .setTaskQueue(worker.getTaskQueue()) + .build()); + + String result = workflow.run("input"); + assertEquals("mocked result", result); + verify(activities).composeGreeting("Hello", "World"); +} +``` + +## Testing Signals and Queries + +```java +@Test +void testSignalsAndQueries( + TestWorkflowEnvironment env, + Worker worker, + WorkflowClient client) { + worker.registerActivitiesImplementations(new MyActivitiesImpl()); + env.start(); + + MyWorkflow workflow = client.newWorkflowStub( + MyWorkflow.class, + WorkflowOptions.newBuilder() + .setTaskQueue(worker.getTaskQueue()) + .build()); + + // Start workflow asynchronously + WorkflowClient.start(workflow::run, "input"); + + // Send signal + workflow.mySignal("data"); + + // Query state + String status = workflow.getStatus(); + assertEquals("expected", status); + + // Wait for completion + String result = WorkflowStub.fromTyped(workflow).getResult(String.class); +} +``` + +## Testing Failure Cases + +```java +import io.temporal.client.WorkflowException; + +@Test +void testActivityFailure( + TestWorkflowEnvironment env, + Worker worker, + WorkflowClient client) { + MyActivities activities = mock(MyActivities.class, withSettings().withoutAnnotations()); + when(activities.unreliableAction(anyString())) + .thenThrow(new RuntimeException("Simulated failure")); + + worker.registerActivitiesImplementations(activities); + env.start(); + + MyWorkflow workflow = client.newWorkflowStub( + MyWorkflow.class, + WorkflowOptions.newBuilder() + .setTaskQueue(worker.getTaskQueue()) + .build()); + + assertThrows(WorkflowException.class, () -> workflow.run("input")); +} +``` + +## Workflow Replay Testing + +```java +import io.temporal.testing.WorkflowReplayer; + +@Test +void testReplayFromHistory() throws Exception { + WorkflowReplayer.replayWorkflowExecutionFromResource( + "my-workflow-history.json", + MyWorkflowImpl.class); +} +``` + +Replay from a `WorkflowHistory` object: + +```java +import io.temporal.common.WorkflowExecutionHistory; + +@Test +void testReplayFromJsonString() throws Exception { + String historyJson = new String(Files.readAllBytes(Paths.get("history.json"))); + WorkflowReplayer.replayWorkflowExecution( + WorkflowExecutionHistory.fromJson(historyJson), + MyWorkflowImpl.class); +} +``` + +## Activity Testing + +Activity implementations are plain Java classes. Test them directly: + +```java +@Test +void testActivity() { + MyActivitiesImpl activities = new MyActivitiesImpl(); + String result = activities.composeGreeting("Hello", "World"); + assertEquals("Hello World", result); +} +``` + +For activities that use `Activity.getExecutionContext()` or heartbeating, use `TestActivityEnvironment` to provide the activity context. + +## Best Practices + +1. Use `TestWorkflowExtension` with JUnit 5 for concise test setup +2. Always use `withSettings().withoutAnnotations()` when mocking activity interfaces with Mockito +3. Mock external dependencies in activities, not in workflows +4. Test replay compatibility when changing workflow code (see `references/java/determinism.md`) +5. Test signal/query handlers explicitly +6. Use unique task queues per test to avoid conflicts (handled automatically by `TestWorkflowExtension`) diff --git a/references/java/versioning.md b/references/java/versioning.md new file mode 100644 index 0000000..d1a9205 --- /dev/null +++ b/references/java/versioning.md @@ -0,0 +1,281 @@ +# Java SDK Versioning + +For conceptual overview and guidance on choosing an approach, see `references/core/versioning.md`. + +## Patching API + +### Workflow.getVersion() + +`Workflow.getVersion(String changeId, int minSupported, int maxSupported)` returns the version to use for a given change: + +```java +import io.temporal.workflow.Workflow; + +@WorkflowInterface +public interface ShippingWorkflow { + @WorkflowMethod + void run(); +} + +public class ShippingWorkflowImpl implements ShippingWorkflow { + @Override + public void run() { + int version = Workflow.getVersion( + "send-email-instead-of-fax", + Workflow.DEFAULT_VERSION, // minSupported (no change) + 1 // maxSupported (current version) + ); + + if (version == 1) { + // New code path + Workflow.newActivityStub(MyActivities.class, options).sendEmail(); + } else { + // Old code path (for replay of existing workflows) + Workflow.newActivityStub(MyActivities.class, options).sendFax(); + } + } +} +``` + +**How it works:** +- For new executions: returns `maxSupported` and records a marker in history +- For replay with the marker: returns the recorded version +- For replay without the marker: returns `DEFAULT_VERSION` (-1) + +### Three-Step Patching Process + +**Step 1: Patch in New Code** + +Add the version check with both old and new code paths: + +```java +public class OrderWorkflowImpl implements OrderWorkflow { + @Override + public String run(Order order) { + int version = Workflow.getVersion( + "add-fraud-check", + Workflow.DEFAULT_VERSION, + 1); + + if (version >= 1) { + activities.checkFraud(order); + } + + return activities.processPayment(order); + } +} +``` + +**Step 2: Remove Old Code Path** + +Once all pre-patch Workflow Executions have completed, remove the old branch and set `minSupported` to `1`: + +```java +public class OrderWorkflowImpl implements OrderWorkflow { + @Override + public String run(Order order) { + Workflow.getVersion("add-fraud-check", 1, 1); + + activities.checkFraud(order); + return activities.processPayment(order); + } +} +``` + +**Step 3: Remove the Patch** + +After all workflows with the patch marker have completed, remove the `getVersion` call entirely: + +```java +public class OrderWorkflowImpl implements OrderWorkflow { + @Override + public String run(Order order) { + activities.checkFraud(order); + return activities.processPayment(order); + } +} +``` + +### Recording TemporalChangeVersion Search Attribute + +Unlike the Python and TypeScript SDKs, the Java SDK does **not** automatically record the `TemporalChangeVersion` search attribute. You must manually upsert it: + +```java +import io.temporal.workflow.Workflow; +import io.temporal.common.SearchAttributeKey; +import java.util.List; + +public class OrderWorkflowImpl implements OrderWorkflow { + private static final SearchAttributeKey> TEMPORAL_CHANGE_VERSION = + SearchAttributeKey.forKeywordList("TemporalChangeVersion"); + + @Override + public String run(Order order) { + int version = Workflow.getVersion("add-fraud-check", Workflow.DEFAULT_VERSION, 1); + + // Manually record for query filtering + Workflow.upsertTypedSearchAttributes( + TEMPORAL_CHANGE_VERSION.valueSet(List.of("add-fraud-check-1"))); + + if (version >= 1) { + activities.checkFraud(order); + } + return activities.processPayment(order); + } +} +``` + +Query with: + +```bash +temporal workflow list --query \ + 'TemporalChangeVersion = "add-fraud-check-1" AND ExecutionStatus = "Running"' +``` + +## Workflow Type Versioning + +For incompatible changes, create a new Workflow Type: + +```java +@WorkflowInterface +public interface PizzaWorkflow { + @WorkflowMethod + String run(PizzaOrder order); +} + +// Original implementation +public class PizzaWorkflowImpl implements PizzaWorkflow { + @Override + public String run(PizzaOrder order) { + return processOrderV1(order); + } +} + +// New workflow type for incompatible changes +@WorkflowInterface +public interface PizzaWorkflowV2 { + @WorkflowMethod + String run(PizzaOrder order); +} + +public class PizzaWorkflowV2Impl implements PizzaWorkflowV2 { + @Override + public String run(PizzaOrder order) { + return processOrderV2(order); + } +} +``` + +Register both with the Worker: + +```java +worker.registerWorkflowImplementationTypes( + PizzaWorkflowImpl.class, + PizzaWorkflowV2Impl.class); +``` + +Start new workflows with the new type: + +```java +PizzaWorkflowV2 workflow = client.newWorkflowStub( + PizzaWorkflowV2.class, + WorkflowOptions.newBuilder() + .setTaskQueue("pizza-task-queue") + .build()); +workflow.run(order); +``` + +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. Available since Java SDK v1.29. + +### Key Concepts + +- **Worker Deployment**: A logical group of Workers processing the same Task Queue, identified by a deployment name (e.g., `"order-service"`). +- **Worker Deployment Version**: A specific version within a deployment, identified by the combination of deployment name and Build ID (e.g., `"order-service:v1.0.0"`). Each version corresponds to a particular code revision. + +### Configuring Workers + +```java +import io.temporal.worker.Worker; +import io.temporal.worker.WorkerFactory; +import io.temporal.worker.WorkerOptions; +import io.temporal.worker.WorkerDeploymentOptions; +import io.temporal.worker.WorkerDeploymentVersion; + +WorkerDeploymentVersion version = WorkerDeploymentVersion.newBuilder() + .setDeploymentName("order-service") + .setBuildId("v1.0.0") // or git commit hash + .build(); + +WorkerDeploymentOptions deploymentOptions = WorkerDeploymentOptions.newBuilder() + .setVersion(version) + .setUseWorkerVersioning(true) + .build(); + +WorkerFactory factory = WorkerFactory.newInstance(client); +Worker worker = factory.newWorker( + "my-task-queue", + WorkerOptions.newBuilder() + .setDeploymentOptions(deploymentOptions) + .build()); + +worker.registerWorkflowImplementationTypes(MyWorkflowImpl.class); +worker.registerActivitiesImplementations(new MyActivitiesImpl()); +factory.start(); +``` + +### PINNED vs AUTO_UPGRADE Behaviors + +Set the versioning behavior on the workflow definition: + +```java +import io.temporal.workflow.VersioningBehavior; +import io.temporal.workflow.Workflow; + +public class MyWorkflowImpl implements MyWorkflow { + @Override + public String run(String input) { + Workflow.setVersioningBehavior(VersioningBehavior.PINNED); + // ... workflow logic + } +} +``` + +**PINNED**: Workflow stays on the Worker version that started it. Use for short-running workflows or when consistency within a single execution is critical. New workflows start on the current version; existing ones stay put. + +**AUTO_UPGRADE**: Workflow moves to the latest Worker version on the next Workflow Task. Use for long-running workflows that need bug fixes or feature updates. Combine with `Workflow.getVersion()` patching to handle version transitions safely. + +### Deployment Strategies + +**Blue-Green**: Run two deployment versions simultaneously. Set the new version as the current deployment. PINNED workflows finish on the old version; new workflows start on the new version. Drain the old version once all its workflows complete. + +**Rainbow**: Run multiple versions concurrently for gradual rollouts. Each version handles its own workflows. Useful when you have many long-running PINNED workflows across several code revisions. + +### Querying Workflows by Worker Version + +```bash +# List workflows running on a specific version +temporal workflow list --query \ + 'TemporalWorkerDeploymentVersion = "order-service:v1.0.0" AND ExecutionStatus = "Running"' + +# Count workflows per version to monitor drain progress +temporal workflow count --query \ + 'TemporalWorkerDeploymentVersion = "order-service:v1.0.0" AND ExecutionStatus = "Running"' +``` + +## Best Practices + +1. **Check for open executions** before removing old code paths +2. **Use descriptive change IDs** that explain the change (e.g., `"add-fraud-check"` not `"patch-1"`) +3. **Deploy patches incrementally**: patch, remove old path, remove `getVersion` +4. **Manually upsert `TemporalChangeVersion`** search attribute when using `getVersion` if you need query filtering +5. **Use PINNED for short workflows** to simplify version management +6. **Use AUTO_UPGRADE with patching** for long-running workflows that need updates +7. **Generate Build IDs from code** (git hash) to ensure changes produce new versions From 7d5ae761fffd79fbcf0492d6b04b5d1ebbc8ae2c Mon Sep 17 00:00:00 2001 From: Donald Pinckney Date: Thu, 2 Apr 2026 17:08:53 -0400 Subject: [PATCH 18/82] Reduce repetition in determinism sans sandboxing (#67) * Reduce repetition in determinism sans sandboxing. * fix merge --- references/go/determinism-protection.md | 2 +- references/go/go.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/references/go/determinism-protection.md b/references/go/determinism-protection.md index cc8d8f5..b37d94a 100644 --- a/references/go/determinism-protection.md +++ b/references/go/determinism-protection.md @@ -2,7 +2,7 @@ ## Overview -The Go SDK has no runtime sandbox. Determinism is enforced by **developer convention** and **optional static analysis**. Unlike the Python and TypeScript SDKs, the Go SDK will not intercept or replace non-deterministic calls at runtime. The Go SDK does perform a limited runtime command-ordering check, but catching non-deterministic code before deployment requires the `workflowcheck` tool and testing, in particular replay tests (see `references/go/testing.md`). +The Go SDK has no runtime sandbox (only Python and TypeScript have sandboxing). Determinism is enforced by **developer convention** and **optional static analysis**. The Go SDK will not intercept or replace non-deterministic calls at runtime. The Go SDK does perform a limited runtime command-ordering check, but catching non-deterministic code before deployment requires the `workflowcheck` tool and testing, in particular replay tests (see `references/go/testing.md`). ## workflowcheck Static Analysis diff --git a/references/go/go.md b/references/go/go.md index 974ee7c..827d35c 100644 --- a/references/go/go.md +++ b/references/go/go.md @@ -2,7 +2,7 @@ ## Overview -The Temporal Go SDK (`go.temporal.io/sdk`) provides a strongly-typed, idiomatic Go approach to building durable workflows. Workflows are regular exported Go functions. The Go SDK does not have an automatic sandbox -- determinism is the developer's responsibility, aided by the `workflowcheck` static analysis tool. +The Temporal Go SDK (`go.temporal.io/sdk`) provides a strongly-typed, idiomatic Go approach to building durable workflows. Workflows are regular exported Go functions. ## Quick Start From 25eb55398ca0a34b56fcdebe4dccda00e4884cbf Mon Sep 17 00:00:00 2001 From: Donald Pinckney Date: Fri, 3 Apr 2026 05:11:50 -0400 Subject: [PATCH 19/82] Bump to 0.2.0 for Java release (#72) --- SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SKILL.md b/SKILL.md index 38c2185..9043177 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,7 +1,7 @@ --- 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", "Temporal Java", "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 +version: 0.2.0 --- # Skill: temporal-developer From 33747f0438a10316c2e8727a23fd1278d2c9f172 Mon Sep 17 00:00:00 2001 From: Donald Pinckney Date: Fri, 17 Apr 2026 10:11:47 -0400 Subject: [PATCH 20/82] Auto-formatting: ran `mdformat --extensions frontmatter --number .` (#81) * Auto-formatting: ran `mdformat --extensions frontmatter --number .` * manual tweaks * manual tweaks --- README.md | 4 +- SKILL.md | 22 +++++----- references/core/ai-patterns.md | 12 +++-- references/core/determinism.md | 13 +++++- references/core/dev-management.md | 1 - references/core/error-reference.md | 16 +++---- references/core/gotchas.md | 28 +++++++++--- references/core/patterns.md | 44 +++++++++++++++++-- references/core/troubleshooting.md | 14 +++--- references/core/versioning.md | 4 ++ references/go/advanced-features.md | 2 + references/go/data-handling.md | 2 + references/go/determinism-protection.md | 5 +++ references/go/go.md | 12 +++++ references/go/gotchas.md | 1 + references/go/observability.md | 3 ++ references/go/patterns.md | 3 ++ references/go/versioning.md | 6 +++ references/java/gotchas.md | 2 + references/java/java.md | 16 ++++++- references/java/observability.md | 1 + references/java/patterns.md | 2 + references/java/versioning.md | 1 + references/python/advanced-features.md | 2 +- references/python/data-handling.md | 2 + references/python/determinism-protection.md | 2 + references/python/determinism.md | 1 + references/python/gotchas.md | 2 + references/python/observability.md | 3 +- references/python/patterns.md | 3 ++ references/python/python.md | 10 ++++- references/python/sync-vs-async.md | 4 ++ references/python/testing.md | 1 - references/python/versioning.md | 6 +++ references/typescript/advanced-features.md | 4 ++ references/typescript/data-handling.md | 1 + .../typescript/determinism-protection.md | 1 - references/typescript/gotchas.md | 1 + references/typescript/patterns.md | 3 ++ references/typescript/typescript.md | 14 +++++- references/typescript/versioning.md | 3 ++ 41 files changed, 226 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index 124b367..8f2a0a5 100644 --- a/README.md +++ b/README.md @@ -12,12 +12,12 @@ A comprehensive skill for developers to use when building [Temporal](https://tem This skill is housed within a [Claude Code plugin](https://github.com/temporalio/agent-skills), which provides a simple way to install and receive future updates to the skill. -1. Run `/plugin marketplace add temporalio/agent-skills` +1. Run `/plugin marketplace add temporalio/agent-skills` 2. Run `/plugin` to open the plugin manager 3. Select **Marketplaces** 4. Choose `temporal-marketplace` from the list 5. Select **Enable auto-update** or **Disable auto-update** -6. run `/plugin install temporal-developer@temporalio-agent-skills` +6. run `/plugin install temporal-developer@temporalio-agent-skills` 7. Restart Claude Code ### Via `npx skills` - supports all major coding agents diff --git a/SKILL.md b/SKILL.md index 9043177..0ed18f0 100644 --- a/SKILL.md +++ b/SKILL.md @@ -77,34 +77,34 @@ Once you've downloaded the file, extract the downloaded archive and add the temp ### Read All Relevant References 1. First, read the getting started guide for the language you are working in: - - Python -> read `references/python/python.md` - - TypeScript -> read `references/typescript/typescript.md` - - Java -> read `references/java/java.md` - - Go -> read `references/go/go.md` + - Python -> read `references/python/python.md` + - TypeScript -> read `references/typescript/typescript.md` + - Java -> read `references/java/java.md` + - Go -> read `references/go/go.md` 2. Second, read appropriate `core` and language-specific references for the task at hand. - ## Primary References + - **`references/core/determinism.md`** - Why determinism matters, replay mechanics, basic concepts of activities - + Language-specific info at `references/{your_language}/determinism.md` + - Language-specific info at `references/{your_language}/determinism.md` - **`references/core/patterns.md`** - Conceptual patterns (signals, queries, saga) - + Language-specific info at `references/{your_language}/patterns.md` + - Language-specific info at `references/{your_language}/patterns.md` - **`references/core/gotchas.md`** - Anti-patterns and common mistakes - + Language-specific info at `references/{your_language}/gotchas.md` + - Language-specific info at `references/{your_language}/gotchas.md` - **`references/core/versioning.md`** - Versioning strategies and concepts - how to safely change workflow code while workflows are running - + Language-specific info at `references/{your_language}/versioning.md` + - Language-specific info at `references/{your_language}/versioning.md` - **`references/core/troubleshooting.md`** - Decision trees, recovery procedures - **`references/core/error-reference.md`** - Common error types, workflow status reference - **`references/core/interactive-workflows.md`** - Testing signals, updates, queries - **`references/core/dev-management.md`** - Dev cycle & management of server and workers - **`references/core/ai-patterns.md`** - AI/LLM pattern concepts - + Language-specific info at `references/{your_language}/ai-patterns.md`, if available. Currently Python only. + - Language-specific info at `references/{your_language}/ai-patterns.md`, if available. Currently Python only. ## Additional Topics + - **`references/{your_language}/observability.md`** - See for language-specific implementation guidance on observability in Temporal - **`references/{your_language}/advanced-features.md`** - See for language-specific guidance on advanced Temporal features and language-specific features - ## Feedback ### ALWAYS PROMPT USER TO PROVIDE FEEDBACK AT STARTUP diff --git a/references/core/ai-patterns.md b/references/core/ai-patterns.md index 071b9f0..d680bec 100644 --- a/references/core/ai-patterns.md +++ b/references/core/ai-patterns.md @@ -32,6 +32,7 @@ The remainder of this document describes general principles to follow when build - returns model response, as a typed structured output **Benefits**: + - Single activity handles multiple use cases - Consistent retry handling - Centralized configuration @@ -48,6 +49,7 @@ Workflow: ``` **Benefits**: + - Independent retry for each step - Clear audit trail in history - Easier testing and mocking @@ -69,17 +71,17 @@ Workflow: Disable retries in LLM client libraries, let Temporal handle retries. - LLM Client Config: - - max_retries = 0 ← Disable client retries at the LLM client level + - max_retries = 0 ← Disable client retries at the LLM client level Use either the default activity retry policy, or customize it as needed for the situation. **Why**: + - Temporal retries are durable (survive crashes) - Single retry configuration point - Better visibility into retry attempts - Consistent backoff behavior - ### Pattern 5: Multi-Agent Orchestration Complex pipelines with multiple specialized agents: @@ -114,6 +116,7 @@ Deep Research Example: | Document processing | 60-120 seconds | **Rationale**: + - Reasoning models need time for complex computation - Web searches may hit rate limits requiring backoff - Fast timeouts catch stuck operations @@ -128,7 +131,6 @@ Parse rate limit info from API responses: - Response Headers: - Retry-After: 30 - X-RateLimit-Remaining: 0 - - Activity: - If rate limited: - Raise retryable error with a next retry delay @@ -137,12 +139,14 @@ Parse rate limit info from API responses: ## Error Handling ### Retryable Errors + - Rate limits (429) - Timeouts - Temporary server errors (500, 502, 503) - Network errors ### Non-Retryable Errors + - Invalid API key (401) - Invalid input/prompt - Content policy violations @@ -161,6 +165,6 @@ Parse rate limit info from API responses: ## Observability See `references/{your_language}/observability.md` for the language you are working in for documentation on implementing observability in Temporal. It is generally recommended to add observability for: + - Token usage, via activity logging - any else to help track LLM usage and debug agentic flows, within moderation. - diff --git a/references/core/determinism.md b/references/core/determinism.md index 16f04db..952cca4 100644 --- a/references/core/determinism.md +++ b/references/core/determinism.md @@ -50,22 +50,27 @@ Result: Commands don't match history → NondeterminismError ## Sources of Non-Determinism ### Time-Based Operations + - `datetime.now()`, `time.time()`, `Date.now()` - Different value on each execution ### Random Values + - `random.random()`, `Math.random()`, `uuid.uuid4()` - Different value on each execution ### External State + - Reading files, environment variables, databases, networking / HTTP calls - State may change between executions ### Non-Deterministic Iteration + - Map/dict iteration order (in some languages) - Set iteration order ### Threading/Concurrency + - Race conditions produce different outcomes - Non-deterministic ordering @@ -76,9 +81,10 @@ In Temporal, activities are the primary mechanism for making non-deterministic c For a few simple cases, like timestamps, random values, UUIDs, etc. the Temporal SDK in your language may provide durable variants that are simple to use. See `references/{your_language}/determinism.md` for the language you are working in for more info. ## SDK Protection Mechanisms + Each Temporal SDK language provides a different level of protection against non-determinism: -- Python: The Python SDK runs workflows in a sandbox that intercepts and aborts non-deterministic calls early at runtime. +- 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. - Java: The Java SDK has no sandbox. Determinism is enforced by developer conventions — the SDK provides `Workflow.*` APIs as safe alternatives (e.g., `Workflow.sleep()` instead of `Thread.sleep()`), and non-determinism is only detected at replay time via `NonDeterministicException`. A static analysis tool (`temporal-workflowcheck`, beta) can catch violations at build time. Cooperative threading under a global lock eliminates the need for synchronization. - 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. @@ -88,6 +94,7 @@ Regardless of which SDK you are using, it is your responsibility to ensure that ## Detecting Non-Determinism ### During Execution + - `NondeterminismError` raised when Commands don't match Events - Workflow becomes blocked until code is fixed @@ -98,13 +105,17 @@ Replay tests verify that workflows follow identical code paths when re-run, by a ## Recovery from Non-Determinism ### Accidental Change + If you accidentally introduced non-determinism: + 1. Revert code to match what's in history 2. Restart worker 3. Workflow auto-recovers ### Intentional Change + If you need to change workflow logic: + 1. Use the **Patching API** to support both old and new code paths 2. Or terminate old workflows and start new ones with updated code diff --git a/references/core/dev-management.md b/references/core/dev-management.md index 01faed0..45385d3 100644 --- a/references/core/dev-management.md +++ b/references/core/dev-management.md @@ -20,7 +20,6 @@ When you need a new worker, you should start it in the background (and preferrab **Best practice**: As far as local development goes, run only ONE worker instance with the latest code. Don't keep stale workers (running old code) around. - ### Cleanup **Always kill workers when done.** Don't leave workers running. diff --git a/references/core/error-reference.md b/references/core/error-reference.md index 74570ae..29a40b7 100644 --- a/references/core/error-reference.md +++ b/references/core/error-reference.md @@ -6,14 +6,14 @@ | **Deadlock** | TMPRL1101 | `WorkflowTaskFailed` in history, worker logs | Workflow blocked too long (deadlock detected) | Remove blocking operations from workflow code (no I/O, no sleep, no threading locks). Use Temporal primitives instead. | https://github.com/temporalio/rules/blob/main/rules/TMPRL1101.md | | **Unfinished handlers** | TMPRL1102 | `WorkflowTaskFailed` in history | Workflow completed while update/signal handlers still running | Ensure all handlers complete before workflow finishes. Use `workflow.wait_condition()` to wait for handler completion. | https://github.com/temporalio/rules/blob/main/rules/TMPRL1102.md | | **Payload overflow** | TMPRL1103 | `WorkflowTaskFailed` or `ActivityTaskFailed` in history | Payload size limit exceeded (default 2MB) | Reduce payload size. Use external storage (S3, database) for large data and pass references instead. | https://github.com/temporalio/rules/blob/main/rules/TMPRL1103.md | -| **Workflow code bug** | | `WorkflowTaskFailed` in history | Bug in workflow logic | Fix code → Restart worker → Workflow auto-resumes | | -| **Missing workflow** | | Worker logs | Workflow not registered | Add to worker.py → Restart worker | | -| **Missing activity** | | Worker logs | Activity not registered | Add to worker.py → Restart worker | | -| **Activity bug** | | `ActivityTaskFailed` in history | Bug in activity code | Fix code → Restart worker → Auto-retries | | -| **Activity retries** | | `ActivityTaskFailed` (count >2) | Repeated failures | Fix code → Restart worker → Auto-retries | | -| **Sandbox violation** | | Worker logs | Bad imports in workflow | Fix workflow.py imports → Restart worker | | -| **Task queue mismatch** | | Workflow never starts | Different queues in starter/worker | Align task queue names | | -| **Timeout** | | Status = TIMED_OUT | Operation too slow | Increase timeout config | | +| **Workflow code bug** | | `WorkflowTaskFailed` in history | Bug in workflow logic | Fix code → Restart worker → Workflow auto-resumes | | +| **Missing workflow** | | Worker logs | Workflow not registered | Add to worker.py → Restart worker | | +| **Missing activity** | | Worker logs | Activity not registered | Add to worker.py → Restart worker | | +| **Activity bug** | | `ActivityTaskFailed` in history | Bug in activity code | Fix code → Restart worker → Auto-retries | | +| **Activity retries** | | `ActivityTaskFailed` (count >2) | Repeated failures | Fix code → Restart worker → Auto-retries | | +| **Sandbox violation** | | Worker logs | Bad imports in workflow | Fix workflow.py imports → Restart worker | | +| **Task queue mismatch** | | Workflow never starts | Different queues in starter/worker | Align task queue names | | +| **Timeout** | | Status = TIMED_OUT | Operation too slow | Increase timeout config | | ## Workflow Status Reference diff --git a/references/core/gotchas.md b/references/core/gotchas.md index 55b6ddb..677362f 100644 --- a/references/core/gotchas.md +++ b/references/core/gotchas.md @@ -9,6 +9,7 @@ This document provides a general overview of conceptual-level gotchas in Tempora **The Problem**: Activities may execute more than once due to retries or Worker failures. If an activity calls an external service without an idempotency key, you may charge a customer twice, send duplicate emails, or create duplicate records. **Symptoms**: + - Duplicate side effects (double charges, duplicate notifications) - Data inconsistencies after retries @@ -21,6 +22,7 @@ This document provides a general overview of conceptual-level gotchas in Tempora **The Problem**: Code in workflow functions runs on first execution AND on every replay. Any side effect (logging, notifications, metrics, etc.) will happen multiple times and non-deterministic code (IO, current time, random numbers, threading, etc.) won't replay correctly. **Symptoms**: + - Non-determinism errors - Sandbox violations, depending on SDK language - Duplicate log entries @@ -28,11 +30,12 @@ This document provides a general overview of conceptual-level gotchas in Tempora - Inflated metrics **The Fix**: + - Use Temporal replay-aware managed side effects for common, non-business logic cases: - - Temporal workflow logging - - Temporal date time - - Temporal UUID generation - - Temporal random number generation + - Temporal workflow logging + - Temporal date time + - Temporal UUID generation + - Temporal random number generation - Put all other side effects in Activities See `references/core/determinism.md` for more info. @@ -42,10 +45,12 @@ See `references/core/determinism.md` for more info. **The Problem**: If Worker A runs part of a workflow with code v1, then Worker B (with code v2) picks it up, replay may produce different Commands. **Symptoms**: + - Non-determinism errors after deploying new code - Errors mentioning "command mismatch" or "unexpected command" **The Fix**: + - Use Worker Versioning for production deployments - Use patching APIs - During development: kill old workers before starting new ones @@ -60,6 +65,7 @@ See `references/core/versioning.md` for more info. **The Problem**: Using aggressive activity retry policies that give up too easily. **Symptoms**: + - Workflows failing on transient errors - Unnecessary workflow failures during brief outages @@ -72,6 +78,7 @@ See `references/core/versioning.md` for more info. **The Problem**: Queries and update validators are read-only. Modifying state causes non-determinism on replay, and must strictly be avoided. **Symptoms**: + - State inconsistencies after workflow replay - Non-determinism errors @@ -82,6 +89,7 @@ See `references/core/versioning.md` for more info. **The Problem**: Queries and update validators must return immediately. They cannot await activities, child workflows, timers, or conditions. **Symptoms**: + - Query / update validators timeouts - Deadlocks @@ -110,6 +118,7 @@ See language-specific gotchas for details. **The Problem**: Not testing what happens when things go wrong. **Questions to answer**: + - What happens when an Activity exhausts all retries? - What happens when a workflow is cancelled mid-execution? - What happens during a Worker restart? @@ -121,6 +130,7 @@ See language-specific gotchas for details. **The Problem**: Changing workflow code without verifying existing workflows can still replay. **Symptoms**: + - Non-determinism errors after deployment - Stuck workflows that can't make progress @@ -133,6 +143,7 @@ See language-specific gotchas for details. **The Problem**: Catching errors without proper handling hides failures. **Symptoms**: + - Silent failures - Workflows completing "successfully" despite errors - Difficult debugging @@ -144,10 +155,12 @@ See language-specific gotchas for details. **The Problem**: Marking transient errors as non-retryable, or permanent errors as retryable. **Symptoms**: + - Workflows failing on temporary network issues (if marked non-retryable) - Infinite retries on invalid input (if marked retryable) **The Fix**: + - **Retryable**: Network errors, timeouts, rate limits, temporary unavailability - **Non-retryable**: Invalid input, authentication failures, business rule violations, resource not found @@ -158,6 +171,7 @@ See language-specific gotchas for details. **The Problem**: When a workflow is cancelled, cleanup code after the cancellation point doesn't run unless explicitly protected. **Symptoms**: + - Resources not released after cancellation - Incomplete compensation/rollback - Leaked state @@ -169,10 +183,12 @@ See language-specific gotchas for details. **The Problem**: Activities must opt in to receive cancellation. Without proper handling, a cancelled activity continues running to completion, wasting resources. **Requirements for activity cancellation**: + 1. **Heartbeating** - Cancellation is delivered via heartbeat. Activities that don't heartbeat won't know they've been cancelled. 2. **Checking for cancellation** - Activity must explicitly check for cancellation or await a cancellation signal. **Symptoms**: + - Cancelled activities running to completion - Wasted compute on work that will be discarded - Delayed workflow cancellation @@ -184,11 +200,13 @@ See language-specific gotchas for details. **The Problem**: Temporal has built-in limits on payload sizes. Exceeding them causes workflows to fail. **Limits**: + - Max 2MB per individual payload - Max 4MB per gRPC message -- Max 50MB for complete workflow history (aim for <10MB in practice) +- Max 50MB for complete workflow history (aim for < 10MB in practice) **Symptoms**: + - Payload too large errors - gRPC message size exceeded errors - Workflow history growing unboundedly diff --git a/references/core/patterns.md b/references/core/patterns.md index 2ab5b72..7e7c7a3 100644 --- a/references/core/patterns.md +++ b/references/core/patterns.md @@ -2,8 +2,9 @@ ## Overview -Common patterns for building robust Temporal workflows. +Common patterns for building robust Temporal workflows. See the language-specific references for the language you are working in: + - `references/{language}/{language}.md` for the root level documentation for that language - `references/{language}/patterns.md` for language-specific example code of the patterns in this file. @@ -12,18 +13,21 @@ See the language-specific references for the language you are working in: **Purpose**: Send data to a running workflow asynchronously (fire-and-forget). **When to Use**: + - Human approval workflows - Adding items to a workflow's queue - Notifying workflow of external events - Live configuration updates **Characteristics**: + - Asynchronous - sender doesn't wait for response - Can mutate workflow state - Durable - signals are persisted in history - Can be sent before workflow starts (signal-with-start) **Example Flow**: + ``` Client Workflow │ │ @@ -41,12 +45,14 @@ you want the external process to Heartbeat or receive Cancellation. If this may **Purpose**: Read workflow state synchronously without modifying it. **When to Use**: + - Building dashboards showing workflow progress - Health checks and monitoring - Debugging workflow state - Exposing current status to external systems **Characteristics**: + - Synchronous - caller waits for response - Read-only - must not modify state - Not recorded in history @@ -54,6 +60,7 @@ you want the external process to Heartbeat or receive Cancellation. If this may - Can run even on completed workflows **Example Flow**: + ``` Client Workflow │ │ @@ -67,12 +74,14 @@ Client Workflow **Purpose**: Modify workflow state and receive a response synchronously. **When to Use**: + - Operations that need confirmation (add item, return count) - Validation before accepting changes - Replace signal+query combinations - Request-response patterns within workflow **Characteristics**: + - Synchronous - caller waits for completion - Can mutate state AND return values - Supports validators to reject invalid updates before they even get persisted into history @@ -80,6 +89,7 @@ Client Workflow - Recorded in history **Example Flow**: + ``` Client Workflow │ │ @@ -91,34 +101,39 @@ Client Workflow ## Child Workflows **When to Use**: + - Prevent history from growing too large - Isolate failure domains (child can fail without failing parent) - Different retry policies for different parts **Characteristics**: + - Own history (doesn't bloat parent) - Independent lifecycle options (ParentClosePolicy) - Can be cancelled independently - Results returned to parent **Parent Close Policies**: + - `TERMINATE` - Child terminated when parent closes (default) - `ABANDON` - Child continues running independently - `REQUEST_CANCEL` - Cancellation requested but not forced -**Note:** Do not need to use child workflows simply for breaking complex logic down into smaller pieces. Standard programming abstractions within a workflow can already be used for that. +**Note:** Do not need to use child workflows simply for breaking complex logic down into smaller pieces. Standard programming abstractions within a workflow can already be used for that. ## Continue-as-New **Purpose**: Prevent unbounded history growth by "restarting" with fresh history. **When to Use**: + - Long-running workflows (entity workflows, subscriptions) - Workflows with many iterations - When history approaches 10,000+ events - Periodic cleanup of accumulated state **How It Works**: + ``` Workflow (history: 10,000 events) │ @@ -136,12 +151,14 @@ New Workflow Execution (history: 0 events) **Purpose**: Distributed transactions with compensation for failures. **When to Use**: + - Multi-step operations that span services - Operations requiring rollback on failure - Financial transactions, order processing - Booking systems with multiple reservations **How It Works**: + ``` Step 1: Reserve inventory └─ Compensation: Release inventory @@ -158,6 +175,7 @@ On failure at step 3: ``` **Implementation Pattern**: + 1. Track compensation actions as you complete each step 2. On failure, execute compensations in reverse order 3. Handle compensation failures gracefully (log, alert, manual intervention) @@ -167,12 +185,14 @@ On failure at step 3: **Purpose**: Run multiple independent operations concurrently. **When to Use**: + - Processing multiple items that don't depend on each other - Calling multiple APIs simultaneously - Fan-out/fan-in patterns - Reducing total workflow duration **Patterns**: + - `Promise` / `asyncio` - Use traditional concurrency helpers (e.g. wait for all, wait for first, etc) - Partial failure handling - Continue with successful results @@ -181,12 +201,14 @@ On failure at step 3: **Purpose**: Model long-lived entities as workflows that handle events. **When to Use**: + - Subscription management - User sessions - Shopping carts - Any stateful entity receiving events over time **How It Works**: + ``` Entity Workflow (user-123) │ @@ -207,12 +229,14 @@ Entity Workflow (user-123) **Purpose**: Durable delays that survive worker restarts. **Use Cases**: + - Scheduled reminders - Timeout handling - Delayed actions - Polling with intervals **Characteristics**: + - Timers are durable (persisted in history) - Can be cancelled @@ -256,12 +280,13 @@ To ensure that polling_activity is restarted in a timely manner, we make sure th Define an Activity which fails (raises an exception) exactly when polling is not completed. The polling loop is accomplished via activity retries, by setting the following Retry options: + - backoff_coefficient: to 1 - initial_interval: to the polling interval (e.g. 60 seconds) This will enable the Activity to be retried exactly on the set interval. -**Advantage:** Individual Activity retries are not recorded in Workflow History, so this approach can poll for a very long time without affecting the history size. +**Advantage:** Individual Activity retries are not recorded in Workflow History, so this approach can poll for a very long time without affecting the history size. ## Idempotency Patterns @@ -285,6 +310,7 @@ Activity: charge_payment(order_id, amount) ``` **Good idempotency key sources**: + - Workflow ID (unique per workflow execution) - Business identifier (order ID, transaction ID) - Workflow ID + activity name + attempt number @@ -337,13 +363,15 @@ This ensures that on replay, already-completed steps are skipped. **Purpose**: Handle data that exceeds Temporal's payload limits without polluting workflow history. **Limits** (see `references/core/gotchas.md` for details): + - Max 2MB per individual payload - Max 4MB per gRPC message -- Max 50MB for workflow history (aim for <10MB) +- Max 50MB for workflow history (aim for < 10MB) **Key Principle**: Large data should never flow through workflow history. Activities read and write large data directly, passing only small references through the workflow. **Wrong Approach**: + ``` Workflow │ @@ -357,6 +385,7 @@ Workflow This defeats the purpose—large data enters workflow history multiple times. **Correct Approach**: + ``` Workflow │ @@ -369,6 +398,7 @@ Workflow The workflow only handles references (small strings). The activity does all large data operations internally. **Implementation Pattern**: + 1. Accept a reference (URL, S3 key, database ID) as activity input 2. Download/fetch the large data inside the activity 3. Process the data inside the activity @@ -376,6 +406,7 @@ The workflow only handles references (small strings). The activity does all larg 5. Return only a reference to the result **Other Strategies**: + - **Compression**: Use a PayloadCodec to compress data automatically - **Chunking**: Split large collections across multiple activities, each handling a subset @@ -384,11 +415,13 @@ The workflow only handles references (small strings). The activity does all larg **Purpose**: Enable cancellation delivery and progress tracking for long-running activities. **Why Heartbeat**: + 1. **Support activity cancellation** - Cancellations are delivered to activities via heartbeat. Activities that don't heartbeat won't know they've been cancelled. 2. **Resume progress after failure** - Heartbeat details persist across retries, allowing activities to resume where they left off. 3. **Detect stuck activities** - If an activity stops heartbeating, Temporal can time it out and retry. **How Cancellation Works**: + ``` Workflow requests activity cancellation │ @@ -411,17 +444,20 @@ Activity calls heartbeat() **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. **When to Use**: + - Short operations completing in milliseconds/seconds - High-frequency calls where task queue overhead is significant - Low-latency requirements where you can't afford task queue round-trip **Characteristics**: + - Executes on the same worker that runs the workflow - No task queue round-trip (lower latency) - Still recorded in history - Should complete quickly (default timeout is short) **Trade-offs**: + - Less visibility in Temporal UI (no separate task) - Must complete on the same worker - Not suitable for long-running operations diff --git a/references/core/troubleshooting.md b/references/core/troubleshooting.md index 952d4e2..1df80f9 100644 --- a/references/core/troubleshooting.md +++ b/references/core/troubleshooting.md @@ -59,19 +59,15 @@ Workflow stuck in RUNNING? 1. **No worker running** - See references/core/dev-management.md - 2. **Worker on wrong task queue** - Check: Worker logs for task queue name - Fix: Start worker with matching task queue - 3. **Worker has stale code** - Check: Worker startup time vs code changes - Fix: Restart worker with updated code - 4. **Workflow waiting for signal** - Check: Workflow history for pending signals - Fix: Send expected signal or check signal sender - 5. **Activity stuck/timing out** - Check: Activity retry attempts in history - Fix: Investigate activity failure, increase timeout @@ -107,6 +103,7 @@ NondeterminismError? ### Common Causes 1. **Changed call order** + ``` # Before # After (BREAKS) await activity_a await activity_b @@ -114,28 +111,33 @@ NondeterminismError? ``` 2. **Changed call name** + ``` # Before # After (BREAKS) await process_order(...) await handle_order(...) ``` 3. **Added/removed call** + - Adding new activity mid-workflow - Removing activity that was previously called 4. **Using non-deterministic code** + - `datetime.now()` in workflow (use `workflow.now()`) - `random.random()` in workflow (use `workflow.random()`) ### Recovery **Accidental Change:** + 1. Identify the change 2. Revert code to match history 3. Restart worker 4. Workflow automatically recovers **Intentional Change:** + 1. Use patching API for gradual migration 2. Or terminate old workflows, start new ones @@ -163,11 +165,9 @@ Workflow status = FAILED? 1. **Unhandled exception in workflow** - Check error message and stack trace - Fix bug in workflow code - 2. **Activity exhausted retries** - All retry attempts failed - Check activity logs for root cause - 3. **Non-retryable error thrown** - Error marked as non-retryable - Intentional failure, check business logic @@ -236,11 +236,9 @@ Activity retrying repeatedly? 1. **Bug in activity code** - Fix the bug - Consider marking certain errors as non-retryable - 2. **External service down** - Retries are working as intended - Monitor service recovery - 3. **Invalid input** - Validate inputs before activity - Return non-retryable error for bad input diff --git a/references/core/versioning.md b/references/core/versioning.md index 226bb83..3081dcb 100644 --- a/references/core/versioning.md +++ b/references/core/versioning.md @@ -40,14 +40,17 @@ else: ### Three-Phase Lifecycle **Phase 1: Patch In** + - Add both old and new code paths - New workflows take new path, old workflows take old path **Phase 2: Deprecate** + - After all old workflows complete, remove old code - Keep deprecation marker for history compatibility **Phase 3: Remove** + - After all deprecated workflows complete - Remove patch entirely, only new code remains @@ -116,6 +119,7 @@ Worker v2.0 (Build ID: def456) **Build ID**: Specific code version (e.g., git commit hash) **Versioning Behaviors**: + - `PINNED` - Workflows stay on original worker version - `AUTO_UPGRADE` - Workflows can move to newer versions diff --git a/references/go/advanced-features.md b/references/go/advanced-features.md index 55e4e57..b64ce94 100644 --- a/references/go/advanced-features.md +++ b/references/go/advanced-features.md @@ -174,12 +174,14 @@ func FileProcessingWorkflow(ctx workflow.Context, file FileParam) error { ``` Key points: + - `workflow.ErrSessionFailed` is returned if the worker hosting the session dies - `CompleteSession` releases resources -- always call it (use `defer`) - Use case: file processing (download, process, upload on same host), GPU workloads, or any pipeline needing local state - `MaxConcurrentSessionExecutionSize` on `worker.Options` limits how many sessions a single worker can handle **Limitations:** + - Sessions do not survive worker process restarts — if the worker dies, the session fails and activities must be retried from the workflow level - There is no server-side support for sessions — the Go SDK implements them entirely client-side using internal task queue routing - Session concurrency limiting is per-process, not per-host — only one worker process per host if you rely on this diff --git a/references/go/data-handling.md b/references/go/data-handling.md index e887e7b..18ccf57 100644 --- a/references/go/data-handling.md +++ b/references/go/data-handling.md @@ -125,11 +125,13 @@ dataConverter := converter.NewCompositeDataConverter( ## Protobuf Support Binary protobuf: + ```go converter.NewProtoPayloadConverter() ``` JSON protobuf: + ```go converter.NewProtoJSONPayloadConverter() ``` diff --git a/references/go/determinism-protection.md b/references/go/determinism-protection.md index b37d94a..2cdd829 100644 --- a/references/go/determinism-protection.md +++ b/references/go/determinism-protection.md @@ -29,6 +29,7 @@ workflowcheck -show-pos ./... ### What It Detects **Non-deterministic functions/variables:** + - `time.Now` -- obtaining current time - `time.Sleep` -- sleeping - `crypto/rand.Reader` -- crypto random reader @@ -36,6 +37,7 @@ workflowcheck -show-pos ./... - `os.Stdin`, `os.Stdout`, `os.Stderr` -- standard I/O streams **Non-deterministic Go constructs:** + - Starting a goroutine (`go func()`) - Sending to a channel - Receiving from a channel @@ -45,6 +47,7 @@ workflowcheck -show-pos ./... ### Limitations `workflowcheck` cannot catch everything. It does **not** detect: + - Global variable mutation - Non-determinism via reflection - Runtime-conditional non-determinism @@ -72,6 +75,7 @@ workflowcheck -config workflowcheck.config.yaml ./... ## Determinism Rules **You must:** + - Use `workflow.Go(ctx, func(ctx workflow.Context) { ... })` instead of `go` - Use `workflow.NewChannel(ctx)` instead of `chan` - Use `workflow.NewSelector(ctx)` instead of `select` @@ -81,6 +85,7 @@ workflowcheck -config workflowcheck.config.yaml ./... - Sort map keys before iterating, or use `workflow.SideEffect` / an activity **You must not:** + - Start native goroutines - Use native channels or `select` - Call `time.Now()` or `time.Sleep()` diff --git a/references/go/go.md b/references/go/go.md index 827d35c..546e1b1 100644 --- a/references/go/go.md +++ b/references/go/go.md @@ -7,11 +7,13 @@ The Temporal Go SDK (`go.temporal.io/sdk`) provides a strongly-typed, idiomatic ## Quick Start **Add Dependency:** In your Go module, add the Temporal SDK: + ```bash go get go.temporal.io/sdk ``` **workflows/greeting.go** - Workflow definition: + ```go package workflows @@ -37,6 +39,7 @@ func GreetingWorkflow(ctx workflow.Context, name string) (string, error) { ``` **activities/greet.go** - Activity definition: + ```go package activities @@ -53,6 +56,7 @@ func (a *Activities) Greet(ctx context.Context, name string) (string, error) { ``` **worker/main.go** - Worker setup: + ```go package main @@ -90,6 +94,7 @@ func main() { **Start the worker:** Run `go run worker/main.go` in the background. **starter/main.go** - Start a workflow execution: + ```go package main @@ -136,6 +141,7 @@ func main() { ## Key Concepts ### Workflow Definition + - Exported function with `workflow.Context` as the first parameter - Returns `(ResultType, error)` or just `error` - Signature: `func MyWorkflow(ctx workflow.Context, input MyInput) (MyOutput, error)` @@ -143,12 +149,14 @@ func main() { - Register with `w.RegisterWorkflow(MyWorkflow)` ### Activity Definition + - Regular function or struct methods with `context.Context` as the first parameter - Struct methods are preferred for dependency injection - Signature: `func (a *Activities) MyActivity(ctx context.Context, input string) (string, error)` - Register struct with `w.RegisterActivity(&Activities{})` (registers all exported methods) ### Worker Setup + - Create client with `client.Dial(client.Options{})` - Create worker with `worker.New(c, "task-queue", worker.Options{})` - Register workflows and activities @@ -159,6 +167,7 @@ func main() { **Workflow code must be deterministic!** The Go SDK has no sandbox -- determinism is enforced by convention and tooling. Use Temporal replacements instead of native Go constructs: + - `workflow.Go()` instead of `go` (goroutines) - `workflow.Channel` instead of `chan` - `workflow.Selector` instead of `select` @@ -167,6 +176,7 @@ Use Temporal replacements instead of native Go constructs: - `workflow.GetLogger()` instead of `log` / `fmt.Println` for replay-safe logging Use the **`workflowcheck`** static analysis tool to catch non-deterministic code: + ```bash go install go.temporal.io/sdk/contrib/tools/workflowcheck@latest workflowcheck ./... @@ -191,6 +201,7 @@ myapp/ ``` **Activities as struct methods for dependency injection:** + ```go // activities/greet.go type Activities struct { @@ -230,6 +241,7 @@ See `references/go/testing.md` for info on writing tests. ## Additional Resources ### Reference Files + - **`references/go/patterns.md`** - Signals, queries, child workflows, saga pattern, etc. - **`references/go/determinism.md`** - Determinism rules, workflowcheck tool, safe alternatives - **`references/go/gotchas.md`** - Go-specific mistakes and anti-patterns diff --git a/references/go/gotchas.md b/references/go/gotchas.md index 4b7ddf3..6ba46ff 100644 --- a/references/go/gotchas.md +++ b/references/go/gotchas.md @@ -206,6 +206,7 @@ func GoodWorkflow(ctx workflow.Context) error { ### Not Handling Activity Cancellation Activities must **opt in** to receive cancellation. This requires: + 1. **Heartbeating** - Cancellation is delivered via heartbeat 2. **Checking ctx.Done()** - Detect when cancellation arrives diff --git a/references/go/observability.md b/references/go/observability.md index ba55140..23ad62f 100644 --- a/references/go/observability.md +++ b/references/go/observability.md @@ -28,6 +28,7 @@ func MyWorkflow(ctx workflow.Context, input string) (string, error) { ``` The workflow logger automatically: + - Suppresses duplicate logs during replay - Includes workflow context (workflow ID, run ID, etc.) @@ -45,6 +46,7 @@ func MyActivity(ctx context.Context, input string) (string, error) { ``` Activity logger includes: + - Activity ID, type, and task queue - Workflow ID and run ID - Attempt number (for retries) @@ -134,6 +136,7 @@ c, err := client.Dial(client.Options{ ``` Key SDK metrics: + - `temporal_workflow_task_execution_latency` -- Workflow task processing time - `temporal_activity_execution_latency` -- Activity execution time - `temporal_workflow_task_replay_latency` -- Replay duration diff --git a/references/go/patterns.md b/references/go/patterns.md index 732083f..298cca4 100644 --- a/references/go/patterns.md +++ b/references/go/patterns.md @@ -284,6 +284,7 @@ func ApprovalWorkflow(ctx workflow.Context) (string, error) { ``` Key points: + - `AddReceive(channel, callback)` -- fires when a channel has a message (must consume with `c.Receive`) - `AddFuture(future, callback)` -- fires when a future resolves (once per Selector) - `AddDefault(callback)` -- fires immediately if nothing else is ready @@ -457,10 +458,12 @@ func MyWorkflow(ctx workflow.Context) (string, error) { ## 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 diff --git a/references/go/versioning.md b/references/go/versioning.md index b6b6c27..c8f7280 100644 --- a/references/go/versioning.md +++ b/references/go/versioning.md @@ -45,6 +45,7 @@ err = workflow.ExecuteActivity(ctx, ActivityC, data).Get(ctx, &result1) ``` Keep the `GetVersion` call even with a single branch. This ensures: + 1. If an older execution replays on this code, it fails fast instead of proceeding incorrectly 2. If you need further changes, you just bump `maxSupported` @@ -139,6 +140,7 @@ w := worker.New(c, "my-task-queue", worker.Options{ ``` **Configuration fields:** + - `UseVersioning`: enables Worker Versioning - `Version`: identifies the Worker Deployment Version (deployment name + build ID) - `DefaultVersioningBehavior`: `VersioningBehaviorPinned` or `VersioningBehaviorAutoUpgrade` @@ -151,6 +153,7 @@ w := worker.New(c, "my-task-queue", worker.Options{ Workflows stay locked to their original Worker version. **When to use PINNED:** + - Short-running workflows (minutes to hours) - Consistency is critical (e.g., financial transactions) - You want to eliminate version compatibility complexity @@ -161,6 +164,7 @@ Workflows stay locked to their original Worker version. Workflows can move to newer versions. **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 @@ -189,6 +193,7 @@ w := worker.New(c, "orders-task-queue", worker.Options{ **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 @@ -197,6 +202,7 @@ Maintain two environments and switch traffic between them: **Rainbow Deployments** Multiple versions run simultaneously: + - New workflows use latest version - Existing workflows complete on their original version - Add new versions alongside existing ones diff --git a/references/java/gotchas.md b/references/java/gotchas.md index 567fb64..4943f0d 100644 --- a/references/java/gotchas.md +++ b/references/java/gotchas.md @@ -7,6 +7,7 @@ Java-specific mistakes and anti-patterns. See also [Common Gotchas](../core/gotc **Critical: The Java SDK has NO sandbox.** Unlike Python (which uses a sandbox) or TypeScript (which uses V8 isolation), the Java SDK relies entirely on developer conventions. Non-deterministic calls silently succeed during initial execution but cause `NonDeterministicException` on replay. Forbidden in workflow code — use the Temporal `Workflow.*` equivalents instead: + - `Thread.sleep` → `Workflow.sleep` - `UUID.randomUUID` → `Workflow.randomUUID` - `Math.random` → `Workflow.newRandom` @@ -103,6 +104,7 @@ public class GoodWorkflow implements MyWorkflow { ### Not Handling Activity Cancellation Activities must **opt in** to receive cancellation. This requires: + 1. **Heartbeating** - Cancellation is delivered via heartbeat 2. **Catching CanceledFailure** - Thrown when heartbeat detects cancellation diff --git a/references/java/java.md b/references/java/java.md index e18d723..b260424 100644 --- a/references/java/java.md +++ b/references/java/java.md @@ -9,11 +9,13 @@ The Temporal Java SDK (`io.temporal:temporal-sdk`) uses an interface + implement **Add Dependencies:** Gradle: + ```groovy implementation 'io.temporal:temporal-sdk:1.+' ``` Maven: + ```xml io.temporal @@ -23,6 +25,7 @@ Maven: ``` **GreetActivities.java** - Activity interface: + ```java package greetingapp; @@ -38,6 +41,7 @@ public interface GreetActivities { ``` **GreetActivitiesImpl.java** - Activity implementation: + ```java package greetingapp; @@ -51,6 +55,7 @@ public class GreetActivitiesImpl implements GreetActivities { ``` **GreetingWorkflow.java** - Workflow interface: + ```java package greetingapp; @@ -66,6 +71,7 @@ public interface GreetingWorkflow { ``` **GreetingWorkflowImpl.java** - Workflow implementation: + ```java package greetingapp; @@ -91,6 +97,7 @@ public class GreetingWorkflowImpl implements GreetingWorkflow { ``` **GreetingWorker.java** - Worker setup: + ```java package greetingapp; @@ -127,6 +134,7 @@ public class GreetingWorker { **Start the worker:** Run `GreetingWorker.main()` (e.g., `./gradlew run` or `mvn compile exec:java -Dexec.mainClass="greetingapp.GreetingWorker"`). **Starter.java** - Start a workflow execution: + ```java package greetingapp; @@ -161,6 +169,7 @@ public class Starter { ## Key Concepts ### Workflow Definition + - Annotate interface with `@WorkflowInterface` - Put any state initialization logic in the workflow constructor to guarantee that it happens before signals/updates arrive. If your state initialization logic requires the workflow parameters, then add the `@WorkflowInit` decorator and parameters to your constructor. - Annotate entry point method with `@WorkflowMethod` (exactly one per interface) @@ -170,12 +179,14 @@ public class Starter { - Implementation class implements the interface ### Activity Definition + - Annotate interface with `@ActivityInterface` - Optionally annotate methods with `@ActivityMethod` (for custom names) - Implementation class can throw any exception - Call from workflow via `Workflow.newActivityStub()` ### Worker Setup + - `WorkflowServiceStubs` -- gRPC connection to Temporal Server - `WorkflowClient` -- client used by worker to communicate with server - `WorkerFactory` -- creates Worker instances @@ -201,6 +212,7 @@ greetingapp/ The Java SDK has **no sandbox**. The developer is fully responsible for writing deterministic workflow code. All non-deterministic operations must happen in Activities. **Do not use in workflow code:** + - `Thread` / `new Thread()` -- use `Workflow.newTimer()` or `Async.function()` - `synchronized` / `Lock` -- workflow code is single-threaded - `UUID.randomUUID()` -- use `Workflow.randomUUID()` @@ -210,7 +222,8 @@ The Java SDK has **no sandbox**. The developer is fully responsible for writing - `Thread.sleep()` -- use `Workflow.sleep()` - Mutable static fields -- workflow instances must not share state -**Use Workflow.* APIs instead:** +**Use `Workflow.*` APIs instead:** + - `Workflow.sleep()` for timers - `Workflow.currentTimeMillis()` for current time - `Workflow.randomUUID()` for UUIDs @@ -238,6 +251,7 @@ See `references/java/testing.md` for info on writing tests. ## Additional Resources ### Reference Files + - **`references/java/patterns.md`** - Signals, queries, child workflows, saga pattern, etc. - **`references/java/determinism.md`** - Determinism rules and safe alternatives for Java - **`references/java/gotchas.md`** - Java-specific mistakes and anti-patterns diff --git a/references/java/observability.md b/references/java/observability.md index d7d9528..338fcb7 100644 --- a/references/java/observability.md +++ b/references/java/observability.md @@ -31,6 +31,7 @@ public class OrderWorkflowImpl implements OrderWorkflow { ``` The workflow logger automatically: + - Suppresses duplicate logs during replay - Includes workflow context (workflow ID, run ID, etc.) - Uses SLF4J under the hood diff --git a/references/java/patterns.md b/references/java/patterns.md index ed2fb37..e6428a9 100644 --- a/references/java/patterns.md +++ b/references/java/patterns.md @@ -423,10 +423,12 @@ public class MyWorkflowImpl implements MyWorkflow { ## 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 diff --git a/references/java/versioning.md b/references/java/versioning.md index d1a9205..0e520f2 100644 --- a/references/java/versioning.md +++ b/references/java/versioning.md @@ -38,6 +38,7 @@ public class ShippingWorkflowImpl implements ShippingWorkflow { ``` **How it works:** + - For new executions: returns `maxSupported` and records a marker in history - For replay with the marker: returns the recorded version - For replay without the marker: returns `DEFAULT_VERSION` (-1) diff --git a/references/python/advanced-features.md b/references/python/advanced-features.md index e0d3297..3584a64 100644 --- a/references/python/advanced-features.md +++ b/references/python/advanced-features.md @@ -85,6 +85,7 @@ The Python SDK runs workflows in a sandbox to help you ensure determinism. You c **The Python SDK is NOT compatible with gevent.** Gevent's monkey patching modifies Python's asyncio event loop in ways that break the SDK's deterministic execution model. If your application uses gevent: + - You cannot run Temporal workers in the same process - Consider running workers in a separate process without gevent - Use a message queue or HTTP API to communicate between gevent and Temporal processes @@ -163,4 +164,3 @@ worker = Worker( workflow_failure_exception_types=[ValueError, CustomBusinessError], ) ``` - diff --git a/references/python/data-handling.md b/references/python/data-handling.md index 662101e..65f4a99 100644 --- a/references/python/data-handling.md +++ b/references/python/data-handling.md @@ -7,6 +7,7 @@ The Python SDK uses data converters to serialize/deserialize workflow inputs, ou ## Default Data Converter The default converter handles: + - `None` - `bytes` (as binary) - Protobuf messages @@ -59,6 +60,7 @@ client = await Client.connect( ## Custom Data Conversion Usually the easiest way to do this is via implementing an EncodingPayloadConverter and CompositePayloadConverter. See: + - https://raw.githubusercontent.com/temporalio/samples-python/refs/heads/main/custom_converter/shared.py - https://raw.githubusercontent.com/temporalio/samples-python/refs/heads/main/custom_converter/starter.py diff --git a/references/python/determinism-protection.md b/references/python/determinism-protection.md index 1376ced..3ff9543 100644 --- a/references/python/determinism-protection.md +++ b/references/python/determinism-protection.md @@ -7,6 +7,7 @@ The Python SDK runs workflows in a sandbox that provides automatic protection ag ## How the Sandbox Works The sandbox: + - Isolates global state via `exec` compilation - Restricts non-deterministic library calls via proxy objects - Passes through standard library with restrictions @@ -35,6 +36,7 @@ with workflow.unsafe.imports_passed_through(): ``` **When to use pass-through:** + - Data classes and models (Pydantic, dataclasses) - Serialization libraries - Type definitions diff --git a/references/python/determinism.md b/references/python/determinism.md index e925f7c..e1b53a7 100644 --- a/references/python/determinism.md +++ b/references/python/determinism.md @@ -34,6 +34,7 @@ Use the `Replayer` class to verify your code changes are compatible with existin ## Sandbox Behavior The sandbox: + - Isolates global state via `exec` compilation - Restricts non-deterministic library calls via proxy objects - Passes through standard library with restrictions diff --git a/references/python/gotchas.md b/references/python/gotchas.md index 95ebe8a..a32b045 100644 --- a/references/python/gotchas.md +++ b/references/python/gotchas.md @@ -211,10 +211,12 @@ class GoodWorkflow: ### Not Handling Activity Cancellation Activities must **opt in** to receive cancellation. This requires: + 1. **Heartbeating** - Cancellation is delivered via heartbeat 2. **Catching the cancellation exception** - Exception is raised when heartbeat detects cancellation **Cancellation exceptions:** + - Async activities: `asyncio.CancelledError` - Sync threaded activities: `temporalio.exceptions.CancelledError` diff --git a/references/python/observability.md b/references/python/observability.md index 26296c3..0130d89 100644 --- a/references/python/observability.md +++ b/references/python/observability.md @@ -27,6 +27,7 @@ class MyWorkflow: ``` The workflow logger automatically: + - Suppresses duplicate logs during replay - Includes workflow context (workflow ID, run ID, etc.) @@ -46,6 +47,7 @@ async def process_order(order_id: str) -> str: ``` Activity logger includes: + - Activity ID, type, and task queue - Workflow ID and run ID - Attempt number (for retries) @@ -92,7 +94,6 @@ Runtime.set_default(runtime, error_if_already_set=True) - `temporal_activity_execution_latency` - Activity execution time - `temporal_workflow_task_replay_latency` - Replay duration - ## Search Attributes (Visibility) See the Search Attributes section of `references/python/data-handling.md` diff --git a/references/python/patterns.md b/references/python/patterns.md index 6843985..ae70757 100644 --- a/references/python/patterns.md +++ b/references/python/patterns.md @@ -321,14 +321,17 @@ class MyWorkflow: ## 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 **Cancellation exceptions:** + - Async activities: `asyncio.CancelledError` - Sync threaded activities: `temporalio.exceptions.CancelledError` ### WHEN: + - **Cancellable activities** - Any activity that should respond to cancellation - **Long-running activities** - Track progress for resumability - **Checkpointing** - Save progress periodically diff --git a/references/python/python.md b/references/python/python.md index 2c56843..bc0a0f3 100644 --- a/references/python/python.md +++ b/references/python/python.md @@ -9,6 +9,7 @@ The Temporal Python SDK (`temporalio`) provides a fully async, type-safe approac **Add Dependency on Temporal:** In the package management system of the Python project you are working on, add a dependency on `temporalio`. **activities/greet.py** - Activity definitions (separate file for performance): + ```python from temporalio import activity @@ -18,6 +19,7 @@ def greet(name: str) -> str: ``` **workflows/greeting.py** - Workflow definition (import activities through sandbox): + ```python from datetime import timedelta from temporalio import workflow @@ -35,6 +37,7 @@ class GreetingWorkflow: ``` **worker.py** - Worker setup (imports activity and workflow, runs indefinitely and processes tasks): + ```python import asyncio import concurrent.futures @@ -70,6 +73,7 @@ if __name__ == "__main__": **Start the worker:** Start `python worker.py` in the background (appropriately adjust command for your project, like `uv run python worker.py`) **starter.py** - Start a workflow execution: + ```python import asyncio from temporalio.client import Client @@ -93,10 +97,10 @@ if __name__ == "__main__": **Run the workflow:** Run `python starter.py` (or uv run, etc.). Should output: `Result: Hello, my-name!`. - ## Key Concepts ### Workflow Definition + - Use `@workflow.defn` decorator on class - Put any state initialization logic in the `__init__` 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 `@workflow.init` decorator and parameters to your `__init__`. - Use `@workflow.run` on the entry point method @@ -104,6 +108,7 @@ if __name__ == "__main__": - Use `@workflow.signal`, `@workflow.query`, `@workflow.update` for handlers ### Activity Definition + - Use `@activity.defn` decorator - Can be sync or async functions - **Default to sync activities** - safer and easier to debug @@ -113,6 +118,7 @@ if __name__ == "__main__": See `sync-vs-async.md` for detailed guidance on choosing between sync and async. ### Worker Setup + - Connect client, create Worker with workflows and activities - Run the worker - Activities can specify custom executor @@ -136,6 +142,7 @@ my_temporal_app/ ``` **In the Workflow file, import Activities through the sandbox:** + ```python # workflows/greeting.py from temporalio import workflow @@ -162,6 +169,7 @@ See `references/python/testing.md` for info on writing tests. ## Additional Resources ### Reference Files + - **`references/python/patterns.md`** - Signals, queries, child workflows, saga pattern, etc. - **`references/python/determinism.md`** - Sandbox behavior, safe alternatives, pass-through pattern, history replay - **`references/python/gotchas.md`** - Python-specific mistakes and anti-patterns diff --git a/references/python/sync-vs-async.md b/references/python/sync-vs-async.md index 7875582..247b0e5 100644 --- a/references/python/sync-vs-async.md +++ b/references/python/sync-vs-async.md @@ -19,6 +19,7 @@ Activities should be synchronous by default. Use async only when certain the cod The Python async event loop runs in a single thread. When any task runs, no other tasks can execute until an `await` is reached. If code makes a blocking call (file I/O, synchronous HTTP, etc.), the entire event loop freezes. **Consequences of blocking the event loop:** + - Worker cannot communicate with Temporal Server - Workflow progress blocks across the worker - Potential deadlocks and unpredictable behavior @@ -73,6 +74,7 @@ async def my_async_activity(name: str) -> str: | `httpx` | Both | Yes (use async mode) | **Example: Wrong way (blocks event loop)** + ```python @activity.defn async def bad_activity(url: str) -> str: @@ -82,6 +84,7 @@ async def bad_activity(url: str) -> str: ``` **Example: Correct way (async-safe)** + ```python @activity.defn async def good_activity(url: str) -> str: @@ -150,6 +153,7 @@ For CPU-bound work and multi-core usage: ### Separate Workers for Workflows vs Activities Some teams deploy: + - Workflow-only workers (CPU-bound, need deadlock detection) - Activity-only workers (I/O-bound, may need more parallelism) diff --git a/references/python/testing.md b/references/python/testing.md index e4a7823..71a47b1 100644 --- a/references/python/testing.md +++ b/references/python/testing.md @@ -140,7 +140,6 @@ async def test_replay(): ) ``` - ## Activity Testing ```python diff --git a/references/python/versioning.md b/references/python/versioning.md index abd4445..1daab78 100644 --- a/references/python/versioning.md +++ b/references/python/versioning.md @@ -30,6 +30,7 @@ class ShippingWorkflow: ``` **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) @@ -213,6 +214,7 @@ worker = Worker( ``` **Configuration parameters:** + - `use_worker_versioning`: Enables Worker Versioning - `version`: Identifies the Worker Deployment Version (deployment name + build ID) - Build ID: Typically a git commit hash, version number, or timestamp @@ -238,6 +240,7 @@ class StableWorkflow: ``` **When to use PINNED:** + - Short-running workflows (minutes to hours) - Consistency is critical (e.g., financial transactions) - You want to eliminate version compatibility complexity @@ -248,6 +251,7 @@ class StableWorkflow: Workflows can move to newer versions: **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 @@ -280,6 +284,7 @@ worker = Worker( **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 @@ -288,6 +293,7 @@ Maintain two environments and switch traffic between them: **Rainbow Deployments** Multiple versions run simultaneously: + - New workflows use latest version - Existing workflows complete on their original version - Add new versions alongside existing ones diff --git a/references/typescript/advanced-features.md b/references/typescript/advanced-features.md index 17b7e61..ed9817d 100644 --- a/references/typescript/advanced-features.md +++ b/references/typescript/advanced-features.md @@ -39,6 +39,7 @@ await handle.delete(); Complete an activity asynchronously from outside the activity function. Useful when the activity needs to wait for an external event. **In the activity - return the task token:** + ```typescript import { CompleteAsyncError, activityInfo } from '@temporalio/activity'; @@ -50,6 +51,7 @@ export async function doSomethingAsync(): Promise { ``` **External completion (from another process, machine, etc.):** + ```typescript import { Client } from '@temporalio/client'; @@ -61,6 +63,7 @@ async function doSomeWork(taskToken: Uint8Array): Promise { ``` **When to use:** + - Waiting for human approval - Waiting for external webhook callback - Long-polling external systems @@ -93,6 +96,7 @@ const worker = await Worker.create({ ``` **Key settings:** + - `maxConcurrentWorkflowTaskExecutions`: Max workflows running simultaneously (default: 40) - `maxConcurrentActivityTaskExecutions`: Max activities running simultaneously (default: 100) - `shutdownGraceTime`: Time to wait for in-progress work before forced shutdown diff --git a/references/typescript/data-handling.md b/references/typescript/data-handling.md index bfd4925..c8be6f8 100644 --- a/references/typescript/data-handling.md +++ b/references/typescript/data-handling.md @@ -7,6 +7,7 @@ The TypeScript SDK uses data converters to serialize/deserialize workflow inputs ## Default Data Converter The default converter handles: + - `undefined` and `null` - `Uint8Array` (as binary) - JSON-serializable types diff --git a/references/typescript/determinism-protection.md b/references/typescript/determinism-protection.md index 54303ba..81c513a 100644 --- a/references/typescript/determinism-protection.md +++ b/references/typescript/determinism-protection.md @@ -29,7 +29,6 @@ const worker = await Worker.create({ Use this with *extreme caution*. - ## Function Replacement Functions like `Math.random()`, `Date`, and `setTimeout()` are replaced by deterministic versions. diff --git a/references/typescript/gotchas.md b/references/typescript/gotchas.md index d234f74..61763b3 100644 --- a/references/typescript/gotchas.md +++ b/references/typescript/gotchas.md @@ -145,6 +145,7 @@ export async function workflowWithCleanup(): Promise { ### Not Handling Activity Cancellation Activities must **opt in** to receive cancellation. This requires: + 1. **Heartbeating** - Cancellation is delivered via heartbeat 2. **Checking for cancellation** - Either await `Context.current().cancelled` or use `cancellationSignal()` diff --git a/references/typescript/patterns.md b/references/typescript/patterns.md index 3d59e23..6dc2b32 100644 --- a/references/typescript/patterns.md +++ b/references/typescript/patterns.md @@ -289,6 +289,7 @@ export async function scopedWorkflow(): Promise { **WHY**: Triggers provide a one-shot promise that resolves when a signal is received. Cleaner than condition() for single-value signals. **WHEN to use**: + - Waiting for a single response (approval, completion notification) - Converting signal-based events into awaitable promises @@ -351,10 +352,12 @@ export async function handlerAwareWorkflow(): Promise { ## 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 diff --git a/references/typescript/typescript.md b/references/typescript/typescript.md index 9918ee7..9e125cb 100644 --- a/references/typescript/typescript.md +++ b/references/typescript/typescript.md @@ -13,13 +13,15 @@ Temporal workflows are durable through history replay. For details on how this w ## Quick Start **Add Dependencies:** Install the Temporal SDK packages (use the package manager appropriate for your project): + ```bash npm install @temporalio/client @temporalio/worker @temporalio/workflow @temporalio/activity ``` -Note: if you are working in production, it is strongly advised to use ~ version constraints, i.e. `npm install ... --save-prefix='~'` if using NPM. +Note: if you are working in production, it is strongly advised to use ~ version constraints, i.e. `npm install ... --save-prefix='~'` if using NPM. **activities.ts** - Activity definitions (separate file to distinguish workflow vs activity code): + ```typescript export async function greet(name: string): Promise { return `Hello, ${name}!`; @@ -27,6 +29,7 @@ export async function greet(name: string): Promise { ``` **workflows.ts** - Workflow definition (use type-only imports for activities): + ```typescript import { proxyActivities } from '@temporalio/workflow'; import type * as activities from './activities'; @@ -41,6 +44,7 @@ export async function greetingWorkflow(name: string): Promise { ``` **worker.ts** - Worker setup (imports activities and workflows, runs indefinitely): + ```typescript import { Worker } from '@temporalio/worker'; import * as activities from './activities'; @@ -62,6 +66,7 @@ run().catch(console.error); **Start the worker:** Run `npx ts-node worker.ts` in the background. **client.ts** - Start a workflow execution: + ```typescript import { Client } from '@temporalio/client'; import { greetingWorkflow } from './workflows'; @@ -87,16 +92,19 @@ run().catch(console.error); ## Key Concepts ### Workflow Definition + - Async functions exported from workflow file - Use `proxyActivities()` with type-only imports - Use `defineSignal()`, `defineQuery()`, `defineUpdate()`, `setHandler()` for handlers ### Activity Definition + - Regular async functions - Can perform I/O, network calls, etc. - Use `heartbeat()` for long operations ### Worker Setup + - Use `Worker.create()` with `workflowsPath` (dev) or `workflowBundle` (production) - see `references/typescript/gotchas.md` - Import activities directly (not via proxy) @@ -115,6 +123,7 @@ my_temporal_app/ ``` **In the Workflow file, use type-only imports for activities:** + ```typescript // workflows/greeting.ts import { proxyActivities } from '@temporalio/workflow'; @@ -130,11 +139,13 @@ const { translate } = proxyActivities({ The TypeScript SDK runs workflows in an isolated V8 sandbox. **Automatic replacements:** + - `Math.random()` → deterministic seeded PRNG - `Date.now()` → workflow start time - `setTimeout` → deterministic timer **Safe to use:** + - `sleep()` from `@temporalio/workflow` - `condition()` for waiting - Standard JavaScript operations @@ -160,6 +171,7 @@ See `references/typescript/testing.md` for info on writing tests. ## Additional Resources ### Reference Files + - **`references/typescript/patterns.md`** - Signals, queries, child workflows, saga pattern, etc. - **`references/typescript/determinism.md`** - Essentials of determinism in TypeScript - **`references/typescript/gotchas.md`** - TypeScript-specific mistakes and anti-patterns diff --git a/references/typescript/versioning.md b/references/typescript/versioning.md index a9f57a2..b4b8e19 100644 --- a/references/typescript/versioning.md +++ b/references/typescript/versioning.md @@ -25,6 +25,7 @@ export async function myWorkflow(): Promise { ``` **How it works:** + - If the Workflow is running for the first time, `patched()` returns `true` and inserts a marker into the Event History - During replay, if the history contains a marker with the same `patchId`, `patched()` returns `true` - During replay, if no matching marker exists, `patched()` returns `false` @@ -175,6 +176,7 @@ const worker = await Worker.create({ ``` **Configuration options:** + - `useWorkerVersioning`: Enables Worker Versioning - `version.deploymentName`: Logical name for your service (consistent across versions) - `version.buildId`: Unique identifier for this build @@ -195,6 +197,7 @@ const worker = await Worker.create({ ### When to Use Worker Versioning Worker Versioning is best suited for: + - **Short-running Workflows**: Old Workers only need to run briefly during deployment transitions - **Frequent deployments**: Eliminates the need for code-level patching on every change - **Blue-green deployments**: Run old and new versions simultaneously with traffic control From 97155470117f08fa921836fe861258dc6437d82f Mon Sep 17 00:00:00 2001 From: Donald Pinckney Date: Fri, 17 Apr 2026 10:12:07 -0400 Subject: [PATCH 21/82] Upstreaming https://github.com/temporalio/codex-temporal-plugin/pull/6/changes (#80) --- SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SKILL.md b/SKILL.md index 0ed18f0..5d2a338 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", "Temporal Java", "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: Develop, debug, and manage Temporal applications across Python, TypeScript, Go, and Java. Use when the user is building workflows, activities, or workers with a Temporal SDK, debugging issues like non-determinism errors, stuck workflows, or activity retries, using Temporal CLI, Temporal Server, or Temporal Cloud, or working with durable execution concepts like signals, queries, heartbeats, versioning, continue-as-new, child workflows, or saga patterns. version: 0.2.0 --- From f4926d58f6285ec2ce3c26632b84f34f26450f43 Mon Sep 17 00:00:00 2001 From: Donald Pinckney Date: Fri, 17 Apr 2026 10:12:21 -0400 Subject: [PATCH 22/82] Add skill to plugin syncing workflow (#78) * Add skill to plugin syncing workflow * Fix Semgrep report --- .github/workflows/sync-skill-to-plugins.yml | 106 ++++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 .github/workflows/sync-skill-to-plugins.yml diff --git a/.github/workflows/sync-skill-to-plugins.yml b/.github/workflows/sync-skill-to-plugins.yml new file mode 100644 index 0000000..d5ca8a7 --- /dev/null +++ b/.github/workflows/sync-skill-to-plugins.yml @@ -0,0 +1,106 @@ +# ABOUTME: GitHub Actions workflow that syncs skill contents to the cursor and codex plugin repos. +# ABOUTME: Triggers when a new release is created (by the package-skill workflow) or manually. +# ABOUTME: Creates or updates a PR in each target repo rather than pushing directly to main. +# ABOUTME: Uses a GitHub App for cross-repo authentication. Required secrets: +# ABOUTME: SKILL_T_DEV_APP_ID — the GitHub App's ID +# ABOUTME: SKILL_T_DEV_KEY — the GitHub App's private key +# ABOUTME: The app must be installed on all three repos with Contents (write) and +# ABOUTME: Pull Requests (write) permissions. + +name: Sync Skill to Plugin Repos + +on: + release: + types: [published] + workflow_dispatch: + +permissions: + contents: read + +jobs: + sync: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - repo: temporalio/cursor-temporal-plugin + target_path: skills/temporal-developer + - repo: temporalio/codex-temporal-plugin + target_path: plugins/temporal-developer/skills/temporal-developer + - repo: temporalio/claude-temporal-plugin + target_path: skills/temporal-developer + + steps: + - name: Generate token from GitHub App + id: app-token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.SKILL_T_DEV_APP_ID }} + private-key: ${{ secrets.SKILL_T_DEV_KEY }} + owner: ${{ github.repository_owner }} + + - name: Checkout source + uses: actions/checkout@v4 + + - name: Checkout target repo + uses: actions/checkout@v4 + with: + repository: ${{ matrix.repo }} + token: ${{ steps.app-token.outputs.token }} + path: target-repo + + - name: Sync skill contents + working-directory: target-repo + run: | + BRANCH="sync/temporal-developer-skill" + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + # Create or reset the sync branch based on current main. + # -B ensures the branch always starts from main's tip, even if a + # stale remote branch exists from a previously merged PR. + git checkout -B "$BRANCH" origin/main + + # Remove old contents and copy current + rm -rf "${{ matrix.target_path }}/SKILL.md" \ + "${{ matrix.target_path }}/references" + cp ../SKILL.md "${{ matrix.target_path }}/" + cp -r ../references "${{ matrix.target_path }}/" + + # Check for changes against main + git add "${{ matrix.target_path }}" + if git diff --cached --quiet; then + echo "no_changes=true" >> "$GITHUB_ENV" + echo "No changes to sync" + else + echo "no_changes=false" >> "$GITHUB_ENV" + version="${{ github.event.release.tag_name || 'manual' }}" + git commit -m "sync temporal-developer skill ${version} from source repo" + git push --force origin "$BRANCH" + fi + + - name: Create or update PR + if: env.no_changes == 'false' + working-directory: target-repo + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + BRANCH="sync/temporal-developer-skill" + version="${{ github.event.release.tag_name || 'manual' }}" + + # Check if a PR already exists from this branch + existing_pr=$(gh pr list --head "$BRANCH" --state open --json number --jq '.[0].number') + + if [ -n "$existing_pr" ]; then + echo "PR #${existing_pr} already exists — updated by the force-push" + gh pr comment "$existing_pr" --body "Updated to ${version} from [skill-temporal-developer](https://github.com/${{ github.repository }})." + else + body="Automated sync of the temporal-developer skill ${version} from [skill-temporal-developer](https://github.com/${{ github.repository }}). + + This PR was created automatically by the [sync workflow](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }})." + + gh pr create \ + --title "Sync temporal-developer skill ${version}" \ + --body "$body" + fi From c92f5ecae73527f10e0989381be1dd45bf394b59 Mon Sep 17 00:00:00 2001 From: Donald Pinckney Date: Fri, 17 Apr 2026 10:25:14 -0400 Subject: [PATCH 23/82] Update to latest versions of actions, to address deprecation warnings (#82) --- .github/workflows/package-skill.yml | 6 +++--- .github/workflows/sync-skill-to-plugins.yml | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/package-skill.yml b/.github/workflows/package-skill.yml index 69c48f5..637deb2 100644 --- a/.github/workflows/package-skill.yml +++ b/.github/workflows/package-skill.yml @@ -16,7 +16,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 0 @@ -44,14 +44,14 @@ jobs: -x '*.DS_Store' - name: Upload artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: temporal-developer-skill path: temporal-developer-skill.zip - name: Create release if: steps.tag_check.outputs.exists == 'false' - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@v3 with: tag_name: ${{ steps.version.outputs.tag }} name: ${{ steps.version.outputs.tag }} diff --git a/.github/workflows/sync-skill-to-plugins.yml b/.github/workflows/sync-skill-to-plugins.yml index d5ca8a7..1610430 100644 --- a/.github/workflows/sync-skill-to-plugins.yml +++ b/.github/workflows/sync-skill-to-plugins.yml @@ -34,17 +34,17 @@ jobs: steps: - name: Generate token from GitHub App id: app-token - uses: actions/create-github-app-token@v2 + uses: actions/create-github-app-token@v3 with: app-id: ${{ secrets.SKILL_T_DEV_APP_ID }} private-key: ${{ secrets.SKILL_T_DEV_KEY }} owner: ${{ github.repository_owner }} - name: Checkout source - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Checkout target repo - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: repository: ${{ matrix.repo }} token: ${{ steps.app-token.outputs.token }} From 418cbd7861b0fceaea517656675882735d1fea1f Mon Sep 17 00:00:00 2001 From: Donald Pinckney Date: Fri, 17 Apr 2026 10:30:42 -0400 Subject: [PATCH 24/82] Improved Syncing UX: Changelogs + Step Summaries (#83) * Add changelog to syncing PRs. * Add step summary to syncing job * Also *edit* PR bodies with changelogs --- .github/workflows/sync-skill-to-plugins.yml | 48 ++++++++++++++++++--- 1 file changed, 43 insertions(+), 5 deletions(-) diff --git a/.github/workflows/sync-skill-to-plugins.yml b/.github/workflows/sync-skill-to-plugins.yml index 1610430..2fb8aac 100644 --- a/.github/workflows/sync-skill-to-plugins.yml +++ b/.github/workflows/sync-skill-to-plugins.yml @@ -42,6 +42,8 @@ jobs: - name: Checkout source uses: actions/checkout@v6 + with: + fetch-depth: 0 - name: Checkout target repo uses: actions/checkout@v6 @@ -80,6 +82,27 @@ jobs: git push --force origin "$BRANCH" fi + - name: Build changelog + id: changelog + run: | + if [ "${{ github.event_name }}" = "release" ]; then + # Use the release body (auto-generated notes from package-skill) + changelog=$(cat <<'RELEASE_BODY' + ${{ github.event.release.body }} + RELEASE_BODY + ) + else + # Manual trigger: generate from git log since the previous tag + prev_tag=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "") + if [ -n "$prev_tag" ]; then + changelog=$(git log --oneline "${prev_tag}..HEAD") + else + changelog=$(git log --oneline -20) + fi + fi + # Write to a file to avoid shell quoting issues + echo "$changelog" > /tmp/changelog.md + - name: Create or update PR if: env.no_changes == 'false' working-directory: target-repo @@ -88,19 +111,34 @@ jobs: run: | BRANCH="sync/temporal-developer-skill" version="${{ github.event.release.tag_name || 'manual' }}" + changelog=$(cat /tmp/changelog.md) # Check if a PR already exists from this branch existing_pr=$(gh pr list --head "$BRANCH" --state open --json number --jq '.[0].number') if [ -n "$existing_pr" ]; then echo "PR #${existing_pr} already exists — updated by the force-push" + gh pr edit "$existing_pr" \ + --title "Sync temporal-developer skill ${version}" \ + --body "Automated sync of the temporal-developer skill ${version} from [skill-temporal-developer](https://github.com/${{ github.repository }}). + + This PR was updated automatically by the [sync workflow](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}). + + ## Changelog + ${changelog}" gh pr comment "$existing_pr" --body "Updated to ${version} from [skill-temporal-developer](https://github.com/${{ github.repository }})." + pr_url=$(gh pr view "$existing_pr" --json url --jq '.url') + echo "### ${{ matrix.repo }}" >> "$GITHUB_STEP_SUMMARY" + echo "Updated [PR #${existing_pr}](${pr_url})" >> "$GITHUB_STEP_SUMMARY" else - body="Automated sync of the temporal-developer skill ${version} from [skill-temporal-developer](https://github.com/${{ github.repository }}). + pr_url=$(gh pr create \ + --title "Sync temporal-developer skill ${version}" \ + --body "Automated sync of the temporal-developer skill ${version} from [skill-temporal-developer](https://github.com/${{ github.repository }}). - This PR was created automatically by the [sync workflow](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }})." + This PR was created automatically by the [sync workflow](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}). - gh pr create \ - --title "Sync temporal-developer skill ${version}" \ - --body "$body" + ## Changelog + ${changelog}") + echo "### ${{ matrix.repo }}" >> "$GITHUB_STEP_SUMMARY" + echo "Created ${pr_url}" >> "$GITHUB_STEP_SUMMARY" fi From 44eba4e91c986e53b2f322d462037756c77962b7 Mon Sep 17 00:00:00 2001 From: Donald Pinckney Date: Fri, 17 Apr 2026 14:28:02 -0400 Subject: [PATCH 25/82] Add .NET SDK support to temporal-developer skill (#39) * 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) * 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) * 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) * 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) * Edits to advanced features * edits to determinism protection, and move the .editorconfig section * missed one * edit determinism.md * edit error-handling.md * edit gotchas.md * edit patterns.md * edit versioning.md * edit observability.md * fix metrics * self-review round 1 * minor correctness fixed * Update references/dotnet/patterns.md Co-authored-by: Justin Anderson <44687433+jmaeagle99@users.noreply.github.com> * address comments, clarify reference to earlier code snippet * clarify that operations are forbidden IN WORKFLOWS * cleanup workflow cancellation handling example * add task token retrieval comment * update .net requirements * Fix propagation of workflow cancellation --------- Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: Justin Anderson <44687433+jmaeagle99@users.noreply.github.com> --- SKILL.md | 7 +- references/core/determinism.md | 1 + references/dotnet/advanced-features.md | 203 ++++++++ references/dotnet/data-handling.md | 216 +++++++++ references/dotnet/determinism-protection.md | 49 ++ references/dotnet/determinism.md | 56 +++ references/dotnet/dotnet.md | 193 ++++++++ references/dotnet/error-handling.md | 157 +++++++ references/dotnet/gotchas.md | 261 +++++++++++ references/dotnet/observability.md | 107 +++++ references/dotnet/patterns.md | 493 ++++++++++++++++++++ references/dotnet/testing.md | 176 +++++++ references/dotnet/versioning.md | 301 ++++++++++++ references/go/determinism.md | 4 +- references/java/advanced-features.md | 1 + references/java/determinism-protection.md | 4 +- references/java/determinism.md | 4 +- references/java/error-handling.md | 5 + references/python/advanced-features.md | 1 + references/python/determinism-protection.md | 4 +- references/python/determinism.md | 4 +- references/python/error-handling.md | 5 +- references/typescript/determinism.md | 4 +- 23 files changed, 2244 insertions(+), 12 deletions(-) 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/SKILL.md b/SKILL.md index 5d2a338..df322df 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,6 +1,6 @@ --- name: temporal-developer -description: Develop, debug, and manage Temporal applications across Python, TypeScript, Go, and Java. Use when the user is building workflows, activities, or workers with a Temporal SDK, debugging issues like non-determinism errors, stuck workflows, or activity retries, using Temporal CLI, Temporal Server, or Temporal Cloud, or working with durable execution concepts like signals, queries, heartbeats, versioning, continue-as-new, child workflows, or saga patterns. +description: Develop, debug, and manage Temporal applications across Python, TypeScript, Go, Java and .NET. Use when the user is building workflows, activities, or workers with a Temporal SDK, debugging issues like non-determinism errors, stuck workflows, or activity retries, using Temporal CLI, Temporal Server, or Temporal Cloud, or working with durable execution concepts like signals, queries, heartbeats, versioning, continue-as-new, child workflows, or saga patterns. version: 0.2.0 --- @@ -8,7 +8,7 @@ version: 0.2.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, Go, and Java. +Temporal is a durable execution platform that makes workflows survive failures automatically. This skill provides guidance for building Temporal applications in Python, TypeScript, Go, Java and .NET. ## Core Architecture @@ -79,8 +79,9 @@ Once you've downloaded the file, extract the downloaded archive and add the temp 1. First, read the getting started guide for the language you are working in: - Python -> read `references/python/python.md` - TypeScript -> read `references/typescript/typescript.md` - - Java -> read `references/java/java.md` - Go -> read `references/go/go.md` + - Java -> read `references/java/java.md` + - .NET (C#) -> read `references/dotnet/dotnet.md` 2. Second, read appropriate `core` and language-specific references for the task at hand. ## Primary References diff --git a/references/core/determinism.md b/references/core/determinism.md index 952cca4..f2439b4 100644 --- a/references/core/determinism.md +++ b/references/core/determinism.md @@ -88,6 +88,7 @@ Each Temporal SDK language provides a different level of protection against non- - 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. - Java: The Java SDK has no sandbox. Determinism is enforced by developer conventions — the SDK provides `Workflow.*` APIs as safe alternatives (e.g., `Workflow.sleep()` instead of `Thread.sleep()`), and non-determinism is only detected at replay time via `NonDeterministicException`. A static analysis tool (`temporal-workflowcheck`, beta) can catch violations at build time. Cooperative threading under a global lock eliminates the need for synchronization. - 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. diff --git a/references/dotnet/advanced-features.md b/references/dotnet/advanced-features.md new file mode 100644 index 0000000..fd0f81e --- /dev/null +++ b/references/dotnet/advanced-features.md @@ -0,0 +1,203 @@ +# .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). +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 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; +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")); + // Retrieve the task token from external storage (e.g., database) + var taskToken = await GetTaskTokenAsync(requestId); + + 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")); +} +``` + +## 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 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. + +**Default behavior:** Only `ApplicationFailureException` fails a workflow. All other exceptions retry the workflow task forever (treated as bugs to fix with a code deployment). + +**Tip for testing:** Set `WorkflowFailureExceptionTypes` to include `Exception` so any unhandled exception fails the workflow immediately rather than retrying the workflow task forever. This surfaces bugs faster. + +### Worker-Level Configuration + +```csharp +var worker = new TemporalWorker( + client, + new TemporalWorkerOptions("my-task-queue") + { + // These exception types will fail the workflow execution (not just the task) + WorkflowFailureExceptionTypes = new[] { typeof(ArgumentException), typeof(InvalidOperationException) }, + } + .AddWorkflow() + .AddAllActivities(new MyActivities())); +``` + +## Dependency Injection + +The .NET SDK supports dependency injection via the `Temporalio.Extensions.Hosting` package, which integrates with .NET's generic host. + +### Worker as Generic Host + +```csharp +using Temporalio.Extensions.Hosting; + +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 + +As shown in the host setup above, activities can be registered with `AddScopedActivities()`, `AddSingletonActivities()`, or `AddTransientActivities()`. Activities registered this way 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..fc8d308 --- /dev/null +++ b/references/dotnet/data-handling.md @@ -0,0 +1,216 @@ +# .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( + SearchAttributeKey.CreateKeyword("OrderStatus").ValueSet("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..f9c480d --- /dev/null +++ b/references/dotnet/determinism-protection.md @@ -0,0 +1,49 @@ +# .NET Determinism Protection + +## Overview + +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 + +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 causes the workflow task to fail, which will continuously retry 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()); + } +} +``` + +## .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 + +## Best Practices + +1. **Always use `Workflow.*` alternatives** for Task operations in workflows +2. **Don't disable the `EventListener`** — it's on by default and 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..c1dbf56 --- /dev/null +++ b/references/dotnet/determinism.md @@ -0,0 +1,56 @@ +# .NET SDK Determinism + +## Overview + +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 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 in Workflows + +The following are forbidden inside workflow code but are appropriate to use in activities. + +```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. 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 diff --git a/references/dotnet/dotnet.md b/references/dotnet/dotnet.md new file mode 100644 index 0000000..29b40aa --- /dev/null +++ b/references/dotnet/dotnet.md @@ -0,0 +1,193 @@ +# 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. Supports .NET Framework 4.6.2+ and .NET Core 3.1+ (including .NET 5+). + +**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.workflow.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 +- 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 + +### 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. Use the `.workflow.cs` extension for workflow files so the `.editorconfig` overrides (see below) apply only to workflow code. + +``` +MyTemporalApp/ +├── Workflows/ +│ └── GreetingWorkflow.workflow.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 +``` + +## 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 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 +``` + +## Determinism Rules + +The .NET SDK has **no sandbox** like Python or TypeScript. Developers must avoid non-deterministic operations manually. Many standard .NET `Task` APIs use `TaskScheduler.Default` implicitly, which breaks determinism. + +See `references/dotnet/determinism.md` for the full list of forbidden operations, safe alternatives, and best practices. See `references/dotnet/determinism-protection.md` for details on the runtime detection mechanism. + +## Common Pitfalls + +1. **Using `Task.Run` in workflows** — Uses default scheduler, breaks determinism. Use `Workflow.RunTaskAsync`. +2. **Using `Task.Delay` in workflows** — Uses system timer. Use `Workflow.DelayAsync`. +3. **`ConfigureAwait(false)` in workflows** — Leaves the deterministic scheduler. Never use in workflows. +4. **Non-`ApplicationFailureException` in workflows** — Other exceptions retry the workflow task forever instead of failing the workflow. +5. **Dictionary iteration in workflows** — `Dictionary` has no guaranteed order. Use `SortedDictionary`. +6. **Forgetting to heartbeat** — Long-running activities need `ActivityExecutionContext.Current.Heartbeat()` calls. +7. **Using `CancellationTokenSource.CancelAsync`** — Use `CancellationTokenSource.Cancel` instead. +8. **Logging with `Console.WriteLine` in workflows** — Use `Workflow.Logger` for replay-safe logging. + +## Writing Tests + +See `references/dotnet/testing.md` for info on writing tests. + +## Additional Resources + +### Reference Files +- **`references/dotnet/patterns.md`** — Signals, queries, child workflows, saga pattern, etc. +- **`references/dotnet/determinism.md`** — Essentials of determinism in .NET +- **`references/dotnet/gotchas.md`** — .NET-specific mistakes and anti-patterns +- **`references/dotnet/error-handling.md`** — ApplicationFailureException, retry policies, non-retryable errors +- **`references/dotnet/observability.md`** — Logging, metrics, tracing +- **`references/dotnet/testing.md`** — WorkflowEnvironment, time-skipping, activity mocking +- **`references/dotnet/advanced-features.md`** — Schedules, worker tuning, dependency injection +- **`references/dotnet/data-handling.md`** — Data converters, payload encryption, etc. +- **`references/dotnet/versioning.md`** — Patching API, workflow type versioning, Worker Versioning +- **`references/dotnet/determinism-protection.md`** — Runtime task detection, .NET Task determinism rules diff --git a/references/dotnet/error-handling.md b/references/dotnet/error-handling.md new file mode 100644 index 0000000..f441620 --- /dev/null +++ b/references/dotnet/error-handling.md @@ -0,0 +1,157 @@ +# .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) when (!TemporalException.IsCanceledException(ex)) + { + Workflow.Logger.LogError(ex, "Activity failed"); + throw new ApplicationFailureException( + "Workflow failed due to activity error"); + } + } +} +``` + +## Retry Configuration + +```csharp +using Temporalio.Common; +using Temporalio.Workflows; + +[Workflow] +public class MyWorkflow +{ + [WorkflowRun] + public async Task RunAsync() + { + 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 +[Workflow] +public class MyWorkflow +{ + [WorkflowRun] + public async Task RunAsync() + { + 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..05213c2 --- /dev/null +++ b/references/dotnet/gotchas.md @@ -0,0 +1,261 @@ +# .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. + +## 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 + +```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. 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 +[Activity] +public async Task LongActivityAsync() +{ + await DoExpensiveWorkAsync(); // Runs to completion even if cancelled +} + +// GOOD: Heartbeat, check cancellation, and handle cleanup +[Activity] +public async Task LongActivityAsync() +{ + try + { + foreach (var item in items) + { + ActivityExecutionContext.Current.Heartbeat(); + ActivityExecutionContext.Current.CancellationToken.ThrowIfCancellationRequested(); + await ProcessAsync(item); + } + } + catch (OperationCanceledException) + { + await CleanupAsync(); + throw; + } +} +``` + +## 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..1150f63 --- /dev/null +++ b/references/dotnet/observability.md @@ -0,0 +1,107 @@ +# .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 + +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.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 +- `temporal_workflow_task_execution_latency` — Workflow task processing time +- `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 diff --git a/references/dotnet/patterns.md b/references/dotnet/patterns.md new file mode 100644 index 0000000..19d3317 --- /dev/null +++ b/references/dotnet/patterns.md @@ -0,0 +1,493 @@ +# .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"; + } +} +``` + +**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 +[Workflow] +public class ParentWorkflow +{ + [WorkflowRun] + public async Task> RunAsync(List orders) + { + var results = new List(); + foreach (var order in orders) + { + var result = await Workflow.ExecuteChildWorkflowAsync( + (ProcessOrderWorkflow wf) => wf.RunAsync(order), + new() + { + Id = $"order-{order.Id}", + // Control what happens to child when parent completes + // Terminate (default), Abandon, RequestCancel + ParentClosePolicy = ParentClosePolicy.Abandon, + }); + results.Add(result); + } + return results; + } +} +``` + +## Handles to External Workflows + +```csharp +[Workflow] +public class CoordinatorWorkflow +{ + [WorkflowRun] + public async Task RunAsync(string targetWorkflowId) + { + var handle = Workflow.GetExternalWorkflowHandle(targetWorkflowId); + + // Signal the external workflow + await handle.SignalAsync(wf => wf.DataReadyAsync(new DataPayload())); + + // Or cancel it + await handle.CancelAsync(); + } +} +``` + +## Parallel Execution + +```csharp +[Workflow] +public class ParallelWorkflow +{ + [WorkflowRun] + public async Task RunAsync(string[] items) + { + var tasks = items.Select(item => + Workflow.ExecuteActivityAsync( + (MyActivities a) => a.ProcessItem(item), + new() { StartToCloseTimeout = TimeSpan.FromMinutes(5) })); + + return await Workflow.WhenAllAsync(tasks); + } +} +``` + +## Deterministic Task Alternatives + +.NET `Task` APIs often use `TaskScheduler.Default` implicitly. Use Temporal's deterministic alternatives: + +```csharp +// Instead of Task.WhenAll: +await Workflow.WhenAllAsync(task1, task2, task3); + +// Instead of Task.WhenAny: +await Workflow.WhenAnyAsync(task1, task2); + +// Instead of Task.Run: +await Workflow.RunTaskAsync(() => SomeWork()); + +// Instead of Task.Delay: +await Workflow.DelayAsync(TimeSpan.FromMinutes(5)); + +// Instead of System.Threading.Mutex: +var mutex = new Temporalio.Workflows.Mutex(); +await mutex.WaitOneAsync(); +try { /* critical section */ } +finally { mutex.ReleaseMutex(); } + +// Instead of System.Threading.Semaphore: +var semaphore = new Temporalio.Workflows.Semaphore(3); +await semaphore.WaitAsync(); +try { /* limited concurrency section */ } +finally { semaphore.Release(); } +``` + +## Continue-as-New + +```csharp +[Workflow] +public class LongRunningWorkflow +{ + [WorkflowRun] + public async Task RunAsync(WorkflowState state) + { + while (true) + { + state = await ProcessNextBatch(state); + + if (state.IsComplete) + return "done"; + + if (Workflow.ContinueAsNewSuggested) + throw Workflow.CreateContinueAsNewException( + (LongRunningWorkflow wf) => wf.RunAsync(state)); + } + } +} +``` + +## Saga Pattern (Compensations) + +**Important:** Compensation activities should be idempotent — they may be retried (as with ALL activities). + +```csharp +[Workflow] +public class OrderSagaWorkflow +{ + [WorkflowRun] + public async Task RunAsync(Order order) + { + var compensations = new List>(); + + try + { + // IMPORTANT: Save compensation BEFORE calling the activity. + // If activity fails after completing but before returning, + // compensation must still be registered. + compensations.Add(() => Workflow.ExecuteActivityAsync( + (OrderActivities a) => a.ReleaseInventoryIfReservedAsync(order), + new() { StartToCloseTimeout = TimeSpan.FromMinutes(5) })); + await Workflow.ExecuteActivityAsync( + (OrderActivities a) => a.ReserveInventoryAsync(order), + new() { StartToCloseTimeout = TimeSpan.FromMinutes(5) }); + + compensations.Add(() => Workflow.ExecuteActivityAsync( + (OrderActivities a) => a.RefundPaymentIfChargedAsync(order), + new() { StartToCloseTimeout = TimeSpan.FromMinutes(5) })); + await Workflow.ExecuteActivityAsync( + (OrderActivities a) => a.ChargePaymentAsync(order), + new() { StartToCloseTimeout = TimeSpan.FromMinutes(5) }); + + await Workflow.ExecuteActivityAsync( + (OrderActivities a) => a.ShipOrderAsync(order), + new() { StartToCloseTimeout = TimeSpan.FromMinutes(5) }); + + return "Order completed"; + } + catch (Exception ex) + { + Workflow.Logger.LogError(ex, "Order failed, running compensations"); + compensations.Reverse(); + foreach (var compensate in compensations) + { + try { await compensate(); } + catch (Exception compErr) + { + Workflow.Logger.LogError(compErr, "Compensation failed"); + } + } + throw; + } + } +} +``` + +## Cancellation Handling (CancellationToken) + +.NET uses standard `CancellationToken` for workflow cancellation. + +```csharp +[Workflow] +public class CancellableWorkflow +{ + [WorkflowRun] + public async Task RunAsync() + { + try + { + await Workflow.ExecuteActivityAsync( + (MyActivities a) => a.LongRunningAsync(), + new() { StartToCloseTimeout = TimeSpan.FromHours(1) }); + return "completed"; + } + catch (Exception e) when (TemporalException.IsCanceledException(e)) + { + // The "when" clause above is because we only want to apply the logic to cancellation, but + // this kind of cleanup could be done on any/all exceptions too. + Workflow.Logger.LogError(e, "Cancellation occurred, performing cleanup"); + + // Call cleanup activity. If this throws, it will swallow the original exception which we + // are ok with here. This could be changed to just log a failure and let the original + // cancellation continue. + // The default token on Workflow.CancellationToken is now marked + // cancelled, so we pass a different one. We use CancellationToken.None here because the + // cleanup activity itself doesn't need to be cancellable; if it did (e.g. you want to + // cancel cleanup from a timeout or another signal), create a new detached + // CancellationTokenSource and pass its Token instead. + await Workflow.ExecuteActivityAsync( + (MyActivities a) => a.MyCancellationCleanupActivity(), + new() + { + ScheduleToCloseTimeout = TimeSpan.FromMinutes(5), + CancellationToken = CancellationToken.None, + }); + + // Rethrow the cancellation + throw; + } + } +} +``` + +## 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.HeartbeatDetailAtAsync(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..8bea410 --- /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` and returns a `Task`, 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..677a64c --- /dev/null +++ b/references/dotnet/versioning.md @@ -0,0 +1,301 @@ +# .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 +[Workflow] +public class OrderWorkflow +{ + [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 +[Workflow] +public class OrderWorkflow +{ + [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: + +```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 + +Use List Filters to find workflows with specific patch versions: + +```bash +# Find running workflows with a specific patch +temporal workflow list --query \ + 'WorkflowType = "OrderWorkflow" AND ExecutionStatus = "Running" AND TemporalChangeVersion = "add-fraud-check"' + +# Find running workflows without any patch (pre-patch versions) +temporal workflow list --query \ + 'WorkflowType = "OrderWorkflow" AND ExecutionStatus = "Running" AND TemporalChangeVersion IS NULL' +``` + +## Workflow Type Versioning + +For incompatible changes, create a new Workflow Type instead of using patches: + +```csharp +[Workflow("PizzaWorkflow")] +public class PizzaWorkflow +{ + [WorkflowRun] + public async Task RunAsync(PizzaOrder order) + { + return await ProcessOrderV1Async(order); + } +} + +[Workflow("PizzaWorkflowV2")] +public class PizzaWorkflowV2 +{ + [WorkflowRun] + public async Task RunAsync(PizzaOrder order) + { + return await ProcessOrderV2Async(order); + } +} +``` + +Register both with the Worker: + +```csharp +var worker = new TemporalWorker( + client, + new TemporalWorkerOptions("pizza-task-queue") + .AddWorkflow() + .AddWorkflow() + .AddAllActivities(new PizzaActivities())); +``` + +Update client code to start new workflows with the new type: + +```csharp +// Old workflows continue on PizzaWorkflow +// New workflows use PizzaWorkflowV2 +var handle = await client.StartWorkflowAsync( + (PizzaWorkflowV2 wf) => wf.RunAsync(order), + new(id: $"pizza-{order.Id}", taskQueue: "pizza-task-queue")); +``` + +Check for open executions before removing the old type: + +```bash +temporal workflow list --query 'WorkflowType = "PizzaWorkflow" AND ExecutionStatus = "Running"' +``` + +## Worker Versioning + +Worker Versioning manages versions at the deployment level, allowing multiple Worker versions to run simultaneously. + +### Key Concepts + +**Worker Deployment**: A logical service grouping similar Workers together (e.g., "loan-processor"). All versions of your code live under this umbrella. + +**Worker Deployment Version**: A specific snapshot of your code identified by a deployment name and Build ID (e.g., "loan-processor:v1.0" or "loan-processor:abc123"). + +### Configuring Workers for Versioning + +```csharp +using Temporalio.Worker; + +var worker = new TemporalWorker( + client, + new TemporalWorkerOptions("my-task-queue") + { + DeploymentOptions = new WorkerDeploymentOptions( + DeploymentName: "my-service", + BuildId: Environment.GetEnvironmentVariable("BUILD_ID") ?? "dev"), + UseWorkerVersioning = true, + } + .AddWorkflow() + .AddAllActivities(new MyActivities())); +``` + +**Configuration parameters:** +- `UseWorkerVersioning`: Enables Worker Versioning +- `DeploymentOptions`: Identifies the Worker Deployment Version (deployment name + build ID) +- Build ID: Typically a git commit hash, version number, or timestamp + +### PINNED vs AUTO_UPGRADE Behaviors + +**PINNED Behavior** + +Workflows stay locked to their original Worker version: + +```csharp +[Workflow(VersioningBehavior = VersioningBehavior.Pinned)] +public class StableWorkflow { /* ... */ } +``` + +**When to use PINNED:** +- Short-running workflows (minutes to hours) +- Consistency is critical (e.g., financial transactions) +- You want to eliminate version compatibility complexity +- Building new applications and want simplest development experience + +**AUTO_UPGRADE Behavior** + +Workflows can move to newer versions: + +```csharp +[Workflow(VersioningBehavior = VersioningBehavior.AutoUpgrade)] +public class UpgradableWorkflow { /* ... */ } +``` + +**When to use AUTO_UPGRADE:** +- Long-running workflows (weeks or months) +- Workflows need to benefit from bug fixes during execution +- Migrating from traditional rolling deployments +- You are already using patching APIs for version transitions + +**Important:** AUTO_UPGRADE workflows still need patching to handle version transitions safely since they can move between Worker versions. + +### 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** + +Maintain two environments and switch traffic between them: +1. Deploy new code to idle environment +2. Run tests and validation +3. Switch traffic to new environment +4. Keep old environment for instant rollback + +**Rainbow Deployments** + +Multiple versions run simultaneously: +- New workflows use latest version +- Existing workflows complete on their original version +- Add new versions alongside existing ones +- Gradually sunset old versions as workflows complete + +### Querying Workflows by Worker Version + +```bash +# Find workflows on a specific Worker version +temporal workflow list --query \ + 'TemporalWorkerDeploymentVersion = "my-service:v1.0.0" AND ExecutionStatus = "Running"' +``` + +## Best Practices + +1. **Check for open executions** before removing old code paths +2. **Use descriptive patch IDs** that explain the change (e.g., "add-fraud-check" not "patch-1") +3. **Deploy patches incrementally**: patch, deprecate, remove +4. **Use PINNED for short workflows** to simplify version management +5. **Use AUTO_UPGRADE with patching** for long-running workflows that need updates +6. **Generate Build IDs from code** (git hash) to ensure changes produce new versions +7. **Avoid rolling deployments** for high-availability services with long-running workflows diff --git a/references/go/determinism.md b/references/go/determinism.md index 0cff905..c8b52b9 100644 --- a/references/go/determinism.md +++ b/references/go/determinism.md @@ -8,9 +8,9 @@ The Go SDK has NO runtime sandbox (unlike Python/TypeScript). Workflows must be 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 +## Forbidden Operations in Workflows -Do not use any of the following in workflow code: +Do not use any of the following in workflow code (they are appropriate to use in activities): - **Native goroutines** (`go func()`) -- use `workflow.Go()` instead - **Native channels** (`chan`, send, receive, `range` over channel) -- use `workflow.Channel` instead diff --git a/references/java/advanced-features.md b/references/java/advanced-features.md index e897bb1..e736da2 100644 --- a/references/java/advanced-features.md +++ b/references/java/advanced-features.md @@ -77,6 +77,7 @@ public void completeApproval(String requestId, boolean approved) { ActivityCompletionClient completionClient = client.newActivityCompletionClient(); + // Retrieve the task token from external storage (e.g., database) byte[] taskToken = getTaskToken(requestId); if (approved) { diff --git a/references/java/determinism-protection.md b/references/java/determinism-protection.md index 78c4446..1894644 100644 --- a/references/java/determinism-protection.md +++ b/references/java/determinism-protection.md @@ -4,7 +4,9 @@ The Java SDK has **no sandbox** (only Python and TypeScript have sandboxing). Java relies on developer conventions and runtime replay detection to enforce determinism. A static analysis tool (`temporal-workflowcheck`) is available in beta. -## Forbidden Operations +## Forbidden Operations in Workflows + +The following are forbidden inside workflow code but are appropriate to use in activities. ```java // BAD: Non-deterministic operations in workflow code diff --git a/references/java/determinism.md b/references/java/determinism.md index 1981d00..29f25d5 100644 --- a/references/java/determinism.md +++ b/references/java/determinism.md @@ -14,7 +14,9 @@ Java workflow code runs in a cooperative threading model where only one workflow `temporal-workflowcheck` (static analysis, beta) and `WorkflowReplayer` (replay testing) can help uncover some violations, but they are not exhaustive — careful code review and adherence to the rules below remain essential. -## Forbidden Operations +## Forbidden Operations in Workflows + +The following are forbidden inside workflow code but are appropriate to use in activities. - `Thread.sleep()` — blocks the real thread, bypasses Temporal timers - `new Thread()` or thread pools — breaks the cooperative threading model diff --git a/references/java/error-handling.md b/references/java/error-handling.md index 97d4cea..753d69a 100644 --- a/references/java/error-handling.md +++ b/references/java/error-handling.md @@ -77,6 +77,7 @@ Activity failures are always wrapped in `ActivityFailure`. The original exceptio ```java import io.temporal.failure.ActivityFailure; import io.temporal.failure.ApplicationFailure; +import io.temporal.failure.CanceledFailure; import io.temporal.failure.TimeoutFailure; import io.temporal.workflow.Workflow; @@ -86,6 +87,10 @@ public class MyWorkflowImpl implements MyWorkflow { try { return activities.riskyOperation(); } catch (ActivityFailure af) { + // Let cancellation propagate so the workflow is canceled, not failed + if (af.getCause() instanceof CanceledFailure) { + throw af; + } if (af.getCause() instanceof ApplicationFailure) { ApplicationFailure appFailure = (ApplicationFailure) af.getCause(); String type = appFailure.getType(); diff --git a/references/python/advanced-features.md b/references/python/advanced-features.md index 3584a64..3d86e9f 100644 --- a/references/python/advanced-features.md +++ b/references/python/advanced-features.md @@ -62,6 +62,7 @@ async def request_approval(request_id: str) -> None: # Later, complete the activity from another process async def complete_approval(request_id: str, approved: bool): client = await Client.connect("localhost:7233", namespace="default") + # Retrieve the task token from external storage (e.g., database) task_token = await get_task_token(request_id) handle = client.get_async_activity_handle(task_token=task_token) diff --git a/references/python/determinism-protection.md b/references/python/determinism-protection.md index 3ff9543..2eba418 100644 --- a/references/python/determinism-protection.md +++ b/references/python/determinism-protection.md @@ -13,9 +13,9 @@ The sandbox: - Passes through standard library with restrictions - Reloads workflow files on each execution -## Forbidden Operations +## Forbidden Operations in Workflows -These operations will fail in the sandbox: +These operations are forbidden inside workflow code (appropriate in activities) and will fail in the sandbox: - **Direct I/O**: Network calls, file reads/writes - **Threading**: `threading` module operations diff --git a/references/python/determinism.md b/references/python/determinism.md index e1b53a7..2be8f75 100644 --- a/references/python/determinism.md +++ b/references/python/determinism.md @@ -8,7 +8,9 @@ The Python SDK runs workflows in a sandbox that provides automatic protection ag 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**. -## Forbidden Operations +## Forbidden Operations in Workflows + +The following are forbidden inside workflow code but are appropriate to use in activities. - Direct I/O (network, filesystem) - Threading operations diff --git a/references/python/error-handling.md b/references/python/error-handling.md index 19460cb..ed9e69d 100644 --- a/references/python/error-handling.md +++ b/references/python/error-handling.md @@ -47,7 +47,7 @@ async def charge_card(input: ChargeCardInput) -> str: ```python from datetime import timedelta from temporalio import workflow -from temporalio.exceptions import ActivityError, ApplicationError +from temporalio.exceptions import ActivityError, ApplicationError, is_cancelled_exception @workflow.defn class MyWorkflow: @@ -59,6 +59,9 @@ class MyWorkflow: start_to_close_timeout=timedelta(minutes=5), ) except ActivityError as e: + # Let cancellation propagate so the workflow is canceled, not failed + if is_cancelled_exception(e): + raise workflow.logger.error(f"Activity failed: {e}") # Handle or re-raise raise ApplicationError("Workflow failed due to activity error") diff --git a/references/typescript/determinism.md b/references/typescript/determinism.md index 47f8948..dfd3464 100644 --- a/references/typescript/determinism.md +++ b/references/typescript/determinism.md @@ -28,7 +28,9 @@ The Temporal workflow sandbox will use the same random seed when replaying a wor See `references/typescript/determinism-protection.md` for more information about the sandbox. -## Forbidden Operations +## Forbidden Operations in Workflows + +The following are forbidden inside workflow code but are appropriate to use in activities. ```typescript // DO NOT do these in workflows: From 0843752be47f13ae82c4ed65fe57873e3ecb0b1d Mon Sep 17 00:00:00 2001 From: Donald Pinckney Date: Fri, 17 Apr 2026 14:41:28 -0400 Subject: [PATCH 26/82] Run formatter over .NET (#87) --- references/core/determinism.md | 2 +- references/dotnet/data-handling.md | 1 + references/dotnet/determinism-protection.md | 2 ++ references/dotnet/dotnet.md | 9 +++++++++ references/dotnet/gotchas.md | 1 + references/dotnet/observability.md | 1 + references/dotnet/patterns.md | 2 ++ references/dotnet/testing.md | 1 + references/dotnet/versioning.md | 6 ++++++ 9 files changed, 24 insertions(+), 1 deletion(-) diff --git a/references/core/determinism.md b/references/core/determinism.md index f2439b4..004f879 100644 --- a/references/core/determinism.md +++ b/references/core/determinism.md @@ -88,7 +88,7 @@ Each Temporal SDK language provides a different level of protection against non- - 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. - Java: The Java SDK has no sandbox. Determinism is enforced by developer conventions — the SDK provides `Workflow.*` APIs as safe alternatives (e.g., `Workflow.sleep()` instead of `Thread.sleep()`), and non-determinism is only detected at replay time via `NonDeterministicException`. A static analysis tool (`temporal-workflowcheck`, beta) can catch violations at build time. Cooperative threading under a global lock eliminates the need for synchronization. - 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. +- .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. diff --git a/references/dotnet/data-handling.md b/references/dotnet/data-handling.md index fc8d308..8d0bb23 100644 --- a/references/dotnet/data-handling.md +++ b/references/dotnet/data-handling.md @@ -7,6 +7,7 @@ The .NET SDK uses data converters to serialize/deserialize workflow inputs, outp ## Default Data Converter The default converter handles: + - `null` - `byte[]` (as binary) - `Google.Protobuf.IMessage` instances diff --git a/references/dotnet/determinism-protection.md b/references/dotnet/determinism-protection.md index f9c480d..8c7f331 100644 --- a/references/dotnet/determinism-protection.md +++ b/references/dotnet/determinism-protection.md @@ -27,6 +27,7 @@ public class BadWorkflow 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`. @@ -36,6 +37,7 @@ Many .NET `Task` APIs implicitly use `TaskScheduler.Default`, which breaks deter - `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 diff --git a/references/dotnet/dotnet.md b/references/dotnet/dotnet.md index 29b40aa..437fcbb 100644 --- a/references/dotnet/dotnet.md +++ b/references/dotnet/dotnet.md @@ -13,11 +13,13 @@ Temporal workflows are durable through history replay. For details on how this w ## 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; @@ -32,6 +34,7 @@ public class MyActivities ``` **GreetingWorkflow.workflow.cs** - Workflow definition: + ```csharp using Temporalio.Workflows; @@ -49,6 +52,7 @@ public class GreetingWorkflow ``` **Worker (Program.cs)** - Worker setup: + ```csharp using Temporalio.Client; using Temporalio.Worker; @@ -69,6 +73,7 @@ await worker.ExecuteAsync(); **Start the worker:** Run `dotnet run` in the worker project. **Starter (Program.cs)** - Start a workflow execution: + ```csharp using Temporalio.Client; @@ -86,6 +91,7 @@ Console.WriteLine($"Result: {result}"); ## Key Concepts ### 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 @@ -93,12 +99,14 @@ Console.WriteLine($"Result: {result}"); - 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)` @@ -181,6 +189,7 @@ 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 diff --git a/references/dotnet/gotchas.md b/references/dotnet/gotchas.md index 05213c2..9b5806c 100644 --- a/references/dotnet/gotchas.md +++ b/references/dotnet/gotchas.md @@ -174,6 +174,7 @@ public class GoodWorkflow ### Not Handling Activity Cancellation 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 diff --git a/references/dotnet/observability.md b/references/dotnet/observability.md index 1150f63..6919207 100644 --- a/references/dotnet/observability.md +++ b/references/dotnet/observability.md @@ -30,6 +30,7 @@ public class MyWorkflow ``` The workflow logger automatically: + - Suppresses duplicate logs during replay - Includes workflow context (workflow ID, run ID, etc.) diff --git a/references/dotnet/patterns.md b/references/dotnet/patterns.md index 19d3317..586fab0 100644 --- a/references/dotnet/patterns.md +++ b/references/dotnet/patterns.md @@ -425,10 +425,12 @@ public class HandlerAwareWorkflow ## 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 diff --git a/references/dotnet/testing.md b/references/dotnet/testing.md index 8bea410..d60805a 100644 --- a/references/dotnet/testing.md +++ b/references/dotnet/testing.md @@ -161,6 +161,7 @@ public async Task TestActivity() ``` The `ActivityEnvironment` provides: + - `Info` — Activity info, defaulted to basic values - `CancellationTokenSource` — Token source for issuing cancellation - `Heartbeater` — Callback invoked each heartbeat diff --git a/references/dotnet/versioning.md b/references/dotnet/versioning.md index 677a64c..6371926 100644 --- a/references/dotnet/versioning.md +++ b/references/dotnet/versioning.md @@ -34,6 +34,7 @@ public class ShippingWorkflow ``` **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) @@ -207,6 +208,7 @@ var worker = new TemporalWorker( ``` **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 @@ -223,6 +225,7 @@ public class StableWorkflow { /* ... */ } ``` **When to use PINNED:** + - Short-running workflows (minutes to hours) - Consistency is critical (e.g., financial transactions) - You want to eliminate version compatibility complexity @@ -238,6 +241,7 @@ 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 @@ -269,6 +273,7 @@ var worker = new TemporalWorker( **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 @@ -277,6 +282,7 @@ Maintain two environments and switch traffic between them: **Rainbow Deployments** Multiple versions run simultaneously: + - New workflows use latest version - Existing workflows complete on their original version - Add new versions alongside existing ones From d68413af30f1b3627356cde094075471804dfd8c Mon Sep 17 00:00:00 2001 From: Donald Pinckney Date: Fri, 17 Apr 2026 15:03:44 -0400 Subject: [PATCH 27/82] Minor fixes to versioning.md (#64) * Edits to python versioning fixes * add missing workflow imports --- references/python/versioning.md | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/references/python/versioning.md b/references/python/versioning.md index 1daab78..c1ad39a 100644 --- a/references/python/versioning.md +++ b/references/python/versioning.md @@ -226,13 +226,13 @@ worker = Worker( Workflows stay locked to their original Worker version: ```python -from temporalio.workflow import VersioningBehavior +from temporalio import workflow +from temporalio.common import VersioningBehavior -@workflow.defn +@workflow.defn(versioning_behavior=VersioningBehavior.PINNED) class StableWorkflow: @workflow.run async def run(self) -> str: - # This workflow will always run on its assigned version return await workflow.execute_activity( process_order, start_to_close_timeout=timedelta(minutes=5), @@ -250,6 +250,20 @@ class StableWorkflow: Workflows can move to newer versions: +```python +from temporalio import workflow +from temporalio.common import VersioningBehavior + +@workflow.defn(versioning_behavior=VersioningBehavior.AUTO_UPGRADE) +class UpgradableWorkflow: + @workflow.run + async def run(self) -> str: + return await workflow.execute_activity( + process_order, + start_to_close_timeout=timedelta(minutes=5), + ) +``` + **When to use AUTO_UPGRADE:** - Long-running workflows (weeks or months) @@ -262,7 +276,6 @@ Workflows can move to newer versions: ### Worker Configuration with Default Behavior ```python -# For short-running workflows, prefer PINNED worker = Worker( client, task_queue="orders-task-queue", @@ -274,7 +287,7 @@ worker = Worker( build_id=os.environ["BUILD_ID"], ), use_worker_versioning=True, - # default_versioning_behavior=VersioningBehavior.PINNED, + default_versioning_behavior=VersioningBehavior.PINNED, ), ) ``` From 999af7884d9c2e425506d08730d07488caa1a68f Mon Sep 17 00:00:00 2001 From: Donald Pinckney Date: Fri, 17 Apr 2026 15:07:01 -0400 Subject: [PATCH 28/82] Update version of temporal-developer skill to 0.3.0 (#88) --- SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SKILL.md b/SKILL.md index df322df..e4950df 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,7 +1,7 @@ --- name: temporal-developer description: Develop, debug, and manage Temporal applications across Python, TypeScript, Go, Java and .NET. Use when the user is building workflows, activities, or workers with a Temporal SDK, debugging issues like non-determinism errors, stuck workflows, or activity retries, using Temporal CLI, Temporal Server, or Temporal Cloud, or working with durable execution concepts like signals, queries, heartbeats, versioning, continue-as-new, child workflows, or saga patterns. -version: 0.2.0 +version: 0.3.0 --- # Skill: temporal-developer From b4fe783d421965a6368642428d17c0f52fe5f533 Mon Sep 17 00:00:00 2001 From: Donald Pinckney Date: Fri, 17 Apr 2026 17:22:11 -0400 Subject: [PATCH 29/82] Change to use the app tokens, so hopefully the sync workflow gets triggered correctly. (#89) --- .github/workflows/package-skill.yml | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/.github/workflows/package-skill.yml b/.github/workflows/package-skill.yml index 637deb2..64cb96e 100644 --- a/.github/workflows/package-skill.yml +++ b/.github/workflows/package-skill.yml @@ -1,5 +1,7 @@ # ABOUTME: GitHub Actions workflow that packages the skill for upload to Claude.ai. # ABOUTME: Creates a ZIP artifact on every push to main and a GitHub Release when the version in SKILL.md increases. +# ABOUTME: Releases are created using a GitHub App token so the release event can trigger downstream workflows +# ABOUTME: (events fired by the default GITHUB_TOKEN do not trigger other workflows). name: Package Skill @@ -12,9 +14,17 @@ jobs: package: runs-on: ubuntu-latest permissions: - contents: write + contents: read steps: + - name: Generate token from GitHub App + id: app-token + uses: actions/create-github-app-token@v3 + with: + app-id: ${{ secrets.SKILL_T_DEV_APP_ID }} + private-key: ${{ secrets.SKILL_T_DEV_KEY }} + owner: ${{ github.repository_owner }} + - name: Checkout uses: actions/checkout@v6 with: @@ -53,6 +63,7 @@ jobs: if: steps.tag_check.outputs.exists == 'false' uses: softprops/action-gh-release@v3 with: + token: ${{ steps.app-token.outputs.token }} tag_name: ${{ steps.version.outputs.tag }} name: ${{ steps.version.outputs.tag }} files: temporal-developer-skill.zip From 801a48d0665e10ac6a33634786e582ceda44e71a Mon Sep 17 00:00:00 2001 From: Donald Pinckney Date: Fri, 17 Apr 2026 17:25:40 -0400 Subject: [PATCH 30/82] Bump version to 0.3.1 in SKILL.md (#90) --- SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SKILL.md b/SKILL.md index e4950df..1bea2e2 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,7 +1,7 @@ --- name: temporal-developer description: Develop, debug, and manage Temporal applications across Python, TypeScript, Go, Java and .NET. Use when the user is building workflows, activities, or workers with a Temporal SDK, debugging issues like non-determinism errors, stuck workflows, or activity retries, using Temporal CLI, Temporal Server, or Temporal Cloud, or working with durable execution concepts like signals, queries, heartbeats, versioning, continue-as-new, child workflows, or saga patterns. -version: 0.3.0 +version: 0.3.1 --- # Skill: temporal-developer From 127d400c67ba9a89593ba1e3ea08c0166595b47a Mon Sep 17 00:00:00 2001 From: Donald Pinckney Date: Fri, 17 Apr 2026 21:45:08 -0400 Subject: [PATCH 31/82] merge into 1 workflow (#92) --- .github/workflows/package-skill.yml | 156 ++++++++++++++++++-- .github/workflows/sync-skill-to-plugins.yml | 144 ------------------ 2 files changed, 141 insertions(+), 159 deletions(-) delete mode 100644 .github/workflows/sync-skill-to-plugins.yml diff --git a/.github/workflows/package-skill.yml b/.github/workflows/package-skill.yml index 64cb96e..a9e0dc2 100644 --- a/.github/workflows/package-skill.yml +++ b/.github/workflows/package-skill.yml @@ -1,9 +1,12 @@ -# ABOUTME: GitHub Actions workflow that packages the skill for upload to Claude.ai. -# ABOUTME: Creates a ZIP artifact on every push to main and a GitHub Release when the version in SKILL.md increases. -# ABOUTME: Releases are created using a GitHub App token so the release event can trigger downstream workflows -# ABOUTME: (events fired by the default GITHUB_TOKEN do not trigger other workflows). +# ABOUTME: Packages the skill on every push to main (as a ZIP artifact) and, if the version in SKILL.md +# ABOUTME: has been bumped, creates a GitHub Release and syncs the skill contents to three plugin repos +# ABOUTME: (cursor-temporal-plugin, codex-temporal-plugin, claude-temporal-plugin) via PRs. +# ABOUTME: Required secrets (used only by the sync job for cross-repo PRs): +# ABOUTME: SKILL_T_DEV_APP_ID — the GitHub App's ID +# ABOUTME: SKILL_T_DEV_KEY — the GitHub App's private key +# ABOUTME: The app must be installed on the three plugin repos with Contents (write) and Pull Requests (write). -name: Package Skill +name: Package and Sync Skill on: push: @@ -14,17 +17,13 @@ jobs: package: runs-on: ubuntu-latest permissions: - contents: read + contents: write + outputs: + version: ${{ steps.version.outputs.version }} + tag: ${{ steps.version.outputs.tag }} + released: ${{ steps.tag_check.outputs.exists == 'false' }} steps: - - name: Generate token from GitHub App - id: app-token - uses: actions/create-github-app-token@v3 - with: - app-id: ${{ secrets.SKILL_T_DEV_APP_ID }} - private-key: ${{ secrets.SKILL_T_DEV_KEY }} - owner: ${{ github.repository_owner }} - - name: Checkout uses: actions/checkout@v6 with: @@ -63,8 +62,135 @@ jobs: if: steps.tag_check.outputs.exists == 'false' uses: softprops/action-gh-release@v3 with: - token: ${{ steps.app-token.outputs.token }} tag_name: ${{ steps.version.outputs.tag }} name: ${{ steps.version.outputs.tag }} files: temporal-developer-skill.zip generate_release_notes: true + + sync: + needs: package + if: needs.package.outputs.released == 'true' || github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + permissions: + contents: read + strategy: + fail-fast: false + matrix: + include: + - repo: temporalio/cursor-temporal-plugin + target_path: skills/temporal-developer + - repo: temporalio/codex-temporal-plugin + target_path: plugins/temporal-developer/skills/temporal-developer + - repo: temporalio/claude-temporal-plugin + target_path: skills/temporal-developer + + steps: + - name: Generate token from GitHub App + id: app-token + uses: actions/create-github-app-token@v3 + with: + app-id: ${{ secrets.SKILL_T_DEV_APP_ID }} + private-key: ${{ secrets.SKILL_T_DEV_KEY }} + owner: ${{ github.repository_owner }} + + - name: Checkout source + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Checkout target repo + uses: actions/checkout@v6 + with: + repository: ${{ matrix.repo }} + token: ${{ steps.app-token.outputs.token }} + path: target-repo + + - name: Sync skill contents + working-directory: target-repo + run: | + BRANCH="sync/temporal-developer-skill" + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + # Create or reset the sync branch based on current main. + # -B ensures the branch always starts from main's tip, even if a + # stale remote branch exists from a previously merged PR. + git checkout -B "$BRANCH" origin/main + + # Remove old contents and copy current + rm -rf "${{ matrix.target_path }}/SKILL.md" \ + "${{ matrix.target_path }}/references" + cp ../SKILL.md "${{ matrix.target_path }}/" + cp -r ../references "${{ matrix.target_path }}/" + + # Check for changes against main + git add "${{ matrix.target_path }}" + if git diff --cached --quiet; then + echo "no_changes=true" >> "$GITHUB_ENV" + echo "No changes to sync" + else + echo "no_changes=false" >> "$GITHUB_ENV" + version="${{ needs.package.outputs.tag }}" + git commit -m "sync temporal-developer skill ${version} from source repo" + git push --force origin "$BRANCH" + fi + + - name: Build changelog + if: env.no_changes == 'false' + env: + GH_TOKEN: ${{ github.token }} + run: | + tag="${{ needs.package.outputs.tag }}" + + # Prefer the release body (auto-generated notes). Fall back to git log + # if no release exists for this tag (e.g. manual re-sync of an older version). + if body=$(gh release view "$tag" --repo "${{ github.repository }}" --json body --jq '.body' 2>/dev/null) && [ -n "$body" ]; then + echo "$body" > /tmp/changelog.md + else + prev_tag=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "") + if [ -n "$prev_tag" ]; then + git log --oneline "${prev_tag}..HEAD" > /tmp/changelog.md + else + git log --oneline -20 > /tmp/changelog.md + fi + fi + + - name: Create or update PR + if: env.no_changes == 'false' + working-directory: target-repo + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + BRANCH="sync/temporal-developer-skill" + version="${{ needs.package.outputs.tag }}" + changelog=$(cat /tmp/changelog.md) + + # Check if a PR already exists from this branch + existing_pr=$(gh pr list --head "$BRANCH" --state open --json number --jq '.[0].number') + + if [ -n "$existing_pr" ]; then + echo "PR #${existing_pr} already exists — updated by the force-push" + gh pr edit "$existing_pr" \ + --title "Sync temporal-developer skill ${version}" \ + --body "Automated sync of the temporal-developer skill ${version} from [skill-temporal-developer](https://github.com/${{ github.repository }}). + + This PR was updated automatically by the [sync workflow](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}). + + ## Changelog + ${changelog}" + gh pr comment "$existing_pr" --body "Updated to ${version} from [skill-temporal-developer](https://github.com/${{ github.repository }})." + pr_url=$(gh pr view "$existing_pr" --json url --jq '.url') + echo "### ${{ matrix.repo }}" >> "$GITHUB_STEP_SUMMARY" + echo "Updated [PR #${existing_pr}](${pr_url})" >> "$GITHUB_STEP_SUMMARY" + else + pr_url=$(gh pr create \ + --title "Sync temporal-developer skill ${version}" \ + --body "Automated sync of the temporal-developer skill ${version} from [skill-temporal-developer](https://github.com/${{ github.repository }}). + + This PR was created automatically by the [sync workflow](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}). + + ## Changelog + ${changelog}") + echo "### ${{ matrix.repo }}" >> "$GITHUB_STEP_SUMMARY" + echo "Created ${pr_url}" >> "$GITHUB_STEP_SUMMARY" + fi diff --git a/.github/workflows/sync-skill-to-plugins.yml b/.github/workflows/sync-skill-to-plugins.yml deleted file mode 100644 index 2fb8aac..0000000 --- a/.github/workflows/sync-skill-to-plugins.yml +++ /dev/null @@ -1,144 +0,0 @@ -# ABOUTME: GitHub Actions workflow that syncs skill contents to the cursor and codex plugin repos. -# ABOUTME: Triggers when a new release is created (by the package-skill workflow) or manually. -# ABOUTME: Creates or updates a PR in each target repo rather than pushing directly to main. -# ABOUTME: Uses a GitHub App for cross-repo authentication. Required secrets: -# ABOUTME: SKILL_T_DEV_APP_ID — the GitHub App's ID -# ABOUTME: SKILL_T_DEV_KEY — the GitHub App's private key -# ABOUTME: The app must be installed on all three repos with Contents (write) and -# ABOUTME: Pull Requests (write) permissions. - -name: Sync Skill to Plugin Repos - -on: - release: - types: [published] - workflow_dispatch: - -permissions: - contents: read - -jobs: - sync: - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - include: - - repo: temporalio/cursor-temporal-plugin - target_path: skills/temporal-developer - - repo: temporalio/codex-temporal-plugin - target_path: plugins/temporal-developer/skills/temporal-developer - - repo: temporalio/claude-temporal-plugin - target_path: skills/temporal-developer - - steps: - - name: Generate token from GitHub App - id: app-token - uses: actions/create-github-app-token@v3 - with: - app-id: ${{ secrets.SKILL_T_DEV_APP_ID }} - private-key: ${{ secrets.SKILL_T_DEV_KEY }} - owner: ${{ github.repository_owner }} - - - name: Checkout source - uses: actions/checkout@v6 - with: - fetch-depth: 0 - - - name: Checkout target repo - uses: actions/checkout@v6 - with: - repository: ${{ matrix.repo }} - token: ${{ steps.app-token.outputs.token }} - path: target-repo - - - name: Sync skill contents - working-directory: target-repo - run: | - BRANCH="sync/temporal-developer-skill" - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - - # Create or reset the sync branch based on current main. - # -B ensures the branch always starts from main's tip, even if a - # stale remote branch exists from a previously merged PR. - git checkout -B "$BRANCH" origin/main - - # Remove old contents and copy current - rm -rf "${{ matrix.target_path }}/SKILL.md" \ - "${{ matrix.target_path }}/references" - cp ../SKILL.md "${{ matrix.target_path }}/" - cp -r ../references "${{ matrix.target_path }}/" - - # Check for changes against main - git add "${{ matrix.target_path }}" - if git diff --cached --quiet; then - echo "no_changes=true" >> "$GITHUB_ENV" - echo "No changes to sync" - else - echo "no_changes=false" >> "$GITHUB_ENV" - version="${{ github.event.release.tag_name || 'manual' }}" - git commit -m "sync temporal-developer skill ${version} from source repo" - git push --force origin "$BRANCH" - fi - - - name: Build changelog - id: changelog - run: | - if [ "${{ github.event_name }}" = "release" ]; then - # Use the release body (auto-generated notes from package-skill) - changelog=$(cat <<'RELEASE_BODY' - ${{ github.event.release.body }} - RELEASE_BODY - ) - else - # Manual trigger: generate from git log since the previous tag - prev_tag=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "") - if [ -n "$prev_tag" ]; then - changelog=$(git log --oneline "${prev_tag}..HEAD") - else - changelog=$(git log --oneline -20) - fi - fi - # Write to a file to avoid shell quoting issues - echo "$changelog" > /tmp/changelog.md - - - name: Create or update PR - if: env.no_changes == 'false' - working-directory: target-repo - env: - GH_TOKEN: ${{ steps.app-token.outputs.token }} - run: | - BRANCH="sync/temporal-developer-skill" - version="${{ github.event.release.tag_name || 'manual' }}" - changelog=$(cat /tmp/changelog.md) - - # Check if a PR already exists from this branch - existing_pr=$(gh pr list --head "$BRANCH" --state open --json number --jq '.[0].number') - - if [ -n "$existing_pr" ]; then - echo "PR #${existing_pr} already exists — updated by the force-push" - gh pr edit "$existing_pr" \ - --title "Sync temporal-developer skill ${version}" \ - --body "Automated sync of the temporal-developer skill ${version} from [skill-temporal-developer](https://github.com/${{ github.repository }}). - - This PR was updated automatically by the [sync workflow](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}). - - ## Changelog - ${changelog}" - gh pr comment "$existing_pr" --body "Updated to ${version} from [skill-temporal-developer](https://github.com/${{ github.repository }})." - pr_url=$(gh pr view "$existing_pr" --json url --jq '.url') - echo "### ${{ matrix.repo }}" >> "$GITHUB_STEP_SUMMARY" - echo "Updated [PR #${existing_pr}](${pr_url})" >> "$GITHUB_STEP_SUMMARY" - else - pr_url=$(gh pr create \ - --title "Sync temporal-developer skill ${version}" \ - --body "Automated sync of the temporal-developer skill ${version} from [skill-temporal-developer](https://github.com/${{ github.repository }}). - - This PR was created automatically by the [sync workflow](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}). - - ## Changelog - ${changelog}") - echo "### ${{ matrix.repo }}" >> "$GITHUB_STEP_SUMMARY" - echo "Created ${pr_url}" >> "$GITHUB_STEP_SUMMARY" - fi From 73fc5f025942187b61b76b6b94f87bb9a59345dc Mon Sep 17 00:00:00 2001 From: Donald Pinckney Date: Mon, 20 Apr 2026 09:28:44 -0400 Subject: [PATCH 32/82] Improve syncing PR changelogs: include changelog from previous versions that were unmerged (#93) --- .github/workflows/package-skill.yml | 40 +++++++++++++++++++++-------- 1 file changed, 29 insertions(+), 11 deletions(-) diff --git a/.github/workflows/package-skill.yml b/.github/workflows/package-skill.yml index a9e0dc2..3bfe16f 100644 --- a/.github/workflows/package-skill.yml +++ b/.github/workflows/package-skill.yml @@ -140,19 +140,37 @@ jobs: env: GH_TOKEN: ${{ github.token }} run: | - tag="${{ needs.package.outputs.tag }}" + current_tag="${{ needs.package.outputs.tag }}" + + # Determine the base for the changelog: the version currently on the + # target repo's main branch. This represents what was last merged, so + # the changelog spans every release since then — correctly accumulating + # unmerged versions if a prior sync PR is still open. + # + # Read the old SKILL.md from git (it's been overwritten on disk by the + # sync step) via `git show origin/main:...`. + target_version=$(git -C target-repo show "origin/main:${{ matrix.target_path }}/SKILL.md" 2>/dev/null \ + | grep '^version:' | sed 's/version:[[:space:]]*//' || echo "") + + if [ -n "$target_version" ]; then + base_tag="v${target_version}" + else + base_tag="" + fi - # Prefer the release body (auto-generated notes). Fall back to git log - # if no release exists for this tag (e.g. manual re-sync of an older version). - if body=$(gh release view "$tag" --repo "${{ github.repository }}" --json body --jq '.body' 2>/dev/null) && [ -n "$body" ]; then - echo "$body" > /tmp/changelog.md + # Prefer GitHub's auto-generated notes for the range (nicely formatted + # with PR links and contributors). Fall back to git log if unavailable. + if [ -n "$base_tag" ] && notes=$(gh api \ + --method POST \ + "/repos/${{ github.repository }}/releases/generate-notes" \ + -f tag_name="${current_tag}" \ + -f previous_tag_name="${base_tag}" \ + --jq '.body' 2>/dev/null) && [ -n "$notes" ]; then + echo "$notes" > /tmp/changelog.md + elif [ -n "$base_tag" ]; then + git log --oneline "${base_tag}..HEAD" > /tmp/changelog.md else - prev_tag=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "") - if [ -n "$prev_tag" ]; then - git log --oneline "${prev_tag}..HEAD" > /tmp/changelog.md - else - git log --oneline -20 > /tmp/changelog.md - fi + git log --oneline -20 > /tmp/changelog.md fi - name: Create or update PR From 150c73371e4f13a40d3c0839f133639ae4abf5b8 Mon Sep 17 00:00:00 2001 From: Patrick Dewey <57921252+ptdewey@users.noreply.github.com> Date: Mon, 20 Apr 2026 09:41:19 -0400 Subject: [PATCH 33/82] docs: update go observability reference with up-to-date logging approach (#59) --- references/go/observability.md | 45 ++++++++++++++++++++++++++-------- 1 file changed, 35 insertions(+), 10 deletions(-) diff --git a/references/go/observability.md b/references/go/observability.md index 23ad62f..a7867b3 100644 --- a/references/go/observability.md +++ b/references/go/observability.md @@ -62,43 +62,68 @@ logger.Info("Processing order") // includes orderId and customerId ## Customizing the Logger -Set a custom logger via `client.Options{Logger: myLogger}`. Implement the `log.Logger` interface (Debug, Info, Warn, Error methods). +The SDK ships a single built-in **`slog` adapter** (`log.NewStructuredLogger`) and considers `slog` (go 1.21+) the universal bridge to other logging libraries. -### Using slog (Go 1.21+) +### The `log.Logger` Interface + +```go +// go.temporal.io/sdk/log +type Logger interface { + Debug(msg string, keyvals ...interface{}) + Info(msg string, keyvals ...interface{}) + Warn(msg string, keyvals ...interface{}) + Error(msg string, keyvals ...interface{}) +} +``` + +Optional companion interfaces: `WithLogger` (adds `.With()`) and `WithSkipCallers` (fixes caller frames). + +### Using slog (Recommended) ```go import ( "log/slog" "os" - tlog "go.temporal.io/sdk/log" + "go.temporal.io/sdk/log" ) slogHandler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}) -logger := tlog.NewStructuredLogger(slog.New(slogHandler)) +logger := log.NewStructuredLogger(slog.New(slogHandler)) c, err := client.Dial(client.Options{ Logger: logger, }) ``` -### Using Third-Party Loggers (Logrus, Zap, etc.) +### Using slog as a Bridge to Third-Party Loggers -Use the [logur](https://github.com/logur/logur) adapter package: +Any third-party logger that can back an `slog.Handler` works with `log.NewStructuredLogger` — this includes zap, zerolog, logrus, and most modern Go logging libraries. The pattern is: create an `slog.Handler` from your logger, then wrap it with `log.NewStructuredLogger`. + +**Example with Zap:** ```go import ( - "github.com/sirupsen/logrus" - logrusadapter "logur.dev/adapter/logrus" - "logur.dev/logur" + "log/slog" + + "go.uber.org/zap" + "go.uber.org/zap/exp/zapslog" + "go.temporal.io/sdk/log" ) -logger := logur.LoggerToKV(logrusadapter.New(logrus.New())) +zapLogger, _ := zap.NewProduction() +handler := zapslog.NewHandler(zapLogger.Core()) +logger := log.NewStructuredLogger(slog.New(handler)) + c, err := client.Dial(client.Options{ Logger: logger, }) ``` +### Direct Adapter (Alternative) + +If you cannot use the slog bridge, you can implement the `log.Logger` interface directly. The Temporal samples repo has a ~60-line [zap adapter](https://github.com/temporalio/samples-go/blob/main/zapadapter/zap_adapter.go) that implements `Logger`, `WithLogger`, and `WithSkipCallers` and can be copied into your project. + ## Metrics Use the Tally library (`go.temporal.io/sdk/contrib/tally`) with Prometheus: From c98e05a6fc31578476b34b84f5e5c7550d177520 Mon Sep 17 00:00:00 2001 From: Donald Pinckney Date: Mon, 20 Apr 2026 09:42:34 -0400 Subject: [PATCH 34/82] Update version of temporal-developer skill to 0.3.2 (#94) --- SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SKILL.md b/SKILL.md index 1bea2e2..9f12f6c 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,7 +1,7 @@ --- name: temporal-developer description: Develop, debug, and manage Temporal applications across Python, TypeScript, Go, Java and .NET. Use when the user is building workflows, activities, or workers with a Temporal SDK, debugging issues like non-determinism errors, stuck workflows, or activity retries, using Temporal CLI, Temporal Server, or Temporal Cloud, or working with durable execution concepts like signals, queries, heartbeats, versioning, continue-as-new, child workflows, or saga patterns. -version: 0.3.1 +version: 0.3.2 --- # Skill: temporal-developer From 62957d1e5683d29d245ffda8eb65f67b8288ccf7 Mon Sep 17 00:00:00 2001 From: Donald Pinckney Date: Mon, 20 Apr 2026 10:36:44 -0400 Subject: [PATCH 35/82] Fix permission for "reading" changelogs (#95) --- .github/workflows/package-skill.yml | 28 ++++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/.github/workflows/package-skill.yml b/.github/workflows/package-skill.yml index 3bfe16f..f2b89e3 100644 --- a/.github/workflows/package-skill.yml +++ b/.github/workflows/package-skill.yml @@ -72,7 +72,9 @@ jobs: if: needs.package.outputs.released == 'true' || github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest permissions: - contents: read + # contents: write is required by the POST /releases/generate-notes endpoint, + # even though it only returns text and doesn't actually write anything. + contents: write strategy: fail-fast: false matrix: @@ -160,16 +162,22 @@ jobs: # Prefer GitHub's auto-generated notes for the range (nicely formatted # with PR links and contributors). Fall back to git log if unavailable. - if [ -n "$base_tag" ] && notes=$(gh api \ - --method POST \ - "/repos/${{ github.repository }}/releases/generate-notes" \ - -f tag_name="${current_tag}" \ - -f previous_tag_name="${base_tag}" \ - --jq '.body' 2>/dev/null) && [ -n "$notes" ]; then - echo "$notes" > /tmp/changelog.md - elif [ -n "$base_tag" ]; then - git log --oneline "${base_tag}..HEAD" > /tmp/changelog.md + echo "Base tag: ${base_tag:-} / Current tag: ${current_tag}" + if [ -n "$base_tag" ]; then + if notes=$(gh api \ + --method POST \ + "/repos/${{ github.repository }}/releases/generate-notes" \ + -f tag_name="${current_tag}" \ + -f previous_tag_name="${base_tag}" \ + --jq '.body') && [ -n "$notes" ]; then + echo "Using auto-generated release notes" + echo "$notes" > /tmp/changelog.md + else + echo "generate-notes API call failed or empty; falling back to git log" + git log --oneline "${base_tag}..HEAD" > /tmp/changelog.md + fi else + echo "No base tag found; using last 20 commits" git log --oneline -20 > /tmp/changelog.md fi From cf347103633038c30c097c5d8e167256a8094b0a Mon Sep 17 00:00:00 2001 From: Amir Benvenisti <128422269+starfleeth@users.noreply.github.com> Date: Tue, 21 Apr 2026 10:28:24 -0700 Subject: [PATCH 36/82] Update README to reflect new plugin packaging (#96) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Update README to reflect new plugin packaging Remove reference to the old agent-skills mono-plugin repo. Link to the per-harness plugin repos (Claude Code, Cursor, Codex) and present the standalone installation options after the plugin links. Co-Authored-By: Claude Opus 4.6 * Mark .NET SDK as supported .NET support has landed — move from 🚧 to ✅. Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- README.md | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 8f2a0a5..a0ae73b 100644 --- a/README.md +++ b/README.md @@ -8,24 +8,26 @@ A comprehensive skill for developers to use when building [Temporal](https://tem ## Installation -### As a Claude Code Plugin +### As a Plugin -This skill is housed within a [Claude Code plugin](https://github.com/temporalio/agent-skills), which provides a simple way to install and receive future updates to the skill. +This skill is packaged as a plugin for major coding agents, which provides a simple way to install and receive future updates: -1. Run `/plugin marketplace add temporalio/agent-skills` -2. Run `/plugin` to open the plugin manager -3. Select **Marketplaces** -4. Choose `temporal-marketplace` from the list -5. Select **Enable auto-update** or **Disable auto-update** -6. run `/plugin install temporal-developer@temporalio-agent-skills` -7. Restart Claude Code +- **Claude Code**: [temporalio/claude-temporal-plugin](https://github.com/temporalio/claude-temporal-plugin) +- **Cursor**: [temporalio/cursor-temporal-plugin](https://github.com/temporalio/cursor-temporal-plugin) +- **OpenAI Codex**: [temporalio/codex-temporal-plugin](https://github.com/temporalio/codex-temporal-plugin) -### Via `npx skills` - supports all major coding agents +See each repo's README for installation instructions. + +### Standalone Installation + +If you prefer to install the skill directly without the plugin wrapper: + +#### Via `npx skills` — supports all major coding agents 1. `npx skills add temporalio/skill-temporal-developer` 2. Follow prompts -### Via manually cloning the skill repo: +#### Via manually cloning the skill repo 1. `mkdir -p ~/.claude/skills && git clone https://github.com/temporalio/skill-temporal-developer ~/.claude/skills/temporal-developer` @@ -37,6 +39,6 @@ Appropriately adjust the installation directory based on your coding agent. - [x] TypeScript ✅ - [x] Go ✅ - [x] Java ✅ -- [ ] .NET 🚧 ([PR](https://github.com/temporalio/skill-temporal-developer/pull/39)) +- [x] .NET ✅ - [ ] Ruby 🚧 ([PR](https://github.com/temporalio/skill-temporal-developer/pull/41)) - [ ] PHP 🚧 ([PR](https://github.com/temporalio/skill-temporal-developer/pull/40)) From 2a1d8b394ff321286900c9c39faff2e011d80937 Mon Sep 17 00:00:00 2001 From: Maksim Mesilov Date: Sat, 25 Apr 2026 16:29:33 +0600 Subject: [PATCH 37/82] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index a0ae73b..e607dcf 100644 --- a/README.md +++ b/README.md @@ -41,4 +41,4 @@ Appropriately adjust the installation directory based on your coding agent. - [x] Java ✅ - [x] .NET ✅ - [ ] Ruby 🚧 ([PR](https://github.com/temporalio/skill-temporal-developer/pull/41)) -- [ ] PHP 🚧 ([PR](https://github.com/temporalio/skill-temporal-developer/pull/40)) +- [x] PHP ✅ From 26955d9cce215a7662e77622810842471a6851cb Mon Sep 17 00:00:00 2001 From: Amir Benvenisti <128422269+starfleeth@users.noreply.github.com> Date: Wed, 29 Apr 2026 09:23:58 -0700 Subject: [PATCH 38/82] Add Task Queue Priority and Fairness reference (#98) Add a core reference file covering Priority (keys 1-5, inheritance, when to use) and Fairness (keys, weights, virtual queues, round-robin dispatch, rate limiting, weight overrides, enabling, limitations) with SDK examples for Go, Java, Python, TypeScript, and .NET. Content is grounded in the official Temporal docs. Update SKILL.md to proactively surface Fairness when developers are building multi-tenant applications, before they run into tenant starvation problems. Co-authored-by: Claude Opus 4.6 --- SKILL.md | 8 + references/core/priority-fairness.md | 340 +++++++++++++++++++++++++++ 2 files changed, 348 insertions(+) create mode 100644 references/core/priority-fairness.md diff --git a/SKILL.md b/SKILL.md index 9f12f6c..fe6650c 100644 --- a/SKILL.md +++ b/SKILL.md @@ -101,6 +101,14 @@ Once you've downloaded the file, extract the downloaded archive and add the temp - **`references/core/ai-patterns.md`** - AI/LLM pattern concepts - Language-specific info at `references/{your_language}/ai-patterns.md`, if available. Currently Python only. +## Task Queue Priority and Fairness + +If the developer is building a **multi-tenant application**, proactively recommend Task Queue Fairness. Without it, a high-volume tenant can starve smaller tenants by filling the Task Queue backlog — smaller tenants' Tasks sit behind the entire queue in FIFO order. Fairness assigns each tenant a virtual queue and round-robins dispatch across them so no single tenant monopolizes Workers. + +Priority and Fairness also apply to tiered workloads (batch vs. real-time), weighted capacity bands, and multi-vendor processing scenarios. + +- **`references/core/priority-fairness.md`** - Priority keys, fairness keys and weights, rate limiting, SDK examples, and limitations + ## Additional Topics - **`references/{your_language}/observability.md`** - See for language-specific implementation guidance on observability in Temporal diff --git a/references/core/priority-fairness.md b/references/core/priority-fairness.md new file mode 100644 index 0000000..cb6930e --- /dev/null +++ b/references/core/priority-fairness.md @@ -0,0 +1,340 @@ +# Task Queue Priority and Fairness + +## Overview + +Priority and Fairness control how Tasks are distributed within a Task Queue. Priority determines execution order. Fairness prevents one group of Tasks from starving others. They can be used independently or together. + +Both features are in Public Preview. Priority is free. Fairness is a paid feature in Temporal Cloud. + +## Priority + +Priority lets you control execution order within a single Task Queue by assigning a priority key (integer 1-5, lower = higher priority). Each priority level acts as a sub-queue. All priority-1 Tasks dispatch before priority-2, and so on. Tasks at the same priority level dispatch in FIFO order. + +Default priority is 3. Activities inherit their parent workflow's priority unless explicitly overridden. + +### When to use Priority + +Use Priority to differentiate execution order between types of work sharing a single Task Queue and Worker pool. For example, process payment-related Tasks before less time-sensitive inventory management Tasks, or ensure real-time Tasks run ahead of batch Tasks. You can also use it to run urgent Tasks immediately by assigning them priority 1. + +### CLI + +``` +temporal workflow start \ + --type ChargeCustomer \ + --task-queue my-task-queue \ + --workflow-id my-workflow-id \ + --input '{"customerId":"12345"}' \ + --priority-key 1 +``` + +### Go + +```go +workflowOptions := client.StartWorkflowOptions{ + ID: "my-workflow-id", + TaskQueue: "my-task-queue", + Priority: temporal.Priority{PriorityKey: 1}, +} +we, err := c.ExecuteWorkflow(context.Background(), workflowOptions, MyWorkflow) +``` + +### Java + +```java +WorkflowOptions options = WorkflowOptions.newBuilder() + .setTaskQueue("my-task-queue") + .setPriority(Priority.newBuilder().setPriorityKey(1).build()) + .build(); +``` + +### Python + +```python +await client.start_workflow( + MyWorkflow.run, + args="hello", + id="my-workflow-id", + task_queue="my-task-queue", + priority=Priority(priority_key=1), +) +``` + +### TypeScript + +```ts +const handle = await startWorkflow(workflows.myWorkflow, { + args: [false, 1], + priority: { priorityKey: 1 }, +}); +``` + +### .NET + +```csharp +var handle = await Client.StartWorkflowAsync( + (MyWorkflow wf) => wf.RunAsync("hello"), + new StartWorkflowOptions(id: "my-workflow-id", taskQueue: "my-task-queue") + { + Priority = new Priority(1), + } +); +``` + +## Fairness + +Fairness prevents one group of Tasks from monopolizing Worker capacity. Each fairness key creates a "virtual queue" within the Task Queue. The server uses round-robin dispatch across virtual queues so no single key can block others, even with a much larger backlog. + +### When to use Fairness + +Fairness solves the multi-tenant starvation problem. Without it, Tasks dispatch FIFO: if tenant-big enqueues 100k Tasks, tenant-small's 10 Tasks sit behind the entire backlog. With Fairness, each tenant gets its own virtual queue and Tasks are interleaved. + +Common scenarios: + +- **Multi-tenant applications** where large tenants should not block small ones. +- **Tiered capacity bands** where you want weighted distribution (e.g., 80% premium, 20% free) without limiting overall throughput when one band is empty. +- **Batch jobs** where some jobs run far more frequently than others. +- **Multi-vendor processing** where a few vendors generate the majority of work. + +If all your Tasks can be dispatched immediately (no backlog), you don't need Fairness. + +Fairness applies at Task dispatch time and considers each Task as having equal cost until dispatch. It does not account for Tasks currently being processed by Workers. So if you look at Tasks being processed by Workers, you might not see "fairness" across tenants — for example, if tenant-big already has Tasks being processed when tenant-small's Tasks are dispatched, it may still appear that tenant-big is using the most resources. + +### Fairness keys and weights + +A fairness key is a string, typically a tenant ID or workload category. Each unique key creates a virtual queue. + +A fairness weight (float, default 1.0) controls how often a key's Tasks are dispatched relative to others. A key with weight 2.0 dispatches twice as often as keys with weight 1.0. + +Example with three tiers: + +| Fairness Key | Weight | Share of Dispatches | +|----------------|--------|---------------------| +| premium-tier | 5.0 | 50% | +| basic-tier | 3.0 | 30% | +| free-tier | 2.0 | 20% | + +Tasks without a fairness key are grouped under an implicit empty-string key with weight 1.0. Adoption is incremental: unkeyed Tasks participate in round-robin alongside keyed Tasks. + +### Using Fairness with Priority + +When combined, Priority determines which sub-queue Tasks go into (priority 1 before 2, etc.), and Fairness applies within each priority level. + +### SDK examples + +#### CLI + +``` +temporal workflow start \ + --type ChargeCustomer \ + --task-queue my-task-queue \ + --workflow-id my-workflow-id \ + --input '{"customerId":"12345"}' \ + --priority-key 1 \ + --fairness-key tenant-123 \ + --fairness-weight 2.0 +``` + +#### Go + +```go +workflowOptions := client.StartWorkflowOptions{ + ID: "my-workflow-id", + TaskQueue: "my-task-queue", + Priority: temporal.Priority{ + PriorityKey: 1, + FairnessKey: "tenant-123", + FairnessWeight: 2.0, + }, +} +we, err := c.ExecuteWorkflow(context.Background(), workflowOptions, MyWorkflow) +``` + +Activities: + +```go +ao := workflow.ActivityOptions{ + StartToCloseTimeout: time.Minute, + Priority: temporal.Priority{ + PriorityKey: 1, + FairnessKey: "tenant-123", + FairnessWeight: 2.0, + }, +} +ctx := workflow.WithActivityOptions(ctx, ao) +err := workflow.ExecuteActivity(ctx, MyActivity).Get(ctx, nil) +``` + +#### Java + +```java +WorkflowOptions options = WorkflowOptions.newBuilder() + .setTaskQueue("my-task-queue") + .setPriority(Priority.newBuilder() + .setPriorityKey(1) + .setFairnessKey("tenant-123") + .setFairnessWeight(2.0) + .build()) + .build(); +``` + +#### Python + +```python +await client.start_workflow( + MyWorkflow.run, + args="hello", + id="my-workflow-id", + task_queue="my-task-queue", + priority=Priority(priority_key=1, fairness_key="tenant-123", fairness_weight=2.0), +) +``` + +Activities: + +```python +await workflow.execute_activity( + say_hello, + "hi", + priority=Priority(priority_key=1, fairness_key="tenant-123", fairness_weight=2.0), + start_to_close_timeout=timedelta(seconds=5), +) +``` + +#### TypeScript + +```ts +const handle = await startWorkflow(workflows.myWorkflow, { + args: [false, 1], + priority: { priorityKey: 1, fairnessKey: 'tenant-123', fairnessWeight: 2.0 }, +}); +``` + +#### .NET + +```csharp +var handle = await Client.StartWorkflowAsync( + (MyWorkflow wf) => wf.RunAsync("hello"), + new StartWorkflowOptions(id: "my-workflow-id", taskQueue: "my-task-queue") + { + Priority = new Priority( + priorityKey: 1, + fairnessKey: "tenant-123", + fairnessWeight: 2.0 + ) + } +); +``` + +#### Child Workflows + +Child workflows can set their own priority and fairness, overriding the parent. + +Go: + +```go +cwo := workflow.ChildWorkflowOptions{ + WorkflowID: "child-workflow-id", + TaskQueue: "child-task-queue", + Priority: temporal.Priority{ + PriorityKey: 1, + FairnessKey: "tenant-123", + FairnessWeight: 2.0, + }, +} +ctx := workflow.WithChildOptions(ctx, cwo) +err := workflow.ExecuteChildWorkflow(ctx, MyChildWorkflow).Get(ctx, nil) +``` + +Java: + +```java +ChildWorkflowOptions childOptions = ChildWorkflowOptions.newBuilder() + .setTaskQueue("child-task-queue") + .setWorkflowId("child-workflow-id") + .setPriority(Priority.newBuilder() + .setPriorityKey(1) + .setFairnessKey("tenant-123") + .setFairnessWeight(2.0) + .build()) + .build(); +MyChildWorkflow child = Workflow.newChildWorkflowStub(MyChildWorkflow.class, childOptions); +child.run(); +``` + +Python: + +```python +await workflow.execute_child_workflow( + MyChildWorkflow.run, + args="hello child", + priority=Priority(priority_key=1, fairness_key="tenant-123", fairness_weight=2.0), +) +``` + +TypeScript: + +```ts +const handle = await startChildWorkflow(workflows.myChildWorkflow, { + args: [false, 1], + priority: { priorityKey: 1, fairnessKey: 'tenant-123', fairnessWeight: 2.0 }, +}); +``` + +.NET: + +```csharp +await Workflow.ExecuteChildWorkflowAsync( + (MyChildWorkflow wf) => wf.RunAsync("hello child"), + new() { + Priority = new( + priorityKey: 1, + fairnessKey: "tenant-123", + fairnessWeight: 2.0 + ) + } +); +``` + +### Rate limiting + +Two rate-limiting controls work alongside Fairness: + +- **`queue-rps-limit`** — overall dispatch rate for the entire Task Queue. +- **`fairness-key-rps-limit-default`** — per-key rate limit, scaled by weight. If the default is 10 rps and a key has weight 2.5, that key's effective limit is 25 rps. + +``` +temporal task-queue config set \ + --task-queue my-task-queue \ + --task-queue-type activity \ + --namespace my-namespace \ + --queue-rps-limit 500 \ + --queue-rps-limit-reason "overall limit" \ + --fairness-key-rps-limit-default 33.3 \ + --fairness-key-rps-limit-reason "per-key limit" +``` + +If both limits are set, the more restrictive one applies. + +### Fairness weight overrides + +You can override the weights of up to 1000 keys through the config API. When an override is set for a key, the SDK-supplied weight is ignored. Overrides are per Task Queue and type (workflow vs. activity), so set them for both if needed. + +### Enabling Fairness + +When you start using fairness keys, it switches your active Task Queues to fairness mode. Existing queued Tasks are processed before any new fairness-mode ones. + +**Temporal Cloud**: automatically enabled when you start using fairness keys. + +**Self-hosted**: set these dynamic config flags to `true`: + +- `matching.useNewMatcher` +- `matching.enableFairness` +- `matching.enableMigration` (to drain existing backlogs after enabling) + +### Limitations + +- Accuracy can degrade with a very large number of distinct fairness keys. +- Task Queue partitioning can interfere with fairness distribution. Contact Temporal Support to set a Task Queue to a single partition if needed. +- Weights apply at schedule time, not dispatch time. Changing a weight does not reorder already-backlogged Tasks. +- Fairness is not guaranteed across different Worker versions when using Worker Versioning. +- After server restarts, less-active keys may briefly dispatch new Tasks ahead of their existing backlog until ordering normalizes. From 8d05f55a5d511bdfb018fde411f053456aac434d Mon Sep 17 00:00:00 2001 From: Devin Smaldore Date: Thu, 30 Apr 2026 13:21:48 -0400 Subject: [PATCH 39/82] Add Spring Boot integration reference for Java SDK (#74) Covers auto-discovery mechanics, annotation layering (@WorkflowImpl vs @ActivityImpl + @Component), WorkflowClient injection, worker lifecycle, testing strategies, and Spring-specific gotchas. Updates java.md and testing.md with pointers to the new reference. Co-authored-by: Donald Pinckney --- references/java/java.md | 3 + references/java/spring-boot.md | 287 +++++++++++++++++++++++++++++++++ references/java/testing.md | 71 ++++++++ 3 files changed, 361 insertions(+) create mode 100644 references/java/spring-boot.md diff --git a/references/java/java.md b/references/java/java.md index b260424..a0ba272 100644 --- a/references/java/java.md +++ b/references/java/java.md @@ -193,6 +193,8 @@ public class Starter { - `Worker` -- polls a single Task Queue, register workflows and activities on it - Call `factory.start()` to begin polling +For Spring Boot apps, `temporal-spring-boot-starter` handles all of the above automatically via auto-configuration. See `references/java/spring-boot.md`. + ## File Organization Best Practice **Keep Workflow and Activity definitions in separate files.** Separating them is good practice for clarity and maintainability. @@ -252,6 +254,7 @@ See `references/java/testing.md` for info on writing tests. ### Reference Files +- **`references/java/spring-boot.md`** - Spring Boot integration: auto-discovery, dependency injection, worker lifecycle, testing - **`references/java/patterns.md`** - Signals, queries, child workflows, saga pattern, etc. - **`references/java/determinism.md`** - Determinism rules and safe alternatives for Java - **`references/java/gotchas.md`** - Java-specific mistakes and anti-patterns diff --git a/references/java/spring-boot.md b/references/java/spring-boot.md new file mode 100644 index 0000000..ceaaaec --- /dev/null +++ b/references/java/spring-boot.md @@ -0,0 +1,287 @@ +# Temporal Spring Boot Integration + +## Overview + +`temporal-spring-boot-starter` auto-configures workers, registers workflow/activity implementations, and exposes `WorkflowClient` as a Spring bean. This eliminates the manual `WorkflowServiceStubs` → `WorkflowClient` → `WorkerFactory` setup required without Spring. + +## Dependency Setup + +Maven: +```xml + + io.temporal + temporal-spring-boot-starter + [1.0,) + +``` + +Gradle: +```groovy +implementation 'io.temporal:temporal-spring-boot-starter:1.+' +``` + +The starter transitively includes `temporal-sdk` and the autoconfigure module. You can declare both `temporal-sdk` and `temporal-spring-boot-starter` explicitly, but the starter alone is sufficient. + +## Minimal Configuration + +`application.properties`: +```properties +spring.temporal.connection.target=local +spring.temporal.start-workers=true +spring.temporal.workersAutoDiscovery.packages=greetingapp +``` + +`application.yml` equivalent: +```yaml +spring: + temporal: + connection: + target: local # shorthand for localhost:7233 + start-workers: true + workersAutoDiscovery: + packages: + - greetingapp + workers: + - task-queue: greeting-queue + name: greeting-worker +``` + +For self-hosted Temporal, replace `local` with the server address: +```properties +spring.temporal.connection.target=temporal.internal:7233 +``` + +## Interface Design + Spring Annotation Layering + +The key concept: Temporal SDK annotations go on **interfaces**, Spring Boot autoconfigure annotations go on **implementation classes**. This is identical to non-Spring usage at the interface level. + +### Workflow Interface (unchanged from non-Spring) +```java +package greetingapp; + +import io.temporal.workflow.WorkflowInterface; +import io.temporal.workflow.WorkflowMethod; + +@WorkflowInterface +public interface GreetingWorkflow { + @WorkflowMethod + String greet(String name); +} +``` + +### Workflow Implementation +```java +package greetingapp; + +import io.temporal.activity.ActivityOptions; +import io.temporal.spring.boot.WorkflowImpl; +import io.temporal.workflow.Workflow; + +import java.time.Duration; + +// @WorkflowImpl replaces manual worker.registerWorkflowImplementationTypes() +// No @Component — workflows are NOT Spring beans; Temporal creates a new instance per execution +@WorkflowImpl(taskQueues = "greeting-queue") +public class GreetingWorkflowImpl implements GreetingWorkflow { + + // Activity stubs created via Workflow.newActivityStub() as usual + private final GreetActivities activities = Workflow.newActivityStub( + GreetActivities.class, + ActivityOptions.newBuilder() + .setStartToCloseTimeout(Duration.ofSeconds(30)) + .setTaskQueue("greeting-queue") + .build() + ); + + @Override + public String greet(String name) { + return activities.greet(name); + } +} +``` + +### Activity Interface (unchanged from non-Spring) +```java +package greetingapp; + +import io.temporal.activity.ActivityInterface; +import io.temporal.activity.ActivityMethod; + +@ActivityInterface +public interface GreetActivities { + @ActivityMethod + String greet(String name); +} +``` + +### Activity Implementation +```java +package greetingapp; + +import io.temporal.spring.boot.ActivityImpl; +import org.springframework.stereotype.Component; + +// @Component makes this a Spring bean — dependencies can be injected normally +// @ActivityImpl replaces manual worker.registerActivitiesImplementations() +@Component +@ActivityImpl(taskQueues = "greeting-queue") +public class GreetActivitiesImpl implements GreetActivities { + + private final GreetingService greetingService; + + // Constructor injection works because this is a Spring bean + public GreetActivitiesImpl(GreetingService greetingService) { + this.greetingService = greetingService; + } + + @Override + public String greet(String name) { + return greetingService.composeGreeting(name); + } +} +``` + +## Auto-Discovery + +Auto-discovery is how the autoconfigure finds and registers implementations without explicit configuration. It requires **both** of the following: + +1. `@WorkflowImpl(taskQueues = "...")` or `@ActivityImpl(taskQueues = "...")` on the implementation class +2. `spring.temporal.workersAutoDiscovery.packages` pointing to a package that contains those classes + +Missing either one results in silent non-registration — no error, nothing polls the task queue. + +The `taskQueues` attribute routes implementations to the right worker when multiple task queues exist. A worker configured with task queue `"greeting-queue"` only picks up implementations annotated with `taskQueues = "greeting-queue"`. + +**Important:** `@ActivityImpl(taskQueues = "greeting-queue")` only registers the activity bean with that worker. It does not route individual activity task executions. Inside the workflow, `ActivityOptions.setTaskQueue("greeting-queue")` must also be set on the activity stub to route activity tasks to the correct queue. + +### Comparison: Auto-Discovery vs Explicit YAML Registration + +Auto-discovery via annotations: +```properties +spring.temporal.workersAutoDiscovery.packages=greetingapp +``` +```java +@Component +@ActivityImpl(taskQueues = "greeting-queue") +public class GreetActivitiesImpl implements GreetActivities { ... } +``` + +Explicit YAML registration (alternative): +```yaml +spring: + temporal: + workers: + - task-queue: greeting-queue + name: greeting-worker + activity-beans: + - greetActivitiesImpl + workflow-classes: + - greetingapp.GreetingWorkflowImpl +``` + +Use auto-discovery when implementations are colocated in a single package tree (most apps). Use explicit YAML when you need fine-grained control, want to exclude specific classes, or are registering beans defined elsewhere. + +## WorkflowClient Injection + +`WorkflowClient` is automatically registered as a Spring bean by the autoconfigure. Inject it into any `@Service` or `@RestController`: + +```java +package greetingapp; + +import io.temporal.client.WorkflowClient; +import io.temporal.client.WorkflowOptions; +import org.springframework.stereotype.Service; + +import java.util.UUID; + +@Service +public class GreetingStarter { + + private final WorkflowClient client; + + public GreetingStarter(WorkflowClient client) { + this.client = client; + } + + public String startGreeting(String name) { + var stub = client.newWorkflowStub( + GreetingWorkflow.class, + WorkflowOptions.newBuilder() + .setWorkflowId(UUID.randomUUID().toString()) + .setTaskQueue("greeting-queue") // must match the worker's task queue + .build() + ); + // Synchronous — blocks until workflow completes + return stub.greet(name); + } + + public void startGreetingAsync(String name) { + var stub = client.newWorkflowStub( + GreetingWorkflow.class, + WorkflowOptions.newBuilder() + .setWorkflowId(UUID.randomUUID().toString()) + .setTaskQueue("greeting-queue") + .build() + ); + // Fire-and-forget — returns immediately + WorkflowClient.start(stub::greet, name); + } +} +``` + +## Worker Lifecycle + +Workers start on `ApplicationReadyEvent` — after the full Spring context is initialized (DB migrations run, all beans wired). This means activity beans are fully ready before any workflow tasks are processed. + +To run a client-only app (one that submits workflows but does not execute them): +```properties +spring.temporal.start-workers=false +``` + +## Testing Strategies + +See `references/java/testing.md` for full details on both approaches. + +**Spring integration tests** — uses an embedded Temporal test server wired into the Spring context: +```properties +# src/test/resources/application-test.properties +spring.temporal.test-server.enabled=true +``` +```java +@SpringBootTest +@ActiveProfiles("test") +class GreetingIntegrationTest { + @Autowired WorkflowClient client; // points at the embedded test server + + @Test + void testWorkflowThroughSpringContext() { ... } +} +``` + +**Unit tests without Spring** — use `TestWorkflowEnvironment` or `TestWorkflowExtension` directly. No Spring context, faster startup, full time-skipping support: +```java +@RegisterExtension +static final TestWorkflowExtension testWorkflow = TestWorkflowExtension.newBuilder() + .setWorkflowTypes(GreetingWorkflowImpl.class) + .setDoNotStart(true) + .build(); +``` + +Do not mix approaches in the same test class — choose one or the other. + +## Spring-Specific Gotchas + +**Workflow impls must not have `@Component`** +Temporal creates a new workflow instance per execution via `beanFactory.createBean()` (not `getBean()`). Adding `@Component` means Spring also registers it as a singleton bean, which can cause confusing lifecycle behavior. Leave `@WorkflowImpl` classes as plain classes with no Spring annotations. + +**Activity beans are Spring singletons** +Temporal may invoke activity methods concurrently across many workflow executions. Keep activity implementations stateless — no mutable instance fields. Use injected services (which are themselves stateless or thread-safe) for all state. + +**`@WorkflowImpl` / `@ActivityImpl` without `workersAutoDiscovery.packages` → silently ignored** +This is the most common setup mistake. If auto-discovery packages are not configured, the annotations are never scanned and nothing registers with the worker. Verify with the Temporal UI that the worker is registering the expected workflow/activity types. + +**`ActivityOptions.setTaskQueue(...)` is required on activity stubs** +`@ActivityImpl(taskQueues = "greeting-queue")` registers the activity bean with the worker — it does not set the default task queue for activity execution. Inside workflow code, always set `.setTaskQueue(...)` in `ActivityOptions` to explicitly route activity tasks to the correct worker. + +**Multiple `DataConverter` beans** +If you define more than one `DataConverter` bean (e.g., a custom JSON converter and a default), the autoconfigure fails with an ambiguity error. Name one of them `mainDataConverter` to designate it as the primary. diff --git a/references/java/testing.md b/references/java/testing.md index 80ed9b2..b46db29 100644 --- a/references/java/testing.md +++ b/references/java/testing.md @@ -182,3 +182,74 @@ For activities that use `Activity.getExecutionContext()` or heartbeating, use `T 4. Test replay compatibility when changing workflow code (see `references/java/determinism.md`) 5. Test signal/query handlers explicitly 6. Use unique task queues per test to avoid conflicts (handled automatically by `TestWorkflowExtension`) + +## Spring Boot Testing + +Two strategies — choose one per test class, do not mix them. + +### Embedded test server in Spring context + +For full integration tests that exercise the Spring context (DB, beans, config): + +```properties +# src/test/resources/application-test.properties +spring.temporal.test-server.enabled=true +``` + +```java +@SpringBootTest +@ActiveProfiles("test") +class TeeTimeMonitorIntegrationTest { + + @Autowired + WorkflowClient client; // auto-configured to point at the embedded test server + + @Test + void testWorkflow() { + var stub = client.newWorkflowStub( + TeeTimeMonitorWorkflow.class, + WorkflowOptions.newBuilder() + .setWorkflowId("test-" + UUID.randomUUID()) + .setTaskQueue("golfnow") + .build() + ); + var result = stub.monitorTeeTimes(new TTMonitorRequest(...)); + assertNotNull(result); + } +} +``` + +The embedded server does not support time-skipping. Use this when you need Spring beans (real DB, email service, etc.) wired alongside Temporal. + +### Unit tests without Spring context + +For faster, isolated tests with time-skipping support, use `TestWorkflowExtension` or `TestWorkflowEnvironment` directly. No Spring context starts, so activity dependencies must be provided manually (real instances or Mockito mocks): + +```java +public class TeeTimeMonitorWorkflowTest { + + @RegisterExtension + static final TestWorkflowExtension testWorkflow = TestWorkflowExtension.newBuilder() + .setWorkflowTypes(TeeTimeMonitorWorkflowImpl.class) + .setDoNotStart(true) + .build(); + + @Test + void testWorkflow(TestWorkflowEnvironment env, Worker worker, WorkflowClient client) { + GolfNowActivities activities = mock(GolfNowActivities.class, withSettings().withoutAnnotations()); + when(activities.searchTeeTimes(any())).thenReturn(List.of()); + + worker.registerActivitiesImplementations(activities); + env.start(); + + var stub = client.newWorkflowStub( + TeeTimeMonitorWorkflow.class, + WorkflowOptions.newBuilder().setTaskQueue(worker.getTaskQueue()).build() + ); + stub.monitorTeeTimes(new TTMonitorRequest(...)); + verify(activities).searchTeeTimes(any()); + } +} +``` + +See the sections above for more detail on mocking, signals/queries, and replay testing. From 0e7a3125f861eca091f0c6465c3c204e693fc3e5 Mon Sep 17 00:00:00 2001 From: Donald Pinckney Date: Thu, 30 Apr 2026 16:06:59 -0400 Subject: [PATCH 40/82] Small tweak to worker setup wording (#100) --- references/dotnet/dotnet.md | 2 +- references/go/go.md | 2 +- references/java/java.md | 2 +- references/python/python.md | 2 +- references/typescript/typescript.md | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/references/dotnet/dotnet.md b/references/dotnet/dotnet.md index 437fcbb..a7f1c54 100644 --- a/references/dotnet/dotnet.md +++ b/references/dotnet/dotnet.md @@ -51,7 +51,7 @@ public class GreetingWorkflow } ``` -**Worker (Program.cs)** - Worker setup: +**Worker (Program.cs)** - Worker setup (registers activity and workflow, runs indefinitely and processes tasks): ```csharp using Temporalio.Client; diff --git a/references/go/go.md b/references/go/go.md index 546e1b1..6c42bed 100644 --- a/references/go/go.md +++ b/references/go/go.md @@ -55,7 +55,7 @@ func (a *Activities) Greet(ctx context.Context, name string) (string, error) { } ``` -**worker/main.go** - Worker setup: +**worker/main.go** - Worker setup (registers activity and workflow, runs indefinitely and processes tasks): ```go package main diff --git a/references/java/java.md b/references/java/java.md index a0ba272..2adfc6d 100644 --- a/references/java/java.md +++ b/references/java/java.md @@ -96,7 +96,7 @@ public class GreetingWorkflowImpl implements GreetingWorkflow { } ``` -**GreetingWorker.java** - Worker setup: +**GreetingWorker.java** - Worker setup (registers activity and workflow, runs indefinitely and processes tasks): ```java package greetingapp; diff --git a/references/python/python.md b/references/python/python.md index bc0a0f3..d3c0e9c 100644 --- a/references/python/python.md +++ b/references/python/python.md @@ -36,7 +36,7 @@ class GreetingWorkflow: ) ``` -**worker.py** - Worker setup (imports activity and workflow, runs indefinitely and processes tasks): +**worker.py** - Worker setup (registers activity and workflow, runs indefinitely and processes tasks): ```python import asyncio diff --git a/references/typescript/typescript.md b/references/typescript/typescript.md index 9e125cb..96fc089 100644 --- a/references/typescript/typescript.md +++ b/references/typescript/typescript.md @@ -43,7 +43,7 @@ export async function greetingWorkflow(name: string): Promise { } ``` -**worker.ts** - Worker setup (imports activities and workflows, runs indefinitely): +**worker.ts** - Worker setup (registers activity and workflow, runs indefinitely and processes tasks): ```typescript import { Worker } from '@temporalio/worker'; From 6495e5927f0cb93fc0c16c09ad93628df98a3095 Mon Sep 17 00:00:00 2001 From: Donald Pinckney Date: Thu, 30 Apr 2026 16:44:30 -0400 Subject: [PATCH 41/82] Cleanup and correct language of workflow initializers (#101) --- references/dotnet/advanced-features.md | 6 +++--- references/java/advanced-features.md | 24 ++++++++++++++++++++++++ references/python/advanced-features.md | 8 +++++--- 3 files changed, 32 insertions(+), 6 deletions(-) diff --git a/references/dotnet/advanced-features.md b/references/dotnet/advanced-features.md index fd0f81e..dd844d0 100644 --- a/references/dotnet/advanced-features.md +++ b/references/dotnet/advanced-features.md @@ -96,9 +96,9 @@ var worker = new TemporalWorker( ## Workflow Init Attribute -Use `[WorkflowInit]` on a constructor to run initialization code when a workflow is first created. +You should always put state initialization logic in the constructor of your workflow class, so that it happens before signals/updates arrive. -**Purpose:** Execute some setup code before signal/update happens or run is invoked. +Normally, your constructor must have no arguments. However, if you add the `[WorkflowInit]` attribute, then your constructor instead receives the same workflow arguments that `[WorkflowRun]` receives: ```csharp [Workflow] @@ -122,7 +122,7 @@ public class MyWorkflow } ``` -Constructor and `[WorkflowRun]` method must have the same parameters with the same types. You cannot make blocking calls (activities, sleeps, etc.) from the constructor. +Constructor (with `[WorkflowInit]`) 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 diff --git a/references/java/advanced-features.md b/references/java/advanced-features.md index e736da2..9db730c 100644 --- a/references/java/advanced-features.md +++ b/references/java/advanced-features.md @@ -116,6 +116,30 @@ worker.registerActivitiesImplementations(new MyActivitiesImpl()); factory.start(); ``` +## Workflow Init Annotation + +You should always put state initialization logic in the constructor of your workflow class, so that it happens before signals/updates arrive. + +Normally, your constructor must have no arguments. However, if you add the `@WorkflowInit` annotation, then your constructor instead receives the same workflow arguments that `run` receives: + +```java +public class MyWorkflowImpl implements MyWorkflow { + private final int foo; + + @WorkflowInit + public MyWorkflowImpl(MyInput input) { + foo = 1234; + } + + @Override + public ClusterManagerResult run(ClusterManagerInput input) { + // this.foo is already initialized + } +} +``` + +Constructor (with `@WorkflowInit`) and `run` 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 failures. diff --git a/references/python/advanced-features.md b/references/python/advanced-features.md index 3d86e9f..c5ec1b3 100644 --- a/references/python/advanced-features.md +++ b/references/python/advanced-features.md @@ -116,9 +116,9 @@ worker = Worker( ## Workflow Init Decorator -Use `@workflow.init` to run initialization code when a workflow is first created. +You should always put state initialization logic in the `__init__` of your workflow class, so that it happens before signals/updates arrive. -**Purpose:** Execute some setup code before signal/update happens or run is invoked. +Normally, your `__init__` must have no arguments. However, if you add the `@workflow.init` decorator, then your `__init__` instead receives the same workflow arguments that `@workflow.run` receives: ```python @workflow.defn @@ -130,11 +130,13 @@ class MyWorkflow: self._items: list[str] = [] @workflow.run - async def run(self) -> str: + async def run(self, initial_value: str) -> str: # self._value and self._items are already initialized return self._value ``` +`__init__` (with `@workflow.init`) and `@workflow.run` must have the same parameters with the same types. You cannot make blocking calls (activities, sleeps, etc.) from the `__init__`. + ## Workflow Failure Exception Types Control which exceptions cause workflow task failures vs workflow failures. From bd513b1f11840d99eb793b2af29bf0c83ee0ae7e Mon Sep 17 00:00:00 2001 From: Donald Pinckney Date: Thu, 30 Apr 2026 16:52:10 -0400 Subject: [PATCH 42/82] Separate out CLI installation instructions to file (#102) --- SKILL.md | 26 +------------------------- references/core/install_cli.md | 25 +++++++++++++++++++++++++ 2 files changed, 26 insertions(+), 25 deletions(-) create mode 100644 references/core/install_cli.md diff --git a/SKILL.md b/SKILL.md index fe6650c..ff770cd 100644 --- a/SKILL.md +++ b/SKILL.md @@ -48,31 +48,7 @@ See `references/core/determinism.md` for detailed explanation. ### Ensure Temporal CLI is installed -Check if `temporal` CLI is installed. If not, follow these instructions: - -#### macOS - -``` -brew install temporal -``` - -#### Linux - -Check your machine's architecture and download the appropriate archive: - -- [Linux amd64](https://temporal.download/cli/archive/latest?platform=linux&arch=amd64) -- [Linux arm64](https://temporal.download/cli/archive/latest?platform=linux&arch=arm64) - -Once you've downloaded the file, extract the downloaded archive and add the temporal binary to your PATH by copying it to a directory like /usr/local/bin - -#### Windows - -Check your machine's architecture and download the appropriate archive: - -- [Windows amd64](https://temporal.download/cli/archive/latest?platform=windows&arch=amd64) -- [Windows arm64](https://temporal.download/cli/archive/latest?platform=windows&arch=arm64) - -Once you've downloaded the file, extract the downloaded archive and add the temporal.exe binary to your PATH. +Check if `temporal` CLI is installed. If not, follow the instructions at `references/core/install_cli.md` to install it for your platform. ### Read All Relevant References diff --git a/references/core/install_cli.md b/references/core/install_cli.md new file mode 100644 index 0000000..4421172 --- /dev/null +++ b/references/core/install_cli.md @@ -0,0 +1,25 @@ +# How to install Temporal CLI + +## macOS + +``` +brew install temporal +``` + +## Linux + +Check your machine's architecture and download the appropriate archive: + +- [Linux amd64](https://temporal.download/cli/archive/latest?platform=linux&arch=amd64) +- [Linux arm64](https://temporal.download/cli/archive/latest?platform=linux&arch=arm64) + +Once you've downloaded the file, extract the downloaded archive and add the temporal binary to your PATH by copying it to a directory like /usr/local/bin + +## Windows + +Check your machine's architecture and download the appropriate archive: + +- [Windows amd64](https://temporal.download/cli/archive/latest?platform=windows&arch=amd64) +- [Windows arm64](https://temporal.download/cli/archive/latest?platform=windows&arch=arm64) + +Once you've downloaded the file, extract the downloaded archive and add the temporal.exe binary to your PATH. \ No newline at end of file From 812f32ee5848e3625d2ebb0fe5b21b7e5299ec99 Mon Sep 17 00:00:00 2001 From: Donald Pinckney Date: Wed, 13 May 2026 13:46:18 -0400 Subject: [PATCH 43/82] Add integrations catalog + per-language integrations/ layout (#151) * Add integrations catalog and per-language integrations/ layout Adds references/integrations.md as a single catalog table for third-party plugins and integrations (one row per integration: language, what it does, link to a reference file). Reference files live under references/{language}/integrations/. Pre-seeds the catalog by moving the existing Spring Boot reference into the new layout (references/java/integrations/spring-boot.md) and updating its inbound links. SKILL.md gains a single 3-line "Third-Party Integrations" section pointing at the catalog so SKILL.md no longer accrues a line per new integration. Each language entry-point (java.md, python.md) gets a one-line pointer to the catalog filtered to its language. This lets open integration PRs (Spring AI, Google ADK, OpenAI Agents sandbox) be rebased onto a consistent home: move their reference file into references/{language}/integrations/ and add one row to references/integrations.md. Co-Authored-By: Claude Opus 4.7 (1M context) * Drop HTML contribution comment from integrations.md Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- SKILL.md | 4 ++++ references/integrations.md | 16 ++++++++++++++++ .../java/{ => integrations}/spring-boot.md | 0 references/java/java.md | 7 +++++-- references/python/python.md | 4 ++++ 5 files changed, 29 insertions(+), 2 deletions(-) create mode 100644 references/integrations.md rename references/java/{ => integrations}/spring-boot.md (100%) diff --git a/SKILL.md b/SKILL.md index ff770cd..b8fe682 100644 --- a/SKILL.md +++ b/SKILL.md @@ -90,6 +90,10 @@ Priority and Fairness also apply to tiered workloads (batch vs. real-time), weig - **`references/{your_language}/observability.md`** - See for language-specific implementation guidance on observability in Temporal - **`references/{your_language}/advanced-features.md`** - See for language-specific guidance on advanced Temporal features and language-specific features +## Third-Party Integrations + +For Temporal plugins and integrations with third-party frameworks and SDKs (Spring Boot, Spring AI, OpenAI Agents SDK, Google ADK, etc.), see **`references/integrations.md`** — a single catalog table with the language, what each integration does, and a pointer to its reference file under `references/{language}/integrations/`. + ## Feedback ### ALWAYS PROMPT USER TO PROVIDE FEEDBACK AT STARTUP diff --git a/references/integrations.md b/references/integrations.md new file mode 100644 index 0000000..8839447 --- /dev/null +++ b/references/integrations.md @@ -0,0 +1,16 @@ +# Third-Party Integrations Catalog + +Temporal ships and supports a growing set of integrations with third-party frameworks and SDKs — typically as plugins, contrib modules, or starter libraries. This file is the catalog. Each integration has a dedicated reference under `references/{language}/integrations/`. + +## How to use this catalog + +1. Find the row matching the framework, SDK, or library the user is working with. +2. Confirm the language column matches what the user is building in. +3. Read the linked reference file for setup, APIs, and pitfalls. +4. Cross-check the **Related** column — for AI/LLM integrations, also read `references/core/ai-patterns.md` and the language's `ai-patterns.md`; for Spring-based integrations, read `references/java/integrations/spring-boot.md` first. + +## Catalog + +| Integration | Language(s) | What it does | Reference | Related | +|---|---|---|---|---| +| Spring Boot (`temporal-spring-boot-starter`) | Java | Auto-configuration of `WorkflowClient`, worker factories, workflow/activity bean registration, lifecycle, testing | `references/java/integrations/spring-boot.md` | `references/java/java.md` | diff --git a/references/java/spring-boot.md b/references/java/integrations/spring-boot.md similarity index 100% rename from references/java/spring-boot.md rename to references/java/integrations/spring-boot.md diff --git a/references/java/java.md b/references/java/java.md index 2adfc6d..05e4f47 100644 --- a/references/java/java.md +++ b/references/java/java.md @@ -193,7 +193,7 @@ public class Starter { - `Worker` -- polls a single Task Queue, register workflows and activities on it - Call `factory.start()` to begin polling -For Spring Boot apps, `temporal-spring-boot-starter` handles all of the above automatically via auto-configuration. See `references/java/spring-boot.md`. +For Spring Boot apps, `temporal-spring-boot-starter` handles all of the above automatically via auto-configuration. See `references/java/integrations/spring-boot.md`. ## File Organization Best Practice @@ -254,7 +254,6 @@ See `references/java/testing.md` for info on writing tests. ### Reference Files -- **`references/java/spring-boot.md`** - Spring Boot integration: auto-discovery, dependency injection, worker lifecycle, testing - **`references/java/patterns.md`** - Signals, queries, child workflows, saga pattern, etc. - **`references/java/determinism.md`** - Determinism rules and safe alternatives for Java - **`references/java/gotchas.md`** - Java-specific mistakes and anti-patterns @@ -264,3 +263,7 @@ See `references/java/testing.md` for info on writing tests. - **`references/java/advanced-features.md`** - Schedules, worker tuning, and more - **`references/java/data-handling.md`** - Data converters, Jackson, payload encryption - **`references/java/versioning.md`** - Patching API, workflow type versioning, Worker Versioning + +### Java Integrations + +For Java-specific third-party integrations (Spring Boot, Spring AI, etc.), see `references/integrations.md` and filter for Java. Reference files live under `references/java/integrations/`. diff --git a/references/python/python.md b/references/python/python.md index d3c0e9c..5493387 100644 --- a/references/python/python.md +++ b/references/python/python.md @@ -182,3 +182,7 @@ See `references/python/testing.md` for info on writing tests. - **`references/python/versioning.md`** - Patching API, workflow type versioning, Worker Versioning - **`references/python/determinism-protection.md`** - Python sandbox specifics, forbidden operations, pass-through imports - **`references/python/ai-patterns.md`** - LLM integration, Pydantic data converter, AI workflow patterns + +### Python Integrations + +For Python-specific third-party integrations (OpenAI Agents SDK, Google ADK, etc.), see `references/integrations.md` and filter for Python. Reference files live under `references/python/integrations/`. From 3c8ed616bd385ec34fd01a7b2a67d454d358634b Mon Sep 17 00:00:00 2001 From: "skill-temporal-developer-updater[bot]" <276371939+skill-temporal-developer-updater[bot]@users.noreply.github.com> Date: Thu, 14 May 2026 14:03:01 -0400 Subject: [PATCH 44/82] Implement planned topic: 0018-spring-ai (#185) * Finalize draft for 0018-spring-ai * Update references/java/integrations/spring-ai.md * Update references/java/integrations/spring-ai.md * Update references/java/integrations/spring-ai.md * Update references/java/integrations/spring-ai.md --------- Co-authored-by: skill-sync[bot] Co-authored-by: Donald Pinckney --- references/integrations.md | 1 + references/java/integrations/spring-ai.md | 248 ++++++++++++++++++++++ 2 files changed, 249 insertions(+) create mode 100644 references/java/integrations/spring-ai.md diff --git a/references/integrations.md b/references/integrations.md index 8839447..53cf1df 100644 --- a/references/integrations.md +++ b/references/integrations.md @@ -14,3 +14,4 @@ Temporal ships and supports a growing set of integrations with third-party frame | Integration | Language(s) | What it does | Reference | Related | |---|---|---|---|---| | Spring Boot (`temporal-spring-boot-starter`) | Java | Auto-configuration of `WorkflowClient`, worker factories, workflow/activity bean registration, lifecycle, testing | `references/java/integrations/spring-boot.md` | `references/java/java.md` | +| Spring AI (`temporal-spring-ai`) | Java | Durable Spring AI agents: chat-model calls run as Activities; tools dispatched per type (Activity stub, Nexus stub, `@SideEffectTool`, plain); vector stores, embeddings, and MCP clients auto-registered | `references/java/integrations/spring-ai.md` | `references/java/integrations/spring-boot.md`, `references/core/ai-patterns.md` | diff --git a/references/java/integrations/spring-ai.md b/references/java/integrations/spring-ai.md new file mode 100644 index 0000000..5ee0704 --- /dev/null +++ b/references/java/integrations/spring-ai.md @@ -0,0 +1,248 @@ +# Temporal Spring AI Integration + +## Overview + +`temporal-spring-ai` makes [Spring AI](https://docs.spring.io/spring-ai/reference/) agents durable on the Temporal Java SDK: chat-model calls run through Temporal Activities that are recorded in Workflow history, and tools are dispatched per their declared type so each kind lands in the right place in Workflow execution. + +The integration is built on the Java SDK Plugin system and ships as the `io.temporal:temporal-spring-ai` module alongside the existing [`temporal-spring-boot-starter`](spring-boot.md) — which is a **required companion module**. + +> [!NOTE] +> This feature is in Public Preview. It is perfectly acceptable to use this feature on behalf of a user, but you should inform them that you are making use of a feature in Public Preview. + +For general Temporal AI/LLM patterns (retries, rate limits, timeouts, multi-agent orchestration) see `references/core/ai-patterns.md`. For Spring Boot autoconfigure mechanics (worker lifecycle, `@WorkflowImpl`, `@ActivityImpl`, auto-discovery) see `references/java/integrations/spring-boot.md`. + +## Prerequisites + +The integration is auto-configured only when **all four** are on the classpath at or above these versions: + +| Dependency | Minimum version | +| ----------------- | --------------- | +| Java | 17 | +| Spring Boot | 3.x | +| Spring AI | 1.1.0 | +| Temporal Java SDK | 1.35.0 | + +You also need `temporal-spring-boot-starter` and a Spring AI **model starter** — for example `spring-ai-starter-model-openai`. `temporal-spring-ai` does not pull in a model provider on its own. + +## Add the dependency + +**Maven:** + +```xml + + io.temporal + temporal-spring-ai + ${temporal-sdk.version} + +``` + +**Gradle (Groovy DSL):** + +```groovy +implementation "io.temporal:temporal-spring-ai:${temporalSdkVersion}" +``` + +With `temporal-spring-ai` on the classpath, `SpringAiPlugin` auto-registers `ChatModelActivity` with every Temporal Worker created by the Spring Boot integration. Three more Activities auto-register when their dependencies are also present: + +| Feature | Required dependency | Auto-registered Activity | +| ------------ | ------------------- | ------------------------- | +| Vector store | `spring-ai-rag` | `VectorStoreActivity` | +| Embeddings | `spring-ai-rag` | `EmbeddingModelActivity` | +| MCP | `spring-ai-mcp` | `McpClientActivity` | + +Two name pairs are easy to confuse: + +- `ActivityChatModel` is the workflow-side factory you call to obtain a Spring AI `ChatModel`. `ChatModelActivity` is the underlying Activity class that the plugin registers with the worker. +- `ActivityMcpClient` is the workflow-side factory that wraps a Spring AI MCP client. `McpClientActivity` is the auto-registered Activity. + +## Call a chat model from a Workflow + +Use `ActivityChatModel` as a Spring AI `ChatModel` inside a Workflow — every call runs through a Temporal Activity, so responses are durable and retried according to your Activity options. Wrap it in a `TemporalChatClient` to build prompts, register tools, and attach advisors: + +```java +@WorkflowInit +public ChatWorkflowImpl(String systemPrompt) { + ActivityChatModel activityChatModel = ActivityChatModel.forDefault(); + + WeatherActivity weatherTool = + Workflow.newActivityStub( + WeatherActivity.class, + ActivityOptions.newBuilder() + .setStartToCloseTimeout(Duration.ofSeconds(30)) + .setRetryOptions(RetryOptions.newBuilder().setMaximumAttempts(3).build()) + .build()); + + StringTools stringTools = new StringTools(); // plain workflow tool + TimestampTools timestampTools = new TimestampTools(); // @SideEffectTool + + ChatMemory chatMemory = + MessageWindowChatMemory.builder() + .chatMemoryRepository(new InMemoryChatMemoryRepository()) + .maxMessages(20) + .build(); + + this.chatClient = + TemporalChatClient.builder(activityChatModel) + .defaultSystem(systemPrompt) + .defaultTools(weatherTool, stringTools, timestampTools) + .defaultAdvisors(PromptChatMemoryAdvisor.builder(chatMemory).build()) + .build(); +} +``` + +`ActivityChatModel.forDefault()` resolves to the default Spring AI `ChatModel` bean. To target a specific model in a multi-model application, pass its bean name: `ActivityChatModel.forModel("openai")`. + +**Streaming responses are not currently supported.** + +## Register tools + +The integration extends Spring AI's tool-registration model by inspecting the type of each tool you pass to `defaultTools(...)` (or per-prompt `tools(...)`) and dispatching it to the appropriate Temporal primitive. You can mix all four kinds in the same chat client. + +### Activity stubs + +An interface annotated with both `@ActivityInterface` and Spring AI `@Tool` methods is auto-detected and executed as a Temporal Activity. Use this for external calls that need retries and timeouts. + +```java +@ActivityInterface +public interface WeatherActivity { + @Tool(description = "Get the current weather for a city. Returns temperature, conditions, and humidity.") + @ActivityMethod + String getWeather(@ToolParam(description = "The name of the city") String city); +} +``` + +### Nexus service stubs + +Nexus service stubs with `@Tool` methods are auto-detected and invoked as Nexus operations, enabling cross-Namespace tool calls. + +### `@SideEffectTool` + +Classes annotated with `@SideEffectTool` have each `@Tool` method wrapped in `Workflow.sideEffect()`. The result is recorded in history on first execution and replayed afterward, so cheap non-deterministic operations (timestamps, UUIDs) stay safe under replay. + +```java +@SideEffectTool +public class TimestampTools { + @Tool(description = "Get the current date and time") + public String getCurrentDateTime() { + return FORMATTER.format(Instant.now()); + } + + @Tool(description = "Generate a random UUID") + public String generateUuid() { + return UUID.randomUUID().toString(); + } +} +``` + +### Plain tools + +Any class with `@Tool` methods that is **not** an Activity stub, Nexus stub, or `@SideEffectTool` runs **directly on the Workflow thread**. Use this for deterministic tools (such as updating in-memory agent state) or for orchestration of durable primitives — calling multiple Activities, child Workflows, wait conditions, or other Temporal primitives from inside the tool method. + +```java +public class StringTools { + @Tool(description = "Reverse a string, returning the characters in opposite order") + public String reverse(@ToolParam(description = "The string to reverse") String input) { + return new StringBuilder(input).reverse().toString(); + } +} +``` + +Determinism still applies: plain tools execute on the workflow thread, so they must follow the same rules as workflow code (no I/O, no system clock, no random sources). See `references/java/determinism.md` and `references/core/determinism.md`. + +## Activity options and retry behavior + +`ActivityChatModel.forDefault()` and `forModel(name)` build the chat Activity stub with these defaults: + +- 2-minute start-to-close timeout +- 3 attempts +- `org.springframework.ai.retry.NonTransientAiException` and `java.lang.IllegalArgumentException` classified as non-retryable, so a bad API key or invalid prompt fails fast + +Pass `ActivityOptions` directly for finer control — a specific Task Queue, heartbeats, priority, or a custom `RetryOptions`: + +```java +ActivityChatModel chatModel = ActivityChatModel.forDefault( + ActivityOptions.newBuilder(ActivityChatModel.defaultActivityOptions()) + .setTaskQueue("chat-heavy") + .build()); +``` + +For configuration-driven per-model overrides, declare a `ChatModelActivityOptions` bean. The plugin consults it whenever `forDefault()` or `forModel(name)` runs in a Workflow. The special key `ChatModelTypes.DEFAULT_MODEL_NAME` (the literal `"default"`) is a global catch-all that applies to any model not explicitly listed, including models contributed by third-party starters: + +```java +@Bean +public ChatModelActivityOptions chatModelActivityOptions() { + return new ChatModelActivityOptions( + Map.of( + "anthropicChatModel", + ActivityOptions.newBuilder(ActivityChatModel.defaultActivityOptions()) + .setStartToCloseTimeout(Duration.ofMinutes(5)) + .setScheduleToCloseTimeout(Duration.ofMinutes(15)) + .build())); +} +``` + +Keys that neither match a registered `ChatModel` bean nor equal `"default"` cause plugin construction to fail, so a typo surfaces at startup rather than at first call. + +`ActivityMcpClient.create()` and `create(ActivityOptions)` work the same way for MCP tool calls, with a **30-second default timeout**. + +## Provider-specific chat options + +Provider-specific `ChatOptions` subclasses — for example, `AnthropicChatOptions` to enable extended thinking, or `OpenAiChatOptions` to set `reasoning_effort` — pass through the Activity boundary unchanged. Attach them via `ChatClient.defaultOptions(...)` and the plugin re-applies them on the Activity side before calling the underlying model: + +```java +AnthropicChatOptions thinkingOptions = + AnthropicChatOptions.builder() + .thinking(AnthropicApi.ThinkingType.ENABLED, 1024) + .temperature(1.0) + .maxTokens(4096) + .build(); + +chatClients.put( + "think", + TemporalChatClient.builder(anthropicModel) + .defaultSystem("You are a helpful assistant with extended thinking. ...") + .defaultOptions(thinkingOptions) + .build()); +``` + +The pass-through relies on the `ChatOptions` subclass overriding `copy()` to return its own type. Every provider class shipped with Spring AI does. + +## Media in messages + +Prefer **URI-based media** when attaching images, audio, or other binary content to chat messages. Raw `byte[]` media is serialized into every chat Activity's input and result payload, which lands inside Temporal Workflow history events. Server-side history events have a fixed **2 MiB** size limit; to leave headroom for messages, tool definitions, and options, the plugin enforces a **1 MiB default cap** on inline bytes and fails fast with a non-retryable `ApplicationFailure` pointing at the URI alternative. + +```java +Media image = new Media(MimeTypeUtils.IMAGE_PNG, URI.create("https://cdn.example.com/pic.png")); +``` + +For anything larger than a small thumbnail, route the bytes to a binary store from an Activity and pass only the URL across the conversation. + + +## Vector stores, embeddings, and MCP + +When the corresponding Spring AI modules (`spring-ai-rag`, `spring-ai-mcp`) are on the classpath, the integration registers Activities for vector stores, embeddings, and MCP tool calls automatically. Inject the matching Spring AI types into your Activities or Workflows and use them as you would in any Spring AI application — each operation executes through a Temporal Activity. + +You can also register these plugins explicitly, without relying on auto-configuration: + +```java +new VectorStorePlugin(vectorStore); +new EmbeddingModelPlugin(embeddingModel); +new McpPlugin(); +``` + +`ActivityMcpClient` wraps a Spring AI MCP client so that remote MCP tool calls become durable Activity executions. + +## Common pitfalls + +- **Auto-configuration silently skipped.** The plugin only runs when Java ≥ 17, Spring Boot 3.x, Spring AI ≥ 1.1.0, and Temporal Java SDK ≥ 1.35.0 are *all* present. If you upgrade one and not the others, the integration won't auto-register and tool dispatch falls back to plain Spring AI behavior. +- **Missing model starter.** `temporal-spring-ai` does not bring its own model provider; you also need a Spring AI model starter such as `spring-ai-starter-model-openai`. +- **Streaming.** Streaming responses are not currently supported — use non-streaming `call(...)` paths. +- **Typos in `ChatModelActivityOptions` keys fail at startup, not at first call.** Any key that isn't a registered `ChatModel` bean name and isn't the literal `"default"` (`ChatModelTypes.DEFAULT_MODEL_NAME`) prevents plugin construction. +- **Inline media over 1 MiB throws non-retryable `ApplicationFailure`.** Switch to URI-based `Media` as needed. +- **Plain tools run on the workflow thread.** Plain `@Tool` classes are not Activities — they must obey workflow determinism rules. If a tool needs the system clock, file system, or network, make it an `@ActivityInterface` tool or annotate the class `@SideEffectTool`. + +## Resources + +- `references/java/integrations/spring-boot.md` — required companion module; covers `WorkflowClient` injection, worker lifecycle, auto-discovery, testing. +- `references/core/ai-patterns.md` — language-agnostic AI/LLM patterns (Activities wrap LLM calls, retry centralization, multi-agent orchestration). +- `references/java/determinism.md` and `references/core/determinism.md` — replay rules that plain tools and `@SideEffectTool` tools must respect. From 93a81ac6cd5086eafcb7612fb75e7dda63dd620f Mon Sep 17 00:00:00 2001 From: "skill-temporal-developer-updater[bot]" <276371939+skill-temporal-developer-updater[bot]@users.noreply.github.com> Date: Fri, 15 May 2026 14:09:58 -0400 Subject: [PATCH 45/82] Implement planned topic: 0028-google-adk (#207) * Finalize draft for 0028-google-adk * Apply suggestions from code review Co-authored-by: Donald Pinckney --------- Co-authored-by: skill-sync[bot] Co-authored-by: Donald Pinckney Co-authored-by: Donald Pinckney --- references/integrations.md | 1 + references/java/integrations/spring-ai.md | 1 - references/python/integrations/google-adk.md | 219 +++++++++++++++++++ 3 files changed, 220 insertions(+), 1 deletion(-) create mode 100644 references/python/integrations/google-adk.md diff --git a/references/integrations.md b/references/integrations.md index 53cf1df..c45dede 100644 --- a/references/integrations.md +++ b/references/integrations.md @@ -15,3 +15,4 @@ Temporal ships and supports a growing set of integrations with third-party frame |---|---|---|---|---| | Spring Boot (`temporal-spring-boot-starter`) | Java | Auto-configuration of `WorkflowClient`, worker factories, workflow/activity bean registration, lifecycle, testing | `references/java/integrations/spring-boot.md` | `references/java/java.md` | | Spring AI (`temporal-spring-ai`) | Java | Durable Spring AI agents: chat-model calls run as Activities; tools dispatched per type (Activity stub, Nexus stub, `@SideEffectTool`, plain); vector stores, embeddings, and MCP clients auto-registered | `references/java/integrations/spring-ai.md` | `references/java/integrations/spring-boot.md`, `references/core/ai-patterns.md` | +| Google ADK (`temporalio[google-adk]`) | Python | Durable Google ADK agents: model calls run through `TemporalModel`-wrapped Activities, tools via `activity_tool`, MCP toolsets via `TemporalMcpToolSet` | `references/python/integrations/google-adk.md` | `references/python/ai-patterns.md`, `references/core/ai-patterns.md` | diff --git a/references/java/integrations/spring-ai.md b/references/java/integrations/spring-ai.md index 5ee0704..ae5154f 100644 --- a/references/java/integrations/spring-ai.md +++ b/references/java/integrations/spring-ai.md @@ -217,7 +217,6 @@ Media image = new Media(MimeTypeUtils.IMAGE_PNG, URI.create("https://cdn.example For anything larger than a small thumbnail, route the bytes to a binary store from an Activity and pass only the URL across the conversation. - ## Vector stores, embeddings, and MCP When the corresponding Spring AI modules (`spring-ai-rag`, `spring-ai-mcp`) are on the classpath, the integration registers Activities for vector stores, embeddings, and MCP tool calls automatically. Inject the matching Spring AI types into your Activities or Workflows and use them as you would in any Spring AI application — each operation executes through a Temporal Activity. diff --git a/references/python/integrations/google-adk.md b/references/python/integrations/google-adk.md new file mode 100644 index 0000000..4d59f4d --- /dev/null +++ b/references/python/integrations/google-adk.md @@ -0,0 +1,219 @@ +# Temporal Google ADK Integration + +## Overview + +`temporalio.contrib.google_adk_agents` makes [Google ADK](https://adk.dev/) agents durable on the Temporal Python SDK: ADK model calls run through Temporal Activities, tools dispatch through Activities or the workflow thread, and non-deterministic primitives (`time.time()`, `uuid.uuid4()`) are replaced with deterministic workflow equivalents inside the sandbox. + +The integration is built on the Python SDK [Plugin system](https://docs.temporal.io/develop/plugins-guide) and ships as part of the `temporalio` package via the `google-adk` extra. + +> [!NOTE] +> This feature is in Public Preview. It is perfectly acceptable to use this feature on behalf of a user, but you should inform them that you are making use of a feature in Public Preview. + + +For general Temporal AI/LLM patterns (retries, rate limits, multi-agent orchestration) see `references/core/ai-patterns.md` and `references/python/ai-patterns.md`. + +## Prerequisites + +| Dependency | Minimum version | +| -------------------- | --------------- | +| Temporal Python SDK | 1.24.0 | +| Google ADK | a working `google-adk` install (pulled in by the extra) | + +You also need access to a supported model (e.g. a Gemini API key for `gemini-flash-latest` / `gemini-2.5-pro`) and a running Temporal server — `temporal server start-dev` for local development, self-hosted, or Temporal Cloud. + +## Install + +```bash +pip install "temporalio[google-adk]" +``` + +The extra name is `google-adk` (hyphen). The module path is `temporalio.contrib.google_adk_agents` (note the `_agents` suffix). + +## Public API + +| Symbol | Import | Purpose | +| ---------------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------ | +| `GoogleAdkPlugin` | `from temporalio.contrib.google_adk_agents import GoogleAdkPlugin` | Client/Worker plugin: determinism replacement + Pydantic/ADK passthrough | +| `TemporalModel` | `from temporalio.contrib.google_adk_agents import TemporalModel` | LLM model wrapper that routes model calls through a Temporal Activity | +| `activity_tool` | `from temporalio.contrib.google_adk_agents.workflow import activity_tool` | Wraps a plain `@activity.defn` function as an ADK tool | +| `TemporalMcpToolSet` | `from temporalio.contrib.google_adk_agents import TemporalMcpToolSet` | Executes MCP tools as Temporal Activities | +| `TemporalMcpToolSetProvider` | `from temporalio.contrib.google_adk_agents import TemporalMcpToolSetProvider` | Factory wired into `GoogleAdkPlugin(toolset_providers=[...])` so MCP activities register with the worker | + +## Configure the plugin on Client and Worker + +`GoogleAdkPlugin()` must be attached to the Temporal `Client` used to build the worker. It installs the determinism replacements, configures Pydantic/ADK serialization, and registers the model and MCP activities on the worker. + +```python +import asyncio +from temporalio.client import Client +from temporalio.worker import Worker +from temporalio.contrib.google_adk_agents import GoogleAdkPlugin + +async def main(): + client = await Client.connect( + "localhost:7233", + plugins=[GoogleAdkPlugin()], + ) + + worker = Worker( + client, + task_queue="my-agent-task-queue", + workflows=[WeatherAgentWorkflow], + activities=[get_weather], + ) + await worker.run() + +asyncio.run(main()) +``` + +Attach the same plugin when starting workflows from a client process so that data converters and toolset providers line up across both sides: + +```python +client = await Client.connect( + "localhost:7233", + plugins=[GoogleAdkPlugin()], +) +result = await client.execute_workflow( + WeatherAgentWorkflow.run, + "What's the weather in San Francisco?", + id="weather-agent-1", + task_queue="my-agent-task-queue", +) +``` + +## Define tools with `activity_tool` + +Write the underlying function as a normal Temporal Activity, then wrap it with `activity_tool(...)` to produce a tool that ADK can attach to an Agent. The wrapper records timeouts and retry policy at construction time, so each invocation runs as a Temporal Activity with those settings. + +```python +from datetime import timedelta +from temporalio import activity +from temporalio.common import RetryPolicy +from temporalio.contrib.google_adk_agents.workflow import activity_tool + +@activity.defn +async def get_weather(city: str) -> str: + return f"72°F and sunny in {city}" + +weather_tool = activity_tool( + get_weather, + start_to_close_timeout=timedelta(seconds=30), + retry_policy=RetryPolicy(maximum_attempts=3), +) +``` + +Register the underlying `@activity.defn` function on the worker's `activities=` list — `activity_tool` does **not** auto-register it. + +## Use `TemporalModel` on the Agent + +`TemporalModel(model_name, activity_config=ActivityConfig(...))` wraps any ADK-supported model so each call lands in a Temporal Activity. `ActivityConfig` comes from `temporalio.workflow` and accepts the usual per-Activity settings (timeouts, retry policy, task queue, summary, etc.). + +```python +from google.adk.agents import Agent +from temporalio.contrib.google_adk_agents import TemporalModel +from temporalio.workflow import ActivityConfig + +agent = Agent( + name="weather_agent", + model=TemporalModel( + "gemini-flash-latest", + activity_config=ActivityConfig(summary="Weather Agent"), + ), + tools=[weather_tool], +) +``` + +Streaming responses are not currently supported through `TemporalModel`. + +## Wrap the agent in a Workflow + +Workflows drive ADK in the same way as a non-Temporal program: build an `InMemoryRunner`, create a session, and iterate `run_async`. Use `contextlib.aclosing` so the async generator is closed deterministically, and consume the events to collect the final assistant text. + +```python +from contextlib import aclosing +from google.adk.runners import InMemoryRunner +from google.genai import types +from temporalio import workflow + +@workflow.defn +class WeatherAgentWorkflow: + @workflow.run + async def run(self, user_message: str) -> str: + runner = InMemoryRunner(agent=agent, app_name="weather_app") + session = await runner.session_service.create_session( + user_id="user", app_name="weather_app", + ) + result = "" + async with aclosing( + runner.run_async( + user_id="user", + session_id=session.id, + new_message=types.Content( + role="user", + parts=[types.Part.from_text(text=user_message)], + ), + ) + ) as events: + async for event in events: + if event.content and event.content.parts: + for part in event.content.parts: + if part.text: + result = part.text + return result +``` + +Determinism rules still apply to the workflow body itself: don't read the wall clock, generate random IDs, or perform I/O outside Activities or the ADK-managed model/tool calls. The plugin redirects `time.time()` and `uuid.uuid4()` to `workflow.now()` and `workflow.uuid4()` when running inside a workflow, but new non-deterministic call sites you add (e.g. `random`, `datetime.now()`, network I/O) are not covered. + +## MCP tools + +To expose [MCP](https://modelcontextprotocol.io/) tools to an ADK agent through Temporal, register a `TemporalMcpToolSetProvider` on the plugin and attach a matching `TemporalMcpToolSet` to the agent. The provider factory builds the underlying ADK `McpToolset`; the same factory is also passed as `not_in_workflow_toolset=` on the workflow-side `TemporalMcpToolSet` so local runs (e.g. `adk run` / `adk web`) execute the toolset directly. + +```python +from google.adk.tools.mcp_tool import McpToolset +from google.adk.tools.mcp_tool.mcp_session_manager import StdioConnectionParams +from mcp import StdioServerParameters +from temporalio.contrib.google_adk_agents import ( + GoogleAdkPlugin, + TemporalMcpToolSet, + TemporalMcpToolSetProvider, + TemporalModel, +) + +def toolset_factory(_): + return McpToolset( + connection_params=StdioConnectionParams( + server_params=StdioServerParameters( + command="npx", + args=["-y", "@modelcontextprotocol/server-filesystem", "/path"], + ), + ), + ) + +toolset_provider = TemporalMcpToolSetProvider("my-tools", toolset_factory) + +client = await Client.connect( + "localhost:7233", + plugins=[GoogleAdkPlugin(toolset_providers=[toolset_provider])], +) + +agent = Agent( + name="tool_agent", + model=TemporalModel("gemini-flash-latest"), + tools=[TemporalMcpToolSet("my-tools", not_in_workflow_toolset=toolset_factory)], +) +``` + +The provider name (`"my-tools"` above) must match between `TemporalMcpToolSetProvider` and `TemporalMcpToolSet`. Always pass `not_in_workflow_toolset=` — without it, local execution outside a workflow has no fallback path. + +## Local fallback for `adk run` / `adk web` + +`TemporalModel`, `activity_tool`, and `TemporalMcpToolSet` detect when they are invoked outside a workflow and execute directly against the underlying model, function, or MCP toolset. The same agent definition therefore works both under `adk run` / `adk web` for local development and under a Temporal worker in production — no separate code paths. + +## Common mistakes + +- **Mixing up `ActivityConfig` and `ActivityOptions`.** `TemporalModel` takes `activity_config=ActivityConfig(...)` from `temporalio.workflow`. +- **Forgetting `not_in_workflow_toolset` on `TemporalMcpToolSet`.** Required for local fallback. +- **Skipping `GoogleAdkPlugin()` on the client used by `execute_workflow`.** Data-converter and toolset wiring lives on the plugin; using only the worker-side plugin leads to serialization errors. +- **Registering `activity_tool(...)` wrappers in `activities=`.** Register the underlying `@activity.defn` function instead. +- **Forgetting non-determinism rules.** As with Temporal broadly, workflow code must be deterministic. This plugin makes it a bit easier by auto-replacing common sources of non-determinism with Temporal durable variants, but you are strongly encouraged to just use those explicitly. In workflow code: use `workflow.now()`, etc. In activity code: use `time.time()` or whatever standard non-deterministic things, NOT `workflow.*` calls. +- **Expecting streaming responses.** Not currently supported via `TemporalModel`. From 43928a1734a27e94cbbd0dd092b41c05ef7dfc40 Mon Sep 17 00:00:00 2001 From: "skill-temporal-developer-updater[bot]" <276371939+skill-temporal-developer-updater[bot]@users.noreply.github.com> Date: Fri, 15 May 2026 14:47:16 -0400 Subject: [PATCH 46/82] Implement planned topic: 0030-langgraph-plugin (#212) * Finalize draft for 0030-langgraph-plugin * Apply suggestions from code review Co-authored-by: Donald Pinckney --------- Co-authored-by: skill-sync[bot] Co-authored-by: Donald Pinckney Co-authored-by: Donald Pinckney --- references/integrations.md | 1 + references/python/integrations/langgraph.md | 217 ++++++++++++++++++++ 2 files changed, 218 insertions(+) create mode 100644 references/python/integrations/langgraph.md diff --git a/references/integrations.md b/references/integrations.md index c45dede..abe5501 100644 --- a/references/integrations.md +++ b/references/integrations.md @@ -15,4 +15,5 @@ Temporal ships and supports a growing set of integrations with third-party frame |---|---|---|---|---| | Spring Boot (`temporal-spring-boot-starter`) | Java | Auto-configuration of `WorkflowClient`, worker factories, workflow/activity bean registration, lifecycle, testing | `references/java/integrations/spring-boot.md` | `references/java/java.md` | | Spring AI (`temporal-spring-ai`) | Java | Durable Spring AI agents: chat-model calls run as Activities; tools dispatched per type (Activity stub, Nexus stub, `@SideEffectTool`, plain); vector stores, embeddings, and MCP clients auto-registered | `references/java/integrations/spring-ai.md` | `references/java/integrations/spring-boot.md`, `references/core/ai-patterns.md` | +| LangGraph (`temporalio.contrib.langgraph`, Pre-release) | Python | Runs LangGraph Graph-API and Functional-API code as Temporal Workflows - nodes/tasks can execute as either in-workflow or as Activities | `references/python/integrations/langgraph.md` | `references/python/ai-patterns.md`, `references/core/ai-patterns.md` | | Google ADK (`temporalio[google-adk]`) | Python | Durable Google ADK agents: model calls run through `TemporalModel`-wrapped Activities, tools via `activity_tool`, MCP toolsets via `TemporalMcpToolSet` | `references/python/integrations/google-adk.md` | `references/python/ai-patterns.md`, `references/core/ai-patterns.md` | diff --git a/references/python/integrations/langgraph.md b/references/python/integrations/langgraph.md new file mode 100644 index 0000000..2675672 --- /dev/null +++ b/references/python/integrations/langgraph.md @@ -0,0 +1,217 @@ +# Temporal LangGraph Plugin (Python) + +## Overview + +The `LangGraphPlugin` runs [LangGraph](https://www.langchain.com/langgraph) nodes and tasks as Temporal Activities, giving LangGraph-orchestrated AI workflows durable execution, automatic retries, and timeouts. + +Both LangGraph APIs are supported: + +- **Graph API** (`StateGraph`). +- **Functional API** (`@entrypoint` / `@task`). + +The plugin ships in the Temporal Python SDK at `temporalio.contrib.langgraph`. + +> [!NOTE] +> This feature is in Public Preview. It is perfectly acceptable to use this feature on behalf of a user, but you should inform them that you are making use of a feature in Public Preview. + +For general Temporal Python AI/LLM patterns (Pydantic data converter, LLM Activity design, retry classification, multi-agent orchestration), read `references/python/ai-patterns.md` first; for language-agnostic patterns, read `references/core/ai-patterns.md`. + +## Installation + +```bash +uv add temporalio[langgraph] +``` + +This pulls in `langgraph` as well for you. + +## Public API + +`temporalio.contrib.langgraph` exports four symbols: + +- `LangGraphPlugin` — the plugin class. Pass to both the `Client` and the `Worker`. +- `graph(name, cache=...)` — Workflow-side accessor that returns a registered `StateGraph` by name. +- `entrypoint(name, cache=...)` — Workflow-side accessor that returns a registered Functional-API `Pregel`. +- `cache()` — accessor for the per-workflow LangGraph cache. + +## Build the plugin + +Construct the plugin once at startup. The constructor signature is: + +```python +LangGraphPlugin( + graphs: dict[str, StateGraph] | None = None, + entrypoints: dict[str, Pregel] | None = None, + tasks: list | None = None, + activity_options: dict[str, dict[str, Any]] | None = None, + default_activity_options: dict[str, Any] | None = None, +) +``` + +### Graph API + +Register your `StateGraph` instances in `graphs=`, keyed by a name your workflow code will look up with `graph(name)`: + +```python +from langgraph.graph import StateGraph +from temporalio.contrib.langgraph import LangGraphPlugin + +g = StateGraph(State) +g.add_node("my_node", my_node, metadata={"execute_in": "activity"}) + +plugin = LangGraphPlugin(graphs={"my-graph": g}) +``` + +### Functional API + +Pass entrypoints in `entrypoints=`, tasks in `tasks=`, and per-task activity options in `activity_options=` (keyed by task function name): + +```python +from temporalio.contrib.langgraph import LangGraphPlugin + +plugin = LangGraphPlugin( + entrypoints={"my_entrypoint": my_entrypoint}, + tasks=[my_task], + activity_options={"my_task": {"execute_in": "activity"}}, +) +``` + +## Register the plugin on Client and Worker + +Pass the same `LangGraphPlugin` instance to both the Temporal Client and the Worker via their `plugins=` parameter, like any other Temporal Python SDK plugin: + +```python +from temporalio.client import Client +from temporalio.worker import Worker + +client = await Client.connect("localhost:7233", plugins=[plugin]) + +worker = Worker( + client, + task_queue="my-task-queue", + workflows=[MyWorkflow], + plugins=[plugin], +) +await worker.run() +``` + +## Execution location — required per node and per task + +Every node (Graph API) and every task (Functional API) must declare `execute_in`, set to either `"activity"` or `"workflow"`: + +```python +# Graph API — set via node metadata +graph.add_node("my_node", my_node, metadata={"execute_in": "activity"}) +graph.add_node("tool_node", tool_node, metadata={"execute_in": "workflow"}) + +# Functional API — set via activity_options on the plugin +plugin = LangGraphPlugin( + tasks=[my_task, tool_task], + activity_options={ + "my_task": {"execute_in": "activity"}, + "tool_task": {"execute_in": "workflow"}, + }, +) +``` + +- Use `"activity"` for I/O, LLM calls, or anything that needs Temporal retries and timeouts. +- Use `"workflow"` for logic that should run on the workflow thread: purely deterministic logic, or logic which orchestrates Temporal durable primitives within the workflow thread (e.g. multiple activity calls, wait conditions, etc.) +- **`execute_in` cannot be defaulted in `default_activity_options`** — it must be specified individually per node or task. + +## Activity options + +For nodes or tasks with `execute_in: "activity"`, you can pass parameters that flow through to [`workflow.execute_activity()`](https://python.temporal.io/temporalio.workflow.html#execute_activity): `start_to_close_timeout`, `retry_policy`, `schedule_to_close_timeout`, `heartbeat_timeout`. + +### Graph API — options in node metadata + +```python +from datetime import timedelta +from temporalio.common import RetryPolicy + +g = StateGraph(State) +g.add_node("my_node", my_node, metadata={ + "execute_in": "activity", + "start_to_close_timeout": timedelta(seconds=30), + "retry_policy": RetryPolicy(maximum_attempts=3), +}) +``` + +### Functional API — options keyed by task name + +```python +from datetime import timedelta +from temporalio.common import RetryPolicy +from temporalio.contrib.langgraph import LangGraphPlugin + +plugin = LangGraphPlugin( + entrypoints={"my_entrypoint": my_entrypoint}, + tasks=[my_task], + activity_options={ + "my_task": { + "execute_in": "activity", + "start_to_close_timeout": timedelta(seconds=30), + "retry_policy": RetryPolicy(maximum_attempts=3), + }, + }, +) +``` + +## Run a graph from a Workflow + +Inside the Workflow, get the registered graph by name with `graph(name)`, compile it, then `ainvoke` (or `astream`) as usual. If the graph requires a checkpointer (for example, when using interrupts), use `InMemorySaver` — Temporal supplies durability, so third-party checkpointers like PostgreSQL or Redis are not needed: + +```python +import typing +import langgraph.checkpoint.memory +from temporalio import workflow +from temporalio.contrib.langgraph import graph + +@workflow.defn +class MyWorkflow: + @workflow.run + async def run(self, input: str) -> typing.Any: + g = graph("my-graph").compile( + checkpointer=langgraph.checkpoint.memory.InMemorySaver(), + ) + return await g.ainvoke({"input": input}) +``` + +Use `entrypoint(name)` instead of `graph(name)` for Functional-API entrypoints. + +## Runtime context + +LangGraph's run-scoped context (`context_schema`) is reconstructed on the Activity side, so a node can read and write `runtime.context` even when the node runs as an Activity: + +```python +from langgraph.runtime import Runtime +from typing_extensions import TypedDict +from temporalio.contrib.langgraph import graph + +class Context(TypedDict): + user_id: str + +async def my_node(state: State, runtime: Runtime[Context]) -> dict: + return {"user": runtime.context["user_id"]} + +# In the Workflow: +g = graph("my-graph").compile() +await g.ainvoke({...}, context=Context(user_id="alice")) +``` + +The `context` object must be serializable by the configured Temporal payload converter, since it crosses the Activity boundary. If your context uses Pydantic models, configure `pydantic_data_converter` — see `references/python/ai-patterns.md`. + +## Tracing + +For LangSmith tracing of LangGraph nodes and Temporal Activities together, use the Temporal LangSmith plugin (`references/python/integrations/langsmith.md`). + +## Hard constraints + +- **`execute_in` is mandatory on every node and task.** Set it to `"activity"` or `"workflow"` per node/task — it cannot be set in `default_activity_options`. +- **Use `InMemorySaver` as your checkpointer.** Temporal handles durability; do not configure PostgreSQL, Redis, or other third-party checkpointers. +- **LangGraph `Store` is not supported across the Activity boundary.** If you pass a `Store` (e.g. `InMemoryStore` via `graph.compile(store=...)` or `@entrypoint(store=...)`), the plugin logs a warning on first use and `runtime.store` is `None` inside nodes. Use Workflow state for per-run memory, or an external database (Postgres/Redis/etc.) configured on each worker for shared memory across runs. +- **Context objects must be serializable by the configured Temporal payload converter,** since they cross the Activity boundary. + +## Resources + +- `references/python/ai-patterns.md` — Python AI/LLM patterns (Pydantic data converter, LLM Activity design, retry/error classification). +- `references/core/ai-patterns.md` — language-agnostic AI/LLM patterns. +- `references/python/integrations/langsmith.md` - Companion LangSmith plugin. From e0c15ae74f9039e5dfddc1be202c8a1446559179 Mon Sep 17 00:00:00 2001 From: "skill-temporal-developer-updater[bot]" <276371939+skill-temporal-developer-updater[bot]@users.noreply.github.com> Date: Fri, 15 May 2026 16:04:57 -0400 Subject: [PATCH 47/82] Implement planned topic: 0031-langsmith-tracing (#217) * Finalize draft for 0031-langsmith-tracing * Apply suggestion from @xumaple Co-authored-by: xumaple <45406854+xumaple@users.noreply.github.com> * Update references/python/integrations/langsmith.md * Update references/integrations.md * Update references/python/integrations/langsmith.md * Add additional resources for LangGraph integration Added a section for additional resources related to LangGraph. --------- Co-authored-by: skill-sync[bot] Co-authored-by: Donald Pinckney Co-authored-by: xumaple <45406854+xumaple@users.noreply.github.com> --- references/integrations.md | 1 + references/python/integrations/langsmith.md | 234 ++++++++++++++++++++ 2 files changed, 235 insertions(+) create mode 100644 references/python/integrations/langsmith.md diff --git a/references/integrations.md b/references/integrations.md index abe5501..0a07f28 100644 --- a/references/integrations.md +++ b/references/integrations.md @@ -15,5 +15,6 @@ Temporal ships and supports a growing set of integrations with third-party frame |---|---|---|---|---| | Spring Boot (`temporal-spring-boot-starter`) | Java | Auto-configuration of `WorkflowClient`, worker factories, workflow/activity bean registration, lifecycle, testing | `references/java/integrations/spring-boot.md` | `references/java/java.md` | | Spring AI (`temporal-spring-ai`) | Java | Durable Spring AI agents: chat-model calls run as Activities; tools dispatched per type (Activity stub, Nexus stub, `@SideEffectTool`, plain); vector stores, embeddings, and MCP clients auto-registered | `references/java/integrations/spring-ai.md` | `references/java/integrations/spring-boot.md`, `references/core/ai-patterns.md` | +| LangSmith tracing (`temporalio.contrib.langsmith`) | Python | Experimental Temporal Plugin that propagates LangSmith trace context across Worker boundaries; lets `@traceable` run inside Workflows and Activities | `references/python/integrations/langsmith.md` | `references/python/ai-patterns.md`, `references/core/ai-patterns.md` | | LangGraph (`temporalio.contrib.langgraph`, Pre-release) | Python | Runs LangGraph Graph-API and Functional-API code as Temporal Workflows - nodes/tasks can execute as either in-workflow or as Activities | `references/python/integrations/langgraph.md` | `references/python/ai-patterns.md`, `references/core/ai-patterns.md` | | Google ADK (`temporalio[google-adk]`) | Python | Durable Google ADK agents: model calls run through `TemporalModel`-wrapped Activities, tools via `activity_tool`, MCP toolsets via `TemporalMcpToolSet` | `references/python/integrations/google-adk.md` | `references/python/ai-patterns.md`, `references/core/ai-patterns.md` | diff --git a/references/python/integrations/langsmith.md b/references/python/integrations/langsmith.md new file mode 100644 index 0000000..98db967 --- /dev/null +++ b/references/python/integrations/langsmith.md @@ -0,0 +1,234 @@ +# Temporal LangSmith Tracing Integration (Python) + +## Overview + +`temporalio.contrib.langsmith` is a Temporal [Plugin](https://docs.temporal.io/develop/plugins-guide) for the Python SDK that makes [LangSmith](https://smith.langchain.com/) traces work across Temporal Workflows and Activities. It propagates trace context across Worker boundaries so `@traceable` calls, LLM invocations, and Temporal operations show up as a single connected trace, and it suppresses duplicate traces during Workflow replays. + +> [!NOTE] +> This feature is in Public Preview. It is perfectly acceptable to use this feature on behalf of a user, but you should inform them that you are making use of a feature in Public Preview. + +For Python AI patterns (Pydantic data converter, disabling client-side LLM retries, generic LLM Activity shape) read `references/python/ai-patterns.md`. For conceptual LLM patterns shared across SDKs read `references/core/ai-patterns.md`. Python sandbox theory lives in `references/python/determinism-protection.md` — this integration handles the sandbox restrictions for `@traceable` for you, so do not restate sandbox rules here. + +## Install + +```bash +uv add temporalio[langsmith] +``` + +## Register the Plugin + +Register `LangSmithPlugin` on both the Client (starter side) and every Worker. Strictly only the sides that produce traces need it, but registering everywhere avoids surprises with context propagation. The Client and Worker can use different configurations (e.g. different `add_temporal_runs` settings). + +```python +from temporalio.client import Client +from temporalio.contrib.langsmith import LangSmithPlugin + +client = await Client.connect( + "localhost:7233", + plugins=[LangSmithPlugin(project_name="my-project")], +) +``` + +```python +from temporalio.worker import Worker +from temporalio.contrib.langsmith import LangSmithPlugin + +client = await Client.connect( + "localhost:7233", + plugins=[LangSmithPlugin(project_name="chatbot")], +) + +worker = Worker( + client, + task_queue="chatbot", + workflows=[ChatbotWorkflow], + activities=[call_openai], +) +await worker.run() +``` + +## `LangSmithPlugin` parameters + +Constructor is keyword-only. + +| Parameter | Type | Default | Purpose | +|---|---|---|---| +| `client` | `langsmith.Client \| None` | `None` (auto-created) | LangSmith client; auto-created if not supplied. | +| `project_name` | `str \| None` | `None` | LangSmith project name traces are written to. | +| `add_temporal_runs` | `bool` | `False` | When `True`, adds Temporal operation nodes (StartWorkflow, RunWorkflow, StartActivity, RunActivity) to the trace tree. | +| `default_metadata` | `dict[str, Any] \| None` | `None` | Custom metadata attached to all LangSmith traces. | +| `default_tags` | `list[str] \| None` | `None` | Custom tags attached to all LangSmith traces. | + +`LangSmithInterceptor` is also exported alongside `LangSmithPlugin`; the plugin is the registration entry point and is what user code should use. + +## Where `@traceable` works + +| Location | Works? | Notes | +|---|---|---| +| Inside Workflow methods | Yes | Traces called from inside `@workflow.run`, `@workflow.signal`, etc.; sync and async methods. | +| Inside Activity methods | Yes | Traces called from inside `@activity.defn`; sync and async methods. | +| On `@activity.defn` functions | Yes | Stack `@traceable` on top of `@activity.defn` (decorator order matters). Fires on every retry. | +| On Workflow methods | No | Do not wrap `@traceable` around `@workflow.defn`, `workflow.run`, `workflow.signal`; Use inside `@workflow.run` instead. | + +Decorator-order example for an Activity — `@traceable` on top: + +```python +from langsmith import traceable +from temporalio import activity + +@traceable(name="Call OpenAI", run_type="llm") +@activity.defn +async def call_openai(...): + ... +``` + +## `add_temporal_runs` — Temporal operation visibility + +By default (`add_temporal_runs=False`), only application `@traceable` runs appear in LangSmith. With `add_temporal_runs=True`, Temporal operation nodes are added so the orchestration layer is visible alongside application logic. + +```python +plugins=[LangSmithPlugin(project_name="my-project", add_temporal_runs=True)] +``` + +With `add_temporal_runs=True`, `StartFoo` and `RunFoo` appear as siblings: the start is the short-lived outbound RPC that enqueues work, the run is the actual execution. + +## Replay safety — handled by the plugin + +The plugin makes `@traceable` replay-safe in the Workflow sandbox. You do not need to write extra code for this. + +- Replay correctness and non-duplication is correctly handled by the plugin, no matter the cause of replay (happy paths, errors, crashes, etc.). Replayed Activities create no new trace data; new work after that produces fresh traces +- The plugin injects metadata using `workflow.now()` for timestamps and `workflow.random()` for UUIDs instead of `datetime.now()` and `uuid4()`. +- LangSmith HTTP calls run on a background thread pool that does not interfere with deterministic Workflow execution. + + +## Context propagation + +Trace context flows automatically across Client → Workflow → Activity → Child Workflow → Nexus via Temporal headers. Do not pass context manually. + +## Worked example — chatbot Workflow with `@traceable` + +A Workflow stays alive waiting for user messages and dispatches each message to an Activity that calls the LLM. `@traceable` can be used both inside `@workflow.run` and stacked on top of `@activity.defn`. + +Activity (wraps the LLM call): + +```python +from langsmith import traceable +from langsmith.wrappers import wrap_openai +from openai import AsyncOpenAI +from temporalio import activity + +@traceable(name="Call OpenAI", run_type="chain") +@activity.defn +async def call_openai(request: OpenAIRequest) -> Response: + client = wrap_openai(AsyncOpenAI()) # traced LangSmith wrapper + return await client.responses.create( + model=request.model, + input=request.input, + instructions=request.instructions, + ) +``` + +Workflow (orchestrates the conversation; `@traceable` used **inside** `@workflow.run`, not on the class): + +```python +from datetime import timedelta +from langsmith import traceable +from temporalio import workflow + +@workflow.defn +class ChatbotWorkflow: + @workflow.run + async def run(self) -> str: + # @traceable works inside Workflows — fully replay-safe + now = workflow.now().strftime("%b %d %H:%M") + return await traceable( + name=f"Session {now}", run_type="chain", + )(self._run_with_trace)() + + async def _run_with_trace(self) -> str: + while not self._done: + await workflow.wait_condition( + lambda: self._pending_message is not None or self._done + ) + if self._done: + break + + message = self._pending_message + self._pending_message = None + + @traceable(name=f"Query: {message[:60]}", run_type="chain") + async def _query(msg: str) -> str: + response = await workflow.execute_activity( + call_openai, + OpenAIRequest(model="gpt-4o-mini", input=msg), + start_to_close_timeout=timedelta(seconds=60), + ) + return response.output_text + + self._last_response = await _query(message) + + return "Session ended." +``` + +With `add_temporal_runs=False`, the trace contains only application logic: + +``` +Session Apr 03 14:30 + Query: "What's the weather in NYC?" + Call OpenAI + openai.responses.create (auto-traced by wrap_openai) +``` + +With `add_temporal_runs=True` and the caller wrapping `start_workflow` in `@traceable`: + +``` +Ask Chatbot # @traceable wrapper around client.start_workflow + StartWorkflow:ChatbotWorkflow + RunWorkflow:ChatbotWorkflow + Session Apr 03 14:30 + Query: "What's the weather in NYC?" + StartActivity:call_openai + RunActivity:call_openai + Call OpenAI + openai.responses.create +``` + +## Grouping Activity retries under one trace + +Because Temporal retries failed Activities and `@traceable` on `@activity.defn` fires per attempt, wrap the Activity call in an outer `@traceable` to group the attempts together: + +```python +@traceable(name="Call OpenAI", run_type="llm") +@activity.defn +async def call_openai(...): + ... + +@traceable(name="my_step", run_type="chain") +async def my_step(message: str) -> str: + return await workflow.execute_activity( + call_openai, + ... + ) +``` + +Result: + +``` +my_step + Call OpenAI # first attempt + openai.responses.create + Call OpenAI # retry + openai.responses.create +``` + +## Common mistakes + +- **`@traceable` on a `@workflow.defn` class.** Not supported — use `@traceable` inside `@workflow.run` instead. +- **`@activity.defn` on top of `@traceable`.** Wrong order — `@traceable` must be the outer decorator on Activities. +- **Registering the plugin only on the Client.** Register on both Client and every Worker. +- **Positional argument to `LangSmithPlugin`.** The constructor is keyword-only — use `LangSmithPlugin(project_name="...")`. +- **Combining with `temporalio.contrib.opentelemetry` and expecting unified traces.** They are independent integrations; this reference covers LangSmith only. + +## Additional Resources + +- `references/python/integrations/langgraph.md` - LangGraph + Temporal plugin - enables running LangGraph agents as durable Temporal workflows. From 14d412f37d3643e68f4afad01a4c3be77e2ff437 Mon Sep 17 00:00:00 2001 From: "skill-temporal-developer-updater[bot]" <276371939+skill-temporal-developer-updater[bot]@users.noreply.github.com> Date: Fri, 15 May 2026 16:34:18 -0400 Subject: [PATCH 48/82] Implement planned topic: 0032-workflow-streams (#220) * Finalize draft for 0032-workflow-streams * Apply suggestions from code review Co-authored-by: Donald Pinckney * Remove the extended example. Can add later if needed. --------- Co-authored-by: skill-sync[bot] Co-authored-by: Donald Pinckney Co-authored-by: Donald Pinckney --- references/python/python.md | 1 + references/python/workflow-streams.md | 399 ++++++++++++++++++++++++++ 2 files changed, 400 insertions(+) create mode 100644 references/python/workflow-streams.md diff --git a/references/python/python.md b/references/python/python.md index 5493387..640a533 100644 --- a/references/python/python.md +++ b/references/python/python.md @@ -182,6 +182,7 @@ See `references/python/testing.md` for info on writing tests. - **`references/python/versioning.md`** - Patching API, workflow type versioning, Worker Versioning - **`references/python/determinism-protection.md`** - Python sandbox specifics, forbidden operations, pass-through imports - **`references/python/ai-patterns.md`** - LLM integration, Pydantic data converter, AI workflow patterns +- **`references/python/workflow-streams.md`** - Public-Preview `temporalio.contrib.workflow_streams` library: durable, offset-addressed event channel for streaming progress to subscribers. ### Python Integrations diff --git a/references/python/workflow-streams.md b/references/python/workflow-streams.md new file mode 100644 index 0000000..fc92ad2 --- /dev/null +++ b/references/python/workflow-streams.md @@ -0,0 +1,399 @@ +# Workflow Streams (Python) + +> [!NOTE] +> This feature is in Public Preview. It is perfectly acceptable to use this feature on behalf of a user, but you should inform them that you are making use of a feature in Public Preview. + +## Overview + +`temporalio.contrib.workflow_streams` is a Python SDK `contrib` library that gives a Workflow a durable, offset-addressed event channel built on Temporal's basic message primitives: Signals, Updates, and Queries. It batch-publishes events, deduplicates batches for exactly-once delivery to the log, supports topic filtering, and carries state across Continue-As-New. + +Use it for modest fan-out progress streaming: AI-agent runs, order pipelines, multi-step workflow status updates, etc. It targets "tens of publishers and subscribers per Workflow, not thousands"; it is not suited to ultra-low-latency cases like real-time voice. + +Only available in the Python SDK today; cross-language is on the roadmap. + + +## When to use / not to use + +- Use it for: updating a UI as an AI agent works; surfacing status from a payment or order pipeline; reporting intermediate results from a data job. +- Skip it for: ultra-low-latency cases like real-time voice. +- Skip it for: high fan-out — thousands of subscribers per Workflow. + +## Where to host the stream + +A `WorkflowStream` is hosted inside a Workflow. The Workflow Id is the address subscribers attach to. + +- **Same-Workflow hosting (common shape):** the Workflow that does the work also hosts the stream. Its lifecycle aligns with the run. Use this for AI agents and most progress-streaming cases. +- **Dedicated Workflow:** when the stream should outlive any single producer, accept fan-in from multiple unrelated sources, or be subscribable before any work has started. Producers publish from outside. Trade-off: explicit lifecycle management — the dedicated Workflow does not terminate on its own, so wire a signal-driven shutdown or a Continue-As-New strategy. +- Multiple subscribers can attach to the same Workflow Id concurrently (e.g. a UI with multiple browser tabs). + +## Enable streaming on a Workflow + +Import: `from temporalio.contrib.workflow_streams import WorkflowStream` + +Construct `WorkflowStream()` from `@workflow.init`, **not** `@workflow.run`. The stream's handlers must be registered before the first publish Signal arrives; doing it from `@workflow.run` raises `RuntimeError`. + +Constructing more than one `WorkflowStream` on the same Workflow also raises `RuntimeError`. + +```python +from dataclasses import dataclass + +from temporalio import workflow +from temporalio.contrib.workflow_streams import WorkflowStream + + +@dataclass +class OrderInput: + order_id: str + + +@workflow.defn +class OrderWorkflow: + @workflow.init + def __init__(self, input: OrderInput) -> None: + self.stream = WorkflowStream() +``` + +Construction creates the in-memory event log and dynamically registers the publish Signal, subscribe Update, and offset Query handlers. + +## Publish from a Workflow + +Bind a topic name to its event type once via `self.stream.topic("name", type=Type)`, then call `publish()` on the returned handle. + +`type=` is optional and defaults to `Any`. The codec chain (encryption, compression) runs once on the Signal/Update envelope, never per item. + +```python +from dataclasses import dataclass + + +@dataclass +class StatusEvent: + state: str + progress: int = 0 + detail: str = "" + + +@workflow.defn +class OrderWorkflow: + @workflow.init + def __init__(self, input: OrderInput) -> None: + self.stream = WorkflowStream() + self.status = self.stream.topic("status", type=StatusEvent) + + @workflow.run + async def run(self, input: OrderInput) -> None: + self.status.publish(StatusEvent(state="validating", detail="checking inventory")) + await validate_order(input.order_id) + self.status.publish(StatusEvent(state="charging", progress=33, detail="authorizing payment")) + await charge_payment(input.order_id) + self.status.publish(StatusEvent(state="shipping", progress=66, detail="dispatching to warehouse")) + await dispatch_order(input.order_id) + self.status.publish(StatusEvent(state="completed", progress=100)) +``` + +Note: `publish()` is **not** awaited. Inside a Workflow it appends synchronously to the in-memory log. + +## Publish from a client (external process or Activity) + +Any process holding a Temporal `Client` and the target Workflow Id can publish by constructing a `WorkflowStreamClient`. This is the general pattern; it covers HTTP backends, starters, one-off scripts, other Workflows' Activities, and standalone Activities. + +General pattern: `WorkflowStreamClient.create(client, workflow_id, batch_interval=...)`. Use it as an `async with` context manager so the buffer flushes on exit. + +```python +from datetime import timedelta + +from temporalio.client import Client +from temporalio.contrib.workflow_streams import WorkflowStreamClient + + +async def publish_status(workflow_id: str) -> None: + temporal_client = await Client.connect("localhost:7233") + stream_client = WorkflowStreamClient.create( + temporal_client, + workflow_id=workflow_id, + batch_interval=timedelta(milliseconds=200), + ) + async with stream_client: + status = stream_client.topic("status", type=StatusEvent) + status.publish(StatusEvent(state="started")) + # Buffer is flushed on context manager exit. +``` + +Inside an Activity scheduled by a Workflow, `WorkflowStreamClient.from_within_activity()` is a convenience that infers the Temporal `Client` and the parent Workflow Id from the Activity context. + +```python +from temporalio import activity +from temporalio.contrib.workflow_streams import WorkflowStreamClient + + +@activity.defn +async def stream_deltas(order_id: str) -> None: + client = WorkflowStreamClient.from_within_activity() + async with client: + deltas = client.topic("delta", type=Delta) + for delta in generate_deltas(order_id): + deltas.publish(delta) + activity.heartbeat() +``` + +For a **standalone Activity** (started directly via `Client.start_activity`), there is no parent Workflow context to infer, so `from_within_activity()` raises. Fall back to `WorkflowStreamClient.create(activity.client(), workflow_id=...)` with the Workflow Id threaded through the Activity input. + +Publish from the Activity directly rather than returning events for the Workflow to forward; the Workflow hosts the stream but does not read its own stream. + +## `force_flush=True` vs. `await client.flush()` + +These are two separate operations. + +- **`publish(..., force_flush=True)`** — wakes the background flusher so the current buffer ships without waiting for the next `batch_interval`. The call returns immediately after appending and signaling; it does **not** wait for delivery. The flusher only runs while the client is entered (`async with client`); outside that, `force_flush=True` queues the wake event but nothing ships until you enter the context or call `await client.flush()`. Use it for latency-sensitive events: first delta, punctuated events like `RETRY` or `STATUS_CHANGE`. + + ```python + deltas.publish(delta, force_flush=True) + ``` + +- **`await client.flush()`** — mid-stream barrier. Successful completion proves the Temporal server has received all prior publications. The client stays open afterward. Exiting `async with client` already flushes on its way out; the explicit call is only for barriers in the middle. + + ```python + async with client: + deltas = client.topic("delta", type=Delta) + for delta in first_phase(): + deltas.publish(delta) + await client.flush() + checkpoint_id = await record_phase_one_complete() + for delta in second_phase(checkpoint_id): + deltas.publish(delta) + ``` + +## Non-blocking publish, no backpressure + +`publish()` is non-blocking and applies no backpressure. A slow subscriber does not slow publishers; if a publisher emits faster than batches can ship, the buffer grows. + +If you need to bound this, apply a policy upstream of `publish()`. The library does not pick block/drop/error/sample for you. + +## Subscribe + +Construct a client with `WorkflowStreamClient.create(client, workflow_id)`, then iterate a topic handle's `subscribe()`. The bound type drives decoding; each `item.data` arrives as `T`. + +```python +from temporalio.client import Client +from temporalio.contrib.workflow_streams import WorkflowStreamClient + + +async def watch_order(order_id: str) -> None: + temporal_client = await Client.connect("localhost:7233") + stream = WorkflowStreamClient.create(temporal_client, workflow_id=order_id) + + status = stream.topic("status", type=StatusEvent) + async for item in status.subscribe(): + evt = item.data + print(f"[{evt.progress:3d}%] {evt.state}: {evt.detail}") + if evt.state == "completed": + break +``` + +The iterator handles re-polling, pagination at the ~1 MB cap, and Workflow-side log truncation transparently. + +**Subscribing from inside the host Workflow is intentionally unsupported.** The Workflow only sees the successful return value of each Activity; the stream may carry partial output from retried attempts. Letting the Workflow read its own stream would mix those two views and break the conduit role. + +A subscriber stores the last delivered `item.offset` and reconnects resume from that offset. + +## Heterogeneous topics + +To consume topics whose payload types differ, call `client.subscribe()` directly with a list of names and `result_type=RawValue`. Passing an empty list (`subscribe([])`) subscribes to every topic. Dispatch on `item.topic`; decode the wrapped payload with the client's payload converter. + +```python +from temporalio.common import RawValue + +converter = temporal_client.data_converter.payload_converter + +async for item in stream.subscribe(["status", "progress"], result_type=RawValue): + if item.topic == "status": + evt = converter.from_payload(item.data.payload, StatusEvent) + print(f"[status] {evt.state}: {evt.detail}") + elif item.topic == "progress": + evt = converter.from_payload(item.data.payload, ProgressEvent) + print(f"[progress] {evt.message}") +``` + +A single iterator over multiple topics avoids the cancellation race that two concurrent subscribers would create. + +## Closing the stream + +End-of-stream is application-level; Workflow Streams does not impose a marker. There is no `stream.close()` / `stream.end_of_stream()` API. + +Without coordination, a subscriber keeps polling until the Workflow reaches a terminal state, and a Workflow that returns immediately after its last publish can lose that publish's poll round-trip in the gap. + +A poll Update that is still in flight when the Workflow returns surfaces to the client as `AcceptedUpdateCompletedWorkflow`, and no new polls can complete after that. That's why an overlap is required. + +The pattern is an **in-band terminator** the subscriber recognizes plus a **brief overlap** before the Workflow returns. + +### Pattern 1: fixed sleep (simplest) + +```python +# at the end of @workflow.run +self.status.publish(StatusEvent(state="completed", progress=100)) +await workflow.sleep(timedelta(seconds=30)) +return result +``` + +Thirty seconds is a generous default. + +### Pattern 2: acknowledgment handshake + +```python +@workflow.signal +async def subscriber_acknowledged_terminator(self) -> None: + self.subscriber_done = True + +@workflow.run +async def run(self, input: ChatInput) -> str: + ... + try: + await workflow.wait_condition( + lambda: self.subscriber_done, + timeout=timedelta(seconds=30), + ) + except TimeoutError: + pass # No subscriber attached; the run still completes cleanly. + return result +``` + +The timeout is still required because the subscriber may not be attached. With the ack, the typical case exits as soon as the subscriber confirms. + +### Inspecting terminal status + +`subscribe()` exits cleanly when the Workflow reaches `COMPLETED`, `FAILED`, `CANCELED`, `TERMINATED`, or `TIMED_OUT`, but does not distinguish among them. Call `await temporal_client.get_workflow_handle(workflow_id).describe()` after the loop to inspect the Workflow's status. + +## Continue-As-New + +For streams that run hours or accumulate thousands of events, roll over to keep history bounded. Subscribers automatically follow Continue-As-New chains. Workflow Ids are stable across CAN. + +CAN-following requires the client retained from `WorkflowStreamClient.create()` or `from_within_activity()`; clients constructed directly with a single handle cannot re-target the new run. + +To carry both application state and stream state across the boundary: + +- Add a `WorkflowStreamState | None` field to your Workflow input. +- Pass it to the constructor as `WorkflowStream(prior_state=...)`. +- Call `WorkflowStream.continue_as_new(build_args)`; the helper drains waiting subscribers, waits for in-flight handlers, then calls `workflow.continue_as_new` with the args produced by `build_args(post_drain_state)`. + +```python +from dataclasses import dataclass, field + +from temporalio import workflow +from temporalio.contrib.workflow_streams import WorkflowStream, WorkflowStreamState + + +@dataclass +class AppState: + items_processed: int = 0 + + +@dataclass +class WorkflowInput: + app_state: AppState = field(default_factory=AppState) + stream_state: WorkflowStreamState | None = None + + +@workflow.defn +class LongRunningWorkflow: + @workflow.init + def __init__(self, input: WorkflowInput) -> None: + self.app_state = input.app_state + self.stream = WorkflowStream(prior_state=input.stream_state) + + @workflow.run + async def run(self, input: WorkflowInput) -> None: + while True: + await do_one_iteration(self) + if workflow.info().is_continue_as_new_suggested(): + await self.stream.continue_as_new( + lambda stream_state: [ + WorkflowInput( + app_state=self.app_state, + stream_state=stream_state, + ) + ] + ) +``` + +**Hard constraint:** the field type must be `WorkflowStreamState | None`, **not** `Any`. With `Any`, the data converter rebuilds the field as a plain `dict` and `WorkflowStream(prior_state=...)` raises `AttributeError` accessing `.log` / `.base_offset` / `.publishers` on the dict. + +To pass other CAN parameters (`task_queue`, `retry_policy`, `run_timeout`), use the explicit recipe: + +```python +self.stream.detach_pollers() +await workflow.wait_condition(workflow.all_handlers_finished) +workflow.continue_as_new( + args=[WorkflowInput(app_state=self.app_state, stream_state=self.stream.get_state())], + task_queue="other-tq", +) +``` + +The carried `WorkflowStreamState` includes the entire in-memory log of the previous run. Offload large items via [External Storage](https://docs.temporal.io/external-storage) so each item is a small reference, and combine with `truncate()` to keep the carried log itself small. + +## Tuning + +The driving question: how often should the UI update? That answer trades user-perceived latency against history events accumulated. Each batched publish is one Signal; each subscriber poll is one Update; both accumulate against the Workflow's history. + +| Setting | Default | Notes | +| --- | --- | --- | +| `batch_interval` | 2 seconds | Maximum time between automatic flushes. 200 ms is a good start for LLM token streams. Below 100 ms the per-Signal RPC overhead starts to dominate. | +| `max_batch_size` | unbounded | Cap by item count to stay under Temporal's per-message gRPC payload limit. | +| `poll_cooldown` | 100 ms | Subscriber sleeps this interval between polls. Skipped only when a poll response hit the ~1 MB cap with more items remaining (`more_ready`). | +| `max_retry_duration` | 10 minutes | How long a `WorkflowStreamClient` retries a failed publish batch before raising `TimeoutError`. | +| `publisher_ttl` | 15 minutes | How long the Workflow retains per-publisher dedup state; entries older than this drop at each CAN. | + +**Invariant:** `max_retry_duration < publisher_ttl`. Defaults (10 min < 15 min) satisfy this. If a publisher's retry window exceeds the dedup retention, a retry that arrives after its dedup record has been pruned is treated as a fresh publish, and if the original delivery had also succeeded the same logical batch lands twice. + +`force_flush=True` is a per-publish latency knob. Use it for the first delta or punctuated events like `RETRY` and `STATUS_CHANGE`. Don't use it per-token or per-character: per-character `force_flush=True` is not tractable. + +Hold a single subscriber iterator and consume from it rather than opening and closing subscriptions in a loop. + +## Delivery semantics + +**Exactly-once at the execution layer.** Each `(publisher_id, sequence)` batch lands in the Workflow's event log at most once, even if the Signal is retried by the SDK or the network. Dedup state is carried across Continue-As-New. + +**Ordering.** +- The log imposes a single total order on all events, fixed once written: an event at offset N stays at offset N on every read. +- Within one publisher, events appear in publish order. +- Across concurrent publishers, the interleaving is whatever the Workflow saw when serializing inbound Signals; stable once recorded but not under application control. +- If event A must precede event B, publish them from the same publisher. + +**Activity retries surface to subscribers.** Both attempts' events appear in the stream. The Workflow itself only sees the successful attempt's return value; a UI subscribed to the stream will see partial output unless it dedupes. + +**Conventional pattern:** an Activity on a retry attempt publishes a `RETRY` event with `force_flush=True`; the consumer clears or annotates prior-attempt output when it sees one. Build an idempotent consumer reducer: overwrite on terminal events like `STATUS_CHANGE` or `TEXT_COMPLETE`; reset an accumulator on a sentinel like `AGENT_START` before deltas resume. + +**Other failure modes.** +- Events still in a publisher's in-memory client buffer are lost if the process crashes before they ship. +- Subscribers that handle an item and crash before persisting their next offset will reprocess that item on resume. +- On `max_retry_duration` exhaustion, a `TimeoutError` raises from inside the background flusher task and terminates it; until you call `await client.flush()` or exit the `async with` block, subsequent publishes accumulate with no flusher to ship them. The dropped batch is at-most-once: may or may not have reached the Workflow. + +## Architecture + +**Append-only in-memory log inside the Workflow.** Each entry is `(topic, data)` with a monotonically increasing global offset. Subscribers maintain their own cursor; each long-poll receives the next range past it. The log is replay-safe and carried across Continue-As-New via `WorkflowStreamState`. + +**Two mechanisms bound log growth, and they do different jobs:** +- `truncate(up_to_offset)` drops entries from the in-memory log (and from the carried CAN payload). It does **not** remove publish Signals already recorded in history. +- **Continue-As-New** starts a fresh history. This is the only way to shrink history; truncate alone cannot. + +A subscriber whose offset falls below the new base after `truncate()` is silently advanced; the iterator does not raise, but the subscriber may re-receive items already in the log past the new base. + +**Wire-level handler names** (registered when you construct a `WorkflowStream`): +- `__temporal_workflow_stream_publish` — Signal that receives batched publishes. +- `__temporal_workflow_stream_poll` — long-poll Update that subscribers use. +- `__temporal_workflow_stream_offset` — Query that reports the current head offset. + +**Poll responses are capped at roughly 1 MB**, by accumulating items until the next would exceed the budget. A single item that exceeds 1 MB on its own is admitted unconditionally; offload via [External Storage](https://docs.temporal.io/external-storage). + +**Batch dedup applies at the Signal layer, not the Activity layer.** When Temporal retries the Activity, the retried execution constructs a new `WorkflowStreamClient` with its own client id, so every Activity attempt is a fresh publisher whose batches will not deduplicate against the prior attempt's. + +## Gotchas + +- **`WorkflowStreamClient` is asyncio-only.** The client buffer is mutated on the publish path and read from the flusher inside a single event loop. Don't call `publish()` from a worker thread. +- **First-activation handler race.** On the very first activation a publish Signal can be queued before class-level `@workflow.signal` or `@workflow.update` handlers have run. The fix: make the handler `async def` and `await` once before reading state. Use `asyncio.sleep(0)` — a no-op yield that adds no history events. **Do not** substitute `workflow.sleep(0)` — it records a timer event. +- **Type bindings are per-instance.** Each `WorkflowStream` and each `WorkflowStreamClient` records topic types only for its own instance. If two publishers bind the same topic name to different types, the mismatch is not caught at publish; the subscriber gets a decode error on events from the mismatched publisher. + +## See also + +- [Workflow Streams samples (samples-python)](https://github.com/temporalio/samples-python/tree/main/workflow_streams) — basic publish/subscribe, reconnecting subscribers, external publishers, bounded logs. +- [`temporalio.contrib.workflow_streams` API reference](https://python.temporal.io/temporalio.contrib.workflow_streams.html). +- `references/python/patterns.md` — Signals/Queries/Updates primitives this builds on. +- `references/python/ai-patterns.md` — LLM patterns. From 8ad839585a34d0d3095e2b570067391c27b069b2 Mon Sep 17 00:00:00 2001 From: Donald Pinckney Date: Fri, 15 May 2026 16:44:14 -0400 Subject: [PATCH 49/82] Update ai-patterns.md (#226) --- references/python/ai-patterns.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/references/python/ai-patterns.md b/references/python/ai-patterns.md index 6a45272..187733c 100644 --- a/references/python/ai-patterns.md +++ b/references/python/ai-patterns.md @@ -322,6 +322,10 @@ class DurableAgentWorkflow: return result.output ``` +## Streaming LLM Output / Tool Calls / etc. to a UI + +For streaming tokens or progress events from an Activity to an outside subscriber (browser, terminal, SSE endpoint), see `references/python/workflow-streams.md`. Workflow Streams is a `contrib` module that handles batching, dedup, and offset-based consumption built on Signals, Updates, and Queries. + ## Best Practices 1. **Always use Pydantic data converter** for complex types From 7a99afb30c26708d3859c3292c5af14a23ef5a73 Mon Sep 17 00:00:00 2001 From: Donald Pinckney Date: Fri, 15 May 2026 16:56:18 -0400 Subject: [PATCH 50/82] Refine Continue-As-New section for clarity and detail (#227) Small tweaks to CAN in streaming --- references/python/workflow-streams.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/references/python/workflow-streams.md b/references/python/workflow-streams.md index fc92ad2..84ecbd7 100644 --- a/references/python/workflow-streams.md +++ b/references/python/workflow-streams.md @@ -262,17 +262,17 @@ The timeout is still required because the subscriber may not be attached. With `subscribe()` exits cleanly when the Workflow reaches `COMPLETED`, `FAILED`, `CANCELED`, `TERMINATED`, or `TIMED_OUT`, but does not distinguish among them. Call `await temporal_client.get_workflow_handle(workflow_id).describe()` after the loop to inspect the Workflow's status. -## Continue-As-New +## Continue-As-New (CAN) -For streams that run hours or accumulate thousands of events, roll over to keep history bounded. Subscribers automatically follow Continue-As-New chains. Workflow Ids are stable across CAN. +Skip this section for short-lived Workflows (single chat completion, order pipeline). CAN is for streams that run for hours or accumulate thousands of events -CAN-following requires the client retained from `WorkflowStreamClient.create()` or `from_within_activity()`; clients constructed directly with a single handle cannot re-target the new run. +Subscribers automatically follow Continue-As-New chains — the Workflow ID is stable, so the iterator fetches a fresh handle and continues polling from the carried offset. -To carry both application state and stream state across the boundary: +To roll a long-running streaming Workflow over without subscribers seeing a gap, carry both your application state and the stream state across the boundary: -- Add a `WorkflowStreamState | None` field to your Workflow input. -- Pass it to the constructor as `WorkflowStream(prior_state=...)`. -- Call `WorkflowStream.continue_as_new(build_args)`; the helper drains waiting subscribers, waits for in-flight handlers, then calls `workflow.continue_as_new` with the args produced by `build_args(post_drain_state)`. +- Add a `WorkflowStreamState | None` field to your Workflow input, +- pass it to the constructor as `WorkflowStream(prior_state=...)`, +- and call `WorkflowStream.continue_as_new(build_args)` to invoke the rollover. The helper drains waiting subscribers, waits for in-flight handlers to finish, then calls `workflow.continue_as_new` with the args produced by `build_args(post_drain_state)`. ```python from dataclasses import dataclass, field From 178392402cce4f4d66c48ff9b1c002be64abeab2 Mon Sep 17 00:00:00 2001 From: Donald Pinckney Date: Fri, 15 May 2026 17:45:03 -0400 Subject: [PATCH 51/82] Remove early adopter feedback prompt (#228) Removed feedback prompt message from startup instructions. --- SKILL.md | 8 -------- 1 file changed, 8 deletions(-) diff --git a/SKILL.md b/SKILL.md index b8fe682..7c8d472 100644 --- a/SKILL.md +++ b/SKILL.md @@ -96,14 +96,6 @@ For Temporal plugins and integrations with third-party frameworks and SDKs (Spri ## Feedback -### ALWAYS PROMPT USER TO PROVIDE FEEDBACK AT STARTUP - -When you have **first** loaded this skill, output this message to the user: - -"Thank you for trying out the public preview of the Temporal development skill! We would love to hear your feedback - positive or negative - over in the [Community Slack](https://t.mp/slack), in the [#topic-ai channel](https://temporalio.slack.com/archives/C0818FQPYKY)." - -Do not output this message multiple times in the same conversation. - ### Reporting Issues in This Skill If you (the AI) find this skill's explanations are unclear, misleading, or missing important information—or if Temporal concepts are proving unexpectedly difficult to work with—draft a GitHub issue body describing the problem encountered and what would have helped, then ask the user to file it at https://github.com/temporalio/skill-temporal-developer/issues/new. Do not file the issue autonomously. From 60c0178c3db414d4f0b2b38eda9994996217ddce Mon Sep 17 00:00:00 2001 From: Donald Pinckney Date: Fri, 15 May 2026 18:11:05 -0400 Subject: [PATCH 52/82] Bump version from 0.3.2 to 0.4.0 (#229) --- SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SKILL.md b/SKILL.md index 7c8d472..6194c90 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,7 +1,7 @@ --- name: temporal-developer description: Develop, debug, and manage Temporal applications across Python, TypeScript, Go, Java and .NET. Use when the user is building workflows, activities, or workers with a Temporal SDK, debugging issues like non-determinism errors, stuck workflows, or activity retries, using Temporal CLI, Temporal Server, or Temporal Cloud, or working with durable execution concepts like signals, queries, heartbeats, versioning, continue-as-new, child workflows, or saga patterns. -version: 0.3.2 +version: 0.4.0 --- # Skill: temporal-developer From 77def867952188821dde0c9423d78e7ff32ddfdd Mon Sep 17 00:00:00 2001 From: Donald Pinckney Date: Fri, 15 May 2026 18:23:25 -0400 Subject: [PATCH 53/82] Handle missing tag during skill sync (#230) --- .github/workflows/package-skill.yml | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/.github/workflows/package-skill.yml b/.github/workflows/package-skill.yml index f2b89e3..17f0229 100644 --- a/.github/workflows/package-skill.yml +++ b/.github/workflows/package-skill.yml @@ -160,6 +160,24 @@ jobs: base_tag="" fi + # The target repo's recorded version may not correspond to an + # actual tag in this source repo (e.g. the target was last + # updated from a different repo, or a tag was never published + # for that version). If the tag doesn't exist locally, find the + # most recent tag that's an ancestor of HEAD and predates the + # current tag; if nothing matches, leave base_tag empty so the + # fallback path runs. + if [ -n "$base_tag" ] && ! git rev-parse --verify --quiet "refs/tags/${base_tag}" >/dev/null; then + echo "Base tag ${base_tag} does not exist in this repo; searching for closest preceding tag" + fallback_tag=$(git tag --sort=-v:refname | awk -v cur="$current_tag" '$0 != cur' | head -1 || true) + if [ -n "$fallback_tag" ]; then + echo "Using ${fallback_tag} as base tag instead" + base_tag="$fallback_tag" + else + base_tag="" + fi + fi + # Prefer GitHub's auto-generated notes for the range (nicely formatted # with PR links and contributors). Fall back to git log if unavailable. echo "Base tag: ${base_tag:-} / Current tag: ${current_tag}" @@ -174,7 +192,8 @@ jobs: echo "$notes" > /tmp/changelog.md else echo "generate-notes API call failed or empty; falling back to git log" - git log --oneline "${base_tag}..HEAD" > /tmp/changelog.md + git log --oneline "${base_tag}..HEAD" > /tmp/changelog.md \ + || git log --oneline -20 > /tmp/changelog.md fi else echo "No base tag found; using last 20 commits" From 8544cc339f541539a7e31d9e6ca88caf8e052981 Mon Sep 17 00:00:00 2001 From: "skill-temporal-developer-updater[bot]" <276371939+skill-temporal-developer-updater[bot]@users.noreply.github.com> Date: Wed, 20 May 2026 12:35:03 -0400 Subject: [PATCH 54/82] Implement planned topic: 0036-openai-agents-sdk (#223) * Finalize draft for 0036-openai-agents-sdk * Apply suggestions from code review Co-authored-by: Jason Steving <32336750+JasonSteving99@users.noreply.github.com> Co-authored-by: Donald Pinckney * Apply suggestion from @donald-pinckney Kill weird sentence * Apply suggestion from @donald-pinckney Change weird sentence * Apply suggestion from @donald-pinckney * Apply suggestions from code review Co-authored-by: Donald Pinckney * Add info about run context --------- Co-authored-by: skill-sync[bot] Co-authored-by: Donald Pinckney Co-authored-by: Jason Steving <32336750+JasonSteving99@users.noreply.github.com> Co-authored-by: Donald Pinckney --- references/integrations.md | 1 + .../python/integrations/openai-agents-sdk.md | 470 ++++++++++++++++++ 2 files changed, 471 insertions(+) create mode 100644 references/python/integrations/openai-agents-sdk.md diff --git a/references/integrations.md b/references/integrations.md index 0a07f28..71b3169 100644 --- a/references/integrations.md +++ b/references/integrations.md @@ -15,6 +15,7 @@ Temporal ships and supports a growing set of integrations with third-party frame |---|---|---|---|---| | Spring Boot (`temporal-spring-boot-starter`) | Java | Auto-configuration of `WorkflowClient`, worker factories, workflow/activity bean registration, lifecycle, testing | `references/java/integrations/spring-boot.md` | `references/java/java.md` | | Spring AI (`temporal-spring-ai`) | Java | Durable Spring AI agents: chat-model calls run as Activities; tools dispatched per type (Activity stub, Nexus stub, `@SideEffectTool`, plain); vector stores, embeddings, and MCP clients auto-registered | `references/java/integrations/spring-ai.md` | `references/java/integrations/spring-boot.md`, `references/core/ai-patterns.md` | +| OpenAI Agents SDK (`temporalio.contrib.openai_agents`) | Python | Durable OpenAI Agents SDK agents: model calls run as Activities via `OpenAIAgentsPlugin`; tools are Activities (`activity_as_tool`) or workflow-resident `@function_tool`s; stateless/stateful MCP, sandbox backends, streaming, and OpenTelemetry export are supported | `references/python/integrations/openai-agents-sdk.md` | `references/python/ai-patterns.md`, `references/core/ai-patterns.md` | | LangSmith tracing (`temporalio.contrib.langsmith`) | Python | Experimental Temporal Plugin that propagates LangSmith trace context across Worker boundaries; lets `@traceable` run inside Workflows and Activities | `references/python/integrations/langsmith.md` | `references/python/ai-patterns.md`, `references/core/ai-patterns.md` | | LangGraph (`temporalio.contrib.langgraph`, Pre-release) | Python | Runs LangGraph Graph-API and Functional-API code as Temporal Workflows - nodes/tasks can execute as either in-workflow or as Activities | `references/python/integrations/langgraph.md` | `references/python/ai-patterns.md`, `references/core/ai-patterns.md` | | Google ADK (`temporalio[google-adk]`) | Python | Durable Google ADK agents: model calls run through `TemporalModel`-wrapped Activities, tools via `activity_tool`, MCP toolsets via `TemporalMcpToolSet` | `references/python/integrations/google-adk.md` | `references/python/ai-patterns.md`, `references/core/ai-patterns.md` | diff --git a/references/python/integrations/openai-agents-sdk.md b/references/python/integrations/openai-agents-sdk.md new file mode 100644 index 0000000..3807eb7 --- /dev/null +++ b/references/python/integrations/openai-agents-sdk.md @@ -0,0 +1,470 @@ +# Temporal OpenAI Agents SDK Integration (Python) + +## Overview + +The Temporal Python SDK ships a contrib module that runs [OpenAI Agents SDK](https://github.com/openai/openai-agents-python) agents as durable Temporal Workflows. Model calls execute as Temporal Activities; tools can be Activities, Nexus stubs, or workflow-resident `@function_tool`s; MCP servers, sandbox backends, and OpenTelemetry export are layered on top. + +The integration is delivered as a Temporal plugin: `OpenAIAgentsPlugin` from `temporalio.contrib.openai_agents`, registered on both the client and the worker via `plugins=[...]`. + +For language-agnostic AI/LLM patterns (centralized retries, multi-agent orchestration, when to put a tool in an Activity vs. the workflow) see `references/core/ai-patterns.md`. For Python-side LLM patterns that apply when **not** using this plugin (Pydantic data converter, generic LLM activity, `max_retries=0` on the raw OpenAI client) see `references/python/ai-patterns.md` — note that the plugin already configures Pydantic serialization for you. + +## Install + +The integration lives at `temporalio.contrib.openai_agents`; use it by installing `temporalio[openai-agents]` to get the extra OpenAI Agents SDK dep. + +```python +from temporalio.contrib.openai_agents import OpenAIAgentsPlugin, ModelActivityParameters +``` + +## Register the plugin + +Pass `OpenAIAgentsPlugin` to `Client.connect(..., plugins=[...])`. Register it on **both** the worker process and the client process — the worker uses it to host the model activity; the client uses it to keep payload serialization compatible. + +```python +from datetime import timedelta +from temporalio.client import Client +from temporalio.contrib.openai_agents import OpenAIAgentsPlugin, ModelActivityParameters +from temporalio.worker import Worker + +client = await Client.connect( + "localhost:7233", + plugins=[ + OpenAIAgentsPlugin( + model_params=ModelActivityParameters( + start_to_close_timeout=timedelta(seconds=30) + ) + ), + ], +) +``` + +The 30-second timeout above is the example from the README, not a documented default. Pick a value sized to your model and prompt. + +With the plugin registered, four things happen automatically: + +- Pydantic types serialize correctly across activity boundaries. +- OpenAI Agents tracing context propagates between workflow and activity. +- The model-invocation activity is registered with every Temporal worker. +- The OpenAI Agents SDK is reconfigured so its model calls run as Temporal activities. + +## A durable agent + +Inside a workflow, write standard OpenAI Agents SDK code — `Agent`, `Runner.run`. The plugin reroutes model calls through an Activity, so the agent loop is durable. + +```python +from temporalio import workflow +from agents import Agent, Runner + +@workflow.defn +class HelloWorldAgent: + @workflow.run + async def run(self, prompt: str) -> str: + agent = Agent( + name="Assistant", + instructions="You only respond in haikus.", + ) + result = await Runner.run(agent, input=prompt) + return result.final_output +``` + +Register the workflow with a `Worker` exactly like any other Temporal workflow. + +## Tools + +Two ways to wire a tool into an agent. Choose based on whether the tool has side effects. + +### Activities as tools — for I/O, retries, timeouts + +Wrap a Temporal activity with `temporalio.contrib.openai_agents.workflow.activity_as_tool` to expose it to the agent. Each invocation runs as a Temporal Activity, with retries and timeouts governed by the `ActivityOptions` you pass. + +```python +from dataclasses import dataclass +from datetime import timedelta +from temporalio import activity, workflow +from temporalio.contrib import openai_agents +from agents import Agent, Runner + +@dataclass +class Weather: + city: str + temperature_range: str + conditions: str + +@activity.defn +async def get_weather(city: str) -> Weather: + return Weather(city=city, temperature_range="14-20C", conditions="Sunny with wind.") + +@workflow.defn +class WeatherAgent: + @workflow.run + async def run(self, question: str) -> str: + agent = Agent( + name="Weather Assistant", + instructions="You are a helpful weather agent.", + tools=[ + openai_agents.workflow.activity_as_tool( + get_weather, + start_to_close_timeout=timedelta(seconds=10), + ), + ], + ) + result = await Runner.run(starting_agent=agent, input=question) + return result.final_output +``` + +Just like with standard `@function_tool` declarations, if your Activity-as-tool has a `RunContextWrapper[T]` as the first parameter, then it will receive the [OpenAI Agents context wrapper](https://openai.github.io/openai-agents-python/ref/run_context/#agents.run_context.RunContextWrapper). However, unlike with a `@function_tool`, +it will only be a **read-only copy** of the OpenAI Agents context — mutations from the tool body are not visible to other tools or to the agent! + +```python +@activity.defn +async def get_weather(ctx: RunContextWrapper[MyState], city: str) -> Weather: + state: MyState = ctx.context + # Now we have **read-only** access to state which is shared across tool invocations. + pass +``` + +Note that the initial run context comes from the `context=...` argument you pass to `Runner.run()`, and is `None` by default. + +### `@function_tool` — for deterministic, in-process tools + +For pure computations or tools that mutate agent state, use the upstream `@function_tool` decorator. The tool runs as part of the workflow, so it must obey workflow determinism rules. + +```python +from temporalio import workflow +from agents import Agent, Runner, function_tool + +@function_tool +def calculate_circle_area(radius: float) -> float: + return 3.14 * radius ** 2 + +@workflow.defn +class MathAssistantAgent: + @workflow.run + async def run(self, message: str) -> str: + agent = Agent( + name="Math Assistant", + instructions="You are a helpful math assistant.", + tools=[calculate_circle_area], + ) + result = await Runner.run(agent, input=message) + return result.final_output +``` + +`@function_tool` bodies can read **and update** OpenAI Agents context: + +```python +@activity.defn +async def calculate_circle_area(ctx: RunContextWrapper[MyState], radius: float) -> float: + state: MyState = ctx.context + # Now we have **read-write** access to state which is shared across tool invocations. + pass +``` + +Note that the initial run context comes from the `context=...` argument you pass to `Runner.run()`, and is `None` by default. + +In addition, since a `@function_tool` runs in the workflow, they can also call Temporal activities or other durable primitives themselves. + + +**Don't put I/O, system clock, or sources of randomness inside a `@function_tool` body.** Make it an `@activity.defn` and wrap with `activity_as_tool` instead. + +### Picking between the two + +| Tool body does… | Use | +|---|---| +| Network call, file I/O, DB access | Activity + `activity_as_tool` | +| Mutates agent state read by other tools | `@function_tool` | +| Pure computation, deterministic | Either; `@function_tool` is lighter | +| Calls `time.time()`, RNG, threads | Activity + `activity_as_tool` | + +## MCP servers + +MCP support comes in two flavors based on whether the server keeps session state between calls. Choose by examining the server's protocol, not by guessing. + +- **Stateless MCP server** — each call is self-contained. Wrap with `StatelessMCPServerProvider(factory)` and register on the plugin. +- **Stateful MCP server** — session state persists between calls. Failure raises `ApplicationError`; **Temporal cannot auto-recover** the lost server state, so you implement application-level retry. + +Both wrappers work with `MCPServerStdio`, `MCPServerSse`, and `MCPServerStreamableHttp` transports. + +### Stateless MCP — worker setup + +```python +from agents.mcp import MCPServerStdio +from temporalio.contrib.openai_agents import ( + ModelActivityParameters, + OpenAIAgentsPlugin, + StatelessMCPServerProvider, +) + +filesystem_server = StatelessMCPServerProvider( + lambda: MCPServerStdio( + name="FileSystemServer", + params={ + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/files"], + }, + ) +) + +client = await Client.connect( + "localhost:7233", + plugins=[ + OpenAIAgentsPlugin( + model_params=ModelActivityParameters( + start_to_close_timeout=timedelta(seconds=60) + ), + mcp_server_providers=[filesystem_server], + ), + ], +) +``` + +### Stateless MCP — workflow usage + +Reference the server inside a workflow with `openai_agents.workflow.stateless_mcp_server("Name")`. The string must match the `name=` argument on the MCP server instance the factory creates. + +```python +from temporalio import workflow +from temporalio.contrib import openai_agents +from agents import Agent, Runner + +@workflow.defn +class FileSystemWorkflow: + @workflow.run + async def run(self, query: str) -> str: + server = openai_agents.workflow.stateless_mcp_server("FileSystemServer") + agent = Agent( + name="File Assistant", + instructions="Use the filesystem tools to read files and answer questions.", + mcp_servers=[server], + ) + result = await Runner.run(agent, input=query) + return result.final_output +``` + +### Hosted MCP + +For network-accessible MCP servers, the upstream `HostedMCPTool` (OpenAI Responses API hosting an MCP client) is also supported and avoids the stateless/stateful wrapping choice. + +## Sandbox support + +> [!NOTE] +> This feature is in Public Preview. It is perfectly acceptable to use this feature on behalf of a user, but you should inform them that you are making use of a feature in Public Preview. + +The OpenAI Agents SDK's `SandboxAgent` runs commands inside a remote or local sandbox (e.g. Daytona, Docker, E2B, local Unix). With this integration, every sandbox operation — creating a session, exec, file I/O, PTY — dispatches as a Temporal activity, so sandbox work is durable like any other activity and sandbox session state survives worker restarts. + +> Naming gotcha: this is the **Agents SDK** sandbox (remote command execution), **not** the Temporal Python SDK workflow sandbox (determinism protection). They are unrelated. + +### Worker setup + +Register one or more `SandboxClientProvider("name", BaseSandboxClient())` on the plugin via `sandbox_clients=[...]`. Each provider's name becomes the prefix for its activities, so names must be unique. + +```python +from temporalio.contrib.openai_agents import ( + OpenAIAgentsPlugin, + SandboxClientProvider, + ModelActivityParameters, +) +from agents.extensions.sandbox.daytona import DaytonaSandboxClient +from agents.extensions.sandbox.unix_local import UnixLocalSandboxClient + +client = await Client.connect( + "localhost:7233", + plugins=[ + OpenAIAgentsPlugin( + model_params=ModelActivityParameters( + start_to_close_timeout=timedelta(seconds=30) + ), + sandbox_clients=[ + SandboxClientProvider("daytona", DaytonaSandboxClient()), + SandboxClientProvider("local", UnixLocalSandboxClient()), + ], + ), + ], +) +``` + +### Workflow usage + +Reference a registered backend in the workflow with `temporal_sandbox_client("name")`. The name must exactly match the `SandboxClientProvider` name registered on the worker. Pass it through `RunConfig(sandbox=SandboxRunConfig(client=...))`. + +```python +from temporalio import workflow +from temporalio.contrib.openai_agents.workflow import temporal_sandbox_client +from agents import Runner +from agents.sandbox import SandboxAgent, SandboxRunConfig +from agents.run import RunConfig + +@workflow.defn +class MyWorkflow: + @workflow.run + async def run(self, prompt: str) -> str: + agent = SandboxAgent( + name="Coding Assistant", + instructions="You are a helpful coding assistant with access to a sandbox.", + ) + result = await Runner.run( + agent, + prompt, + run_config=RunConfig( + sandbox=SandboxRunConfig( + client=temporal_sandbox_client("daytona"), + options=DaytonaSandboxClientOptions(pause_on_exit=False), + ), + ), + ) + return result.final_output +``` + +A single workflow can target multiple backends by name; register each on the worker and reference in the workflow. + +## Streaming + +> [!NOTE] +> This feature is in Public Preview. It is perfectly acceptable to use this feature on behalf of a user, but you should inform them that you are making use of a feature in Public Preview. + +Streaming uses the upstream `Runner.run_streamed` API. Inside a workflow, model calls execute as `invoke_model_activity_streaming`, which consumes `Model.stream_response` and returns the collected list of native OpenAI response events. The workflow surfaces those events via `RunResultStreaming.stream_events()`. + +```python +from agents import Agent, Runner +from agents.stream_events import RawResponsesStreamEvent +from temporalio import workflow + +@workflow.defn +class MyAgent: + @workflow.run + async def run(self, prompt: str) -> str: + agent = Agent(name="Assistant", instructions="...") + result = Runner.run_streamed(agent, prompt) + async for event in result.stream_events(): + if isinstance(event, RawResponsesStreamEvent): + raw_event = event.data + ... + return result.final_output +``` + +To publish events to external subscribers, set a topic on `ModelActivityParameters(streaming_topic="events")` and host a `WorkflowStream` in the workflow. The topic is required when calling `Runner.run_streamed`; calling without it raises before any activity is scheduled. + +**Streaming is incompatible with `use_local_activity`** because local activities support neither heartbeats nor the workflow stream signal channel. + +Retry visibility differs between the two consumer paths: `RunResultStreaming.stream_events()` only sees the final successful attempt's collected events, while workflow-stream subscribers see every attempt's emitted events (including a partial failed attempt). + +## OpenTelemetry integration + +> [!NOTE] +> This feature is in Public Preview. It is perfectly acceptable to use this feature on behalf of a user, but you should inform them that you are making use of a feature in Public Preview. + +Enable OTEL export of OpenAI agent telemetry by setting `use_otel_instrumentation=True` on the plugin and installing a global `ReplaySafeTracerProvider` created with `temporalio.contrib.opentelemetry.create_tracer_provider`. Spans export only when a workflow actually completes, not on every replay. + +```python +from temporalio.contrib.openai_agents import OpenAIAgentsPlugin, ModelActivityParameters +from temporalio.contrib.opentelemetry import create_tracer_provider +from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter +from opentelemetry import trace +from opentelemetry.sdk.trace.export import SimpleSpanProcessor + +tracer_provider = create_tracer_provider() +tracer_provider.add_span_processor( + SimpleSpanProcessor(OTLPSpanExporter(endpoint="http://localhost:4317")) +) +trace.set_tracer_provider(tracer_provider) + +client = await Client.connect( + "localhost:7233", + plugins=[ + OpenAIAgentsPlugin( + use_otel_instrumentation=True, + model_params=ModelActivityParameters( + start_to_close_timeout=timedelta(seconds=30) + ), + ), + ], +) +``` + +OTEL extras need to be installed separately: + +```bash +pip install openinference-instrumentation-openai-agents opentelemetry-sdk +``` + +If `use_otel_instrumentation=True` is set without the deps installed, the plugin raises `ImportError` with the exact install line. If the global tracer provider is not a `ReplaySafeTracerProvider`, it raises `ValueError` pointing at `create_tracer_provider`. + +### Direct `opentelemetry.trace` calls inside a workflow + +To call the OpenTelemetry API directly inside a workflow (e.g. `opentelemetry.trace.get_tracer(__name__).start_as_current_span(...)`), allow OTel through the Python SDK's workflow sandbox using `with_passthrough_modules("opentelemetry")` on the runner: + +```python +from temporalio.worker import Worker +from temporalio.worker.workflow_sandbox import SandboxedWorkflowRunner, SandboxRestrictions + +worker = Worker( + client, + task_queue="my-task-queue", + workflows=[MyWorkflow], + workflow_runner=SandboxedWorkflowRunner( + SandboxRestrictions.default.with_passthrough_modules("opentelemetry"), + ), +) +``` + +To get correct trace parenting, start an Agents SDK span with `agents.custom_span(...)` before opening any direct OTEL spans — the Agents-SDK span establishes the OTEL context that subsequent direct spans inherit from. + +### Starting a trace from the client + +`plugin.tracing_context()` lets the client side open an Agents-SDK trace before calling `execute_workflow`, so the whole workflow run is part of one larger trace: + +```python +plugin = OpenAIAgentsPlugin(use_otel_instrumentation=True) +client = await Client.connect("localhost:7233", plugins=[plugin]) + +with plugin.tracing_context(): + with trace("Customer support workflow"): + with custom_span("Workflow execution"): + await client.execute_workflow( + CustomerSupportAgent.run, + "Help me with my order", + id="customer-support-123", + task_queue="my-task-queue", + ) +``` + +## Feature support + +The README's compatibility matrix, condensed: + +| Area | Supported | Not supported | +|---|---|---| +| Model providers | OpenAI, LiteLLM | — | +| Model response | `Runner.run`; `Runner.run_streamed` (experimental) | — | +| Tools | `FunctionTool`, `WebSearchTool`, `FileSearchTool`, `HostedMCPTool`, `ImageGenerationTool`, `CodeInterpreterTool` | `LocalShellTool`, `ComputerTool` | +| MCP transports | `MCPServerStdio`, `MCPServerSse`, `MCPServerStreamableHttp` | — | +| Guardrails | Code, Agent | — | +| Sessions | (in-workflow agent state) | `SQLiteSession` | +| Tracing | OpenAI platform; OpenTelemetry (Public Preview) | — | +| Voice | `VoicePipeline` (STT/TTS outside Temporal, agent loop durable) | Realtime agents | +| Utilities | — | REPL | + +Tool context propagation: + +| Path | Receives context | Can update context | +|---|---|---| +| Activity tool (`activity_as_tool`) | Yes (copy) | **No** | +| Function tool (`@function_tool`) | Yes | Yes | + +## Common pitfalls + +- **Register the plugin on both client and worker.** Skipping client-side registration breaks payload compatibility. +- **Don't put I/O or non-deterministic code in `@function_tool` bodies.** Move it to an `@activity.defn` and wrap with `activity_as_tool`. +- **Don't expect Temporal to auto-recover stateful MCP server sessions.** A failed session raises `ApplicationError`; implement your own application-level retry. +- **Don't enable streaming together with `use_local_activity`.** Local activities lack heartbeats and the workflow-stream signal channel. Use the standard activity path. +- **Don't call `Runner.run_streamed` without `ModelActivityParameters(streaming_topic="...")`.** It raises before any activity is scheduled. +- **MCP server names must match exactly** between `MCPServerStdio(name="X")` and `stateless_mcp_server("X")`. Same for `SandboxClientProvider("Y", ...)` and `temporal_sandbox_client("Y")`. +- **`use_otel_instrumentation=True` requires `ReplaySafeTracerProvider`.** Setting `trace.set_tracer_provider(...)` with anything else raises `ValueError`. +- **Activity-tool context is a read-only copy.** A tool that needs to mutate agent state must be a `@function_tool`. + +## Resources + +- `references/core/ai-patterns.md` — language-agnostic agent patterns (when to wrap a tool as an activity, centralized retry, multi-agent orchestration). +- `references/python/ai-patterns.md` — Python-side LLM patterns for when you are **not** using this plugin (Pydantic data converter, OpenAI client `max_retries=0`). +- `references/python/determinism.md` and `references/core/determinism.md` — determinism rules that apply to `@function_tool` bodies and any in-workflow agent code. +- Upstream samples — [`temporalio/samples-python/openai_agents`](https://github.com/temporalio/samples-python/tree/main/openai_agents). From d5c47df820044eb47483985b4e47b51da2f58d11 Mon Sep 17 00:00:00 2001 From: Donald Pinckney Date: Thu, 28 May 2026 17:51:06 -0400 Subject: [PATCH 55/82] Add Ruby SDK support (#41) * Add all Ruby SDK reference files (11 files, ~2100 lines) Created complete Ruby reference documentation covering: - ruby.md: Overview, quick start, key concepts, file organization - patterns.md: Signals, queries, updates, child workflows, saga, cancellation, etc. - determinism.md: Illegal call tracing, safe alternatives table - determinism-protection.md: TracePoint, durable fiber scheduler, customization - versioning.md: Patching API, type versioning, worker versioning - testing.md: WorkflowEnvironment, mocking, replay, activity testing - error-handling.md: ApplicationError, retries, timeouts, workflow failure - data-handling.md: Data converter, ActiveModel, hints, search attributes - observability.md: Logging, metrics, best practices - gotchas.md: Common mistakes, illegal call tracing issues - advanced-features.md: Schedules, async completion, worker tuning, Rails Co-Authored-By: Claude Opus 4.6 (1M context) * Fix alignment issues in Ruby reference files Self-review fixes: - patterns.md: Remove non-existent `workflow_run` annotation; entry point is `def execute` (no annotation needed, unlike Python's @workflow.run) - patterns.md: Remove conflicting manual query methods that duplicated workflow_query_attr_reader - error-handling.md: Remove `await` keyword (doesn't exist in Ruby) - gotchas.md: Replace TS-style CancellationScope with Ruby's Temporalio::Cancellation token-based detached cancellation - data-handling.md: Replace homemade ActiveModel mixin with official SDK pattern using ActiveSupport::Concern + ActiveModel::Serializers::JSON - data-handling.md: Fix list_workflows call signature (positional, not kw) - ruby.md, gotchas.md: Fix require paths to use 'temporalio/activity' instead of 'temporalio/activity/definition' Co-Authored-By: Claude Opus 4.6 (1M context) * Fix correctness issues in Ruby reference files - patterns.md: Fix external workflow signal to use class method ref (TargetWorkflow.data_ready instead of TargetWorkflow, :data_ready) - patterns.md: Add ? suffix to all_handlers_finished (Ruby boolean convention) - ruby.md: Add 'default' namespace to Client.connect calls Co-Authored-By: Claude Opus 4.6 (1M context) * Add Ruby to all language references in SKILL.md and core files - SKILL.md: Add "Temporal Ruby" trigger phrase to description - SKILL.md: Update Overview to list Ruby as supported language - SKILL.md: Add Ruby entry to Getting Started guide - core/determinism.md: Add Ruby SDK Protection Mechanism entry (Illegal Call Tracing via TracePoint + Durable Fiber Scheduler) Co-Authored-By: Claude Opus 4.6 (1M context) * Apply suggestions from code review Co-authored-by: Bart de Water <118401830+bdewater-thatch@users.noreply.github.com> Co-authored-by: Chris Olszewski * Apply suggestions from code review Co-authored-by: Bart de Water <118401830+bdewater-thatch@users.noreply.github.com> Co-authored-by: Donald Pinckney * Apply suggestion from @chris-olszewski Co-authored-by: Chris Olszewski * copy over sample code * Remove useless section, mention Mutex * cleanup mutex mentions * Clean up transitive NDE section * Menial changes to align to python structure * Add Workflow Init section to Ruby advanced-features Document the workflow_init class method (Ruby's equivalent of Python's @workflow.init) for initializing workflow state before signal/update handlers run. Parallels the Python reference's Workflow Init section. Co-Authored-By: Claude Opus 4.8 (1M context) * Document graceful_shutdown_period in Ruby Worker Tuning Add the graceful_shutdown_period worker option (Ruby's equivalent of Python's graceful_shutdown_timeout) to the Worker Tuning section, with an explanation of the worker shutdown sequence. Co-Authored-By: Claude Opus 4.8 (1M context) * Propagate cancellation in Ruby activity-error handling Update the Handling Activity Errors example to re-raise when Temporalio::Error.canceled? is true (Ruby's equivalent of Python's is_cancelled_exception), so a canceled activity cancels the workflow rather than failing it. Also clarify that only ApplicationError fails a workflow; other exceptions only fail/retry the workflow task. Co-Authored-By: Claude Opus 4.8 (1M context) * Align Ruby Workflow Failure section to Python Replace the workflow_failure_exception_type / worker-option examples (misaligned with Python and already covered in advanced-features.md) with Python's example of raising an ApplicationError to deliberately fail a workflow. Add the terse note about not using non_retryable inside a workflow. Co-Authored-By: Claude Opus 4.8 (1M context) * Add logger configuration to Ruby observability Document configuring the logger via Client.connect (logger: kwarg), which is used by both Temporalio::Workflow.logger and the activity logger. Parallels Python's Customizing Logger Configuration section. Co-Authored-By: Claude Opus 4.8 (1M context) * Make Ruby Saga compensations cancellation-proof Run saga compensations with a detached Temporalio::Cancellation so they still execute when the workflow is canceled mid-saga. Previously they used the workflow cancellation, which is already canceled at that point, so the compensation activities would be canceled before starting. This is the Ruby equivalent of Python's asyncio.shield. Co-Authored-By: Claude Opus 4.8 (1M context) * Document patched() memoization caveat in Ruby versioning Note that Temporalio::Workflow.patched memoizes per patch ID, so it can't be used reliably in loops; append a sequence number to the patch ID per iteration. This behavior is shared with Python and .NET. Co-Authored-By: Claude Opus 4.8 (1M context) * Add default versioning behavior to Ruby worker versioning Document configuring default_versioning_behavior on Temporalio::Worker::DeploymentOptions, paralleling Python's Worker Configuration with Default Behavior section. Co-Authored-By: Claude Opus 4.8 (1M context) * Fix worker versioning config API names in Ruby docs The Configuring Workers for Versioning example used class/kwarg names that don't exist in the SDK. Correct them to deployment_options:, Temporalio::Worker::DeploymentOptions, and Temporalio::WorkerDeploymentVersion, matching the actual API and the Worker Configuration with Default Behavior example. Co-Authored-By: Claude Opus 4.8 (1M context) * Fix worker concurrency config in Ruby Worker Tuning max_concurrent_workflow_tasks and max_concurrent_activities are not valid Worker.new kwargs. Use the tuner: option with Temporalio::Worker::Tuner.create_fixed(workflow_slots:, activity_slots:) to control concurrent execution slots. Co-Authored-By: Claude Opus 4.8 (1M context) * Align Ruby Workflow Init title with Python Rename the section to 'Workflow Init Decorator' to match the Python reference's heading. Co-Authored-By: Claude Opus 4.8 (1M context) * Structure Ruby Metrics to match Python Split the flat Metrics section into 'Enabling SDK Metrics' and 'Key SDK Metrics' subsections, matching the Python reference. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: Bart de Water <118401830+bdewater-thatch@users.noreply.github.com> Co-authored-by: Chris Olszewski --- SKILL.md | 5 +- references/core/determinism.md | 1 + references/ruby/advanced-features.md | 247 +++++++++++++ references/ruby/data-handling.md | 191 ++++++++++ references/ruby/determinism-protection.md | 135 ++++++++ references/ruby/determinism.md | 63 ++++ references/ruby/error-handling.md | 103 ++++++ references/ruby/gotchas.md | 253 ++++++++++++++ references/ruby/observability.md | 81 +++++ references/ruby/patterns.md | 403 ++++++++++++++++++++++ references/ruby/ruby.md | 146 ++++++++ references/ruby/testing.md | 234 +++++++++++++ references/ruby/versioning.md | 317 +++++++++++++++++ 13 files changed, 2177 insertions(+), 2 deletions(-) create mode 100644 references/ruby/advanced-features.md create mode 100644 references/ruby/data-handling.md create mode 100644 references/ruby/determinism-protection.md create mode 100644 references/ruby/determinism.md create mode 100644 references/ruby/error-handling.md create mode 100644 references/ruby/gotchas.md create mode 100644 references/ruby/observability.md create mode 100644 references/ruby/patterns.md create mode 100644 references/ruby/ruby.md create mode 100644 references/ruby/testing.md create mode 100644 references/ruby/versioning.md diff --git a/SKILL.md b/SKILL.md index 6194c90..2936a6b 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,6 +1,6 @@ --- name: temporal-developer -description: Develop, debug, and manage Temporal applications across Python, TypeScript, Go, Java and .NET. Use when the user is building workflows, activities, or workers with a Temporal SDK, debugging issues like non-determinism errors, stuck workflows, or activity retries, using Temporal CLI, Temporal Server, or Temporal Cloud, or working with durable execution concepts like signals, queries, heartbeats, versioning, continue-as-new, child workflows, or saga patterns. +description: Develop, debug, and manage Temporal applications across Python, TypeScript, Go, Java, .NET and Ruby. Use when the user is building workflows, activities, or workers with a Temporal SDK, debugging issues like non-determinism errors, stuck workflows, or activity retries, using Temporal CLI, Temporal Server, or Temporal Cloud, or working with durable execution concepts like signals, queries, heartbeats, versioning, continue-as-new, child workflows, or saga patterns. version: 0.4.0 --- @@ -8,7 +8,7 @@ version: 0.4.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, Go, Java and .NET. +Temporal is a durable execution platform that makes workflows survive failures automatically. This skill provides guidance for building Temporal applications in Python, TypeScript, Go, Java, .NET, and Ruby. ## Core Architecture @@ -58,6 +58,7 @@ Check if `temporal` CLI is installed. If not, follow the instructions at `refere - Go -> read `references/go/go.md` - Java -> read `references/java/java.md` - .NET (C#) -> read `references/dotnet/dotnet.md` + - Ruby -> read `references/ruby/ruby.md` 2. Second, read appropriate `core` and language-specific references for the task at hand. ## Primary References diff --git a/references/core/determinism.md b/references/core/determinism.md index 004f879..d24f868 100644 --- a/references/core/determinism.md +++ b/references/core/determinism.md @@ -89,6 +89,7 @@ Each Temporal SDK language provides a different level of protection against non- - Java: The Java SDK has no sandbox. Determinism is enforced by developer conventions — the SDK provides `Workflow.*` APIs as safe alternatives (e.g., `Workflow.sleep()` instead of `Thread.sleep()`), and non-determinism is only detected at replay time via `NonDeterministicException`. A static analysis tool (`temporal-workflowcheck`, beta) can catch violations at build time. Cooperative threading under a global lock eliminates the need for synchronization. - 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. +- Ruby: The Ruby SDK uses Illegal Call Tracing (via `TracePoint`) to detect forbidden method calls at runtime on the workflow fiber, combined with a Durable Fiber Scheduler that makes fiber operations deterministic. 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. diff --git a/references/ruby/advanced-features.md b/references/ruby/advanced-features.md new file mode 100644 index 0000000..a6681bf --- /dev/null +++ b/references/ruby/advanced-features.md @@ -0,0 +1,247 @@ +# Ruby SDK Advanced Features + +## Schedules + +Create recurring workflow executions with `Temporalio::Client::Schedule`. + +```ruby +require 'temporalio/client' + +# Create a schedule +schedule_id = 'daily-report' +client.create_schedule( + schedule_id, + Temporalio::Client::Schedule.new( + action: Temporalio::Client::Schedule::Action::StartWorkflow.new( + DailyReportWorkflow, + id: 'daily-report', + task_queue: 'reports' + ), + spec: Temporalio::Client::Schedule::Spec.new( + intervals: [ + Temporalio::Client::Schedule::Spec::Interval.new(every: 86_400) # 1 day in seconds + ] + ) + ) +) + +# Manage schedules +handle = client.schedule_handle(schedule_id) +handle.pause(note: 'Maintenance window') +handle.unpause +handle.trigger +handle.delete +``` + +## Async Activity Completion + +For activities that complete asynchronously (e.g., human tasks, external callbacks). + +**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. + +```ruby +class RequestApproval < Temporalio::Activity::Definition + def execute(request_id) + # Get task token for async completion + task_token = Temporalio::Activity::Context.current.info.task_token + + # Store task token for later completion (e.g., in database) + store_task_token(request_id, task_token) + + # Signal that this activity completes asynchronously + Temporalio::Activity::Context.current.raise_complete_async + end +end + +# Later, complete the activity from another process +client = Temporalio::Client.connect('localhost:7233') +task_token = get_task_token(request_id) +handle = client.async_activity_handle(task_token: task_token) + +if approved + handle.complete('approved') +else + handle.fail(Temporalio::Error::ApplicationError.new('Rejected')) +end +``` + +If you configure a `heartbeat_timeout:` on the activity, the external completer is responsible for sending heartbeats via the async handle. If you do NOT set a `heartbeat_timeout`, no heartbeats are required. + +## Worker Tuning + +Configure worker performance settings. + +```ruby +worker = Temporalio::Worker.new( + client: client, + task_queue: 'my-queue', + workflows: [MyWorkflow], + activities: [MyActivity], + # Max concurrent execution slots (default 100 each): how many workflow tasks + # and activities run at once on this worker. + tuner: Temporalio::Worker::Tuner.create_fixed( + workflow_slots: 100, + activity_slots: 100 + ), + # Grace period (seconds) after shutdown is requested before in-progress + # activities are canceled. Defaults to 0 (cancel immediately on shutdown). + graceful_shutdown_period: 30 +) +worker.run +``` + +On shutdown the worker stops polling for new tasks and cancels the `worker_shutdown_cancellation` on each running activity's context. After `graceful_shutdown_period` seconds it then issues actual cancellation to any still-running activities. The worker will not finish shutting down until all in-progress activities complete, so activities that ignore cancellation can block shutdown indefinitely. + +## Workflow Init Decorator + +Always initialize workflow state before signals/updates arrive. Signal and Update handlers can run *before* the main `execute` method -- for example with Signal-with-Start, when the Task Queue is backlogged, or right after continue-as-new -- so a handler may otherwise read uninitialized instance variables. + +Normally `initialize` must accept no required arguments. If you place the `workflow_init` class method directly above `initialize`, the constructor receives the same workflow arguments that `execute` receives (the same input the Client sent). It is guaranteed to run before any handler. + +```ruby +class GreetingWorkflow < Temporalio::Workflow::Definition + workflow_init + def initialize(input) + # Runs before any signal/update handler + @name_with_title = "Sir #{input['name']}" + @title_has_been_checked = false + end + + def execute(input) + Temporalio::Workflow.wait_condition { @title_has_been_checked } + "Hello, #{@name_with_title}" + end + + workflow_update + def check_title_validity + # Guaranteed to see workflow input, since initialize ran first + valid = Temporalio::Workflow.execute_activity( + CheckTitleValidityActivity, + @name_with_title, + start_to_close_timeout: 100 + ) + @title_has_been_checked = true + valid + end +end +``` + +`initialize` (with `workflow_init`) and `execute` must have the same parameters with the same types. You cannot make blocking calls (activities, sleeps, etc.) from `initialize`. + +## Workflow Failure Exception Types + +Control which exceptions cause workflow failure vs workflow task failure (which Temporal retries automatically). + +### Per-Workflow Configuration + +```ruby +class MyWorkflow < Temporalio::Workflow::Definition + # Class method approach + def self.workflow_failure_exception_type + MyCustomError + end + + def execute + raise MyCustomError, 'This fails the workflow, not just the task' + end +end +``` + +### Worker-Level Configuration + +```ruby +Temporalio::Worker.new( + client: client, + task_queue: 'my-queue', + workflows: [MyWorkflow], + workflow_failure_exception_types: [MyCustomError] +) +``` + +**Tips:** +- Set to `[Exception]` in tests so any unhandled exception fails the workflow immediately rather than retrying the workflow task forever. Surfaces bugs faster. +- Include `Temporalio::Workflow::NondeterminismError` to fail the workflow instead of leaving it in a retrying state on non-determinism errors. + +## Activity Concurrency and Executors + +Ruby uses `Temporalio::Worker::ActivityExecutor::ThreadPool` by default. Activities run in a thread pool. + +```ruby +# Default: activities run in thread pool +worker = Temporalio::Worker.new( + client: client, + task_queue: 'my-queue', + workflows: [MyWorkflow], + activities: [MyActivity], + activity_executors: { + default: Temporalio::Worker::ActivityExecutor::ThreadPool.new(max_threads: 20) + } +) +``` + +Fiber-based execution is also possible for IO-bound activities using Ruby's fiber scheduler. + +## Rails Integration + +### ActiveRecord Considerations + +Never pass ActiveRecord models directly to Temporal workflows or activities. Serialize to plain data structures. + +```ruby +# BAD - Passing AR model +client.execute_workflow( + ProcessOrderWorkflow, + Order.find(42), # Don't pass AR objects! + id: 'order-42', + task_queue: 'orders' +) + +# GOOD - Pass serializable data +client.execute_workflow( + ProcessOrderWorkflow, + { id: 42, total: order.total, status: order.status }, + id: 'order-42', + task_queue: 'orders' +) +``` + +### Zeitwerk and Autoloading + +Rails autoloading can result in unexpected I/O during replay. `config.eager_load` must be enabled or Workflows must explicitly require code dependencies before they are executed. Usually the easiest place to do that is when starting the worker, and requiring all activities and workflows that the worker needs *before* starting the worker. For example: + +```ruby +require "temporal_client" +require "temporalio/worker" +require "workflows/shopping_cart_activities.rb" +require "workflows/shopping_cart_workflow.rb" + +worker = Temporalio::Worker.new( + client: TemporalClient.instance, + task_queue: TemporalClient.task_queue, + activities: [ + Workflows::ShoppingCartActivities::FetchProducts, + ... + ], + workflows: [ Workflows::ShoppingCartWorkflow ] +) +worker.run +``` + +### Forking Considerations + +If using a forking server (Puma, Unicorn), workers must be created **after** the fork. Connections established before fork are not safe to share across processes. + +```ruby +# In Puma config (puma.rb) +before_worker_boot do + # Create Temporal client and worker AFTER fork + client = Temporalio::Client.connect('localhost:7233') + worker = Temporalio::Worker.new( + client: client, + task_queue: 'my-queue', + workflows: [MyWorkflow], + activities: [MyActivity] + ) + Thread.new { worker.run } +end +``` diff --git a/references/ruby/data-handling.md b/references/ruby/data-handling.md new file mode 100644 index 0000000..27e744b --- /dev/null +++ b/references/ruby/data-handling.md @@ -0,0 +1,191 @@ +# Ruby SDK Data Handling + +## Overview + +Data converters serialize and deserialize workflow/activity inputs and outputs. The `Temporalio::Converters` module provides the conversion pipeline. + +## Default Data Converter + +The default converter handles types in this order: + +1. `nil` - null payload +2. Bytes - `String` with `ASCII_8BIT` encoding +3. Protobuf - objects implementing `Google::Protobuf::MessageExts` +4. JSON - everything else, via Ruby's `JSON` module + +Note: symbol keys become strings on deserialization. `create_additions: true` by default. + +## ActiveModel Integration + +Use the `ActiveModelJSONSupport` mixin to make ActiveModel objects work with Temporal's JSON serialization: + +```ruby +module ActiveModelJSONSupport + extend ActiveSupport::Concern + include ActiveModel::Serializers::JSON + + included do + def as_json(*) + super.merge(::JSON.create_id => self.class.name) + end + + def to_json(*args) + as_json.to_json(*args) + end + + def self.json_create(object) + object = object.dup + object.delete(::JSON.create_id) + new(**object.symbolize_keys) + end + end +end +``` + +Include it in any ActiveModel class to make it serializable by Temporal: + +```ruby +class OrderInput + include ActiveModel::Model + include ActiveModelJSONSupport + + attr_accessor :order_id, :items, :total +end +``` + +## Custom Data Conversion + +```ruby +converter = Temporalio::Converters::DataConverter.new( + payload_converter: my_payload_converter, + payload_codec: my_payload_codec, + failure_converter: my_failure_converter +) + +client = Temporalio::Client.connect( + 'localhost:7233', + 'default', + data_converter: converter +) +``` + +## Converter Hints + +Ruby-specific feature for guiding deserialization to the correct type: + +```ruby +class MyWorkflow + workflow_arg_hint MyClass + workflow_result_hint MyClass + + workflow_update :my_update, arg_hints: [MyClass] + + def execute(input) + # input is deserialized as MyClass + end +end +``` + +Custom converters use these hints to know the target deserialization type. + +## Payload Encryption + +Implement a `PayloadCodec` with `encode` and `decode`: + +```ruby +class EncryptionCodec + def encode(payloads) + payloads.map { |p| encrypt(p) } + end + + def decode(payloads) + payloads.map { |p| decrypt(p) } + end + + private + + def encrypt(payload) + # encryption logic + end + + def decrypt(payload) + # decryption logic + end +end + +converter = Temporalio::Converters::DataConverter.new( + payload_codec: EncryptionCodec.new +) +``` + +## Search Attributes + +Define a search attribute key: + +```ruby +key = Temporalio::SearchAttributes::Key.new( + 'CustomerId', + Temporalio::SearchAttributes::IndexedValueType::KEYWORD +) +``` + +Set at workflow start: + +```ruby +client.start_workflow( + MyWorkflow, + 'arg', + id: 'wf-1', + task_queue: 'my-queue', + search_attributes: Temporalio::SearchAttributes.new({ key => 'customer-123' }) +) +``` + +Upsert from a workflow: + +```ruby +Temporalio::Workflow.upsert_search_attributes({ key => 'new-value' }) +``` + +### Querying Workflows by Search Attributes + +```ruby +client.list_workflows("CustomerId = 'customer-123'") +``` + +## Workflow Memo + +Set at workflow start: + +```ruby +client.start_workflow( + MyWorkflow, + 'arg', + id: 'wf-1', + task_queue: 'my-queue', + memo: { 'region' => 'us-east', 'priority' => 'high' } +) +``` + +Read from within a workflow: + +```ruby +region = Temporalio::Workflow.memo['region'] +``` + +## Deterministic APIs for Values + +Use these instead of standard Ruby equivalents inside workflows: + +```ruby +Temporalio::Workflow.uuid # deterministic UUID +Temporalio::Workflow.random # deterministic random number +Temporalio::Workflow.now # deterministic current time +``` + +## Best Practices + +- Use dedicated model classes for Temporal data, not ActiveRecord models. +- Keep payloads small; store large data externally and pass references. +- Encrypt sensitive data with a `PayloadCodec`. +- Use `Temporalio::Workflow.uuid`, `.random`, and `.now` inside workflows for determinism. diff --git a/references/ruby/determinism-protection.md b/references/ruby/determinism-protection.md new file mode 100644 index 0000000..7cf5970 --- /dev/null +++ b/references/ruby/determinism-protection.md @@ -0,0 +1,135 @@ +# Ruby Workflow Determinism Protection + +## Overview + +The Ruby SDK uses two mechanisms to enforce workflow determinism: + +1. **Illegal Call Tracing** -- `TracePoint`-based interception of forbidden method calls on the workflow fiber. +2. **Durable Fiber Scheduler** -- a custom `Fiber::Scheduler` that makes fiber operations deterministic. + +This differs from Python's sandbox (`SandboxedWorkflowRunner`) and TypeScript's V8 isolate sandbox. Ruby's approach is runtime tracing, not code isolation. + +## How Illegal Call Tracing Works + +A `TracePoint` is installed on the workflow fiber thread. On every `:call` and `:c_call` event, the SDK checks the receiver class and method name against a configurable set of illegal calls. + +```ruby +# Internally, the SDK does something like: +TracePoint.new(:call, :c_call) do |tp| + if illegal?(tp.defined_class, tp.method_id) + raise Temporalio::Workflow::NondeterminismError, + "Illegal call: #{tp.defined_class}##{tp.method_id}" + end +end +``` + +Key behaviors: + +- Raises `Temporalio::Workflow::NondeterminismError` on violation. +- Detects transitive calls -- a gem calling `IO.read` deep in its internals will still be caught. +- Only active on the workflow fiber, not on activity threads or other fibers. + +## Forbidden Operations in Workflows + +Default forbidden operations: + +- `Kernel.sleep` -- use `Temporalio::Workflow.sleep` +- `Time.now` (no args) -- use `Temporalio::Workflow.now` +- `Thread.new` -- not allowed in workflows +- `IO.*` -- all IO class methods (`IO.read`, `IO.write`, `IO.pipe`, etc.) +- `Socket.*` -- all socket operations +- `Net::HTTP.*` -- all HTTP client calls +- `Random.*` -- use `Temporalio::Workflow.random` +- `SecureRandom.*` -- use `Temporalio::Workflow.uuid` for UUIDs +- `Timeout.timeout` -- use `Temporalio::Workflow.sleep` with cancellation +- `Mutex` / `synchronize` -- use an explicit `Temporalio::Workflow::Mutex` + +Note: `Time.new('2000-12-31')` with arguments IS deterministic and allowed. Only `Time.now` (wall-clock) is forbidden. + +## Disabling Illegal Call Tracing + +Use `Temporalio::Workflow::Unsafe.illegal_call_tracing_disabled` when third-party code is known safe: + +```ruby +class MyWorkflow < Temporalio::Workflow::Definition + def execute + # Third-party gem that does harmless Time.now internally + result = Temporalio::Workflow::Unsafe.illegal_call_tracing_disabled do + SomeGem.format_data(input) + end + result + end +end +``` + +The block disables tracing only for its duration. Keep it as narrow as possible. + +## Customizing Illegal Calls + +Pass `illegal_workflow_calls:` to `Temporalio::Worker.new`: + +```ruby +worker = Temporalio::Worker.new( + client: client, + task_queue: 'my-queue', + workflows: [MyWorkflow], + illegal_workflow_calls: Temporalio::Worker.default_illegal_workflow_calls.merge( + 'MyInternalClass' => :all, + 'AnotherClass' => { dangerous_method: true } + ) +) +``` + +Default set available via: + +```ruby +Temporalio::Worker.default_illegal_workflow_calls +# => { 'Kernel' => { sleep: true }, 'IO' => :all, ... } +``` + +Hash format: + +- `{ 'ClassName' => :all }` -- block all methods on the class. +- `{ 'ClassName' => { method_name: true } }` -- block specific methods. + +## Common Issues + +### Third-party gems triggering NondeterminismError + +Gems that call `IO`, `Time.now`, or `Socket` internally will trigger errors even if you don't call those methods directly. The correct fix is context-dependent and requires understanding and possibly debugging of the situation. + +**Fix 1:** The call to a gem genuinely is doing IO, side effects, or other non-deterministic things. Then just like with other Temporal Workflow code, it should be moved into an activity. + +**Fix 2:** Escape hatch: for code that needs IO to run within the workflow, use `io_enabled`. You should very seriously consider why IO is needed in the workflow and not in an activity. If you use this escape hatch to create non-determinism issues, you might later face non-determinism errors. + +```ruby +Temporalio::Workflow::Unsafe.io_enabled do + config = YAML.load_file('config.yml') +end +``` + +**Fix 3:** Escape hatch: for code that triggers the illegal call tracing but doesn't cause actual non-determinism issues, you can disable `illegal_call_tracing_disabled`. Again, you must be sure that you are semantically correct that there is no non-determinism involved. + +```ruby +Temporalio::Workflow::Unsafe.illegal_call_tracing_disabled do + ThirdPartyGem.safe_pure_computation(data) +end +``` + +### Durable scheduler conflicts + +If a gem requires its own fiber scheduler behavior, disable the durable scheduler for that block: + +```ruby +Temporalio::Workflow::Unsafe.durable_scheduler_disabled do + some_fiber_aware_gem.call +end +``` + +## Best Practices + +- Use `Temporalio::Workflow.sleep`, `.now`, `.random`, `.uuid`, `.logger` for all workflow-level operations. +- Never perform IO, network calls, or file access in workflow code -- delegate to activities. +- Use `illegal_call_tracing_disabled` sparingly and only when you are certain the code is deterministic. +- Wrap side-effect-only code in `unless Temporalio::Workflow::Unsafe.replaying?` to avoid duplicate emissions during replay. +- Prefer activities over `io_enabled` blocks -- activities have proper retry, timeout, and heartbeat semantics. diff --git a/references/ruby/determinism.md b/references/ruby/determinism.md new file mode 100644 index 0000000..4e0f959 --- /dev/null +++ b/references/ruby/determinism.md @@ -0,0 +1,63 @@ +# Ruby SDK Determinism + +## Overview + +The Ruby SDK enforces workflow determinism through **Illegal Call Tracing** (via `TracePoint`) and a **Durable Fiber Scheduler**. This is not a sandbox like Python's `SandboxedWorkflowRunner`; instead, it intercepts illegal calls at runtime on the workflow fiber. + +## Why Determinism Matters: History Replay + +Temporal re-executes workflow code from the beginning on recovery (worker restart, continue-as-new, etc.). Commands already recorded in history are matched against replayed commands. If the code produces different commands on replay, the workflow fails with a non-determinism error. + +All workflow code must therefore be deterministic: same input and history must produce the same sequence of commands every time. + +## SDK Protection: Illegal Call Tracing + +The Ruby SDK installs a `TracePoint` on the workflow fiber thread. Every method call is checked against a configurable set of illegal calls. If a forbidden method is invoked, the SDK raises `Temporalio::Workflow::NondeterminismError`. + +Configuration is via the `illegal_workflow_calls` parameter on `Temporalio::Worker.new`. The default set is available at: + +```ruby +Temporalio::Worker.default_illegal_workflow_calls +``` + +## Forbidden Operations in Workflows + +The following are forbidden inside workflow code by default: + +- `Kernel.sleep` -- blocks the fiber non-deterministically +- `Time.now` (without args) -- returns wall-clock time +- `Thread.new` -- spawns non-deterministic OS threads +- `IO` operations (`IO.read`, `IO.write`, `File.open`, etc.) +- `Random.rand` / `SecureRandom` -- non-deterministic randomness +- `Process` calls (`Process.spawn`, `Process.exec`, etc.) +- Network calls (`Net::HTTP`, `Socket`, etc.) +- `Mutex` / `synchronize` + +## Safe Builtin Alternatives to Common Non Deterministic Things + +| Forbidden | Safe Alternative | +|-----------|------------------| +| `Kernel.sleep(n)` | `Temporalio::Workflow.sleep(n)` | +| `Time.now` | `Temporalio::Workflow.now` | +| `Random.rand` / `SecureRandom` | `Temporalio::Workflow.random.rand(100)` | +| `SecureRandom.uuid` | `Temporalio::Workflow.uuid` | +| `Logger.new` / `puts` | `Temporalio::Workflow.logger.info(...)` | +| `Mutex` / `synchronize` | explicit `Temporalio::Workflow::Mutex` | + +## Testing Replay Compatibility + +Use `Temporalio::Worker::WorkflowReplayer` to verify that workflow code is replay-compatible against recorded histories. See `testing.md` for details. + +```ruby +replayer = Temporalio::Worker::WorkflowReplayer.new( + workflows: [MyWorkflow] +) +replayer.replay_workflow(workflow_history) +``` + +## Best Practices + +- Use `Temporalio::Workflow.sleep`, `.now`, `.random`, `.uuid`, `.logger` instead of stdlib equivalents. +- Delegate all I/O, network calls, and side effects to activities. +- Test with `WorkflowReplayer` against saved histories before deploying workflow changes. +- Use `Temporalio::Workflow.logger` for all logging inside workflows -- it is replay-aware and suppresses duplicate logs during replay. diff --git a/references/ruby/error-handling.md b/references/ruby/error-handling.md new file mode 100644 index 0000000..a66f350 --- /dev/null +++ b/references/ruby/error-handling.md @@ -0,0 +1,103 @@ +# Ruby SDK Error Handling + +## Overview + +Application errors use `Temporalio::Error::ApplicationError`. Retry behavior is configured via `Temporalio::RetryPolicy`. + +## Application Errors + +```ruby +raise Temporalio::Error::ApplicationError.new('message', type: 'ErrorType') +``` + +In activities, any Ruby exception is automatically converted to an `ApplicationError`. + +## Non-Retryable Errors + +```ruby +raise Temporalio::Error::ApplicationError.new( + 'message', + non_retryable: true +) +``` + +Override the retry interval with `next_retry_delay:`: + +```ruby +raise Temporalio::Error::ApplicationError.new( + 'message', + next_retry_delay: 30 +) +``` + +## Handling Activity Errors + +```ruby +begin + Temporalio::Workflow.execute_activity(MyActivity, 'arg', start_to_close_timeout: 10) +rescue Temporalio::Error::ActivityError => e + # Let cancellation propagate so the workflow is canceled, not failed. + # Temporalio::Error.canceled? is true for a CanceledError, or an + # ActivityError/ChildWorkflowError whose cause is a CanceledError. + raise if Temporalio::Error.canceled?(e) + + Temporalio::Workflow.logger.error("Activity failed: #{e.message}") + # Deliberately fail the workflow. Only raising an ApplicationError fails a + # workflow; other exceptions only fail/retry the workflow task. + raise Temporalio::Error::ApplicationError.new('Workflow failed due to activity error') +end +``` + +## Retry Policy Configuration + +```ruby +retry_policy: Temporalio::RetryPolicy.new( + max_interval: 60, + max_attempts: 5, + non_retryable_error_types: ['ValidationError'] +) +``` + +Only set retry policies when you have a domain-specific reason. Prefer the defaults otherwise. + +## Timeout Configuration + +```ruby +Temporalio::Workflow.execute_activity( + MyActivity, + 'arg', + start_to_close_timeout: 30, + schedule_to_close_timeout: 300, + heartbeat_timeout: 10 +) +``` + +All timeout values are in seconds (numeric). + +## Workflow Failure + +Only `Temporalio::Error::ApplicationError` causes a workflow failure. Other exceptions cause a workflow task failure, which Temporal retries automatically. + +```ruby +class MyWorkflow < Temporalio::Workflow::Definition + def execute + if some_condition + raise Temporalio::Error::ApplicationError.new( + 'Cannot process order', + type: 'BusinessError' + ) + end + 'success' + end +end +``` + +**Note:** Do not use `non_retryable:` with `ApplicationError` inside a workflow (as opposed to an activity). + +## Best Practices + +- Use specific error types (`type:` parameter) for differentiation. +- Mark permanent failures as `non_retryable: true`. +- Set retry policies only when defaults are insufficient. +- Log errors before re-raising. +- Design activities for idempotency so retries are safe. diff --git a/references/ruby/gotchas.md b/references/ruby/gotchas.md new file mode 100644 index 0000000..c2e962c --- /dev/null +++ b/references/ruby/gotchas.md @@ -0,0 +1,253 @@ +# Ruby Gotchas + +Ruby-specific mistakes and anti-patterns. See also [Common Gotchas](references/core/gotchas.md) for language-agnostic concepts. + +## File Organization + +Unlike Python, Ruby doesn't reload workflow files (no sandbox). Still best practice to separate workflows and activities for clarity and maintainability. + +```ruby +# BAD - Everything in one file +# app.rb +class MyWorkflow < Temporalio::Workflow::Definition + def execute(name) + Temporalio::Workflow.execute_activity( + MyActivity, + name, + start_to_close_timeout: 30 + ) + end +end + +class MyActivity < Temporalio::Activity::Definition + def execute(name) + # Heavy I/O, external calls, etc. + end +end +``` + +```ruby +# GOOD - Separate files +# workflows/my_workflow.rb +require 'temporalio/workflow' + +class MyWorkflow < Temporalio::Workflow::Definition + def execute(name) + Temporalio::Workflow.execute_activity( + MyActivity, + name, + start_to_close_timeout: 30 + ) + end +end + +# activities/my_activity.rb +require 'temporalio/activity' + +class MyActivity < Temporalio::Activity::Definition + def execute(name) + # Heavy I/O, external calls, etc. + end +end + +# worker.rb +require_relative 'workflows/my_workflow' +require_relative 'activities/my_activity' +``` + +## Wrong Retry Classification + +Transient network errors should be retried. Authentication errors should not be. See `references/ruby/error-handling.md` to understand how to classify errors with `non_retryable: true` and `non_retryable_error_types`. + + +## Heartbeating + +### Forgetting to Heartbeat Long Activities + +```ruby +# BAD - No heartbeat, can't detect stuck activities +class ProcessLargeFile < Temporalio::Activity::Definition + def execute(path) + File.foreach(path).each_slice(1000) do |chunk| + process(chunk) # Takes hours, no heartbeat + end + end +end +``` + +```ruby +# GOOD - Regular heartbeats with progress +class ProcessLargeFile < Temporalio::Activity::Definition + def execute(path) + File.foreach(path).each_slice(1000).with_index do |chunk, i| + Temporalio::Activity::Context.current.heartbeat("Processing chunk #{i}") + process(chunk) + end + end +end +``` + +### Heartbeat Timeout Too Short + +```ruby +# BAD - Heartbeat timeout shorter than processing time between heartbeats +Temporalio::Workflow.execute_activity( + ProcessChunk, + start_to_close_timeout: 1800, + heartbeat_timeout: 10 # Too short! +) + +# GOOD - Heartbeat timeout allows for processing variance +Temporalio::Workflow.execute_activity( + ProcessChunk, + start_to_close_timeout: 1800, + heartbeat_timeout: 120 +) +``` + +Set heartbeat timeout as high as acceptable for your use case -- each heartbeat counts as an action. + +## Cancellation + +### Not Handling Workflow Cancellation + +```ruby +# BAD - Cleanup doesn't run on cancellation +class BadWorkflow < Temporalio::Workflow::Definition + def execute + Temporalio::Workflow.execute_activity(AcquireResource, start_to_close_timeout: 300) + Temporalio::Workflow.execute_activity(DoWork, start_to_close_timeout: 300) + Temporalio::Workflow.execute_activity(ReleaseResource, start_to_close_timeout: 300) # Never runs if cancelled! + end +end +``` + +```ruby +# GOOD - Use ensure with detached cancellation for cleanup +class GoodWorkflow < Temporalio::Workflow::Definition + def execute + Temporalio::Workflow.execute_activity(AcquireResource, start_to_close_timeout: 300) + Temporalio::Workflow.execute_activity(DoWork, start_to_close_timeout: 300) + ensure + # Create a detached cancellation (not tied to workflow cancellation) + # so cleanup activity runs even after workflow is cancelled + detached_cancel, _cancel_proc = Temporalio::Cancellation.new + Temporalio::Workflow.execute_activity( + ReleaseResource, + start_to_close_timeout: 300, + cancellation: detached_cancel + ) + end +end +``` + +### Not Handling Activity Cancellation + +Activities must **opt in** to receive cancellation. This requires: + +1. **Heartbeating** -- cancellation is delivered via the heartbeat response +2. **Catching the cancellation exception** -- `Temporalio::Error::CancelledError` is raised when a heartbeat detects cancellation + +```ruby +# BAD - Activity ignores cancellation +class LongActivity < Temporalio::Activity::Definition + def execute + do_expensive_work # Runs to completion even if cancelled + end +end +``` + +```ruby +# GOOD - Heartbeat and handle cancellation +class LongActivity < Temporalio::Activity::Definition + def execute + items.each do |item| + Temporalio::Activity::Context.current.heartbeat + process(item) + end + rescue Temporalio::Error::CancelledError + cleanup + raise + end +end +``` + +## Testing + +### Not Testing Failures + +Make sure workflows work as expected under failure paths, not just happy paths. See `references/ruby/testing.md` for more info. + +### Not Testing Replay + +Replay tests help you detect hidden sources of non-determinism in your workflow code and should be considered in addition to standard testing. See `references/ruby/testing.md` for more info. + +## Timers and Sleep + +### Using Kernel.sleep + +```ruby +# BAD - Kernel.sleep raises NondeterminismError +class BadWorkflow < Temporalio::Workflow::Definition + def execute + sleep(60) # NondeterminismError! + Kernel.sleep(60) # NondeterminismError! + end +end +``` + +```ruby +# GOOD - Use Temporalio::Workflow.sleep for durable timers +class GoodWorkflow < Temporalio::Workflow::Definition + def execute + Temporalio::Workflow.sleep(60) # Deterministic, durable timer + end +end +``` + +**Why this matters:** `Kernel.sleep` uses the system clock, which differs between original execution and replay. `Temporalio::Workflow.sleep` creates a durable timer in the event history, ensuring consistent behavior during replay. + +## Illegal Call Tracing Gotchas + +### Third-Party Gems Triggering NondeterminismError + +The Ruby SDK uses `TracePoint`-based illegal call tracing on the workflow fiber. Any gem that internally uses `Thread`, `IO`, `Socket`, `Net::HTTP`, or other forbidden operations will trigger `NondeterminismError` -- even if the call is deep in the gem's internals. + +```ruby +# BAD - Logging gem that uses Thread internally +class MyWorkflow < Temporalio::Workflow::Definition + def execute + SomeFancyLogger.info("Starting workflow") # NondeterminismError if gem uses Thread.new! + end +end +``` + +### Fix: Disable Illegal Call Tracing for Specific Code + +```ruby +# Wrap non-deterministic but safe code +Temporalio::Workflow::Unsafe.illegal_call_tracing_disabled do + # Code here won't trigger NondeterminismError + SomeFancyLogger.info("Starting workflow") +end +``` + +For code that performs IO that you know is safe and want to allow: + +```ruby +# Disable the durable scheduler for IO operations +Temporalio::Workflow::Unsafe.durable_scheduler_disabled do + # IO operations allowed here +end +``` + +### Side Effects and Replay Safety + +Always check replaying status before performing side effects in workflows: + +```ruby +unless Temporalio::Workflow::Unsafe.replaying? + # Only runs during original execution, not replay + Temporalio::Workflow.logger.info("Processing started") +end +``` diff --git a/references/ruby/observability.md b/references/ruby/observability.md new file mode 100644 index 0000000..bfe9107 --- /dev/null +++ b/references/ruby/observability.md @@ -0,0 +1,81 @@ +# Ruby SDK Observability + +## Overview + +Temporal Ruby SDK provides logging, metrics, tracing, and visibility for monitoring workflows and activities. + +## Logging + +### Workflow Logging (Replay-Safe) + +```ruby +Temporalio::Workflow.logger.info("Processing order") +Temporalio::Workflow.logger.warn("Retrying with fallback") +Temporalio::Workflow.logger.error("Order failed: #{reason}") +``` + +### Activity Logging + +```ruby +Temporalio::Activity::Context.current.logger.info("Sending email") +Temporalio::Activity::Context.current.logger.warn("Slow response: #{elapsed}s") +``` + +Do not use `puts` or `print` in workflows. They are not replay-safe and produce duplicate output on replay. + +### Customizing Logger Configuration + +```ruby +require 'logger' + +# The logger set on the client is used by Temporalio::Workflow.logger and +# Temporalio::Activity::Context.current.logger. Defaults to stdout at WARN. +client = Temporalio::Client.connect( + 'localhost:7233', 'my-namespace', + logger: Logger.new($stdout, level: Logger::INFO) +) +``` + +## Metrics + +### Enabling SDK Metrics + +Configure telemetry via `Temporalio::Runtime`: + +```ruby +prometheus_options = Temporalio::Runtime::PrometheusMetricsOptions.new( + bind_address: '0.0.0.0:9000' +) + +metrics_options = Temporalio::Runtime::MetricsOptions.new( + prometheus: prometheus_options +) + +telemetry_options = Temporalio::Runtime::TelemetryOptions.new( + metrics: metrics_options +) + +runtime = Temporalio::Runtime.new(telemetry: telemetry_options) +Temporalio::Runtime.default = runtime +``` + +Set the default runtime **before** creating any clients or workers. + +### Key SDK Metrics + +- `temporal_workflow_completed` - workflow completions +- `temporal_workflow_failed` - workflow failures +- `temporal_activity_execution_latency` - activity duration +- `temporal_sticky_cache_hit` - workflow cache hits +- `temporal_workflow_task_execution_latency` - workflow task duration + +## Search Attributes (Visibility) + +See the Search Attributes section of `references/ruby/data-handling.md` + +## Best Practices + +- Use `Temporalio::Workflow.logger` in workflows, `Temporalio::Activity::Context.current.logger` in activities. +- Configure Prometheus metrics for production deployments. +- Use Search Attributes for workflow visibility and filtering. +- Never use `puts` or `print` in workflow code. diff --git a/references/ruby/patterns.md b/references/ruby/patterns.md new file mode 100644 index 0000000..0bfa33c --- /dev/null +++ b/references/ruby/patterns.md @@ -0,0 +1,403 @@ +# Ruby SDK Patterns + +## Signals + +```ruby +class OrderWorkflow < Temporalio::Workflow::Definition + def initialize + @approved = false + @items = [] + end + + workflow_signal + def approve + @approved = true + end + + workflow_signal + def add_item(item) + @items << item + end + + def execute + Temporalio::Workflow.wait_condition { @approved } + "Processed #{@items.length} items" + end +end +``` + +### 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. + +```ruby +class DynamicSignalWorkflow < Temporalio::Workflow::Definition + def initialize + @signals = {} + end + + workflow_signal dynamic: true, raw_args: true + def dynamic_signal(signal_name, *args) + @signals[signal_name] ||= [] + @signals[signal_name] << Temporalio::Workflow.payload_converter.from_payload(args.first) + end +end +``` + +## Queries + +**Important:** Queries must NOT modify workflow state or have side effects. + +```ruby +class StatusWorkflow < Temporalio::Workflow::Definition + def initialize + @status = 'pending' + @progress = 0 + end + + # Shorthand for simple attribute readers + workflow_query_attr_reader :status, :progress + + def execute + @status = 'running' + 100.times do |i| + @progress = i + Temporalio::Workflow.execute_activity( + ProcessItem, i, + start_to_close_timeout: 60 + ) + end + @status = 'completed' + 'done' + end +end +``` + +### 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. + +```ruby +workflow_query dynamic: true, raw_args: true +def dynamic_query(query_name, *args) + if query_name == 'get_field' + field_name = Temporalio::Workflow.payload_converter.from_payload(args.first) + instance_variable_get(:"@#{field_name}") + end +end +``` + +## Updates + +```ruby +class OrderWorkflow < Temporalio::Workflow::Definition + def initialize + @items = [] + end + + workflow_update + def add_item(item) + @items << item + @items.length # Returns new count to caller + end + + workflow_update_validator(:add_item) + def validate_add_item(item) + raise 'Item cannot be empty' if item.nil? || item.empty? + raise 'Order is full' if @items.length >= 100 + end +end +``` + +**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. Raise an exception to reject the update; return `nil` to accept. + +## Child Workflows + +```ruby +class MyWorkflow < Temporalio::Workflow::Definition + def execute(orders) + results = [] + orders.each do |order| + result = Temporalio::Workflow.execute_child_workflow( + ProcessOrderWorkflow, order, + id: "order-#{order.id}", + parent_close_policy: Temporalio::Workflow::ParentClosePolicy::ABANDON + ) + results << result + end + results + end +end +``` + +### Child Workflow Options + +```ruby +Temporalio::Workflow.execute_child_workflow( + ChildWorkflow, arg, + id: 'child-1', + parent_close_policy: Temporalio::Workflow::ParentClosePolicy::ABANDON, + cancellation_type: Temporalio::Workflow::ChildWorkflowCancellationType::WAIT_CANCELLATION_COMPLETED, + execution_timeout: 3600, + run_timeout: 1800 +) +``` + +## Handles to External Workflows + +```ruby +class MyWorkflow < Temporalio::Workflow::Definition + def execute(target_workflow_id) + handle = Temporalio::Workflow.external_workflow_handle(target_workflow_id) + + # Signal the external workflow + handle.signal(TargetWorkflow.data_ready, data_payload) + + # Or cancel it + handle.cancel + end +end +``` + +## Parallel Execution + +```ruby +class MyWorkflow < Temporalio::Workflow::Definition + def execute(items) + futures = items.map do |item| + Temporalio::Workflow::Future.new do + Temporalio::Workflow.execute_activity( + ProcessItem, item, + start_to_close_timeout: 300 + ) + end + end + Temporalio::Workflow::Future.all_of(*futures).wait + results = futures.map(&:result) + results + end +end +``` + +## Continue-as-New + +```ruby +class MyWorkflow < Temporalio::Workflow::Definition + def execute(state) + loop do + state = process_batch(state) + + return 'done' if state.complete? + + # Continue with fresh history before hitting limits + if Temporalio::Workflow.continue_as_new_suggested + raise Temporalio::Workflow::ContinueAsNewError.new(state) + end + end + end +end +``` + +## Saga Pattern (Compensations) + +**Important:** Compensation activities should be idempotent - they may be retried (as with ALL activities). + +```ruby +class MyWorkflow < Temporalio::Workflow::Definition + def execute(order) + compensations = [] + + begin + # Save compensation before running the activity, because: + # 1. reserve_inventory starts running + # 2. it successfully reserves inventory + # 3. but then fails for some other reason (timeout, reporting metrics, etc.) + # 4. the activity failed, but the effect (reserved inventory) already happened + # So the compensation must handle both reserved and unreserved states. + compensations << lambda { |cancellation| + Temporalio::Workflow.execute_activity( + ReleaseInventoryIfReserved, order, + start_to_close_timeout: 300, + cancellation: cancellation + ) + } + Temporalio::Workflow.execute_activity( + ReserveInventory, order, + start_to_close_timeout: 300 + ) + + compensations << lambda { |cancellation| + Temporalio::Workflow.execute_activity( + RefundPaymentIfCharged, order, + start_to_close_timeout: 300, + cancellation: cancellation + ) + } + Temporalio::Workflow.execute_activity( + ChargePayment, order, + start_to_close_timeout: 300 + ) + + Temporalio::Workflow.execute_activity( + ShipOrder, order, + start_to_close_timeout: 300 + ) + + 'Order completed' + + rescue => e + Temporalio::Workflow.logger.error("Order failed: #{e}, running compensations") + # Use a detached cancellation so compensations still run even if the workflow + # was canceled (the workflow's own cancellation is already canceled by then). + detached_cancel, = Temporalio::Cancellation.new + compensations.reverse.each do |compensate| + begin + compensate.call(detached_cancel) + rescue => comp_err + Temporalio::Workflow.logger.error("Compensation failed: #{comp_err}") + end + end + raise + end + end +end +``` + +## Cancellation (Token-based) + +Ruby uses `Temporalio::Cancellation` tokens. + +```ruby +class MyWorkflow < Temporalio::Workflow::Definition + def execute + # The workflow's cancellation token + workflow_cancel = Temporalio::Workflow.cancellation + + begin + Temporalio::Workflow.execute_activity( + LongRunningActivity, + start_to_close_timeout: 3600, + cancellation: workflow_cancel + ) + 'completed' + ensure + # Create a detached cancellation for cleanup + # (not tied to workflow cancellation) + cancel, _cancel_proc = Temporalio::Cancellation.new + Temporalio::Workflow.execute_activity( + CleanupActivity, + start_to_close_timeout: 300, + cancellation: cancel + ) + end + end +end +``` + +## Wait Condition with Timeout + +```ruby +class MyWorkflow < Temporalio::Workflow::Definition + def execute + @approved = false + + # Wait for approval with 24-hour timeout + # Returns false on timeout (no exception raised) + if Temporalio::Workflow.wait_condition(timeout: 86400) { @approved } + 'approved' + else + 'auto-rejected due to timeout' + end + end +end +``` + +## 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 `wait_condition { all_handlers_finished }` at the end of your workflow (or before continue-as-new) to prevent completion until all pending handlers complete. + +```ruby +class MyWorkflow < Temporalio::Workflow::Definition + def execute + # ... main workflow logic ... + + # Before exiting, wait for all handlers to finish + Temporalio::Workflow.wait_condition { Temporalio::Workflow.all_handlers_finished? } + 'done' + end +end +``` + +## 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 + +```ruby +class ProcessLargeFile < Temporalio::Activity::Definition + def execute(file_path) + context = Temporalio::Activity::Context.current + + # Get heartbeat details from previous attempt (if any) + heartbeat_details = context.info.heartbeat_details + start_line = heartbeat_details&.first || 0 + + begin + File.foreach(file_path).with_index do |line, i| + next if i < start_line + + process_line(line) + + # Heartbeat with progress + # If cancelled, heartbeat raises Temporalio::Error::CanceledError + context.heartbeat(i + 1) + end + + 'completed' + rescue Temporalio::Error::CanceledError + cleanup + raise + end + end +end +``` + +## Timers + +```ruby +class MyWorkflow < Temporalio::Workflow::Definition + def execute + Temporalio::Workflow.sleep(3600) + + 'Timer fired' + end +end +``` + +## 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. + +```ruby +class MyWorkflow < Temporalio::Workflow::Definition + def execute + result = Temporalio::Workflow.execute_local_activity( + QuickLookup, 'key', + start_to_close_timeout: 5 + ) + result + end +end +``` + +## Using ActiveModel + +See `references/ruby/data-handling.md`. diff --git a/references/ruby/ruby.md b/references/ruby/ruby.md new file mode 100644 index 0000000..bc7f9e0 --- /dev/null +++ b/references/ruby/ruby.md @@ -0,0 +1,146 @@ +# Temporal Ruby SDK Reference + +## Overview + +The Temporal Ruby SDK (`temporalio` gem) provides a class-based approach to building durable workflows. Ruby 3.3+ required. Workflows run using a Durable Fiber Scheduler for determinism protection, with Illegal Call Tracing via `TracePoint` to detect non-deterministic operations. + +## Quick Demo of Temporal + +**Add Dependency on Temporal:** Add `temporalio` to your Gemfile or install directly with `gem install temporalio`. + +**say_hello_activity.rb** - Activity definition: +```ruby +require 'temporalio/activity' + +class SayHelloActivity < Temporalio::Activity::Definition + def execute(name) + "Hello, #{name}!" + end +end +``` + +**say_hello_workflow.rb** - Workflow definition: +```ruby +require 'temporalio/workflow' + +class SayHelloWorkflow < Temporalio::Workflow::Definition + def execute(name) + Temporalio::Workflow.execute_activity( + SayHelloActivity, + name, + schedule_to_close_timeout: 30 + ) + end +end +``` + +**worker.rb** - Worker setup (imports activity and workflow, runs indefinitely and processes tasks): +```ruby +require 'temporalio/client' +require 'temporalio/worker' +require_relative 'say_hello_activity' +require_relative 'say_hello_workflow' + +# Create client connected to server at the given address +# This is the default port for `temporal server start-dev` +client = Temporalio::Client.connect('localhost:7233', 'default') + +# Create and run the worker +worker = Temporalio::Worker.new( + client: client, + task_queue: 'my-task-queue', + workflows: [SayHelloWorkflow], + activities: [SayHelloActivity] +) +worker.run +``` + +**Start the dev server:** Start `temporal server start-dev` in the background. + +**Start the worker:** Start `ruby worker.rb` in the background. + +**execute_workflow.rb** - Start a workflow execution: +```ruby +require 'temporalio/client' +require 'securerandom' +require_relative 'say_hello_workflow' + +# Create client connected to server at the given address +client = Temporalio::Client.connect('localhost:7233', 'default') + +# Execute a workflow +result = client.execute_workflow( + SayHelloWorkflow, + 'my name', + id: SecureRandom.uuid, + task_queue: 'my-task-queue' +) + +puts "Result: #{result}" +``` + +**Run the workflow:** Run `ruby execute_workflow.rb`. Should output: `Result: Hello, my name!`. + +## Key Concepts + +### Workflow Definition +- Subclass `Temporalio::Workflow::Definition` +- Define `def execute(args)` as the entry point +- Use `Temporalio::Workflow.execute_activity` to call activities +- Define signals, queries, and updates via class-level DSL methods + +### Activity Definition +- Subclass `Temporalio::Activity::Definition` +- Define `def execute(args)` as the entry point +- Activities contain all non-deterministic and side-effectful code +- Can access `Temporalio::Activity::Context.current` for heartbeating + +### Worker Setup +- Connect client with `Temporalio::Client.connect` +- Create worker with `Temporalio::Worker.new(client:, task_queue:, workflows:, activities:)` +- Run with `worker.run` + +### Determinism + +**Workflow code must be deterministic!** The Ruby SDK uses a Durable Fiber Scheduler and Illegal Call Tracing (via Ruby's `TracePoint`) to detect non-deterministic operations at runtime. All sources of non-determinism should either use Temporal-provided alternatives or be defined in Activities. Read `references/core/determinism.md` and `references/ruby/determinism.md` to understand more. + +## File Organization Best Practice + +**Keep Workflow definitions in separate files from Activity definitions.** Unlike Python, Ruby does not have a sandbox reloading concern, but separating workflows and activities is still good practice for clarity and maintainability. Use `require_relative` to import between files. + +``` +my_temporal_app/ +├── workflows/ +│ └── say_hello_workflow.rb # Only Workflow classes +├── activities/ +│ └── say_hello_activity.rb # Only Activity classes +├── worker.rb # Worker setup, requires both +└── execute_workflow.rb # Client code to start workflows +``` + +## Common Pitfalls + +1. **Using `sleep` instead of `Temporalio::Workflow.sleep`** - Standard `sleep` is non-deterministic and will be flagged by Illegal Call Tracing; use the Temporal-provided version +2. **Using `Time.now` instead of `Temporalio::Workflow.now`** - Same issue; `Time.now` is non-deterministic in workflow context +3. **Third-party gems triggering illegal calls** - Gems that perform I/O, use threads, or call system time will be caught by `TracePoint` tracing; move that logic to activities +4. **Using `puts`/`Logger` in workflows** - Use `Temporalio::Workflow.logger` instead for replay-safe logging +5. **Not heartbeating long activities** - Long-running activities need `Temporalio::Activity::Context.current.heartbeat` +6. **Mixing Workflows and Activities in same file** - Bad structure; keep them separated for clarity + +## Writing Tests + +See `references/ruby/testing.md` for info on writing tests. + +## Additional Resources + +### Reference Files +- **`references/ruby/patterns.md`** - Signals, queries, child workflows, saga pattern, etc. +- **`references/ruby/determinism.md`** - Durable Fiber Scheduler behavior, safe alternatives, history replay +- **`references/ruby/determinism-protection.md`** - Illegal Call Tracing via TracePoint, forbidden operations, runtime detection +- **`references/ruby/versioning.md`** - Patching API, workflow type versioning, Worker Versioning +- **`references/ruby/testing.md`** - Test environments, time-skipping, activity mocking +- **`references/ruby/error-handling.md`** - ApplicationError, retry policies, non-retryable errors, idempotency +- **`references/ruby/data-handling.md`** - Data converters, payload encryption +- **`references/ruby/observability.md`** - Logging, metrics, tracing, Search Attributes +- **`references/ruby/gotchas.md`** - Ruby-specific mistakes and anti-patterns +- **`references/ruby/advanced-features.md`** - Schedules, worker tuning, and more diff --git a/references/ruby/testing.md b/references/ruby/testing.md new file mode 100644 index 0000000..5135b78 --- /dev/null +++ b/references/ruby/testing.md @@ -0,0 +1,234 @@ +# Ruby SDK Testing + +## Overview + +The Temporal Ruby SDK provides testing utilities compatible with any Ruby test framework (minitest is commonly used). The two main testing classes are `Temporalio::Testing::WorkflowEnvironment` for end-to-end workflow testing and `Temporalio::Testing::ActivityEnvironment` for isolated activity testing. + +## Workflow Test Environment + +The core pattern: +1. Start a test `WorkflowEnvironment` with `start_local` +2. Create a Worker in that environment with your Workflows and Activities registered +3. Execute the Workflow using the environment's client +4. Assert on the result + +```ruby +require 'minitest/autorun' +require 'securerandom' +require 'temporalio/testing/workflow_environment' +require 'temporalio/worker' + +require_relative '../workflows/my_workflow' +require_relative '../activities/my_activity' + +class MyWorkflowTest < Minitest::Test + def test_workflow_returns_expected_result + Temporalio::Testing::WorkflowEnvironment.start_local do |env| + task_queue = SecureRandom.uuid + + worker = Temporalio::Worker.new( + client: env.client, + task_queue: task_queue, + workflows: [MyWorkflow], + activities: [MyActivity] + ) + + worker.run do + result = env.client.execute_workflow( + MyWorkflow, + 'input-arg', + id: SecureRandom.uuid, + task_queue: task_queue + ) + + assert_equal 'expected output', result + end + end + end +end +``` + +For workflows with long durations (timers, sleeps), use `start_time_skipping` instead of `start_local`: + +```ruby +Temporalio::Testing::WorkflowEnvironment.start_time_skipping do |env| + # Timers are automatically skipped +end +``` + +## Mocking Activities + +Create fake activity classes with the same activity name as the real ones. Pass them to the Worker instead of the real activities: + +```ruby +class FakeComposeGreetingActivity < Temporalio::Activity::Definition + activity_name 'ComposeGreetingActivity' + + def execute(input) + 'mocked greeting' + end +end + +class MyWorkflowMockTest < Minitest::Test + def test_workflow_with_mocked_activity + Temporalio::Testing::WorkflowEnvironment.start_local do |env| + task_queue = SecureRandom.uuid + + worker = Temporalio::Worker.new( + client: env.client, + task_queue: task_queue, + workflows: [MyWorkflow], + activities: [FakeComposeGreetingActivity] + ) + + worker.run do + result = env.client.execute_workflow( + MyWorkflow, + 'test-input', + id: SecureRandom.uuid, + task_queue: task_queue + ) + + assert_equal 'mocked greeting', result + end + end + end +end +``` + +## Testing Signals and Queries + +Use `start_workflow` to get a handle, then interact via signal/query methods: + +```ruby +class SignalQueryTest < Minitest::Test + def test_signal_and_query + Temporalio::Testing::WorkflowEnvironment.start_local do |env| + task_queue = SecureRandom.uuid + + worker = Temporalio::Worker.new( + client: env.client, + task_queue: task_queue, + workflows: [MyWorkflow], + activities: [MyActivity] + ) + + worker.run do + handle = env.client.start_workflow( + MyWorkflow, + id: SecureRandom.uuid, + task_queue: task_queue + ) + + # Send a signal + handle.signal(MyWorkflow.my_signal, 'signal-data') + + # Query workflow state + status = handle.query(MyWorkflow.get_status) + assert_equal 'expected-status', status + + # Wait for completion + result = handle.result + assert_equal 'done', result + end + end + end +end +``` + +## Testing Failure Cases + +Test workflows that encounter errors using activities that raise exceptions: + +```ruby +class FailingActivity < Temporalio::Activity::Definition + activity_name 'MyActivity' + + def execute(input) + raise Temporalio::Error::ApplicationError.new('Simulated failure', non_retryable: true) + end +end + +class FailureTest < Minitest::Test + def test_workflow_handles_activity_failure + Temporalio::Testing::WorkflowEnvironment.start_local do |env| + task_queue = SecureRandom.uuid + + worker = Temporalio::Worker.new( + client: env.client, + task_queue: task_queue, + workflows: [MyWorkflow], + activities: [FailingActivity] + ) + + worker.run do + assert_raises(Temporalio::Error::WorkflowFailureError) do + env.client.execute_workflow( + MyWorkflow, + 'input', + id: SecureRandom.uuid, + task_queue: task_queue + ) + end + end + end + end +end +``` + +## Workflow Replay Testing + +Use `WorkflowReplayer` to verify that workflow code changes remain compatible with existing histories: + +```ruby +require 'temporalio/worker/workflow_replayer' +require 'temporalio/workflow_history' + +class ReplayTest < Minitest::Test + def test_replay_from_json + json = File.read('test/fixtures/my_workflow_history.json') + replayer = Temporalio::Worker::WorkflowReplayer.new(workflows: [MyWorkflow]) + + # Replay a single workflow history + replayer.replay_workflow( + Temporalio::WorkflowHistory.from_history_json(json) + ) + end + + def test_replay_bulk + histories = Dir['test/fixtures/histories/*.json'].map do |path| + Temporalio::WorkflowHistory.from_history_json(File.read(path)) + end + + replayer = Temporalio::Worker::WorkflowReplayer.new(workflows: [MyWorkflow]) + + # Replay multiple histories - raises on nondeterminism + replayer.replay_workflows(histories) + end +end +``` + +## Activity Testing + +Use `ActivityEnvironment` to test activities in isolation without a full Temporal server: + +```ruby +require 'temporalio/testing/activity_environment' + +class ActivityTest < Minitest::Test + def test_activity_returns_greeting + env = Temporalio::Testing::ActivityEnvironment.new + result = env.run(MyActivity, 'World') + assert_equal 'Hello, World!', result + end +end +``` + +## Best Practices + +1. **Use `start_local` for most tests** - provides a real Temporal environment without external dependencies +2. **Use `start_time_skipping` for timer tests** - automatically skips timers rather than waiting +3. **Mock external dependencies** - create fake activity classes with `activity_name` matching the real activity +4. **Test replay compatibility** - add replay tests when changing workflow code to catch nondeterminism errors early +5. **Use unique IDs per test** - use `SecureRandom.uuid` for workflow IDs and task queue names to avoid conflicts +6. **Test signals and queries explicitly** - use `start_workflow` to get a handle rather than `execute_workflow` diff --git a/references/ruby/versioning.md b/references/ruby/versioning.md new file mode 100644 index 0000000..ba8f315 --- /dev/null +++ b/references/ruby/versioning.md @@ -0,0 +1,317 @@ +# Ruby SDK Versioning + +For conceptual overview, see `references/core/versioning.md`. + +## Patching API + +### The patched() Method + +`Temporalio::Workflow.patched('my-patch')` returns `true`/`false` to branch between new and old code paths: + +```ruby +class MyWorkflow < Temporalio::Workflow::Definition + def execute + if Temporalio::Workflow.patched('my-patch') + # New code path + Temporalio::Workflow.execute_activity( + PostPatchActivity, + start_to_close_timeout: 100 + ) + else + # Old code path (for replay of existing workflows) + Temporalio::Workflow.execute_activity( + PrePatchActivity, + start_to_close_timeout: 100 + ) + end + end +end +``` + +**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) + +**Note:** The `patched()` return value is memoized per patch ID. This means you cannot reliably use `patched()` in loops—it will return the same value every iteration. Workaround: append a sequence number to the patch ID for each iteration (e.g., `"my-change-#{i}"`). + +### 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** + +Add the patch with both old and new code paths: + +```ruby +class OrderWorkflow < Temporalio::Workflow::Definition + def execute(order) + if Temporalio::Workflow.patched('add-fraud-check') + # New: Run fraud check before payment + Temporalio::Workflow.execute_activity( + CheckFraudActivity, + order, + start_to_close_timeout: 120 + ) + end + + # Original payment logic runs for both paths + Temporalio::Workflow.execute_activity( + ProcessPaymentActivity, + order, + start_to_close_timeout: 300 + ) + end +end +``` + +**Step 2: Deprecate the Patch** + +Once all pre-patch Workflow Executions have completed, remove the old code and use `deprecate_patch()`: + +```ruby +class OrderWorkflow < Temporalio::Workflow::Definition + def execute(order) + Temporalio::Workflow.deprecate_patch('add-fraud-check') + + # Only new code remains + Temporalio::Workflow.execute_activity( + CheckFraudActivity, + order, + start_to_close_timeout: 120 + ) + + Temporalio::Workflow.execute_activity( + ProcessPaymentActivity, + order, + start_to_close_timeout: 300 + ) + end +end +``` + +**Step 3: Remove the Patch** + +After all workflows with the deprecated patch marker have completed, remove the `deprecate_patch()` call entirely: + +```ruby +class OrderWorkflow < Temporalio::Workflow::Definition + def execute(order) + Temporalio::Workflow.execute_activity( + CheckFraudActivity, + order, + start_to_close_timeout: 120 + ) + + Temporalio::Workflow.execute_activity( + ProcessPaymentActivity, + order, + start_to_close_timeout: 300 + ) + end +end +``` + +### Query Filters for Finding Workflows by Version + +```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 by duplicating the class: + +```ruby +class MyWorkflow < Temporalio::Workflow::Definition + def execute + # Original implementation + Temporalio::Workflow.execute_activity( + OriginalActivity, + start_to_close_timeout: 100 + ) + end +end + +class MyWorkflowV2 < Temporalio::Workflow::Definition + def execute + # New implementation with incompatible changes + Temporalio::Workflow.execute_activity( + NewActivity, + start_to_close_timeout: 100 + ) + end +end +``` + +Register both with the Worker: + +```ruby +worker = Temporalio::Worker.new( + client: client, + task_queue: 'my-task-queue', + workflows: [MyWorkflow, MyWorkflowV2], + activities: [OriginalActivity, NewActivity] +) +``` + +Update client code to start new workflows with the new type: + +```ruby +# Old workflows continue on MyWorkflow +# New workflows use MyWorkflowV2 +handle = client.start_workflow( + MyWorkflowV2, + input, + id: SecureRandom.uuid, + task_queue: 'my-task-queue' +) +``` + +Check for open executions before removing the old type: + +```bash +temporal workflow list --query 'WorkflowType = "MyWorkflow" AND ExecutionStatus = "Running"' +``` + +## Worker Versioning + +Worker Versioning manages versions at the deployment level, allowing multiple Worker versions to run simultaneously. Requires Ruby SDK v0.5.0+. + +### Key Concepts + +**Worker Deployment**: A logical service grouping similar Workers together (e.g., "order-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., "order-processor:v1.0" or "order-processor:abc123"). + +### Configuring Workers for Versioning + +```ruby +worker = Temporalio::Worker.new( + client: client, + task_queue: 'my-task-queue', + workflows: [MyWorkflow], + activities: [MyActivity], + deployment_options: Temporalio::Worker::DeploymentOptions.new( + version: Temporalio::WorkerDeploymentVersion.new( + deployment_name: 'my-service', + build_id: 'v1.0.0' # or git commit hash + ), + use_worker_versioning: true + ) +) +``` + +### PINNED vs AUTO_UPGRADE Behaviors + +**PINNED Behavior** + +Workflows stay locked to their original Worker version. Set on the workflow definition: + +```ruby +class StableWorkflow < Temporalio::Workflow::Definition + workflow_versioning_behavior :pinned + + def execute + Temporalio::Workflow.execute_activity( + ProcessOrderActivity, + start_to_close_timeout: 300 + ) + end +end +``` + +**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: + +```ruby +class LongRunningWorkflow < Temporalio::Workflow::Definition + workflow_versioning_behavior :auto_upgrade + + def execute + # This workflow may be picked up by a newer Worker version + Temporalio::Workflow.execute_activity( + ProcessActivity, + start_to_close_timeout: 300 + ) + end +end +``` + +**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. + +### Worker Configuration with Default Behavior + +```ruby +worker = Temporalio::Worker.new( + client: client, + task_queue: 'orders-task-queue', + workflows: [OrderWorkflow], + activities: [ProcessOrderActivity], + deployment_options: Temporalio::Worker::DeploymentOptions.new( + version: Temporalio::WorkerDeploymentVersion.new( + deployment_name: 'order-service', + build_id: ENV.fetch('BUILD_ID') + ), + use_worker_versioning: true, + default_versioning_behavior: Temporalio::VersioningBehavior::PINNED + ) +) +``` + +### 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 + +This works well with Kubernetes where you manage multiple ReplicaSets running different Worker versions. + +### Querying Workflows by Worker Version + +```bash +# Find workflows on a specific Worker version +temporal workflow list --query \ + 'TemporalWorkerDeploymentVersion = "my-service:v1.0.0" AND ExecutionStatus = "Running"' +``` + +## Best Practices + +1. **Check for open executions** before removing old code paths +2. **Use descriptive patch IDs** that explain the change (e.g., "add-fraud-check" not "patch-1") +3. **Deploy patches incrementally**: patch, deprecate, remove +4. **Use PINNED for short workflows** to simplify version management +5. **Use AUTO_UPGRADE with patching** for long-running workflows that need updates +6. **Generate Build IDs from code** (git hash) to ensure changes produce new versions +7. **Avoid rolling deployments** for high-availability services with long-running workflows From d52c54af05e880f5a1a892a7475ae9b4414e5d86 Mon Sep 17 00:00:00 2001 From: Amir Benvenisti <128422269+starfleeth@users.noreply.github.com> Date: Fri, 29 May 2026 14:09:06 -0700 Subject: [PATCH 56/82] Absorb CLI skill: workflow commands, dev server, CLI gotchas (#231) * Absorb CLI skill content: workflow commands, dev server, and CLI gotchas Migrate developer-facing content from skill-temporal-cli as part of the CLI skill consolidation. Adds cli-workflow-commands.md (start, execute, signal, query, update reference), expands dev-management.md with full dev server flags and dev-to-prod recipe, appends CLI gotchas, and enriches install_cli.md. Updates SKILL.md triggers and routing. Co-Authored-By: Claude Opus 4.6 * Address review feedback: flag table consistency, global flag annotations, dedup gotchas Standardize cli-workflow-commands.md flag tables to use Required column throughout. Annotate --log-level and --log-format as global flags in dev-management.md. Collapse redundant dev server gotchas into a cross-reference to dev-management.md. Co-Authored-By: Claude Opus 4.6 * Clarify that a local dev server is not required for development Workers can target a local dev server, self-hosted cluster, or Temporal Cloud. The previous wording implied a local server was mandatory. Co-Authored-By: Claude Opus 4.6 * Clean up out of scope section * Cleanup CLI installation * Remove junk, add --output json * Apply edit suggestion * Add --output json explicitly * remove docs markers * Add one more --output json * Apply suggestion from @donald-pinckney * Change env to profile, and remove dead cross-reference to ops skill. * Remove dead cross-links for now * Update references/core/dev-management.md --------- Co-authored-by: Claude Opus 4.6 Co-authored-by: Donald Pinckney --- SKILL.md | 3 +- references/core/cli-workflow-commands.md | 255 +++++++++++++++++++++++ references/core/dev-management.md | 87 +++++++- references/core/gotchas.md | 23 ++ references/core/install_cli.md | 41 +++- 5 files changed, 401 insertions(+), 8 deletions(-) create mode 100644 references/core/cli-workflow-commands.md diff --git a/SKILL.md b/SKILL.md index 2936a6b..78a00cc 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,6 +1,6 @@ --- name: temporal-developer -description: Develop, debug, and manage Temporal applications across Python, TypeScript, Go, Java, .NET and Ruby. Use when the user is building workflows, activities, or workers with a Temporal SDK, debugging issues like non-determinism errors, stuck workflows, or activity retries, using Temporal CLI, Temporal Server, or Temporal Cloud, or working with durable execution concepts like signals, queries, heartbeats, versioning, continue-as-new, child workflows, or saga patterns. +description: Develop, debug, and manage Temporal applications across Python, TypeScript, Go, Java, .NET and Ruby. Use when the user is building workflows, activities, or workers with a Temporal SDK, debugging issues like non-determinism errors, stuck workflows, or activity retries, using Temporal CLI, Temporal Server, or Temporal Cloud, or working with durable execution concepts like signals, queries, heartbeats, versioning, continue-as-new, child workflows, or saga patterns. Also use when the user mentions "run a Temporal workflow from the CLI", "start a dev server", "run temporal server start-dev", "temporal workflow start", "temporal workflow execute", "temporal workflow signal", "temporal workflow query", "temporal workflow update". version: 0.4.0 --- @@ -75,6 +75,7 @@ Check if `temporal` CLI is installed. If not, follow the instructions at `refere - **`references/core/error-reference.md`** - Common error types, workflow status reference - **`references/core/interactive-workflows.md`** - Testing signals, updates, queries - **`references/core/dev-management.md`** - Dev cycle & management of server and workers +- **`references/core/cli-workflow-commands.md`** - Developer-facing CLI commands for workflow interaction (start, execute, signal, query, update) - **`references/core/ai-patterns.md`** - AI/LLM pattern concepts - Language-specific info at `references/{your_language}/ai-patterns.md`, if available. Currently Python only. diff --git a/references/core/cli-workflow-commands.md b/references/core/cli-workflow-commands.md new file mode 100644 index 0000000..de73094 --- /dev/null +++ b/references/core/cli-workflow-commands.md @@ -0,0 +1,255 @@ +# CLI Workflow Commands for Developers + +Developer-facing CLI commands for interacting with workflows during development and testing. These commands work identically against a dev server, a self-hosted cluster, or Temporal Cloud -- only the connection descriptor changes. + +**IMPORTANT:** In order to make outputs of `temporal` CLI commands easier to read and parse, use the `--output json` flag. + +## Table of contents + +- [Workflow start](#workflow-start) +- [Workflow execute](#workflow-execute) +- [Workflow signal](#workflow-signal) +- [Workflow query](#workflow-query) +- [Workflow update](#workflow-update) +- [Workflow signal-with-start](#workflow-signal-with-start) +- [Workflow result](#workflow-result) +- [Workflow metadata](#workflow-metadata) + +## Workflow start + +Start a new Workflow Execution asynchronously. Returns the Workflow ID and Run ID. + +```bash +temporal workflow start \ + --output json \ + --workflow-id YourWorkflowId \ + --type YourWorkflow \ + --task-queue YourTaskQueue \ + --input '{"some-key": "some-value"}' +``` + +Required flags: `--type`, `--task-queue`. Optional `--workflow-id` -- the Service generates a UUID if omitted. + +| Flag | Required | Purpose | +|---|---|---| +| `--type` | Yes | Workflow Type name. | +| `--task-queue`, `-t` | Yes | Workflow Task queue. | +| `--workflow-id`, `-w` | No | Workflow ID. Service generates a UUID if omitted. | +| `--input`, `-i` | No | Input value (JSON). Repeatable. Mutually exclusive with `--input-file`. | +| `--input-file` | No | Read input from file(s). Repeatable. Mutually exclusive with `--input`. | +| `--input-base64` | No | Decode `--input` as base64 before sending. | +| `--input-meta` | No | Override payload metadata as `KEY=VALUE` (e.g., `encoding=json/protobuf`). Repeatable. | +| `--id-reuse-policy` | No | How to reuse a previously-seen Workflow ID. Values: `AllowDuplicate`, `AllowDuplicateFailedOnly`, `RejectDuplicate`, `TerminateIfRunning`. | +| `--id-conflict-policy` | No | How to resolve conflicts with a running execution sharing the same ID. Values: `Fail`, `UseExisting`, `TerminateExisting`. | +| `--execution-timeout` | No | Fail a Workflow Execution if it lasts longer than this (duration). Includes retries and ContinueAsNew. | +| `--run-timeout` | No | Fail a single Workflow Run if it lasts longer than this (duration). | +| `--task-timeout` | No | Start-to-close timeout for a Workflow Task (duration). | +| `--search-attribute` | No | Set a search attribute as `KEY=VALUE` (JSON values). Repeatable. | +| `--memo` | No | Attach unindexed metadata as `KEY="VALUE"` (JSON values). Repeatable. | +| `--start-delay` | No | Delay before starting (duration). Cannot combine with `--cron`. | +| `--cron` | No | Legacy cron schedule (prefer `temporal schedule create`). | +| `--priority-key` | No | Priority 1-5 (default 3). Lower = higher priority. | +| `--fairness-key` | No | Proportional task dispatch grouping key (string, max 64 bytes). | +| `--fairness-weight` | No | Weight for this fairness key (0.001-1000). Keys dispatched proportionally. | +| `--static-summary` | No | Human-readable summary for UIs. Single line. _(Experimental)_ | +| `--static-details` | No | Human-readable details for UIs. May be multi-line. _(Experimental)_ | +| `--fail-existing` | No | Fail if the Workflow already exists. | +| `--headers` | No | Temporal workflow headers as `KEY=VALUE` (JSON). Not gRPC headers. Repeatable. | + +## Workflow execute + +Start a Workflow Execution and block until it completes, streaming progress to stdout. + +```bash +temporal workflow execute \ + --output json \ + --workflow-id YourWorkflowId \ + --type YourWorkflow \ + --task-queue YourTaskQueue \ + --input '{"some-key": "some-value"}' +``` + +Accepts the same start-time flags as `workflow start`. The only `workflow execute` specific flag is `--detailed` (display events as sections rather than a table; not applied to JSON output). With `--output json`, the emitted blob includes the full `history` key for the run. + +A non-zero exit code means the Workflow failed, was cancelled, terminated, or timed out. Useful for one-shot scripts and smoke tests during development. + +## Workflow signal + +Send an asynchronous signal to a running Workflow Execution. + +```bash +temporal workflow signal \ + --output json \ + --workflow-id YourWorkflowId \ + --name YourSignal \ + --input '{"YourInputKey": "YourInputValue"}' +``` + +| Flag | Required | Purpose | +|---|---|---| +| `--workflow-id`, `-w` | Yes (or `--query`) | Workflow ID. | +| `--name` | Yes | Signal name. | +| `--input`, `-i` | No | Input value (JSON). Repeatable. | +| `--run-id`, `-r` | No | Pin to a specific run. Only with `--workflow-id`. | + +For bulk signaling with `--query` (runs as a batch job), see skill-temporal-ops. + +## Workflow query + +Invoke a read-only query handler. Queries do not mutate workflow state and can run on both running and completed workflows. + +```bash +temporal workflow query \ + --output json \ + --workflow-id YourWorkflowId \ + --name YourQueryType \ + --input '{"YourInputKey": "YourInputValue"}' +``` + +| Flag | Required | Purpose | +|---|---|---| +| `--workflow-id`, `-w` | Yes | Workflow ID. | +| `--name` | Yes | Query Type/Name. | +| `--input`, `-i` | No | Input value (JSON). Repeatable. | +| `--run-id`, `-r` | No | Run ID. | +| `--reject-condition` | No | Reject queries based on Workflow state. Accepted values: `not_open`, `not_completed_cleanly`. | + +## Workflow update + +Update is a command **group**, not a single command. It has four subcommands: `describe`, `execute`, `result`, `start`. + +### `temporal workflow update start` + +Initiate an update and wait for the validator to accept or reject it. + +```bash +temporal workflow update start \ + --output json \ + --workflow-id YourWorkflowId \ + --name YourUpdate \ + --input '{"some-key": "some-value"}' \ + --wait-for-stage accepted +``` + +| Flag | Required | Purpose | +|---|---|---| +| `--workflow-id`, `-w` | Yes | Workflow ID. | +| `--name` | Yes | Handler method name. | +| `--wait-for-stage` | Yes | Update stage to wait for. The **only** accepted value is `accepted`. Required to allow a future CLI version to choose a default. | +| `--input`, `-i` | No | Input value (JSON). Repeatable. | +| `--update-id` | No | Idempotency key. Defaults to a UUID. | +| `--run-id`, `-r` | No | Run ID. If unset, targets the currently-running execution. | +| `--first-execution-run-id` | No | Pin the update to the last execution in the chain started with this Run ID. | + +### `temporal workflow update execute` + +Start an update and wait for it to complete or fail. Can also wait on an existing in-flight update by reusing its Update ID. + +```bash +temporal workflow update execute \ + --output json \ + --workflow-id YourWorkflowId \ + --name YourUpdate \ + --input '{"some-key": "some-value"}' +``` + +| Flag | Required | Purpose | +|---|---|---| +| `--workflow-id`, `-w` | Yes | Workflow ID. | +| `--name` | Yes | Handler method name. | +| `--input`, `-i` | No | Input value (JSON). Repeatable. | +| `--update-id` | No | Idempotency key. Defaults to a UUID. | +| `--run-id`, `-r` | No | Run ID. If unset, targets the currently-running execution. | +| `--first-execution-run-id` | No | Pin the update to the last execution in the chain started with this Run ID. | + +### `temporal workflow update result` + +Wait for a previously started update to complete or fail, then print the result. + +```bash +temporal workflow update result \ + --output json \ + --workflow-id YourWorkflowId \ + --update-id YourUpdateId +``` + +| Flag | Required | Purpose | +|---|---|---| +| `--workflow-id`, `-w` | Yes | Workflow ID. | +| `--update-id` | Yes | Update ID. Must be unique per Workflow Execution. | +| `--run-id`, `-r` | No | Run ID. | + +### `temporal workflow update describe` + +Inspect the current status of an update, including a result if it has finished. + +```bash +temporal workflow update describe \ + --output json \ + --workflow-id YourWorkflowId \ + --update-id YourUpdateId +``` + +| Flag | Required | Purpose | +|---|---|---| +| `--workflow-id`, `-w` | Yes | Workflow ID. | +| `--update-id` | Yes | Update ID. Must be unique per Workflow Execution. | +| `--run-id`, `-r` | No | Run ID. | + +## Workflow signal-with-start + +Atomically signal a Workflow Execution -- if the target run does not exist, a new Workflow Execution is created first, then the signal is delivered. + +```bash +temporal workflow signal-with-start \ + --output json \ + --signal-name YourSignal \ + --signal-input '{"some-key": "some-value"}' \ + --workflow-id YourWorkflowId \ + --type YourWorkflowType \ + --task-queue YourTaskQueue \ + --input '{"some-key": "some-value"}' +``` + +Takes `--signal-name` (required), `--signal-input`, plus all start-time flags from `workflow start`. + +| Flag | Required | Purpose | +|---|---|---| +| `--signal-name` | Yes | Signal name. | +| `--signal-input` | No | Signal input value (JSON). Repeatable. | +| `--type` | Yes | Workflow Type name. | +| `--task-queue`, `-t` | Yes | Workflow Task queue. | +| `--workflow-id`, `-w` | No | Workflow ID. Service generates a UUID if omitted. | + +All other start-time flags (`--input`, `--id-reuse-policy`, `--id-conflict-policy`, timeouts, `--search-attribute`, `--memo`, etc.) are accepted. See [Workflow start](#workflow-start) for the full flag table. + +## Workflow result + +Block until a running Workflow Execution completes, then print the result. + +```bash +temporal workflow result \ + --output json \ + --workflow-id YourWorkflowId +``` + +| Flag | Required | Purpose | +|---|---|---| +| `--workflow-id`, `-w` | Yes | Workflow ID. | +| `--run-id`, `-r` | No | Run ID. | + +## Workflow metadata + +Issue a query to read user-set summary and details metadata for a Workflow Execution. + +```bash +temporal workflow metadata \ + --output json \ + --workflow-id YourWorkflowId +``` + +| Flag | Required | Purpose | +|---|---|---| +| `--workflow-id`, `-w` | Yes | Workflow ID. | +| `--run-id`, `-r` | No | Run ID. | +| `--reject-condition` | No | Reject queries based on Workflow state. Accepted values: `not_open`, `not_completed_cleanly`. | diff --git a/references/core/dev-management.md b/references/core/dev-management.md index 45385d3..6ced2c6 100644 --- a/references/core/dev-management.md +++ b/references/core/dev-management.md @@ -2,13 +2,43 @@ ## Server Management -Before starting workers or workflows, you MUST start a local dev server, using the Temporal CLI: +Workers and workflows need a running Temporal Server. You can develop against a local dev server, a self-hosted cluster, or Temporal Cloud — the choice depends on your setup. If you need a local server, start one with the Temporal CLI: ```bash temporal server start-dev # Start this in the background. ``` -It is perfectly OK for this process to be shared across multiple projects / left running as you develop your Temporal code. +The dev server can be shared across projects and left running as you develop. + +The dev server is in-memory by default -- all workflows, schedules, and history are lost on restart. Use `--db-filename temporal.db` to persist across restarts. + +The dev server is for local development only, not production. + +### `temporal server start-dev` flags + +| Flag | Default | Purpose | +|---|---|---| +| `--db-filename`, `-f` | in-memory | Persistent SQLite file. Without it, state is in-memory and lost on exit. | +| `--namespace`, `-n` | `default` only | Namespaces to create at launch. Repeatable. The `default` namespace is always created. | +| `--search-attribute` | — | Register search attributes as `KEY=TYPE` pairs. TYPE is one of: `Text`, `Keyword`, `Int`, `Double`, `Bool`, `Datetime`, `KeywordList`. Repeatable. | +| `--port`, `-p` | `7233` | Front-end gRPC port. | +| `--ui-port` | `--port` + 1000 | Web UI port. | +| `--ip` | `127.0.0.1` | IP address bound to the front-end service. Use `0.0.0.0` for Docker/LAN access. | +| `--dynamic-config-value` | — | Dynamic config in `KEY=JSON_VALUE` form. Repeatable. | +| `--log-level` | `warn` | (Global flag) Log level. Accepted values: `debug`, `info`, `warn`, `error`, `never`. Default is `warn` for `start-dev`. | +| `--log-format` | `text` | (Global flag) Log format. Accepted values: `text`, `json`. | +| `--headless` | — | Disable the Web UI. | +| `--http-port` | random free port | HTTP API port. | +| `--metrics-port` | random free port | Prometheus `/metrics` port. | + +Example with persistence, extra namespaces, and a search attribute: + +```bash +temporal server start-dev \ + --db-filename /tmp/temporal.db \ + --namespace dev \ + --search-attribute OrderId=Keyword +``` ## Worker Management Details @@ -23,3 +53,56 @@ When you need a new worker, you should start it in the background (and preferrab ### Cleanup **Always kill workers when done.** Don't leave workers running. + +## Dev to Prod + +Steps to promote a workflow from a local dev server to a production backend. For the most part, the workflow and worker code do not change between environments; only the connection descriptor does. If the connection code is in the application code, then just those spots in the code need to be updated. + +### 1. Start a local dev server with persistence + +```bash +temporal server start-dev --db-filename dev.db +``` + +### 2. Run the workflow against dev + +Start your worker, then execute the workflow: + +```bash +temporal workflow execute \ + --type MyWorkflow \ + --task-queue my-queue \ + --input '{"key": "value"}' +``` + +`workflow execute` blocks until the run terminates; a non-zero exit means the run failed, was cancelled, terminated, or timed out. + +### 3. Create a prod profile configuration + +```bash +temporal config --profile prod set --prop address --value "your-ns.your-acct.tmprl.cloud:7233" +temporal config --profile prod set --prop namespace --value "your-ns.your-acct" +temporal config --profile prod set --prop api-key --value "your-key" +``` + +The profile-selecting flag is `--profile `. + +### 4. Smoke-test prod + +```bash +temporal workflow list --profile prod --limit 1 --output json +``` + +If this returns (even an empty list), the connection descriptor is correct. + +### 5. Run in prod + +```bash +temporal workflow start \ + --profile prod \ + --type MyWorkflow \ + --task-queue my-queue \ + --input '{"key": "value"}' +``` + +`workflow start` is asynchronous (returns a Workflow/Run ID); use `workflow execute` instead if you want the CLI to block. \ No newline at end of file diff --git a/references/core/gotchas.md b/references/core/gotchas.md index 677362f..372caed 100644 --- a/references/core/gotchas.md +++ b/references/core/gotchas.md @@ -195,6 +195,29 @@ See language-specific gotchas for details. **The Fix**: Heartbeat regularly and check for cancellation. See language-specific gotchas for implementation patterns. +## CLI Gotchas for Developers + +### Dev Server Is In-Memory and Not for Production + +The dev server loses all state on restart (use `--db-filename` to persist) and runs everything in a single process. See `dev-management.md` for the full flag table and persistence guidance. Even with persistence enabled, the dev server should NEVER be used for production deployments. + +### `workflow update` Is a Command Group, Not a Single Command + +Running `temporal workflow update` alone will not work. Use the correct subcommand: + +- `temporal workflow update execute` -- start an update and wait for completion. +- `temporal workflow update start` -- fire an update and wait for acceptance. Requires `--wait-for-stage accepted`. +- `temporal workflow update result` -- get the result of a previously started update. +- `temporal workflow update describe` -- check an update's current status. + +### `--wait-for-stage` Only Accepts `accepted` + +Despite looking like an enum, the only valid value for `--wait-for-stage` on `temporal workflow update start` is `accepted`. Passing `completed` or other values will fail. The flag is required to allow a future CLI version to choose a default. + +### `--reapply-type` Only Accepts `Signal` or `None` + +When resetting a workflow with `temporal workflow reset`, `--reapply-type` controls which events get reapplied after the reset point. Only `Signal` and `None` are valid values. + ## Payload Size Limits **The Problem**: Temporal has built-in limits on payload sizes. Exceeding them causes workflows to fail. diff --git a/references/core/install_cli.md b/references/core/install_cli.md index 4421172..41b46fb 100644 --- a/references/core/install_cli.md +++ b/references/core/install_cli.md @@ -2,24 +2,55 @@ ## macOS -``` +### Via homebrew + +```bash brew install temporal ``` +### Via tarball download + +- [Darwin amd64](https://temporal.download/cli/archive/latest?platform=darwin&arch=amd64) +- [Darwin arm64](https://temporal.download/cli/archive/latest?platform=darwin&arch=arm64) + +Extract any downloaded archive and add the `temporal` binary to your `PATH`. + ## Linux -Check your machine's architecture and download the appropriate archive: +Homebrew (if available), Snap, or tarball download: + +```bash +brew install temporal +# or +snap install temporal +``` - [Linux amd64](https://temporal.download/cli/archive/latest?platform=linux&arch=amd64) - [Linux arm64](https://temporal.download/cli/archive/latest?platform=linux&arch=arm64) -Once you've downloaded the file, extract the downloaded archive and add the temporal binary to your PATH by copying it to a directory like /usr/local/bin +Extract any downloaded archive and add the `temporal` binary to your `PATH`. ## Windows -Check your machine's architecture and download the appropriate archive: +Download the tarballs: - [Windows amd64](https://temporal.download/cli/archive/latest?platform=windows&arch=amd64) - [Windows arm64](https://temporal.download/cli/archive/latest?platform=windows&arch=arm64) -Once you've downloaded the file, extract the downloaded archive and add the temporal.exe binary to your PATH. \ No newline at end of file +Extract the archive and add the `temporal.exe` binary to your `PATH`. + +## Docker + +```bash +docker run --rm temporalio/temporal --help +``` + +## `tcld` (Temporal Cloud CLI) + +Only needed for Cloud-connected development (managing Cloud namespaces, API keys, etc.). + +Homebrew: + +```bash +brew install temporalio/brew/tcld +``` \ No newline at end of file From 351eadaf54baf84e6d58949b4495eb86d80123f6 Mon Sep 17 00:00:00 2001 From: "skill-temporal-developer-updater[bot]" <276371939+skill-temporal-developer-updater[bot]@users.noreply.github.com> Date: Fri, 29 May 2026 17:34:40 -0400 Subject: [PATCH 57/82] Implement planned topic: 0035-dns-resolver-config (#219) * Implement planned topic: 0035-dns-resolver-config Add DNS Resolver Configuration section to references/python/advanced-features.md documenting temporalio.service.DnsLoadBalancingConfig: the resolution_interval_millis field, the default classvar, the Client.connect / CloudOperationsClient.connect kwargs, and the silent mutual-exclusion with HttpConnectProxyConfig. Anchored to sdk-python v1.27.2 source (the official docs site does not yet cover this class). Co-Authored-By: Claude Opus 4.7 * Finalize draft for 0035-dns-resolver-config * Apply suggestions from code review Co-authored-by: Donald Pinckney --------- Co-authored-by: skill-sync[bot] Co-authored-by: Claude Opus 4.7 Co-authored-by: Donald Pinckney Co-authored-by: Donald Pinckney --- references/python/advanced-features.md | 27 ++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/references/python/advanced-features.md b/references/python/advanced-features.md index c5ec1b3..6ad8ae8 100644 --- a/references/python/advanced-features.md +++ b/references/python/advanced-features.md @@ -114,6 +114,33 @@ worker = Worker( ) ``` +## DNS Resolver Configuration + +`DnsLoadBalancingConfig` makes Core periodically re-resolve the client's target host and round-robin requests across the resolved addresses . Use it when `target_host` resolves to multiple A/AAAA records (e.g., a load-balanced gRPC frontend, multi-address private endpoints) and you want the client to spread RPCs across them. + +### Configuration + +```python +from temporalio.client import Client +from temporalio.service import DnsLoadBalancingConfig + +client = await Client.connect( + "frontend.example.internal:7233", + dns_load_balancing_config=DnsLoadBalancingConfig( + resolution_interval_millis=5000, # re-resolve every 5 seconds + ), +) +``` + +- The only field is `resolution_interval_millis: int = 30000` — how often to re-resolve DNS, in milliseconds. +- `DnsLoadBalancingConfig.default` is a pre-built instance with the default 30-second interval. +- `dns_load_balancing_config` defaults to 30 seconds if you don't pass anything explicitly. +- Pass `dns_load_balancing_config=None` to disable DNS load balancing entirely. + +### Mutual exclusion with HTTP CONNECT proxy + +DNS load balancing and `HttpConnectProxyConfig` cannot be used together. When `http_connect_proxy_config` is set on the same client, DNS load balancing is **silently disabled** — there is no error and no precedence flag. If you need both, you cannot have both; choose the one your network requires. + ## Workflow Init Decorator You should always put state initialization logic in the `__init__` of your workflow class, so that it happens before signals/updates arrive. From 38400885763323d3275167ace231edb96c4a79d9 Mon Sep 17 00:00:00 2001 From: "skill-temporal-developer-updater[bot]" <276371939+skill-temporal-developer-updater[bot]@users.noreply.github.com> Date: Fri, 29 May 2026 17:38:24 -0400 Subject: [PATCH 58/82] Implement planned topic: 0025-preload-modules (#205) * Add Preload Modules subsection to TypeScript advanced features Documents the `preloadModules` bundler option on `BundleOptions` and `WorkerOptions.bundlerOptions`, including the `reuseV8Context` precondition, the per-workflow-state warning, and the bundle-time conflict with `ignoreModules`. Sourced from sdk-typescript JSDoc since the docs clone is silent on this option. Co-Authored-By: Claude Opus 4.7 * Finalize draft for 0025-preload-modules --------- Co-authored-by: skill-sync[bot] Co-authored-by: Claude Opus 4.7 --- references/typescript/advanced-features.md | 35 ++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/references/typescript/advanced-features.md b/references/typescript/advanced-features.md index ed9817d..29d1738 100644 --- a/references/typescript/advanced-features.md +++ b/references/typescript/advanced-features.md @@ -102,6 +102,41 @@ const worker = await Worker.create({ - `shutdownGraceTime`: Time to wait for in-progress work before forced shutdown - `maxCachedWorkflows`: Number of workflows to keep in cache (reduces replay on cache hit) +## Preload Modules + +`preloadModules` is a `string[]` bundler option that loads a list of modules once during reusable V8 context bootstrap; preloaded modules are then shared across workflows executing in the same V8 context. It is only beneficial when `reuseV8Context` is enabled, which is the default (`@default true`). + +**Ahead-of-time bundling via `BundleOptions`:** + +```typescript +import { bundleWorkflowCode } from '@temporalio/worker'; + +const { code } = await bundleWorkflowCode({ + workflowsPath: require.resolve('./workflows'), + preloadModules: ['lodash', './workflow-helpers'], +}); +``` + +**Startup bundling via `WorkerOptions.bundlerOptions`:** + +```typescript +const worker = await Worker.create({ + taskQueue: 'my-queue', + workflowsPath: require.resolve('./workflows'), + activities, + bundlerOptions: { + preloadModules: ['lodash', './workflow-helpers'], + }, +}); +``` + +**Constraints:** + +- **`preloadModules` is only beneficial when `reuseV8Context` is enabled (default `true`). ** If `reuseV8Context` is disabled, leave the list empty. +- **Module top-level code runs once, before any workflow activator exists. ** Only preload modules whose initialization is safe to execute that early. +- **Preloading a module that internally stores per-workflow state will leak context across workflows and cause non-deterministic behavior. ** Remove such modules from `preloadModules`. +- **A module listed in both `preloadModules` and `ignoreModules` fails the bundle with `Cannot preload modules that are also ignored: ''`. ** Remove the module from one of the two lists. + ## Sinks Sinks allow workflows to emit events for side effects (logging, metrics). From 16bc5715a1a9fd0daa7cd58e538c265c3310ada2 Mon Sep 17 00:00:00 2001 From: Donald Pinckney Date: Fri, 29 May 2026 17:46:33 -0400 Subject: [PATCH 59/82] Release version 0.5.0 of temporal-developer skill (#233) --- SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SKILL.md b/SKILL.md index 78a00cc..471c806 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,7 +1,7 @@ --- name: temporal-developer description: Develop, debug, and manage Temporal applications across Python, TypeScript, Go, Java, .NET and Ruby. Use when the user is building workflows, activities, or workers with a Temporal SDK, debugging issues like non-determinism errors, stuck workflows, or activity retries, using Temporal CLI, Temporal Server, or Temporal Cloud, or working with durable execution concepts like signals, queries, heartbeats, versioning, continue-as-new, child workflows, or saga patterns. Also use when the user mentions "run a Temporal workflow from the CLI", "start a dev server", "run temporal server start-dev", "temporal workflow start", "temporal workflow execute", "temporal workflow signal", "temporal workflow query", "temporal workflow update". -version: 0.4.0 +version: 0.5.0 --- # Skill: temporal-developer From a2f0903486b7050c1ac48c1dac590dfe73bf75c3 Mon Sep 17 00:00:00 2001 From: Donald Pinckney Date: Mon, 1 Jun 2026 13:07:39 -0400 Subject: [PATCH 60/82] Update Ruby status to completed in README (#236) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index a0ae73b..b91d12a 100644 --- a/README.md +++ b/README.md @@ -40,5 +40,5 @@ Appropriately adjust the installation directory based on your coding agent. - [x] Go ✅ - [x] Java ✅ - [x] .NET ✅ -- [ ] Ruby 🚧 ([PR](https://github.com/temporalio/skill-temporal-developer/pull/41)) +- [x] Ruby ✅ - [ ] PHP 🚧 ([PR](https://github.com/temporalio/skill-temporal-developer/pull/40)) From 5f32b62499bdc4c398516d6a03c88ed83c20579d Mon Sep 17 00:00:00 2001 From: "skill-temporal-developer-updater[bot]" <276371939+skill-temporal-developer-updater[bot]@users.noreply.github.com> Date: Tue, 2 Jun 2026 15:29:04 -0400 Subject: [PATCH 61/82] Implement planned topic: 0001-standalone-activities (#224) * Add Python standalone activities reference * Add TypeScript standalone activities reference * Add .NET standalone activities reference * Add Java standalone activities reference * Finalize draft for 0001-standalone-activities * remove incomplete sections * Add core page which abstracts out all shared stuff * Standardize connection logic * Unify worker setup section across SDK standalone-activity refs Rename the worker section to "Worker setup & activity registration" in all four SDK files and lead with a single sentence noting the Activity is defined and registered exactly as normal. Drop the .NET "Define the Activity" section so no file repeats how to define an activity, matching the Python structure. Co-Authored-By: Claude Opus 4.8 (1M context) * re-organize to a LOGICAL structure, not just a flat list of H2 headings. * finish cleaning up parts other than calling activities * Get client connection in order * cleanup of operations content * Add links --------- Co-authored-by: skill-sync[bot] Co-authored-by: Donald Pinckney Co-authored-by: Claude Opus 4.8 (1M context) --- SKILL.md | 2 + references/core/standalone-activities.md | 160 ++++++++++++++++++ references/dotnet/dotnet.md | 1 + references/dotnet/standalone-activities.md | 156 +++++++++++++++++ references/java/java.md | 1 + references/java/standalone-activities.md | 134 +++++++++++++++ references/python/python.md | 1 + references/python/standalone-activities.md | 157 +++++++++++++++++ .../typescript/standalone-activities.md | 147 ++++++++++++++++ references/typescript/typescript.md | 1 + 10 files changed, 760 insertions(+) create mode 100644 references/core/standalone-activities.md create mode 100644 references/dotnet/standalone-activities.md create mode 100644 references/java/standalone-activities.md create mode 100644 references/python/standalone-activities.md create mode 100644 references/typescript/standalone-activities.md diff --git a/SKILL.md b/SKILL.md index 471c806..627ef2e 100644 --- a/SKILL.md +++ b/SKILL.md @@ -71,6 +71,8 @@ Check if `temporal` CLI is installed. If not, follow the instructions at `refere - Language-specific info at `references/{your_language}/gotchas.md` - **`references/core/versioning.md`** - Versioning strategies and concepts - how to safely change workflow code while workflows are running - Language-specific info at `references/{your_language}/versioning.md` +- **`references/core/standalone-activities.md`** - Standalone Activities: run an Activity directly from a Client without a Workflow (Public Preview) + - Language-specific info at `references/{your_language}/standalone-activities.md` - **`references/core/troubleshooting.md`** - Decision trees, recovery procedures - **`references/core/error-reference.md`** - Common error types, workflow status reference - **`references/core/interactive-workflows.md`** - Testing signals, updates, queries diff --git a/references/core/standalone-activities.md b/references/core/standalone-activities.md new file mode 100644 index 0000000..976b1b3 --- /dev/null +++ b/references/core/standalone-activities.md @@ -0,0 +1,160 @@ +> [!NOTE] +> Standalone Activities are in Public Preview. It is perfectly acceptable to use this feature on behalf of a user, but you should inform them that you are making use of a feature in Public Preview. + +# Standalone Activities (Concepts) + +This document provides core conceptual explanations of Standalone Activities in Temporal. For language-specific implementation details, see `references/{your_language}/standalone-activities.md` for the language you are working in (Python, TypeScript, Java, .NET). + +## What is a Standalone Activity? + +A **Standalone Activity** is a top-level Activity Execution started directly by a Client, without using a Workflow. It is Temporal's job queue — the simplest way to run a single durable, retryable task. + +The rule of thumb: + +- **Need to orchestrate multiple Activities?** Use a Workflow. +- **Just need to execute a single Activity?** Use a Standalone Activity. + +The same Activity Function code runs in both modes with no changes — the only difference is how it is invoked. An Activity defined for a Workflow can also be executed standalone, and the Worker that hosts it does not need to know how it will be invoked. + +Compared to wrapping a single Activity in a Workflow, a Standalone Activity: + +- Reduces billable actions in Temporal Cloud. +- Lowers latency for short-lived executions (fewer Worker round-trips). +- Lives in a separate ID space from Workflows. + +### Use cases + +Standalone Activities fit durable single-job processing where you don't need multi-step orchestration: + +- Sending an email +- Processing a webhook +- Syncing data +- Any single-function task that benefits from built-in retries and timeouts + +### Key features + +- Execute Activities as a top-level primitive, without Workflow overhead. +- Native async job lifecycle: **schedule → dispatch → process → result**. +- Arbitrary-length jobs, with heartbeats for progress tracking. +- **At-least-once execution by default**, with native retry policy and timeouts. +- **At-most-once execution** when the retry policy's maximum attempts is 1. +- Addressable by Activity ID / Run ID for result retrieval, cancellation, and termination. +- Deduplication via configurable conflict policies. +- Priority and fairness support. +- Full visibility — list and count executions. + +## Using Standalone Activities + +### Defining activities + +Defining standalone activities is IDENTICAL to defining activities callable from a workflow - there is no distinction AT ALL between the two at activity definition or worker configuration site. Follow language-specific guidance for how to normally define activities and configure workers to run them. + +### Calling and Interacting with Standalone Activities + +The CLI and every SDK exposes the same conceptual operations against a Standalone Activity (method names differ per language — see the language reference): + +- **Execute** — durably enqueue the Activity, wait for a Worker to run it, and return the result. +- **Start** — durably enqueue the Activity and return a handle immediately, without waiting. +- **Get handle** — rebind a handle to a previously started Activity by ID (and optionally Run ID). +- **Get result** — wait on a handle for completion. `execute` is equivalent to `start` followed by awaiting the handle's result. +- **Cancel / Terminate** — via the handle or CLI. + +**Choosing an Activity ID.** Every Standalone Activity call requires an **Activity ID**, which uniquely identifies that one call. It is the key you use later to get the result, describe, cancel, or terminate the Activity, and it is what conflict/reuse policies dedupe against. Use a **business-logic identifier** that uniquely identifies the call — for example `send-welcome-email:user-42`, `sync-invoice:INV-2026-001`, or `process-webhook:`. This makes Activities addressable and naturally deduplicated by your domain. Only if you genuinely have no meaningful business-level identifier should you generate a **UUID** to use as the Activity ID. + +Visibility operations are available as well: +- **List** — enumerate Standalone Activity Executions matching a query. Only Standalone Activities are returned; Activities running inside Workflows are not included. +- **Count** — return the total number of executions matching a query (running, completed, failed, etc. — not the number of queued tasks). +- **Describe** — via the handle or CLI. + +See below for a quick reference how to call these operations from the CLI rather than SDKs. + +> [!IMPORTANT] +> When using an SDK, these operations are owned by the Temporal Client, and belong **in your non-workflow application code**. It is INVALID to call an activity as a standalone activity from within a workflow: you instead should use standard within-workflow activity calls. + +**Currently Supported SDKs: Python, TypeScript, Java, .NET** + +## Quick CLI Standalone Activity Man Page + +Ultimately, any standalone activity invocation code should live in your application code and use the appropriate SDK, but the Temporal CLI is a quick and easy way to test invoking standalone activities during development. All subcommands live under `temporal activity`. + +The key operations are: + +**Execute (start and wait for the result).** Blocks until the Activity completes and prints the result to stdout. Requires `--activity-id`, `--type`, `--task-queue`, and at least one of `--start-to-close-timeout` / `--schedule-to-close-timeout`: + +```bash +temporal activity execute \ + --activity-id my-activity-id \ + --type ComposeGreeting \ + --task-queue my-task-queue \ + --start-to-close-timeout 10s \ + --input '{"some-key": "some-value"}' +``` + +`--input` takes a JSON value; pass it multiple times for multiple positional arguments. `--input-file` is also a convenient option for larger inputs. The same required flags apply to `start` below. + +Reminder: `--activity-id` must be unique across all activity calls, as discussed above. + +**Start (do not wait).** Enqueues the Activity and prints the Activity ID and Run ID without blocking: + +```bash +temporal activity start \ + --activity-id my-activity-id \ + --type ComposeGreeting \ + --task-queue my-task-queue \ + --start-to-close-timeout 10s \ + --input '{"some-key": "some-value"}' +``` + +Outputs this JSON shape: + +```json +{ + "activityId": "my-activity-id", + "runId": "019e84d3-949a-7a0e-ae78-63b8a0b172bd", + "namespace": "default" +} +``` + +**Result (wait for a started Activity).** Waits for completion and prints the result. `--run-id` is optional and defaults to the latest run of that Activity ID: + +```bash +temporal activity result --activity-id my-activity-id +``` + +**Describe (current state of one Activity).** Shows status, run state, task queue, timeouts, attempt count, etc.: + +```bash +temporal activity describe --activity-id my-activity-id +``` + +**List / Count (visibility across many Activities).** Only Standalone Activity Executions are returned (Activities running inside Workflows are not): + +```bash +temporal activity list +temporal activity count +``` + +**Cancel / Terminate (stop an Activity).** `cancel` requests cooperative cancellation (surfaced to the Activity on its next heartbeat response); `terminate` forcefully ends it (Activity code cannot see or respond to it). Both accept `--reason`: + +```bash +temporal activity cancel --activity-id my-activity-id --reason "no longer needed" +temporal activity terminate --activity-id my-activity-id --reason "no longer needed" +``` + +## Observability + +All existing Activity metrics apply to Standalone Activities (scheduled, started, completed, failed, timed out, canceled). + +## Public Preview limitations + +- Pause, reset, and update options are not supported (scheduled for GA). +- The `TerminateExisting` conflict policy and `TerminateIfRunning` reuse policy are not yet supported. + +## Temporal CLI support + +- Requires **Temporal CLI v1.7.0+** and **Temporal Server v1.31.0+**. See `references/core/install_cli.md` if you need to update the CLI. +- The Temporal Dev Server (`temporal server start-dev`) has Standalone Activities enabled by default. + +## Temporal Cloud support + +Standalone Activities are available in Temporal Cloud as a Public Preview feature. Because the SDK client config loaders read environment variables and TOML profiles, the same code runs against a local server or Temporal Cloud with no code changes. diff --git a/references/dotnet/dotnet.md b/references/dotnet/dotnet.md index a7f1c54..8ed4a53 100644 --- a/references/dotnet/dotnet.md +++ b/references/dotnet/dotnet.md @@ -199,4 +199,5 @@ See `references/dotnet/testing.md` for info on writing tests. - **`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/standalone-activities.md`** — Standalone Activities: run an Activity directly from a Client without a Workflow (Public Preview). Concept overview at `references/core/standalone-activities.md`. - **`references/dotnet/determinism-protection.md`** — Runtime task detection, .NET Task determinism rules diff --git a/references/dotnet/standalone-activities.md b/references/dotnet/standalone-activities.md new file mode 100644 index 0000000..f31d5ce --- /dev/null +++ b/references/dotnet/standalone-activities.md @@ -0,0 +1,156 @@ +> [!NOTE] +> This feature is in Public Preview. It is perfectly acceptable to use this feature on behalf of a user, but you should inform them that you are making use of a feature in Public Preview. + +## Overview + +Standalone Activities are Activities run independently of any Workflow, started directly from a Temporal Client — useful when you need a single durable, retryable task (job-queue style) and not multi-step orchestration. The same Activity method can be executed both as a Standalone Activity and as a Workflow Activity with no code changes. + +Standalone Activities are conceptually the same across all SDKs. Read the [cross-SDK concept file](references/core/standalone-activities.md) if you have not already, and then see below for the .NET SDK specific APIs for calling Standalone Activities. + +## Prerequisites + +- Temporal .NET SDK v1.12.0 or higher. +- Temporal CLI v1.7.0 or higher — see [Temporal CLI install instructions](references/core/install_cli.md) if needed. Dev server includes Standalone Activities support. +- For production, Temporal Server v1.31.0 or higher (or Temporal Cloud). + +## Hosting Activities on a Worker + +The Activity is defined just as activities normally are in Temporal. Worker registration is also the same. + +```csharp +using Microsoft.Extensions.Logging; +using Temporalio.Client; +using Temporalio.Common.EnvConfig; +using Temporalio.Worker; +using TemporalioSamples.StandaloneActivity; + +var connectOptions = ClientEnvConfig.LoadClientConnectOptions(); +connectOptions.TargetHost ??= "localhost:7233"; +connectOptions.LoggerFactory = LoggerFactory.Create(builder => + builder. + AddSimpleConsole(options => options.TimestampFormat = "[HH:mm:ss] "). + SetMinimumLevel(LogLevel.Information)); +var client = await TemporalClient.ConnectAsync(connectOptions); + +const string taskQueue = "standalone-activity-sample"; + +using var tokenSource = new CancellationTokenSource(); +Console.CancelKeyPress += (_, eventArgs) => +{ + tokenSource.Cancel(); + eventArgs.Cancel = true; +}; + +using var worker = new TemporalWorker( + client, + new TemporalWorkerOptions(taskQueue). + AddActivity(MyActivities.ComposeGreetingAsync)); // register whatever your activity(ies) is/are + +await worker.ExecuteAsync(tokenSource.Token); +``` + +## Calling and managing Standalone Activities + +Start and manage Standalone Activities from your application code using the Temporal Client. + +### Do not call from inside a Workflow + +Don't call `client.ExecuteActivityAsync` / `client.StartActivityAsync` or any other Standalone Activity APIs from inside a Workflow Definition — use Workflow-side activity invocation (`Workflow.ExecuteActivityAsync`) instead. + +### Connect a Client + +The Standalone Activity operations are methods on a connected `TemporalClient`. The examples below assume this `client`. + +```csharp +using Temporalio.Client; +using Temporalio.Common.EnvConfig; + +var connectOptions = ClientEnvConfig.LoadClientConnectOptions(); +connectOptions.TargetHost ??= "localhost:7233"; +var client = await TemporalClient.ConnectAsync(connectOptions); +``` + +### Execute (wait for result) + +Use `client.ExecuteActivityAsync(...)` to durably enqueue the Activity, wait for it to run on a Worker, and return the result. The activity options require `Id`, `TaskQueue`, and at least one of `ScheduleToCloseTimeout` or `StartToCloseTimeout`. + +#### With type checking + +Use when activity definitions are available in this language. Pass a lambda invoking the activity method: + +```csharp +// In practice, use a meaningful business identifier, like customer or transaction identifier +var activityId = Guid.NewGuid().ToString(); + +var result = await client.ExecuteActivityAsync( + () => MyActivities.ComposeGreetingAsync(new ComposeGreetingInput("Hello", "World")), + new(activityId, "standalone-activity-sample") + { + ScheduleToCloseTimeout = TimeSpan.FromSeconds(10), + }); +``` + +#### Without type checking + +Use when activity definitions are unavailable in this language (i.e. you can't import them). Pass the activity type name as a string and an argument array: + +```csharp +var result = await client.ExecuteActivityAsync( + "ComposeGreeting", + new object?[] { new ComposeGreetingInput("Hello", "World") }, + new(activityId, "standalone-activity-sample") + { + ScheduleToCloseTimeout = TimeSpan.FromSeconds(10), + }); +``` + +### Start (do not wait for result) + +Use `client.StartActivityAsync(...)` to durably enqueue the Activity and get back a handle without waiting for completion. This takes the **exact same arguments as `ExecuteActivityAsync`**. + +```csharp +var handle = await client.StartActivityAsync(...); +``` + +### Get a handle to an existing Activity execution + +Use `client.GetActivityHandle(...)` to attach a handle to a previously started Standalone Activity. Passing `null` as the run ID (the default) targets the latest run of that Activity ID. + +```csharp +// Without a known result type +var handle = client.GetActivityHandle("my-activity-id", runId: "the-run-id"); + +// With a known result type +var typedHandle = client.GetActivityHandle("my-activity-id", runId: "the-run-id"); +``` + +### Wait for the result of a handle + +```csharp +var result = await handle.GetResultAsync(); +``` + +Calling `ExecuteActivityAsync` is equivalent to `StartActivityAsync` followed by `await handle.GetResultAsync()`. + +### List Standalone Activities + +```csharp +await foreach (var info in client.ListActivitiesAsync( + "TaskQueue = 'standalone-activity-sample'")) // returns an IAsyncEnumerable +{ + Console.WriteLine( + $"ActivityID: {info.ActivityId}, Type: {info.ActivityType}, Status: {info.Status}"); +} +``` + +Only Standalone Activity Executions are returned; Activities running inside Workflows are not included. + +### Count Standalone Activities + +Use `client.CountActivitiesAsync(query)` to count matching executions; this takes the **exact same arguments as `ListActivitiesAsync`**. + +```csharp +var resp = await client.CountActivitiesAsync( + "TaskQueue = 'standalone-activity-sample'"); +Console.WriteLine($"Total activities: {resp.Count}"); +``` diff --git a/references/java/java.md b/references/java/java.md index 05e4f47..593d138 100644 --- a/references/java/java.md +++ b/references/java/java.md @@ -263,6 +263,7 @@ See `references/java/testing.md` for info on writing tests. - **`references/java/advanced-features.md`** - Schedules, worker tuning, and more - **`references/java/data-handling.md`** - Data converters, Jackson, payload encryption - **`references/java/versioning.md`** - Patching API, workflow type versioning, Worker Versioning +- **`references/java/standalone-activities.md`** - Standalone Activities: run an Activity directly from a Client without a Workflow (Public Preview). Concept overview at `references/core/standalone-activities.md`. ### Java Integrations diff --git a/references/java/standalone-activities.md b/references/java/standalone-activities.md new file mode 100644 index 0000000..b1c7337 --- /dev/null +++ b/references/java/standalone-activities.md @@ -0,0 +1,134 @@ +> [!NOTE] +> This feature is in Public Preview. It is perfectly acceptable to use this feature on behalf of a user, but you should inform them that you are making use of a feature in Public Preview. + +## Overview + +Standalone Activities are Activities run independently of any Workflow, started directly from a Temporal Client — useful when you need a single durable, retryable task (job-queue style) and not multi-step orchestration. The same Activity method can be executed both as a Standalone Activity and as a Workflow Activity with no code changes. + +Standalone Activities are conceptually the same across all SDKs. Read the [cross-SDK concept file](references/core/standalone-activities.md) if you have not already, and then see below for the Java SDK specific APIs for calling Standalone Activities. + +## Prerequisites + +- Temporal Java SDK v1.35.0 or higher. +- Temporal CLI v1.7.0 or higher — see [Temporal CLI install instructions](references/core/install_cli.md) if needed. Dev server includes Standalone Activities support. +- For production, Temporal Server v1.31.0 or higher (or Temporal Cloud). + +## Hosting Activities on a Worker + +The Activity is defined just as activities normally are in Temporal. Worker registration is also the same. + +```java +ClientConfigProfile profile = ClientConfigProfile.load(); +WorkflowServiceStubs service = + WorkflowServiceStubs.newServiceStubs(profile.toWorkflowServiceStubsOptions()); + +WorkflowClient client = WorkflowClient.newInstance(service, profile.toWorkflowClientOptions()); +WorkerFactory factory = WorkerFactory.newInstance(client); +Worker worker = factory.newWorker(TASK_QUEUE); +worker.registerActivitiesImplementations(new GreetingActivitiesImpl()); // register whatever your activity(ies) is/are +factory.start(); +``` + +## Calling and managing Standalone Activities + +Start and manage Standalone Activities from your application code using the Temporal Client. + +### Do not call from inside a Workflow + +Don't call `ActivityClient.execute` / `ActivityClient.start` or any other Standalone Activity APIs from inside a Workflow Definition — use Workflow-side activity invocation (`Workflow.newActivityStub(...)`) instead. + +### Connect a Client + +The Standalone Activity operations are methods on a connected `ActivityClient`. The examples below assume this `client`. + +```java +ActivityClient client = + ActivityClient.newInstance( + service, + ActivityClientOptions.newBuilder().setNamespace(profile.getNamespace()).build()); +``` + +### Execute (wait for result) + +Use `client.execute(...)` to durably enqueue the Activity, wait for it to run on a Worker, and return the result. `StartActivityOptions` must set `id`, `taskQueue`, and at least one of `startToCloseTimeout` or `scheduleToCloseTimeout`. + +#### With type checking + +Use when activity definitions are available in this language. The typed form takes the Activity interface class and an unbound method reference; the SDK infers the Activity type name and result type at runtime. + +```java +// In practice, use a meaningful business identifier, like customer or transaction identifier +String activityId = UUID.randomUUID().toString(); + +StartActivityOptions options = + StartActivityOptions.newBuilder() + .setId(activityId) + .setTaskQueue(TASK_QUEUE) + .setStartToCloseTimeout(Duration.ofSeconds(10)) + .build(); + +String result = + client.execute( + GreetingActivities.class, + GreetingActivities::composeGreeting, + options, + "Hello", + "World"); +``` + +#### Without type checking + +Use when activity definitions are unavailable in this language (i.e. you can't import them). Call the Activity by its string type name and pass the result class. + +```java +String result = client.execute("ComposeGreeting", String.class, options, "Hello", "World"); +``` + +### Start (do not wait for result) + +Use `client.start(...)` to durably enqueue the Activity and get back an `ActivityHandle` without waiting for completion. This takes the **exact same arguments as `execute`**. + +```java +ActivityHandle handle = client.start(...); +``` + +### Get a handle to an existing Activity execution + +Use `client.getHandle(...)` to attach a typed handle to a previously started Standalone Activity. Passing `null` as the run ID targets the latest run of that Activity ID. + +```java +ActivityHandle handle = client.getHandle("standalone-activity-id", null, String.class); +``` + +### Wait for the result of a handle + +```java +String result = handle.getResult(); +// or, for a non-blocking wait... +CompletableFuture future = handle.getResultAsync(); +``` + +Calling `execute` is equivalent to `start` followed by `getResult()`. + +### List Standalone Activities + +```java +client + .listExecutions("TaskQueue = '" + TASK_QUEUE + "'") // returns a Stream + .forEach( + info -> + System.out.printf( + "ActivityID: %s, Type: %s, Status: %s%n", + info.getActivityId(), info.getActivityType(), info.getStatus())); +``` + +Only Standalone Activity Executions are returned; Activities running inside Workflows are not included. + +### Count Standalone Activities + +Use `client.countExecutions(query)` to count matching executions; this takes the **exact same arguments as `listExecutions`**. + +```java +ActivityExecutionCount resp = client.countExecutions("TaskQueue = '" + TASK_QUEUE + "'"); +System.out.println("Total activities: " + resp.getCount()); +``` diff --git a/references/python/python.md b/references/python/python.md index 640a533..2528e97 100644 --- a/references/python/python.md +++ b/references/python/python.md @@ -180,6 +180,7 @@ See `references/python/testing.md` for info on writing tests. - **`references/python/advanced-features.md`** - Schedules, worker tuning, and more - **`references/python/data-handling.md`** - Data converters, Pydantic, payload encryption - **`references/python/versioning.md`** - Patching API, workflow type versioning, Worker Versioning +- **`references/python/standalone-activities.md`** - Standalone Activities: run an Activity directly from a Client without a Workflow (Public Preview). Concept overview at `references/core/standalone-activities.md`. - **`references/python/determinism-protection.md`** - Python sandbox specifics, forbidden operations, pass-through imports - **`references/python/ai-patterns.md`** - LLM integration, Pydantic data converter, AI workflow patterns - **`references/python/workflow-streams.md`** - Public-Preview `temporalio.contrib.workflow_streams` library: durable, offset-addressed event channel for streaming progress to subscribers. diff --git a/references/python/standalone-activities.md b/references/python/standalone-activities.md new file mode 100644 index 0000000..a7b2710 --- /dev/null +++ b/references/python/standalone-activities.md @@ -0,0 +1,157 @@ +> [!NOTE] +> This feature is in Public Preview. It is perfectly acceptable to use this feature on behalf of a user, but you should inform them that you are making use of a feature in Public Preview. + +## Overview + +Standalone Activities are Activities run independently of any Workflow, started directly from a Temporal Client — useful when you need a single durable, retryable task (job-queue style) and not multi-step orchestration. The same Activity method can be executed both as a Standalone Activity and as a Workflow Activity with no code changes. + +Standalone Activities are conceptually the same across all SDKs. Read the [cross-SDK concept file](references/core/standalone-activities.md) if you have not already, and then see below for the Python SDK specific APIs for calling Standalone Activities. + +## Prerequisites + +- Temporal Python SDK v1.23.0 or higher. +- Temporal CLI v1.7.0 or higher — see [Temporal CLI install instructions](references/core/install_cli.md) if needed. Dev server includes Standalone Activities support. +- For production, Temporal Server v1.31.0 or higher (or Temporal Cloud). + +## Hosting Activities on a Worker + +The Activity is defined just as activities normally are in Temporal. Worker registration is also the same. + +```python +import asyncio +import concurrent.futures + +from temporalio.client import Client +from temporalio.envconfig import ClientConfig +from temporalio.worker import Worker + +from my_activity import compose_greeting + + +async def main(): + connect_config = ClientConfig.load_client_connect_config() + connect_config.setdefault("target_host", "localhost:7233") + client = await Client.connect(**connect_config) + with concurrent.futures.ThreadPoolExecutor(max_workers=100) as activity_executor: + worker = Worker( + client, + task_queue="my-standalone-activity-task-queue", + activities=[compose_greeting], # register whatever your activity(ies) is/are + activity_executor=activity_executor, + ) + await worker.run() + + +if __name__ == "__main__": + asyncio.run(main()) +``` + +## Calling and managing Standalone Activities + +Start and manage Standalone Activities from your application code using the Temporal Client. + +### Do not call from inside a Workflow + +Don't call `client.execute_activity` / `client.start_activity` or any other Standalone Activity APIs from inside a Workflow Definition — use Workflow-side activity invocation (`workflow.execute_activity`) instead. + +### Connect a Client + +The Standalone Activity operations are methods on a connected `Client`. The examples below assume this `client`. + +```python +from temporalio.client import Client +from temporalio.envconfig import ClientConfig + +connect_config = ClientConfig.load_client_connect_config() +connect_config.setdefault("target_host", "localhost:7233") +client = await Client.connect(**connect_config) +``` + +### Execute (wait for result) + +Use `client.execute_activity(...)` to durably enqueue the Activity, wait for it to run on a Worker, and return the result. Required arguments: the activity (first positional), `args=[...]`, `id`, `task_queue`, and a timeout such as `start_to_close_timeout`. + +#### With type checking + +Use when activity definitions are available in this language. Pass the activity function reference; the SDK infers the result type from its signature. + +```python +import uuid +from datetime import timedelta + +# In practice, use a meaningful business identifier, like customer or transaction identifier +activity_id = str(uuid.uuid4()) + +activity_result = await client.execute_activity( + compose_greeting, + args=[ComposeGreetingInput("Hello", "World")], + id=activity_id, + task_queue="my-standalone-activity-task-queue", + start_to_close_timeout=timedelta(seconds=10), +) +``` + +#### Without type checking + +Use when activity definitions are unavailable in this language (i.e. you can't import them). Pass the activity type name as a string; optionally set `result_type` to decode the result. + +```python +from datetime import timedelta + +activity_result = await client.execute_activity( + "compose_greeting", + args=[ComposeGreetingInput("Hello", "World")], + id=activity_id, + task_queue="my-standalone-activity-task-queue", + start_to_close_timeout=timedelta(seconds=10), + result_type=str, +) +``` + +### Start (do not wait for result) + +Use `client.start_activity(...)` to durably enqueue the Activity and get back a handle without waiting for completion. This takes the **exact same arguments as `execute_activity`**. + +```python +activity_handle = await client.start_activity(...) +``` + +### Get a handle to an existing Activity execution + +Use `client.get_activity_handle(...)` to attach a handle to a previously started Standalone Activity. Omitting `run_id` (or passing `None`) targets the latest run of that Activity ID. + +```python +activity_handle = client.get_activity_handle(activity_id="my-standalone-activity-id") +``` + +### Wait for the result of a handle + +```python +result = await activity_handle.result() +``` + +Calling `execute_activity` is equivalent to `start_activity` followed by `await activity_handle.result()`. + +### List Standalone Activities + +```python +activities = client.list_activities( + query="TaskQueue = 'my-standalone-activity-task-queue'", +) # returns an async iterator of ActivityExecution + +async for info in activities: + print(f"ActivityID: {info.activity_id}, Type: {info.activity_type}, Status: {info.status}") +``` + +Only Standalone Activity Executions are returned; Activities running inside Workflows are not included. + +### Count Standalone Activities + +Use `client.count_activities(query=...)` to count matching executions; this takes the **exact same arguments as `list_activities`**. + +```python +resp = await client.count_activities( + query="TaskQueue = 'my-standalone-activity-task-queue'", +) +print("Total activities:", resp.count) +``` diff --git a/references/typescript/standalone-activities.md b/references/typescript/standalone-activities.md new file mode 100644 index 0000000..ee7dbab --- /dev/null +++ b/references/typescript/standalone-activities.md @@ -0,0 +1,147 @@ +> [!NOTE] +> This feature is in Public Preview. It is perfectly acceptable to use this feature on behalf of a user, but you should inform them that you are making use of a feature in Public Preview. + +## Overview + +Standalone Activities are Activities run independently of any Workflow, started directly from a Temporal Client — useful when you need a single durable, retryable task (job-queue style) and not multi-step orchestration. The same Activity method can be executed both as a Standalone Activity and as a Workflow Activity with no code changes. + +Standalone Activities are conceptually the same across all SDKs. Read the [cross-SDK concept file](references/core/standalone-activities.md) if you have not already, and then see below for the TypeScript SDK specific APIs for calling Standalone Activities. + +## Prerequisites + +- Temporal TypeScript SDK v1.17.0 or higher. +- All `@temporalio/*` packages must be pinned to the same version (heads-up — install/upgrade them together). +- Temporal CLI v1.7.0 or higher — see [Temporal CLI install instructions](references/core/install_cli.md) if needed. Dev server includes Standalone Activities support. +- For production, Temporal Server v1.31.0 or higher (or Temporal Cloud). + +## Hosting Activities on a Worker + +The Activity is defined just as activities normally are in Temporal. Worker registration is also the same. + +```typescript +import { NativeConnection, Worker } from '@temporalio/worker'; +import * as activities from './activities'; +import { loadClientConnectConfig } from '@temporalio/envconfig'; + +async function run() { + const config = loadClientConnectConfig(); + const connection = await NativeConnection.connect(config.connectionOptions); + const worker = await Worker.create({ + connection, + taskQueue: 'hello-standalone-activities', + activities, // register whatever your activity(ies) is/are + }); + await worker.run(); +} + +run().catch(console.error); +``` + +## Calling and managing Standalone Activities + +Start and manage Standalone Activities from your application code using the Temporal Client. + +### Do not call from inside a Workflow + +Don't call `client.activity.execute` / `client.activity.start` or any other Standalone Activity APIs from inside a Workflow Definition — use Workflow-side activity invocation (`proxyActivities`) instead. + +### Connect a Client + +The Standalone Activity operations are methods on `client.activity`, where `client` is a connected `Client`. The examples below assume this `client`. + +```typescript +import { Connection, Client } from '@temporalio/client'; +import { loadClientConnectConfig } from '@temporalio/envconfig'; + +const config = loadClientConnectConfig(); +const connection = await Connection.connect(config.connectionOptions); +const client = new Client({ connection }); +``` + +### Execute (wait for result) + +Use `execute` to durably enqueue the Activity, wait for it to run on a Worker, and return the result. The options require `id`, `taskQueue`, and at least one of `startToCloseTimeout` or `scheduleToCloseTimeout`. + +#### With type checking + +Use when activity definitions are available in this language. Call `client.activity.typed()` to obtain a typed Activity Client interface. Calling `typed` does not create a new Client object — it only adjusts the type annotation of the existing Client. + +```typescript +import * as activities from './activities'; +import { nanoid } from 'nanoid'; + +const activitiesClient = client.activity.typed(); + +const activityOptions = { + taskQueue: 'hello-standalone-activities', + startToCloseTimeout: '10s', +}; + +// In practice, use a meaningful business identifier, like customer or transaction identifier +const activityId = nanoid(); + +const result = await activitiesClient.execute('greet', { + ...activityOptions, + id: activityId, + args: ['World'], +}); +``` + +#### Without type checking + +Use when activity definitions are unavailable in this language (i.e. you can't import them). Call `execute` directly on `client.activity`. + +```typescript +const result = await client.activity.execute('greet', { + ...activityOptions, + id: activityId, + args: [1], +}); +``` + +### Start (do not wait for result) + +Use `activitiesClient.start(...)` (or `client.activity.start(...)` on the untyped interface) to durably enqueue the Activity and get back a handle without waiting for completion. This takes the **exact same arguments as `execute`**. + +```typescript +const handle = await activitiesClient.start(...); +``` + +### Get a handle to an existing Activity execution + +Use `client.activity.getHandle(activityId, runId?)` to attach a handle to a previously started Standalone Activity. Omitting `runId` targets the latest run of that Activity ID. `getHandle` is not available on the typed interface, and the optional type argument constrains the result type but isn't verified. + +```typescript +const newHandle = client.activity.getHandle(activityId); +``` + +### Wait for the result of a handle + +```typescript +const result = await handle.result(); +``` + +Calling `execute` is equivalent to `start` followed by `await handle.result()`. + +### List Standalone Activities + +```typescript +const query = 'TaskQueue="hello-standalone-activities"'; + +for await (const a of client.activity.list(query)) { // returns an AsyncIterable + console.log( + `${a.activityId} | ${a.activityRunId} | ${a.activityType} | ${a.status} | ${a.closeTime?.toISOString()}`, + ); +} +``` + +Only Standalone Activity Executions are returned; Activities running inside Workflows are not included. + +### Count Standalone Activities + +Use `client.activity.count(query)` to count matching executions; this takes the **exact same arguments as `list`**. + +```typescript +const { count } = await client.activity.count(query); +console.log(`Total activities: ${count}`); +``` diff --git a/references/typescript/typescript.md b/references/typescript/typescript.md index 96fc089..f426fe8 100644 --- a/references/typescript/typescript.md +++ b/references/typescript/typescript.md @@ -181,4 +181,5 @@ See `references/typescript/testing.md` for info on writing tests. - **`references/typescript/advanced-features.md`** - Schedules, worker tuning, and more - **`references/typescript/data-handling.md`** - Data converters, payload encryption, etc. - **`references/typescript/versioning.md`** - Patching API, workflow type versioning, Worker Versioning +- **`references/typescript/standalone-activities.md`** - Standalone Activities: run an Activity directly from a Client without a Workflow (Public Preview). Concept overview at `references/core/standalone-activities.md`. - **`references/typescript/determinism-protection.md`** - V8 sandbox and bundling From 1c1b6d81952eba93973673dd8932698c7b97cea1 Mon Sep 17 00:00:00 2001 From: "skill-temporal-developer-updater[bot]" <276371939+skill-temporal-developer-updater[bot]@users.noreply.github.com> Date: Tue, 2 Jun 2026 17:19:07 -0400 Subject: [PATCH 62/82] Implement planned topic: 0037-standalone-activities-go (#241) * Finalize draft for 0037-standalone-activities-go * Update Standalone Activities documentation for Go SDK Edits throughout Go standalone activities --------- Co-authored-by: skill-sync[bot] Co-authored-by: Donald Pinckney --- references/core/standalone-activities.md | 4 +- references/go/go.md | 1 + references/go/standalone-activities.md | 195 ++++++++++++++++++ references/python/advanced-features.md | 2 +- references/python/integrations/google-adk.md | 1 - references/python/integrations/langgraph.md | 2 +- references/python/integrations/langsmith.md | 1 - .../python/integrations/openai-agents-sdk.md | 1 - references/python/workflow-streams.md | 1 - references/ruby/gotchas.md | 1 - 10 files changed, 200 insertions(+), 9 deletions(-) create mode 100644 references/go/standalone-activities.md diff --git a/references/core/standalone-activities.md b/references/core/standalone-activities.md index 976b1b3..7731bd9 100644 --- a/references/core/standalone-activities.md +++ b/references/core/standalone-activities.md @@ -3,7 +3,7 @@ # Standalone Activities (Concepts) -This document provides core conceptual explanations of Standalone Activities in Temporal. For language-specific implementation details, see `references/{your_language}/standalone-activities.md` for the language you are working in (Python, TypeScript, Java, .NET). +This document provides core conceptual explanations of Standalone Activities in Temporal. For language-specific implementation details, see `references/{your_language}/standalone-activities.md` for the language you are working in (Python, TypeScript, Java, .NET, Go). ## What is a Standalone Activity? @@ -71,7 +71,7 @@ See below for a quick reference how to call these operations from the CLI rather > [!IMPORTANT] > When using an SDK, these operations are owned by the Temporal Client, and belong **in your non-workflow application code**. It is INVALID to call an activity as a standalone activity from within a workflow: you instead should use standard within-workflow activity calls. -**Currently Supported SDKs: Python, TypeScript, Java, .NET** +**Currently Supported SDKs: Python, TypeScript, Java, .NET, Go** ## Quick CLI Standalone Activity Man Page diff --git a/references/go/go.md b/references/go/go.md index 6c42bed..08c14ee 100644 --- a/references/go/go.md +++ b/references/go/go.md @@ -252,3 +252,4 @@ See `references/go/testing.md` for info on writing tests. - **`references/go/data-handling.md`** - Data converters, payload codecs, encryption - **`references/go/versioning.md`** - Patching API (`workflow.GetVersion`), Worker Versioning - **`references/go/determinism-protection.md`** - Information on **`workflowcheck`** tool to help statically check for determinism issues. +- **`references/go/standalone-activities.md`** - Standalone Activities (Public Preview): run an Activity directly from a Client without a Workflow; see also `references/core/standalone-activities.md` for cross-SDK concepts. diff --git a/references/go/standalone-activities.md b/references/go/standalone-activities.md new file mode 100644 index 0000000..694ff4a --- /dev/null +++ b/references/go/standalone-activities.md @@ -0,0 +1,195 @@ +> [!NOTE] +> This feature is in Public Preview. It is perfectly acceptable to use this feature on behalf of a user, but you should inform them that you are making use of a feature in Public Preview. + +## Overview + +Standalone Activities are Activities run independently of any Workflow, started directly from a Temporal Client — useful when you need a single durable, retryable task (job-queue style) and not multi-step orchestration. The same Activity method can be executed both as a Standalone Activity and as a Workflow Activity with no code changes. + +Standalone Activities are conceptually the same across all SDKs. Read the [cross-SDK concept file](references/core/standalone-activities.md) if you have not already, and then see below for the Go SDK specific APIs for calling Standalone Activities. + +## Prerequisites + +- Temporal Go SDK v1.41.0 or higher. +- Temporal CLI v1.7.0 or higher — see [Temporal CLI install instructions](references/core/install_cli.md) if needed. The Temporal Dev Server has Standalone Activities enabled by default. +- For production, Temporal Server v1.31.0 or higher (or Temporal Cloud). + +## Hosting Activities on a Worker + +The Activity is defined just as activities normally are in Temporal. Worker registration is also the same. + +```go +package main + +import ( + "github.com/temporalio/samples-go/standalone-activity/helloworld" + "go.temporal.io/sdk/client" + "go.temporal.io/sdk/contrib/envconfig" + "go.temporal.io/sdk/worker" + "log" +) + +func main() { + c, err := client.Dial(envconfig.MustLoadDefaultClientOptions()) + if err != nil { + log.Fatalln("Unable to create client", err) + } + defer c.Close() + + w := worker.New(c, "standalone-activity-helloworld", worker.Options{}) + + w.RegisterActivity(helloworld.Activity) + + err = w.Run(worker.InterruptCh()) + if err != nil { + log.Fatalln("Unable to start worker", err) + } +} +``` + +## Calling and managing Standalone Activities + +Start and manage Standalone Activities from your application code using the Temporal `Client`. + +### Do not call from inside a Workflow + +Don't call `client.ExecuteActivity` or any other Standalone Activity APIs from inside a Workflow Definition — use Workflow-side activity invocation (`workflow.ExecuteActivity(ctx, ...)`) instead. + +### Connect a Client + +The Standalone Activity operations are methods on a connected `Client`. The examples below assume this client `c`. + +```go +import ( + "go.temporal.io/sdk/client" + "go.temporal.io/sdk/contrib/envconfig" + "context" +) + +c, err := client.Dial(envconfig.MustLoadDefaultClientOptions()) +if err != nil { + log.Fatalln("Unable to create client", err) +} +defer c.Close() +``` + +### Execute a Standalone Activity + +Use `client.ExecuteActivity(...)` to durably enqueue the Activity. It then returns an `ActivityHandle` immediately — it does not wait for completion. After that, call `handle.Get(ctx, &out)` to wait for the result. There is no separate `Start` function in the Go SDK; `ExecuteActivity` is the only entry point. + +`client.StartActivityOptions` requires `ID`, `TaskQueue`, and at least one of `ScheduleToCloseTimeout` or `StartToCloseTimeout`. + +#### With type checking + +Use when activity definitions are available in this language. Pass the activity function reference. + +Pass the Activity as a function reference: + +```go +activityOptions := client.StartActivityOptions{ + ID: "send-welcome-email:user-42", + TaskQueue: "standalone-activity-helloworld", + ScheduleToCloseTimeout: 10 * time.Second, +} + +handle, err := c.ExecuteActivity(context.Background(), activityOptions, helloworld.Activity, "Temporal") +if err != nil { + log.Fatalln("Unable to execute activity", err) +} + +log.Println("Started", "ActivityID", handle.GetID(), "RunID", handle.GetRunID()) + +var result string +err := handle.Get(context.Background(), &result) +if err != nil { + log.Fatalln("Activity failed", err) +} +log.Println("Activity result:", result) +``` + +#### Without type checking + +Use when activity definitions are unavailable in this language (i.e. you can't import them). Pass the activity type name as a string. + +```go +activityOptions := client.StartActivityOptions{ + ID: "send-welcome-email:user-42", + TaskQueue: "standalone-activity-helloworld", + ScheduleToCloseTimeout: 10 * time.Second, +} + +handle, err := c.ExecuteActivity(context.Background(), activityOptions, "Activity", "Temporal") +if err != nil { + log.Fatalln("Unable to execute activity", err) +} + +log.Println("Started", "ActivityID", handle.GetID(), "RunID", handle.GetRunID()) + +var result string +err := handle.Get(context.Background(), &result) +if err != nil { + log.Fatalln("Activity failed", err) +} +log.Println("Activity result:", result) +``` + +### Get a handle to an existing Activity execution + +Use `client.GetActivityHandle()` to attach a handle to a previously started Standalone Activity. Both `ActivityID` and `RunID` are required. + +```go +handle := c.GetActivityHandle(client.GetActivityHandleOptions{ + ActivityID: "send-welcome-email:user-42", + RunID: "the-run-id", +}) +``` + +### Wait for the result of a handle + +Call `handle.Get(ctx, &out)` to block until the Activity completes and deserialize its result into the provided pointer. If the Activity failed, the failure is returned as an error. + +```go +var result string +err := handle.Get(context.Background(), &result) +if err != nil { + log.Fatalln("Activity failed", err) +} +log.Println("Activity result:", result) +``` + +Calling `ExecuteActivity` and then `handle.Get(ctx, &out)` is the Go equivalent of the synchronous "Execute and wait" pattern that other SDKs offer as a single call. + +### List Standalone Activities + +```go +resp, err := c.ListActivities(context.Background(), client.ListActivitiesOptions{ + Query: "TaskQueue = 'standalone-activity-helloworld'", +}) +if err != nil { + log.Fatalln("Unable to list activities", err) +} + +for info, err := range resp.Results { // a range-over-func iterator that yields `(ActivityExecutionInfo, error)` pairs. + if err != nil { + log.Fatalln("Error iterating activities", err) + } + log.Printf("ActivityID: %s, Type: %s, Status: %v\n", + info.ActivityID, info.ActivityType, info.Status) +} +``` + +Only Standalone Activity Executions are returned; Activities running inside Workflows are not included. + +### Count Standalone Activities + +Use `client.CountActivities()` to count matching executions; this takes the **exact same arguments as `ListActivities`**. + +```go +resp, err := c.CountActivities(context.Background(), client.CountActivitiesOptions{ + Query: "TaskQueue = 'standalone-activity-helloworld'", +}) +if err != nil { + log.Fatalln("Unable to count activities", err) +} + +log.Println("Total activities:", resp.Count) +``` diff --git a/references/python/advanced-features.md b/references/python/advanced-features.md index 6ad8ae8..4cb4ad6 100644 --- a/references/python/advanced-features.md +++ b/references/python/advanced-features.md @@ -134,7 +134,7 @@ client = await Client.connect( - The only field is `resolution_interval_millis: int = 30000` — how often to re-resolve DNS, in milliseconds. - `DnsLoadBalancingConfig.default` is a pre-built instance with the default 30-second interval. -- `dns_load_balancing_config` defaults to 30 seconds if you don't pass anything explicitly. +- `dns_load_balancing_config` defaults to 30 seconds if you don't pass anything explicitly. - Pass `dns_load_balancing_config=None` to disable DNS load balancing entirely. ### Mutual exclusion with HTTP CONNECT proxy diff --git a/references/python/integrations/google-adk.md b/references/python/integrations/google-adk.md index 4d59f4d..3e0e015 100644 --- a/references/python/integrations/google-adk.md +++ b/references/python/integrations/google-adk.md @@ -9,7 +9,6 @@ The integration is built on the Python SDK [Plugin system](https://docs.temporal > [!NOTE] > This feature is in Public Preview. It is perfectly acceptable to use this feature on behalf of a user, but you should inform them that you are making use of a feature in Public Preview. - For general Temporal AI/LLM patterns (retries, rate limits, multi-agent orchestration) see `references/core/ai-patterns.md` and `references/python/ai-patterns.md`. ## Prerequisites diff --git a/references/python/integrations/langgraph.md b/references/python/integrations/langgraph.md index 2675672..4237ca7 100644 --- a/references/python/integrations/langgraph.md +++ b/references/python/integrations/langgraph.md @@ -214,4 +214,4 @@ For LangSmith tracing of LangGraph nodes and Temporal Activities together, use t - `references/python/ai-patterns.md` — Python AI/LLM patterns (Pydantic data converter, LLM Activity design, retry/error classification). - `references/core/ai-patterns.md` — language-agnostic AI/LLM patterns. -- `references/python/integrations/langsmith.md` - Companion LangSmith plugin. +- `references/python/integrations/langsmith.md` - Companion LangSmith plugin. diff --git a/references/python/integrations/langsmith.md b/references/python/integrations/langsmith.md index 98db967..a0ab26a 100644 --- a/references/python/integrations/langsmith.md +++ b/references/python/integrations/langsmith.md @@ -100,7 +100,6 @@ The plugin makes `@traceable` replay-safe in the Workflow sandbox. You do not ne - The plugin injects metadata using `workflow.now()` for timestamps and `workflow.random()` for UUIDs instead of `datetime.now()` and `uuid4()`. - LangSmith HTTP calls run on a background thread pool that does not interfere with deterministic Workflow execution. - ## Context propagation Trace context flows automatically across Client → Workflow → Activity → Child Workflow → Nexus via Temporal headers. Do not pass context manually. diff --git a/references/python/integrations/openai-agents-sdk.md b/references/python/integrations/openai-agents-sdk.md index 3807eb7..8ddf36a 100644 --- a/references/python/integrations/openai-agents-sdk.md +++ b/references/python/integrations/openai-agents-sdk.md @@ -164,7 +164,6 @@ Note that the initial run context comes from the `context=...` argument you pass In addition, since a `@function_tool` runs in the workflow, they can also call Temporal activities or other durable primitives themselves. - **Don't put I/O, system clock, or sources of randomness inside a `@function_tool` body.** Make it an `@activity.defn` and wrap with `activity_as_tool` instead. ### Picking between the two diff --git a/references/python/workflow-streams.md b/references/python/workflow-streams.md index 84ecbd7..b53915b 100644 --- a/references/python/workflow-streams.md +++ b/references/python/workflow-streams.md @@ -11,7 +11,6 @@ Use it for modest fan-out progress streaming: AI-agent runs, order pipelines, mu Only available in the Python SDK today; cross-language is on the roadmap. - ## When to use / not to use - Use it for: updating a UI as an AI agent works; surfacing status from a payment or order pipeline; reporting intermediate results from a data job. diff --git a/references/ruby/gotchas.md b/references/ruby/gotchas.md index c2e962c..e56391d 100644 --- a/references/ruby/gotchas.md +++ b/references/ruby/gotchas.md @@ -59,7 +59,6 @@ require_relative 'activities/my_activity' Transient network errors should be retried. Authentication errors should not be. See `references/ruby/error-handling.md` to understand how to classify errors with `non_retryable: true` and `non_retryable_error_types`. - ## Heartbeating ### Forgetting to Heartbeat Long Activities From 0a15f217c2d567c84ccb2d4c6d0667298384c9fa Mon Sep 17 00:00:00 2001 From: "skill-temporal-developer-updater[bot]" <276371939+skill-temporal-developer-updater[bot]@users.noreply.github.com> Date: Thu, 4 Jun 2026 10:45:54 -0400 Subject: [PATCH 63/82] Implement planned topic: 0009-versioned-continue-as-new (#211) * Finalize draft for 0009-versioned-continue-as-new * Fix non-Go languages * Simplify examples, don't give an antipattern! --------- Co-authored-by: skill-sync[bot] Co-authored-by: Donald Pinckney --- references/core/versioning.md | 46 ++++++++++++++++++++++++++++- references/dotnet/versioning.md | 43 +++++++++++++++++++++++++++ references/go/versioning.md | 41 +++++++++++++++++++++++++ references/java/versioning.md | 42 ++++++++++++++++++++++++++ references/python/versioning.md | 39 ++++++++++++++++++++++++ references/typescript/versioning.md | 40 +++++++++++++++++++++++++ 6 files changed, 250 insertions(+), 1 deletion(-) diff --git a/references/core/versioning.md b/references/core/versioning.md index 3081dcb..06b3663 100644 --- a/references/core/versioning.md +++ b/references/core/versioning.md @@ -136,6 +136,49 @@ Worker v2.0 (Build ID: def456) - Workflows need bug fixes during execution - Still requires patching for version transitions +## Upgrading on Continue-as-New + +> [!NOTE] +> This feature is in Public Preview. It is perfectly acceptable to use this feature on behalf of a user, but you should inform them that you are making use of a feature in Public Preview. + +Long-running Pinned Workflows that use Continue-as-New can upgrade to newer Worker Deployment Versions at the Continue-as-New boundary without patching. + +This pattern is for: + +- Entity Workflows that run for months or years +- Batch processing Workflows that checkpoint with Continue-as-New +- AI agent Workflows with long sleeps waiting for user input + +### How it works + +By default, Pinned Workflows stay on their original Worker Deployment Version even when they Continue-as-New. With the upgrade option enabled: + +1. Each Workflow run remains pinned to its version (no patching needed during a run). +2. The Temporal Server tells the Workflow when a new **Target Version** becomes available — that is, when the Workflow's Worker Deployment gets a new Current or Ramping Version that the Workflow would move to next. +3. When the Workflow performs Continue-as-New with the upgrade option, the new run starts on the Target Version. + +### Detection flag + +Active Workflows detect a Target Version change by checking a per-Workflow flag exposed on `WorkflowInfo` (called `target_worker_deployment_version_changed` in the docs). The flag is refreshed after each Workflow Task completes; check it from code that runs as part of a Workflow Task (for example, before accepting an Update, starting an Activity, or starting a child Workflow). See the per-language `references/{your_language}/versioning.md` for the SDK-specific call. + +### Triggering the new run + +When the flag is set, return a Continue-as-New error with the new run's initial Versioning Behavior set to `AutoUpgrade`. This makes the new run start on the Target Version of its Worker Deployment. The Workflow Type itself retains its Pinned annotation; only the *initial* behavior of the *new* run is overridden so it picks up the Target Version. Once the new run is on the new version, the per-Workflow-type annotation continues to apply on subsequent CaN. + +### Limitations + +- **Lazy moving only — sleeping Workflows do not auto-upgrade.** Send a Signal to wake an idle Workflow so it can check the flag. +- **Interface compatibility is your responsibility.** When continuing as new to a different version, the previous version's Workflow input must be compatible with the new version's Workflow definition. If incompatible, the new run may fail on its first Workflow Task. +- **Pinned Workflows only.** Auto-Upgrade Workflows already move to the Target Version at Workflow Task boundaries; this pattern adds nothing for them. + +### When to use this pattern + +- Workflow Type is Pinned **and** +- Workflow runs longer than your Worker Deployment Version lifetime **and** +- Workflow already uses Continue-as-New to bound Event History size. + +For long-running Workflows that cannot use Continue-as-New (e.g., compliance audits that need full history), use `AUTO_UPGRADE` with patching instead. + ## Choosing an Approach | Scenario | Recommended Approach | @@ -143,7 +186,8 @@ Worker v2.0 (Build ID: def456) | Small change, few running workflows | Patching API | | Major rewrite | Workflow Type Versioning | | Many short workflows, frequent deploys | Worker Versioning (PINNED) | -| Long-running workflows needing updates | Worker Versioning (AUTO_UPGRADE) + Patching | +| Long-running workflows, uses Continue-as-New | Worker Versioning (PINNED) + upgrade on Continue-as-New | +| Long-running workflows, no Continue-as-New | Worker Versioning (AUTO_UPGRADE) + Patching | | Quick fix, can wait for completion | Wait for workflows to complete | ## Best Practices diff --git a/references/dotnet/versioning.md b/references/dotnet/versioning.md index 6371926..8e4cd84 100644 --- a/references/dotnet/versioning.md +++ b/references/dotnet/versioning.md @@ -296,6 +296,49 @@ temporal workflow list --query \ 'TemporalWorkerDeploymentVersion = "my-service:v1.0.0" AND ExecutionStatus = "Running"' ``` +## Upgrading on Continue-as-New + +> [!NOTE] +> This feature is in Public Preview. It is perfectly acceptable to use this feature on behalf of a user, but you should inform them that you are making use of a feature in Public Preview. + +For long-running Pinned Workflows that use Continue-as-New, detect a new Target Worker Deployment Version on `Workflow.TargetWorkerDeploymentVersionChanged` and continue-as-new with `InitialVersioningBehavior.AutoUpgrade` so the new run starts on the Target Version. See `references/core/versioning.md` for the conceptual model. + +### Detecting the Target Version change + +`Workflow.TargetWorkerDeploymentVersionChanged` is `true` when a new Current or Ramping Version is available for this Workflow's Worker Deployment. The flag is refreshed after each Workflow Task completes. + +Check the flag from code that runs as part of a Workflow Task — for example, before accepting an Update, starting an Activity, or starting a child Workflow. + +### Continue-as-new with upgrade + +When the flag is set, throw the exception from `Workflow.CreateContinueAsNewException`, passing a `ContinueAsNewOptions` whose `InitialVersioningBehavior` is `AutoUpgrade`, so the new run starts on the Target Version of its Worker Deployment. + +```csharp +using Temporalio.Common; +using Temporalio.Workflows; + +// At a natural Workflow Task boundary, e.g. before accepting Updates, +// starting Activities, starting child Workflows, etc.: +if (Workflow.TargetWorkerDeploymentVersionChanged) +{ + throw Workflow.CreateContinueAsNewException( + (MyWorkflow wf) => wf.RunAsync(nextInput), + new ContinueAsNewOptions + { + InitialVersioningBehavior = InitialVersioningBehavior.AutoUpgrade, + }); +} +``` + +> [!IMPORTANT] +> Don't busy-poll the flag on a timer. Check it at a natural Workflow Task boundary — before accepting Updates, starting Activities, starting child Workflows, etc. For idle Workflows, send a Signal to wake them so they can check it (see Limitations). + +### Limitations + +- **Lazy moving only — idle Workflows do not upgrade.** Send a Signal to wake an idle Workflow so it can check `TargetWorkerDeploymentVersionChanged`. +- **Workflow input must remain compatible across versions.** The new version's Workflow definition must accept the previous version's input; otherwise the new run may fail on its first Workflow Task. +- **Pinned Workflow Types only.** Auto-Upgrade Workflows move at Workflow Task boundaries already; the upgrade-on-CaN pattern adds nothing for them. + ## Best Practices 1. **Check for open executions** before removing old code paths diff --git a/references/go/versioning.md b/references/go/versioning.md index c8f7280..06f2ff4 100644 --- a/references/go/versioning.md +++ b/references/go/versioning.md @@ -226,6 +226,47 @@ temporal workflow list --query \ 'TemporalWorkerDeploymentVersion = "my-service:v1.0.0" AND ExecutionStatus = "Running"' ``` +## Upgrading on Continue-as-New + +> [!NOTE] +> This feature is in Public Preview. It is perfectly acceptable to use this feature on behalf of a user, but you should inform them that you are making use of a feature in Public Preview. + +For long-running Pinned Workflows that use Continue-as-New, detect a new Target Worker Deployment Version on `WorkflowInfo` and continue-as-new with `ContinueAsNewVersioningBehaviorAutoUpgrade` so the new run starts on the Target Version. See `references/core/versioning.md` for the conceptual model. + +### Detecting the Target Version change + +`workflow.GetInfo(ctx).GetTargetWorkerDeploymentVersionChanged()` returns `true` when a new Current or Ramping Version is available for this Workflow's Worker Deployment. The flag is refreshed after each Workflow Task completes. + +Check the flag from code that runs as part of a Workflow Task — for example, before accepting an Update, starting an Activity, or starting a child Workflow. + +### Continue-as-new with upgrade + +When the flag is set, return `workflow.NewContinueAsNewErrorWithOptions` with `InitialVersioningBehavior: workflow.ContinueAsNewVersioningBehaviorAutoUpgrade` so the new run starts on the Target Version of its Worker Deployment. + +```go +// At a natural Workflow Task boundary, e.g. before accepting Updates, +// starting Activities, starting child Workflows, etc.: +if workflow.GetInfo(ctx).GetTargetWorkerDeploymentVersionChanged() { + return "", workflow.NewContinueAsNewErrorWithOptions( + ctx, + workflow.ContinueAsNewErrorOptions{ + InitialVersioningBehavior: workflow.ContinueAsNewVersioningBehaviorAutoUpgrade, + }, + "ContinueAsNewWithVersionUpgrade", + nextInput, + ) +} +``` + +> [!IMPORTANT] +> Don't busy-poll the flag on a timer. Check it at a natural Workflow Task boundary — before accepting Updates, starting Activities, starting child Workflows, etc. For idle Workflows, send a Signal to wake them so they can check it (see Limitations). + +### Limitations + +- **Lazy moving only — idle Workflows do not upgrade.** Send a Signal to wake an idle Workflow so it can check `GetTargetWorkerDeploymentVersionChanged`. +- **Workflow input must remain compatible across versions.** The new version's Workflow definition must accept the previous version's input; otherwise the new run may fail on its first Workflow Task. +- **Pinned Workflow Types only.** Auto-Upgrade Workflows move at Workflow Task boundaries already; the upgrade-on-CaN pattern adds nothing for them. + ## Best Practices 1. **Keep GetVersion calls** even when only a single branch remains -- it guards against stale replays and simplifies future changes diff --git a/references/java/versioning.md b/references/java/versioning.md index 0e520f2..d138e98 100644 --- a/references/java/versioning.md +++ b/references/java/versioning.md @@ -271,6 +271,48 @@ temporal workflow count --query \ 'TemporalWorkerDeploymentVersion = "order-service:v1.0.0" AND ExecutionStatus = "Running"' ``` +## Upgrading on Continue-as-New + +> [!NOTE] +> This feature is in Public Preview. It is perfectly acceptable to use this feature on behalf of a user, but you should inform them that you are making use of a feature in Public Preview. + +For long-running Pinned Workflows that use Continue-as-New, detect a new Target Worker Deployment Version on `Workflow.getInfo()` and continue-as-new with `InitialVersioningBehavior.AUTO_UPGRADE` so the new run starts on the Target Version. See `references/core/versioning.md` for the conceptual model. + +### Detecting the Target Version change + +`Workflow.getInfo().isTargetWorkerDeploymentVersionChanged()` returns `true` when a new Current or Ramping Version is available for this Workflow's Worker Deployment. The flag is refreshed after each Workflow Task completes. + +Check the flag from code that runs as part of a Workflow Task — for example, before accepting an Update, starting an Activity, or starting a child Workflow. + +### Continue-as-new with upgrade + +When the flag is set, call `Workflow.continueAsNew` with a `ContinueAsNewOptions` whose `InitialVersioningBehavior` is `AUTO_UPGRADE` so the new run starts on the Target Version of its Worker Deployment. + +```java +import io.temporal.common.InitialVersioningBehavior; +import io.temporal.workflow.ContinueAsNewOptions; +import io.temporal.workflow.Workflow; + +// At a natural Workflow Task boundary, e.g. before accepting Updates, +// starting Activities, starting child Workflows, etc.: +if (Workflow.getInfo().isTargetWorkerDeploymentVersionChanged()) { + Workflow.continueAsNew( + ContinueAsNewOptions.newBuilder() + .setInitialVersioningBehavior(InitialVersioningBehavior.AUTO_UPGRADE) + .build(), + nextInput); +} +``` + +> [!IMPORTANT] +> Don't busy-poll the flag on a timer. Check it at a natural Workflow Task boundary — before accepting Updates, starting Activities, starting child Workflows, etc. For idle Workflows, send a Signal to wake them so they can check it (see Limitations). + +### Limitations + +- **Lazy moving only — idle Workflows do not upgrade.** Send a Signal to wake an idle Workflow so it can check `isTargetWorkerDeploymentVersionChanged`. +- **Workflow input must remain compatible across versions.** The new version's Workflow definition must accept the previous version's input; otherwise the new run may fail on its first Workflow Task. +- **Pinned Workflow Types only.** Auto-Upgrade Workflows move at Workflow Task boundaries already; the upgrade-on-CaN pattern adds nothing for them. + ## Best Practices 1. **Check for open executions** before removing old code paths diff --git a/references/python/versioning.md b/references/python/versioning.md index c1ad39a..6616c35 100644 --- a/references/python/versioning.md +++ b/references/python/versioning.md @@ -322,6 +322,45 @@ temporal workflow list --query \ 'TemporalWorkerDeploymentVersion = "my-service:v1.0.0" AND ExecutionStatus = "Running"' ``` +## Upgrading on Continue-as-New + +> [!NOTE] +> This feature is in Public Preview. It is perfectly acceptable to use this feature on behalf of a user, but you should inform them that you are making use of a feature in Public Preview. + +For long-running Pinned Workflows that use Continue-as-New, detect a new Target Worker Deployment Version on `workflow.info()` and continue-as-new with `ContinueAsNewVersioningBehavior.AUTO_UPGRADE` so the new run starts on the Target Version. See `references/core/versioning.md` for the conceptual model. + +### Detecting the Target Version change + +`workflow.info().is_target_worker_deployment_version_changed()` returns `True` when a new Current or Ramping Version is available for this Workflow's Worker Deployment. The flag is refreshed after each Workflow Task completes. + +Check the flag from code that runs as part of a Workflow Task — for example, before accepting an Update, starting an Activity, or starting a child Workflow. + +### Continue-as-new with upgrade + +When the flag is set, call `workflow.continue_as_new` with `initial_versioning_behavior=ContinueAsNewVersioningBehavior.AUTO_UPGRADE` so the new run starts on the Target Version of its Worker Deployment. + +```python +from temporalio import workflow +from temporalio.workflow import ContinueAsNewVersioningBehavior + +# At a natural Workflow Task boundary, e.g. before accepting Updates, +# starting Activities, starting child Workflows, etc.: +if workflow.info().is_target_worker_deployment_version_changed(): + workflow.continue_as_new( + next_input, + initial_versioning_behavior=ContinueAsNewVersioningBehavior.AUTO_UPGRADE, + ) +``` + +> [!IMPORTANT] +> Don't busy-poll the flag on a timer. Check it at a natural Workflow Task boundary — before accepting Updates, starting Activities, starting child Workflows, etc. For idle Workflows, send a Signal to wake them so they can check it (see Limitations). + +### Limitations + +- **Lazy moving only — idle Workflows do not upgrade.** Send a Signal to wake an idle Workflow so it can check `is_target_worker_deployment_version_changed`. +- **Workflow input must remain compatible across versions.** The new version's Workflow definition must accept the previous version's input; otherwise the new run may fail on its first Workflow Task. +- **Pinned Workflow Types only.** Auto-Upgrade Workflows move at Workflow Task boundaries already; the upgrade-on-CaN pattern adds nothing for them. + ## Best Practices 1. **Check for open executions** before removing old code paths diff --git a/references/typescript/versioning.md b/references/typescript/versioning.md index b4b8e19..6f56bb5 100644 --- a/references/typescript/versioning.md +++ b/references/typescript/versioning.md @@ -204,6 +204,46 @@ Worker Versioning is best suited for: For long-running Workflows, consider combining Worker Versioning with the Patching API, or use Continue-as-New to move Workflows to newer versions. +## Upgrading on Continue-as-New + +> [!NOTE] +> This feature is in Public Preview. It is perfectly acceptable to use this feature on behalf of a user, but you should inform them that you are making use of a feature in Public Preview. + +For long-running Pinned Workflows that use Continue-as-New, detect a new Target Worker Deployment Version on `workflowInfo()` and continue-as-new with `InitialVersioningBehavior.AUTO_UPGRADE` so the new run starts on the Target Version. See `references/core/versioning.md` for the conceptual model. + +### Detecting the Target Version change + +`workflowInfo().targetWorkerDeploymentVersionChanged` is `true` when a new Current or Ramping Version is available for this Workflow's Worker Deployment. The flag is refreshed after each Workflow Task completes. + +Check the flag from code that runs as part of a Workflow Task — for example, before accepting an Update, starting an Activity, or starting a child Workflow. + +### Continue-as-new with upgrade + +When the flag is set, build the Continue-as-New function with `makeContinueAsNewFunc`, passing `initialVersioningBehavior: InitialVersioningBehavior.AUTO_UPGRADE`, so the new run starts on the Target Version of its Worker Deployment. + +```ts +import * as wf from '@temporalio/workflow'; +import { InitialVersioningBehavior } from '@temporalio/common'; + +// At a natural Workflow Task boundary, e.g. before accepting Updates, +// starting Activities, starting child Workflows, etc.: +if (wf.workflowInfo().targetWorkerDeploymentVersionChanged) { + const continueAsNew = wf.makeContinueAsNewFunc({ + initialVersioningBehavior: InitialVersioningBehavior.AUTO_UPGRADE, + }); + await continueAsNew(nextInput); +} +``` + +> [!IMPORTANT] +> Don't busy-poll the flag on a timer. Check it at a natural Workflow Task boundary — before accepting Updates, starting Activities, starting child Workflows, etc. For idle Workflows, send a Signal to wake them so they can check it (see Limitations). + +### Limitations + +- **Lazy moving only — idle Workflows do not upgrade.** Send a Signal to wake an idle Workflow so it can check `targetWorkerDeploymentVersionChanged`. +- **Workflow input must remain compatible across versions.** The new version's Workflow definition must accept the previous version's input; otherwise the new run may fail on its first Workflow Task. +- **Pinned Workflow Types only.** Auto-Upgrade Workflows move at Workflow Task boundaries already; the upgrade-on-CaN pattern adds nothing for them. + ## Best Practices 1. Use descriptive `patchId` names that explain the change From 8cf8a5ee9dd5da8c5fc483622962ff1708bc35da Mon Sep 17 00:00:00 2001 From: Will <32029716+wcygan@users.noreply.github.com> Date: Fri, 5 Jun 2026 09:05:09 -0500 Subject: [PATCH 64/82] feat: add Temporal Rust SDK references (#235) * feat(rust): add Temporal Rust SDK references * remove cruft * official references link * deslop * deslop v2 * docs(rust): mark Rust as Public Preview in SKILL.md reference list Addresses review comment on SKILL.md:62 (donald-pinckney). * docs(rust): label Rust as Public Preview in README support list Addresses review comment on README.md:43 (donald-pinckney). * docs(rust): add Public Preview NOTE callout to rust.md Uses the reviewer's exact wording. Addresses review comment on references/rust/rust.md:1 (donald-pinckney). * docs(rust): point dependency guidance to official Rust SDK Quickstart Drops the pinned-version caveat now that there are no crate versions in the file. Addresses review comment on references/rust/rust.md:22 (chris-olszewski). * docs(rust): drop Skipping Activity timeouts pitfall The Rust SDK always applies a to_close Activity timeout, so the pitfall does not apply. Addresses review comment on references/rust/rust.md:171 (chris-olszewski). * docs(rust): link directly to sdk-rust examples directory The examples are not at the repo top, so link the explicit path. Addresses review comment on references/rust/rust.md:177 (chris-olszewski). --- README.md | 3 +- SKILL.md | 5 +- references/core/determinism.md | 1 + references/rust/rust.md | 179 +++++++++++++++++++++++++++++++++ 4 files changed, 185 insertions(+), 3 deletions(-) create mode 100644 references/rust/rust.md diff --git a/README.md b/README.md index b91d12a..8d0302c 100644 --- a/README.md +++ b/README.md @@ -33,12 +33,13 @@ If you prefer to install the skill directly without the plugin wrapper: Appropriately adjust the installation directory based on your coding agent. -## Currently Supported Temporal SDK Langages +## Currently Supported Temporal SDK Languages - [x] Python ✅ - [x] TypeScript ✅ - [x] Go ✅ - [x] Java ✅ - [x] .NET ✅ +- [x] Rust (Public Preview) - [x] Ruby ✅ - [ ] PHP 🚧 ([PR](https://github.com/temporalio/skill-temporal-developer/pull/40)) diff --git a/SKILL.md b/SKILL.md index 627ef2e..603ba4c 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,6 +1,6 @@ --- name: temporal-developer -description: Develop, debug, and manage Temporal applications across Python, TypeScript, Go, Java, .NET and Ruby. Use when the user is building workflows, activities, or workers with a Temporal SDK, debugging issues like non-determinism errors, stuck workflows, or activity retries, using Temporal CLI, Temporal Server, or Temporal Cloud, or working with durable execution concepts like signals, queries, heartbeats, versioning, continue-as-new, child workflows, or saga patterns. Also use when the user mentions "run a Temporal workflow from the CLI", "start a dev server", "run temporal server start-dev", "temporal workflow start", "temporal workflow execute", "temporal workflow signal", "temporal workflow query", "temporal workflow update". +description: Develop, debug, and manage Temporal applications across Python, TypeScript, Go, Java, .NET, Ruby, and Rust. Use when the user is building workflows, activities, or workers with a Temporal SDK, debugging issues like non-determinism errors, stuck workflows, or activity retries, using Temporal CLI, Temporal Server, or Temporal Cloud, or working with durable execution concepts like signals, queries, heartbeats, versioning, continue-as-new, child workflows, or saga patterns. Also use when the user mentions "run a Temporal workflow from the CLI", "start a dev server", "run temporal server start-dev", "temporal workflow start", "temporal workflow execute", "temporal workflow signal", "temporal workflow query", "temporal workflow update". version: 0.5.0 --- @@ -8,7 +8,7 @@ version: 0.5.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, Go, Java, .NET, and Ruby. +Temporal is a durable execution platform that makes workflows survive failures automatically. This skill provides guidance for building Temporal applications in Python, TypeScript, Go, Java, .NET, Ruby, and Rust. ## Core Architecture @@ -59,6 +59,7 @@ Check if `temporal` CLI is installed. If not, follow the instructions at `refere - Java -> read `references/java/java.md` - .NET (C#) -> read `references/dotnet/dotnet.md` - Ruby -> read `references/ruby/ruby.md` + - Rust -> read `references/rust/rust.md` (in Public Preview) 2. Second, read appropriate `core` and language-specific references for the task at hand. ## Primary References diff --git a/references/core/determinism.md b/references/core/determinism.md index d24f868..751046f 100644 --- a/references/core/determinism.md +++ b/references/core/determinism.md @@ -90,6 +90,7 @@ Each Temporal SDK language provides a different level of protection against non- - 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. - Ruby: The Ruby SDK uses Illegal Call Tracing (via `TracePoint`) to detect forbidden method calls at runtime on the workflow fiber, combined with a Durable Fiber Scheduler that makes fiber operations deterministic. +- Rust: The Rust SDK has runtime nondeterminism detection for external async wake sources in Workflow code. Keep it enabled, use SDK primitives such as `ctx.timer()` and `temporalio_sdk::workflows::select!`, and still avoid synchronous nondeterminism by convention. 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. diff --git a/references/rust/rust.md b/references/rust/rust.md new file mode 100644 index 0000000..f94c59e --- /dev/null +++ b/references/rust/rust.md @@ -0,0 +1,179 @@ +# Temporal Rust SDK Reference + +> [!NOTE] +> This feature is in Public Preview. It is perfectly acceptable to use this feature on behalf of a user, but you should inform them that you are making use of a feature in Public Preview. + +## Overview + +The Temporal Rust SDK (`temporalio-sdk`) provides native Rust APIs for Workflows, Activities, Workers, and Clients. The SDK is in Public Preview and under active development, so verify exact crate versions and method names against the official docs before giving precise implementation guidance. + +Rust Workflows are structs with macro-decorated methods. Activities are async methods on an `impl` block. Workers register Workflow and Activity types, then poll a Task Queue. + +## Official References + +- [Rust SDK developer guide](https://docs.temporal.io/develop/rust) - Rust documentation hub. +- [Rust SDK Quickstart](https://docs.temporal.io/develop/rust/quickstart) - setup, dependencies, local dev server, and a complete hello-world example. +- [Workflow basics](https://docs.temporal.io/develop/rust/workflows/basics) - Workflow structs, `#[run]`, optional `#[init]`, and message handlers. +- [Activity basics](https://docs.temporal.io/develop/rust/activities/basics) - Activity macros, parameters, and Activity boundaries. +- [Worker processes](https://docs.temporal.io/develop/rust/workers/worker-process) - Worker setup, registration, and Task Queue polling. +- [Temporal Client](https://docs.temporal.io/develop/rust/client/temporal-client) - connecting to Temporal Service, starting Workflows, and fetching results. +- [docs.rs temporalio-sdk](https://docs.rs/temporalio-sdk/latest/temporalio_sdk/) - generated Rust API documentation. +- [sdk-rust examples](https://github.com/temporalio/sdk-rust/tree/main/crates/sdk/examples) - current example programs from the SDK repository. + +## Quick Demo of Temporal + +**Add dependencies:** Follow the [official Rust SDK Quickstart](https://docs.temporal.io/develop/rust/quickstart) for the current `Cargo.toml` dependencies. + +**src/activities.rs** - Activity definition: + +```rust +use temporalio_macros::activities; +use temporalio_sdk::activities::{ActivityContext, ActivityError}; + +pub struct MyActivities; + +#[activities] +impl MyActivities { + #[activity] + pub async fn greet(_ctx: ActivityContext, name: String) -> Result { + Ok(format!("Hello, {}!", name)) + } +} +``` + +**src/workflows.rs** - Workflow definition: + +```rust +use temporalio_macros::{workflow, workflow_methods}; +use temporalio_sdk::{ActivityOptions, WorkflowContext, WorkflowContextView, WorkflowResult}; +use std::time::Duration; + +use crate::activities::MyActivities; + +#[workflow] +pub struct GreetingWorkflow { + name: String, +} + +#[workflow_methods] +impl GreetingWorkflow { + #[init] + fn new(_ctx: &WorkflowContextView, name: String) -> Self { + Self { name } + } + + #[run] + pub async fn run(ctx: &mut WorkflowContext) -> WorkflowResult { + let name = ctx.state(|s| s.name.clone()); + + // Execute an activity + let greeting = ctx.start_activity( + MyActivities::greet, + name, + ActivityOptions::start_to_close_timeout(Duration::from_secs(30)), + ).await?; + + println!("{}", greeting); + Ok(greeting) + } +} +``` + +**src/main.rs** - Worker setup: + +```rust +use temporalio_client::{Client, ClientOptions, Connection}; +use temporalio_common::envconfig::LoadClientConfigProfileOptions; +use temporalio_sdk::{Worker, WorkerOptions}; +use temporalio_sdk_core::{CoreRuntime, RuntimeOptions}; + +mod workflows; +mod activities; + +use crate::workflows::GreetingWorkflow; +use crate::activities::MyActivities; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let runtime = CoreRuntime::new_assume_tokio(RuntimeOptions::builder().build()?)?; + + // Set up client connection options, loading from config if available + let (connection_options, client_options) = ClientOptions::load_from_config( + LoadClientConfigProfileOptions::default(), + )?; + + let connection = Connection::connect(connection_options).await?; + let client = Client::new(connection, client_options)?; + + let worker_options = WorkerOptions::new("my-task-queue") + .register_activities(MyActivities) + .register_workflow::() + .build(); + + Worker::new(&runtime, client, worker_options)?.run().await?; + + Ok(()) +} +``` + +**Run locally:** + +1. Start the dev server with `temporal server start-dev`. +2. Run the Worker with `cargo run`. +3. Start a Workflow Execution with the CLI: + +```sh +temporal workflow start \ + --type GreetingWorkflow \ + --task-queue my-task-queue \ + --input '"Ziggy"' +``` + +## Key Concepts + +### Workflow Definition + +- Define a struct and annotate it with `#[workflow]`. +- Put Workflow methods in a `#[workflow_methods]` impl block. +- Use `#[run]` for the main Workflow logic, and optionally use `#[init]`, `#[signal]`, `#[query]`, and `#[update]`. + +### Activity Definition + +- Put Activity methods in a `#[activities]` impl block. +- Annotate each Activity method with `#[activity]`. +- Activities can perform I/O, call services, use system time, and do other non-deterministic work. + +### Worker Setup + +- A Worker registers Workflow and Activity types, then polls one Task Queue. +- Workers polling the same Task Queue should register the same Workflow and Activity types. +- Keep Worker runtime, client, config, secrets, and logging setup outside Workflow code. + +### Temporal Client + +- Use the Rust client outside Workflow code to start Workflows and send Signals, Queries, and Updates. +- Do not create or use a Temporal Client inside Workflow code. +- A Client can be used inside an Activity when the Activity needs to interact with Temporal Service. + +## File Organization Best Practice + +Keep Workflow definitions, Activity implementations, Worker setup, and starter/client code separate. This makes the determinism boundary easy to inspect. + +```text +my_temporal_app/ +|-- src/ +| |-- activities.rs # Activity implementations and side effects +| |-- workflows.rs # Workflow definitions and orchestration +| `-- main.rs # Worker process in the Quickstart +`-- Cargo.toml +``` + +## Common Pitfalls + +1. **Calling I/O from a Workflow** - Put network, database, filesystem, process calls, and other side effects in Activities. +2. **Mixing Worker and Workflow concerns** - Runtime setup, clients, secrets, environment config, and external logging sinks belong outside Workflow code. +3. **Assuming APIs are stable** - The Rust SDK is Public Preview, so check official docs, docs.rs, and SDK examples before naming exact APIs. + +## Rust-Specific References Status + +Rust-specific local reference files do not exist yet. For deeper Rust SDK details, use the official Rust SDK docs, docs.rs, and [`sdk-rust` examples](https://github.com/temporalio/sdk-rust/tree/main/crates/sdk/examples). For SDK-neutral Temporal concepts, use the core references under `references/core/`. From 3973e73202f72cb6b157b827f270c04f96ad8c1f Mon Sep 17 00:00:00 2001 From: "skill-temporal-developer-updater[bot]" <276371939+skill-temporal-developer-updater[bot]@users.noreply.github.com> Date: Fri, 5 Jun 2026 16:41:31 -0400 Subject: [PATCH 65/82] Implement planned topic: 0022-opentelemetry-plugins (#243) * Finalize draft for 0022-opentelemetry-plugins * Remove legacy TracingInterceptor content from OpenTelemetry docs Co-Authored-By: Claude Opus 4.8 (1M context) * Remove Nexus content from OpenTelemetry docs Co-Authored-By: Claude Opus 4.8 (1M context) * Tie OpenTelemetry tracing into the observability references Both observability.md files advertised "tracing" but had no tracing section and never linked to the OTel integration docs. Add a concise Distributed Tracing (OpenTelemetry) section to each, surface the trace/log/metric correlation, and cross-link so the OTel <-> observability relationship is bidirectional. Deep API stays canonical in integrations/opentelemetry.md. Co-Authored-By: Claude Opus 4.8 (1M context) * Trim TypeScript OpenTelemetry doc to lean style Mirror the lean, example-driven style now used in the Python OTel doc: fold the Public API / Constructor options / Span names tables into inline comments and prose, compress propagator customization to a one-liner, and keep the log/metric correlation tie-in. Update the observability.md pointer so it no longer promises tables that were removed. Co-Authored-By: Claude Opus 4.8 (1M context) * Finalize Python file * Simplify OpenTelemetry rows in integrations catalog Reduce both OTel rows to a purpose-only description, dropping mechanism detail (plugin names, interceptors, sinks, propagation specifics). Co-Authored-By: Claude Opus 4.8 (1M context) * Finalize observability files other than code snippets * finalize python observaibility file * Finalize TS observability file * cut correlation * Move TypeScript OpenTelemetry docs to a separate PR The TypeScript material needs more work, so split it out (now on branch feat/ts-otel). This leaves PR #243 scoped to the Python OpenTelemetry integration only: removes the TS integration reference, reverts the TS observability tracing section, and drops the TS row from the catalog. Co-Authored-By: Claude Opus 4.8 (1M context) * Apply suggestions from code review Co-authored-by: Donald Pinckney * Apply suggestion from @donald-pinckney * Apply suggestion from @donald-pinckney * Consolidate Python OpenTelemetry docs into observability Remove the standalone references/python/integrations/opentelemetry.md file and fold its unique content (Common mistakes, workflow custom-span example) into the Distributed Tracing section of observability.md. Repoint the integrations catalog row at the observability section. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: skill-sync[bot] Co-authored-by: Donald Pinckney Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: Donald Pinckney --- references/integrations.md | 1 + references/python/observability.md | 50 +++++++++++++++++++++++++++++- 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/references/integrations.md b/references/integrations.md index 71b3169..36196a8 100644 --- a/references/integrations.md +++ b/references/integrations.md @@ -19,3 +19,4 @@ Temporal ships and supports a growing set of integrations with third-party frame | LangSmith tracing (`temporalio.contrib.langsmith`) | Python | Experimental Temporal Plugin that propagates LangSmith trace context across Worker boundaries; lets `@traceable` run inside Workflows and Activities | `references/python/integrations/langsmith.md` | `references/python/ai-patterns.md`, `references/core/ai-patterns.md` | | LangGraph (`temporalio.contrib.langgraph`, Pre-release) | Python | Runs LangGraph Graph-API and Functional-API code as Temporal Workflows - nodes/tasks can execute as either in-workflow or as Activities | `references/python/integrations/langgraph.md` | `references/python/ai-patterns.md`, `references/core/ai-patterns.md` | | Google ADK (`temporalio[google-adk]`) | Python | Durable Google ADK agents: model calls run through `TemporalModel`-wrapped Activities, tools via `activity_tool`, MCP toolsets via `TemporalMcpToolSet` | `references/python/integrations/google-adk.md` | `references/python/ai-patterns.md`, `references/core/ai-patterns.md` | +| OpenTelemetry (`temporalio[opentelemetry]`) | Python | Distributed tracing for Temporal apps with OpenTelemetry | `references/python/observability.md` (Distributed Tracing section) | | diff --git a/references/python/observability.md b/references/python/observability.md index 0130d89..5ad5e18 100644 --- a/references/python/observability.md +++ b/references/python/observability.md @@ -2,7 +2,9 @@ ## Overview -The Python SDK provides comprehensive observability through logging, metrics, tracing, and visibility (Search Attributes). +The Python SDK provides comprehensive observability through logging, metrics, tracing (OpenTelemetry), and visibility (Search Attributes). + +These pillars are complementary: **logging** (below) captures discrete events, **metrics** capture aggregate health, **tracing** stitches a single request across Client/Workflow/Activity boundaries, and **Search Attributes** make executions queryable. ## Logging @@ -94,6 +96,51 @@ Runtime.set_default(runtime, error_if_already_set=True) - `temporal_activity_execution_latency` - Activity execution time - `temporal_workflow_task_replay_latency` - Replay duration +## Distributed Tracing (OpenTelemetry) + +> [!NOTE] +> This feature is in Public Preview. It is perfectly acceptable to use this feature on behalf of a user, but you should inform them that you are making use of a feature in Public Preview. + +OpenTelemetry is the supported way to add distributed tracing to Temporal applications. The `OpenTelemetryPlugin` (from `temporalio.contrib.opentelemetry`, installed via the `temporalio[opentelemetry]` extra) propagates W3C TraceContext + Baggage through Temporal headers across Client, Workflow, Activity (including Standalone), and Child Workflow boundaries, so one trace follows a request through your whole execution — with replay-safe, accurate span durations. + +```python +import opentelemetry.trace +from opentelemetry.sdk.trace.export import ConsoleSpanExporter, SimpleSpanProcessor +from temporalio.client import Client +from temporalio.contrib.opentelemetry import OpenTelemetryPlugin, create_tracer_provider + +provider = create_tracer_provider() +provider.add_span_processor(SimpleSpanProcessor(ConsoleSpanExporter())) # attach your span processors as normal for OTel +opentelemetry.trace.set_tracer_provider(provider) + +client = await Client.connect("localhost:7233", plugins=[OpenTelemetryPlugin()]) +``` + +Workers created from this Client inherit the plugin automatically. Inside a Workflow you then use standard OpenTelemetry APIs (`get_tracer(...).start_as_current_span(...)`); pass `OpenTelemetryPlugin(add_temporal_spans=True)` to also emit `StartWorkflow` / `RunWorkflow` / `StartActivity` / `RunActivity` spans automatically alongside the SDK metrics above. + +```python +from datetime import timedelta +from opentelemetry.trace import get_tracer +from temporalio import workflow + +@workflow.defn +class MyWorkflow: + @workflow.run + async def run(self) -> None: + tracer = get_tracer(__name__) + with tracer.start_as_current_span("workflow-operation"): + await workflow.execute_activity( + my_activity, + start_to_close_timeout=timedelta(seconds=30), + ) +``` + +**Common mistakes:** + +- **Registering the same plugin on both Client and Worker.** Register on the Client only; Workers inherit. +- **Calling `Client.connect` before `opentelemetry.trace.set_tracer_provider(provider)`.** `OpenTelemetryPlugin` raises an exception unless the global tracer provider is already set. +- **Building a plain `opentelemetry.sdk.trace.TracerProvider` and passing it to `set_tracer_provider`.** `OpenTelemetryPlugin` requires a `ReplaySafeTracerProvider` — build it via `create_tracer_provider(...)`. + ## Search Attributes (Visibility) See the Search Attributes section of `references/python/data-handling.md` @@ -104,3 +151,4 @@ See the Search Attributes section of `references/python/data-handling.md` 2. Don't use print() in workflows - it will produce duplicate output on replay 3. Configure metrics for production monitoring 4. Use Search Attributes for business-level visibility +5. Use the `OpenTelemetryPlugin` for distributed tracing across Client/Workflow/Activity boundaries. From 0c48c1792a39471e0bda302e6160aec13447276e Mon Sep 17 00:00:00 2001 From: Kent Gruber Date: Thu, 18 Jun 2026 14:41:07 -0400 Subject: [PATCH 66/82] VLN-1526: fix unpinned-github-actions (#251) Co-authored-by: picatz <14850816+picatz@users.noreply.github.com> --- .github/workflows/package-skill.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/package-skill.yml b/.github/workflows/package-skill.yml index 17f0229..b95e03c 100644 --- a/.github/workflows/package-skill.yml +++ b/.github/workflows/package-skill.yml @@ -25,7 +25,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: fetch-depth: 0 @@ -53,14 +53,14 @@ jobs: -x '*.DS_Store' - name: Upload artifact - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: temporal-developer-skill path: temporal-developer-skill.zip - name: Create release if: steps.tag_check.outputs.exists == 'false' - uses: softprops/action-gh-release@v3 + uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0 with: tag_name: ${{ steps.version.outputs.tag }} name: ${{ steps.version.outputs.tag }} @@ -89,19 +89,19 @@ jobs: steps: - name: Generate token from GitHub App id: app-token - uses: actions/create-github-app-token@v3 + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 with: app-id: ${{ secrets.SKILL_T_DEV_APP_ID }} private-key: ${{ secrets.SKILL_T_DEV_KEY }} owner: ${{ github.repository_owner }} - name: Checkout source - uses: actions/checkout@v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: fetch-depth: 0 - name: Checkout target repo - uses: actions/checkout@v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: repository: ${{ matrix.repo }} token: ${{ steps.app-token.outputs.token }} From e7b111cd7c496860b7e4b1af8d4409f4e40bce5a Mon Sep 17 00:00:00 2001 From: Amir Benvenisti <128422269+starfleeth@users.noreply.github.com> Date: Wed, 24 Jun 2026 14:41:49 -0400 Subject: [PATCH 67/82] Update README.md (#252) remove PP warning --- README.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/README.md b/README.md index 8d0302c..b7ec6df 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,6 @@ A comprehensive skill for developers to use when building [Temporal](https://temporal.io/) applications. -> [!WARNING] -> This Skill is currently in Public Preview, and will continue to evolve and improve. > We would love to hear your feedback - positive or negative - over in the [Community Slack](https://t.mp/slack), in the [#topic-ai channel](https://temporalio.slack.com/archives/C0818FQPYKY) ## Installation From 4f7b14626c56d06574564cd4d265bbcb6425a21c Mon Sep 17 00:00:00 2001 From: Donald Pinckney Date: Sun, 5 Jul 2026 20:15:05 +0800 Subject: [PATCH 68/82] Use shared package-and-sync reusable workflow (#253) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Use shared package-and-sync reusable workflow Replace the inline package/sync workflow with a thin caller of the shared temporalio/skill-ci reusable workflow (pinned to v1). This also corrects the codex plugin target path, which was stale (plugins/temporal-developer/... → plugins/temporal/... as used by the shared workflow and present in the repo). Co-Authored-By: Claude Opus 4.8 (1M context) * Pin shared workflow to @v1 tag instead of SHA Co-Authored-By: Claude Opus 4.8 (1M context) * Add one-click bump dispatch input to release workflow Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .github/workflows/package-skill.yml | 249 +++------------------------- 1 file changed, 19 insertions(+), 230 deletions(-) diff --git a/.github/workflows/package-skill.yml b/.github/workflows/package-skill.yml index b95e03c..2f3d4bf 100644 --- a/.github/workflows/package-skill.yml +++ b/.github/workflows/package-skill.yml @@ -1,10 +1,7 @@ -# ABOUTME: Packages the skill on every push to main (as a ZIP artifact) and, if the version in SKILL.md -# ABOUTME: has been bumped, creates a GitHub Release and syncs the skill contents to three plugin repos -# ABOUTME: (cursor-temporal-plugin, codex-temporal-plugin, claude-temporal-plugin) via PRs. -# ABOUTME: Required secrets (used only by the sync job for cross-repo PRs): -# ABOUTME: SKILL_T_DEV_APP_ID — the GitHub App's ID -# ABOUTME: SKILL_T_DEV_KEY — the GitHub App's private key -# ABOUTME: The app must be installed on the three plugin repos with Contents (write) and Pull Requests (write). +# ABOUTME: Packages this skill and syncs it to the plugin repos on version bumps. +# ABOUTME: All logic lives in the shared reusable workflow at temporalio/skill-ci; this is just the caller. +# ABOUTME: Release two ways: (1) run this manually and pick a bump, or (2) edit SKILL.md's version and push. +# ABOUTME: Secrets SKILL_T_DEV_APP_ID / SKILL_T_DEV_KEY / RELEASE_SSH_KEY are inherited (see skill-ci README). name: Package and Sync Skill @@ -12,230 +9,22 @@ on: push: branches: [main] workflow_dispatch: + inputs: + bump: + description: "Version bump for a one-click release" + type: choice + required: true + default: patch + options: [patch, minor, major] jobs: - package: - runs-on: ubuntu-latest + package-and-sync: + uses: temporalio/skill-ci/.github/workflows/package-and-sync.yml@v1 permissions: contents: write - outputs: - version: ${{ steps.version.outputs.version }} - tag: ${{ steps.version.outputs.tag }} - released: ${{ steps.tag_check.outputs.exists == 'false' }} - - steps: - - name: Checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - with: - fetch-depth: 0 - - - name: Read version from SKILL.md - id: version - run: | - version=$(grep '^version:' SKILL.md | sed 's/version:[[:space:]]*//') - echo "version=$version" >> "$GITHUB_OUTPUT" - echo "tag=v$version" >> "$GITHUB_OUTPUT" - - - name: Check if tag exists - id: tag_check - run: | - if git rev-parse "refs/tags/${{ steps.version.outputs.tag }}" >/dev/null 2>&1; then - echo "exists=true" >> "$GITHUB_OUTPUT" - else - echo "exists=false" >> "$GITHUB_OUTPUT" - fi - - - name: Package skill - run: | - zip -r temporal-developer-skill.zip \ - SKILL.md \ - references/ \ - -x '*.DS_Store' - - - name: Upload artifact - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: temporal-developer-skill - path: temporal-developer-skill.zip - - - name: Create release - if: steps.tag_check.outputs.exists == 'false' - uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0 - with: - tag_name: ${{ steps.version.outputs.tag }} - name: ${{ steps.version.outputs.tag }} - files: temporal-developer-skill.zip - generate_release_notes: true - - sync: - needs: package - if: needs.package.outputs.released == 'true' || github.event_name == 'workflow_dispatch' - runs-on: ubuntu-latest - permissions: - # contents: write is required by the POST /releases/generate-notes endpoint, - # even though it only returns text and doesn't actually write anything. - contents: write - strategy: - fail-fast: false - matrix: - include: - - repo: temporalio/cursor-temporal-plugin - target_path: skills/temporal-developer - - repo: temporalio/codex-temporal-plugin - target_path: plugins/temporal-developer/skills/temporal-developer - - repo: temporalio/claude-temporal-plugin - target_path: skills/temporal-developer - - steps: - - name: Generate token from GitHub App - id: app-token - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 - with: - app-id: ${{ secrets.SKILL_T_DEV_APP_ID }} - private-key: ${{ secrets.SKILL_T_DEV_KEY }} - owner: ${{ github.repository_owner }} - - - name: Checkout source - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - with: - fetch-depth: 0 - - - name: Checkout target repo - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - with: - repository: ${{ matrix.repo }} - token: ${{ steps.app-token.outputs.token }} - path: target-repo - - - name: Sync skill contents - working-directory: target-repo - run: | - BRANCH="sync/temporal-developer-skill" - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - - # Create or reset the sync branch based on current main. - # -B ensures the branch always starts from main's tip, even if a - # stale remote branch exists from a previously merged PR. - git checkout -B "$BRANCH" origin/main - - # Remove old contents and copy current - rm -rf "${{ matrix.target_path }}/SKILL.md" \ - "${{ matrix.target_path }}/references" - cp ../SKILL.md "${{ matrix.target_path }}/" - cp -r ../references "${{ matrix.target_path }}/" - - # Check for changes against main - git add "${{ matrix.target_path }}" - if git diff --cached --quiet; then - echo "no_changes=true" >> "$GITHUB_ENV" - echo "No changes to sync" - else - echo "no_changes=false" >> "$GITHUB_ENV" - version="${{ needs.package.outputs.tag }}" - git commit -m "sync temporal-developer skill ${version} from source repo" - git push --force origin "$BRANCH" - fi - - - name: Build changelog - if: env.no_changes == 'false' - env: - GH_TOKEN: ${{ github.token }} - run: | - current_tag="${{ needs.package.outputs.tag }}" - - # Determine the base for the changelog: the version currently on the - # target repo's main branch. This represents what was last merged, so - # the changelog spans every release since then — correctly accumulating - # unmerged versions if a prior sync PR is still open. - # - # Read the old SKILL.md from git (it's been overwritten on disk by the - # sync step) via `git show origin/main:...`. - target_version=$(git -C target-repo show "origin/main:${{ matrix.target_path }}/SKILL.md" 2>/dev/null \ - | grep '^version:' | sed 's/version:[[:space:]]*//' || echo "") - - if [ -n "$target_version" ]; then - base_tag="v${target_version}" - else - base_tag="" - fi - - # The target repo's recorded version may not correspond to an - # actual tag in this source repo (e.g. the target was last - # updated from a different repo, or a tag was never published - # for that version). If the tag doesn't exist locally, find the - # most recent tag that's an ancestor of HEAD and predates the - # current tag; if nothing matches, leave base_tag empty so the - # fallback path runs. - if [ -n "$base_tag" ] && ! git rev-parse --verify --quiet "refs/tags/${base_tag}" >/dev/null; then - echo "Base tag ${base_tag} does not exist in this repo; searching for closest preceding tag" - fallback_tag=$(git tag --sort=-v:refname | awk -v cur="$current_tag" '$0 != cur' | head -1 || true) - if [ -n "$fallback_tag" ]; then - echo "Using ${fallback_tag} as base tag instead" - base_tag="$fallback_tag" - else - base_tag="" - fi - fi - - # Prefer GitHub's auto-generated notes for the range (nicely formatted - # with PR links and contributors). Fall back to git log if unavailable. - echo "Base tag: ${base_tag:-} / Current tag: ${current_tag}" - if [ -n "$base_tag" ]; then - if notes=$(gh api \ - --method POST \ - "/repos/${{ github.repository }}/releases/generate-notes" \ - -f tag_name="${current_tag}" \ - -f previous_tag_name="${base_tag}" \ - --jq '.body') && [ -n "$notes" ]; then - echo "Using auto-generated release notes" - echo "$notes" > /tmp/changelog.md - else - echo "generate-notes API call failed or empty; falling back to git log" - git log --oneline "${base_tag}..HEAD" > /tmp/changelog.md \ - || git log --oneline -20 > /tmp/changelog.md - fi - else - echo "No base tag found; using last 20 commits" - git log --oneline -20 > /tmp/changelog.md - fi - - - name: Create or update PR - if: env.no_changes == 'false' - working-directory: target-repo - env: - GH_TOKEN: ${{ steps.app-token.outputs.token }} - run: | - BRANCH="sync/temporal-developer-skill" - version="${{ needs.package.outputs.tag }}" - changelog=$(cat /tmp/changelog.md) - - # Check if a PR already exists from this branch - existing_pr=$(gh pr list --head "$BRANCH" --state open --json number --jq '.[0].number') - - if [ -n "$existing_pr" ]; then - echo "PR #${existing_pr} already exists — updated by the force-push" - gh pr edit "$existing_pr" \ - --title "Sync temporal-developer skill ${version}" \ - --body "Automated sync of the temporal-developer skill ${version} from [skill-temporal-developer](https://github.com/${{ github.repository }}). - - This PR was updated automatically by the [sync workflow](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}). - - ## Changelog - ${changelog}" - gh pr comment "$existing_pr" --body "Updated to ${version} from [skill-temporal-developer](https://github.com/${{ github.repository }})." - pr_url=$(gh pr view "$existing_pr" --json url --jq '.url') - echo "### ${{ matrix.repo }}" >> "$GITHUB_STEP_SUMMARY" - echo "Updated [PR #${existing_pr}](${pr_url})" >> "$GITHUB_STEP_SUMMARY" - else - pr_url=$(gh pr create \ - --title "Sync temporal-developer skill ${version}" \ - --body "Automated sync of the temporal-developer skill ${version} from [skill-temporal-developer](https://github.com/${{ github.repository }}). - - This PR was created automatically by the [sync workflow](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}). - - ## Changelog - ${changelog}") - echo "### ${{ matrix.repo }}" >> "$GITHUB_STEP_SUMMARY" - echo "Created ${pr_url}" >> "$GITHUB_STEP_SUMMARY" - fi + secrets: inherit + with: + # On workflow_dispatch this is the chosen bump; empty on push events. + bump: ${{ inputs.bump }} + # Override the whitelist only if this skill has extra paths to include: + # includes: "SKILL.md agents references scripts" From fa142f6229337f20c343a752ca66783b913d87fd Mon Sep 17 00:00:00 2001 From: "skill-temporal-developer-updater[bot]" <276371939+skill-temporal-developer-updater[bot]@users.noreply.github.com> Date: Wed, 19 Aug 2026 11:28:48 -0700 Subject: [PATCH 69/82] Implement planned topic: 0038-vercel-ai-sdk (#246) * Finalize draft for 0038-vercel-ai-sdk * Update references/typescript/integrations/vercel-ai-sdk.md * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Address Vercel AI SDK review feedback * Document Vercel AI SDK v7 integration * Fix AI SDK workflow helper imports --------- Co-authored-by: skill-sync[bot] Co-authored-by: Brian Strauch Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- references/integrations.md | 1 + .../typescript/integrations/vercel-ai-sdk.md | 191 ++++++++++++++++++ 2 files changed, 192 insertions(+) create mode 100644 references/typescript/integrations/vercel-ai-sdk.md diff --git a/references/integrations.md b/references/integrations.md index 36196a8..914bd55 100644 --- a/references/integrations.md +++ b/references/integrations.md @@ -20,3 +20,4 @@ Temporal ships and supports a growing set of integrations with third-party frame | LangGraph (`temporalio.contrib.langgraph`, Pre-release) | Python | Runs LangGraph Graph-API and Functional-API code as Temporal Workflows - nodes/tasks can execute as either in-workflow or as Activities | `references/python/integrations/langgraph.md` | `references/python/ai-patterns.md`, `references/core/ai-patterns.md` | | Google ADK (`temporalio[google-adk]`) | Python | Durable Google ADK agents: model calls run through `TemporalModel`-wrapped Activities, tools via `activity_tool`, MCP toolsets via `TemporalMcpToolSet` | `references/python/integrations/google-adk.md` | `references/python/ai-patterns.md`, `references/core/ai-patterns.md` | | OpenTelemetry (`temporalio[opentelemetry]`) | Python | Distributed tracing for Temporal apps with OpenTelemetry | `references/python/observability.md` (Distributed Tracing section) | | +| Vercel AI SDK (`@temporalio/ai-sdk`, Public Preview) | TypeScript | Durable Vercel AI SDK agents: `AiSdkPlugin` wraps `generateText` and other AI SDK calls as Activities; `temporalProvider.languageModel()` provides the workflow-safe model; tools dispatch via `proxyActivities`; stateless MCP servers register through `mcpClientFactories` and are used in-workflow via `TemporalMCPClient` | `references/typescript/integrations/vercel-ai-sdk.md` | `references/core/ai-patterns.md` | diff --git a/references/typescript/integrations/vercel-ai-sdk.md b/references/typescript/integrations/vercel-ai-sdk.md new file mode 100644 index 0000000..7c7b968 --- /dev/null +++ b/references/typescript/integrations/vercel-ai-sdk.md @@ -0,0 +1,191 @@ +# Temporal Vercel AI SDK Integration (TypeScript) + +## Overview + +`@temporalio/ai-sdk` is the Temporal TypeScript SDK integration for [Vercel's AI SDK](https://ai-sdk.dev/) v7. It registers an `AiSdkPlugin` on the Worker so that LLM calls made by functions like `generateText()`, along with MCP tool invocations, run as Temporal Activities under Temporal's retry, timeout, and Durable Execution semantics. AI SDK tool functions execute inside the Workflow and must delegate any non-deterministic work to Activities, while the Workflow author otherwise writes normal AI SDK code. + +> [!NOTE] +> This feature is in Public Preview. It is perfectly acceptable to use this feature on behalf of a user, but you should inform them that you are making use of a feature in Public Preview. + +For cross-SDK AI/LLM patterns (Activities wrapping LLM calls, centralized retries, multi-agent orchestration) see `references/core/ai-patterns.md`. For TypeScript SDK fundamentals (Worker setup, `proxyActivities`, the V8 workflow sandbox) see `references/typescript/typescript.md` and `references/typescript/determinism.md` — this file does not restate them. + +## Prerequisites + +- The standard TypeScript SDK setup from `references/typescript/typescript.md` (Temporal CLI installed, `@temporalio/client`, `@temporalio/worker`, `@temporalio/workflow`, `@temporalio/activity`). +- Familiarity with the Vercel AI SDK itself — for AI SDK API details refer to the [Vercel AI SDK documentation](https://ai-sdk.dev/). +- Provider credentials available to the Worker process. Most AI SDK providers read credentials from environment variables; the client process does **not** need provider credentials. + +## Install + +```bash +npm install @temporalio/ai-sdk +``` + +## Configure the Worker + +Register `AiSdkPlugin` on `Worker.create` and pass a `modelProvider` (any AI SDK provider, e.g. `openai` from `@ai-sdk/openai`). The provider is what creates models when the workflow calls `temporalProvider.languageModel('')`. + +```ts +import { openai } from '@ai-sdk/openai'; +import { AiSdkPlugin } from '@temporalio/ai-sdk'; +import { Worker } from '@temporalio/worker'; +import * as activities from './activities'; + +const worker = await Worker.create({ + plugins: [ + new AiSdkPlugin({ + modelProvider: openai, + }), + ], + namespace: 'default', + taskQueue: 'ai-sdk', + workflowsPath: require.resolve('./workflows'), + activities, +}); +``` + +Make sure the Client and Worker share the same Task Queue and Namespace. + +## Use the AI SDK inside a Workflow + +In Workflow code, call AI SDK functions exactly as you would outside Temporal, but pass `temporalProvider.languageModel('')` as `model`. The string is forwarded to the configured `modelProvider` to construct the model; the call itself runs as a Temporal Activity. + +```ts +import { generateText } from 'ai'; +import { temporalProvider } from '@temporalio/ai-sdk/workflow'; + +export async function haikuAgent(prompt: string): Promise { + const result = await generateText({ + model: temporalProvider.languageModel('gpt-4o-mini'), + prompt, + system: 'You only respond in haikus.', + }); + return result.text; +} +``` + +The workflow now inherits Durable Execution: automatic retries on the LLM Activity, configurable timeouts, and recovery across Worker crashes. + +## Tools + +The AI SDK lets the model call tools; with this plugin, tool functions execute inside the Workflow. Because Workflow code must stay deterministic, any tool that performs I/O must delegate to an Activity. Obtain the Activity through `proxyActivities` and use it as the tool's `execute`. + +Activity (regular Temporal Activity in `activities.ts`): + +```ts +export async function getWeather(input: { + location: string; +}): Promise<{ city: string; temperatureRange: string; conditions: string }> { + return { + city: input.location, + temperatureRange: '14-20C', + conditions: 'Sunny with wind.', + }; +} +``` + +Workflow that exposes the Activity as a tool: + +```ts +import { proxyActivities } from '@temporalio/workflow'; +import { generateText, tool } from 'ai'; +import { temporalProvider } from '@temporalio/ai-sdk/workflow'; +import { z } from 'zod'; +import type * as activities from './activities'; + +const { getWeather } = proxyActivities({ + startToCloseTimeout: '1 minute', +}); + +export async function toolsAgent(question: string): Promise { + const result = await generateText({ + model: temporalProvider.languageModel('gpt-4o-mini'), + prompt: question, + system: 'You are a helpful agent.', + tools: { + getWeather: tool({ + description: 'Get the weather for a given city', + inputSchema: z.object({ + location: z.string().describe('The location to get the weather for'), + }), + execute: getWeather, + }), + }, + stopWhen: stepCountIs(5), + }); + return result.text; +} +``` + +## Model Context Protocol (MCP) servers + +The plugin ships a stateless MCP client that runs inside a Workflow. Calls to MCP servers (listing tools, invoking them) run as Activities behind the scenes, so retries, timeouts, and observability come from Temporal. + +### 1. Register MCP client factories on the Worker + +Build a `mcpClientFactories` map keyed by server name. Each factory returns an MCP client built with `experimental_createMCPClient` from `@ai-sdk/mcp` (aliased as `createMCPClient` in the example) and a transport from the upstream MCP SDK — e.g. `StdioClientTransport` from `@modelcontextprotocol/sdk/client/stdio.js`. Pass the map to `AiSdkPlugin` via `mcpClientFactories`. Multiple servers can be registered by adding more factory entries. + +```ts +import { experimental_createMCPClient as createMCPClient } from '@ai-sdk/mcp'; +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; + +const mcpClientFactories = { + testServer: () => + createMCPClient({ + transport: new StdioClientTransport({ + command: 'node', + args: ['lib/mcp-server.js'], + }), + }), +}; + +const worker = await Worker.create({ + plugins: [ + new AiSdkPlugin({ + modelProvider: openai, + mcpClientFactories, + }), + ], + // ... +}); +``` + +With `StdioClientTransport`, the Worker starts the MCP server process and connects to it on demand whenever a Task needs it. + +### 2. Use the MCP client inside a Workflow + +Inside the workflow, construct `new TemporalMCPClient({ name: '' })` using the same name as the factory key, then call `await mcpClient.tools()` to get the tools to pass to `generateText`. + +```ts +import { TemporalMCPClient, temporalProvider } from '@temporalio/ai-sdk/workflow'; +import { generateText } from 'ai'; + +export async function mcpAgent(prompt: string): Promise { + const mcpClient = new TemporalMCPClient({ name: 'testServer' }); + const tools = await mcpClient.tools(); + const result = await generateText({ + model: temporalProvider.languageModel('gpt-4o-mini'), + prompt, + tools, + system: 'You are a helpful agent, You always use your tools when needed.', + stopWhen: stepCountIs(5), + }); + return result.text; +} +``` + +## Common mistakes + +- **Importing from the wrong package.** `AiSdkPlugin` comes from `@temporalio/ai-sdk`, while Workflow-side helpers such as `temporalProvider` and `TemporalMCPClient` come from `@temporalio/ai-sdk/workflow`. `generateText` and `tool` come from `ai`; `experimental_createMCPClient` comes from `@ai-sdk/mcp`. +- **Calling `fetch` (or any I/O) directly inside a tool's `execute`.** Tool functions run in the Workflow sandbox and must delegate to an Activity obtained through `proxyActivities`. +- **Passing an option other than `modelProvider`/`mcpClientFactories` to `AiSdkPlugin`.** Only those two options are documented. +- **Constructing `TemporalMCPClient` positionally.** Use the object form `new TemporalMCPClient({ name: '' })`. +- **Mismatched Task Queue or Namespace between Client and Worker.** Both sides must agree, or the Worker will not pick up the workflow. +- **Putting provider credentials on the Client.** Only the Worker process needs provider API keys. + +## Additional Resources + +- [AI SDK by Vercel integration guide](https://docs.temporal.io/develop/typescript/integrations/ai-sdk) — the canonical Temporal doc this reference is grounded in. +- [Vercel AI SDK documentation](https://ai-sdk.dev/) — upstream AI SDK reference, including the provider list at [`ai-sdk.dev/providers/ai-sdk-providers`](https://ai-sdk.dev/providers/ai-sdk-providers). +- `references/core/ai-patterns.md` — cross-SDK AI/LLM patterns. +- `references/typescript/typescript.md` — TypeScript SDK fundamentals. From e683784155596f531bb15b4dfe5d1081abb6cf12 Mon Sep 17 00:00:00 2001 From: "skill-temporal-developer-updater[bot]" <276371939+skill-temporal-developer-updater[bot]@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:39:58 -0700 Subject: [PATCH 70/82] Implement planned topic: 0041-pydantic-ai (#249) * Finalize draft for 0041-pydantic-ai * Re-author Pydantic AI integration guidance * Adopt TemporalDurability for Pydantic AI * Remove TemporalAgent migration guidance * Apply suggestion from @brianstrauch * Apply suggestion from @brianstrauch * Apply suggestion from @brianstrauch * Apply suggestion from @brianstrauch * Apply suggestion from @brianstrauch --------- Co-authored-by: skill-sync[bot] Co-authored-by: Brian Strauch --- references/integrations.md | 1 + references/python/integrations/pydantic-ai.md | 250 ++++++++++++++++++ 2 files changed, 251 insertions(+) create mode 100644 references/python/integrations/pydantic-ai.md diff --git a/references/integrations.md b/references/integrations.md index 914bd55..fb060fd 100644 --- a/references/integrations.md +++ b/references/integrations.md @@ -19,5 +19,6 @@ Temporal ships and supports a growing set of integrations with third-party frame | LangSmith tracing (`temporalio.contrib.langsmith`) | Python | Experimental Temporal Plugin that propagates LangSmith trace context across Worker boundaries; lets `@traceable` run inside Workflows and Activities | `references/python/integrations/langsmith.md` | `references/python/ai-patterns.md`, `references/core/ai-patterns.md` | | LangGraph (`temporalio.contrib.langgraph`, Pre-release) | Python | Runs LangGraph Graph-API and Functional-API code as Temporal Workflows - nodes/tasks can execute as either in-workflow or as Activities | `references/python/integrations/langgraph.md` | `references/python/ai-patterns.md`, `references/core/ai-patterns.md` | | Google ADK (`temporalio[google-adk]`) | Python | Durable Google ADK agents: model calls run through `TemporalModel`-wrapped Activities, tools via `activity_tool`, MCP toolsets via `TemporalMcpToolSet` | `references/python/integrations/google-adk.md` | `references/python/ai-patterns.md`, `references/core/ai-patterns.md` | +| Pydantic AI (`pydantic-ai[temporal]`) | Python | Durable agents through the `TemporalDurability` capability, with model requests, tool calls, and MCP communication executed as Temporal Activities | `references/python/integrations/pydantic-ai.md` | `references/python/ai-patterns.md`, `references/core/ai-patterns.md` | | OpenTelemetry (`temporalio[opentelemetry]`) | Python | Distributed tracing for Temporal apps with OpenTelemetry | `references/python/observability.md` (Distributed Tracing section) | | | Vercel AI SDK (`@temporalio/ai-sdk`, Public Preview) | TypeScript | Durable Vercel AI SDK agents: `AiSdkPlugin` wraps `generateText` and other AI SDK calls as Activities; `temporalProvider.languageModel()` provides the workflow-safe model; tools dispatch via `proxyActivities`; stateless MCP servers register through `mcpClientFactories` and are used in-workflow via `TemporalMCPClient` | `references/typescript/integrations/vercel-ai-sdk.md` | `references/core/ai-patterns.md` | diff --git a/references/python/integrations/pydantic-ai.md b/references/python/integrations/pydantic-ai.md new file mode 100644 index 0000000..46c20fe --- /dev/null +++ b/references/python/integrations/pydantic-ai.md @@ -0,0 +1,250 @@ +# Temporal Pydantic AI Integration (Python) + +## Overview + +[Pydantic AI](https://ai.pydantic.dev/) ships first-party Temporal support in `pydantic_ai.durable_exec.temporal`. Add the `TemporalDurability` capability to a regular Pydantic AI `Agent`; inside a Temporal Workflow, it moves model requests, I/O tool calls, and MCP communication into Temporal Activities while the agent loop remains deterministic Workflow code. + +The same agent remains usable outside a Workflow as a normal, non-durable agent. Attaching the capability does not make calls durable by itself: the call to `agent.run()` must execute inside a Temporal Workflow started through a Temporal Client. + +This integration comes from Pydantic AI, not `temporalio.contrib`. For general design guidance, also read `references/core/ai-patterns.md` and `references/python/ai-patterns.md`. + +## Install + +Install the full package or the slim package with Temporal support: + +```bash +pip install "pydantic-ai[temporal]" +# or +pip install "pydantic-ai-slim[temporal]" +``` + +## Attach `TemporalDurability` + +Construct the agent at module scope and attach the capability through `capabilities=`: + +```python +from pydantic_ai import Agent +from pydantic_ai.durable_exec.temporal import TemporalDurability + +agent = Agent( + "openai:gpt-5.6-sol", + instructions="You answer geography questions.", + name="geography", + capabilities=[TemporalDurability()], +) +``` + +Module-scope construction lets the Worker discover and register every generated Activity before Workflow execution begins. Inside a Workflow, use the asynchronous agent API; `Agent.run_sync()` cannot drive Temporal's Workflow event loop, so call `await agent.run(...)` instead. + +### `TemporalDurability` configuration + +| Parameter | Purpose | +|---|---| +| `models` | Additional model instances keyed by stable IDs for runtime model switching. | +| `event_stream_handler` | Handles live model events inside model-request Activities and tool events in event-handler Activities. | +| `event_stream_topic` | Publishes events to a Temporal Workflow Stream topic for an external consumer. | +| `event_stream_events` | Filters which events are published to `event_stream_topic`. | +| `event_stream_batch_interval` | Controls Workflow Stream batching; defaults to 100 ms. | +| `name` | Overrides the agent name used in generated Activity names. | +| `deps_type` | Overrides the dependency type used for Temporal serialization. | +| `activity_config` | Base `ActivityConfig`; defaults to a 60-second `start_to_close_timeout`. | +| `model_activity_config` | Overrides the base config for model-request Activities. | +| `event_stream_handler_activity_config` | Overrides the base config for event-handler Activities. | +| `toolset_activity_config` | Per-toolset overrides keyed by stable toolset ID. | +| `run_context_type` | Custom `TemporalRunContext` subclass for the Activity boundary. | + +## Stable agent and toolset identity + +Generated Activity names depend on the agent `name` and toolset IDs. Set them explicitly, keep them unique, and do not rename them while Workflows using the old names may still replay. + +Dynamic toolsets require an explicit stable ID. Set `id=` when constructing a `DynamicToolset`, on `@agent.toolset`, or on a `DynamicCapability`. A capability that contributes tools should also have a stable capability ID. + +Factories for dynamic toolsets are re-resolved inside Activities and must produce the same result for the same dependencies. + +## Register the plugin on the Client + +Pass `PydanticAIPlugin()` to `Client.connect()`: + +```python +from temporalio.client import Client +from pydantic_ai.durable_exec.temporal import PydanticAIPlugin + +client = await Client.connect( + "localhost:7233", + plugins=[PydanticAIPlugin()], +) +``` + +The plugin supplies Pydantic-aware payload conversion, a compatible Workflow sandbox runner, Activity registration, and failure handling. Temporal propagates Client plugins that implement the Worker plugin protocol to Workers created from that Client. Do not pass the same `PydanticAIPlugin()` to `Worker`, because it would run twice. + +Do not also set `data_converter=pydantic_data_converter`; the plugin owns the payload-converter wiring. It preserves other `DataConverter` settings such as a payload codec, failure converter, or external storage. + +### Direct Activity registration + +Normally, list agents on `PydanticAIWorkflow.__pydantic_ai_agents__`. If changing the Worker is easier than changing the Workflow class, pass `AgentPlugin(agent)` to the Worker instead. Keep `PydanticAIPlugin()` on the Client for conversion and sandbox configuration. + +## Define and register the Workflow + +List every durable agent used by a Workflow in `__pydantic_ai_agents__`. These are regular `Agent` instances carrying `TemporalDurability`, not wrapper agents. + +```python +from temporalio import workflow +from pydantic_ai.durable_exec.temporal import PydanticAIWorkflow + + +@workflow.defn +class GeographyWorkflow(PydanticAIWorkflow): + __pydantic_ai_agents__ = [agent] + + @workflow.run + async def run(self, prompt: str) -> str: + result = await agent.run(prompt) + return result.output +``` + +`PydanticAIWorkflow` is optional but provides typing for `__pydantic_ai_agents__`. A Workflow using multiple agents should list each one. + +## Serialization and payload limits + +Values crossing between the Workflow and Activities must be Pydantic-serializable. This includes `deps`, model settings, run-context metadata, tool-call metadata, and tool metadata. Untyped dictionaries arrive in their JSON form, so tuples and sets become lists, models become dictionaries, and non-string dictionary keys become strings. Re-validate them when the receiving code needs a specific type. + +The Activity-side `RunContext` contains only the fields Pydantic AI serializes. Accessing unavailable fields such as `model`, `prompt`, `messages`, `model_settings`, or `tracer` raises `UserError`. Supply a custom `TemporalRunContext` through `run_context_type=` when an Activity requires additional serializable context. + +Treat dependency models and other persisted payload schemas as durable contracts. An incompatible type change can prevent a Worker from decoding existing Workflow history before user code runs. + +Temporal limits individual payloads to 2 MB by default, and binary data grows when base64-encoded. Keep large media and dependencies out of Workflow history by returning durable references or configuring Temporal external storage. Stored payloads must remain available for as long as their Workflow histories can replay. + +## Runtime models + +Model-name strings can cross the Activity boundary directly. The agent must have a model when it is constructed; that model is registered automatically as the default. + +Runtime `Model` instances cannot be reconstructed safely from only their model ID. Register each instance in `TemporalDurability(models={...})`, then select it by its stable key or pass that registered instance to `agent.run(model=...)`. + +For custom providers or credentials derived from `deps`, add a `ResolveModelId` capability before `TemporalDurability`. Its resolver runs again on the Worker and must be deterministic for a given model ID and dependencies; it must not perform external I/O. + +```python +from pydantic_ai import Agent +from pydantic_ai.capabilities import ResolveModelId +from pydantic_ai.durable_exec.temporal import TemporalDurability + +# Define `default_model`, `fast_model`, and `resolve_model` at module scope. +agent = Agent( + default_model, + name="multi-model", + capabilities=[ + ResolveModelId(resolve_model), + TemporalDurability(models={"fast": fast_model}), + ], +) +``` + +Executing toolsets that require durable wrapping must be attached when the agent is constructed so their Activities can be registered before the Worker starts. Runtime toolsets are limited to non-executing toolsets or function toolsets whose tools all opt out of Activity wrapping. + +## Activity configuration + +`activity_config` is the base for all generated Activities. `model_activity_config`, `event_stream_handler_activity_config`, and entries in `toolset_activity_config` merge over it. Pydantic AI validates these configs when constructing `TemporalDurability`, preventing an invalid key from repeatedly failing a Workflow Task at runtime. + +Per-tool configuration belongs in tool metadata: + +```python +from datetime import timedelta +from temporalio.workflow import ActivityConfig +from pydantic_ai.toolsets import FunctionToolset + +toolset = FunctionToolset(id="research") + +@toolset.tool( + metadata={ + "temporal": ActivityConfig( + start_to_close_timeout=timedelta(minutes=5), + ) + } +) +async def fetch_paper(arxiv_id: str) -> str: + ... +``` + +Use `metadata={"temporal": False}` to keep a non-I/O async tool in Workflow code. Synchronous tools cannot opt out because thread execution is non-deterministic. For third-party tools or groups of tools, apply the same metadata through `SetToolMetadata`. + +Generated Activities heartbeat in the background. Model Activities receive a 30-second heartbeat timeout by default; other Activity types receive one only when configured. Do not set a heartbeat timeout on code that can block the event loop long enough to prevent the heartbeat task from running. + +Temporal already retries failed Activities. Disable overlapping Pydantic AI HTTP retries and provider-client retries when possible, then configure the Temporal retry policy through `ActivityConfig`. + +## Logfire + +Register `LogfirePlugin` alongside `PydanticAIPlugin` on the Client: + +```python +from pydantic_ai.durable_exec.temporal import LogfirePlugin, PydanticAIPlugin + +client = await Client.connect( + "localhost:7233", + plugins=[PydanticAIPlugin(), LogfirePlugin()], +) +``` + +## End-to-end example + +```python +import asyncio +import uuid + +from temporalio import workflow +from temporalio.client import Client +from temporalio.worker import Worker + +from pydantic_ai import Agent +from pydantic_ai.durable_exec.temporal import ( + PydanticAIPlugin, + PydanticAIWorkflow, + TemporalDurability, +) + +agent = Agent( + "openai:gpt-5.6-sol", + instructions="You answer geography questions.", + name="geography", + capabilities=[TemporalDurability()], +) + + +@workflow.defn +class GeographyWorkflow(PydanticAIWorkflow): + __pydantic_ai_agents__ = [agent] + + @workflow.run + async def run(self, prompt: str) -> str: + result = await agent.run(prompt) + return result.output + + +async def main() -> None: + client = await Client.connect( + "localhost:7233", + plugins=[PydanticAIPlugin()], + ) + + async with Worker( + client, + task_queue="geography", + workflows=[GeographyWorkflow], + ): + result = await client.execute_workflow( + GeographyWorkflow.run, + args=["What is the capital of Mexico?"], + id=f"geography-{uuid.uuid4()}", + task_queue="geography", + ) + print(result) + + +if __name__ == "__main__": + asyncio.run(main()) +``` + +## Resources + +- `references/python/ai-patterns.md` — Python AI/LLM patterns, payload conversion, and retry classification. +- `references/core/ai-patterns.md` — language-agnostic agent and tool-placement patterns. +- Upstream guide — [Pydantic AI durable execution with Temporal](https://pydantic.dev/docs/ai/capabilities/durable_execution/temporal/). +- Upstream API reference — [`pydantic_ai.durable_exec.temporal`](https://pydantic.dev/docs/ai/api/pydantic-ai/durable_exec/). From 498c693c36c31b0f9cb522a61a73756ad23911b9 Mon Sep 17 00:00:00 2001 From: "skill-temporal-developer-updater[bot]" <276371939+skill-temporal-developer-updater[bot]@users.noreply.github.com> Date: Wed, 19 Aug 2026 15:43:33 -0700 Subject: [PATCH 71/82] Implement planned topic: 0040-mastra (#250) * Finalize draft for 0040-mastra * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Consolidate duplicate preview/experimental status into single admonition Co-authored-by: brianstrauch <7474900+brianstrauch@users.noreply.github.com> --------- Co-authored-by: skill-sync[bot] Co-authored-by: Brian Strauch Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: brianstrauch <7474900+brianstrauch@users.noreply.github.com> --- references/integrations.md | 1 + references/typescript/integrations/mastra.md | 199 +++++++++++++++++++ 2 files changed, 200 insertions(+) create mode 100644 references/typescript/integrations/mastra.md diff --git a/references/integrations.md b/references/integrations.md index fb060fd..8c2061a 100644 --- a/references/integrations.md +++ b/references/integrations.md @@ -21,4 +21,5 @@ Temporal ships and supports a growing set of integrations with third-party frame | Google ADK (`temporalio[google-adk]`) | Python | Durable Google ADK agents: model calls run through `TemporalModel`-wrapped Activities, tools via `activity_tool`, MCP toolsets via `TemporalMcpToolSet` | `references/python/integrations/google-adk.md` | `references/python/ai-patterns.md`, `references/core/ai-patterns.md` | | Pydantic AI (`pydantic-ai[temporal]`) | Python | Durable agents through the `TemporalDurability` capability, with model requests, tool calls, and MCP communication executed as Temporal Activities | `references/python/integrations/pydantic-ai.md` | `references/python/ai-patterns.md`, `references/core/ai-patterns.md` | | OpenTelemetry (`temporalio[opentelemetry]`) | Python | Distributed tracing for Temporal apps with OpenTelemetry | `references/python/observability.md` (Distributed Tracing section) | | +| Mastra (`@mastra/temporal`, Public Preview) | TypeScript | Build-time transform of Mastra `createWorkflow`/`createStep` definitions into Temporal Workflows and Activities; `MastraPlugin` auto-registers Activities on the Worker | `references/typescript/integrations/mastra.md` | `references/typescript/typescript.md`, `references/core/ai-patterns.md` | | Vercel AI SDK (`@temporalio/ai-sdk`, Public Preview) | TypeScript | Durable Vercel AI SDK agents: `AiSdkPlugin` wraps `generateText` and other AI SDK calls as Activities; `temporalProvider.languageModel()` provides the workflow-safe model; tools dispatch via `proxyActivities`; stateless MCP servers register through `mcpClientFactories` and are used in-workflow via `TemporalMCPClient` | `references/typescript/integrations/vercel-ai-sdk.md` | `references/core/ai-patterns.md` | diff --git a/references/typescript/integrations/mastra.md b/references/typescript/integrations/mastra.md new file mode 100644 index 0000000..99ec9e6 --- /dev/null +++ b/references/typescript/integrations/mastra.md @@ -0,0 +1,199 @@ +# Temporal Mastra Integration (TypeScript) + +## Overview + +[Mastra](https://mastra.ai/docs) is a TypeScript agent / workflow framework. The `@mastra/temporal` package transforms Mastra workflow and step definitions into Temporal Workflows and Activities at build time, then auto-registers them on a Temporal Worker via the `MastraPlugin`. Each `createStep` becomes a Temporal Activity and each `createWorkflow` becomes a Temporal Workflow. + +Mastra appears on the Temporal TypeScript integrations page as the "Mastra | Agent framework" row, which links out to the upstream Mastra deployment guide. + +> [!NOTE] +> This feature is in Public Preview. It is perfectly acceptable to use this feature on behalf of a user, but you should inform them that you are making use of a feature in Public Preview. The upstream `@mastra/temporal` package is also flagged as "experimental and not ready for production use"; the API may change between releases. + +For Temporal TypeScript SDK fundamentals (Worker, Workflow, Activity, Task Queue, replay), see `references/typescript/typescript.md` and `references/typescript/determinism.md`. + +## Install + +```bash +npm install @mastra/temporal@latest @temporalio/client @temporalio/worker @temporalio/envconfig +``` + +`pnpm`, `yarn`, and `bun` equivalents are all supported. + +## Initialize the integration + +Wire up a Temporal `Client` once at module scope and pass it to `init()` from `@mastra/temporal`. `init()` returns Mastra's `createWorkflow` and `createStep` factories bound to that client and task queue. + +```ts +// src/temporal.ts +import { init } from '@mastra/temporal' +import { Client, Connection } from '@temporalio/client' +import { loadClientConnectConfig } from '@temporalio/envconfig' + +const config = loadClientConnectConfig() +const connection = await Connection.connect(config.connectionOptions) +const client = new Client({ connection }) + +export const { createWorkflow, createStep } = init({ + client, + taskQueue: 'mastra', +}) +``` + +`init()` parameters: + +- `client` — a `@temporalio/client` `Client` instance. +- `taskQueue` — the Task Queue name Mastra-derived Workflows and Activities run on. The same value must be passed to the Worker (below). +- `startToCloseTimeout` — optional. Maximum activity runtime. **Default: 1 minute.** Accepts string values like `'5 minutes'`. + +`loadClientConnectConfig()` from `@temporalio/envconfig` reads `TEMPORAL_ADDRESS`, `TEMPORAL_NAMESPACE`, and `TEMPORAL_API_KEY` from the environment. + +## Define a step and a workflow + +Use the bound `createStep` and `createWorkflow` from `src/temporal.ts`. Each `createStep` becomes a Temporal Activity; each `createWorkflow` becomes a Temporal Workflow. + +```ts +// src/mastra/workflows.ts +import { z } from 'zod' +import { createWorkflow, createStep } from '../temporal' + +const incrementStep = createStep({ + id: 'increment', + inputSchema: z.object({ + value: z.number(), + }), + outputSchema: z.object({ + value: z.number(), + }), + execute: async ({ inputData }) => { + return { value: inputData.value + 1 } + }, +}) + +const workflow = createWorkflow({ + id: 'increment-workflow', + steps: [incrementStep], + inputSchema: z.object({ + value: z.number(), + }), + outputSchema: z.object({ + value: z.number(), + }), +}).then(incrementStep) + +workflow.commit() + +export { workflow as incrementWorkflow } +``` + +- **Workflow `id` must be a static string literal.** The build-time transformer derives each Workflow's Temporal export name from this `id`, so it cannot be a variable, template, or computed value. +- **Call `workflow.commit()` before exporting.** Workflows without `.commit()` are not picked up by the build-time transformer. + +## Register workflows with Mastra + +```ts +// src/mastra/index.ts +import { Mastra } from '@mastra/core' +import { PinoLogger } from '@mastra/loggers' +import { incrementWorkflow } from './workflows' + +export const mastra = new Mastra({ + workflows: { incrementWorkflow }, + logger: new PinoLogger({ name: 'Mastra', level: 'info' }), +}) +``` + +## Worker + +Construct a `MastraPlugin` from `@mastra/temporal/worker`, run its build-time `prebuild` step pointing at the Mastra entry file, then pass the plugin into `Worker.create({ plugins: [...] })`. + +```ts +// src/mastra/worker.ts +import { MastraPlugin } from '@mastra/temporal/worker' +import { NativeConnection, Worker } from '@temporalio/worker' + +const connection = await NativeConnection.connect({ + address: 'localhost:7233', +}) + +const mastraPlugin = new MastraPlugin() + +await mastraPlugin.prebuild({ + entryFile: import.meta.resolve('./index.ts'), +}) + +const worker = await Worker.create({ + connection, + namespace: 'default', + taskQueue: 'mastra', + plugins: [mastraPlugin], +}) + +await worker.run() +``` + +- **Don't pass `activities` to `Worker.create`.** `MastraPlugin` auto-registers every Activity derived from `createStep` after `prebuild` runs. +- **`taskQueue` on the Worker must match the `taskQueue` passed to `init()`.** Both sides target the same queue. +- **`prebuild({ entryFile })` is a build-time transform.** It must complete before `Worker.create`; pass the path to the Mastra entry file (`src/mastra/index.ts` above). + +## Run a workflow + +Resolve the workflow off the configured `mastra` instance and start a run. The bound client routes execution through Temporal. + +```ts +// scripts/run.ts +import { mastra } from '../src/mastra' + +const run = await mastra.getWorkflow('incrementWorkflow').createRun() +const result = await run.start({ inputData: { value: 5 } }) + +console.log(result) +``` + +## Local development + +```bash +docker run --rm -p 7233:7233 -p 8080:8080 temporalio/auto-setup:latest +``` + +Run the worker in another terminal: + +```bash +npx tsx src/mastra/worker.ts +``` + +The Temporal UI is available at `http://localhost:8080`. + +## Environment variables + +`@temporalio/envconfig`'s `loadClientConnectConfig()` consumes these variables when wiring the Client connection: + +- `TEMPORAL_ADDRESS` +- `TEMPORAL_NAMESPACE` +- `TEMPORAL_API_KEY` + +## Hard constraints + +- **Workflow `id` must be a static string literal.** Pass a literal to `createWorkflow({ id: 'my-workflow', ... })`; the build-time transformer derives the Temporal export name from it. +- **Don't pass `activities` to `Worker.create`.** `MastraPlugin` auto-registers Activities; manual registration conflicts with the plugin. +- **`mastraPlugin.prebuild({ entryFile })` must run before `Worker.create`.** The transform produces the Workflow and Activity definitions the Worker hosts. +- **Temporal Workers require a long-lived process.** Don't deploy the worker to serverless platforms that hibernate between requests. + +## Common mistakes + +- Importing `MastraPlugin` from `@mastra/temporal` instead of `@mastra/temporal/worker`. +- Passing `activities` to `Worker.create` alongside `MastraPlugin`. +- Forgetting `workflow.commit()` after `.then(step)` — the transformer skips uncommitted workflows. +- Using a computed `id` on `createWorkflow` — breaks the build-time transformer's Temporal export naming. +- Mismatching `taskQueue` between `init()` and `Worker.create`. +- Calling `MastraPlugin` without first running `prebuild({ entryFile })`. + +## Out of scope + +The upstream Mastra Temporal guide covers `createWorkflow` and `createStep` only. Mastra Agents, Tools, Memory, RAG, evals, and Mastra Studio are **not** documented as participating in this Temporal integration. + +For language-agnostic AI/LLM orchestration patterns (centralized retries, tool placement, multi-agent), see `references/core/ai-patterns.md`. + +## Resources + +- Temporal TypeScript integrations index: +- Upstream Mastra deployment guide: From 56cba7798fad5c60bdcf72eb321f7a990408ebf1 Mon Sep 17 00:00:00 2001 From: "skill-temporal-developer-updater[bot]" <276371939+skill-temporal-developer-updater[bot]@users.noreply.github.com> Date: Wed, 19 Aug 2026 15:44:49 -0700 Subject: [PATCH 72/82] Implement planned topic: 0039-braintrust (#247) * Add Python Braintrust integration reference Authoring topic 0039-braintrust. Adds references/python/integrations/braintrust.md grounded in docs/develop/python/integrations/braintrust.mdx covering install, BraintrustPlugin registration on Client + Worker, wrap_openai, start_span, and load_prompt with fallback. Co-Authored-By: Claude Opus 4.7 * Add TypeScript Braintrust integration reference Authoring topic 0039-braintrust. Adds references/typescript/integrations/braintrust.md grounded in the Temporal TS integrations index and the canonical Braintrust-hosted guide. Covers @braintrust/temporal install, initLogger, and BraintrustTemporalPlugin registration on Client + Worker. Marks wrapTraced/startSpan/loadPrompt details as VERIFY since the Temporal docs link out for the TS API surface. Co-Authored-By: Claude Opus 4.7 * Add Braintrust rows to integrations catalog Append Python and TypeScript rows linking to the new Braintrust integration reference files. Python row notes Public Preview status. Co-Authored-By: Claude Opus 4.7 * Finalize draft for 0039-braintrust * Remove Braintrust familiarity prerequisite * Use uv add for Braintrust dependency * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: skill-sync[bot] Co-authored-by: Claude Opus 4.7 Co-authored-by: Brian Strauch Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- references/integrations.md | 2 + references/python/integrations/braintrust.md | 197 ++++++++++++++++++ .../typescript/integrations/braintrust.md | 81 +++++++ 3 files changed, 280 insertions(+) create mode 100644 references/python/integrations/braintrust.md create mode 100644 references/typescript/integrations/braintrust.md diff --git a/references/integrations.md b/references/integrations.md index 8c2061a..6d31bba 100644 --- a/references/integrations.md +++ b/references/integrations.md @@ -21,5 +21,7 @@ Temporal ships and supports a growing set of integrations with third-party frame | Google ADK (`temporalio[google-adk]`) | Python | Durable Google ADK agents: model calls run through `TemporalModel`-wrapped Activities, tools via `activity_tool`, MCP toolsets via `TemporalMcpToolSet` | `references/python/integrations/google-adk.md` | `references/python/ai-patterns.md`, `references/core/ai-patterns.md` | | Pydantic AI (`pydantic-ai[temporal]`) | Python | Durable agents through the `TemporalDurability` capability, with model requests, tool calls, and MCP communication executed as Temporal Activities | `references/python/integrations/pydantic-ai.md` | `references/python/ai-patterns.md`, `references/core/ai-patterns.md` | | OpenTelemetry (`temporalio[opentelemetry]`) | Python | Distributed tracing for Temporal apps with OpenTelemetry | `references/python/observability.md` (Distributed Tracing section) | | +| Braintrust (`braintrust[temporal]`, Public Preview) | Python | LLM observability + prompt management: `BraintrustPlugin` traces every Workflow/Activity, `wrap_openai` captures LLM calls, `start_span` adds custom context, `load_prompt` fetches Braintrust-managed prompts | `references/python/integrations/braintrust.md` | `references/python/ai-patterns.md`, `references/core/ai-patterns.md` | +| Braintrust (`@braintrust/temporal`) | TypeScript | LLM observability: `BraintrustTemporalPlugin` registers on Client + Worker to trace Workflow/Activity spans; canonical guide hosted by Braintrust | `references/typescript/integrations/braintrust.md` | `references/core/ai-patterns.md` | | Mastra (`@mastra/temporal`, Public Preview) | TypeScript | Build-time transform of Mastra `createWorkflow`/`createStep` definitions into Temporal Workflows and Activities; `MastraPlugin` auto-registers Activities on the Worker | `references/typescript/integrations/mastra.md` | `references/typescript/typescript.md`, `references/core/ai-patterns.md` | | Vercel AI SDK (`@temporalio/ai-sdk`, Public Preview) | TypeScript | Durable Vercel AI SDK agents: `AiSdkPlugin` wraps `generateText` and other AI SDK calls as Activities; `temporalProvider.languageModel()` provides the workflow-safe model; tools dispatch via `proxyActivities`; stateless MCP servers register through `mcpClientFactories` and are used in-workflow via `TemporalMCPClient` | `references/typescript/integrations/vercel-ai-sdk.md` | `references/core/ai-patterns.md` | diff --git a/references/python/integrations/braintrust.md b/references/python/integrations/braintrust.md new file mode 100644 index 0000000..bf26d5a --- /dev/null +++ b/references/python/integrations/braintrust.md @@ -0,0 +1,197 @@ +# Temporal Braintrust Integration (Python) + +## Overview + +[Braintrust](https://braintrust.dev) is an LLM observability and prompt-management platform. The Temporal Python SDK integrates with it through `braintrust.contrib.temporal.BraintrustPlugin`, which traces every Workflow and Activity as a span in Braintrust and links client-initiated spans to the Workflows they start. + +> [!NOTE] +> This feature is in Public Preview. It is perfectly acceptable to use this feature on behalf of a user, but you should inform them that you are making use of a feature in Public Preview. + +For Python AI patterns (Pydantic data converter, disabling client-side LLM retries, generic LLM Activity shape) read `references/python/ai-patterns.md`. For conceptual LLM patterns shared across SDKs read `references/core/ai-patterns.md`. + +## Prerequisites + +- An existing Temporal Python development environment as described in `references/python/python.md`. + +## Install + +```bash +uv add "braintrust[temporal]" +``` + +## Initialize the logger before the Client or Worker + +The Braintrust logger must be initialized **before** the Temporal Client and Worker are constructed so that spans connect correctly. + +```python +import os +from braintrust import init_logger + +init_logger(project=os.environ.get("BRAINTRUST_PROJECT", "my-project")) +``` + +`init_logger` takes a `project` argument that names the Braintrust project traces are written to. + +## Register `BraintrustPlugin` on the Client and the Worker + +Register `BraintrustPlugin` on **both** the Client and every Worker. The Worker registration produces Workflow/Activity spans; the Client registration propagates span context so client-side spans link to the Workflow they start. + +Client: + +```python +from temporalio.client import Client +from braintrust.contrib.temporal import BraintrustPlugin + +client = await Client.connect( + "localhost:7233", + plugins=[BraintrustPlugin()], +) +``` + +Worker: + +```python +from braintrust.contrib.temporal import BraintrustPlugin +from temporalio.worker import Worker + +worker = Worker( + client, + task_queue="my-task-queue", + workflows=[MyWorkflow], + activities=[my_activity], + plugins=[BraintrustPlugin()], +) +``` + +## API credentials + +The Worker process needs `BRAINTRUST_API_KEY` in its environment. The Client process that starts Workflow Executions does **not** need the Braintrust API key. + +```bash +export BRAINTRUST_API_KEY="your-api-key" +python worker.py +``` + +## Trace LLM calls with `wrap_openai` + +Wrap the OpenAI client with `braintrust.wrap_openai` so every chat/completion call is captured as a span with inputs, outputs, token counts, and latency. Pass `max_retries=0` so Temporal — not the OpenAI client — owns retries. + +```python +from braintrust import wrap_openai +from openai import AsyncOpenAI +from temporalio import activity + +@activity.defn +async def invoke_model(prompt: str) -> str: + client = wrap_openai(AsyncOpenAI(max_retries=0)) + + response = await client.chat.completions.create( + model="gpt-4o", + messages=[ + {"role": "system", "content": "You are a helpful assistant."}, + {"role": "user", "content": prompt}, + ], + ) + + return response.choices[0].message.content +``` + +The resulting trace nests the OpenAI span under the Activity span, which sits under the Workflow span, which sits under the client-side span: + +``` +my-workflow-request (client span) +└── temporal.workflow.MyWorkflow + └── temporal.activity.invoke_model + └── Chat Completion (gpt-4o) +``` + +## Add custom spans with `start_span` + +Use `braintrust.start_span` from client code to capture application-level context (the user query, the final result) alongside the Workflow/Activity spans the plugin produces. + +```python +import uuid +from braintrust import start_span + +async def run_research(query: str): + with start_span(name="research-request", type="task") as span: + span.log(input={"query": query}) + + result = await client.execute_workflow( + ResearchWorkflow.run, + query, + id=f"research-{uuid.uuid4()}", + task_queue="research-task-queue", + ) + + span.log(output={"result": result}) + return result +``` + +## Manage prompts with `load_prompt` + +`braintrust.load_prompt(project=..., slug=...)` fetches a prompt managed in the Braintrust UI, so prompt edits go live without redeploying Workflow or Activity code. Call it from an Activity (model calls live in Activities), then call `prompt.build()` to get the prompt configuration; extract the message you need before invoking the LLM. + +```python +import os +import braintrust +from braintrust import wrap_openai +from openai import AsyncOpenAI +from temporalio import activity + +@activity.defn +async def invoke_model(prompt_slug: str, user_input: str) -> str: + prompt = braintrust.load_prompt( + project=os.environ.get("BRAINTRUST_PROJECT", "my-project"), + slug=prompt_slug, + ) + + built = prompt.build() + + system_content = "You are a helpful assistant." + for msg in built.get("messages", []): + if msg.get("role") == "system" and msg.get("content"): + system_content = msg["content"] + break + + client = wrap_openai(AsyncOpenAI(max_retries=0)) + + response = await client.chat.completions.create( + model="gpt-4o", + messages=[ + {"role": "system", "content": system_content}, + {"role": "user", "content": user_input}, + ], + ) + + return response.choices[0].message.content +``` + +### Fallback prompt for resilience + +Wrap `load_prompt` in a `try`/`except` and fall back to a hardcoded prompt so the Activity still runs if Braintrust is unreachable. + +```python +DEFAULT_SYSTEM_PROMPT = "You are a helpful assistant." + +try: + prompt = braintrust.load_prompt(project="my-project", slug="my-prompt") + system_content = extract_system_message(prompt.build()) +except Exception as e: + activity.logger.warning(f"Failed to load prompt: {e}. Using fallback.") + system_content = DEFAULT_SYSTEM_PROMPT +``` + +## Common mistakes + +- **Initializing the Braintrust logger after constructing the Client or Worker.** Call `init_logger(...)` first; otherwise spans don't connect to the Worker process. +- **Registering `BraintrustPlugin` on only the Worker (or only the Client).** Register on both — the Client registration is what links client-side spans to Workflow executions. +- **Forgetting `max_retries=0` on the wrapped OpenAI client.** Temporal owns retries; leaving the OpenAI client's built-in retries on duplicates work and obscures retry counts in traces. +- **Calling `load_prompt` from inside a Workflow.** Prompt loading is an external I/O call; keep it in an Activity. +- **Setting `BRAINTRUST_API_KEY` only on the Client process.** The Worker is what calls Braintrust; the Client doesn't need the key. + +## Additional Resources + +- `references/python/ai-patterns.md` — Python LLM patterns (Pydantic, retry discipline, generic LLM Activity shape). +- `references/core/ai-patterns.md` — Conceptual LLM patterns shared across SDKs. +- [Deep research sample](https://github.com/braintrustdata/braintrust-cookbook/blob/main/examples/TemporalDeepResearch/TemporalDeepResearch.mdx) — end-to-end agent showing `BraintrustPlugin`, `wrap_openai`, `start_span`, and `load_prompt`. diff --git a/references/typescript/integrations/braintrust.md b/references/typescript/integrations/braintrust.md new file mode 100644 index 0000000..7e60cd8 --- /dev/null +++ b/references/typescript/integrations/braintrust.md @@ -0,0 +1,81 @@ +# Temporal Braintrust Integration (TypeScript) + +## Overview + +[Braintrust](https://braintrust.dev) is an LLM observability and prompt-management platform. The Temporal TypeScript integration is delivered as the `@braintrust/temporal` package, which exposes a `BraintrustTemporalPlugin` that registers on both the Temporal Client and the Worker. Once registered, the plugin produces Braintrust spans for Workflow and Activity executions and propagates trace context across the Worker boundary. + +The Temporal TypeScript documentation lists Braintrust as a supported integration and points to the Braintrust-hosted guide as the canonical reference. + +> Canonical TypeScript guide: . Treat the Braintrust-hosted page as authoritative for TypeScript-specific API surface; this reference file captures only what is independently verifiable from Temporal's documentation and the canonical guide. + +For conceptual LLM patterns shared across SDKs read `references/core/ai-patterns.md`. + +## Prerequisites + +- An existing Temporal TypeScript development environment as described in `references/typescript/typescript.md`. +- Temporal TypeScript SDK 2.1.0 or later. +- A Braintrust account. + +## Install + +```bash +npm install @braintrust/temporal braintrust @temporalio/client @temporalio/worker @temporalio/workflow @temporalio/activity @temporalio/common +``` + +The integration package is `@braintrust/temporal`; it sits alongside the standard `braintrust` SDK and the relevant `@temporalio/*` packages. + +## Initialize the Braintrust logger + +Initialize the Braintrust logger before constructing the Temporal Client and Worker so spans connect to the active project. + +```typescript +import * as braintrust from "braintrust"; + +braintrust.initLogger({ projectName: "my-project" }); +``` + +## Register `BraintrustTemporalPlugin` on the Client and the Worker + +Create one `BraintrustTemporalPlugin` instance and pass it to **both** the Client and the Worker via `plugins`. + +```typescript +import { Client, Connection } from "@temporalio/client"; +import { Worker } from "@temporalio/worker"; +import { BraintrustTemporalPlugin } from "@braintrust/temporal"; +import * as activities from "./activities"; + +const plugin = new BraintrustTemporalPlugin(); + +const client = new Client({ + connection: await Connection.connect(), + plugins: [plugin], +}); + +const worker = await Worker.create({ + taskQueue: "my-task-queue", + workflowsPath: require.resolve("./workflows"), + activities, + plugins: [plugin], +}); +``` + +The Client registration links client-initiated spans to the Workflow Executions they start. The Worker registration produces the Workflow and Activity spans inside Braintrust. + +## What Braintrust traces + +The plugin captures: + +- Workflow execution spans named `temporal.workflow.`, including Workflow type, ID, run ID, and errors. +- Activity execution spans named `temporal.activity.`, including Activity type, ID, result, errors, and parent Workflow metadata. +- Trace context propagated through Temporal headers to Activities, Local Activities, and Child Workflows. +- Parent-child relationships across Client calls, Workflows, and Activities. + +## Common mistakes + +- **Initializing the Braintrust logger after constructing the Client or Worker.** Call `braintrust.initLogger({ projectName: ... })` first so the Worker process attaches spans to the correct project. +- **Registering `BraintrustTemporalPlugin` on only one side.** Register on both the Client and the Worker so client-side spans link to the Workflows they start. + +## Additional Resources + +- Canonical TypeScript guide: . +- `references/core/ai-patterns.md` — conceptual LLM patterns shared across SDKs. From 559ec2842b31f1b2364ce1365c082dd60b493404 Mon Sep 17 00:00:00 2001 From: Donald Pinckney Date: Thu, 20 Aug 2026 11:29:32 -0400 Subject: [PATCH 73/82] Add TypeScript OpenTelemetry integration docs (#245) * Add TypeScript OpenTelemetry integration docs Split out from the OpenTelemetry plugins topic (PR #243) so the TypeScript material can be finalized separately. Adds the TS OTel integration reference, the Distributed Tracing section in TS observability, and the TS row in the integrations catalog. Co-Authored-By: Claude Opus 4.8 (1M context) * fix: align python with ts skill --------- Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: Patrik Beqo Co-authored-by: Patrik Beqo --- references/integrations.md | 3 +- .../python/integrations/opentelemetry.md | 63 +++++++++++++++ references/python/observability.md | 47 +---------- .../typescript/integrations/opentelemetry.md | 77 +++++++++++++++++++ references/typescript/observability.md | 9 ++- 5 files changed, 153 insertions(+), 46 deletions(-) create mode 100644 references/python/integrations/opentelemetry.md create mode 100644 references/typescript/integrations/opentelemetry.md diff --git a/references/integrations.md b/references/integrations.md index 6d31bba..af5953b 100644 --- a/references/integrations.md +++ b/references/integrations.md @@ -20,7 +20,8 @@ Temporal ships and supports a growing set of integrations with third-party frame | LangGraph (`temporalio.contrib.langgraph`, Pre-release) | Python | Runs LangGraph Graph-API and Functional-API code as Temporal Workflows - nodes/tasks can execute as either in-workflow or as Activities | `references/python/integrations/langgraph.md` | `references/python/ai-patterns.md`, `references/core/ai-patterns.md` | | Google ADK (`temporalio[google-adk]`) | Python | Durable Google ADK agents: model calls run through `TemporalModel`-wrapped Activities, tools via `activity_tool`, MCP toolsets via `TemporalMcpToolSet` | `references/python/integrations/google-adk.md` | `references/python/ai-patterns.md`, `references/core/ai-patterns.md` | | Pydantic AI (`pydantic-ai[temporal]`) | Python | Durable agents through the `TemporalDurability` capability, with model requests, tool calls, and MCP communication executed as Temporal Activities | `references/python/integrations/pydantic-ai.md` | `references/python/ai-patterns.md`, `references/core/ai-patterns.md` | -| OpenTelemetry (`temporalio[opentelemetry]`) | Python | Distributed tracing for Temporal apps with OpenTelemetry | `references/python/observability.md` (Distributed Tracing section) | | +| OpenTelemetry (`temporalio[opentelemetry]`) | Python | Distributed tracing for Temporal apps with OpenTelemetry | `references/python/integrations/opentelemetry.md` | `references/python/observability.md` | +| OpenTelemetry (`@temporalio/interceptors-opentelemetry`) | TypeScript | Distributed tracing for Temporal apps with OpenTelemetry | `references/typescript/integrations/opentelemetry.md` | `references/typescript/observability.md` | | Braintrust (`braintrust[temporal]`, Public Preview) | Python | LLM observability + prompt management: `BraintrustPlugin` traces every Workflow/Activity, `wrap_openai` captures LLM calls, `start_span` adds custom context, `load_prompt` fetches Braintrust-managed prompts | `references/python/integrations/braintrust.md` | `references/python/ai-patterns.md`, `references/core/ai-patterns.md` | | Braintrust (`@braintrust/temporal`) | TypeScript | LLM observability: `BraintrustTemporalPlugin` registers on Client + Worker to trace Workflow/Activity spans; canonical guide hosted by Braintrust | `references/typescript/integrations/braintrust.md` | `references/core/ai-patterns.md` | | Mastra (`@mastra/temporal`, Public Preview) | TypeScript | Build-time transform of Mastra `createWorkflow`/`createStep` definitions into Temporal Workflows and Activities; `MastraPlugin` auto-registers Activities on the Worker | `references/typescript/integrations/mastra.md` | `references/typescript/typescript.md`, `references/core/ai-patterns.md` | diff --git a/references/python/integrations/opentelemetry.md b/references/python/integrations/opentelemetry.md new file mode 100644 index 0000000..efdd8c4 --- /dev/null +++ b/references/python/integrations/opentelemetry.md @@ -0,0 +1,63 @@ +# Temporal OpenTelemetry Integration (Python) + +## Overview + +`temporalio.contrib.opentelemetry` wires OpenTelemetry tracing into Temporal through the `OpenTelemetryPlugin`. It propagates W3C TraceContext + Baggage across Client, Workflow, Activity, and Nexus code and supports replay-safe custom Workflow spans. + +For observability beyond OpenTelemetry tracing (metrics, logging, Search Attributes) read `references/python/observability.md`. + +> [!NOTE] +> This feature is Pre-release. It is acceptable to use it on behalf of a user, but inform them that it is Pre-release. + +## Install the plugin + +Install the `temporalio[opentelemetry]` extra plus the OpenTelemetry exporter packages you use. + +## `OpenTelemetryPlugin` + +Create a replay-safe tracer provider, set it globally before creating the Client, and register the plugin on the Client. Workers created from that Client inherit the plugin automatically. Application spans propagate by default; pass `OpenTelemetryPlugin(add_temporal_spans=True)` to also emit Temporal lifecycle spans. + +```python +import opentelemetry.trace +from opentelemetry.sdk.trace.export import ConsoleSpanExporter, SimpleSpanProcessor +from temporalio.client import Client +from temporalio.contrib.opentelemetry import OpenTelemetryPlugin, create_tracer_provider + +provider = create_tracer_provider() +provider.add_span_processor(SimpleSpanProcessor(ConsoleSpanExporter())) +opentelemetry.trace.set_tracer_provider(provider) + +client = await Client.connect( + "localhost:7233", + plugins=[OpenTelemetryPlugin()], +) +``` + +Inside a Workflow, use standard OpenTelemetry APIs to create custom replay-safe spans: + +```python +from datetime import timedelta +from opentelemetry.trace import get_tracer +from temporalio import workflow + +@workflow.defn +class MyWorkflow: + @workflow.run + async def run(self) -> None: + tracer = get_tracer(__name__) + with tracer.start_as_current_span("workflow-operation"): + await workflow.execute_activity( + my_activity, + start_to_close_timeout=timedelta(seconds=30), + ) +``` + +## Common mistakes + +- **Registering the same plugin on both Client and Worker.** Register on the Client only; Workers inherit it. +- **Creating a Workflow Worker before installing the replay-safe global provider.** Set the provider returned by `create_tracer_provider(...)` globally before constructing a Worker that uses `OpenTelemetryPlugin`. +- **Building a plain `opentelemetry.sdk.trace.TracerProvider` and passing it to `set_tracer_provider`.** `OpenTelemetryPlugin` requires a `ReplaySafeTracerProvider`; build it with `create_tracer_provider(...)`. + +## Resources + +- SDK metrics and observability reference: `references/python/observability.md` diff --git a/references/python/observability.md b/references/python/observability.md index 5ad5e18..ab271b8 100644 --- a/references/python/observability.md +++ b/references/python/observability.md @@ -4,7 +4,7 @@ The Python SDK provides comprehensive observability through logging, metrics, tracing (OpenTelemetry), and visibility (Search Attributes). -These pillars are complementary: **logging** (below) captures discrete events, **metrics** capture aggregate health, **tracing** stitches a single request across Client/Workflow/Activity boundaries, and **Search Attributes** make executions queryable. +These pillars are complementary: **logging** (below) captures discrete events, **metrics** capture aggregate worker health, **tracing** stitches a single request across Client/Workflow/Activity/Nexus boundaries, and **Search Attributes** make executions queryable. ## Logging @@ -98,48 +98,7 @@ Runtime.set_default(runtime, error_if_already_set=True) ## Distributed Tracing (OpenTelemetry) -> [!NOTE] -> This feature is in Public Preview. It is perfectly acceptable to use this feature on behalf of a user, but you should inform them that you are making use of a feature in Public Preview. - -OpenTelemetry is the supported way to add distributed tracing to Temporal applications. The `OpenTelemetryPlugin` (from `temporalio.contrib.opentelemetry`, installed via the `temporalio[opentelemetry]` extra) propagates W3C TraceContext + Baggage through Temporal headers across Client, Workflow, Activity (including Standalone), and Child Workflow boundaries, so one trace follows a request through your whole execution — with replay-safe, accurate span durations. - -```python -import opentelemetry.trace -from opentelemetry.sdk.trace.export import ConsoleSpanExporter, SimpleSpanProcessor -from temporalio.client import Client -from temporalio.contrib.opentelemetry import OpenTelemetryPlugin, create_tracer_provider - -provider = create_tracer_provider() -provider.add_span_processor(SimpleSpanProcessor(ConsoleSpanExporter())) # attach your span processors as normal for OTel -opentelemetry.trace.set_tracer_provider(provider) - -client = await Client.connect("localhost:7233", plugins=[OpenTelemetryPlugin()]) -``` - -Workers created from this Client inherit the plugin automatically. Inside a Workflow you then use standard OpenTelemetry APIs (`get_tracer(...).start_as_current_span(...)`); pass `OpenTelemetryPlugin(add_temporal_spans=True)` to also emit `StartWorkflow` / `RunWorkflow` / `StartActivity` / `RunActivity` spans automatically alongside the SDK metrics above. - -```python -from datetime import timedelta -from opentelemetry.trace import get_tracer -from temporalio import workflow - -@workflow.defn -class MyWorkflow: - @workflow.run - async def run(self) -> None: - tracer = get_tracer(__name__) - with tracer.start_as_current_span("workflow-operation"): - await workflow.execute_activity( - my_activity, - start_to_close_timeout=timedelta(seconds=30), - ) -``` - -**Common mistakes:** - -- **Registering the same plugin on both Client and Worker.** Register on the Client only; Workers inherit. -- **Calling `Client.connect` before `opentelemetry.trace.set_tracer_provider(provider)`.** `OpenTelemetryPlugin` raises an exception unless the global tracer provider is already set. -- **Building a plain `opentelemetry.sdk.trace.TracerProvider` and passing it to `set_tracer_provider`.** `OpenTelemetryPlugin` requires a `ReplaySafeTracerProvider` — build it via `create_tracer_provider(...)`. +See `references/python/integrations/opentelemetry.md`. ## Search Attributes (Visibility) @@ -151,4 +110,4 @@ See the Search Attributes section of `references/python/data-handling.md` 2. Don't use print() in workflows - it will produce duplicate output on replay 3. Configure metrics for production monitoring 4. Use Search Attributes for business-level visibility -5. Use the `OpenTelemetryPlugin` for distributed tracing across Client/Workflow/Activity boundaries. +5. Use the `OpenTelemetryPlugin` for distributed tracing across Client/Workflow/Activity/Nexus boundaries. diff --git a/references/typescript/integrations/opentelemetry.md b/references/typescript/integrations/opentelemetry.md new file mode 100644 index 0000000..c11afb2 --- /dev/null +++ b/references/typescript/integrations/opentelemetry.md @@ -0,0 +1,77 @@ +# Temporal OpenTelemetry Integration (TypeScript) + +## Overview + +`@temporalio/interceptors-opentelemetry` wires OpenTelemetry tracing into Temporal through the `OpenTelemetryPlugin`. It traces Client, Workflow, Activity, and Nexus code, propagating W3C TraceContext + Baggage across all of them. + +Workflow-side spans are emitted out of the Workflow isolate through an injected Sink that hands serialized spans to a host-side `SpanProcessor`. + +For observability beyond OpenTelemetry tracing (metrics, runtime logger, sinks) read `references/typescript/observability.md`. + +> [!NOTE] +> This feature is in Public Preview. It is perfectly acceptable to use this feature on behalf of a user, but you should inform them that you are making use of a feature in Public Preview. + +## Install the plugin + +Install `@temporalio/interceptors-opentelemetry` plus the OpenTelemetry peer packages you use — typically `@opentelemetry/api`, `@opentelemetry/sdk-trace-base`, and `@opentelemetry/resources` (plus an exporter package such as `@opentelemetry/exporter-trace-otlp-grpc` when you ship spans to a collector). + +## `OpenTelemetryPlugin` + +Construct one `OpenTelemetryPlugin` and pass it to the Client, `bundleWorkflowCode`, and `Worker.create`. It must reach `bundleWorkflowCode` so the Workflow-side interceptors are included in the bundle. Lifecycle spans (workflow / activity / client / nexus) are then created automatically. + +```ts +import { Resource } from '@opentelemetry/resources'; +import { BasicTracerProvider, ConsoleSpanExporter, SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base'; +import { NativeConnection, Worker, bundleWorkflowCode } from '@temporalio/worker'; +import { OpenTelemetryPlugin } from '@temporalio/interceptors-opentelemetry'; + +const resource = new Resource({ 'service.name': 'orders-worker' }); +const spanProcessor = new SimpleSpanProcessor(new ConsoleSpanExporter()); // swap in your own exporter + +const provider = new BasicTracerProvider({ resource }); +provider.addSpanProcessor(spanProcessor); +provider.register(); + +// `resource` and `spanProcessor` are required; pass an optional `tracer` to override +// the tracer used by the Client/Activity interceptors. +const plugin = new OpenTelemetryPlugin({ resource, spanProcessor }); + +const bundle = await bundleWorkflowCode({ + workflowsPath: require.resolve('./workflows'), + plugins: [plugin], +}); + +const connection = await NativeConnection.connect(); +const worker = await Worker.create({ + connection, + taskQueue: 'orders', + workflowBundle: bundle, + activities: { /* ... */ }, + plugins: [plugin], +}); +await worker.run(); +``` + +Pass the same plugin to the Client so client-side calls are traced: + +```ts +import { Client, Connection } from '@temporalio/client'; + +const client = new Client({ + connection: await Connection.connect(), + plugins: [plugin], +}); +``` + +The SDK uses the global OpenTelemetry propagator (default: W3C TraceContext + Baggage). To use a non-default propagator (e.g. Jaeger), call `propagation.setGlobalPropagator(...)` at the top level of your Workflow code BEFORE the Worker bundles it. + +## Common mistakes + +- **Passing only `resource` or only `spanProcessor`.** Both are required; `new OpenTelemetryPlugin()` with no argument throws. +- **Passing the plugin to `Worker.create` but not `bundleWorkflowCode`.** Workflow-side interceptors must be in the bundle. +- **Installing `@temporalio/opentelemetry`.** The package is `@temporalio/interceptors-opentelemetry`. +- **Expecting a non-default propagator (e.g. Jaeger) to work without setting the global propagator before `bundleWorkflowCode` runs.** + +## Resources + +- SDK metrics / observability reference: `references/typescript/observability.md` diff --git a/references/typescript/observability.md b/references/typescript/observability.md index 211fbc6..d5c8a77 100644 --- a/references/typescript/observability.md +++ b/references/typescript/observability.md @@ -2,7 +2,9 @@ ## Overview -The TypeScript SDK provides replay-aware logging, metrics, and integrations for production observability. +The TypeScript SDK provides replay-aware logging, metrics, and distributed tracing (OpenTelemetry) for production observability. + +These pillars are complementary: **logging** (below) captures discrete events, **metrics** capture aggregate worker health, **tracing** stitches a single request across Client/Workflow/Activity/Nexus boundaries, and **Search Attributes** make executions queryable. ## Replay-Aware Logging @@ -100,6 +102,10 @@ Runtime.install({ }); ``` +## Distributed Tracing (OpenTelemetry) + +See `references/typescript/integrations/opentelemetry.md`. + ## Search Attributes (Visibility) See the Search Attributes section of `references/typescript/data-handling.md` @@ -111,3 +117,4 @@ See the Search Attributes section of `references/typescript/data-handling.md` 3. Configure Winston or similar for production log aggregation 4. Monitor Prometheus metrics for worker health 5. Use Event History for debugging workflow issues +6. Use the `OpenTelemetryPlugin` for distributed tracing across Client/Workflow/Activity/Nexus boundaries. From 27b35d90b51ae0554e3350739c746b0c760fdbec Mon Sep 17 00:00:00 2001 From: Brian Strauch Date: Thu, 20 Aug 2026 10:44:17 -0700 Subject: [PATCH 74/82] Fix Python reference guidance from issue #256 (#259) * Fix Python reference guidance from issue 256 * Correct Worker Versioning parameter and Build ID references Split the Python Worker Versioning parameter list into one list per class. build_id is a field of WorkerDeploymentVersion, not a parameter of WorkerDeploymentConfig, so listing it alongside version and use_worker_versioning invited WorkerDeploymentConfig(build_id=...), which raises TypeError. Also adds the previously missing default_versioning_behavior parameter. Drop the claim that a Build ID is "not the legacy compatibility-set API". A Build ID is an identifier rather than an API, and Build IDs are used by both the legacy compatibility-set model and the current Worker Deployment model, so the clause implied the opposite of the intended disambiguation. Verified against the temporalio 1.31.0 wheel: WorkerDeploymentConfig (temporalio/worker/_worker.py) declares version, use_worker_versioning, and default_versioning_behavior; WorkerDeploymentVersion (temporalio/common.py) declares deployment_name and build_id. Co-Authored-By: Claude Opus 5 (1M context) * Apply suggestions from code review Co-authored-by: Brian Strauch --------- Co-authored-by: Claude Opus 5 (1M context) --- references/core/versioning.md | 15 ++++++++++----- references/python/advanced-features.md | 2 +- references/python/versioning.md | 21 +++++++++++++-------- references/typescript/versioning.md | 2 -- 4 files changed, 24 insertions(+), 16 deletions(-) diff --git a/references/core/versioning.md b/references/core/versioning.md index 06b3663..d5b0863 100644 --- a/references/core/versioning.md +++ b/references/core/versioning.md @@ -8,7 +8,7 @@ Workflow versioning allows safe deployment of code changes without breaking runn 1. **Patching API** - Code-level version branching 2. **Workflow Type Versioning** - New workflow types for incompatible changes -3. **Worker Versioning** - Deployment-level control with Build IDs +3. **Worker Versioning** - Deployment-level routing with Worker Deployment Versions ## Why Versioning is Needed @@ -101,13 +101,16 @@ Create a new workflow type (e.g., `OrderWorkflowV2`) instead of patching. ### Concept -Manage versions at deployment level using Build IDs. Multiple worker versions can run simultaneously. +Manage versions through Worker Deployments. Multiple Worker Deployment Versions can run simultaneously, and each version is identified by a deployment name and Build ID. + +> [!IMPORTANT] +> This is the current Worker Deployment-based versioning model. Do not confuse it with the legacy Build ID-based Worker Versioning APIs, which manage compatibility sets directly. Those APIs are deprecated. ``` -Worker v1.0 (Build ID: abc123) +Worker Deployment Version (deployment: order-service, build: abc123) └── Handles workflows started on this version -Worker v2.0 (Build ID: def456) +Worker Deployment Version (deployment: order-service, build: def456) └── Handles new workflows └── Can also handle upgraded old workflows ``` @@ -116,7 +119,9 @@ Worker v2.0 (Build ID: def456) **Worker Deployment**: Logical service grouping (e.g., "order-service") -**Build ID**: Specific code version (e.g., git commit hash) +**Worker Deployment Version**: A specific snapshot identified by a Worker Deployment name and a Build ID + +**Build ID**: The code-version component of a Worker Deployment Version (e.g., a git commit hash) **Versioning Behaviors**: diff --git a/references/python/advanced-features.md b/references/python/advanced-features.md index 4cb4ad6..38db3f4 100644 --- a/references/python/advanced-features.md +++ b/references/python/advanced-features.md @@ -152,7 +152,7 @@ Normally, your `__init__` must have no arguments. However, if you add the `@work class MyWorkflow: @workflow.init def __init__(self, initial_value: str) -> None: - # This runs only on first execution, not replay + # This runs when the Workflow is instantiated, including during replay self._value = initial_value self._items: list[str] = [] diff --git a/references/python/versioning.md b/references/python/versioning.md index 6616c35..3f4dcdc 100644 --- a/references/python/versioning.md +++ b/references/python/versioning.md @@ -183,6 +183,9 @@ temporal workflow list --query 'WorkflowType = "PizzaWorkflow" AND ExecutionStat Worker Versioning manages versions at the deployment level, allowing multiple Worker versions to run simultaneously. +> [!IMPORTANT] +> Use the Worker Deployment APIs described below. The older Build ID-based APIs manage legacy compatibility sets and are deprecated. + ### Key Concepts **Worker Deployment**: A logical service grouping similar Workers together (e.g., "loan-processor"). All versions of your code live under this umbrella. @@ -192,11 +195,8 @@ Worker Versioning manages versions at the deployment level, allowing multiple Wo ### Configuring Workers for Versioning ```python -from temporalio.worker import Worker -from temporalio.worker.deployment_config import ( - WorkerDeploymentConfig, - WorkerDeploymentVersion, -) +from temporalio.common import WorkerDeploymentVersion +from temporalio.worker import Worker, WorkerDeploymentConfig worker = Worker( client, @@ -213,11 +213,16 @@ worker = Worker( ) ``` -**Configuration parameters:** +`WorkerDeploymentConfig` accepts exactly three parameters: +- `version`: A `WorkerDeploymentVersion` identifying this Worker Deployment Version - `use_worker_versioning`: Enables Worker Versioning -- `version`: Identifies the Worker Deployment Version (deployment name + build ID) -- Build ID: Typically a git commit hash, version number, or timestamp +- `default_versioning_behavior`: Fallback `VersioningBehavior` for Workflows that do not declare one + +`WorkerDeploymentVersion` accepts exactly two parameters: + +- `deployment_name`: The logical service name (e.g., "my-service") +- `build_id`: The code-version component, typically a git commit hash, version number, or timestamp ### PINNED vs AUTO_UPGRADE Behaviors diff --git a/references/typescript/versioning.md b/references/typescript/versioning.md index 6f56bb5..2fdb272 100644 --- a/references/typescript/versioning.md +++ b/references/typescript/versioning.md @@ -148,8 +148,6 @@ After all V1 executions complete, remove the old Workflow function. Worker Versioning allows multiple Worker versions to run simultaneously, routing Workflows to specific versions without code-level patching. Workflows are pinned to the Worker Deployment Version they started on. -> **Note:** Worker Versioning is currently in Public Preview. The legacy Worker Versioning API (before 2025) will be removed from Temporal Server in March 2026. - ### Key Concepts - **Worker Deployment**: A logical name for your application (e.g., "order-service") From 67f41f97d623538fb79d779cbd114ba6ac1faf17 Mon Sep 17 00:00:00 2001 From: Brian Strauch Date: Thu, 20 Aug 2026 12:18:19 -0700 Subject: [PATCH 75/82] Fix .NET worker cancellation example (#260) * Fix .NET worker cancellation example * Simplify .NET worker execution example * Revert "Simplify .NET worker execution example" This reverts commit 83b883601af800628842e41326d2d725a0d5c8e9. --- references/dotnet/dotnet.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/references/dotnet/dotnet.md b/references/dotnet/dotnet.md index 8ed4a53..5da7402 100644 --- a/references/dotnet/dotnet.md +++ b/references/dotnet/dotnet.md @@ -59,13 +59,20 @@ using Temporalio.Worker; var client = await TemporalClient.ConnectAsync(new("localhost:7233")); +using var tokenSource = new CancellationTokenSource(); +Console.CancelKeyPress += (_, eventArgs) => +{ + tokenSource.Cancel(); + eventArgs.Cancel = true; +}; + using var worker = new TemporalWorker( client, new TemporalWorkerOptions("my-task-queue") .AddWorkflow() .AddAllActivities(new MyActivities())); -await worker.ExecuteAsync(); +await worker.ExecuteAsync(tokenSource.Token); ``` **Start the dev server:** Start `temporal server start-dev` in the background. From 3b191bd75436dc1b96fd26c7f33f983138d0ac48 Mon Sep 17 00:00:00 2001 From: Brian Strauch Date: Thu, 20 Aug 2026 13:35:21 -0700 Subject: [PATCH 76/82] Use env-config for quick-start connections (#261) * Use env-config for quick-start connections * Pass namespace from env-config to TypeScript Workers NativeConnection carries no namespace, and WorkerOptions defaults to 'default' when it is omitted. With TEMPORAL_NAMESPACE (or a temporal.toml profile) set, the Worker polled 'default' while the Client used the configured namespace, so the workflow was never picked up. Verified against a dev server with a non-default namespace: the previous snippets left the workflow Running with pollers on 'default'; with namespace: config.namespace both quick starts complete. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- references/dotnet/dotnet.md | 12 ++++++-- references/go/go.md | 10 ++++--- references/java/java.md | 30 +++++++++++++------ references/python/python.md | 15 ++++++---- references/ruby/ruby.md | 17 +++++++---- .../typescript/standalone-activities.md | 3 +- references/typescript/typescript.md | 18 ++++++++--- 7 files changed, 72 insertions(+), 33 deletions(-) diff --git a/references/dotnet/dotnet.md b/references/dotnet/dotnet.md index 5da7402..b9e5a0b 100644 --- a/references/dotnet/dotnet.md +++ b/references/dotnet/dotnet.md @@ -55,9 +55,12 @@ public class GreetingWorkflow ```csharp using Temporalio.Client; +using Temporalio.Common.EnvConfig; using Temporalio.Worker; -var client = await TemporalClient.ConnectAsync(new("localhost:7233")); +var connectOptions = ClientEnvConfig.LoadClientConnectOptions(); +connectOptions.TargetHost ??= "localhost:7233"; +var client = await TemporalClient.ConnectAsync(connectOptions); using var tokenSource = new CancellationTokenSource(); Console.CancelKeyPress += (_, eventArgs) => @@ -83,8 +86,11 @@ await worker.ExecuteAsync(tokenSource.Token); ```csharp using Temporalio.Client; +using Temporalio.Common.EnvConfig; -var client = await TemporalClient.ConnectAsync(new("localhost:7233")); +var connectOptions = ClientEnvConfig.LoadClientConnectOptions(); +connectOptions.TargetHost ??= "localhost:7233"; +var client = await TemporalClient.ConnectAsync(connectOptions); var result = await client.ExecuteWorkflowAsync( (GreetingWorkflow wf) => wf.RunAsync("my name"), @@ -114,7 +120,7 @@ Console.WriteLine($"Result: {result}"); ### Worker Setup -- Connect client, create `TemporalWorker` with workflows and activities +- Load connection settings with `ClientEnvConfig.LoadClientConnectOptions()`, connect the client, and create `TemporalWorker` with workflows and activities - Use `AddWorkflow()` and `AddAllActivities(instance)` or `AddActivity(method)` ### Determinism diff --git a/references/go/go.md b/references/go/go.md index 08c14ee..3e259de 100644 --- a/references/go/go.md +++ b/references/go/go.md @@ -9,7 +9,7 @@ The Temporal Go SDK (`go.temporal.io/sdk`) provides a strongly-typed, idiomatic **Add Dependency:** In your Go module, add the Temporal SDK: ```bash -go get go.temporal.io/sdk +go get go.temporal.io/sdk go.temporal.io/sdk/contrib/envconfig ``` **workflows/greeting.go** - Workflow definition: @@ -67,11 +67,12 @@ import ( "yourmodule/workflows" "go.temporal.io/sdk/client" + "go.temporal.io/sdk/contrib/envconfig" "go.temporal.io/sdk/worker" ) func main() { - c, err := client.Dial(client.Options{}) + c, err := client.Dial(envconfig.MustLoadDefaultClientOptions()) if err != nil { log.Fatalln("Unable to create client", err) } @@ -107,10 +108,11 @@ import ( "github.com/google/uuid" "go.temporal.io/sdk/client" + "go.temporal.io/sdk/contrib/envconfig" ) func main() { - c, err := client.Dial(client.Options{}) + c, err := client.Dial(envconfig.MustLoadDefaultClientOptions()) if err != nil { log.Fatalln("Unable to create client", err) } @@ -157,7 +159,7 @@ func main() { ### Worker Setup -- Create client with `client.Dial(client.Options{})` +- Load file- and environment-based connection settings with `envconfig.MustLoadDefaultClientOptions()`, then pass them to `client.Dial` - Create worker with `worker.New(c, "task-queue", worker.Options{})` - Register workflows and activities - Run with `w.Run(worker.InterruptCh())` diff --git a/references/java/java.md b/references/java/java.md index 593d138..7b7c2f3 100644 --- a/references/java/java.md +++ b/references/java/java.md @@ -12,6 +12,7 @@ Gradle: ```groovy implementation 'io.temporal:temporal-sdk:1.+' +implementation 'io.temporal:temporal-envconfig:1.+' ``` Maven: @@ -22,6 +23,11 @@ Maven: temporal-sdk [1.0,) + + io.temporal + temporal-envconfig + [1.0,) + ``` **GreetActivities.java** - Activity interface: @@ -102,18 +108,19 @@ public class GreetingWorkflowImpl implements GreetingWorkflow { package greetingapp; import io.temporal.client.WorkflowClient; +import io.temporal.envconfig.ClientConfigProfile; import io.temporal.serviceclient.WorkflowServiceStubs; import io.temporal.worker.Worker; import io.temporal.worker.WorkerFactory; public class GreetingWorker { - public static void main(String[] args) { - // Create gRPC stubs for local dev server (localhost:7233) - WorkflowServiceStubs service = WorkflowServiceStubs.newLocalServiceStubs(); - - // Create client - WorkflowClient client = WorkflowClient.newInstance(service); + public static void main(String[] args) throws Exception { + ClientConfigProfile profile = ClientConfigProfile.load(); + WorkflowServiceStubs service = + WorkflowServiceStubs.newServiceStubs(profile.toWorkflowServiceStubsOptions()); + WorkflowClient client = + WorkflowClient.newInstance(service, profile.toWorkflowClientOptions()); // Create factory and worker WorkerFactory factory = WorkerFactory.newInstance(client); @@ -140,15 +147,19 @@ package greetingapp; import io.temporal.client.WorkflowClient; import io.temporal.client.WorkflowOptions; +import io.temporal.envconfig.ClientConfigProfile; import io.temporal.serviceclient.WorkflowServiceStubs; import java.util.UUID; public class Starter { - public static void main(String[] args) { - WorkflowServiceStubs service = WorkflowServiceStubs.newLocalServiceStubs(); - WorkflowClient client = WorkflowClient.newInstance(service); + public static void main(String[] args) throws Exception { + ClientConfigProfile profile = ClientConfigProfile.load(); + WorkflowServiceStubs service = + WorkflowServiceStubs.newServiceStubs(profile.toWorkflowServiceStubsOptions()); + WorkflowClient client = + WorkflowClient.newInstance(service, profile.toWorkflowClientOptions()); GreetingWorkflow workflow = client.newWorkflowStub( GreetingWorkflow.class, @@ -187,6 +198,7 @@ public class Starter { ### Worker Setup +- Load connection settings with `ClientConfigProfile.load()` and use the profile to configure both service stubs and the client - `WorkflowServiceStubs` -- gRPC connection to Temporal Server - `WorkflowClient` -- client used by worker to communicate with server - `WorkerFactory` -- creates Worker instances diff --git a/references/python/python.md b/references/python/python.md index 2528e97..c48c6dc 100644 --- a/references/python/python.md +++ b/references/python/python.md @@ -42,6 +42,7 @@ class GreetingWorkflow: import asyncio import concurrent.futures from temporalio.client import Client +from temporalio.envconfig import ClientConfig from temporalio.worker import Worker # Import the activity and workflow from our other files @@ -49,9 +50,9 @@ from activities.greet import greet from workflows.greeting import GreetingWorkflow async def main(): - # Create client connected to server at the given address - # This is the default port for `temporal server start-dev` - client = await Client.connect("localhost:7233") + connect_config = ClientConfig.load_client_connect_config() + connect_config.setdefault("target_host", "localhost:7233") + client = await Client.connect(**connect_config) # Run the worker with concurrent.futures.ThreadPoolExecutor(max_workers=100) as activity_executor: @@ -77,14 +78,16 @@ if __name__ == "__main__": ```python import asyncio from temporalio.client import Client +from temporalio.envconfig import ClientConfig import uuid # Import the workflow from the previous code from workflows.greeting import GreetingWorkflow async def main(): - # Create client connected to server at the given address - client = await Client.connect("localhost:7233") + connect_config = ClientConfig.load_client_connect_config() + connect_config.setdefault("target_host", "localhost:7233") + client = await Client.connect(**connect_config) # Execute a workflow result = await client.execute_workflow(GreetingWorkflow.run, "my name", id=str(uuid.uuid4()), task_queue="my-task-queue") @@ -119,7 +122,7 @@ See `sync-vs-async.md` for detailed guidance on choosing between sync and async. ### Worker Setup -- Connect client, create Worker with workflows and activities +- Load connection settings with `ClientConfig.load_client_connect_config()`, connect the client, and create a Worker with workflows and activities - Run the worker - Activities can specify custom executor diff --git a/references/ruby/ruby.md b/references/ruby/ruby.md index bc7f9e0..cf771ea 100644 --- a/references/ruby/ruby.md +++ b/references/ruby/ruby.md @@ -37,13 +37,15 @@ end **worker.rb** - Worker setup (imports activity and workflow, runs indefinitely and processes tasks): ```ruby require 'temporalio/client' +require 'temporalio/env_config' require 'temporalio/worker' require_relative 'say_hello_activity' require_relative 'say_hello_workflow' -# Create client connected to server at the given address -# This is the default port for `temporal server start-dev` -client = Temporalio::Client.connect('localhost:7233', 'default') +args, kwargs = Temporalio::EnvConfig::ClientConfig.load_client_connect_options +args[0] ||= 'localhost:7233' +args[1] ||= 'default' +client = Temporalio::Client.connect(*args, **kwargs) # Create and run the worker worker = Temporalio::Worker.new( @@ -62,11 +64,14 @@ worker.run **execute_workflow.rb** - Start a workflow execution: ```ruby require 'temporalio/client' +require 'temporalio/env_config' require 'securerandom' require_relative 'say_hello_workflow' -# Create client connected to server at the given address -client = Temporalio::Client.connect('localhost:7233', 'default') +args, kwargs = Temporalio::EnvConfig::ClientConfig.load_client_connect_options +args[0] ||= 'localhost:7233' +args[1] ||= 'default' +client = Temporalio::Client.connect(*args, **kwargs) # Execute a workflow result = client.execute_workflow( @@ -96,7 +101,7 @@ puts "Result: #{result}" - Can access `Temporalio::Activity::Context.current` for heartbeating ### Worker Setup -- Connect client with `Temporalio::Client.connect` +- Load connection settings with `Temporalio::EnvConfig::ClientConfig.load_client_connect_options` and connect with `Temporalio::Client.connect` - Create worker with `Temporalio::Worker.new(client:, task_queue:, workflows:, activities:)` - Run with `worker.run` diff --git a/references/typescript/standalone-activities.md b/references/typescript/standalone-activities.md index ee7dbab..00328bb 100644 --- a/references/typescript/standalone-activities.md +++ b/references/typescript/standalone-activities.md @@ -28,6 +28,7 @@ async function run() { const connection = await NativeConnection.connect(config.connectionOptions); const worker = await Worker.create({ connection, + namespace: config.namespace, taskQueue: 'hello-standalone-activities', activities, // register whatever your activity(ies) is/are }); @@ -55,7 +56,7 @@ import { loadClientConnectConfig } from '@temporalio/envconfig'; const config = loadClientConnectConfig(); const connection = await Connection.connect(config.connectionOptions); -const client = new Client({ connection }); +const client = new Client({ connection, namespace: config.namespace }); ``` ### Execute (wait for result) diff --git a/references/typescript/typescript.md b/references/typescript/typescript.md index f426fe8..1c4ff4f 100644 --- a/references/typescript/typescript.md +++ b/references/typescript/typescript.md @@ -15,7 +15,7 @@ Temporal workflows are durable through history replay. For details on how this w **Add Dependencies:** Install the Temporal SDK packages (use the package manager appropriate for your project): ```bash -npm install @temporalio/client @temporalio/worker @temporalio/workflow @temporalio/activity +npm install @temporalio/client @temporalio/worker @temporalio/workflow @temporalio/activity @temporalio/envconfig ``` Note: if you are working in production, it is strongly advised to use ~ version constraints, i.e. `npm install ... --save-prefix='~'` if using NPM. @@ -46,11 +46,16 @@ export async function greetingWorkflow(name: string): Promise { **worker.ts** - Worker setup (registers activity and workflow, runs indefinitely and processes tasks): ```typescript -import { Worker } from '@temporalio/worker'; +import { NativeConnection, Worker } from '@temporalio/worker'; +import { loadClientConnectConfig } from '@temporalio/envconfig'; import * as activities from './activities'; async function run() { + const config = loadClientConnectConfig(); + const connection = await NativeConnection.connect(config.connectionOptions); const worker = await Worker.create({ + connection, + namespace: config.namespace, workflowsPath: require.resolve('./workflows'), // For production, use workflowBundle instead activities, taskQueue: 'greeting-queue', @@ -68,12 +73,15 @@ run().catch(console.error); **client.ts** - Start a workflow execution: ```typescript -import { Client } from '@temporalio/client'; +import { Client, Connection } from '@temporalio/client'; +import { loadClientConnectConfig } from '@temporalio/envconfig'; import { greetingWorkflow } from './workflows'; import { v4 as uuid } from 'uuid'; async function run() { - const client = new Client(); + const config = loadClientConnectConfig(); + const connection = await Connection.connect(config.connectionOptions); + const client = new Client({ connection, namespace: config.namespace }); const result = await client.workflow.execute(greetingWorkflow, { workflowId: uuid(), @@ -105,6 +113,8 @@ run().catch(console.error); ### Worker Setup +- Load connection settings with `loadClientConnectConfig()` and pass them to `NativeConnection.connect()` +- Pass `namespace: config.namespace` to `Worker.create()` - `NativeConnection` carries no namespace, and the Worker defaults to `default` without it - Use `Worker.create()` with `workflowsPath` (dev) or `workflowBundle` (production) - see `references/typescript/gotchas.md` - Import activities directly (not via proxy) From b01c63294d0dbf079858ac0ea2491800d5a915c2 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 20 Aug 2026 20:35:51 +0000 Subject: [PATCH 77/82] Bump temporal-developer skill version to v0.6.0 [skip ci] --- SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SKILL.md b/SKILL.md index 603ba4c..50a1603 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,7 +1,7 @@ --- name: temporal-developer description: Develop, debug, and manage Temporal applications across Python, TypeScript, Go, Java, .NET, Ruby, and Rust. Use when the user is building workflows, activities, or workers with a Temporal SDK, debugging issues like non-determinism errors, stuck workflows, or activity retries, using Temporal CLI, Temporal Server, or Temporal Cloud, or working with durable execution concepts like signals, queries, heartbeats, versioning, continue-as-new, child workflows, or saga patterns. Also use when the user mentions "run a Temporal workflow from the CLI", "start a dev server", "run temporal server start-dev", "temporal workflow start", "temporal workflow execute", "temporal workflow signal", "temporal workflow query", "temporal workflow update". -version: 0.5.0 +version: 0.6.0 --- # Skill: temporal-developer From 5de78ea2a3775abfc0802926ead357afb53f2258 Mon Sep 17 00:00:00 2001 From: "skill-temporal-developer-updater[bot]" <276371939+skill-temporal-developer-updater[bot]@users.noreply.github.com> Date: Mon, 24 Aug 2026 12:25:47 -0700 Subject: [PATCH 78/82] Implement planned topic: 0011-external-storage (#209) * Finalize draft for 0011-external-storage * Fix Python external storage examples * Add TypeScript external storage guidance * Address review findings on external storage references Python: - Import ClientConfig from temporalio.envconfig, not temporalio.client. load_client_connect_config() is a staticmethod on the envconfig class; the temporalio.client.ClientConfig TypedDict has no such member, so the snippet raised AttributeError. Follow main's env-config convention (setdefault target_host) from #261. - Register real Workflow/Activity placeholders. Worker() with empty workflows and activities raises "At least one activity, Nexus service, or workflow must be specified", and wrap the setup in async main(). Go: - Cover the GCS driver (contrib/gcp/gcsdriver + gcssdk), which the SDK ships and the docs install alongside S3. - Load client options with envconfig.MustLoadDefaultClientOptions() and note that Workers inherit External Storage from their Client. Align coverage across all three languages, each of which was missing something the others had: - 50 MiB MaxPayloadSize/max_payload_size ceiling and the matching anti-pattern (Go, Python). - Store/Retrieve are not retried within a Task attempt; the Task retries as a whole, so storage must be idempotent (Go, Python). - Multi-region durability with CRR + an MRAP ARN (Go, Python). - Distinct driver names when registering two drivers of the same kind (Go, Python). - Codec Server guidance (TypeScript), including that neither the TypeScript nor Python SDK ships a storage-aware handler. - Built-in driver behavior sections (concurrency, content-addressed keys, integrity checks, diagnostics) in Go and Python. - ctx.Context on the Go driver contexts, mirroring TypeScript's abortSignal guidance; optional type() override in Python. Also: standardize the TypeScript Public Preview admonition on the repo's wording, drop the transplanted `payloadSizeThreshold: 1` anti-pattern (TypeScript compares >=, so 1 behaves like 0), replace site-relative plugins-guide links with absolute URLs, refresh the index pointers, and revert an unrelated whitespace change in the Spring AI reference. Co-Authored-By: Claude Opus 5 (1M context) * Harden external storage driver examples * Fix correctness bugs in external storage references Address code-review findings on the new external storage docs: - Go: add missing "context" and "log" imports to the S3 driver, GCS driver, and client/worker setup snippets, which presented complete import lists but failed to compile. - Go: add go.temporal.io/sdk/contrib/envconfig to both go get lines; it is a separate module and is imported by the setup snippet. - Go: give the local-disk worked example an import block, and introduce the commonpb alias at its first use in the selector example. - Go and Python: validate claim data in Retrieve/retrieve so a hand-crafted reference payload cannot read files outside the store directory, matching the hardening already applied to Store/store. - Python: the Worker inherits the Data Converter from its Client and takes no data_converter argument; the prose said to pass it to both. Verified by compiling every Go snippet against sdk-go and exercising both path guards. Co-Authored-By: Claude Opus 5 (1M context) * Route large-payload triage to the external storage references The new external storage docs were only reachable from the language index files, so the paths an agent actually takes when a user hits a payload limit still sent it to hand-roll the claim-check pattern. - core/error-reference.md: TMPRL1103 recovery now points at built-in External Storage before manual reference passing. - core/gotchas.md: the payload-limit fix notes the SDK does this for you in Go, Python, and TypeScript. - core/patterns.md: Large Data Handling leads with the SDK-native option and scopes the manual pattern to the cases that need it. Also link the Go external storage sample from the Codec Server section, matching what the Python reference already does. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: skill-sync[bot] Co-authored-by: Brian Strauch Co-authored-by: Claude Opus 5 (1M context) --- references/core/error-reference.md | 2 +- references/core/gotchas.md | 2 + references/core/patterns.md | 2 + references/go/external-storage.md | 394 ++++++++++++++++++++++ references/go/go.md | 1 + references/python/external-storage.md | 299 ++++++++++++++++ references/python/python.md | 1 + references/typescript/external-storage.md | 246 ++++++++++++++ references/typescript/typescript.md | 1 + 9 files changed, 947 insertions(+), 1 deletion(-) create mode 100644 references/go/external-storage.md create mode 100644 references/python/external-storage.md create mode 100644 references/typescript/external-storage.md diff --git a/references/core/error-reference.md b/references/core/error-reference.md index 29a40b7..5c108c9 100644 --- a/references/core/error-reference.md +++ b/references/core/error-reference.md @@ -5,7 +5,7 @@ | **Non-determinism** | TMPRL1100 | `WorkflowTaskFailed` in history | Replay doesn't match history | Analyze error first. **If accidental**: fix code to match history → restart worker. **If intentional v2 change**: terminate → start fresh workflow. | https://github.com/temporalio/rules/blob/main/rules/TMPRL1100.md | | **Deadlock** | TMPRL1101 | `WorkflowTaskFailed` in history, worker logs | Workflow blocked too long (deadlock detected) | Remove blocking operations from workflow code (no I/O, no sleep, no threading locks). Use Temporal primitives instead. | https://github.com/temporalio/rules/blob/main/rules/TMPRL1101.md | | **Unfinished handlers** | TMPRL1102 | `WorkflowTaskFailed` in history | Workflow completed while update/signal handlers still running | Ensure all handlers complete before workflow finishes. Use `workflow.wait_condition()` to wait for handler completion. | https://github.com/temporalio/rules/blob/main/rules/TMPRL1102.md | -| **Payload overflow** | TMPRL1103 | `WorkflowTaskFailed` or `ActivityTaskFailed` in history | Payload size limit exceeded (default 2MB) | Reduce payload size. Use external storage (S3, database) for large data and pass references instead. | https://github.com/temporalio/rules/blob/main/rules/TMPRL1103.md | +| **Payload overflow** | TMPRL1103 | `WorkflowTaskFailed` or `ActivityTaskFailed` in history | Payload size limit exceeded (default 2MB) | Reduce payload size. Use the SDK's built-in External Storage where available (see `references/{your_language}/external-storage.md`; Go, Python, and TypeScript), or pass references to external storage yourself (see the Large Data Handling pattern in `references/core/patterns.md`). | https://github.com/temporalio/rules/blob/main/rules/TMPRL1103.md | | **Workflow code bug** | | `WorkflowTaskFailed` in history | Bug in workflow logic | Fix code → Restart worker → Workflow auto-resumes | | | **Missing workflow** | | Worker logs | Workflow not registered | Add to worker.py → Restart worker | | | **Missing activity** | | Worker logs | Activity not registered | Add to worker.py → Restart worker | | diff --git a/references/core/gotchas.md b/references/core/gotchas.md index 372caed..8b6568f 100644 --- a/references/core/gotchas.md +++ b/references/core/gotchas.md @@ -235,3 +235,5 @@ When resetting a workflow with `temporal workflow reset`, `--reapply-type` contr - Workflow history growing unboundedly **The Fix**: Store large data externally (S3/GCS) and pass references, use compression codecs, or chunk data across multiple activities. See the Large Data Handling pattern in `references/core/patterns.md`. + +Before hand-rolling reference passing, check whether the SDK does it for you: the Go, Python, and TypeScript SDKs have built-in External Storage that applies the claim-check pattern automatically. See `references/{your_language}/external-storage.md`, if available. diff --git a/references/core/patterns.md b/references/core/patterns.md index 7e7c7a3..1922106 100644 --- a/references/core/patterns.md +++ b/references/core/patterns.md @@ -368,6 +368,8 @@ This ensures that on replay, already-completed steps are skipped. - Max 4MB per gRPC message - Max 50MB for workflow history (aim for < 10MB) +**Check for SDK support first**: the Go, Python, and TypeScript SDKs have built-in External Storage that applies the claim-check pattern for you — Payloads over a size threshold are offloaded to S3 or GCS and replaced in Event History with a small reference, with no changes to Workflow or Activity code. Prefer it where it exists; see `references/{your_language}/external-storage.md`, if available. The rest of this section applies when you need explicit control over which data is offloaded, or when your SDK has no built-in support. + **Key Principle**: Large data should never flow through workflow history. Activities read and write large data directly, passing only small references through the workflow. **Wrong Approach**: diff --git a/references/go/external-storage.md b/references/go/external-storage.md new file mode 100644 index 0000000..cc0dd46 --- /dev/null +++ b/references/go/external-storage.md @@ -0,0 +1,394 @@ +# Go SDK External Storage + +> [!NOTE] +> This feature is in Public Preview. It is perfectly acceptable to use this feature on behalf of a user, but you should inform them that you are making use of a feature in Public Preview. + +## What this is + +External Storage uses the **claim check pattern**: it offloads each Payload to an external store (e.g. Amazon S3 or Google Cloud Storage), records a small reference token (the "claim check") in Event History, and uses that token to retrieve the Payload when needed. The SDK handles storage and retrieval transparently. + +## When to use it + +- A Workflow input, Activity input, Activity result, or Workflow result will exceed the **2 MB** per-payload limit (the limit is fixed at 2 MB on Temporal Cloud; configurable on self-hosted only). +- Long Event Histories degrade Workflow Task latency (e.g. AI agent conversations that grow per turn). +- The user wants payload data to live in storage **they** control. Set `PayloadSizeThreshold: 1` to externalize all payloads (`0` selects the default 256 KiB threshold in Go). +- The user is migrating from self-hosted (with a larger configured limit) to Temporal Cloud. + +## Where it sits in the pipeline + +Order: **Payload Converter → Payload Codec → External Storage**. Storage runs last on outbound; it reverses on inbound. + +Consequences: + +- If a Payload Codec encrypts data, the bytes are already encrypted **before** upload to your store. +- The Temporal UI shows the reference token, not the data; the SDK transparently retrieves the payload before handing it to your Workflow or Client. +- Every Client and Worker that might read an offloaded payload needs the same External Storage configuration. + +## Setup with a built-in driver + +The Go SDK ships drivers for Amazon S3 and Google Cloud Storage. Only the driver setup differs between the two; everything after that is identical. + +Amazon S3: + +```bash +go get go.temporal.io/sdk/contrib/aws/s3driver \ + go.temporal.io/sdk/contrib/aws/s3driver/awssdkv2 \ + go.temporal.io/sdk/contrib/envconfig \ + github.com/aws/aws-sdk-go-v2/config \ + github.com/aws/aws-sdk-go-v2/service/s3 +``` + +Google Cloud Storage: + +```bash +go get go.temporal.io/sdk/contrib/gcp/gcsdriver \ + go.temporal.io/sdk/contrib/gcp/gcsdriver/gcssdk \ + go.temporal.io/sdk/contrib/envconfig \ + cloud.google.com/go/storage +``` + +### Amazon S3 driver + +```go +import ( + "context" + "log" + + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/service/s3" + "go.temporal.io/sdk/contrib/aws/s3driver" + "go.temporal.io/sdk/contrib/aws/s3driver/awssdkv2" +) + +cfg, err := config.LoadDefaultConfig(context.Background(), + config.WithRegion("us-east-2"), +) +if err != nil { + log.Fatalf("load AWS config: %v", err) +} + +driver, err := s3driver.NewDriver(s3driver.Options{ + Client: awssdkv2.NewClient(s3.NewFromConfig(cfg)), + Bucket: s3driver.StaticBucket("my-temporal-payloads"), +}) +if err != nil { + log.Fatalf("create S3 driver: %v", err) +} +``` + +The AWS SDK reads standard credentials from the environment (env vars, IAM role, or AWS config file). + +### Google Cloud Storage driver + +```go +import ( + "context" + "log" + + "cloud.google.com/go/storage" + "go.temporal.io/sdk/contrib/gcp/gcsdriver" + "go.temporal.io/sdk/contrib/gcp/gcsdriver/gcssdk" +) + +gcsClient, err := storage.NewClient(context.Background()) +if err != nil { + log.Fatalf("create GCS client: %v", err) +} + +driver, err := gcsdriver.NewDriver(gcsdriver.Options{ + Client: gcssdk.NewClient(gcsClient), + Bucket: gcsdriver.StaticBucket("my-temporal-payloads"), +}) +if err != nil { + log.Fatalf("create GCS driver: %v", err) +} +``` + +The Google Cloud SDK reads Application Default Credentials. + +For either driver, pass a `BucketFunc` as `Bucket` instead of `StaticBucket` to route payloads at runtime. The function receives the store context and the payload and returns a bucket name. + +### Configure the Client and Worker + +```go +import ( + "log" + + "go.temporal.io/sdk/client" + "go.temporal.io/sdk/contrib/envconfig" + "go.temporal.io/sdk/converter" + "go.temporal.io/sdk/worker" +) + +opts := envconfig.MustLoadDefaultClientOptions() +opts.ExternalStorage = converter.ExternalStorage{ + Drivers: []converter.StorageDriver{driver}, +} + +c, err := client.Dial(opts) +if err != nil { + log.Fatalf("connect to Temporal: %v", err) +} +defer c.Close() + +w := worker.New(c, "my-task-queue", worker.Options{}) +``` + +A Worker inherits External Storage from the Client it is created with. When your Workers run in their own process, repeat this setup there — a Client or Worker without the matching driver cannot resolve a reference. + +Workflows and Activities running on the Worker use the driver automatically — no changes to business logic. + +## Built-in driver behavior + +Both the S3 and GCS drivers: + +- Upload and download payloads **concurrently**. Multiple offloaded payloads in a single Workflow Task are stored or retrieved in parallel, not sequentially. +- Address objects by a SHA-256 hash of the contents, scoped by Namespace, Workflow ID, and Run ID, and verify that hash on retrieval. One Run passing the same payload to several Activities uploads it once; a different Run, Workflow, or Namespace stores its own copy, so storage scales with the number of Runs rather than with how often a Run passes a payload around. +- Reject any single payload larger than `MaxPayloadSize`, which defaults to **50 MiB**. `PayloadSizeThreshold` does not raise this ceiling — set `MaxPayloadSize` for the largest payload the application must support, and size the backing store to match. +- Include diagnostic metadata, such as the AWS region, in storage errors. + +## Payload size threshold + +- Default: **256 KiB**. +- Set `PayloadSizeThreshold: 1` to externalize **all** payloads regardless of size. +- `PayloadSizeThreshold: 0` is **interpreted as the default (256 KiB)** — it does **not** mean "externalize everything". +- The size compared against the threshold is that of the serialized Payload, including its metadata, not just your data. + +```go +opts := envconfig.MustLoadDefaultClientOptions() +opts.ExternalStorage = converter.ExternalStorage{ + Drivers: []converter.StorageDriver{driver}, + PayloadSizeThreshold: 1, +} + +c, err := client.Dial(opts) +``` + +## Multiple drivers and migration + +When you register more than one driver, you **must** supply a `DriverSelector` implementing `StorageDriverSelector`. The selector chooses which driver stores each payload. Unselected drivers remain available for **retrieval** — this is how you migrate between storage backends without losing access to existing claims. + +- Return `nil` from the selector to keep a specific payload inline in Event History. +- Every registered driver must have a distinct `Name()`; duplicates are rejected when the Client or Worker is constructed. `s3driver` defaults its name to `"aws.s3driver"` and `gcsdriver` to `"gcp.gcsdriver"`, so registering two drivers of the same kind requires setting `DriverName` on at least one. + +```go +import ( + commonpb "go.temporal.io/api/common/v1" + + "go.temporal.io/sdk/converter" +) + +type PreferredSelector struct { + preferred converter.StorageDriver +} + +func (s *PreferredSelector) SelectDriver( + ctx converter.StorageDriverStoreContext, + payload *commonpb.Payload, +) (converter.StorageDriver, error) { + return s.preferred, nil +} + +func MultipleDriversSetup(preferredDriver, legacyDriver converter.StorageDriver) converter.ExternalStorage { + return converter.ExternalStorage{ + Drivers: []converter.StorageDriver{preferredDriver, legacyDriver}, + DriverSelector: &PreferredSelector{preferred: preferredDriver}, + } +} +``` + +Useful routing patterns include driver migration, hot/cold storage tiers, per-tenant storage, and selecting S3 or GCS based on the runtime environment. + +## Custom storage driver + +Implement `converter.StorageDriver` with **four** methods: + +- `Name() string` — unique identifier for **this driver instance**, stored in the claim reference so the SDK can route retrieval. Renaming after payloads are stored **breaks retrieval**. +- `Type() string` — identifier for the driver **implementation**, same across all instances regardless of configuration (e.g. `"aws.s3driver"`, `"local-disk"`). It is reported in Worker heartbeats. +- `Store(ctx, payloads) ([]StorageDriverClaim, error)` — upload each Payload protobuf and return one claim per payload, in the same order. A claim is a `map[string]string` the driver uses to locate the payload later. +- `Retrieve(ctx, claims) ([]*commonpb.Payload, error)` — download bytes using claim data and reconstruct each Payload, one per claim, in the same order. + +Inside `Store()`, marshal each payload with `proto.Marshal(payload)`; in `Retrieve()`, reconstruct with `proto.Unmarshal(data, payload)`. The application data has already been serialized by the Payload Converter and Payload Codec before it reaches the driver. + +`ctx.Context` carries the context of the operation that triggered the driver call — pass it to your storage calls so cancellation and deadlines propagate, and so sibling operations stop after the first failure. + +`ctx.Target` provides identity information. Type-switch over `StorageDriverWorkflowInfo` and `StorageDriverActivityInfo` to access the namespace / Workflow ID / Activity ID, and use it to scope storage keys. Hash or encode identifiers before using them as path segments because identifiers can contain path separators or traversal sequences. `StorageDriverActivityInfo` is only used for standalone (non-workflow-bound) Activities; Activities started by a Workflow get `StorageDriverWorkflowInfo`. + +Validate claim data in `Retrieve()` as untrusted input. A driver that resolves a filesystem path, object key, or URL straight out of the claim will follow whatever a hand-crafted reference payload puts there, so re-check that the resolved location stays inside the store the driver owns. + +Worked example — local-disk driver (development/testing only): + +```go +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "os" + "path/filepath" + "strings" + + commonpb "go.temporal.io/api/common/v1" + "google.golang.org/protobuf/proto" + + "go.temporal.io/sdk/converter" +) + +type LocalDiskStorageDriver struct { + storeDir string +} + +func safePathSegment(value string) string { + sum := sha256.Sum256([]byte(value)) + return hex.EncodeToString(sum[:]) +} + +func NewLocalDiskStorageDriver(storeDir string) converter.StorageDriver { + return &LocalDiskStorageDriver{storeDir: storeDir} +} + +// resolvePath rejects claim data that points outside the store directory. +func (d *LocalDiskStorageDriver) resolvePath(claimPath string) (string, error) { + root, err := filepath.Abs(d.storeDir) + if err != nil { + return "", fmt.Errorf("resolve store directory: %w", err) + } + resolved, err := filepath.Abs(claimPath) + if err != nil { + return "", fmt.Errorf("resolve claim path: %w", err) + } + if resolved != root && !strings.HasPrefix(resolved, root+string(os.PathSeparator)) { + return "", fmt.Errorf("claim path %q escapes the store directory", claimPath) + } + return resolved, nil +} + +func (d *LocalDiskStorageDriver) Name() string { return "my-local-disk" } +func (d *LocalDiskStorageDriver) Type() string { return "local-disk" } + +func (d *LocalDiskStorageDriver) Store( + ctx converter.StorageDriverStoreContext, + payloads []*commonpb.Payload, +) ([]converter.StorageDriverClaim, error) { + dir := d.storeDir + switch info := ctx.Target.(type) { + case converter.StorageDriverWorkflowInfo: + if info.WorkflowID != "" { + dir = filepath.Join( + d.storeDir, + safePathSegment(info.Namespace), + safePathSegment(info.WorkflowID), + ) + } + case converter.StorageDriverActivityInfo: + if info.ActivityID != "" { + dir = filepath.Join( + d.storeDir, + safePathSegment(info.Namespace), + safePathSegment(info.ActivityID), + ) + } + } + if err := os.MkdirAll(dir, 0o755); err != nil { + return nil, fmt.Errorf("create store directory: %w", err) + } + + claims := make([]converter.StorageDriverClaim, len(payloads)) + for i, payload := range payloads { + data, err := proto.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("marshal payload: %w", err) + } + sum := sha256.Sum256(data) + key := hex.EncodeToString(sum[:]) + ".bin" + filePath := filepath.Join(dir, key) + if err := os.WriteFile(filePath, data, 0o644); err != nil { + return nil, fmt.Errorf("write payload: %w", err) + } + claims[i] = converter.StorageDriverClaim{ + ClaimData: map[string]string{"path": filePath}, + } + } + return claims, nil +} + +func (d *LocalDiskStorageDriver) Retrieve( + ctx converter.StorageDriverRetrieveContext, + claims []converter.StorageDriverClaim, +) ([]*commonpb.Payload, error) { + payloads := make([]*commonpb.Payload, len(claims)) + for i, claim := range claims { + filePath, err := d.resolvePath(claim.ClaimData["path"]) + if err != nil { + return nil, err + } + data, err := os.ReadFile(filePath) + if err != nil { + return nil, fmt.Errorf("read payload: %w", err) + } + payload := &commonpb.Payload{} + if err := proto.Unmarshal(data, payload); err != nil { + return nil, fmt.Errorf("unmarshal payload: %w", err) + } + payloads[i] = payload + } + return payloads, nil +} +``` + +You can package a custom driver as a [plugin](https://docs.temporal.io/develop/plugins-guide) for reuse across services. + +## Multi-region durability with Amazon S3 + +For regional-failure tolerance, configure S3 Cross-Region Replication (CRR) and an S3 Multi-Region Access Point (MRAP), then pass the MRAP ARN as the bucket: + +```go +driver, err := s3driver.NewDriver(s3driver.Options{ + Client: awssdkv2.NewClient(s3.NewFromConfig(cfg)), + Bucket: s3driver.StaticBucket("arn:aws:s3::123456789012:accesspoint/mfzwi23gnjvgw.mrap"), +}) +``` + +The AWS SDK for Go v2 uses SigV4A signing automatically when the bucket value is an MRAP ARN, so no additional client configuration is required. + +Cross-region replication is eventually consistent. Activities reading newly written Payloads from another region need an appropriate Retry Policy. Replication, versioning, and Replication Time Control can add significant cost. + +## Codec Server with External Storage + +When Workers and Clients use External Storage, Event History contains reference tokens — not payload data. For the Web UI and CLI to display decoded payloads, the Codec Server must download from external storage **and** decode through the Payload Codec in the correct order. + +Build the Codec Server with `NewPayloadHTTPHandler` and `PayloadHTTPHandlerOptions`. Pass it your storage drivers, your pre-storage codecs (the Payload Codecs your Workers use), and any post-storage codecs (applied by a proxy after external storage). + +When configured with storage drivers, the handler exposes: + +- **`/download`** — retrieves payload data from external storage and decodes it through the Payload Codec. The Web UI calls this when a user clicks to view the full payload behind a reference. +- **`/decode`** — decodes encoded payloads and, by default, retrieves storage references inline. Pass `?preserveStorageRefs=true` to return storage references as-is without retrieval. +- **`/encode`** — applies the Payload Codec, then uploads payloads exceeding the threshold and replaces them with reference tokens. + +**Don't use `NewPayloadHTTPHandler` as a remote Data Converter or remote codec target for your Workers** — it runs the full encode-store-encode and decode-retrieve-decode pipeline. For remote codecs use `NewPayloadCodecHTTPHandler` separately. If you need both, run both handlers, configured with the same codecs. + +The [Go External Storage sample](https://github.com/temporalio/samples-go/tree/main/external-storage) is a working end-to-end setup to copy from: a Worker with an S3 driver behind a zlib Payload Codec, a Codec Server built on `NewPayloadHTTPHandler` (`codec-server/main.go`), and a mock S3 service so it runs locally without an AWS account. + +## Lifecycle and failure handling + +Temporal does **not** auto-delete payloads from your store. Configure a TTL on your bucket: + +``` +TTL > Maximum Workflow Run Timeout + Namespace Retention Period +``` + +Example: Run Timeout 14 days + Namespace retention 30 days → set TTL to at least 44 days. + +For Workflows with no finite Run Timeout, there is no safe finite TTL. Use Continue-as-New so the new run uploads fresh payloads and the old run's payloads only need to survive its retention period. + +The SDK does not retry a failed `Store` or `Retrieve` call within the same Task attempt. The failure fails the current Workflow Task or Activity Task attempt; Temporal then retries the Task as a whole, and the new attempt retries the storage operation along with it. For Activities, the Retry Policy controls the timing. Storage operations should therefore be idempotent — content-addressable keys are one way to get that. + +## Anti-patterns + +- **Don't change `Name()` after payloads have been stored.** The name is embedded in the claim reference; renaming breaks retrieval of existing claims. +- **Don't use `PayloadSizeThreshold: 0` to mean "externalize all".** `0` is interpreted as the default (256 KiB). Use `PayloadSizeThreshold: 1`. +- **Don't register multiple drivers without a `DriverSelector`.** The selector is required when there are multiple drivers. +- **Don't register duplicate driver names.** Two same-kind drivers share a default name; set `DriverName` on at least one. +- **Don't omit External Storage configuration from a Client or Worker that may retrieve offloaded data.** It cannot resolve the reference without the matching driver. +- **Don't assume the 2 MB Temporal limit is the driver's maximum.** The S3 and GCS drivers reject payloads above `MaxPayloadSize`, which defaults to 50 MiB. +- **Don't point a Worker's remote codec at `NewPayloadHTTPHandler`.** Use `NewPayloadCodecHTTPHandler` for remote codec endpoints. +- **Don't omit a TTL on the bucket.** Payloads are orphaned otherwise; orphaned objects can also remain if a request fails after upload. diff --git a/references/go/go.md b/references/go/go.md index 3e259de..4fe4c6b 100644 --- a/references/go/go.md +++ b/references/go/go.md @@ -252,6 +252,7 @@ See `references/go/testing.md` for info on writing tests. - **`references/go/testing.md`** - TestWorkflowEnvironment, time-skipping, activity mocking - **`references/go/advanced-features.md`** - Schedules, worker tuning, and more - **`references/go/data-handling.md`** - Data converters, payload codecs, encryption +- **`references/go/external-storage.md`** - Claim-check pattern for large payloads (S3 and GCS drivers, custom drivers, codec-server handling, multi-region durability) - **`references/go/versioning.md`** - Patching API (`workflow.GetVersion`), Worker Versioning - **`references/go/determinism-protection.md`** - Information on **`workflowcheck`** tool to help statically check for determinism issues. - **`references/go/standalone-activities.md`** - Standalone Activities (Public Preview): run an Activity directly from a Client without a Workflow; see also `references/core/standalone-activities.md` for cross-SDK concepts. diff --git a/references/python/external-storage.md b/references/python/external-storage.md new file mode 100644 index 0000000..20866fb --- /dev/null +++ b/references/python/external-storage.md @@ -0,0 +1,299 @@ +# Python SDK External Storage + +> [!NOTE] +> This feature is in Public Preview. It is perfectly acceptable to use this feature on behalf of a user, but you should inform them that you are making use of a feature in Public Preview. + +## What this is + +External Storage uses the **claim check pattern**: it offloads each Payload to an external store (e.g. Amazon S3), records a small reference token (the "claim check") in Event History, and uses that token to retrieve the Payload when needed. The SDK handles storage and retrieval transparently. + +## When to use it + +- A Workflow input, Activity input, Activity result, or Workflow result will exceed the **2 MB** per-payload limit (fixed at 2 MB on Temporal Cloud; configurable on self-hosted only). +- Long Event Histories degrade Workflow Task latency (e.g. AI agent conversations growing per turn). +- The user wants payload data to live in storage **they** control. Set `payload_size_threshold=0` to externalize all payloads. +- The user is migrating from self-hosted (with a larger configured limit) to Temporal Cloud. + +## Where it sits in the pipeline + +Order: **Payload Converter → Payload Codec → External Storage**. Storage runs last on outbound; it reverses on inbound. + +Consequences: + +- If a Payload Codec encrypts data, the bytes are already encrypted **before** upload. +- The Temporal UI displays the reference token, not the data; the SDK retrieves the payload transparently before handing it to your Workflow or Client. +- Every Client and Worker that might read an offloaded payload needs the same External Storage configuration. + +## Setup with the built-in S3 driver + +The Python SDK ships an Amazon S3 driver (there is no built-in GCS driver — use a custom driver for other backends). Install the `aioboto3` extra: + +```bash +python -m pip install "temporalio[aioboto3]" +``` + +Create the driver, attach it to a `DataConverter`, and pass the converter to `Client.connect`. A Worker inherits the Data Converter from the Client it is created with — `Worker` takes no `data_converter` argument of its own: + +```python +import asyncio +import dataclasses + +import aioboto3 +from temporalio.client import Client +from temporalio.contrib.aws.s3driver import S3StorageDriver +from temporalio.contrib.aws.s3driver.aioboto3 import new_aioboto3_client +from temporalio.converter import DataConverter, ExternalStorage +from temporalio.envconfig import ClientConfig +from temporalio.worker import Worker + +from activities.greet import greet +from workflows.greeting import GreetingWorkflow + + +async def main() -> None: + session = aioboto3.Session(region_name="us-east-2") + async with session.client("s3") as s3_client: + driver = S3StorageDriver( + client=new_aioboto3_client(s3_client), + bucket="my-temporal-payloads", + ) + + data_converter = dataclasses.replace( + DataConverter.default, + external_storage=ExternalStorage(drivers=[driver]), + ) + + connect_config = ClientConfig.load_client_connect_config() + connect_config.setdefault("target_host", "localhost:7233") + client = await Client.connect(**connect_config, data_converter=data_converter) + + worker = Worker( + client, + task_queue="my-task-queue", + workflows=[GreetingWorkflow], + activities=[greet], + ) + await worker.run() + + +if __name__ == "__main__": + asyncio.run(main()) +``` + +`ClientConfig` for connection settings comes from `temporalio.envconfig`, not `temporalio.client`. The S3 driver uses standard AWS credentials from the environment (env vars, IAM role, or AWS config file); pass `profile_name=` to `aioboto3.Session` to select a named profile. Keep the `async with session.client("s3")` block open for as long as the Worker runs — the driver uses that client for every upload and download. + +Workflows and Activities on the Worker use the driver automatically — no business-logic changes. + +## Built-in driver behavior + +The S3 driver: + +- Uploads and downloads payloads **concurrently**. Multiple offloaded payloads in a single Workflow Task are stored or retrieved in parallel, not sequentially. +- Addresses objects by a SHA-256 hash of their contents, segmented by Namespace and Workflow/Activity identifiers, and validates payload integrity on retrieval. +- Rejects any single payload larger than `max_payload_size`, which defaults to **50 MiB**. `payload_size_threshold` does not raise this ceiling — set `max_payload_size` for the largest payload the application must support, and size the backing store to match. +- Includes diagnostic metadata, such as the AWS region, in error messages. + +## Payload size threshold + +- Default: **256 KiB**. +- Set `payload_size_threshold=0` to externalize **all** payloads regardless of size. +- Payloads whose serialized size is **greater than or equal to** the threshold are eligible; smaller ones stay inline. The measured size includes Payload metadata, not just your data. + +```python +data_converter = dataclasses.replace( + DataConverter.default, + external_storage=ExternalStorage( + drivers=[driver], + payload_size_threshold=0, + ), +) +``` + +## Multiple drivers and migration + +When you register more than one driver, you **must** supply a `driver_selector` function. The selector chooses which driver stores each payload. Unselected drivers remain available for **retrieval** — this is how you migrate between storage backends without losing access to existing claims. + +- Return `None` from the selector to keep a specific payload inline in Event History. +- Every registered driver must have a distinct name; duplicates raise `ValueError` at construction. `S3StorageDriver` defaults its name to `"aws.s3driver"`, so registering two S3 drivers requires passing `driver_name=` to at least one. + +```python +preferred_driver = S3StorageDriver( + client=new_aioboto3_client(s3_client), + bucket="my-bucket", + driver_name="s3-primary", +) +legacy_driver = LegacyStorageDriver() + +ExternalStorage( + drivers=[preferred_driver, legacy_driver], + driver_selector=lambda context, payload: preferred_driver, +) +``` + +Useful routing patterns include driver migration, hot/cold storage tiers, and per-tenant storage. + +## Custom storage driver + +Extend `StorageDriver` and implement **three** methods: + +- `name() -> str` — unique identifier for the driver, stored in the claim reference so the SDK can route retrieval. Renaming after payloads are stored **breaks retrieval**. +- `async store(context, payloads) -> list[StorageDriverClaim]` — upload each Payload and return one claim per payload, in the same order. A claim is a `dict[str, str]` the driver uses to locate the payload later. +- `async retrieve(context, claims) -> list[Payload]` — download bytes using claim data and reconstruct each Payload, one per claim, in the same order. + +`type() -> str` is optional and defaults to the class name. Override it with a stable identifier shared by every instance of the implementation (e.g. `"aws.s3driver"`) so the driver reports the same type as its equivalents in other languages. + +Inside `store()`, serialize each payload with `payload.SerializeToString()`; in `retrieve()`, reconstruct with `payload.ParseFromString(data)`. The application data has already been serialized by the Payload Converter and Payload Codec before reaching the driver. + +`context.target` provides identity information (namespace, Workflow ID, or Activity ID). Check the target type with `isinstance(target, StorageDriverWorkflowInfo)`; the Workflow info exposes `target.namespace` and `target.id`. Use this to scope storage keys per Workflow, but hash or encode identifiers before using them as path segments because identifiers can contain path separators or traversal sequences. Within that scope, content-addressable keys (such as a SHA-256 hash of the payload bytes) deduplicate identical payloads and make retries idempotent. + +Treat claim data in `retrieve()` as untrusted input. A driver that resolves a filesystem path, object key, or URL straight out of the claim will follow whatever a hand-crafted reference payload puts there, so re-check that the resolved location stays inside the store the driver owns. + +Worked example — local-disk driver (development/testing only): + +```python +import hashlib +import os +from typing import Sequence + +from temporalio.api.common.v1 import Payload +from temporalio.converter import ( + StorageDriver, + StorageDriverClaim, + StorageDriverRetrieveContext, + StorageDriverStoreContext, + StorageDriverWorkflowInfo, +) + + +def safe_path_segment(value: str) -> str: + return hashlib.sha256(value.encode("utf-8")).hexdigest() + + +class LocalDiskStorageDriver(StorageDriver): + def __init__(self, store_dir: str = "/tmp/temporal-payload-store") -> None: + self._store_dir = store_dir + + def _resolve_path(self, claim_path: str) -> str: + """Reject claim data that points outside the store directory.""" + root = os.path.realpath(self._store_dir) + resolved = os.path.realpath(claim_path) + if resolved != root and not resolved.startswith(root + os.sep): + raise ValueError(f"claim path {claim_path!r} escapes the store directory") + return resolved + + def name(self) -> str: + return "local-disk" + + def type(self) -> str: + return "local-disk" + + async def store( + self, + context: StorageDriverStoreContext, + payloads: Sequence[Payload], + ) -> list[StorageDriverClaim]: + os.makedirs(self._store_dir, exist_ok=True) + + prefix = self._store_dir + target = context.target + if isinstance(target, StorageDriverWorkflowInfo) and target.id: + prefix = os.path.join( + self._store_dir, + safe_path_segment(target.namespace), + safe_path_segment(target.id), + ) + os.makedirs(prefix, exist_ok=True) + + claims = [] + for payload in payloads: + data = payload.SerializeToString() + key = f"{hashlib.sha256(data).hexdigest()}.bin" + file_path = os.path.join(prefix, key) + with open(file_path, "wb") as f: + f.write(data) + claims.append(StorageDriverClaim(claim_data={"path": file_path})) + return claims + + async def retrieve( + self, + context: StorageDriverRetrieveContext, + claims: Sequence[StorageDriverClaim], + ) -> list[Payload]: + payloads = [] + for claim in claims: + file_path = self._resolve_path(claim.claim_data["path"]) + with open(file_path, "rb") as f: + raw = f.read() + payload = Payload() + payload.ParseFromString(raw) + payloads.append(payload) + return payloads +``` + +Wire the custom driver into the Data Converter the same way as the S3 driver: + +```python +data_converter = dataclasses.replace( + DataConverter.default, + external_storage=ExternalStorage( + drivers=[LocalDiskStorageDriver()], + ), +) +``` + +You can package a custom driver as a [plugin](https://docs.temporal.io/develop/plugins-guide) for reuse across services. + +## Multi-region durability with Amazon S3 + +For regional-failure tolerance, configure S3 Cross-Region Replication (CRR) and an S3 Multi-Region Access Point (MRAP), then pass the MRAP ARN as `bucket`: + +```python +driver = S3StorageDriver( + client=new_aioboto3_client(s3_client), + bucket="arn:aws:s3::123456789012:accesspoint/mfzwi23gnjvgw.mrap", +) +``` + +`aioboto3` (via `botocore`) uses SigV4A signing automatically when the bucket value is an MRAP ARN. Make sure `botocore` is recent enough to support SigV4A. + +Cross-region replication is eventually consistent. Activities reading newly written payloads from another region need an appropriate Retry Policy. Replication, versioning, and Replication Time Control can add significant cost. + +## Codec Server with External Storage + +When Workers and Clients use External Storage, Event History contains reference tokens — not payload data. For the Web UI and CLI to show decoded payloads, the Codec Server must download from external storage **and** decode through the Payload Codec in the correct order. + +The Python SDK does not ship a storage-aware Codec Server handler — implement the routes yourself (e.g. with `aiohttp`), giving them your storage drivers, your pre-storage codecs (the Payload Codecs your Workers use), and any post-storage codecs (applied by a proxy after external storage). The [Python External Storage sample](https://github.com/temporalio/samples-python/tree/main/external_storage) has a working implementation (`payload_routes` in `handler.py`) to copy from. + +Endpoints to expose when storage drivers are configured: + +- **`/download`** — retrieves payload data from external storage and decodes it through the Payload Codec. The Web UI calls this when a user clicks to view the full payload behind a reference. +- **`/decode`** — decodes encoded payloads and, by default, retrieves storage references inline. Support `?preserveStorageRefs=true` to return storage references as-is without retrieval; the Web UI uses it to render history without downloading every blob. +- **`/encode`** — applies the Payload Codec, then uploads payloads exceeding the threshold and replaces them with reference tokens. + +**Don't point a Worker's remote codec at the storage-aware handler** — it runs the full encode-store-encode and decode-retrieve-decode pipeline. Run a separate non-storage codec HTTP handler for remote codecs, configured with the same codecs. + +## Lifecycle and failure handling + +Temporal does **not** auto-delete payloads from your store. Configure a TTL on your bucket: + +``` +TTL > Maximum Workflow Run Timeout + Namespace Retention Period +``` + +Example: Run Timeout 14 days + Namespace retention 30 days → set TTL to at least 44 days. + +For Workflows with no finite Run Timeout, there is no safe finite TTL. Use Continue-as-New so the new run uploads fresh payloads and the old run's payloads only need to survive its retention period. + +The SDK does not retry a failed `store()` or `retrieve()` call within the same Task attempt. The failure fails the current Workflow Task or Activity Task attempt; Temporal then retries the Task as a whole, and the new attempt retries the storage operation along with it. For Activities, the Retry Policy controls the timing. Storage operations should therefore be idempotent — content-addressable keys are one way to get that. + +## Anti-patterns + +- **Don't change the value returned by `name()` after payloads have been stored.** The name is embedded in the claim reference; renaming breaks retrieval of existing claims. +- **Don't use `payload_size_threshold=1` to mean "externalize all"** — use `payload_size_threshold=0`. (This sentinel differs from Go, where `0` is the default and `1` externalizes all.) +- **Don't register multiple drivers without a `driver_selector`.** The selector is required when there is more than one driver. +- **Don't register duplicate driver names.** Two `S3StorageDriver` instances share a default name; pass `driver_name=` to at least one. +- **Don't omit External Storage configuration from a Client or Worker that may retrieve offloaded data.** It cannot resolve the reference without the matching driver. +- **Don't assume the 2 MB Temporal limit is the driver's maximum.** The S3 driver rejects payloads above `max_payload_size`, which defaults to 50 MiB. +- **Don't import `ClientConfig` from `temporalio.client` for connection settings.** `load_client_connect_config()` lives on `temporalio.envconfig.ClientConfig`. +- **Don't pass the storage-aware payload HTTP handler as a Worker's remote codec target.** Use a separate non-storage codec HTTP handler for that role. +- **Don't omit a TTL on the bucket.** Payloads can be orphaned if a request fails after upload. diff --git a/references/python/python.md b/references/python/python.md index c48c6dc..e035da1 100644 --- a/references/python/python.md +++ b/references/python/python.md @@ -182,6 +182,7 @@ See `references/python/testing.md` for info on writing tests. - **`references/python/sync-vs-async.md`** - Sync vs async activities, event loop blocking, executor configuration - **`references/python/advanced-features.md`** - Schedules, worker tuning, and more - **`references/python/data-handling.md`** - Data converters, Pydantic, payload encryption +- **`references/python/external-storage.md`** - Claim-check pattern for large payloads (S3 driver, custom drivers, codec-server handling, multi-region durability) - **`references/python/versioning.md`** - Patching API, workflow type versioning, Worker Versioning - **`references/python/standalone-activities.md`** - Standalone Activities: run an Activity directly from a Client without a Workflow (Public Preview). Concept overview at `references/core/standalone-activities.md`. - **`references/python/determinism-protection.md`** - Python sandbox specifics, forbidden operations, pass-through imports diff --git a/references/typescript/external-storage.md b/references/typescript/external-storage.md new file mode 100644 index 0000000..52b8fe4 --- /dev/null +++ b/references/typescript/external-storage.md @@ -0,0 +1,246 @@ +# TypeScript SDK External Storage + +> [!NOTE] +> This feature is in Public Preview. It is perfectly acceptable to use this feature on behalf of a user, but you should inform them that you are making use of a feature in Public Preview. + +## What this is + +External Storage uses the **claim check pattern**: it offloads each Payload to an external store (e.g. Amazon S3 or Google Cloud Storage), records a small reference token (the "claim check") in Event History, and uses that token to retrieve the Payload when needed. The SDK handles storage and retrieval transparently. + +## When to use it + +- A Workflow input, Activity input, Activity result, or Workflow result will exceed the **2 MB** per-payload limit (fixed at 2 MB on Temporal Cloud; configurable on self-hosted only). +- Long Event Histories degrade Workflow Task latency (e.g. AI agent conversations growing per turn). +- The user wants payload data to live in storage **they** control. Set `payloadSizeThreshold: 0` to externalize all payloads. +- The user is migrating from self-hosted (with a larger configured limit) to Temporal Cloud. + +## Where it sits in the pipeline + +Order: **Payload Converter → Payload Codec → External Storage**. Storage runs last on outbound; it reverses on inbound. + +Consequences: + +- If a Payload Codec encrypts data, the bytes are already encrypted **before** upload. +- The Temporal UI displays the reference token, not the data; the SDK retrieves the payload transparently before handing it to your Workflow or Client. +- Every Client and Worker that might read an offloaded payload needs the same External Storage configuration. + +## Setup with a built-in driver + +The TypeScript SDK provides first-party drivers for Amazon S3 and Google Cloud Storage. Install one driver, its SDK adapter, and the cloud provider's SDK. Keep all `@temporalio/*` packages on the same version. + +Amazon S3: + +```bash +npm install @temporalio/external-storage-s3 \ + @temporalio/external-storage-s3-aws-sdk \ + @temporalio/envconfig \ + @aws-sdk/client-s3 +``` + +Google Cloud Storage: + +```bash +npm install @temporalio/external-storage-gcs \ + @temporalio/external-storage-gcs-google-sdk \ + @temporalio/envconfig \ + @google-cloud/storage +``` + +### Amazon S3 driver + +```typescript +import { S3Client } from '@aws-sdk/client-s3'; +import { S3StorageDriver } from '@temporalio/external-storage-s3'; +import { AwsSdkS3StorageDriverClient } from '@temporalio/external-storage-s3-aws-sdk'; + +const s3Client = new S3Client({ region: 'us-east-2' }); + +const driver = new S3StorageDriver({ + client: new AwsSdkS3StorageDriverClient(s3Client), + bucket: 'my-temporal-payloads', +}); +``` + +The AWS SDK reads standard credentials from environment variables, an IAM role, or the AWS config file. + +### Google Cloud Storage driver + +```typescript +import { Storage } from '@google-cloud/storage'; +import { GcsStorageDriver } from '@temporalio/external-storage-gcs'; +import { GoogleCloudGcsStorageDriverClient } from '@temporalio/external-storage-gcs-google-sdk'; + +const storage = new Storage(); + +const driver = new GcsStorageDriver({ + client: new GoogleCloudGcsStorageDriverClient(storage), + bucket: 'my-temporal-payloads', +}); +``` + +The Google Cloud SDK reads Application Default Credentials. + +For either driver, `bucket` can be a function instead of a string. The function receives the store context and Payload and returns a bucket name, allowing runtime routing. + +### Configure the Client and Worker + +Create one Data Converter configuration and pass it to both the Client and Worker. Load connection settings with `loadClientConnectConfig()`, and remember that `NativeConnection` carries no namespace, so the Worker needs `namespace` passed explicitly: + +```typescript +import { Client, Connection } from '@temporalio/client'; +import { ExternalStorage } from '@temporalio/common'; +import { loadClientConnectConfig } from '@temporalio/envconfig'; +import { NativeConnection, Worker } from '@temporalio/worker'; + +const dataConverter = { + externalStorage: new ExternalStorage({ drivers: [driver] }), +}; + +const config = loadClientConnectConfig(); + +const connection = await Connection.connect(config.connectionOptions); +const client = new Client({ connection, namespace: config.namespace, dataConverter }); + +const workerConnection = await NativeConnection.connect(config.connectionOptions); +const worker = await Worker.create({ + connection: workerConnection, + namespace: config.namespace, + workflowsPath: require.resolve('./workflows'), + taskQueue: 'my-task-queue', + dataConverter, +}); +``` + +External Storage runs outside the Workflow sandbox, so pass the driver object directly. Workflows and Activities use it automatically; business logic does not change. + +## Built-in driver behavior + +The S3 and GCS drivers: + +- Upload and download Payloads concurrently. +- Address objects by a SHA-256 hash of their contents, deduplicating identical Payloads. +- Verify the content hash during retrieval. +- Reject any single Payload larger than `maxPayloadSize`, which defaults to **50 MiB**. +- Include diagnostic metadata in storage errors. + +The External Storage threshold does not override `maxPayloadSize`. Configure the backing store and driver for the largest Payload the application needs to support. + +## Payload size threshold + +- Default: **256 KiB**. +- Set `payloadSizeThreshold: 0` to externalize **all** Payloads regardless of size. +- Payloads whose serialized size is **greater than or equal to** the threshold are eligible for external storage. +- The measured size includes Payload metadata after Payload Converter and Payload Codec processing, not only the raw application value. + +```typescript +const dataConverter = { + externalStorage: new ExternalStorage({ + drivers: [driver], + payloadSizeThreshold: 0, + }), +}; +``` + +## Multiple drivers and migration + +When registering more than one driver, supply a `driverSelector`. The selector chooses which driver stores each Payload. Unselected registered drivers remain available for **retrieval**, which supports migrations without losing access to existing claims. + +- Return `null` from the selector to keep a specific Payload inline in Event History. +- Every registered driver must have a distinct `name`. +- `S3StorageDriver` defaults its name to `"aws.s3driver"`; when registering two S3 drivers, set `driverName` on at least one. + +```typescript +const preferredDriver = new S3StorageDriver({ + client: new AwsSdkS3StorageDriverClient(s3Client), + bucket: 'my-bucket', +}); +const legacyDriver = new LegacyStorageDriver(); + +const externalStorage = new ExternalStorage({ + drivers: [preferredDriver, legacyDriver], + driverSelector: () => preferredDriver, +}); +``` + +Useful routing patterns include driver migration, hot/cold storage tiers, per-tenant storage, and selecting S3 or GCS based on the runtime environment. + +## Custom storage driver + +Implement the `StorageDriver` interface with two readonly properties and two methods: + +- `name: string` — unique identifier for **this driver instance**, stored in the reference so the SDK can route retrieval. Changing it after Payloads are stored **breaks retrieval**. +- `type: string` — stable identifier for the driver implementation, shared by all instances of that implementation and reported in Worker heartbeats (e.g. `"aws.s3driver"`). +- `store(context, payloads): Promise` — serialize and upload each Payload, then return one claim per Payload. Each claim contains string key-value data sufficient to find the object later. +- `retrieve(context, claims): Promise` — download and reconstruct one Payload per claim, preserving input order. + +The `store()` context includes an optional `abortSignal` and `target`. The target is a discriminated union: + +- Check `target.kind` for `"workflow"` or `"activity"`. +- Read `namespace`, `id`, `runId`, and `type` to scope storage keys. + +Honor `abortSignal` in storage calls so sibling operations can be cancelled after the first failure. Content-addressable keys can make retries idempotent and deduplicate identical Payloads. + +Return exactly one claim for each Payload passed to `store()` and exactly one Payload for each claim passed to `retrieve()`. Store the complete serialized Payload protobuf: application data has already passed through the Payload Converter and Payload Codec before reaching the driver. + +## Multi-region durability with Amazon S3 + +For regional-failure tolerance, configure S3 Cross-Region Replication and an S3 Multi-Region Access Point (MRAP), then use the MRAP ARN as `bucket`. + +MRAP requests require a SigV4A signer. The AWS SDK for JavaScript does not bundle one, so install and register it at application startup: + +```bash +npm install @aws-sdk/signature-v4a +``` + +```typescript +import '@aws-sdk/signature-v4a'; +``` + +Then configure the driver with the MRAP ARN: + +```typescript +const driver = new S3StorageDriver({ + client: new AwsSdkS3StorageDriverClient(s3Client), + bucket: 'arn:aws:s3::123456789012:accesspoint/mfzwi23gnjvgw.mrap', +}); +``` + +`@aws-sdk/signature-v4-crt` is an alternative backed by the AWS Common Runtime. The AWS SDK prefers it when both signer implementations are installed. + +Cross-region replication is eventually consistent. Activities reading newly written Payloads from another region need an appropriate Retry Policy. Replication, versioning, and Replication Time Control can add significant cost. + +## Codec Server with External Storage + +When Workers and Clients use External Storage, Event History contains reference tokens — not payload data. A plain codec server that only implements `/encode` and `/decode` leaves the Web UI and CLI showing raw reference tokens. + +The TypeScript SDK does not ship a codec-server handler, so implement the routes yourself (e.g. with Express), wiring in your storage drivers, your pre-storage codecs (the Payload Codecs your Workers use), and any post-storage codecs (applied by a proxy after external storage): + +- **`/download`** — retrieves payload data from external storage and decodes it through the Payload Codec. The Web UI calls this when a user clicks to view the full payload behind a reference. +- **`/decode`** — decodes encoded payloads and, by default, retrieves storage references inline. Support `?preserveStorageRefs=true` to return storage references as-is without retrieval; the Web UI uses it to render history without downloading every blob. +- **`/encode`** — applies the Payload Codec, then uploads payloads exceeding the threshold and replaces them with reference tokens. + +**Don't point a Worker's remote codec at the storage-aware handler** — it runs the full encode-store-encode and decode-retrieve-decode pipeline. Serve remote codecs from a separate non-storage endpoint, configured with the same codecs. + +## Lifecycle and failure handling + +Temporal does **not** automatically delete Payloads from the external store. Configure a bucket lifecycle policy with: + +``` +TTL > Maximum Workflow Run Timeout + Namespace Retention Period +``` + +Example: Run Timeout 14 days + Namespace retention 30 days → set TTL to at least 44 days. + +For Workflows with no finite Run Timeout, there is no safe finite TTL. Use Continue-as-New so the new run uploads fresh Payloads and the old run's Payloads only need to survive its retention period. + +The SDK does not retry a failed `store()` or `retrieve()` call within the same Task attempt. The failure fails the current Workflow Task or Activity Task attempt; Temporal then retries the Task as a whole. Storage operations should therefore be idempotent. + +## Anti-patterns + +- **Don't change a driver's `name` after Payloads have been stored.** The name is embedded in references; changing it breaks retrieval. +- **Don't register duplicate driver names.** Give each instance a unique `name` or `driverName`. +- **Don't register multiple drivers without a `driverSelector`.** Construction fails when more than one driver is registered without one. +- **Don't omit External Storage configuration from a Client or Worker that may retrieve offloaded data.** It cannot resolve the reference without the matching driver. +- **Don't assume the 2 MB Temporal limit is the built-in driver's maximum.** The S3 and GCS drivers default `maxPayloadSize` to 50 MiB. +- **Don't point a Worker's remote codec at a storage-aware codec-server handler.** Serve remote codecs from a separate non-storage endpoint. +- **Don't omit a lifecycle policy.** Payloads are otherwise retained indefinitely, and failed requests can leave orphaned objects. diff --git a/references/typescript/typescript.md b/references/typescript/typescript.md index 1c4ff4f..fceea30 100644 --- a/references/typescript/typescript.md +++ b/references/typescript/typescript.md @@ -190,6 +190,7 @@ See `references/typescript/testing.md` for info on writing tests. - **`references/typescript/testing.md`** - TestWorkflowEnvironment, time-skipping, activity mocking - **`references/typescript/advanced-features.md`** - Schedules, worker tuning, and more - **`references/typescript/data-handling.md`** - Data converters, payload encryption, etc. +- **`references/typescript/external-storage.md`** - Claim-check pattern for large Payloads (S3 and GCS drivers, custom drivers, codec-server handling, multi-region durability) - **`references/typescript/versioning.md`** - Patching API, workflow type versioning, Worker Versioning - **`references/typescript/standalone-activities.md`** - Standalone Activities: run an Activity directly from a Client without a Workflow (Public Preview). Concept overview at `references/core/standalone-activities.md`. - **`references/typescript/determinism-protection.md`** - V8 sandbox and bundling From 49e0c2441be65127b0dd7456e502900cb0134116 Mon Sep 17 00:00:00 2001 From: "temporal-plugin-updater[bot]" <276371939+temporal-plugin-updater[bot]@users.noreply.github.com> Date: Fri, 4 Sep 2026 15:53:01 +0000 Subject: [PATCH 79/82] Release v0.6.1 (#263) Co-authored-by: patbeqo <46697474+patbeqo@users.noreply.github.com> --- SKILL.md | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/SKILL.md b/SKILL.md index 50a1603..a770333 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,7 +1,15 @@ --- name: temporal-developer -description: Develop, debug, and manage Temporal applications across Python, TypeScript, Go, Java, .NET, Ruby, and Rust. Use when the user is building workflows, activities, or workers with a Temporal SDK, debugging issues like non-determinism errors, stuck workflows, or activity retries, using Temporal CLI, Temporal Server, or Temporal Cloud, or working with durable execution concepts like signals, queries, heartbeats, versioning, continue-as-new, child workflows, or saga patterns. Also use when the user mentions "run a Temporal workflow from the CLI", "start a dev server", "run temporal server start-dev", "temporal workflow start", "temporal workflow execute", "temporal workflow signal", "temporal workflow query", "temporal workflow update". -version: 0.6.0 +description: Develop, debug, and manage Temporal applications across Python, TypeScript, + Go, Java, .NET, Ruby, and Rust. Use when the user is building workflows, activities, + or workers with a Temporal SDK, debugging issues like non-determinism errors, stuck + workflows, or activity retries, using Temporal CLI, Temporal Server, or Temporal + Cloud, or working with durable execution concepts like signals, queries, heartbeats, + versioning, continue-as-new, child workflows, or saga patterns. Also use when the + user mentions "run a Temporal workflow from the CLI", "start a dev server", "run + temporal server start-dev", "temporal workflow start", "temporal workflow execute", + "temporal workflow signal", "temporal workflow query", "temporal workflow update". +version: 0.6.1 --- # Skill: temporal-developer @@ -103,4 +111,4 @@ For Temporal plugins and integrations with third-party frameworks and SDKs (Spri ### Reporting Issues in This Skill -If you (the AI) find this skill's explanations are unclear, misleading, or missing important information—or if Temporal concepts are proving unexpectedly difficult to work with—draft a GitHub issue body describing the problem encountered and what would have helped, then ask the user to file it at https://github.com/temporalio/skill-temporal-developer/issues/new. Do not file the issue autonomously. +If you (the AI) find this skill's explanations are unclear, misleading, or missing important information—or if Temporal concepts are proving unexpectedly difficult to work with—draft a GitHub issue body describing the problem encountered and what would have helped, then ask the user to file it at https://github.com/temporalio/skill-temporal-developer/issues/new. Do not file the issue autonomously. \ No newline at end of file From 454fcfefed19495c0498a65958e28e05996ab1a4 Mon Sep 17 00:00:00 2001 From: Patrik Beqo Date: Fri, 4 Sep 2026 12:25:29 -0400 Subject: [PATCH 80/82] Restore SKILL.md formatting (#264) --- SKILL.md | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/SKILL.md b/SKILL.md index a770333..edc29db 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,14 +1,6 @@ --- name: temporal-developer -description: Develop, debug, and manage Temporal applications across Python, TypeScript, - Go, Java, .NET, Ruby, and Rust. Use when the user is building workflows, activities, - or workers with a Temporal SDK, debugging issues like non-determinism errors, stuck - workflows, or activity retries, using Temporal CLI, Temporal Server, or Temporal - Cloud, or working with durable execution concepts like signals, queries, heartbeats, - versioning, continue-as-new, child workflows, or saga patterns. Also use when the - user mentions "run a Temporal workflow from the CLI", "start a dev server", "run - temporal server start-dev", "temporal workflow start", "temporal workflow execute", - "temporal workflow signal", "temporal workflow query", "temporal workflow update". +description: Develop, debug, and manage Temporal applications across Python, TypeScript, Go, Java, .NET, Ruby, and Rust. Use when the user is building workflows, activities, or workers with a Temporal SDK, debugging issues like non-determinism errors, stuck workflows, or activity retries, using Temporal CLI, Temporal Server, or Temporal Cloud, or working with durable execution concepts like signals, queries, heartbeats, versioning, continue-as-new, child workflows, or saga patterns. Also use when the user mentions "run a Temporal workflow from the CLI", "start a dev server", "run temporal server start-dev", "temporal workflow start", "temporal workflow execute", "temporal workflow signal", "temporal workflow query", "temporal workflow update". version: 0.6.1 --- @@ -111,4 +103,4 @@ For Temporal plugins and integrations with third-party frameworks and SDKs (Spri ### Reporting Issues in This Skill -If you (the AI) find this skill's explanations are unclear, misleading, or missing important information—or if Temporal concepts are proving unexpectedly difficult to work with—draft a GitHub issue body describing the problem encountered and what would have helped, then ask the user to file it at https://github.com/temporalio/skill-temporal-developer/issues/new. Do not file the issue autonomously. \ No newline at end of file +If you (the AI) find this skill's explanations are unclear, misleading, or missing important information—or if Temporal concepts are proving unexpectedly difficult to work with—draft a GitHub issue body describing the problem encountered and what would have helped, then ask the user to file it at https://github.com/temporalio/skill-temporal-developer/issues/new. Do not file the issue autonomously. From 2d7fda32ffbf71106c65c98478ee1031aca1b65b Mon Sep 17 00:00:00 2001 From: "temporal-plugin-updater[bot]" <276371939+temporal-plugin-updater[bot]@users.noreply.github.com> Date: Fri, 4 Sep 2026 16:26:43 +0000 Subject: [PATCH 81/82] Release v0.6.2 (#265) Co-authored-by: patbeqo <46697474+patbeqo@users.noreply.github.com> --- SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SKILL.md b/SKILL.md index edc29db..a2fc13d 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,7 +1,7 @@ --- name: temporal-developer description: Develop, debug, and manage Temporal applications across Python, TypeScript, Go, Java, .NET, Ruby, and Rust. Use when the user is building workflows, activities, or workers with a Temporal SDK, debugging issues like non-determinism errors, stuck workflows, or activity retries, using Temporal CLI, Temporal Server, or Temporal Cloud, or working with durable execution concepts like signals, queries, heartbeats, versioning, continue-as-new, child workflows, or saga patterns. Also use when the user mentions "run a Temporal workflow from the CLI", "start a dev server", "run temporal server start-dev", "temporal workflow start", "temporal workflow execute", "temporal workflow signal", "temporal workflow query", "temporal workflow update". -version: 0.6.1 +version: 0.6.2 --- # Skill: temporal-developer From 8d998b8ba6f82f789c417f02cf4cf47a536c0794 Mon Sep 17 00:00:00 2001 From: mesilov Date: Mon, 7 Sep 2026 00:53:25 +0600 Subject: [PATCH 82/82] docs(php): refresh SDK guidance and course references --- SKILL.md | 2 +- references/integrations.md | 4 +- references/php/advanced-features.md | 111 ++------ references/php/batch-processing.md | 59 ++++ references/php/data-handling.md | 224 +++------------ references/php/determinism-protection.md | 38 +-- references/php/determinism.md | 54 +--- references/php/error-handling.md | 143 +++------- references/php/gotchas.md | 151 +--------- .../php/integrations/laravel-temporal.md | 44 +++ references/php/integrations/support.md | 37 +++ references/php/observability.md | 16 +- references/php/patterns.md | 158 +++++++---- references/php/php.md | 43 ++- references/php/skill-scenarios.md | 30 ++ references/php/sources.md | 92 ++++++ references/php/testing.md | 263 ++++++------------ references/php/versioning.md | 239 +++------------- references/php/workers.md | 71 +++++ 19 files changed, 753 insertions(+), 1026 deletions(-) create mode 100644 references/php/batch-processing.md create mode 100644 references/php/integrations/laravel-temporal.md create mode 100644 references/php/integrations/support.md create mode 100644 references/php/skill-scenarios.md create mode 100644 references/php/sources.md create mode 100644 references/php/workers.md diff --git a/SKILL.md b/SKILL.md index ed8f12b..77a101f 100644 --- a/SKILL.md +++ b/SKILL.md @@ -60,7 +60,7 @@ Check if `temporal` CLI is installed. If not, follow the instructions at `refere - .NET (C#) -> read `references/dotnet/dotnet.md` - Ruby -> read `references/ruby/ruby.md` - Rust -> read `references/rust/rust.md` (in Public Preview) - - PHP -> read `references/php/php.md` + - PHP -> read `references/php/php.md`; it routes to RoadRunner operations, Laravel/support integrations, and a reviewed PHP course/source map 2. Second, read appropriate `core` and language-specific references for the task at hand. ## Primary References diff --git a/references/integrations.md b/references/integrations.md index af5953b..950e45b 100644 --- a/references/integrations.md +++ b/references/integrations.md @@ -1,6 +1,6 @@ # Third-Party Integrations Catalog -Temporal ships and supports a growing set of integrations with third-party frameworks and SDKs — typically as plugins, contrib modules, or starter libraries. This file is the catalog. Each integration has a dedicated reference under `references/{language}/integrations/`. +This catalog includes Temporal-maintained and community integrations with third-party frameworks and SDKs — typically plugins, contrib modules, or starter libraries. This file is the catalog. Each integration has a dedicated reference under `references/{language}/integrations/`. ## How to use this catalog @@ -26,3 +26,5 @@ Temporal ships and supports a growing set of integrations with third-party frame | Braintrust (`@braintrust/temporal`) | TypeScript | LLM observability: `BraintrustTemporalPlugin` registers on Client + Worker to trace Workflow/Activity spans; canonical guide hosted by Braintrust | `references/typescript/integrations/braintrust.md` | `references/core/ai-patterns.md` | | Mastra (`@mastra/temporal`, Public Preview) | TypeScript | Build-time transform of Mastra `createWorkflow`/`createStep` definitions into Temporal Workflows and Activities; `MastraPlugin` auto-registers Activities on the Worker | `references/typescript/integrations/mastra.md` | `references/typescript/typescript.md`, `references/core/ai-patterns.md` | | Vercel AI SDK (`@temporalio/ai-sdk`, Public Preview) | TypeScript | Durable Vercel AI SDK agents: `AiSdkPlugin` wraps `generateText` and other AI SDK calls as Activities; `temporalProvider.languageModel()` provides the workflow-safe model; tools dispatch via `proxyActivities`; stateless MCP servers register through `mcpClientFactories` and are used in-workflow via `TemporalMCPClient` | `references/typescript/integrations/vercel-ai-sdk.md` | `references/core/ai-patterns.md` | +| Laravel (`keepsuit/laravel-temporal`, community) | PHP | Framework discovery, DI/lifecycle, converters, worker and test helpers | [PHP Laravel guide](php/integrations/laravel-temporal.md) | `references/php/workers.md`, `references/php/testing.md` | +| Temporal PHP Support (`temporal-php/support`, community) | PHP | Optional stub factories, default attributes and VirtualPromise typing | [PHP support guide](php/integrations/support.md) | `references/php/php.md` | diff --git a/references/php/advanced-features.md b/references/php/advanced-features.md index 1303935..a03fc3c 100644 --- a/references/php/advanced-features.md +++ b/references/php/advanced-features.md @@ -1,111 +1,60 @@ # PHP SDK Advanced Features +See [workers.md](workers.md) for tuning and RoadRunner configuration, and [patterns.md](patterns.md) for messages, child workflows and cancellation. + ## Schedules -Create recurring workflow executions. +Use Schedules for independently managed recurring starts. Use a durable timer for a wait within one workflow; Continue-As-New preserves a long-lived workflow identity while rotating history. These solve different lifecycle problems. ```php -use Temporal\Client\Schedule\Schedule; +use Temporal\Client\GRPC\ServiceClient; use Temporal\Client\Schedule\Action\StartWorkflowAction; +use Temporal\Client\Schedule\Policy\ScheduleOverlapPolicy; +use Temporal\Client\Schedule\Policy\SchedulePolicies; +use Temporal\Client\Schedule\Schedule; use Temporal\Client\Schedule\Spec\ScheduleSpec; -use Temporal\Client\Schedule\Spec\ScheduleIntervalSpec; +use Temporal\Client\ScheduleClient; +$scheduleClient = ScheduleClient::create(ServiceClient::create('127.0.0.1:7233')); $handle = $scheduleClient->createSchedule( Schedule::new() - ->withAction(StartWorkflowAction::new('DailyReportWorkflow') + ->withAction(StartWorkflowAction::new('DailyReport') ->withTaskQueue('reports') - ) - ->withSpec(ScheduleSpec::new() - ->withIntervals(new ScheduleIntervalSpec(every: new \DateInterval('P1D'))) - ), + ->withInput(['report-account-id'])) + ->withSpec(ScheduleSpec::new()->withAddedInterval(new \DateInterval('P1D'))) + ->withPolicies(SchedulePolicies::new() + ->withOverlapPolicy(ScheduleOverlapPolicy::Skip)), scheduleId: 'daily-report', ); - -// Manage schedules -$handle->pause(); -$handle->unpause(); -$handle->trigger(); // Run immediately -$handle->delete(); ``` -## Async Activity Completion +Use registered Workflow Type names and a converter compatible with the worker. `withAddedInterval()` is the verified API; there is no `ScheduleIntervalSpec`/`withIntervals()` combination in the reviewed SDK. Intervals are epoch/phase based, not necessarily “N days after creation.” For “Monday at 09:00,” use calendar/structured-calendar rules plus an explicit IANA timezone and test DST behavior. Jitter distributes scheduled load; it does not replace rate limits. -For activities that complete asynchronously (e.g., human tasks, external callbacks). +Choose overlap deliberately: `Skip`, `BufferOne`, `BufferAll`, `CancelOther`, `TerminateOther`, or `AllowAll`. Termination does not run workflow cleanup. Set catchup window and pause-on-failure behavior where required. Handles support pause/unpause, trigger, describe, update, backfill and delete; backfill starts actions and can repeat business side effects. Use stable schedule identity and per-occurrence idempotency, especially for payments. Do not retry the whole scheduled workflow merely to retry one Activity. -```php -use Temporal\Activity; +Sources: [ScheduleSpec](https://github.com/temporalio/sdk-php/blob/v2.18/src/Client/Schedule/Spec/ScheduleSpec.php), [Schedule client](https://github.com/temporalio/sdk-php/blob/v2.18/src/Client/ScheduleClient.php), [course schedule assessment](sources.md). -#[ActivityMethod] -public function requestApproval(string $requestId): void -{ - // Get task token for async completion - $taskToken = Activity::getInfo()->taskToken; +## Asynchronous Activity completion - // Store task token for later completion (e.g., in database) - $this->storeTaskToken($requestId, $taskToken); - - // Mark this activity as waiting for external completion - Activity::doNotCompleteOnReturn(); -} -``` - -Complete the activity from another process: +An Activity can hand work to an external process and finish later: ```php -use Temporal\Client\WorkflowClient; - -$client = WorkflowClient::create(); -$taskToken = getStoredTaskToken($requestId); - -$completionClient = $client->newActivityCompletionClient(); -$completionClient->complete($taskToken, 'approved'); +use Temporal\Activity; -// Or fail it: -// $completionClient->completeExceptionally($taskToken, new \Exception('Rejected')); +// Inside an Activity; persist/correlate securely before handing off. +$taskToken = Activity::getInfo()->taskToken; +$this->storeCompletionToken($requestId, $taskToken); +Activity::doNotCompleteOnReturn(); ``` -**Note:** If the external system can reliably signal back with the result and doesn't need to heartbeat or receive cancellation, consider using **signals** instead. - -## Worker Tuning - -Configure worker performance settings. +From a client process with an explicitly connected `$client`: ```php -use Temporal\Worker\WorkerOptions; - -$worker = $factory->newWorker( - taskQueue: 'my-queue', - options: WorkerOptions::new() - ->withMaxConcurrentWorkflowTaskPollers(5) - ->withMaxConcurrentActivityTaskPollers(5) - ->withMaxConcurrentWorkflowTaskExecutionSize(100) - ->withMaxConcurrentActivityExecutionSize(100) -); +$completion = $client->newActivityCompletionClient(); +$completion->completeByToken($taskToken, 'approved'); +// Or: $completion->completeExceptionallyByToken($taskToken, $error); ``` -PHP workers run as RoadRunner processes — the number of concurrent activities is also bounded by the number of RoadRunner worker processes configured in `.rr.yaml`. - -## RoadRunner Configuration - -PHP uses [RoadRunner](https://roadrunner.dev/) as the process supervisor. Configure it in `.rr.yaml`: - -```yaml -version: "3" - -temporal: - address: "localhost:7233" - namespace: "default" - activities: - num_workers: 10 # Number of PHP processes for activities - max_jobs: 100 # Restart worker after N jobs (prevents memory leaks) - memory_limit: 128MB # Restart worker if it exceeds this memory limit - -server: - command: "php worker.php" - relay: "pipes" -``` +`complete()` takes Workflow ID, Run ID, Activity ID and result; token completion uses **`completeByToken()`**. Treat the token as opaque sensitive data; it identifies an Activity attempt. Plan timeout, retry, heartbeat and cancellation behavior for stale/duplicate callbacks. For a human approval that naturally belongs to workflow state, Signal or Update plus a timer is often simpler than holding an Activity open. -Key settings: -- `num_workers` — controls activity concurrency (set based on available CPU/memory) -- `max_jobs` — prevents memory leaks by recycling PHP processes after N executions -- `memory_limit` — safety net for runaway memory usage +Source: [ActivityCompletionClientInterface](https://github.com/temporalio/sdk-php/blob/v2.18/src/Client/ActivityCompletionClientInterface.php). diff --git a/references/php/batch-processing.md b/references/php/batch-processing.md new file mode 100644 index 0000000..c949435 --- /dev/null +++ b/references/php/batch-processing.md @@ -0,0 +1,59 @@ +# PHP Batches and Concurrent Branches + +Read for large imports, per-item pipelines, or first-useful-result searches. See [worker capacity](workers.md) separately: workflow fan-out and Activity execution capacity are different limits. + +## Bounded fan-out and fan-in + +Use a batch ID plus a durable cursor, not 100,000 input records. An Activity reads a bounded page of IDs from a stable snapshot/keyset. Launch one coroutine per item in that page. Keep its dependent steps sequential with `yield`; give each external step its own idempotency key. Store large results externally and retain only counters/references in workflow state. + +A page-level fragment, inside a generator workflow with configured `$activities`: + +```php +use Temporal\Exception\Failure\ActivityFailure; +use Temporal\Exception\Failure\CanceledFailure; +use Temporal\Promise; +use Temporal\Workflow; + +// Application contract: ids has at most 50 entries; nextCursor is durable. +$page = yield $activities->readPage($batchId, $cursor, 50); +$promises = []; +foreach ($page['ids'] as $itemId) { + $promises[] = Workflow::async(function () use ($activities, $batchId, $itemId) { + try { + yield $activities->markStarted($batchId, $itemId); + $resultRef = yield $activities->process($batchId, $itemId); + yield $activities->markCompleted($batchId, $itemId, $resultRef); + return ['ok' => true]; + } catch (CanceledFailure $cancelled) { + throw $cancelled; + } catch (ActivityFailure $failure) { + if ($failure->getPrevious() instanceof CanceledFailure) { + throw $failure; + } + // Best-effort batch policy, after that Activity's retries end. + // Persist failures too; a reporting failure must not become success. + yield $activities->markFailed($batchId, $itemId, $failure->getMessage()); + return ['ok' => false]; + } + }); +} +$outcomes = yield Promise::all($promises); // Join AFTER launching this page. +``` + +After the join, update cumulative counts and advance the cursor. Release per-page arrays. Either process a bounded number of pages per run or Continue-As-New after each page; carry batch ID, cursor, policy and summary forward. Make that handoff explicit and terminal in the main generator with `return yield Workflow::continueAsNew(...)`; drain message handlers first and preserve pending input. Finish the batch only when no page remains and all intended work has settled. An empty page must either terminate or advance the cursor, never create a busy loop. + +`Promise::all()` rejects when a constituent rejects; it does not automatically cancel or join remaining branches on failure. Choose fail-fast with explicit cancellation/draining, or turn expected terminal per-item failures into outcomes as above. Do not swallow workflow programming failures or cancellation as “one failed row.” For progress visible before the slowest item completes, update bounded counters in each completing coroutine or in `then()` callbacks, rather than awaiting results only in input order. + +Activities retry their own steps. If `process()` fails, successful `markStarted()` is already in history; a normal Activity retry does not rerun the whole chain. Restarting the workflow or re-reading a page after a failed run can repeat steps, so business-level deduplication is still required. For a multi-step item with its own lifecycle, use bounded child workflows; huge child fan-out also grows the parent's history. + +Sources: [AsyncClosure](https://github.com/temporalio/samples-php/tree/bb3e9d3d1dee9f035359bea68fa7cd7c6e3153d4/app/src/AsyncClosure), [PHP Promise implementation](https://github.com/temporalio/sdk-php/blob/v2.18/src/Promise.php), [Continue-As-New](https://docs.temporal.io/develop/php/workflows/continue-as-new). + +## First useful result + +Courier search from the course illustrates a different policy: return the first **non-empty** successful result, or stop when every provider finishes or the deadline expires. `Promise::race()` means first settled; `Promise::any()` means first fulfilled, which may be an empty list. Neither implies “first acceptable business result.” + +Maintain attempt-local winner and completion count. Handle rejected provider calls and increment completion in `finally`. When a winner or timeout occurs, cancel and drain losing scopes when safe, or guard late callbacks with an attempt token so results from an old search cannot mutate the next attempt. Cancellation requests cannot undo an external reservation; release losing reservations idempotently. Validate acceptance Signals against offered IDs and the current search round, not just “no courier assigned yet.” + +## Article assessment + +[Thierry Feuzeu's batch article](https://medium.com/@thierry.feuzeu/parallel-batch-processing-with-temporal-b10ae89e7269) contributes per-item sequential chains, concurrent items, queryable progress and explicit success/failure outcomes. Its intermediate snippet puts the join inside the launch loop; the full example places it after the loop. Use the latter shape. `always()` reflects an older promise API; verify the installed implementation before using `finally()`/callbacks. Its Schedule-To-Start and workflow timeout values are demonstration choices, not required batch settings. The bounded design above adds explicit memory/history budgets and cancellation handling based on SDK primitives. diff --git a/references/php/data-handling.md b/references/php/data-handling.md index 86257ef..be968be 100644 --- a/references/php/data-handling.md +++ b/references/php/data-handling.md @@ -1,232 +1,82 @@ # PHP SDK Data Handling -## Overview +Sources: [SDK converters](https://github.com/temporalio/sdk-php/tree/v2.18/src/DataConverter), [typed Search Attributes](https://github.com/temporalio/sdk-php/blob/v2.18/src/Common/TypedSearchAttributes.php), [Laravel integration](integrations/laravel-temporal.md). -The PHP SDK uses data converters to serialize/deserialize Workflow inputs, outputs, and Activity parameters. JSON is the default format. +## Contracts and payloads -## Default Data Converter +The default `DataConverter` tries `NullConverter`, `BinaryConverter`, `ProtoJsonConverter`, `ProtoConverter`, then `JsonConverter`. Use small explicit inputs: business IDs, immutable DTO snapshots, cursors and external result references. Do not serialize service/container objects or rely on a hydrated ORM model to stay current. Loading a lazy relation in a workflow is still DB I/O. -The default converter handles: -- `null` -- Scalars (`string`, `int`, `float`, `bool`) -- Arrays (JSON-serialized) -- Objects (JSON-serialized via public properties) - -**PHP-specific:** Workflow methods are generators. To specify the return type of a Workflow method, use the `#[ReturnType]` attribute on the interface method: +For a generator workflow, its PHP return declaration describes the coroutine, not the serialized result: ```php use Temporal\Workflow\ReturnType; +use Temporal\Workflow\WorkflowInterface; +use Temporal\Workflow\WorkflowMethod; #[WorkflowInterface] interface OrderWorkflowInterface { - #[WorkflowMethod] + #[WorkflowMethod(name: 'Order')] #[ReturnType(OrderResult::class)] public function run(OrderInput $input): \Generator; } ``` -Without `#[ReturnType]`, the SDK cannot deserialize the result into the correct class. - -## Custom Data Conversion - -Implement a custom `PayloadConverter` to handle types the default converter does not support: +`#[ReturnType]` supplies the result type for typed client/child calls; untyped clients can specify a result type explicitly. Use a compatible declared return type for synchronous methods. Test nested DTOs, enums, UUIDs, dates, collections and nullable fields through the actual converter on both sides. Adding a required constructor field can break old payloads; preserve backward-compatible decoding and fixtures. -```php -use Temporal\DataConverter\PayloadConverter; -use Temporal\Api\Common\V1\Payload; +## Custom conversion and encryption -class MyCustomConverter implements PayloadConverter -{ - public function getEncodingType(): string - { - return 'json/my-custom'; - } - - public function toPayload($value): ?Payload - { - if (!$value instanceof MyCustomType) { - return null; // Return null to let other converters handle it - } - - $payload = new Payload(); - $payload->setMetadata(['encoding' => $this->getEncodingType()]); - $payload->setData(json_encode($value->toArray())); - return $payload; - } - - public function fromPayload(Payload $payload, \ReflectionType $type) - { - return MyCustomType::fromArray(json_decode($payload->getData(), true)); - } -} -``` +Implement `Temporal\DataConverter\PayloadConverterInterface` when specializing one payload type. Its decode signature is `fromPayload(Payload $payload, Temporal\DataConverter\Type $type)`, not `ReflectionType`. Return `null` from `toPayload()` for unsupported values. Put specialized converters before the catch-all `JsonConverter` and preserve support for other encodings that the application uses. -Register the custom converter when creating the `WorkflowClient`: +Register the same converter for the client and worker: ```php -use Temporal\DataConverter\DataConverter; -use Temporal\DataConverter\JsonPayloadConverter; -use Temporal\DataConverter\NullPayloadConverter; - -$dataConverter = new DataConverter( - new NullPayloadConverter(), - new JsonPayloadConverter(), - new MyCustomConverter(), -); - -$client = WorkflowClient::create( - ServiceClient::create('localhost:7233'), - dataConverter: $dataConverter +$client = \Temporal\Client\WorkflowClient::create( + \Temporal\Client\GRPC\ServiceClient::create('127.0.0.1:7233'), + converter: $converter, ); +$factory = \Temporal\WorkerFactory::create(converter: $converter); ``` -## Payload Encryption +Also align Schedule clients, replay workers, and test invocation caches. The named parameter is `converter`, not `dataConverter`. -Encrypt sensitive Workflow data using a custom `PayloadCodec`: +Payload encryption/compression needs an implementation compatible with PHP's converter contracts, for example a `DataConverterInterface` decorator around the normal serialization pipeline. Verify encode/decode, encoding metadata, key rotation and old-history replay; use a reviewed encryption library. SDK v2.18 has no `DataConverter::withCodec()` or `Temporal\DataConverter\PayloadCodecInterface`; do not copy those APIs from other SDKs. A codec server is an optional UI/CLI decoding service, not worker-side encryption by itself. Search Attributes remain queryable metadata and must not contain secrets. -```php -use Temporal\DataConverter\PayloadCodecInterface; -use Temporal\Api\Common\V1\Payload; +## Search Attributes and Memo -class EncryptionCodec implements PayloadCodecInterface -{ - public function __construct(private string $key) {} - - public function encode(array $payloads): array - { - return array_map(function (Payload $payload) { - $encrypted = $this->encrypt($payload->serializeToString()); - $result = new Payload(); - $result->setMetadata(['encoding' => 'binary/encrypted']); - $result->setData($encrypted); - return $result; - }, $payloads); - } - - public function decode(array $payloads): array - { - return array_map(function (Payload $payload) { - if (($payload->getMetadata()['encoding'] ?? null) !== 'binary/encrypted') { - return $payload; - } - $decrypted = $this->decrypt($payload->getData()); - $result = new Payload(); - $result->mergeFromString($decrypted); - return $result; - }, $payloads); - } - - private function encrypt(string $data): string { /* ... */ } - private function decrypt(string $data): string { /* ... */ } -} -``` - -Apply the codec via `DataConverter` on the client: - -```php -$dataConverter = DataConverter::createDefault()->withCodec(new EncryptionCodec($encryptionKey)); - -$client = WorkflowClient::create( - ServiceClient::create('localhost:7233'), - dataConverter: $dataConverter -); -``` - -## Search Attributes - -Custom searchable fields for Workflow visibility. - -Define Search Attribute keys and set them at Workflow start: +Register custom Search Attribute names/types in the target namespace before using them. Set values at workflow start: ```php +use Temporal\Client\WorkflowOptions; use Temporal\Common\SearchAttributes\SearchAttributeKey; use Temporal\Common\TypedSearchAttributes; -$orderIdKey = SearchAttributeKey::forKeyword('OrderId'); -$orderStatusKey = SearchAttributeKey::forKeyword('OrderStatus'); - -$workflow = $client->newWorkflowStub( - OrderWorkflowInterface::class, - WorkflowOptions::new() - ->withTaskQueue('orders') - ->withTypedSearchAttributes( - TypedSearchAttributes::new() - ->withSearchAttribute($orderIdKey, $order->id) - ->withSearchAttribute($orderStatusKey, 'pending') - ) -); +$options = WorkflowOptions::new() + ->withTaskQueue('orders') + ->withTypedSearchAttributes( + TypedSearchAttributes::empty() + ->withValue(SearchAttributeKey::forKeyword('OrderId'), 'order-123') + ->withValue(SearchAttributeKey::forKeyword('OrderStatus'), 'pending'), + ) + ->withMemo(['source' => 'checkout']); ``` -Upsert Search Attributes during Workflow execution: +Within a workflow: ```php -use Temporal\Workflow; -use Temporal\Common\SearchAttributes\SearchAttributeKey; - -class OrderWorkflow implements OrderWorkflowInterface -{ - public function run(array $order): \Generator - { - // ... process order ... - - Workflow::upsertTypedSearchAttributes( - SearchAttributeKey::forKeyword('OrderStatus')->valueSet('completed') - ); - - return 'done'; - } -} -``` - -### Querying Workflows by Search Attributes - -```php -$executions = $client->listWorkflowExecutions( - 'OrderStatus = "processing" OR OrderStatus = "pending"' -); - -foreach ($executions as $execution) { - echo "Workflow {$execution->getExecution()->getWorkflowId()} is still processing\n"; -} -``` - -## Workflow Memo - -Store arbitrary metadata with Workflows (not searchable). - -```php -// Set memo at Workflow start -$workflow = $client->newWorkflowStub( - OrderWorkflowInterface::class, - WorkflowOptions::new() - ->withTaskQueue('orders') - ->withMemo([ - 'customer_name' => $order->customerName, - 'notes' => 'Priority customer', - ]) +\Temporal\Workflow::upsertTypedSearchAttributes( + \Temporal\Common\SearchAttributes\SearchAttributeKey::forKeyword('OrderStatus') + ->valueSet('completed'), ); +\Temporal\Workflow::upsertMemo(['phase' => 'fulfilled']); ``` -Upsert memo during Workflow execution: +These upserts are synchronous SDK calls. Memo is non-indexed metadata; Search Attributes support visibility filtering. Queries read one workflow's current state and require a worker; visibility is indexed and can lag. Use a DB projection for application reporting when appropriate, with Activities updating it idempotently. ```php -class OrderWorkflow implements OrderWorkflowInterface -{ - public function run(array $order): \Generator - { - // ... process order ... - - Workflow::upsertMemo(['status' => 'fraud-checked']); - - return yield $this->activity->processPayment($order); - } +foreach ($client->listWorkflowExecutions('OrderStatus = "pending"') as $info) { + echo $info->execution->getID(), PHP_EOL; // Client-side code only. } ``` -## Best Practices - -1. Use `#[ReturnType]` on Workflow interface methods to enable correct deserialization -2. Keep payloads small — see `references/core/gotchas.md` for limits -3. Encrypt sensitive data with a `PayloadCodec` -4. Use typed Search Attributes for business-level visibility and querying +Avoid placing customer names/phones/addresses in Memo, Search Attributes or logs merely to make demos convenient. Payload size, retained workflow state and event-history growth are separate budgets; see [bounded batch design](batch-processing.md). diff --git a/references/php/determinism-protection.md b/references/php/determinism-protection.md index 3255291..1910429 100644 --- a/references/php/determinism-protection.md +++ b/references/php/determinism-protection.md @@ -1,43 +1,17 @@ # PHP Determinism Protection -## Overview +The SDK has no sandbox that blocks nondeterministic PHP operations. Laravel's application sandbox isolates framework state; it does not enforce replay determinism. Read [determinism.md](determinism.md) for the rules and [workers.md](workers.md) for persistent process state. -The PHP SDK does NOT have a sandbox. Unlike Python (exec-based sandbox) and TypeScript (V8 isolates), PHP relies entirely on runtime command-ordering checks and developer discipline. - -The `WorkflowPanicPolicy` enum controls what happens when non-determinism is detected at runtime: +`WorkflowPanicPolicy` controls the response to detected workflow panics/nondeterminism: ```php use Temporal\Worker\WorkerOptions; use Temporal\Worker\WorkflowPanicPolicy; -// In worker setup -$worker = $factory->newWorker('task-queue', WorkerOptions::new() - ->withWorkflowPanicPolicy(WorkflowPanicPolicy::FailWorkflow) -); +$options = WorkerOptions::new() + ->withWorkflowPanicPolicy(WorkflowPanicPolicy::BlockWorkflow); ``` -## Forbidden Operations - -These operations must NOT be used in workflow code: - -- No I/O: `fopen()`, `file_get_contents()`, `curl_*`, PDO, etc. -- No `sleep()` — use `yield Workflow::timer()` -- No `time()`, `date()`, `microtime()` — use `Workflow::now()` -- No `rand()`, `random_int()`, `uniqid()` — use `yield Workflow::sideEffect()` -- No blocking SPL functions -- No mutable global variables - -## Common Issues - -RoadRunner-specific issues to watch for: - -- **Worker memory leaks:** PHP workers are long-running processes. Configure `max_jobs` in `.rr.yaml` to restart workers periodically. -- **Shared state between workflow executions:** Class-level static variables persist across executions in the same worker process. Avoid mutable statics in workflow code. -- **Long-running PHP processes:** Unlike traditional PHP request/response, RoadRunner workers persist — ensure resources are released properly. - -## Best Practices +`BlockWorkflow` is the default and allows a corrected deployment to recover the execution. `FailWorkflow` makes it terminal; use it deliberately in disposable tests or where terminal failure is the intended policy. It is not a debugging switch that makes nondeterministic code safe. Validate command compatibility with [replay tests](testing.md). -1. Keep workflow code pure — orchestration only, no side effects -2. Use activities for all I/O and external calls -3. Configure `WorkflowPanicPolicy::FailWorkflow` for development to surface non-determinism immediately -4. Use `WorkflowPanicPolicy::BlockWorkflow` (default) for production to allow investigation without data loss +Source: [WorkerOptions](https://github.com/temporalio/sdk-php/blob/v2.18/src/Worker/WorkerOptions.php). diff --git a/references/php/determinism.md b/references/php/determinism.md index af2109c..1d413fe 100644 --- a/references/php/determinism.md +++ b/references/php/determinism.md @@ -1,48 +1,20 @@ # PHP SDK Determinism -## Overview +PHP has no workflow determinism sandbox. Replay reconstructs state by matching commands against recorded history; it cannot prove that every PHP computation is pure. See [core replay concepts](../core/determinism.md). -The PHP SDK does NOT have a sandbox like Python or TypeScript. There is no automatic enforcement of determinism — the developer must be disciplined. The SDK provides runtime command-ordering checks only. +| In workflow code | Use instead | +| --- | --- | +| DB/ORM, HTTP, files, external services | Activities | +| `sleep()` / `usleep()` | `yield Workflow::timer(...)` | +| Wall-clock `time()`, `microtime()`, `Carbon::now()` | `Workflow::now()` | +| Random UUID generation | `yield Workflow::uuid()` (check newer UUID APIs against the SDK) | +| Small random/computed external value | `yield Workflow::sideEffect(fn() => random_int(...))` | +| Mutable globals, environment-dependent branching | Explicit input or Activity-provided recorded data | -## Why Determinism Matters: History Replay +`sideEffect()` records a returned value. Do not put a payment, notification, arbitrary DB write or a change to workflow fields in its callback; replay skips that callback. Use its yielded result. `Workflow::now()` is synchronous, while UUID, timer, sideEffect, getVersion and Continue-As-New APIs return promises. Predicates passed to `Workflow::await()`/`awaitWithTimeout()` must re-evaluate state, not capture a boolean already evaluated once. -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**. +A method containing `yield` must return a generator-compatible PHP type, regardless of the final serialized result. Waiting on an Activity does not block a PHP thread for its entire duration, but a blocking call inside workflow code stalls the worker's event loop and can exceed Workflow Task timeouts. -See `references/core/determinism.md` for the full explanation. +Changes to command sequence require [versioning](versioning.md). Replay checks do not compare every ordinary variable, Activity input or timer duration; replay testing is compatibility evidence for the supplied histories, not a universal side-effect detector. Pair [recorded-history replay](testing.md) with code review and outcome tests. Use `Workflow::getLogger()` for replay-aware logging. -## SDK Protection / Runtime Checking - -The PHP SDK performs runtime checks that detect adding, removing, or reordering calls to: - -- `ExecuteActivity()` -- `ExecuteChildWorkflow()` -- `NewTimer()` -- `RequestCancelWorkflow()` -- `SideEffect()` -- `SignalExternalWorkflow()` -- `Sleep()` - -**This is NOT a thorough check** — it does not verify arguments or timer durations. Non-determinism that doesn't reorder commands will go undetected. Use replay testing to catch subtler issues. - -## Forbidden Operations - -These must NOT be used in workflow code: - -- No direct I/O: `fopen()`, `file_get_contents()`, `curl_*`, PDO, etc. -- No `sleep()` — use `yield Workflow::timer(new \DateInterval('PT10S'))` -- No `time()`, `date()`, `microtime()` — use `Workflow::now()` -- No `rand()`, `random_int()`, `uniqid()` — use `yield Workflow::sideEffect()` -- No blocking SPL functions -- No mutable global state - -## Testing Replay Compatibility - -Use the `WorkflowReplayer` class to verify your code changes are compatible with existing histories. See the Workflow Replay Testing section of `references/php/testing.md`. - -## Best Practices - -1. Use `Workflow::now()` for all time and date operations -2. Use `yield Workflow::sideEffect()` for any non-deterministic values -3. Delegate all I/O to activities -4. Test with `WorkflowReplayer` to catch non-determinism -5. Use `Workflow::getLogger()` instead of `error_log()` for replay-safe logging +Source: [PHP Workflow facade](https://github.com/temporalio/sdk-php/blob/v2.18/src/Workflow.php). diff --git a/references/php/error-handling.md b/references/php/error-handling.md index 5689947..eed9417 100644 --- a/references/php/error-handling.md +++ b/references/php/error-handling.md @@ -1,131 +1,64 @@ # PHP SDK Error Handling -## Overview +Source: [ApplicationFailure](https://github.com/temporalio/sdk-php/blob/v2.18/src/Exception/Failure/ApplicationFailure.php), [ActivityOptions](https://github.com/temporalio/sdk-php/blob/v2.18/src/Activity/ActivityOptions.php), [RetryOptions](https://github.com/temporalio/sdk-php/blob/v2.18/src/Common/RetryOptions.php). -The PHP SDK uses `ApplicationFailure` for application-specific errors and provides retry policy configuration via `RetryOptions`. Generally, the following information about errors and retryability applies across activities, child workflows, and Nexus operations. +## Failure boundaries -## Application Errors +An Activity's ordinary PHP exception is encoded as `ApplicationFailure` (normally using the exception's full class name as its type). The workflow sees an `ActivityFailure` wrapper after retries are exhausted or a terminal failure occurs; inspect its previous exception for `ApplicationFailure`, `TimeoutFailure` or cancellation. The external client normally sees `WorkflowFailedException` for a failed workflow. Do not expect a raw Activity exception at every boundary. -```php -use Temporal\Exception\Failure\ApplicationFailure; - -#[ActivityMethod] -public function validateOrder(Order $order): void -{ - if (!$order->isValid()) { - throw new ApplicationFailure( - message: 'Invalid order', - type: 'ValidationError', - nonRetryable: false, - ); - } -} -``` - -`ApplicationFailure` constructor: `new ApplicationFailure(string $message, string $type, bool $nonRetryable, array $details)`. +Throw `ApplicationFailure` for an intentional workflow/business failure. An ordinary programming error in workflow code normally fails a Workflow Task and can keep retrying/blocking the workflow; catching every `Throwable` and returning success hides defects. Cancellation is not an ordinary item failure: propagate it unless completing with a cancellation business result is deliberate. -## Non-Retryable Errors +## Terminal and transient errors ```php +use Temporal\DataConverter\EncodedValues; use Temporal\Exception\Failure\ApplicationFailure; -#[ActivityMethod] -public function chargeCard(ChargeCardInput $input): string -{ - if (!$this->isValidCard($input->cardNumber)) { - throw new ApplicationFailure( - message: 'Permanent failure - invalid credit card', - type: 'PaymentError', - nonRetryable: true, // Will not retry activity - ); - } - return $this->processPayment($input->cardNumber, $input->amount); -} -``` - -## Handling Activity Errors - -```php -use Temporal\Exception\Failure\ApplicationFailure; -use Temporal\Exception\Failure\ActivityFailure; - -#[WorkflowMethod] -public function run(): string -{ - try { - return yield $this->myActivity->doSomething('input'); - } catch (ActivityFailure $e) { - // $e->getPrevious() contains the original ApplicationFailure - $cause = $e->getPrevious(); - throw new ApplicationFailure( - message: 'Workflow failed due to activity error', - type: 'WorkflowError', - nonRetryable: false, - ); - } -} +throw new ApplicationFailure( + message: 'Payment declined', + type: 'PaymentDeclined', + nonRetryable: true, + details: EncodedValues::fromValues([['reason' => 'insufficient_funds']]), +); ``` -Activities throw exceptions; workflows catch `ActivityFailure` (which wraps the original exception). +`details` accepts `?ValuesInterface`, not a raw array. `fromValues()` takes a list of positional detail values: the outer list above makes `getValue(0)` return the whole reason map. `nextRetryDelay: new \DateInterval('PT30S')` can override the next retry interval where supported by the locked SDK/server. Classify the actual failure: a permanent business refusal differs from a temporary API outage. Do not blanket-classify every authentication failure without understanding credential refresh and recovery. -## Retry Policy Configuration +## Options and timeouts ```php use Temporal\Activity\ActivityOptions; use Temporal\Common\RetryOptions; -use Carbon\CarbonInterval; - -$options = ActivityOptions::new() - ->withRetryOptions( - RetryOptions::new() - ->withInitialInterval(CarbonInterval::seconds(1)) - ->withMaximumInterval(CarbonInterval::minutes(1)) - ->withMaximumAttempts(5) - ->withNonRetryableExceptions(['ValidationError', 'PaymentError']) - ); - -$result = yield $this->myActivityStub->withOptions($options)->doSomething('input'); +use Temporal\Workflow; + +$activities = Workflow::newActivityStub( + PaymentActivities::class, + ActivityOptions::new() + ->withStartToCloseTimeout(30) + ->withScheduleToCloseTimeout(300) + ->withRetryOptions( + RetryOptions::new()->withNonRetryableExceptions(['PaymentDeclined']) + ), +); ``` -Only set options such as `withMaximumInterval`, `withMaximumAttempts` etc. if you have a domain-specific reason to. If not, prefer to leave them at their defaults. +Set at least Start-To-Close or Schedule-To-Close. Pass options when creating the stub, or use the SDK's supported stub-options mechanism for the installed version; a typed Activity proxy does not expose a generic `withOptions()` method. -## Timeout Configuration +| Timeout | Meaning | +| --- | --- | +| Start-To-Close | One Activity attempt, after the worker accepts the task | +| Schedule-To-Close | Total Activity execution budget including queue waits and retries | +| Schedule-To-Start | Queue wait; generally leave unset unless explicit routing/failover needs it; this timeout is non-retryable | +| Heartbeat | Maximum heartbeat silence when configured | -```php -use Temporal\Activity\ActivityOptions; -use Carbon\CarbonInterval; +A timeout does not reliably kill an external HTTP call or PHP process. An old attempt can overlap a retry. Bound I/O timeouts and use a stable operation idempotency key across attempts. Heartbeats do not extend other timeouts and may be throttled; checkpoint only completed durable work, expect repeated work since the last persisted checkpoint. -$options = ActivityOptions::new() - ->withScheduleToCloseTimeout(CarbonInterval::minutes(30)) // Including retries - ->withStartToCloseTimeout(CarbonInterval::minutes(5)) // Single attempt - ->withHeartbeatTimeout(CarbonInterval::minutes(2)); // Between heartbeats - -$result = yield $this->myActivityStub->withOptions($options)->doSomething('input'); -``` +Default Activity retries already exist; add attempt limits/backoff only for a business or operational reason. A workflow retry re-executes a whole run and is not a substitute for per-Activity retries. Stable workflow IDs help deduplicate starts, but do not make a payment, DB write or notification exactly-once. Put deduplication at that side-effect boundary. -## Workflow Failure +`Activity::heartbeat()` reports cancellation inside PHP Activity code with `Temporal\Exception\Client\ActivityCanceledException`; workflow-side cancellation uses failure types such as `CanceledFailure`. Do not catch only the workflow failure type inside the Activity. -```php -use Temporal\Exception\Failure\ApplicationFailure; - -#[WorkflowMethod] -public function run(): string -{ - if ($someCondition) { - throw new ApplicationFailure( - message: 'Cannot process order', - type: 'BusinessError', - nonRetryable: false, - ); - } - return 'success'; -} -``` +## Compensation -## Best Practices +Use [the Saga and cancellation patterns](patterns.md). Register idempotent compensation before an operation that may succeed without reporting success. The compensation must tolerate both “nothing happened” and “already compensated.” The SDK's `Saga::compensate()` already uses detached cancellation scopes; a handwritten cleanup generator must explicitly use `Workflow::asyncDetached()` when its parent is cancelled. Decide how to report compensation failure; logging it and reporting success is usually misleading. -1. Use specific error types (the `type` parameter) for different failure modes -2. Mark permanent failures as non-retryable with `nonRetryable: true` -3. Configure appropriate retry policies for activities -4. Catch `ActivityFailure` in workflows — the original exception is in `$e->getPrevious()` -5. Design activity code to be idempotent for safe retries (see more at `references/core/patterns.md`) +Cancellation also needs an ordering policy. The default Activity cancellation mode is `TryCancel`: the workflow can enter compensation while the original Activity still runs. A refund that sees no charge yet can miss a later charge. Where appropriate, configure `ActivityCancellationType::WaitCancellationCompleted`, heartbeat delivery and finite timeout budgets, then reconcile uncertain external outcomes. A cancellation-intent/fencing protocol at the payment or reservation boundary must prevent or detect late side effects; detached cleanup and idempotency alone do not establish this ordering. diff --git a/references/php/gotchas.md b/references/php/gotchas.md index ca5cfab..4471428 100644 --- a/references/php/gotchas.md +++ b/references/php/gotchas.md @@ -1,140 +1,15 @@ # PHP Gotchas -PHP-specific mistakes and anti-patterns. See also `references/core/gotchas.md` for language-agnostic concepts. - -## Wrong Retry Classification - -**Example:** Transient network errors should be retried. Authentication errors should not be. -See `references/php/error-handling.md` to understand how to classify errors. - -## Cancellation - -### Not Handling Workflow Cancellation - -```php -// BAD - Cleanup doesn't run on cancellation -#[WorkflowMethod] -public function run(): void -{ - yield $this->myActivity->acquireResource(); - yield $this->myActivity->doWork(); - yield $this->myActivity->releaseResource(); // Never runs if cancelled! -} - -// GOOD - Use try/finally for cleanup -#[WorkflowMethod] -public function run(): void -{ - yield $this->myActivity->acquireResource(); - try { - yield $this->myActivity->doWork(); - } finally { - // Runs even on cancellation - yield $this->myActivity->releaseResource(); - } -} -``` - -When a workflow is cancelled from the client, a `Temporal\Exception\Client\WorkflowFailedException` is thrown on the caller side. Inside the workflow, cancellation arrives as a `CanceledFailure` on the yielded promise. - -### Not Handling Activity Cancellation - -Activities detect cancellation through heartbeat. Without heartbeating, an activity runs to completion even when cancelled. - -```php -// BAD - Activity ignores cancellation -#[ActivityMethod] -public function longRunningTask(): void -{ - foreach ($this->items as $item) { - $this->process($item); // Runs to completion even if cancelled - } -} - -// GOOD - Heartbeat and detect cancellation -#[ActivityMethod] -public function longRunningTask(): void -{ - foreach ($this->items as $i => $item) { - Activity::heartbeat(['progress' => $i]); // Throws on cancellation - $this->process($item); - } -} -``` - -`Activity::heartbeat()` throws `Temporal\Exception\Failure\CanceledFailure` when the activity has been cancelled. Let it propagate or catch it for cleanup. - -## Heartbeating - -### Forgetting to Heartbeat Long Activities - -```php -// BAD - No heartbeat, can't detect stuck activities -#[ActivityMethod] -public function processLargeFile(string $path): void -{ - foreach ($this->readChunks($path) as $chunk) { - $this->process($chunk); // Takes hours, no heartbeat - } -} - -// GOOD - Regular heartbeats with progress -#[ActivityMethod] -public function processLargeFile(string $path): void -{ - foreach ($this->readChunks($path) as $i => $chunk) { - Activity::heartbeat(['chunk' => $i]); - $this->process($chunk); - } -} -``` - -### Heartbeat Timeout Too Short - -```php -// BAD - Heartbeat timeout shorter than processing time -$options = ActivityOptions::new() - ->withStartToCloseTimeout(CarbonInterval::minutes(30)) - ->withHeartbeatTimeout(CarbonInterval::seconds(10)); // Too short! - -// GOOD - Heartbeat timeout allows for processing variance -$options = ActivityOptions::new() - ->withStartToCloseTimeout(CarbonInterval::minutes(30)) - ->withHeartbeatTimeout(CarbonInterval::minutes(2)); -``` - -Set heartbeat timeout as high as acceptable for your use case — each heartbeat counts as an action. - -## Testing - -### Not Testing Failures - -It is important to make sure workflows work as expected under failure paths in addition to happy paths. Please see `references/php/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, and should be considered in addition to standard testing. Please see `references/php/testing.md` for more info. - -## Timers and Sleep - -### Using sleep() in Workflows - -```php -// BAD: sleep() is not deterministic during replay -#[WorkflowMethod] -public function run(): void -{ - sleep(60); // Non-deterministic! Uses wall clock, not workflow timer -} - -// GOOD: Use Workflow::timer() for deterministic timers -#[WorkflowMethod] -public function run(): \Generator -{ - yield Workflow::timer(60); - // Or with CarbonInterval: - yield Workflow::timer(CarbonInterval::seconds(60)); -} -``` - -**Why this matters:** `sleep()` uses the system clock, which differs between original execution and replay. `Workflow::timer()` creates a durable timer in the event history, ensuring consistent behavior during replay. +1. **Direct PHP worker startup:** RoadRunner must launch the entrypoint; use `rr serve` with the matching configuration. See [quickstart](php.md). +2. **Generator return declarations:** A method with `yield` cannot return `string` or `void` in PHP. `#[ReturnType]` describes the serialized workflow result separately. +3. **Forgetting a join:** Collect promises for concurrent work, then yield their join. Returning after scheduling a notification does not ensure it completes. `Workflow::uuid()` also requires `yield`. +4. **Awaiting a boolean:** Use `yield Workflow::await(fn() => Workflow::allHandlersFinished())`, not the already evaluated result of `allHandlersFinished()`. +5. **Lost Signals:** Clearing a shared buffer after yielding can erase messages received during the wait. Snapshot/reset before yielding; preserve pending data across Continue-As-New. See [patterns](patterns.md). +6. **Reusing Update ID for every change:** Use an ID per logical request; retry that request with the same ID. StageAccepted is not StageCompleted. +7. **Compensation after cancellation:** A handwritten `finally` remains in the cancelled scope. Use detached cleanup and await it. SDK `Saga::compensate()` already uses detached scopes. +8. **Heartbeat misconceptions:** Set HeartbeatTimeout for heartbeat-based failure detection; checkpoint completed work, resume from persisted details and remain idempotent. Heartbeats may be throttled and do not reset Start-To-Close. See [errors/timeouts](error-handling.md). +9. **Unbounded batches:** Limit page size, in-flight promises, retained results and history independently. More Activity workers do not bound workflow memory. See [batch processing](batch-processing.md). +10. **Long-lived services:** Shared Activity instances, static caches and ORM identity maps can retain both memory and tenant state. Reset per-invocation resources and measure RSS separately from PHP allocations. See [workers](workers.md). +11. **Fake means replay-safe:** Dispatch fakes are not recorded-history replay; real message/timer tests also require a worker and the correct server. See [testing](testing.md). +12. **Copying another SDK's APIs:** Verify namespace, method signature and locked version. Examples involving converters, completion tokens, deployment options and testing lifecycle are especially easy to mistranslate. +13. **Treating samples as production code:** Preserve the demonstrated concept while reviewing idempotency, cancellation, workflow IDs, payloads, source versions and unfinished paths. See [source assessment](sources.md). diff --git a/references/php/integrations/laravel-temporal.md b/references/php/integrations/laravel-temporal.md new file mode 100644 index 0000000..e4f4287 --- /dev/null +++ b/references/php/integrations/laravel-temporal.md @@ -0,0 +1,44 @@ +# Laravel Temporal (keepsuit) + +Use when the application already uses Laravel or the user asks for this integration. It is a community package, not a prerequisite for PHP Temporal. Reviewed source: [0173907](https://github.com/keepsuit/laravel-temporal/tree/0173907640765f13f4e8e9ad2a970f5db4f4b1fd). That snapshot requires PHP ^8.2, Laravel components ^11/^12/^13 and SDK **~2.17.0**. The course instead locks integration **2.2.1**, Laravel **12.51.0**, SDK **2.16.0**. Do not force SDK 2.18 into a package whose constraint excludes it. + +## Bootstrapping and discovery + +```bash +composer require keepsuit/laravel-temporal +php artisan temporal:install +php artisan vendor:publish --tag=temporal-config +php artisan temporal:make:workflow Order +php artisan temporal:make:activity Payment +php artisan temporal:work orders +``` + +Inspect existing project conventions before generating files. The integration discovers definitions under `app`; external paths can be registered using `TemporalRegistry` and discovery helpers. Bind interfaces/implementations according to the package's discovery and container rules. Verify the actual registered Workflow/Activity Type names and queue, not just PHP file names. + +Client code can use the bound `WorkflowClientInterface` or `Keepsuit\LaravelTemporal\Facade\Temporal` builders. Distinguish the **Laravel facade** from the SDK's `Temporal\Workflow` facade. Framework DI, DB, HTTP, config and request helpers belong in client/Activity code, not arbitrary workflow decisions. + +`Temporal::buildWorkerOptionsUsing(fn(string $queue) => WorkerOptions::new()...)` customizes logical worker options. RoadRunner process limits are separate. `temporal:work` owns a RoadRunner worker process lifecycle; the course's shared Octane/Temporal `.rr.yaml` is an alternative deployment arrangement, not a requirement. Verify graceful shutdown support/options in the installed version and test SIGTERM with active Activities. + +## Serialization and application lifetime + +The package supports `TemporalSerializable`, Eloquent conversion via `TemporalEloquentSerialize`, and Spatie Laravel Data integration. `TemporalSerializableCastAndTransformer` can handle nested serializable values when configured. Test both ends with `DataConverterInterface` resolved from the same integration; mixing a native default client converter with a Laravel worker can break nested objects. + +Prefer small explicit DTO snapshots and IDs for long-running workflows. Serialized Eloquent state does not track subsequent DB changes; lazy loading is workflow I/O. Decide whether the workflow or database projection owns each status, and update projections through Activities. Creating a DB row then starting a workflow is not one atomic transaction: use a stable business Workflow ID with reconciliation or an outbox when that gap matters. + +The package's [ApplicationSandboxInterceptor](https://github.com/keepsuit/laravel-temporal/blob/0173907640765f13f4e8e9ad2a970f5db4f4b1fd/src/Interceptors/ApplicationSandboxInterceptor.php) creates a scoped application, flushes it and resets `CurrentApplication` in `finally`. This isolates container state; it is not a determinism/security sandbox. Custom static caches, native resources and process-wide listeners still need bounded lifetimes. Do not install another reset mechanism without checking the existing lifecycle. Test tenant context across successful and failed invocations in one process. + +## Testing + +- `Temporal::fake()` with `mockWorkflow(...)->andReturn(...)` or `mockActivity([Interface::class, 'method'])->andReturn(...)` supports dispatch/input/queue assertions. It is not a replay test. +- `Keepsuit\LaravelTemporal\Testing\WithTemporal` owns a test server and worker. `TEMPORAL_TESTING_SERVER=false` uses an external server while starting a worker. +- `WithTemporalWorker` is for a server started separately, such as via `temporal:server`. +- `TEMPORAL_TESTING_SERVER_TIME_SKIPPING=true` or the server command's `--enable-time-skipping` selects the special time-skipping server. A normal dev server does not skip time. +- `WithoutTimeSkipping` plus `TemporalTestTime::sleep()` controls time in interaction tests. + +Use one lifecycle owner. Isolate test server/namespace/queue/RPC ports from application workers, and share the right converter and mock cache. A DB transaction confined to PHPUnit's process is not automatically visible to a separate Activity worker. Test real messages/cancellation and [history replay](../testing.md) in addition to dispatch fakes. + +## Interceptors and analysis + +Register compatible interceptors in `config/temporal.php`. Use headers and scoped tracing propagation across client → workflow → Activity → child calls; reset context at invocation boundaries. The package provides a PHPStan extension (`extension.neon`) for proxy typing. Neither that extension nor `VirtualPromise` proves deterministic execution. + +Sources: [package README](https://github.com/keepsuit/laravel-temporal/blob/0173907640765f13f4e8e9ad2a970f5db4f4b1fd/README.md), [integration tests](https://github.com/keepsuit/laravel-temporal/tree/0173907640765f13f4e8e9ad2a970f5db4f4b1fd/tests/Integrations/Temporal), [course findings](../sources.md). diff --git a/references/php/integrations/support.md b/references/php/integrations/support.md new file mode 100644 index 0000000..46a8755 --- /dev/null +++ b/references/php/integrations/support.md @@ -0,0 +1,37 @@ +# temporal-php/support + +Optional runtime helpers, not the Temporal runtime or a testing framework. Reviewed at [b177152](https://github.com/temporal-php/support/tree/b177152a2add479b0b3e83c1431517db8ce8184d), whose Composer requirements include PHP >=8.1 and SDK ^2.15. The course locks support 1.1.0. Respect the application's lockfile. + +```bash +composer require temporal-php/support +``` + +Despite the README's “dev dependency” wording, keep the package available at runtime if the worker/client calls its factories or uses its runtime attributes. Do not install it solely for a native SDK example. + +## Factories and default attributes + +- `Temporal\Support\Factory\ActivityStub::activity()` creates an Activity stub with named timeout/retry/queue options. +- `Temporal\Support\Factory\WorkflowStub::workflow()` creates a client stub. +- `Temporal\Support\Factory\WorkflowStub::childWorkflow()` creates a child stub inside a workflow. +- `Temporal\Support\Attribute\TaskQueue` and `RetryPolicy` supply defaults **only when these factories read them**. Native `Workflow::newActivityStub()` does not automatically interpret support attributes. + +Put attributes on the definition passed to the factory (usually the interface when separate). Explicit factory arguments override defaults; inspect the factory signature for available options. Avoid introducing retry policy on entire workflows just to simplify Activity defaults. + +```php +use Temporal\Support\Factory\ActivityStub; + +$activities = ActivityStub::activity( + class: PaymentActivities::class, + startToCloseTimeout: 30, + taskQueue: 'payments', +); +$result = yield $activities->charge($paymentId); +``` + +## VirtualPromise + +`Temporal\Support\VirtualPromise` is an IDE/static-analysis annotation for the value yielded from a proxy call. Use it in PHPDoc where useful; never instantiate or implement it, and do not make a synchronous Activity return a promise merely to satisfy the annotation. The real Activity still returns its ordinary value; the stub returns an awaitable. + +Analyzer support for the package's `@yield` annotation varies. Check the installed Psalm/PHPStorm/PHPStan integration instead of treating README capability notes as permanent facts. Laravel's PHPStan extension is a separate integration. + +Sources: [factories](https://github.com/temporal-php/support/tree/b177152a2add479b0b3e83c1431517db8ce8184d/src/Factory), [factory tests](https://github.com/temporal-php/support/tree/b177152a2add479b0b3e83c1431517db8ce8184d/tests/Unit/Factory). diff --git a/references/php/observability.md b/references/php/observability.md index 1967f45..c88902b 100644 --- a/references/php/observability.md +++ b/references/php/observability.md @@ -76,7 +76,7 @@ use Monolog\Handler\StreamHandler; use Temporal\WorkerFactory; $logger = new Logger('temporal'); -$logger->pushHandler(new StreamHandler('php://stdout')); +$logger->pushHandler(new StreamHandler('php://stderr')); $factory = WorkerFactory::create(); @@ -95,6 +95,18 @@ See the Search Attributes section of `references/php/data-handling.md` ## Best Practices 1. Use `Workflow::getLogger()` inside Workflow code for replay-safe logging -2. Do not use `echo` or `print()` in Workflows — output appears on every replay +2. Do not use `echo` or `print()` in worker code — replay duplicates output and stdout may carry the RoadRunner protocol 3. Use standard PSR-3 loggers in Activities (no replay concern) 4. Use Search Attributes for business-level visibility and querying across Workflow executions + +## Metrics and tracing + +The course distinguishes two pipelines: `temporal.metrics` exposes SDK/Go metrics through RoadRunner; the top-level `metrics` plugin accepts application metrics through RoadRunner RPC. Configure and scrape them separately. Use queue latency/backlog, execution duration, retries/timeouts, worker capacity and RSS for operations. Check actual metric names/labels in the installed version before writing dashboards or autoscaling rules. + +The course emits business counters through an Activity to keep direct RPC I/O out of workflow code. Such a counter can still increment more than once on Activity retry after a lost completion. Use a business-event ledger/deduplication when exact counts matter; do not present a retried metrics Activity as exactly-once accounting. Avoid high-cardinality metric labels such as order IDs or trace IDs; keep those in logs/traces. + +The optional `temporal/open-telemetry-interceptors` package supplies tracing integration. Register the client, workflow-outbound and Activity-inbound interceptors appropriate to the installed version, and configure the exporter/propagator consistently. The course keeps an HTTP span active while starting a workflow to propagate one trace context; verify incoming distributed-context extraction and cleanup as well. A generated `X-Trace-Id` header is correlation, not proof of end-to-end parent propagation. + +Exporting each span synchronously is a demo tradeoff, not a universal long-running-worker rule. If batching, configure and test periodic flushing, shutdown flushing and bounded queues. Never manually export spans over HTTP in deterministic workflow code. Redact sensitive payloads and SQL bindings. Workflow logs are process logs; they are not automatically shown in Temporal Web UI or stored as history events. + +Sources: [course telemetry configuration](https://github.com/agoalofalife-screencasts/temporal-course/blob/9218c4002d79f9f9ee4675784c4ce63d7ad0c1ab/config/temporal.php), [provider](https://github.com/agoalofalife-screencasts/temporal-course/blob/9218c4002d79f9f9ee4675784c4ce63d7ad0c1ab/app/Providers/AppServiceProvider.php), [metrics Activity](https://github.com/agoalofalife-screencasts/temporal-course/blob/9218c4002d79f9f9ee4675784c4ce63d7ad0c1ab/app/Temporal/Activities/MetricsActivity.php). diff --git a/references/php/patterns.md b/references/php/patterns.md index d77c9cc..7f31c0d 100644 --- a/references/php/patterns.md +++ b/references/php/patterns.md @@ -1,5 +1,7 @@ # PHP SDK Patterns +Sources: [SDK workflow APIs](https://github.com/temporalio/sdk-php/blob/v2.18/src/Workflow.php), [official samples](https://github.com/temporalio/samples-php/tree/bb3e9d3d1dee9f035359bea68fa7cd7c6e3153d4/app/src), and [reviewed course lessons](sources.md). Examples are independent fragments; supply imports and application contracts from the surrounding project. + ## Signals ```php @@ -185,7 +187,7 @@ class ParentWorkflow ProcessOrderWorkflow::class, ChildWorkflowOptions::new() ->withWorkflowId('order-' . $order->id) - ->withParentClosePolicy(ParentClosePolicy::POLICY_ABANDON) + ->withParentClosePolicy(\Temporal\Workflow\ParentClosePolicy::Abandon) )->run($order); $results[] = $result; } @@ -216,20 +218,22 @@ class CoordinatorWorkflow // Get stub for external workflow $handle = Workflow::newExternalWorkflowStub( TargetWorkflow::class, - $targetWorkflowId + new \Temporal\Workflow\WorkflowExecution($targetWorkflowId) ); // Signal the external workflow yield $handle->dataReady($dataPayload); // Or cancel it - yield $handle->cancel(); + yield Workflow::newUntypedExternalWorkflowStub(new \Temporal\Workflow\WorkflowExecution($targetWorkflowId))->cancel(); } } ``` ## Parallel Execution +Use this only for a small, already bounded input. For many items or independent multi-step chains, read [bounded batch processing](batch-processing.md). `Workflow::async()` gives cooperative workflow concurrency; RoadRunner Activity processes provide execution capacity. Awaiting already-started promises in submission order does not serialize the Activities, but it delays publication of later results. + ```php use Temporal\Workflow; @@ -280,6 +284,8 @@ class LongRunningWorkflow // Continue with fresh history before hitting limits if (Workflow::getInfo()->shouldContinueAsNew) { + yield Workflow::await(fn() => Workflow::allHandlersFinished()); + // Include pending messages and the cursor in $state. return yield Workflow::continueAsNew( 'LongRunningWorkflow', [$state] @@ -292,49 +298,58 @@ class LongRunningWorkflow ## Saga Pattern (Compensations) -**Important:** Compensation activities should be idempotent — they may be retried (as with ALL activities). +`Temporal\Workflow\Saga` keeps compensation callbacks and defaults to reverse registration order. `setParallelCompensation(true)` is suitable only for independent compensations. `setContinueWithError(true)` attempts later sequential compensations after a failure and reports collected failures. These are business choices, not universal defaults. + +Choose Activity cancellation semantics before using the example. The default `TryCancel` returns cancellation to the workflow immediately, so compensation may race the still-running operation. For operations that cooperate through heartbeats, a configured stub can wait for cancellation completion: ```php -#[WorkflowInterface] -class OrderSagaWorkflow -{ - #[WorkflowMethod] - public function run(Order $order): \Generator - { - $compensations = []; - $activities = Workflow::newActivityStub( - OrderActivities::class, - ActivityOptions::new()->withStartToCloseTimeout(CarbonInterval::minutes(5)) - ); +use Temporal\Activity\ActivityCancellationType; +use Temporal\Activity\ActivityOptions; + +$options = ActivityOptions::new() + ->withStartToCloseTimeout(60) + ->withScheduleToCloseTimeout(300) + ->withHeartbeatTimeout(10) + ->withCancellationType(ActivityCancellationType::WaitCancellationCompleted); +``` - try { - // Note: save the compensation BEFORE running the activity, - // because the activity could succeed but fail to report (timeout, crash, etc.). - // The compensation must handle both reserved and unreserved states. - $compensations[] = fn() => yield $activities->releaseInventoryIfReserved($order); - yield $activities->reserveInventory($order); - - $compensations[] = fn() => yield $activities->refundPaymentIfCharged($order); - yield $activities->chargePayment($order); - - yield $activities->shipOrder($order); - - return 'Order completed'; - } catch (\Throwable $e) { - Workflow::getLogger()->error('Order failed, running compensations', ['error' => $e->getMessage()]); - foreach (array_reverse($compensations) as $compensate) { - try { - yield $compensate(); - } catch (\Throwable $compErr) { - Workflow::getLogger()->error('Compensation failed', ['error' => $compErr->getMessage()]); - } - } - throw $e; - } - } +Pass these options to the Activity stub. Waiting can be slow if the Activity ignores cancellation; bound external I/O and heartbeat regularly. A timeout or acknowledgement does not prove an external payment cannot settle later. Use a stable operation ID plus cancellation intent/fencing or reconciliation at the side-effect boundary. In particular, `refundIfCharged()` must not treat “no charge yet” as proof that no future charge is possible. See [SDK cancellation modes](https://github.com/temporalio/sdk-php/blob/v2.18/src/Activity/ActivityCancellationType.php). + +```php +use Temporal\Workflow\Saga; + +// Inside a generator workflow; $activities is a configured Activity stub. +$saga = new Saga(); +try { + // Register first: reserve may succeed even if its response is lost. + // releaseIfReserved must tolerate both absence and repeated compensation. + $saga->addCompensation(fn() => yield $activities->releaseIfReserved($orderId)); + yield $activities->reserve($orderId); + + $saga->addCompensation(fn() => yield $activities->refundIfCharged($orderId)); + yield $activities->charge($orderId); +} catch (\Throwable $failure) { + yield $saga->compensate(); + throw $failure; } ``` +The SDK's `Saga::compensate()` creates detached cancellation scopes internally, so the call above is usable after cancellation. If compensation fails, decide how to retain/report both the original and cleanup failures. Returning a cancellation DTO or swallowing `CanceledFailure` completes the workflow normally; rethrow when the workflow must remain Cancelled. + +For handwritten cleanup, a plain `finally` does not make its scope immune to cancellation: + +```php +try { + yield $activities->doWork($resourceId); +} finally { + yield Workflow::asyncDetached(function () use ($activities, $resourceId) { + yield $activities->releaseIfAcquired($resourceId); + }); +} +``` + +Detached means independent of parent cancellation, not an independently durable process. Await cleanup before completing. Termination and execution timeouts do not provide the same cleanup opportunity as cooperative cancellation. + ## Wait Condition with Timeout ```php @@ -368,9 +383,11 @@ class ApprovalWorkflow ## 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. +Signal and Update handlers may yield Activities; validators and Queries must not. Async handlers interleave at yield points, so guard shared state and use a workflow-safe lock when a state transition must stay atomic. Initialize handler-visible state before it can be read. Do not finish the main workflow while a handler is still running. -When async handlers are necessary, use `Workflow::await(Workflow::allHandlersFinished())` at the end of your workflow (or before continue-as-new) to prevent completion until all pending handlers complete. +Use a shared `Temporal\Workflow\Mutex` instance per workflow and `yield Workflow::runLocked($this->mutex, function () { /* yielding state transition */ })` for handlers that must serialize. Create the mutex before handlers can run; a new mutex per call cannot coordinate them. `runLocked()` releases it in `finally`. Do not use blocking OS/DB locks in workflow code or hold the mutex while waiting for a handler that needs that same mutex. + +When async handlers are necessary, use `Workflow::await(fn() => Workflow::allHandlersFinished())` at the end of your workflow (or before continue-as-new) to prevent completion until all pending handlers complete. ```php #[WorkflowInterface] @@ -382,7 +399,7 @@ class HandlerAwareWorkflow // ... main workflow logic ... // Before exiting, wait for all handlers to finish - yield Workflow::await(Workflow::allHandlersFinished()); + yield Workflow::await(fn() => Workflow::allHandlersFinished()); return 'done'; } } @@ -403,7 +420,7 @@ class HandlerAwareWorkflow use Temporal\Activity; use Temporal\Activity\ActivityInterface; use Temporal\Activity\ActivityMethod; -use Temporal\Exception\Failure\CanceledFailure; +use Temporal\Exception\Client\ActivityCanceledException; #[ActivityInterface] class FileProcessingActivities @@ -416,18 +433,25 @@ class FileProcessingActivities ? Activity::getHeartbeatDetails('int') : 0; - $lines = file($filePath); + // Stream inside the Activity; do not materialize the entire file. + $lines = new \SplFileObject($filePath); + $lines->seek($startLine); try { - for ($i = $startLine; $i < count($lines); $i++) { - $this->processLine($lines[$i]); + while (!$lines->eof()) { + $i = $lines->key(); + $line = $lines->fgets(); + if ($line === '') { + break; + } + $this->processLine($line); // Heartbeat with progress - // If cancelled, heartbeat() throws CanceledFailure + // If cancelled, heartbeat() throws ActivityCanceledException Activity::heartbeat($i + 1); } return 'completed'; - } catch (CanceledFailure $e) { + } catch (ActivityCanceledException $e) { // Perform cleanup on cancellation $this->cleanup(); throw $e; @@ -453,7 +477,7 @@ class TimerWorkflow ## 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. +**Purpose**: Reduce scheduling latency for short operations executed by the workflow worker infrastructure without a server Activity Task Queue round trip. Completed results are recorded in history, but a local Activity may be re-executed before that record is persisted. Use idempotency; local Activities lack normal heartbeat/routing semantics and can delay Workflow Tasks. Prefer normal Activities unless measured latency justifies the tradeoff. ```php #[WorkflowInterface] @@ -472,3 +496,37 @@ class LocalActivityWorkflow } } ``` + +## Safe signal buffering + +Snapshot and remove only the batch being processed **before** yielding. Clearing the whole shared buffer after an Activity can discard Signals received during the wait: + +```php +$batch = $this->pendingOffers; +$this->pendingOffers = []; +yield $activities->persistOffers($batch); +// New signals are now in pendingOffers and remain available for the next batch. +``` + +On terminal persistence failure, explicitly fail or requeue that batch; do not silently discard it. Bound the buffer and define an overload/admission policy. Before Continue-As-New, wait for handlers, preserve all pending state in the next input, and `return yield Workflow::continueAsNew(...)` from the main method. `allHandlersFinished()` does not mean the application buffer is empty. Start the next run with the same Workflow ID and new Run ID; caller protocols must tolerate the transition. + +## Client-side Updates + +A typed running stub can call an attributed Update and wait for its result. For accepted-but-not-completed processing: + +```php +use Temporal\Client\Update\LifecycleStage; +use Temporal\Client\Update\UpdateOptions; + +$stub = $client->newUntypedRunningWorkflowStub($workflowId); +$handle = $stub->startUpdate( + UpdateOptions::new('updateAddress', LifecycleStage::StageAccepted) + ->withUpdateId($requestId) + ->withResultType(OrderResult::class), + $newAddress, +); +// Persist handle identity if another request/process will fetch the result. +$result = $handle->getResult(timeout: 5); +``` + +The request ID identifies one logical mutation. Reuse it for retries of that mutation; use another ID for another address change. Store Workflow ID, Run ID and Update ID when later retrieval must target the accepting run. Acceptance follows validation; it does not mean the Activity or state change finished. A client result timeout does not cancel the Update. Test validator rejection (`WorkflowUpdateException`), completion and handler-draining behavior. diff --git a/references/php/php.md b/references/php/php.md index 33909ec..c8a63d1 100644 --- a/references/php/php.md +++ b/references/php/php.md @@ -2,7 +2,11 @@ ## Overview -The Temporal PHP SDK (`temporal/sdk`) uses RoadRunner as the application server to run workflows and activities. PHP 8.1+ required. Workflows and activities are defined as classes using PHP attributes (`#[WorkflowInterface]`, `#[ActivityInterface]`, etc.). Async operations use generators with `yield` instead of `await`. There is no sandbox — the SDK relies on runtime determinism checks to detect non-deterministic code. +The Temporal PHP SDK (`temporal/sdk`) uses RoadRunner to run workflows and activities. The SDK requires PHP 8.1+; framework integrations can require newer PHP. Async operations use generators and `yield`. There is no determinism sandbox: replay checks do not prevent arbitrary PHP I/O. + +Before adapting examples, inspect `composer.lock`, the RoadRunner binary/configuration, PHP extensions, and the Temporal Server version. The reviewed course locks SDK 2.16.0; the SDK reference was also checked at tag `v2.18`. These are source snapshots, not an instruction to upgrade. See [sources and course lessons](sources.md) for commits, coverage, and limitations. + +Read only the relevant detail: [worker lifecycle and scaling](workers.md), [bounded batches and message handling](patterns.md), [Laravel](integrations/laravel-temporal.md), [support factories and typing](integrations/support.md), or [testing and replay](testing.md). ## Quick Demo of Temporal @@ -12,6 +16,8 @@ The Temporal PHP SDK (`temporal/sdk`) uses RoadRunner as the application server composer require temporal/sdk ``` +Configure Composer PSR-4 autoloading (`"App\\": "src/"`) and run `composer dump-autoload`. For the native gRPC client shown below, install/enable `ext-grpc`; `ext-protobuf` is an optional performance improvement. Install a RoadRunner binary compatible with the locked SDK, for example through `./vendor/bin/rr get-binary`. + **src/Activity/GreetingActivityInterface.php** - Activity interface: ```php run(); **Start the dev server:** Start `temporal server start-dev` in the background. -**Start the worker:** Start `php worker.php` in the background (RoadRunner must be available; alternatively use `./rr serve` with an `.rr.yaml` config). +**.rr.yaml** — RoadRunner launches `worker.php` and supplies its transport: + +```yaml +version: "3" +rpc: + listen: tcp://127.0.0.1:6001 +server: + command: "php worker.php" + relay: pipes +temporal: + address: "127.0.0.1:7233" + activities: + num_workers: 2 +logs: + level: info +``` + +**Start the worker:** Run `./rr serve -c .rr.yaml`. Running `php worker.php` directly does not provide the RoadRunner worker transport. Keep server, worker, and starter addresses/namespace/task queue consistent. **starter.php** - Start a workflow execution: ```php @@ -161,9 +184,9 @@ echo "Result: {$result}" . PHP_EOL; ### Workflow Definition - Use `#[WorkflowInterface]` attribute on the interface - Use `#[WorkflowMethod]` on the entry point method -- Workflow method must return `\Generator` (use `yield` for async calls) +- Methods containing `yield` must have a compatible return declaration such as `\Generator`, or omit it; they cannot declare `string`/`void`. A synchronous workflow can return a value directly. Use `#[ReturnType(...)]` for the serialized result of a generator workflow. - Use `#[SignalMethod]`, `#[QueryMethod]`, `#[UpdateMethod]` attributes for handlers -- Implementation class does not need any attributes — attributes go on the interface +- Put attributes on the interface when using a separate contract. Directly attributed concrete classes are also supported; do not invent an interface-only requirement. ### Activity Definition - Use `#[ActivityInterface]` attribute on the interface @@ -175,7 +198,7 @@ echo "Result: {$result}" . PHP_EOL; - Create `WorkerFactory::create()` — connects through RoadRunner - Call `$factory->newWorker('task-queue')` to bind to a task queue - Register workflow types: `$worker->registerWorkflowTypes(MyWorkflow::class)` -- Register activities: `$worker->registerActivity(MyActivity::class)` (or pass an instance) +- Register activities by class name. For constructor dependencies: `$worker->registerActivity(MyActivity::class, fn(\ReflectionClass $type) => $container->get($type->getName()))`. `registerActivity()` does not accept an instance; the older `registerActivityImplementations($instance)` API is deprecated. Keep instances stateless between invocations. - Call `$factory->run()` to start processing (blocks) ### Determinism @@ -212,16 +235,16 @@ PHP has **no sandbox**. Non-deterministic code in a workflow will cause history | `rand()` / `mt_rand()` / `random_int()` | `yield Workflow::sideEffect(fn() => rand())` | | Direct I/O (`file_get_contents`, `curl_exec`, DB queries) | Execute an activity | | Blocking SPL functions that depend on external state | Execute an activity | -| `getenv()` / `$_ENV` reads (non-constant) | Pass via workflow input or use `sideEffect` | +| `getenv()` / `$_ENV` reads for decisions | Pass configuration via workflow input; fetch changing configuration through an Activity | -Always `yield` promises returned by activity stubs and `Workflow::*` async methods. Forgetting `yield` means the workflow continues without waiting for the result. +Await async results with `yield`, or collect promises and yield their join for concurrency. `Workflow::uuid()`, `sideEffect()`, `getVersion()` and `continueAsNew()` return promises; `now()`, `allHandlersFinished()` and Search Attribute upserts are synchronous. Do not mechanically yield every facade method. ## Common Pitfalls 1. **Non-deterministic code in workflows** — Use activities for all I/O, randomness, and time-dependent logic 2. **Forgetting `yield` on promises** — `$this->activity->greet($name)` returns a promise; without `yield` the workflow gets the promise object, not the result 3. **Blocking operations in workflow code** — Never call `sleep()`, make HTTP requests, or query a database directly inside a workflow method -4. **Not heartbeating long-running activities** — Long activities must call `Activity::heartbeat()` periodically or Temporal will time them out +4. **Incorrect heartbeat assumptions** — Heartbeat timeout applies when configured. Heartbeat long work for failure detection, cancellation and checkpoints; it does not extend Start-To-Close or guarantee exactly-once processing. 5. **Using `echo` or `print()` in workflows** — Use `Workflow::getLogger()->info(...)` instead for replay-safe logging 6. **Mixing workflow and activity classes in the same file** — Keep them separate for clarity and maintainability 7. **Registering the wrong class** — Register the implementation class (e.g., `GreetingWorkflow::class`), not the interface @@ -240,4 +263,8 @@ See `references/php/testing.md` for info on writing tests. - **`references/php/observability.md`** - Logging, metrics, tracing, Search Attributes - **`references/php/testing.md`** - Testing workflows and activities with the PHP SDK - **`references/php/versioning.md`** - Patching API, workflow type versioning +- **[workers.md](workers.md)** - RoadRunner pools, memory, state isolation and scaling +- **[integrations/laravel-temporal.md](integrations/laravel-temporal.md)** - Laravel discovery, builders, data conversion and test helpers +- **[integrations/support.md](integrations/support.md)** - Optional factories, attributes and VirtualPromise +- **[sources.md](sources.md)** - Source snapshots, course lesson map and corrections to teaching examples - **`references/core/determinism.md`** - Core determinism concepts shared across all SDKs diff --git a/references/php/skill-scenarios.md b/references/php/skill-scenarios.md new file mode 100644 index 0000000..0398b1f --- /dev/null +++ b/references/php/skill-scenarios.md @@ -0,0 +1,30 @@ +# PHP Skill Application Scenarios + +Run these as retrieval/application checks when updating the PHP references. Ask a fresh reviewer to solve them using only the skill first, then check resulting API usage against the locked SDK/source. Do not mark a live workflow test passed on the strength of this review. + +## Scenarios and acceptance criteria + +1. **Worker with DI:** Provide minimal registration/config/start commands for an Activity with a logger dependency. Must launch through RoadRunner, pass a class plus factory to `registerActivity()`, keep Task Queue/namespace/address aligned, and distinguish logical worker concurrency from PHP pool size. Must not use `registerActivity(new ...)` or `memory_limit` as a Temporal pool option. +2. **100,000-item multi-tenant batch:** Each item has three sequential Activities; items run concurrently. Must bound page size, in-flight promises, retained outputs and history; preserve cursor/state across Continue-As-New; propagate cancellation; isolate/reset tenant context even after failure. Must explain that more pollers do not create more PHP execution capacity. +3. **PHPUnit fakes and replay:** Explain Laravel dispatch fakes versus real-worker ActivityMocker versus recorded-history replay. Must use exact registered Activity names and shared RPC/KV/converter, identify SDK 2.16 versus 2.18 environment differences, and use `Temporal\Testing\Replay\WorkflowReplayer` with a WorkflowExecution including Run ID for server replay. Must not invent factory start/stop methods or treat a printed replay failure as successful CI. +4. **Messages and continuation:** A Signal arrives while batch persistence yields; an Update is accepted but still executing. Must preserve new Signals, use a re-evaluated handler-finished predicate, preserve pending inputs, distinguish acceptance from completion, assign per-request Update IDs and test the Run ID transition. Queries/validators must remain read-only and nonblocking. +5. **Terminal payment failure and cancellation:** Must show a non-retryable ApplicationFailure with correct positional details, inspect ActivityFailure wrappers, distinguish Activity retries from workflow retries, register idempotent compensation before uncertain side effects, and use detached cleanup. Must recognize SDK Saga already detaches, explain the default TryCancel race with ongoing side effects, choose waiting/cooperative cancellation and/or external fencing/reconciliation, and decide cancellation outcome deliberately. +6. **Source adaptation traps:** Explain why course pins, Python tuning APIs, “memory never returns,” optional support attributes and the course's incomplete schedule command cannot be adopted as universal PHP requirements. Must find the pinned lesson/source map and integration constraints. + +## Baseline recorded before the 2026-09-06 update + +A separate agent read all 11 original PHP references. It produced `registerActivity(new OrderActivity($logger))` from the guide, could only assemble unbounded fan-out, and could not find tenant-lifetime guidance. It found unsupported test lifecycle/replay signatures, generator methods declared `string`/`void`, incomplete message-draining guidance, and handwritten cancellation cleanup without detached scopes. These were retrieval/application failures in the existing instructions, not executed PHP workflow failures. + +## Validation scope + +Re-run the scenarios after editing. Also lint PHP fences with the target PHP version, check local reference links and confirm nontrivial SDK calls against source or reflection. Any snippets depending on external Temporal/RoadRunner services require a separate runtime test before claiming they execute end to end. Preserve that distinction in the delivery report. + +## Results for the 2026-09-06 update + +- All six scenarios passed the separate agent's final retrieval/application review after corrections, including the late-payment cancellation race. +- All 48 PHP fences passed `php -l` on PHP 8.4. Seven workflow-body fragments were placed in a generator function for syntax checking; missing application contracts/imports were not runtime-tested. +- Twenty-two API/reflection/value checks passed with the available SDK v2.16.0 runtime, including registration, converters, failure details, schedules, deployment options and replay signatures. Newer APIs were checked against the pinned v2.18 source, not a v2.18 service stack. +- Local Markdown links and pinned repository paths resolved; YAML examples parsed; changed/new files passed whitespace checks. +- The generic skill-creator validator rejects the repository's pre-existing top-level `version` field on both original and updated `SKILL.md`. Temporary copies excluding that repository extension pass. The actual version field remains intact because the release workflow uses it. + +No live Temporal/RoadRunner workflow run, historical replay execution, course Docker stack or framework integration suite was performed. Those remain required when applying these patterns to an application. diff --git a/references/php/sources.md b/references/php/sources.md new file mode 100644 index 0000000..f9d2bf8 --- /dev/null +++ b/references/php/sources.md @@ -0,0 +1,92 @@ +# PHP Source Map and Course Assessment + +Reviewed on **2026-09-06**. Use this file when refreshing the PHP references, adapting a course lesson, or checking the provenance/limits of a recommendation. Read the task-specific guide first; this map is not required context for every PHP task. + +## Evidence and compatibility + +Repositories were cloned locally, including all course lesson refs/history. Course inspection covered custom workflows/Activities, DTOs/models, HTTP/console entrypoints, configuration, tests, Docker/telemetry wiring and historical code removed from `main`. This is a source-code review; the course videos were not supplied or watched. The full course stack was not run. Recency is recorded from commits, not inferred from the user's description of the course. + +| Source | Reviewed revision / date | Use | +| --- | --- | --- | +| [Official PHP developer guide](https://docs.temporal.io/develop/php) | Live pages on review date: setup, worker processes, testing, messages, versioning, Activity timeouts | Concepts and supported usage; resolve ambiguous examples against SDK source | +| [temporal-course](https://github.com/agoalofalife-screencasts/temporal-course/tree/9218c4002d79f9f9ee4675784c4ce63d7ad0c1ab) | `9218c4002d79f9f9ee4675784c4ce63d7ad0c1ab`, 2026-07-02; 24 commits reachable across fetched refs | Evolving Laravel food-delivery case study; lesson map below | +| [samples-php](https://github.com/temporalio/samples-php/tree/bb3e9d3d1dee9f035359bea68fa7cd7c6e3153d4) | `bb3e9d3d1dee9f035359bea68fa7cd7c6e3153d4`, 2026-06-04 | SDK patterns and executable project structure | +| [temporal-php/support](https://github.com/temporal-php/support/tree/b177152a2add479b0b3e83c1431517db8ce8184d) | `b177152a2add479b0b3e83c1431517db8ce8184d` | Optional factory defaults, attributes, VirtualPromise; factory tests checked | +| [keepsuit/laravel-temporal](https://github.com/keepsuit/laravel-temporal/tree/0173907640765f13f4e8e9ad2a970f5db4f4b1fd) | `0173907640765f13f4e8e9ad2a970f5db4f4b1fd`, 2026-08-07 | Laravel lifecycle, discovery, converters, fake and real-worker testing | +| [SDK PHP v2.18](https://github.com/temporalio/sdk-php/tree/v2.18) | `6e81416d87df815d1626c0ed77bf1a7875d69340`, 2026-08-17; also inspected `v2.16.0` | API/signature checks, including differences from the course | +| [Parallel batch processing](https://medium.com/@thierry.feuzeu/parallel-batch-processing-with-temporal-b10ae89e7269) | Thierry Feuzeu, 2025-04-07; article body accessible | Pattern inspiration; assessment in [batch-processing.md](batch-processing.md) | +| [Long-running PHP memory](https://butschster.medium.com/the-memory-pattern-every-php-developer-should-know-about-long-running-processes-d3a03b87271c) | Pavel Buchnev, 2025-11-19; article body accessible | Operational hypotheses; cross-checked against PHP/RoadRunner manuals | +| [Worker architecture and scaling](https://levelup.gitconnected.com/temporal-worker-architecture-and-scaling-af0c670ce6c1) | Sanil Khurana, 2025-08-07; article body accessible | Python-oriented model; port concepts only after PHP/RoadRunner verification | + +The course's `composer.lock` records PHP application requirement ^8.4, Laravel **12.51.0**, `keepsuit/laravel-temporal` **2.2.1**, SDK **2.16.0**, support **1.1.0**, and `internal/promise` **3.4.1**. Its Dockerfile uses RoadRunner **2025.1.5**, and Compose uses Temporal **1.29.3**. These are reproduction facts, not recommended deployment pins. The reviewed newer Laravel integration requires SDK **~2.17.0**, so SDK `v2.18` is a separate API reference, not a tested combination with that integration. + +Prefer the target application's lockfile and compatible release documentation. Community packages and examples can lag independently; “newest source” is not one coherent runtime. + +## Course lesson map + +Remote names are preserved verbatim, including `lessson-6.*`. Links pin the lesson snapshot so an earlier approach remains inspectable even if later lessons replace it. + +| Lesson / ref | Source | Ideas to retain and where to apply them | +| --- | --- | --- | +| `lesson-0.2` (`139c1cb`) | [Hello World baseline](https://github.com/agoalofalife-screencasts/temporal-course/tree/139c1cb/app/Temporal) | Attributed concrete workflows, stub construction, a generator entrypoint, synchronous client result, Laravel/Octane HTTP versus Temporal worker roles → [quickstart](php.md), [Laravel](integrations/laravel-temporal.md) | +| `lesson-1.1` (`b98c181`) | [Order introduction](https://github.com/agoalofalife-screencasts/temporal-course/tree/b98c181/app) | Order DTO/model conversion; business Workflow IDs; asynchronous HTTP start; Activity type naming; retries, timeout budgets and cancellation choices → [data](data-handling.md), [errors](error-handling.md) | +| `lesson-1.2` (`9b18453`) | [PrepareOrderActivity](https://github.com/agoalofalife-screencasts/temporal-course/blob/9b18453/app/Temporal/Activities/PrepareOrderActivity.php) | Staged work, attempt-aware failure injection, heartbeat progress and resumption. This Activity is deleted later; preserve the checkpointing lesson → [patterns](patterns.md) | +| `lesson-2.1` (`3c5d9cf`) | [Signal-based order](https://github.com/agoalofalife-screencasts/temporal-course/tree/3c5d9cf/app) | Replace simulated preparation with an external restaurant callback; Signal mutates workflow state, main coroutine awaits it; validate/deduplicate callback inputs → [patterns](patterns.md) | +| `lesson-2.2` (`2c84d27`) | [Restaurant deadline](https://github.com/agoalofalife-screencasts/temporal-course/blob/2c84d27/app/Temporal/Workflows/OrderWorkflow.php) | Durable await-with-timeout and explicit accepted/rejected/timeout outcomes, rather than blocking HTTP or PHP sleep | +| `lesson-2.3` (`f5cce01`) | [Query endpoint](https://github.com/agoalofalife-screencasts/temporal-course/blob/f5cce01/app/Http/Controllers/OrderStatusController.php) | Read workflow state through Query; enum-to-display mapping; not-found handling; distinguish live workflow state from DB projections | +| `lesson-3.1` (`3515c7f`) | [Courier child workflow](https://github.com/agoalofalife-screencasts/temporal-course/tree/3515c7f/app/Temporal/Workflows/SearchCourier) | Child lifecycle/ID/parent-close policy; contract interface and `ReturnType`; expanding radius with bounded attempts; accept/decline Signals; structured found/not-found outcome | +| `lesson-3.2` (`703845a`) | [Parallel courier providers](https://github.com/agoalofalife-screencasts/temporal-course/blob/703845a/app/Temporal/Workflows/SearchCourier/FindCourierWorkflow.php) | Compare all-results, first-fulfilled and first-non-empty policies; deadline and provider completion tracking → [batch/branch coordination](batch-processing.md) | +| `lesson-4.1` (`8eb245a`) | [Saga and cancellation](https://github.com/agoalofalife-screencasts/temporal-course/tree/8eb245a/app/Temporal) | Restaurant/courier compensation, reverse versus parallel cleanup, detached cleanup in child cancellation, cancellation as a domain outcome | +| `lesson-4.2` (`dc81aa4`) | [Versioned notification](https://github.com/agoalofalife-screencasts/temporal-course/blob/dc81aa4/app/Temporal/Workflows/OrderWorkflow.php) | Preserve no-notification/SMS/push branches with `getVersion()`; do not change historic command ordering → [versioning](versioning.md) | +| Xdebug commits (`8ede7e2`, `d648c87`) | [Activity debug configuration](https://github.com/agoalofalife-screencasts/temporal-course/blob/8ede7e2/.rr.yaml) | Separate Activity worker command, trigger-based debugging and container-to-host IDE mappings; account for timeout effects → [workers](workers.md) | +| `lesson-4.3` (`25a6de8`) | [Promotion workflow](https://github.com/agoalofalife-screencasts/temporal-course/tree/25a6de8/app) | Entity workflow, Signal buffer, batch persistence, history growth, handler draining and Continue-As-New; bounded signal generator for exercises | +| `lesson-5.1` (`d6e2202`) | [Weekly scheduling](https://github.com/agoalofalife-screencasts/temporal-course/blob/d6e2202/app/Console/Commands/MakeWeeklyOrderWorkflowBySchedule.php) | Schedule client/converter, calendar versus interval, timezone, jitter, overlap, catchup, pause/trigger/backfill; per-occurrence payment/booking workflow → [advanced features](advanced-features.md) | +| `lesson-5.2` (`85370b7`) | [Updates and result polling](https://github.com/agoalofalife-screencasts/temporal-course/blob/85370b7/app/Http/Controllers/OrderController.php) | Synchronous Update, pure validator, async StageAccepted handle, later result retrieval and HTTP error mapping; `sideEffect()` for recorded random values | +| `lessson-6.1` (`5917908`) | [Test infrastructure](https://github.com/agoalofalife-screencasts/temporal-course/tree/5917908/tests) | Dedicated server versus time-skipping server; test RoadRunner/RPC/KV isolation; ActivityMocker; ready-state polling; timers that would otherwise take a month → [testing](testing.md) | +| `lessson-6.2` / `main` (`9218c40`) | [Telemetry deployment](https://github.com/agoalofalife-screencasts/temporal-course/blob/9218c4002d79f9f9ee4675784c4ce63d7ad0c1ab/docker-compose.yml) | Server/SDK/application metrics; Prometheus/Grafana; OpenTelemetry → collector → Zipkin; JSON stderr logs → Vector → VictoriaLogs; Search Attributes and trace correlation → [observability](observability.md) | + +## Course limitations to resolve when adapting + +These are source-level findings and reasoning, not claims reproduced against a running course stack. They belong in implementation reviews, not as silent changes to the upstream course. + +1. **Unawaited values and side effects.** [OrderWorkflow](https://github.com/agoalofalife-screencasts/temporal-course/blob/9218c4002d79f9f9ee4675784c4ce63d7ad0c1ab/app/Temporal/Workflows/OrderWorkflow.php) assigns `Workflow::uuid()` without `yield`; it is a promise. [WeeklySubscriptionWorkflow](https://github.com/agoalofalife-screencasts/temporal-course/blob/9218c4002d79f9f9ee4675784c4ce63d7ad0c1ab/app/Temporal/Workflows/WeeklySubscriptionWorkflow.php) schedules a notification without waiting. Explicitly await results or their join before declaring completion. +2. **Signal initialization/races.** Order status is initialized after a yielded side effect, and RestaurantProcessing is set after notification. A Query/Signal can encounter uninitialized or unexpected state. Initialize handler-visible fields and define early/duplicate/late-message behavior before external callbacks can arrive. +3. **Lost promotion input.** [RestaurantPromotionWorkflow](https://github.com/agoalofalife-screencasts/temporal-course/blob/9218c4002d79f9f9ee4675784c4ce63d7ad0c1ab/app/Temporal/Workflows/RestaurantPromotionWorkflow.php) clears `pendingOffers` after yielding persistence. Offers received during that wait can be discarded. Snapshot/reset first; handle failed persistence; bound the buffer and carry pending state through Continue-As-New. Make the handoff terminal and explicit with `return yield` in the main method. +4. **Incomplete provider settlement.** [FindCourierWorkflow](https://github.com/agoalofalife-screencasts/temporal-course/blob/9218c4002d79f9f9ee4675784c4ce63d7ad0c1ab/app/Temporal/Workflows/SearchCourier/FindCourierWorkflow.php) increments completion only on success and leaves losing branches active. Rejections can prevent an all-complete condition; late results can affect later attempts. Use attempt-local state, completion in `finally`, and deliberate cancellation/draining. +5. **Courier acceptance validation.** The handler accepts any ID when none is assigned. Validate the sender/correlation at ingress and offered ID/current round in workflow state; handle duplicate/late acceptance and release losing reservations. +6. **Compensation window and outcome.** Order registers restaurant cancellation after notification succeeds; a successful side effect with a lost response has no registered compensation. Register idempotent “if applied” compensation first. SDK `Saga::compensate()` already detaches; do not misdiagnose its call as ordinary cancelled-scope cleanup. Separately, choose cancellation ordering: default `TryCancel` can start compensation before the original Activity finishes, requiring cooperative waiting and/or fencing/reconciliation of late side effects. Catching cancellation and returning a DTO/void completes normally: choose that business outcome or rethrow intentionally. +7. **Update deduplication.** The async controller uses Workflow ID as Update ID, making distinct address edits share one deduplication identity. Use a stable ID per logical request; retain the accepting Run ID for later retrieval when needed. Acceptance does not imply completion. +8. **Address projection mismatch.** [DatabaseActivity](https://github.com/agoalofalife-screencasts/temporal-course/blob/9218c4002d79f9f9ee4675784c4ce63d7ad0c1ab/app/Temporal/Activities/DatabaseActivity.php) updates `address`, while the model/DTO use `delivery_address`. Do not transplant that field name. Concurrent yielding Updates also need a coherent state/DB ordering policy. +9. **Schedule code is exploratory.** The console command fetches a hard-coded handle and executes `exit(0)` before creation; booking failure/payment compensation is unfinished. Its “interval starts at creation” comment is not the interval semantics. Extract schedule choices, not the command verbatim; backfill and whole-workflow retries can duplicate charges. +10. **Workflow versioning is local, not global.** The notification patch does not protect every later addition, such as a new early side-effect marker. Replay representative pre-patch histories before deploying any evolved course stage against existing executions. +11. **Mock names and versions.** [OrderWorkflowTest](https://github.com/agoalofalife-screencasts/temporal-course/blob/9218c4002d79f9f9ee4675784c4ce63d7ad0c1ab/tests/Feature/OrderWorkflowTest.php) mocks `NotifyRestaurant.notify`, while the attributes declare prefix `NotifyRestaurant` and method `Notify`; verify the exact registered type rather than guessing punctuation/case. Its fixed-result limitation describes the old mocker, not SDK v2.18. Reset shared mocks/time locks between tests. +12. **Observability semantics.** A metrics Activity can overcount on retry. Synchronous span export is a throughput tradeoff; configure flushing for batching instead of assuming a long-lived worker can never flush. DB listeners should not imply a zero-duration post-query span measured the actual query; handle SQL privacy and label cardinality. +13. **Demo operations and defaults.** Docker ports, container names, development credentials, unpinned extension installs, timeout values, debugger configuration and `auto-setup` are local-course choices. Preserve separate HTTP/Temporal/test lifecycles, but use the target application's deployment and isolation practices. + +## Official sample ideas beyond the course + +Use the [pinned sample tree](https://github.com/temporalio/samples-php/tree/bb3e9d3d1dee9f035359bea68fa7cd7c6e3153d4/app/src) as a retrieval index: + +| Samples | Reusable idea / adaptation boundary | +| --- | --- | +| `SimpleActivity`, `ActivityRetry`, `Exception`, `PolymorphicActivity` | Basic contracts, retries, exception wrapping and shared interfaces; verify current registration APIs | +| `AsyncActivity`, `AsyncClosure`, `CancellationScope` | Start concurrent branches before joining; cancel explicit scopes; add bounds for large inputs | +| `Child`, `Saga`, `BookingSaga`, `MoneyTransfer` | Child lifecycle and compensating operations; test loss-of-response windows and idempotency | +| `Signal`, `Query`, `Updates`, `SafeMessageHandlers` | Message contracts, state readiness, `Workflow::runLocked()`/Mutex, handler draining, state handoff and deduplication; review continuation details against the SDK | +| `MoneyBatch` | Signal-with-start for lazy creation of an entity workflow, and aggregation before one downstream action | +| `Periodic`, `Subscription`, `Cron`, `UpdatableTimer` | Long-lived timers, cancellation and rescheduling; use managed Schedules where recurring independent starts are intended | +| `FileProcessing` | Return host/queue affinity with a file reference; host-local routing couples recovery to that host. Prefer durable shared storage when replacement workers must recover | +| `AsyncActivityCompletion`, `LocalActivity` | Deferred completion token handling and local execution tradeoffs; do not confuse Activity completion with a Signal | +| `SearchAttributes`, `Interceptors`, `MtlsHelloWorld` | Visibility, boundary hooks and secure client/worker connections; verify PHP-specific configuration | +| `InfrequentPolling` | Poll via retrying Activities; benign failure category is version-dependent, not a blanket error-suppression policy | +| `Replay`, `app/tests/Feature` | Real RoadRunner/KV mock transport and history replay; adapt bootstrap to SDK version and make replay errors fail CI | + +## Article conclusions + +The memory article usefully highlights large ORM/file allocations, bounded caches, streaming, scope cleanup and worker rotation. Its absolute “memory never returns”/“process dies each request” framing is too strong. PHP's manual documents `gc_mem_caches()` and distinguishes live allocations from reserved memory; PHP-FPM normally reuses processes. Diagnose retention versus real leaks with PHP counters and RSS. The [worker guide](workers.md) uses those verified distinctions. + +The scaling article explains polling, execution slots, resource limits and pod autoscaling using **Python**. Its executor/tuner/PollerBehavior APIs and deprecation statements do not establish PHP support. Transfer the distinction between admission and execution capacity, metric-driven scaling and graceful scale-down; use verified RoadRunner pools and PHP WorkerOptions. The bounded batch article's specific adoption/corrections are in [batch-processing.md](batch-processing.md). + +## Refresh and verification + +Recheck pinned paths, package constraints and changed API signatures on refresh. Use the [behavioral scenarios](skill-scenarios.md) to detect retrieval/application regressions. PHP lint proves syntax, source signature checks prove only API shape, and a scenario review proves instruction usability; none substitutes for a live worker/server integration run. Report those levels separately. diff --git a/references/php/testing.md b/references/php/testing.md index 3d2d8fa..ba67863 100644 --- a/references/php/testing.md +++ b/references/php/testing.md @@ -1,211 +1,124 @@ # PHP SDK Testing -## Overview - -You test Temporal PHP Workflows using PHPUnit with the Temporal testing package. The PHP SDK provides `WorkerFactory` from `Temporal\Testing` and a RoadRunner test server for running workflows in an isolated environment. +Read the installed SDK and framework versions before copying a test bootstrap. Testing helpers ship under `Temporal\Testing` in `temporal/sdk`; they are not an in-process replacement for RoadRunner. Sources: [official testing guide](https://docs.temporal.io/develop/php/best-practices/testing-suite), [SDK testing code at v2.18](https://github.com/temporalio/sdk-php/tree/v2.18/testing/src), [sample feature tests](https://github.com/temporalio/samples-php/tree/bb3e9d3d1dee9f035359bea68fa7cd7c6e3153d4/app/tests/Feature). + +## Choose the test boundary + +| Test | Establishes | Does not establish | +| --- | --- | --- | +| Plain PHPUnit Activity unit test | Domain logic, I/O adapter behavior, idempotency | Worker transport or workflow replay | +| Laravel `Temporal::fake()` | Dispatch/input/queue assertions and controlled fakes | Real timers, cancellation delivery or replay compatibility | +| Real worker + mocked Activities | Orchestration, messages, failure handling | Real external side effects | +| Real worker + real test dependencies | Serialization, registration and integration | Compatibility with all historical executions | +| Replay of recorded histories | Compatibility of commands with those histories | Correctness of new external Activity behavior | + +## Real worker with Activity mocks + +Run a dedicated test worker process. Its entrypoint is the normal worker registration code with `Temporal\Testing\WorkerFactory` instead of `Temporal\WorkerFactory`, ending in `$factory->run()`. Do not call `$factory->start()`, `$factory->stop()` or treat `$factory->getClient()` as a workflow client: those are not the testing lifecycle shown by the SDK. + +Configure the test worker and shared mock transport: + +```yaml +version: "3" +rpc: + listen: tcp://127.0.0.1:6002 +server: + command: "php worker.test.php" + relay: pipes +temporal: + address: "127.0.0.1:7235" + activities: + num_workers: 2 +kv: + test: + driver: memory + config: + interval: 10 +logs: + level: info +``` -## Workflow Test Environment +This is `tests/.rr.test.yaml`, with commands resolved from the project root. Use the same test Temporal endpoint and namespace in the client, server and worker, and an isolated test Task Queue. Export `TEMPORAL_ADDRESS=127.0.0.1:7235` and `RR_RPC=tcp://127.0.0.1:6002` for the test runner. A mock written to a different RoadRunner RPC/KV instance will never reach the worker. An ordinary application worker polling the test queue can steal tasks and execute real Activities. -Set up a `bootstrap.php` to initialize the test environment: +Environment API changed between the course's SDK and `v2.18`: ```php use Temporal\Testing\Environment; $environment = Environment::create(); -$environment->start(); - -register_shutdown_function(function () use ($environment): void { - $environment->stop(); -}); +// SDK v2.18: explicit server + worker lifecycle. +$environment->startTemporalTestServer(); +$environment->startRoadRunner( + ['./rr', 'serve', '-c', 'tests/.rr.test.yaml'], + configFile: 'tests/.rr.test.yaml', +); +register_shutdown_function(static fn() => $environment->stop()); ``` -Configure `phpunit.xml` to use the bootstrap: +Provision the compatible RoadRunner and Temporal test-server binaries first; v2.18 does not perform the old automatic test-server download here. `configFile` is also used for the readiness check; `-c` in the command selects the actual serving configuration. -```xml - - - - tests - - - -``` +For SDK **v2.16.0**, the course-compatible equivalent is `$environment->start('./rr serve -c tests/.rr.test.yaml')`. Against an already running dedicated server, start only the worker through an external process supervisor or the framework lifecycle. In v2.16, `Environment::startRoadRunner()` can run alone with a command string. In v2.18 it checks Environment server state first; do not copy the course's worker-only bootstrap unchanged. Do not combine these bootstraps with a framework trait that already owns the server/worker lifecycle. -A test case uses `WorkerFactory` from the Testing namespace and registers workflows and activities: +A test body can use an explicit cache connection, especially with custom DTO converters: ```php -use PHPUnit\Framework\TestCase; -use Temporal\Testing\WorkerFactory; - -class MyWorkflowTest extends TestCase -{ - private WorkerFactory $factory; - - protected function setUp(): void - { - $this->factory = WorkerFactory::create(); - $worker = $this->factory->newWorker(); - $worker->registerWorkflowTypes(MyWorkflow::class); - $worker->registerActivity(MyActivity::class); - $this->factory->start(); - } - - protected function tearDown(): void - { - $this->factory->stop(); - } -} -``` - -## Mocking Activities - -Use `ActivityMocker` to mock activities without executing their real implementation: - -```php -use PHPUnit\Framework\TestCase; +use Temporal\Client\GRPC\ServiceClient; +use Temporal\Client\WorkflowClient; +use Temporal\Client\WorkflowOptions; use Temporal\Testing\ActivityMocker; -use Temporal\Testing\WorkerFactory; - -class MyWorkflowTest extends TestCase -{ - private WorkerFactory $factory; - private ActivityMocker $activityMocks; - - protected function setUp(): void - { - $this->factory = WorkerFactory::create(); - $worker = $this->factory->newWorker(); - $worker->registerWorkflowTypes(MyWorkflow::class); - $this->factory->start(); - - $this->activityMocks = new ActivityMocker(); - } - - protected function tearDown(): void - { - $this->activityMocks->clear(); - $this->factory->stop(); - } - - public function testWorkflowWithMock(): void - { - $this->activityMocks->expectCompletion( - MyActivity::class . '::doSomething', - 'mocked result' - ); - - $workflow = $this->factory->getClient()->newWorkflowStub(MyWorkflow::class); - $result = $workflow->run('input'); - - $this->assertEquals('expected output', $result); - } +use Temporal\Worker\ActivityInvocationCache\RoadRunnerActivityInvocationCache; + +$client = WorkflowClient::create(ServiceClient::create('127.0.0.1:7235')); +$mocks = new ActivityMocker( + new RoadRunnerActivityInvocationCache('tcp://127.0.0.1:6002', 'test'), +); +try { + // Must match the registered Activity Type, including prefix and case. + // Assumes #[ActivityInterface(prefix: 'Greeting.')] and method name 'greet'. + $mocks->expectCompletion('Greeting.greet', 'Hello, test!'); + $workflow = $client->newWorkflowStub( + GreetingWorkflowInterface::class, + WorkflowOptions::new()->withTaskQueue('test-queue'), + ); + $run = $client->start($workflow, 'test'); + self::assertSame('Hello, test!', $run->getResult(timeout: 10)); +} finally { + $mocks->clear(); } ``` -`expectCompletion(string $name, mixed $result)` — mock a successful activity result. - -## Testing Signals and Queries - -Start a workflow asynchronously, send a signal via the client, then query state: - -```php -public function testSignalAndQuery(): void -{ - $workflow = $this->factory->getClient()->newWorkflowStub(MyWorkflow::class); - - // Start workflow asynchronously - $run = $this->factory->getClient()->startWorkflow($workflow, 'input'); +`expectFailure($activityType, $throwable)` configures failure. SDK v2.16's mocker supplies fixed results; v2.18 also has `expectConsecutiveCompletions()` and `expectCompletionWhen()`. Verify capabilities rather than carrying forward the course comment that consecutive results are impossible. For DTOs, use the same converter in the client, test worker and invocation cache. Clear mocks between tests and isolate concurrently running tests; global cache clearing can interfere with another test. - // Send signal - $workflow->mySignal('signal data'); +## Time and messages - // Query state - $status = $workflow->getStatus(); - $this->assertEquals('expected', $status); +The normal dev server does **not** support time skipping. The special test server does. `Environment::startTemporalTestServer()` and `TestService` support that server; do not call test-service APIs against a normal server. Lock/unlock time skipping deliberately around interaction tests and restore it in teardown. A real Activity still needs real execution time; time skipping is not a faster PHP clock. - // Wait for completion - $result = $run->getResult(); - $this->assertEquals('done', $result); -} -``` - -## Testing Failure Cases +Create the helper with `Temporal\Testing\TestService::create('127.0.0.1:7235')`. Time-skipping locks are a **counter**, not a boolean: `lockTimeSkipping()` increments it and `unlockTimeSkipping()` decrements it. Balance each acquired lock in `finally`; do not unlock locks owned by framework lifecycle code. On an exclusively owned test server with exactly one lock, `unlockTimeSkippingWithSleep(3600)` temporarily releases and restores that lock to advance one hour. Additional locks prevent that fast-forward; an already-zero counter makes the call unbalanced. In SDK v2.18, `lockDelta()` helps detect an imbalance introduced through that helper instance. -Mock activity failures with `expectFailure()`: - -```php -public function testActivityFailureHandling(): void -{ - $this->activityMocks->expectFailure( - MyActivity::class . '::doSomething', - new \RuntimeException('Simulated failure') - ); +Start asynchronously with `$client->start($stub, ...$input)`. For a Signal, observe an initialized ready state through a bounded Query before sending when the protocol requires it; after sending, wait for the expected state or result. Signal acknowledgement is not a business-result acknowledgement. Initialize state visible to early queries; do not swallow arbitrary query errors until a test happens to pass. - $workflow = $this->factory->getClient()->newWorkflowStub(MyWorkflow::class); +Test Query read-only behavior, duplicate and late Signals, timeout versus Signal races, Update validator rejection, successful Update result, and accepted-but-not-completed async Updates. Give each logical Update a distinct stable request ID; retry the same request with the same ID. Before workflow completion/Continue-As-New, drain handlers with `yield Workflow::await(fn() => Workflow::allHandlersFinished())` and preserve pending inputs. Verify same Workflow ID/new Run ID across Continue-As-New and test a Signal arriving while a batch Activity is pending. - $this->expectException(\Temporal\Exception\Failure\ApplicationFailure::class); - $workflow->run('input'); -} -``` +See [Laravel testing helpers](integrations/laravel-temporal.md) for `WithTemporal`, `WithTemporalWorker`, `WithoutTimeSkipping` and `TemporalTestTime`. -## Workflow Replay Testing +## Replay testing -Use `WorkflowReplayer` to verify workflow determinism against recorded histories: +Use the actual namespace and signatures: ```php -use Temporal\Testing\WorkflowReplayer; +use Temporal\Testing\Replay\WorkflowReplayer; +use Temporal\Workflow\WorkflowExecution; -// Replay from server $replayer = new WorkflowReplayer(); $replayer->replayFromServer( - workflowType: MyWorkflow::class, - workflowId: 'workflow-id-to-replay', -); - -// Replay from JSON file -$replayer->replayFromJSON( - workflowType: MyWorkflow::class, - path: __DIR__ . '/history.json', -); - -// Replay from a WorkflowHistory object -$replayer->replayHistory( - workflowType: MyWorkflow::class, - history: $history, + workflowType: 'Order', // Registered Workflow Type, not necessarily a PHP FQCN. + execution: new WorkflowExecution('order-123', 'recorded-run-id'), ); +$replayer->replayFromJSON('Order', __DIR__ . '/histories/order.json'); +// Given a Temporal\Api\History\V1\History object: +// $replayer->replayHistory($history); ``` -## Activity Testing - -Test activities directly without a workflow: - -```php -use PHPUnit\Framework\TestCase; - -class MyActivityTest extends TestCase -{ - private MyActivity $activity; - - protected function setUp(): void - { - $this->activity = new MyActivity(); - } - - public function testActivity(): void - { - $result = $this->activity->doSomething('arg1'); - $this->assertEquals('expected', $result); - } -} -``` - -Activities are plain PHP classes — test them directly by instantiating and calling methods. - -## Best Practices +A compatible RoadRunner instance must be running with the workflow implementation registered and replay RPC support (introduced in RoadRunner 2023.3). `RR_RPC` must reach it. Server replay requires both Workflow ID and Run ID. JSON replay paths must exist where RoadRunner reads them, including across containers. `replayHistory()` takes only the History object; it derives the type from the first event. -1. Use the test environment (`Temporal\Testing\Environment`) for all workflow tests -2. Mock external dependencies using `ActivityMocker` rather than calling real services -3. Test replay compatibility when changing workflow code to catch determinism violations -4. Use unique workflow IDs per test to avoid conflicts -5. Call `$this->activityMocks->clear()` in `tearDown()` to reset mocks between tests -6. Test signal and query handlers explicitly with async workflow start +Keep fixtures for pre/post-patch executions and meaningful message/timer/child/failure branches. Fail the test/CI command on `ReplayerException`, especially `NonDeterministicWorkflowException`. The samples' replay command prints failures but returns success, so do not reuse its exit behavior as a CI gate. A replay pass only covers supplied histories. diff --git a/references/php/versioning.md b/references/php/versioning.md index 598baaf..4ef0636 100644 --- a/references/php/versioning.md +++ b/references/php/versioning.md @@ -1,228 +1,57 @@ # PHP SDK Versioning -For conceptual overview and guidance on choosing an approach, see `references/core/versioning.md`. +See [core versioning](../core/versioning.md) for strategy. Sources: [PHP versioning guide](https://docs.temporal.io/develop/php/workflows/versioning), [SDK deployment options](https://github.com/temporalio/sdk-php/blob/v2.18/src/Worker/WorkerDeploymentOptions.php). -## Patching API +## Patching workflow commands -### The getVersion() Function - -PHP uses `Workflow::getVersion()` (not `patched()`) to check whether a Workflow should run new or old code: +PHP uses `yield Workflow::getVersion()`, not another SDK's `patched()`: ```php -use Temporal\Workflow; - -class OrderWorkflow implements OrderWorkflowInterface -{ - public function run(array $order): \Generator - { - $version = yield Workflow::getVersion('add-fraud-check', Workflow::DEFAULT_VERSION, 1); - - if ($version === 1) { - // New code path - yield $this->activity->checkFraud($order); - } - // else: DEFAULT_VERSION — old code path (for replay of pre-patch executions) - - return yield $this->activity->processPayment($order); - } -} -``` - -**How it works:** -- `getVersion(changeId, minSupported, maxSupported)` records a marker in the Workflow history -- For new executions: returns `maxSupported` (e.g., `1`) -- For replay of pre-patch history: returns `Workflow::DEFAULT_VERSION` (value: `-1`) -- `DEFAULT_VERSION` represents executions that predate the patch - -**PHP-specific:** `getVersion()` is a coroutine — always `yield` it. - -### Three-Step Patching Process - -Patching is a three-step process for safely deploying changes. - -**Warning:** Failing to follow this process will result in non-determinism errors for in-flight Workflows. - -**Step 1: Patch in New Code** - -Add the version check with both old and new code paths: - -```php -public function run(array $order): \Generator -{ - $version = yield Workflow::getVersion('add-fraud-check', Workflow::DEFAULT_VERSION, 1); - - if ($version === 1) { - // New: Run fraud check before payment - yield $this->activity->checkFraud($order); - } - // DEFAULT_VERSION: skip fraud check (original behavior) - - return yield $this->activity->processPayment($order); -} -``` - -**Step 2: Deprecate the Patch** - -Once all pre-patch Workflow Executions have completed, remove the old branch. Keep the `getVersion()` call with `minSupported = maxSupported = 1`: - -```php -public function run(array $order): \Generator -{ - // minSupported = 1: will throw on replay of pre-patch history (safe — those are all done) - yield Workflow::getVersion('add-fraud-check', 1, 1); - - // Only new code remains - yield $this->activity->checkFraud($order); - - return yield $this->activity->processPayment($order); -} -``` - -**Step 3: Remove the Version Call** - -After all Workflows that passed through Step 2 have completed, remove the `getVersion()` call entirely: - -```php -public function run(array $order): \Generator -{ - yield $this->activity->checkFraud($order); - - return yield $this->activity->processPayment($order); +$version = yield \Temporal\Workflow::getVersion( + 'notification-channel', + \Temporal\Workflow::DEFAULT_VERSION, + 2, +); +if ($version === 1) { + yield $activities->sendSms($orderId); +} elseif ($version === 2) { + yield $activities->sendPush($orderId); } +// DEFAULT_VERSION preserves the original history with neither notification. ``` -### 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 +A new execution records the maximum supported version; an execution replaying a history from before the marker uses `DEFAULT_VERSION` (-1). Keep each historical branch needed by retained executions. Adding a new `sideEffect()`, UUID generation, timer or Activity elsewhere in the workflow can still break replay even if the notification itself is versioned. Review the entire command sequence. -For incompatible changes, create a new Workflow type instead of patching: +Once old versions are no longer needed, remove their branches but retain a `getVersion()` call with narrowed supported bounds. Remove the marker only after verifying the SDK's removal rules and every history you still need to replay/reset. “No open workflows” alone does not cover retained closed histories, reset points or rollback needs. Use actual visibility/history evidence and replay fixtures, not an assumed deployment date. -```php -// Original interface -#[WorkflowInterface] -interface PizzaWorkflowInterface -{ - #[WorkflowMethod(name: 'PizzaWorkflow')] - public function run(array $order): \Generator; -} +When querying `TemporalChangeVersion`, inspect the emitted values; the value includes change ID **and version**, such as `notification-channel-2`, not just `notification-channel`. Absence alone is not a reliable classification of every old code path. Preserve a unique change ID for each logical patch. -// New interface for incompatible changes -#[WorkflowInterface] -interface PizzaWorkflowV2Interface -{ - #[WorkflowMethod(name: 'PizzaWorkflowV2')] - public function run(array $order): \Generator; -} -``` +## New Workflow Types -Register both with the Worker: +For a substantially incompatible contract, register a new name (for example `OrderV2`), route new starts to it and keep the old implementation available for existing executions. Rename PHP classes independently from registered type names only when their attributes preserve the contract. Check routing on every worker polling the affected Task Queue. -```php -$worker = $factory->newWorker('pizza-task-queue'); -$worker->registerWorkflowTypes(PizzaWorkflow::class); -$worker->registerWorkflowTypes(PizzaWorkflowV2::class); -``` +## Worker Deployment Versioning -Start new executions using the new type: +Verify SDK, RoadRunner and server compatibility together. The deployment types below exist in SDK 2.16+ and were experimental in 2.16. Do not infer feature maturity or server support from class existence. Avoid copying historical preview/removal dates as current facts. ```php -$workflow = $client->newWorkflowStub( - PizzaWorkflowV2Interface::class, - WorkflowOptions::new()->withTaskQueue('pizza-task-queue') -); -$result = $workflow->run($order); -``` - -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., "order-service"). All versions of your code live under this umbrella. +use Temporal\Common\Versioning\VersioningBehavior; +use Temporal\Common\Versioning\WorkerDeploymentVersion; +use Temporal\Worker\WorkerDeploymentOptions; +use Temporal\Worker\WorkerOptions; -**Worker Deployment Version**: A specific snapshot of your code identified by a deployment name and Build ID (e.g., "order-service:v1.0.0" or "order-service:abc123"). - -### Configuring Workers for Versioning - -> **Note:** Worker Versioning is currently in Public Preview. The legacy Worker Versioning API (before 2025) will be removed from Temporal Server in March 2026. - -```php -$factory = WorkerFactory::create(); - -$worker = $factory->newWorker( - taskQueue: 'order-service', - deploymentOptions: WorkerDeploymentOptions::new() - ->withDeploymentName('order-service') - ->withBuildId('v1.0.0') // git commit hash or semver - ->withUseWorkerVersioning(true) +$options = WorkerOptions::new()->withDeploymentOptions( + WorkerDeploymentOptions::new() + ->withUseVersioning(true) + ->withVersion(WorkerDeploymentVersion::new('orders', 'build-abc123')) + ->withDefaultVersioningBehavior(VersioningBehavior::Pinned), ); - -$worker->registerWorkflowTypes(OrderWorkflow::class); -$worker->registerActivity(OrderActivity::class); - -$factory->run(); -``` - -**Configuration parameters:** -- `withUseWorkerVersioning`: Enables Worker Versioning -- `withDeploymentName`: Logical name for your service (consistent across versions) -- `withBuildId`: Unique identifier for this build (git hash, semver, etc.) - -### PINNED vs AUTO_UPGRADE Behaviors - -**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 - -**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. - -Use the Temporal CLI to set the current version: - -```bash -temporal worker deployment set-current-version \ - --deployment-name order-service \ - --build-id v1.0.0 +$worker = $factory->newWorker('orders', $options); ``` -### Querying Workflows by Worker Version - -```bash -# Find workflows on a specific Worker version -temporal workflow list --query \ - 'TemporalWorkerDeploymentVersion = "order-service:v1.0.0" AND ExecutionStatus = "Running"' -``` +Deployment options belong inside `WorkerOptions`; `newWorker()` has no `deploymentOptions:` argument. The canonical version string is `deploymentName.buildId`, not `deploymentName:buildId`. -## Best Practices +- `Pinned` keeps a workflow on its assigned deployment version. Retain capacity/code for that version until it is safe to retire. Moving a pinned workflow deliberately still requires compatibility review. +- `AutoUpgrade` allows movement to the current deployment version; incompatible command changes still require patching. -1. **Check for open executions** before removing old code paths -2. **Use descriptive change IDs** that explain the change (e.g., `add-fraud-check` not `patch-1`) -3. **Deploy incrementally**: patch in, deprecate (remove old branch), remove version call -4. **Use `yield` on `getVersion()`** — it is a coroutine and must be awaited -5. **Use List Filters** to verify no running Workflows before removing version support +Use the installed CLI's `temporal worker deployment --help` and subcommand help before performing a rollout. Setting the current deployment version affects routing; it is not a local code-only operation. Verify drained executions, visibility, replay and rollback requirements before removing workers or historical branches. diff --git a/references/php/workers.md b/references/php/workers.md new file mode 100644 index 0000000..5975476 --- /dev/null +++ b/references/php/workers.md @@ -0,0 +1,71 @@ +# PHP Workers: RoadRunner, State and Scaling + +Read for worker startup, DI, memory growth, throughput, and deployment. See [PHP quickstart](php.md) for a minimal worker. Source snapshots are in [sources.md](sources.md). + +## Execution model + +RoadRunner embeds the Temporal Go SDK and bridges it to PHP processes. PHP workflow coroutines orchestrate work; Activity PHP processes perform blocking I/O or computation. Waiting on a durable timer does not reserve an Activity process for the timer's duration. A PHP `WorkerFactory::newWorker()` registers a logical Task Queue worker; it is not an operating-system process constructor. + +Distinguish these controls: + +| Control | What it limits | +| --- | --- | +| Workflow code's in-flight promises | Per-workflow fan-out and retained state | +| `WorkerOptions::withMaxConcurrentActivityExecutionSize()` | Concurrent Activity executions admitted by that worker | +| `withMaxConcurrentActivityTaskPollers()` / `withMaxConcurrentWorkflowTaskPollers()` | Concurrent polling, not PHP execution capacity | +| `temporal.activities.num_workers` | Size of the RoadRunner PHP Activity process pool | +| RoadRunner replicas | Aggregate capacity, subject to shared DB/API limits | +| `withTaskQueueActivitiesPerSecond()` | Server-side Activity dispatch rate for a queue | +| `withWorkerActivitiesPerSecond()` | Activity rate per worker | + +More pollers cannot compensate for a saturated PHP pool. A large SDK execution limit with few PHP processes can hold tasks locally while their Start-To-Close budget runs. Do not derive a universal concurrency value from CPU count: measure work duration, memory per task, downstream connection limits and queue latency. Multi-queue workers can share a process pool, so per-queue limits are not automatically isolated capacity. + +Sources: [RoadRunner worker model](https://docs.roadrunner.dev/docs/workflow-engine/worker), [SDK WorkerOptions](https://github.com/temporalio/sdk-php/blob/v2.18/src/Worker/WorkerOptions.php). + +## Pool configuration + +Example values must be adjusted to measured workload requirements: + +```yaml +version: "3" +rpc: + listen: tcp://127.0.0.1:6001 +server: + command: "php worker.php" + relay: pipes +temporal: + address: "127.0.0.1:7233" + activities: + num_workers: 4 + max_jobs: 1000 + supervisor: + max_worker_memory: 256 +logs: + level: info +``` + +In the Temporal plugin the pool section is named `activities`, not `pool`. `supervisor.max_worker_memory` is a soft memory limit in megabytes; `temporal.activities.memory_limit: 128MB` is not the pool option. `max_jobs` recycles processes after a job count; it neither fixes leaks nor controls peak memory. Prefer the memory supervisor for memory-driven recycling. A hard `exec_ttl` can interrupt an Activity and cause a retry, so it is not interchangeable with a Temporal timeout. + +HTTP/Octane processes have their own `http.pool` settings. If HTTP and Temporal share RoadRunner, explicitly route the worker bootstraps by mode or set the Temporal worker command. Changing HTTP pool limits does not configure Temporal Activity capacity. + +Sources: [pool settings](https://docs.roadrunner.dev/docs/php-worker/pool), [Temporal pool naming](https://docs.roadrunner.dev/docs/php-worker/developer). + +## Memory and invocation lifetime + +Inspect three different measurements: `memory_get_usage(false)` for live PHP allocations, `memory_get_usage(true)` for memory reserved by PHP's allocator, and process/container RSS for the whole process. Native extension allocations may be missing from PHP's counters. A high-water plateau can be allocator retention or fragmentation; sustained growth in live allocations suggests retained objects. Neither symptom proves the cause without measurement. + +Use keyset-paged DB reads and streaming file reads inside Activities. Keep ORM identity maps, query logs, listeners, caches and tracing buffers bounded. Avoid `file()` for large inputs, `Model::all()`, huge eager relation graphs, and unbounded workflow result arrays. Store large outputs externally and return references. `unset()` removes a reference; it does not guarantee lower RSS. `gc_collect_cycles()` collects cycles; `gc_mem_caches()` can reclaim allocator caches, but neither replaces fixing retained references. PHP-FPM normally resets request state while reusing its process; it does not necessarily exit after every request. + +An Activity factory may resolve a shared service. Never retain the current tenant, credentials, request, transaction or tracing scope in mutable statics or shared Activity fields. Pass business context explicitly; use SDK headers/interceptors for tracing context. Restore scoped context in `finally`, including on failures. For interleaved workflow coroutines use coroutine-aware context, not one global current-tenant variable. A worker restart is not a per-invocation isolation mechanism. + +`registerActivityFinalizer()` can release/reset application resources after each Activity; an Activity's own `try/finally` is useful for resources it owns. In Laravel, inspect the integration's application sandbox lifecycle and add a test that executes tenant A then tenant B in the same worker, including an exception in A. Its container isolation does not make workflow I/O deterministic. + +Sources: [PHP memory counters](https://www.php.net/manual/en/function.memory-get-usage.php), [allocator cache reclamation](https://www.php.net/manual/en/function.gc-mem-caches.php), [Activity registration/finalizers](https://github.com/temporalio/sdk-php/blob/v2.18/src/Worker/WorkerInterface.php), [Laravel sandbox](integrations/laravel-temporal.md). + +## Scaling and shutdown + +Scale from Schedule-To-Start latency, backlog, available execution capacity, CPU/RSS, Activity duration and downstream errors. High queue latency with a full pool suggests insufficient execution capacity; high latency with idle capacity warrants checking polling, task-type registration, queue/namespace/version routing and connectivity first. Split queues and deployments when workloads need independent resource or rate limits. + +For Kubernetes/KEDA, use metrics verified for the installed server/SDK and test scale-down with active long Activities. An empty server queue does not imply idle workers: tasks may already be executing. Configure termination grace periods, graceful draining, heartbeat checkpoints and idempotent retry. Test forced worker loss separately. Do not port Python `ThreadPoolExecutor`, `WorkerTuner` or `PollerBehavior` examples into PHP APIs. + +For local Xdebug, the course sets an Activity-specific command through `/usr/bin/env XDEBUG_TRIGGER=PHPSTORM php ...`; reproduce it only in development, with verified worker paths and IDE mappings. A debugger pause can exceed Temporal task/Activity timeouts. Confirm recovery with replay and use [testing.md](testing.md) for an isolated stack.