diff --git a/.github/workflows/check-redirects.yml b/.github/workflows/check-redirects.yml index 23bde607fa..9e2ad4cb0c 100644 --- a/.github/workflows/check-redirects.yml +++ b/.github/workflows/check-redirects.yml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out repository - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 diff --git a/.github/workflows/cleanup-report.yml b/.github/workflows/cleanup-report.yml index b639b19895..b02895a793 100644 --- a/.github/workflows/cleanup-report.yml +++ b/.github/workflows/cleanup-report.yml @@ -18,7 +18,7 @@ jobs: BRANCH_REPORTS_DIR: reports/${{ github.event.ref }} steps: - name: Checkout GitHub Pages Branch - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: gh-pages diff --git a/.github/workflows/docs-preview-links.yml b/.github/workflows/docs-preview-links.yml index 0c061644bd..ca3c762553 100644 --- a/.github/workflows/docs-preview-links.yml +++ b/.github/workflows/docs-preview-links.yml @@ -17,7 +17,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out repository - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 diff --git a/.github/workflows/mermaid-ci.yml b/.github/workflows/mermaid-ci.yml new file mode 100644 index 0000000000..a57bfc2674 --- /dev/null +++ b/.github/workflows/mermaid-ci.yml @@ -0,0 +1,33 @@ +name: Mermaid CI + +on: + pull_request: + paths: + - "docs/**" + - "scripts/lint-mermaid.mjs" + - "package.json" + - "yarn.lock" + - ".github/workflows/mermaid-ci.yml" + +permissions: + contents: read + +jobs: + mermaid: + name: Validate Mermaid syntax + runs-on: ubuntu-latest + steps: + - name: Checkout Repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: Setup Node.js Environment + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: "24" + cache: "yarn" + + - name: Install Dependencies + run: yarn install --frozen-lockfile + + - name: Lint Mermaid Diagrams + run: yarn lint:mermaid diff --git a/.github/workflows/screenshot-capture.yml b/.github/workflows/screenshot-capture.yml index 3ec6b0a17f..810e0b5f8c 100644 --- a/.github/workflows/screenshot-capture.yml +++ b/.github/workflows/screenshot-capture.yml @@ -27,7 +27,7 @@ jobs: contents: read steps: - name: Checkout Repository - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup Node.js Environment uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 @@ -67,7 +67,7 @@ jobs: shardTotal: [4] steps: - name: Checkout Repository - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup Node.js Environment uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 diff --git a/.github/workflows/snipsync.yml b/.github/workflows/snipsync.yml index 8d415d0216..8434cbf7fd 100644 --- a/.github/workflows/snipsync.yml +++ b/.github/workflows/snipsync.yml @@ -21,7 +21,7 @@ jobs: private-key: ${{ secrets.TEMPORAL_CICD_PRIVATE_KEY }} - name: Checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: token: ${{ steps.generate_token.outputs.token }} ref: main diff --git a/.github/workflows/update-cli-docs.yml b/.github/workflows/update-cli-docs.yml index 47df06f854..c827b55253 100644 --- a/.github/workflows/update-cli-docs.yml +++ b/.github/workflows/update-cli-docs.yml @@ -36,7 +36,7 @@ jobs: private-key: ${{ secrets.TEMPORAL_CICD_PRIVATE_KEY }} - name: Checkout docs repo - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: true token: ${{ steps.generate_token.outputs.token }} @@ -44,7 +44,7 @@ jobs: ref: main - name: Checkout CLI repo - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: path: cli ref: ${{ github.event.inputs.cli_release_tag }} @@ -54,12 +54,12 @@ jobs: repository: temporalio/cli - name: Setup Go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version: '1.26' - name: Checkout cloud-cli repo - uses: actions/checkout@v6.0.3 + uses: actions/checkout@v7 with: path: cloud-cli submodules: recursive @@ -77,6 +77,31 @@ jobs: go run ./cmd/gen-docs -input internal/temporalcli/commands.yaml -input cliext/option-sets.yaml -output dist/docs go run ./cmd/gen-docs -input ../cloud-cli/temporalcloudcli/commands.yml -output dist/docs -subdir cloud + - name: Copy generated command reference into the docs repo + working-directory: docs + run: | + set -ex + + # Regenerate the command reference pages. cp -R preserves the cloud/ + # subdirectory produced by -subdir. + rm -rf docs/cli/command-reference + mkdir -p docs/cli/command-reference + cp -R ../cli/dist/docs/* docs/cli/command-reference/ + + # gen-docs intentionally does not emit index.mdx landing pages: those + # are hand-maintained (custom ordering, embedded components such as + # ReleaseNoteHeader). Restore them after regeneration so they are not + # lost by the rm -rf above. + git checkout HEAD -- docs/cli/command-reference/index.mdx + git checkout HEAD -- docs/cli/command-reference/cloud/index.mdx + + - name: Post-process generated CLI docs + working-directory: docs + # Injects the ReleaseNoteHeader banner into cloud CLI pages and keeps + # command-reference/index.mdx and sidebars.js in sync with the + # generated commands. Not previously called from this workflow. + run: node bin/post-process-cli-docs.js + - name: Publish generated docs to documentation repo env: GH_TOKEN: ${{ github.token }} @@ -93,10 +118,7 @@ jobs: branch_name="update-cli-docs-$CLI_RELEASE_TAG" git checkout -b "$branch_name" - rm -rf docs/cli/command-reference - mkdir -p docs/cli/command-reference - cp -r ../cli/dist/docs/* docs/cli/command-reference/ - git add . + git add -A docs/cli/command-reference git commit -m "$COMMIT_MESSAGE" git push origin "$branch_name" diff --git a/.github/workflows/update-custom-role-permissions.yml b/.github/workflows/update-custom-role-permissions.yml index 44ae660fa6..59eaafcbd9 100644 --- a/.github/workflows/update-custom-role-permissions.yml +++ b/.github/workflows/update-custom-role-permissions.yml @@ -22,13 +22,13 @@ jobs: private-key: ${{ secrets.TEMPORAL_CICD_PRIVATE_KEY }} - name: Checkout docs repo - uses: actions/checkout@v6.0.3 + uses: actions/checkout@v7 with: persist-credentials: true token: ${{ steps.generate_token.outputs.token }} - name: Setup Node - uses: actions/setup-node@v6 + uses: actions/setup-node@v6.4.0 with: node-version: '20' diff --git a/.github/workflows/vale-ci.yml b/.github/workflows/vale-ci.yml index acff2ae7f9..1ce01ce8d6 100644 --- a/.github/workflows/vale-ci.yml +++ b/.github/workflows/vale-ci.yml @@ -14,7 +14,7 @@ jobs: name: Lint prose runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - uses: vale-cli/vale-action@2.1.2 with: @@ -22,4 +22,7 @@ jobs: level: info filter_mode: diff_context fail_on_error: false + # Scope linting to docs only; do not put the path in vale_flags — + # the action runs `vale ${vale_flags} sync`, so a path there breaks sync. + files: docs vale_flags: "--config=.vale-ci.ini" diff --git a/.github/workflows/visual-comparison.yml b/.github/workflows/visual-comparison.yml index 749019b2f4..2a60fa5f36 100644 --- a/.github/workflows/visual-comparison.yml +++ b/.github/workflows/visual-comparison.yml @@ -44,7 +44,7 @@ jobs: pull-requests: write steps: - name: Checkout repository - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 @@ -119,7 +119,7 @@ jobs: shardTotal: [4] steps: - name: Checkout repository - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 @@ -212,7 +212,7 @@ jobs: actions: read steps: - name: Checkout repository - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 diff --git a/.gitignore b/.gitignore index 4826f13671..1530ef1b80 100644 --- a/.gitignore +++ b/.gitignore @@ -22,8 +22,6 @@ temp/ .env.test.local .env.production.local .vs -AGENTS.md -CLAUDE.md .claude/* .omc @@ -55,3 +53,4 @@ test-results/* screenshots # Ignore cookbook recipes since they are synced from the remote repo ai-cookbook +scripts/mermaid-compare/* diff --git a/.vale-ci.ini b/.vale-ci.ini index 0ecef14e79..17d07650f1 100644 --- a/.vale-ci.ini +++ b/.vale-ci.ini @@ -5,11 +5,65 @@ MinAlertLevel = suggestion [*.{md,mdx}] BasedOnStyles = Temporal -; Only enable the two rules we want in CI +; Only enable the high-confidence rules we want in CI. +; Temporal.terms (term-capitalization substitution) is disabled here because +; it produces too many false positives to be useful as a CI gate. Temporal.badwords = NO Temporal.complexwords = NO Temporal.programming = NO Temporal.WordList = NO +Temporal.terms = NO + +; Temporal.Headings and Temporal.RelativeLinks are intentionally left enabled +; (not listed above) — they're the two high-confidence checks this CI gate runs. + +; ignore MDX custom heading-ID comments, e.g. {/* #ha-worker-patterns */} +TokenIgnores = (\{/\*[^\n]*?\*/\}) + +; These are reference docs whose headings are literal command/metric names +; (e.g. "## audit-log", "### --workflow_id"), not prose — sentence-case +; capitalization doesn't apply, so skip Temporal.Headings for them. +[docs/references/sdk-metrics.mdx] +Temporal.Headings = NO + +[docs/tctl-v1/**] +Temporal.Headings = NO + +[docs/cli/command-reference/**] +Temporal.Headings = NO + +[docs/cloud/tcld/**] +Temporal.Headings = NO + +[docs/cloud/references/regions/**] +Temporal.Headings = NO + +[docs/cloud/metrics/reference.mdx] +Temporal.Headings = NO + +[docs/cloud/metrics/openmetrics/metrics-reference.mdx] +Temporal.Headings = NO + +[docs/references/cluster-metrics.mdx] +Temporal.Headings = NO + +[docs/references/configuration.mdx] +Temporal.Headings = NO + +[docs/references/web-ui-configuration.mdx] +Temporal.Headings = NO + +[docs/references/server-options.mdx] +Temporal.Headings = NO + +[docs/references/errors.mdx] +Temporal.Headings = NO + +[docs/references/commands.mdx] +Temporal.Headings = NO + +[docs/references/events.mdx] +Temporal.Headings = NO [formats] mdx = md diff --git a/.vale.ini b/.vale.ini index 4ff4340491..9753a3830e 100644 --- a/.vale.ini +++ b/.vale.ini @@ -27,6 +27,51 @@ Google.Will = NO Microsoft.ComplexWords = NO Google.WordList = NO +; These are reference docs whose headings are literal command/metric names +; (e.g. "## audit-log", "### --workflow_id"), not prose — sentence-case +; capitalization doesn't apply, so skip Temporal.Headings for them. +[docs/references/sdk-metrics.mdx] +Temporal.Headings = NO + +[docs/tctl-v1/**] +Temporal.Headings = NO + +[docs/cli/command-reference/**] +Temporal.Headings = NO + +[docs/cloud/tcld/**] +Temporal.Headings = NO + +[docs/cloud/references/regions/**] +Temporal.Headings = NO + +[docs/cloud/metrics/reference.mdx] +Temporal.Headings = NO + +[docs/cloud/metrics/openmetrics/metrics-reference.mdx] +Temporal.Headings = NO + +[docs/references/cluster-metrics.mdx] +Temporal.Headings = NO + +[docs/references/configuration.mdx] +Temporal.Headings = NO + +[docs/references/web-ui-configuration.mdx] +Temporal.Headings = NO + +[docs/references/server-options.mdx] +Temporal.Headings = NO + +[docs/references/errors.mdx] +Temporal.Headings = NO + +[docs/references/commands.mdx] +Temporal.Headings = NO + +[docs/references/events.mdx] +Temporal.Headings = NO + [formats] mdx = md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000..c394b94b17 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,152 @@ +# AGENTS.md + +Guidance for AI coding agents working in the [Temporal documentation](https://github.com/temporalio/documentation) repository. + +## Read these first + +| Topic | File | +|-------|------| +| Where content belongs | [INFORMATION-ARCHITECTURE.md](./INFORMATION-ARCHITECTURE.md) | +| React components in MDX | [COMPONENTS.md](./COMPONENTS.md) | +| Mermaid diagrams | [MERMAID.md](./readme/MERMAID.md) | +| LLM Markdown pipeline | [MARKDOWN_PIPELINE.md](./MARKDOWN_PIPELINE.md) | +| Component → Markdown mapping | [COMPONENT_REGISTRY.md](./COMPONENT_REGISTRY.md) | + +## Repository overview + +- **Site:** Docusaurus static site for [docs.temporal.io](https://docs.temporal.io). +- **Content:** MDX files under `docs/`. URLs are served at the site root (`/workflows`), not under `/docs/`. +- **UI code:** React components in `src/components/`. Sidebar navigation is in `sidebars.js`. +- **Redirects:** Legacy URL changes go in `vercel.json` before or alongside file moves. + +## Where to put content + +Use [INFORMATION-ARCHITECTURE.md](./INFORMATION-ARCHITECTURE.md) to choose the section. + +## Style Guide + +Follow [STYLE.md](./STYLE.md) and Vale rules in `vale/styles/`. The most common mistakes: + +### Temporal terms are proper nouns + +Capitalize Temporal core terms. Do not capitalize generic uses. + +- Correct: "Register the Activity in the Workflow." +- Incorrect: "Register the activity in the workflow." + +Vale enforces many substitutions automatically. See `vale/styles/Temporal/terms.yml`. + +### Temporal Service, not Cluster + +In body prose, use **Temporal Service**, not **Cluster** or **Temporal Cluster**. + +- Correct: "The Worker polls the Temporal Service for tasks." +- Incorrect: "The Worker polls the Cluster for tasks." + +Exceptions: **Multi-Cluster Replication**, code/config identifiers, and CLI command names. + +### Identifier abbreviation + +Outside Temporal core terms, spell out "identifier." For core terms, use `Id` (not `ID` or `id`). + +- Correct: "Provide an order identifier as a Workflow Id." +- Incorrect: "Provide an order ID as a Workflow ID." + +In code blocks, follow each language's conventions. + +### Headings + +- Use **sentence case**: "How to get started with Temporal" +- Prefer **infinitive verb forms** or questions: "How to install Temporal", not "Installing Temporal" +- Develop pages: task-oriented headings ("Start a Workflow Execution") + +### Writing style + +Follow [STYLE.md](./STYLE.md) and the [Google developer documentation style guide](https://developers.google.com/style) for tone (conversational, second person, active voice) and structure (short paragraphs, one idea per sentence). A few additions specific to model output: + +- Cut filler that adds no information: "it's worth noting that," "in order to," "simply," "easily," "just." +- Avoid vague intensifiers doing the work a specific fact should do: "powerful," "robust," "seamless," "cutting-edge," "leverage" (use "use"), "unlock," "elevate," "streamline." Replace with what the thing actually does. +- Don't pad a page to look thorough. Prefer brevity over overly-verbose paragraphs. +- Do not add emojis to documentation prose and use em-dashes sparingly. + +### Word choice + +Prefer common, concrete verbs and nouns. + +| Prefer | Instead of | +|--------|------------| +| use | utilize, leverage | +| help | facilitate | +| to | in order to | +| many | numerous, various (when you can be specific, be specific) | + +### Tense and time + +Document current behavior. Don't make future promises. Tie claims to a version, release note, or release stage when that matters. + +| Prefer | Instead of | +|--------|------------| +| Temporal Server v1.31.0 and later supports Cassandra 5.0.4 and later. | A future release will support Cassandra 5. | + +## Frontmatter + +Typical fields: + +```yaml +--- +id: page-id +title: Page title +sidebar_label: Short label +description: One sentence for SEO and previews. +keywords: + - keyword +tags: + - Concepts +--- +``` + +- Write `description` as a single clear sentence. +- Do not change `id` or `slug` without a redirect plan. +- Match `tags` and `keywords` to sibling pages in the same section. +- Use existing concepts and keywords. Don't add new ones unless it's a new feature. + +## MDX and components + +- Pages are `.mdx` with YAML frontmatter (see [Frontmatter](#frontmatter) above). +- Import shared components from `@site/src/components` unless a page uses a one-off import path already established nearby. +- Before adding a component, check [COMPONENTS.md](./COMPONENTS.md) and [COMPONENT_REGISTRY.md](./COMPONENT_REGISTRY.md). +- Reuse existing components (`Tabs`, `SdkTabs`, `CaptionedImage`, `ViewSourceCodeNotice`, etc.) instead of inventing inline HTML patterns. +- Interactive demos live in `src/components/elements/`. Export new public components from `src/components/index.js` when needed. + + +## URLs and navigation + +Adding or moving pages usually requires: + +- Updating `sidebars.js` for navigation placement +- Adding redirects in `vercel.json` when the public URL changes +- Fixing internal links across the repo + +## Code samples and Snipsync + +- Prefer code extracted from CI-enabled sample repos via [Snipsync](https://github.com/temporalio/snipsync). +- Snippets are wrapped in `` / ``. Edit the **source repo** named inside the wrapper, then run `yarn snipsync`. + +## Commands + +Requires Node.js 24+ and Yarn. + +```bash +yarn # Install dependencies +yarn start # Dev server at http://localhost:3000 +yarn build # Production build; catches MDX/build errors +yarn check-links # Broken link check (run after build) +yarn snipsync # Refresh Snipsync code snippets +``` + +Vale linting (style): + +```bash +yarn lint:py # Example: lint Python SDK docs +yarn vale # Full Vale run via assembly script +``` diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000000..43c994c2d3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/COMPONENTS.md b/COMPONENTS.md index ab7811c73f..095b3b4af3 100644 --- a/COMPONENTS.md +++ b/COMPONENTS.md @@ -20,12 +20,10 @@ Whether you’re using core components or experimenting with new ones, this guid - [Using Components in MDX Source Files](#using-components-in-mdx-source-files) - [Using IntegrationsGrid](#using-integrationsgrid) - [Using CaptionedImage](#using-captionedimage) -- [Using DiscoverableDisclosure](#using-discoverabledisclosure) - [Using DocsTable](#using-docstable) - [Using PhotoCarousel](#using-photocarousel) - [Using RelatedRead](#using-relatedread) - [Using ToolTipTerm](#using-tooltipterm) -- [Using ZoomingImage](#using-zoomingimage) - [Using SdkGuideLinks](#using-sdkguidelinks) - [Using ReleaseNoteHeader](#using-release-note-header) @@ -118,9 +116,9 @@ import * as Components from '@site/src/components'; Components imported this way use the "Components." prefix before their name: ``` - + ...Content that is folded away... - + ``` ## Using IntegrationsGrid @@ -253,23 +251,6 @@ One click will zoom out, another will zoom back. Most rendering will allow readers to pull the image around to view all parts of the image. On non-mobile systems, cursors will change to interactive elements when hovered above zoomable items to indicate interaction. -## Using DiscoverableDisclosure - -Role: Provide a more discoverable `
` section. - -Unlike Details, it does not require a separate ``. - -Usage: - -``` - -...your content... - -``` - -An additional `prompt` prop enables you to specify the leading text. -Normally it defaults to `prompt = "Dive deeper — "`. - ## Using DocsTable In certain rare instances, standard Markdown tables won't work for our needs. @@ -442,25 +423,6 @@ Use the `tooltip` prop to add non-reusable content specific to one tooltip term. ``` -## Using ZoomingImage - -Role: Provide image "asides" embedded into instructions where a standard image would normally interfere. - -A ZoomingImage is presented at a very small size, with the understanding that it is too small to view. -An optional component, the image uses a magnifying glass and text prompt for discovery. -See the instructions for [Prometheus Grafana](http://docs.temporal.io/cloud/metrics/prometheus-grafana#grafana-data-sources-configuration) for an example. - -Reserve ZoomingImages for situations where the image itself is an obstacle to communicating steps or other content. -It provides opt-in supplementary information. - -Usage: - -``` - -``` - -Images are normally stored in the '/static' folder in `img` or `diagrams`. - ## Using SdkGuideLinks Role: Render a vertical list of SDK guide links, each with a colored block icon and label, linking readers to the relevant SDK-specific page. diff --git a/COMPONENT_REGISTRY.md b/COMPONENT_REGISTRY.md index e4274bc8fd..6ef6f6b7f7 100644 --- a/COMPONENT_REGISTRY.md +++ b/COMPONENT_REGISTRY.md @@ -24,14 +24,12 @@ Generated by `scripts/audit-components.mjs`. Run to update. | `AWSRegions` | 2 | `transclude` | ✅ | | `GCPRegions` | 2 | `transclude` | ✅ | | `PatternCards` | 2 | `cards` | ✅ | -| `DiscoverableDisclosure` | 1 | `transparent` | ✅ | | `CustomRolePermissionsTable` | 1 | `transclude` | ✅ | | `InvitationContent` | 1 | `strip-block` | ✅ | | `RetrySimulator` | 1 | `strip-block` | ✅ | | `SayHelloWorkflow` | 1 | `UNKNOWN` | ❌ | | `ISayHelloNexusService` | 1 | `UNKNOWN` | ❌ | | `CallerWorkflow` | 1 | `UNKNOWN` | ❌ | -| `SdkLogos` | 1 | `strip-block` | ✅ | | `StandaloneActivityDemo` | 1 | `UNKNOWN` | ❌ | | `SdkSvg` | 1 | `strip-block` | ✅ | | `SdkGuideLinks` | 1 | `UNKNOWN` | ❌ | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 679976ad91..cebd1ad50f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -134,6 +134,72 @@ The location of the source code is written just inside the wrappers. After you have edited the source code, then you can run `yarn snipsync` to update that code snippet. +# How page routing works + +Here is the hierarchy of how the canonical URLs work: + +1. Vercel redirects in `vercel.json` + - Old URL → destination URL, before Docusaurus page routing. + +2. Docusaurus route generation + - routeBasePath: `"/"` in docusaurus.config.js, so docs live at root, not `/docs/*`. + +3. Markdown front matter `slug` field + - Overrides the generated URL path for that doc page. + +4. File path fallback + - If no `slug` exists, URL comes from `docs//.mdx`. + +5. Sidebar hierarchy + - Controls nav placement only; it does not determine the canonical URL. + +We do not encourage the use of the `slug` field in the frontmatter for a page. This field will override the URL and could potentially cause issues with redirects and the sidebar nav. A `slug` is useful when you want the public URL to be independent of where the file lives in the repo. In most cases, you don't need one. + +If you are considering using a custom slug, answer these questions first: + +- Is this page going to be widely linked from blogs, GitHub issues, or other docs? +- Are you replacing an existing page? +- Did you already check the redirects in `vercel.json`? +- Is it in the page in a folder under the topic that makes the most sense? + +# Social share images (og:image) + +Every doc page automatically gets a social-share preview card generated at build time — the image that +shows up when a link to the page is shared in Slack, X, LinkedIn, etc. It's built from the page's `title`, +`description`, and a section label derived from its folder (e.g. `develop/go/...` → "Go SDK"). There's +nothing to do for a normal page. + +If you want a page to use a different image instead of the generated one, you have two options: + +- Add an `image` field to the page's front matter, pointing at a static asset (same as the site-wide + default in `docusaurus.config.js`): + + ``` + --- + title: My page + image: /img/assets/my-custom-image.png + --- + ``` + +- For pages that can't use front matter, override it directly with a `` component: + + ```mdx + import Head from '@docusaurus/Head'; + + + + + + ``` + + The URL must be absolute (`https://docs.temporal.io/...`), not a relative path. + +Either way, the generator leaves the page alone. + +This only happens during `yarn build` (see below) — running `yarn start` won't show generated or +overridden images, since it skips the production build step entirely. The generator itself lives in +`plugins/og-image/`. + # Local development command reference The following commands are available to aid in local development: @@ -149,7 +215,15 @@ Note that the `/build` directory is ignored by Git. ## `yarn start` -This command spins up a local web server and serves the contents of the `/build` directory to [localhost:3000](http://localhost:3000/). +This command spins up a local dev server with hot reload at [localhost:3000](http://localhost:3000/). It +builds from source directly and does not run the production build step, so anything that only happens +during `yarn build` (like generated og:images) won't show up here. + +## `yarn serve` + +This command serves the contents of the `/build` directory (the output of `yarn build`) at +[localhost:3000](http://localhost:3000/), so you can check the actual production output, including +generated og:images. ## `yarn snipsync` diff --git a/MARKDOWN_PIPELINE.md b/MARKDOWN_PIPELINE.md index eb1cda7ae5..aa71a5ef57 100644 --- a/MARKDOWN_PIPELINE.md +++ b/MARKDOWN_PIPELINE.md @@ -108,7 +108,7 @@ Each component maps to a strategy in `COMPONENT_REGISTRY` (in `scripts/mdx-to-md | `Tabs` / `TabItem` | `tabs` / `tabitem` | All tabs flattened, each under a **bold label** header | | `SdkTabs` / `SdkTabs.` | `sdk-tabs` | Same, with language labels (`DotNet` → **.NET**) | | `CodeSnippet` | `code-snippet` | Fenced code block using the `language` prop | -| `CaptionedImage`, `EnlargeImage`, `ZoomingImage`, `Components.CaptionedImage` | `captioned-image` | `![alt or caption or title](src)` | +| `CaptionedImage`, `EnlargeImage`, `Components.CaptionedImage` | `captioned-image` | `![alt or caption or title](src)` | | `PhotoCarousel` | `photo-carousel` | One `![caption](url)` per entry in the `images`/`captions` arrays | | `CallToAction` | `call-to-action` | `- [h3 title](href): p description` | | `ReleaseNoteHeader` | `release-note-header` | `> **Public Preview** — Go, Java…` availability note + body blockquote. The self-closing form (``) emits just the note and leaves the page body intact. | @@ -120,8 +120,8 @@ Each component maps to a strategy in `COMPONENT_REGISTRY` (in `scripts/mdx-to-md | `IntegrationsGrid` | `integrations-grid` | Markdown list resolved from `integrations-data.json`, filtered by the `defaultSdks` prop | | `HomePageHero` | `home-page-hero` | Homepage hero's headline, intro paragraphs, and link cards (content mirrored from the component) | | `QuickstartCards`, `PatternCards` | `cards` | Markdown link list parsed from the inline `items={[{href,title,description}]}` prop | -| `ZoomPanPinch`, `DiscoverableDisclosure` | `transparent` | Wrapper stripped; inner content passed through | -| `DocCardList`, `CardList`, `LandingCard`, `ThemedImage`, `SdkLogos`, `SdkSvg`, `CloudRegionCount`, `RetrySimulator`, `ServerlessWorkerDemo`, `OperationsTable`, `InvitationContent` | `strip-block` | Removed entirely (visual/dynamic, no extractable text) | +| `ZoomPanPinch` | `transparent` | Wrapper stripped; inner content passed through | +| `DocCardList`, `CardList`, `LandingCard`, `ThemedImage`, `SdkSvg`, `CloudRegionCount`, `RetrySimulator`, `ServerlessWorkerDemo`, `OperationsTable`, `InvitationContent` | `strip-block` | Removed entirely (visual/dynamic, no extractable text) | | `DL`, `DT`, `DD`, `DefinitionList` | `strip-tag` | Tags stripped, text content kept | | `details` / `summary` | `details` / `summary` | `` becomes a heading; body expanded inline | diff --git a/STYLE.md b/STYLE.md index 14712b19a4..e9209790d5 100644 --- a/STYLE.md +++ b/STYLE.md @@ -21,6 +21,32 @@ Do not capitalize generic versions of Temporal terms. Use generic versions spari - Incorrect: "Next, register the activity within the workflow." +#### Temporal Service, not Cluster + +In body prose, refer to the Temporal backend as **Temporal Service**, not **Cluster** or **Temporal Cluster**. + +- Correct: "Connect your Temporal Client to the Temporal Service." +- Incorrect: "Connect your Temporal Client to the Temporal Cluster." + +This applies to running instances you operate or connect to ("your Temporal Service", "the Temporal Service", "across the Temporal Service"). + +Exceptions: + +- **Multi-Cluster Replication** and other compound terms that include "Cluster" as part of an official name. +- Code, configuration keys, file paths, CLI commands, and API identifiers (for example, `tctl cluster`, `cluster.pem`). +- Quoting third-party or historical text where the source uses "Cluster". + +#### Word choice + +Prefer common, concrete verbs and nouns. + +| Prefer | Instead of | +|--------|------------| +| use | utilize, leverage | +| help | facilitate | +| to | in order to | +| many | numerous, various (when you can be specific, be specific) | + #### Abbreviation of "identifier" Do not abbreviate the word "identifier" as "ID", "Id", or "id" unless it's part of a Temporal core term. For core terms, the correct abbreviation is "Id", such as in "Workflow Id" or "Activity Id". diff --git a/bin/check-og-build-budget.js b/bin/check-og-build-budget.js new file mode 100644 index 0000000000..0a5744c14c --- /dev/null +++ b/bin/check-og-build-budget.js @@ -0,0 +1,63 @@ +#!/usr/bin/env node + +// Phase 6: catches build-time/output-size regressions in the og-image +// plugin instead of relying on someone noticing a slow build or a bloated +// deploy. Thresholds below were set from real measurements on the full +// ~625-page docs set (see plugins/og-image/render.js's PNG compression +// step, added specifically because the first measurement here came back +// at 142MB/232KB-per-card): a cold build renders every card fresh in +// ~86s total (~109ms/card) and produces ~17.7MB of PNGs (~29KB/card). +// Thresholds give ~3x headroom over those numbers so normal content growth +// doesn't trip this, while a real regression (e.g. a design change that +// balloons per-card size, or an accidentally-quadratic render path) does. + +const fs = require('fs'); +const path = require('path'); + +const STATS_FILE = path.join(process.cwd(), 'build', '.og-image-stats.json'); + +const MAX_AVG_RENDER_MS = 300; +const MAX_TOTAL_OUTPUT_BYTES = 60 * 1024 * 1024; +const MAX_AVG_IMAGE_BYTES = 80 * 1024; + +function main() { + if (!fs.existsSync(STATS_FILE)) { + console.error(`Stats file not found at ${STATS_FILE}. Run \`yarn build\` first.`); + process.exit(1); + } + + const stats = JSON.parse(fs.readFileSync(STATS_FILE, 'utf8')); + const { generated, cached, renderMs, outputBytes } = stats; + const totalImages = generated + cached; + const avgRenderMs = generated > 0 ? renderMs / generated : 0; + const avgImageBytes = totalImages > 0 ? outputBytes / totalImages : 0; + + console.log(`[check-og-build-budget] ${generated} rendered this build, ${cached} from cache`); + console.log( + `[check-og-build-budget] render time: ${(renderMs / 1000).toFixed(1)}s total, ${avgRenderMs.toFixed(1)}ms/card avg (budget: ${MAX_AVG_RENDER_MS}ms/card)`, + ); + console.log( + `[check-og-build-budget] output size: ${(outputBytes / 1024 / 1024).toFixed(1)}MB total, ${(avgImageBytes / 1024).toFixed(1)}KB/card avg (budget: ${(MAX_TOTAL_OUTPUT_BYTES / 1024 / 1024).toFixed(0)}MB total, ${(MAX_AVG_IMAGE_BYTES / 1024).toFixed(0)}KB/card)`, + ); + + const failures = []; + if (generated > 0 && avgRenderMs > MAX_AVG_RENDER_MS) { + failures.push(`average render time ${avgRenderMs.toFixed(1)}ms/card exceeds budget of ${MAX_AVG_RENDER_MS}ms/card`); + } + if (outputBytes > MAX_TOTAL_OUTPUT_BYTES) { + failures.push(`total output size ${(outputBytes / 1024 / 1024).toFixed(1)}MB exceeds budget of ${(MAX_TOTAL_OUTPUT_BYTES / 1024 / 1024).toFixed(0)}MB`); + } + if (avgImageBytes > MAX_AVG_IMAGE_BYTES) { + failures.push(`average image size ${(avgImageBytes / 1024).toFixed(1)}KB exceeds budget of ${(MAX_AVG_IMAGE_BYTES / 1024).toFixed(0)}KB`); + } + + if (failures.length > 0) { + console.error(`\n${failures.length} budget check(s) failed:\n`); + failures.forEach((f) => console.error(` - ${f}`)); + process.exit(1); + } + + console.log('\nOK: og-image build time and output size are within budget.'); +} + +main(); diff --git a/bin/generate-og-gallery.js b/bin/generate-og-gallery.js new file mode 100644 index 0000000000..6689b1dfc9 --- /dev/null +++ b/bin/generate-og-gallery.js @@ -0,0 +1,146 @@ +#!/usr/bin/env node + +// Dev-only tool: builds a single HTML page listing every generated og:image +// card (thumbnail + section/title/route) so a large batch can be reviewed +// in a browser at once, instead of opening 600+ PNGs one at a time. Not +// part of the build pipeline — run manually after `yarn build`. + +const fs = require('fs'); +const path = require('path'); +const matter = require('gray-matter'); +const ogImagePlugin = require('../plugins/og-image'); + +const DOCS_DIR = path.join(process.cwd(), 'docs'); +const BUILD_DIR = path.join(process.cwd(), 'build'); +const OUT_FILE = path.join(BUILD_DIR, '__og-gallery.html'); + +// Section grouping/labeling is purely a gallery-review concern now — the +// generated card itself dropped the section pill in the Figma redesign, so +// this doesn't live in plugins/og-image/index.js (the actual production +// plugin) anymore, only here. +const SDK_LABELS = { + go: 'Go', + python: 'Python', + java: 'Java', + typescript: 'TypeScript', + dotnet: '.NET', + php: 'PHP', + ruby: 'Ruby', + rust: 'Rust', +}; + +const SECTION_OVERRIDES = { + cli: 'CLI', + 'tctl-v1': 'tctl v1', +}; + +function humanize(id) { + return id + .replace(/[-_]+/g, ' ') + .replace(/\b\w/g, (c) => c.toUpperCase()); +} + +function resolveSection(docsDir, filePath) { + const rel = path.relative(docsDir, filePath).replace(/\\/g, '/'); + const segments = rel.split('/'); + if (segments.length === 1) return 'Docs'; + const top = segments[0]; + if (top === 'develop' && segments[1] && SDK_LABELS[segments[1]]) { + return `${SDK_LABELS[segments[1]]} SDK`; + } + return SECTION_OVERRIDES[top] || humanize(top); +} + +async function getSiteUrl() { + const createConfigAsync = require('../docusaurus.config.js'); + const config = await createConfigAsync(); + return config.url + (config.baseUrl || '/'); +} + +function escapeHtml(str) { + return String(str).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); +} + +async function main() { + if (!fs.existsSync(BUILD_DIR)) { + console.error(`Build directory not found at ${BUILD_DIR}. Run \`yarn build\` first.`); + process.exit(1); + } + + const siteUrl = await getSiteUrl(); + const cards = []; + for (const filePath of ogImagePlugin.walkDir(DOCS_DIR)) { + const raw = fs.readFileSync(filePath, 'utf8'); + const { data: frontmatter, content } = matter(raw); + const urlPath = ogImagePlugin.resolveUrlPath(DOCS_DIR, filePath, frontmatter); + const htmlPath = ogImagePlugin.htmlPathForUrlPath(BUILD_DIR, urlPath); + if (!fs.existsSync(htmlPath)) continue; + + const section = resolveSection(DOCS_DIR, filePath); + const routePath = urlPath === 'index' ? '/' : `/${urlPath}`; + + if (ogImagePlugin.hasManualOverride(frontmatter, content)) { + const id = frontmatter.id || path.basename(filePath).replace(/\.(md|mdx)$/i, ''); + const title = ogImagePlugin.extractTitle(content, frontmatter, id); + const overrideImage = ogImagePlugin.overrideImageFor(frontmatter, content, siteUrl); + cards.push({ urlPath: routePath, section, title, isOverride: true, imgSrc: overrideImage }); + continue; + } + + const id = frontmatter.id || path.basename(filePath).replace(/\.(md|mdx)$/i, ''); + const title = ogImagePlugin.extractTitle(content, frontmatter, id); + const description = frontmatter.description; + const hash = ogImagePlugin.hashFor(title, description); + + cards.push({ urlPath: routePath, section, title, isOverride: false, imgSrc: `/img/og/${hash}.${ogImagePlugin.IMAGE_EXTENSION}` }); + } + + cards.sort((a, b) => a.section.localeCompare(b.section) || a.title.localeCompare(b.title)); + + const html = ` + + + +og:image gallery (${cards.length} pages) + + + +

${cards.length} og:image cards (${cards.filter((c) => c.isOverride).length} manual override(s))

+
+${cards + .map( + (c) => `
+ +
+ ${escapeHtml(c.section)}${c.isOverride ? 'Override' : ''}
+ ${escapeHtml(c.title)}
+ ${escapeHtml(c.urlPath)} +
+
`, + ) + .join('\n')} +
+ + +`; + + fs.writeFileSync(OUT_FILE, html); + console.log(`[generate-og-gallery] wrote ${cards.length} card(s) to ${path.relative(process.cwd(), OUT_FILE)}`); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/bin/validate-og-images.js b/bin/validate-og-images.js new file mode 100644 index 0000000000..ab5d0a1dae --- /dev/null +++ b/bin/validate-og-images.js @@ -0,0 +1,185 @@ +#!/usr/bin/env node + +// Phase 2 changed the expected behavior this script checks: docs pages now +// get their own generated og:image (plugins/og-image), so they no longer +// match the site-wide default from Phase 1. This script now asserts three +// things instead of one: (1) every doc page's og:image/twitter:image point +// to a real generated image under build/img/og/, UNLESS (2) the page declares +// a manual override (front-matter `image:` or an inline og:image), +// in which case it must keep that exact override instead of the generated +// image — Phase 4 intentionally makes "every doc page gets a generated +// image" no longer universally true. (The override *detection* logic itself +// is unit-tested in plugins/og-image/index.test.js against synthetic +// frontmatter/content, rather than via dedicated fixture pages in docs/ — +// this loop just needs to not choke if a real page ever uses one.) And (3) +// every page the plugin doesn't touch (404, search, changelog, category/tag +// pages, etc.) still falls back to the site-wide default, which remains the +// Phase 1 regression floor for everything outside the plugin's reach. +// +// Generated-image expectations use ogImagePlugin.resolveSiteUrl(), which +// substitutes VERCEL_URL on non-production Vercel deployments — see the +// comment on that function in plugins/og-image/index.js for why. The +// site-wide default check intentionally does NOT use it: that image is a +// pre-existing static asset already live on the production domain in every +// environment, and Docusaurus's own theme (not this plugin) renders that +// meta tag against the hardcoded config.url regardless of environment. + +const fs = require('fs'); +const path = require('path'); +const matter = require('gray-matter'); +const ogImagePlugin = require('../plugins/og-image'); + +const BUILD_DIR = path.join(process.cwd(), 'build'); +const DOCS_DIR = path.join(process.cwd(), 'docs'); + +function walkHtmlFiles(dir) { + if (!fs.existsSync(dir)) return []; + return fs.readdirSync(dir).flatMap((name) => { + const full = path.join(dir, name); + if (fs.statSync(full).isDirectory()) return walkHtmlFiles(full); + if (/\.html$/i.test(name)) return [full]; + return []; + }); +} + +function extractMetaContent(html, attr, value) { + const tagMatch = html.match(new RegExp(`]*${attr}=["']?${value}["']?[^>]*>`, 'i')); + if (!tagMatch) return null; + const contentMatch = tagMatch[0].match(/content=["']?([^"'\s>]+)["']?/i); + return contentMatch ? contentMatch[1] : null; +} + +async function getSiteConfig() { + const createConfigAsync = require('../docusaurus.config.js'); + return createConfigAsync(); +} + +async function main() { + if (!fs.existsSync(BUILD_DIR)) { + console.error(`Build directory not found at ${BUILD_DIR}. Run \`yarn build\` first.`); + process.exit(1); + } + + const config = await getSiteConfig(); + const siteUrl = config.url + (config.baseUrl || '/'); + const defaultImage = new URL(config.themeConfig.image, siteUrl).toString(); + + const docHtmlPaths = new Set(); + const docMismatches = []; + const missingImages = []; + const overrideMismatches = []; + let docPagesChecked = 0; + let overridePagesChecked = 0; + let skippedPartials = 0; + + for (const filePath of ogImagePlugin.walkDir(DOCS_DIR)) { + const raw = fs.readFileSync(filePath, 'utf8'); + const { data: frontmatter, content } = matter(raw); + const urlPath = ogImagePlugin.resolveUrlPath(DOCS_DIR, filePath, frontmatter); + const htmlPath = ogImagePlugin.htmlPathForUrlPath(BUILD_DIR, urlPath); + + if (!fs.existsSync(htmlPath)) { + skippedPartials++; + continue; + } + docHtmlPaths.add(htmlPath); + docPagesChecked++; + + const html = fs.readFileSync(htmlPath, 'utf8'); + const ogImage = extractMetaContent(html, 'property', 'og:image'); + const twitterImage = extractMetaContent(html, 'name', 'twitter:image'); + + if (ogImagePlugin.hasManualOverride(frontmatter, content)) { + overridePagesChecked++; + const expectedOverride = ogImagePlugin.overrideImageFor(frontmatter, content, siteUrl); + + if (ogImage !== expectedOverride || twitterImage !== expectedOverride) { + overrideMismatches.push({ + file: path.relative(BUILD_DIR, htmlPath), + expected: expectedOverride, + ogImage, + twitterImage, + }); + } + continue; + } + + const id = frontmatter.id || path.basename(filePath).replace(/\.(md|mdx)$/i, ''); + const title = ogImagePlugin.extractTitle(content, frontmatter, id); + const description = frontmatter.description; + const hash = ogImagePlugin.hashFor(title, description); + const expectedImage = new URL( + path.posix.join(config.baseUrl, 'img/og', `${hash}.${ogImagePlugin.IMAGE_EXTENSION}`), + ogImagePlugin.resolveSiteUrl(config), + ).toString(); + const expectedImagePath = path.join(BUILD_DIR, 'img', 'og', `${hash}.${ogImagePlugin.IMAGE_EXTENSION}`); + + if (ogImage !== expectedImage || twitterImage !== expectedImage) { + docMismatches.push({ + file: path.relative(BUILD_DIR, htmlPath), + expected: expectedImage, + ogImage, + twitterImage, + }); + } + if (!fs.existsSync(expectedImagePath)) { + missingImages.push({ file: path.relative(BUILD_DIR, htmlPath), expectedImagePath }); + } + } + + const otherMismatches = []; + const allHtmlFiles = walkHtmlFiles(BUILD_DIR); + const otherFiles = allHtmlFiles.filter((f) => !docHtmlPaths.has(f)); + + for (const file of otherFiles) { + const ogImage = extractMetaContent(fs.readFileSync(file, 'utf8'), 'property', 'og:image'); + if (ogImage !== defaultImage) { + otherMismatches.push({ file: path.relative(BUILD_DIR, file), ogImage }); + } + } + + const generatedPagesChecked = docPagesChecked - overridePagesChecked; + + console.log(`[validate-og-images] ${docPagesChecked} doc page(s) checked, ${skippedPartials} partial(s) skipped (not routed)`); + console.log(`[validate-og-images] ${generatedPagesChecked - docMismatches.length}/${generatedPagesChecked} doc page(s) have a correct generated og:image + twitter:image`); + console.log(`[validate-og-images] ${generatedPagesChecked - missingImages.length}/${generatedPagesChecked} doc page(s) have their generated image present on disk`); + console.log(`[validate-og-images] ${overridePagesChecked - overrideMismatches.length}/${overridePagesChecked} manual-override doc page(s) kept their own og:image`); + console.log(`[validate-og-images] ${otherFiles.length - otherMismatches.length}/${otherFiles.length} non-doc page(s) still match the site default`); + + const failures = docMismatches.length + missingImages.length + overrideMismatches.length + otherMismatches.length; + if (failures > 0) { + if (docMismatches.length > 0) { + console.error(`\n${docMismatches.length} doc page(s) have an unexpected og:image/twitter:image:\n`); + for (const m of docMismatches.slice(0, 20)) { + console.error(` ${m.file}: expected ${m.expected}, got og:image=${m.ogImage} twitter:image=${m.twitterImage}`); + } + } + if (overrideMismatches.length > 0) { + console.error(`\n${overrideMismatches.length} manual-override page(s) had their override replaced or not applied correctly:\n`); + for (const m of overrideMismatches.slice(0, 20)) { + console.error(` ${m.file}: expected ${m.expected}, got og:image=${m.ogImage} twitter:image=${m.twitterImage}`); + } + } + if (missingImages.length > 0) { + console.error(`\n${missingImages.length} doc page(s) reference an image that doesn't exist on disk:\n`); + for (const m of missingImages.slice(0, 20)) { + console.error(` ${m.file}: missing ${m.expectedImagePath}`); + } + } + if (otherMismatches.length > 0) { + console.error(`\n${otherMismatches.length} non-doc page(s) no longer match the site default:\n`); + for (const m of otherMismatches.slice(0, 20)) { + console.error(` ${m.file}: ${m.ogImage ?? '(no og:image tag found)'}`); + } + } + process.exit(1); + } + + console.log('\nOK: every doc page has a correct generated og:image (or kept its manual override), and every other page still uses the site default.'); +} + +module.exports = { walkHtmlFiles, extractMetaContent, getSiteConfig }; + +if (require.main === module) { + main(); +} diff --git a/context.json b/context.json deleted file mode 100644 index 7713637108..0000000000 --- a/context.json +++ /dev/null @@ -1,277 +0,0 @@ -{ - "siteMetadata": { - "title": "Temporal Documentation", - "description": "The official and authoritative documentation for Temporal Durable Execution, covering Workflows and best practices.", - "version": "latest", - "canonicalUrl": "https://docs.temporal.io", - "lastUpdated": "2025-03-17T12:00:00Z", - "authors": ["Temporal Documentation Team"], - "license": "CC BY 4.0", - "repository": { - "url": "https://github.com/temporalio/documentation", - "branch": "main" - }, - "seo": { - "keywords": ["Temporal", "Temporal Cloud", "Workflows", "Durable Execution", "Orchestration", "SDK", "Temporal Server"], - "robots": "index, follow", - "schemaOrg": { - "@context": "https://schema.org", - "@type": "TechArticle", - "headline": "Temporal Documentation", - "author": { - "@type": "Organization", - "name": "Temporal Technologies" - }, - "publisher": { - "@type": "Organization", - "name": "Temporal Technologies", - "logo": { - "@type": "ImageObject", - "url": "https://docs.temporal.io/logo.png" - } - }, - "datePublished": "2025-03-17", - "dateModified": "2025-03-17", - "mainEntityOfPage": { - "@type": "WebPage", - "@id": "https://docs.temporal.io" - } - } - } - }, - "navigation": { - "home": "/", - "evaluate": "/evaluate", - "getStarted": "https://learn.temporal.io", - "develop": { - "overview": "/develop", - "goSdk": { - "docs": "/go-sdk", - "guides": { - "gettingStarted": "/go-sdk/getting-started", - "workflowBasics": "/go-sdk/workflow-basics", - "activityRetries": "/go-sdk/activity-retries", - "signalsQueries": "/go-sdk/signals-queries", - "sideEffects": "/go-sdk/side-effects", - "childWorkflows": "/go-sdk/child-workflows", - "testing": "/go-sdk/testing", - "errorHandling": "/go-sdk/error-handling", - "advancedTopics": "/go-sdk/advanced-topics" - }, - "apiReference": "/go-sdk/api", - "bestPractices": "/go-sdk/best-practices", - "sampleCode": "https://github.com/temporalio/samples-go", - "troubleshooting": "/go-sdk/troubleshooting", - "learn": "https://learn.temporal.io/courses" - }, - "javaSdk": { - "docs": "/java-sdk", - "guides": { - "gettingStarted": "/java-sdk/getting-started", - "workflowBasics": "/java-sdk/workflow-basics", - "activityRetries": "/java-sdk/activity-retries", - "signalsQueries": "/java-sdk/signals-queries", - "sideEffects": "/java-sdk/side-effects", - "childWorkflows": "/java-sdk/child-workflows", - "testing": "/java-sdk/testing", - "errorHandling": "/java-sdk/error-handling", - "advancedTopics": "/java-sdk/advanced-topics" - }, - "apiReference": "/java-sdk/api", - "bestPractices": "/java-sdk/best-practices", - "sampleCode": "https://github.com/temporalio/samples-java", - "troubleshooting": "/java-sdk/troubleshooting", - "learn": "https://learn.temporal.io/courses" - }, - "typescriptSdk": { - "docs": "/typescript-sdk", - "guides": { - "gettingStarted": "/typescript-sdk/getting-started", - "workflowBasics": "/typescript-sdk/workflow-basics", - "activityRetries": "/typescript-sdk/activity-retries", - "signalsQueries": "/typescript-sdk/signals-queries", - "sideEffects": "/typescript-sdk/side-effects", - "childWorkflows": "/typescript-sdk/child-workflows", - "testing": "/typescript-sdk/testing", - "errorHandling": "/typescript-sdk/error-handling", - "advancedTopics": "/typescript-sdk/advanced-topics" - }, - "apiReference": "/typescript-sdk/api", - "bestPractices": "/typescript-sdk/best-practices", - "sampleCode": "https://github.com/temporalio/samples-typescript", - "troubleshooting": "/typescript-sdk/troubleshooting", - "learn": "https://learn.temporal.io/courses" - }, - "pythonSdk": { - "docs": "/python-sdk", - "guides": { - "gettingStarted": "/python-sdk/getting-started", - "workflowBasics": "/python-sdk/workflow-basics", - "activityRetries": "/python-sdk/activity-retries", - "signalsQueries": "/python-sdk/signals-queries", - "sideEffects": "/python-sdk/side-effects", - "childWorkflows": "/python-sdk/child-workflows", - "testing": "/python-sdk/testing", - "errorHandling": "/python-sdk/error-handling", - "advancedTopics": "/python-sdk/advanced-topics" - }, - "apiReference": "/python-sdk/api", - "bestPractices": "/python-sdk/best-practices", - "sampleCode": "https://github.com/temporalio/samples-python", - "troubleshooting": "/python-sdk/troubleshooting", - "learn": "https://learn.temporal.io/courses/python-sdk" - }, - "dotnetSdk": { - "docs": "/dotnet-sdk", - "guides": { - "gettingStarted": "/dotnet-sdk/getting-started", - "workflowBasics": "/dotnet-sdk/workflow-basics", - "activityRetries": "/dotnet-sdk/activity-retries", - "signalsQueries": "/dotnet-sdk/signals-queries", - "sideEffects": "/dotnet-sdk/side-effects", - "childWorkflows": "/dotnet-sdk/child-workflows", - "testing": "/dotnet-sdk/testing", - "errorHandling": "/dotnet-sdk/error-handling", - "advancedTopics": "/dotnet-sdk/advanced-topics" - }, - "apiReference": "/dotnet-sdk/api", - "bestPractices": "/dotnet-sdk/best-practices", - "sampleCode": "https://github.com/temporalio/samples-dotnet", - "troubleshooting": "/dotnet-sdk/troubleshooting", - "learn": "https://learn.temporal.io/courses" - }, - "tools": { - "activityRetrySimulator": "/activity-retry-simulator", - "workerPerformance": "/worker-performance", - "safeDeployments": "/safe-deployments" - } - }, - "deployToProduction": { - "overview": "/deploy", - "temporalCloud": { - "overview": "/cloud", - "gettingStarted": "/cloud/getting-started", - "highAvailability": "/cloud/high-availability", - "multiRegionNamespaces": "/cloud/multi-region", - "security": "/cloud/security", - "networking": "/cloud/networking", - "monitoring": "/cloud/monitoring", - "failovers": "/cloud/failovers", - "migrateToCloud": "/migrate-to-cloud", - "pricing": "https://temporal.io/pricing", - "learn": "https://learn.temporal.io/courses" - }, - "selfHosting": { - "overview": "/self-host", - "installation": "/self-host/install", - "databaseSetup": "/self-host/database", - "workerScaling": "/self-host/worker-scaling", - "monitoringObservability": "/self-host/monitoring", - "securityBestPractices": "/self-host/security", - "networking": "/self-host/networking", - "failoverStrategies": "/self-host/failover-strategies", - "backupRestore": "/self-host/backup-restore", - "upgradeGuide": "/self-host/upgrade", - "troubleshooting": "/self-host/troubleshooting" - }, - "deploymentConsiderations": { - "scalability": "/deployment/scalability", - "performanceTuning": "/deployment/performance-tuning", - "workerScaling": "/deployment/worker-scaling", - "observability": "/deployment/observability", - "bestPractices": "/deployment/best-practices", - "learn": "https://learn.temporal.io/courses" - }, - "tools": { - "cli": "/cli", - "tctl": "/cli/tctl", - "tcld": "/cli/tcld", - "codecsAndEncryption": "/codecs-and-encryption" - } - }, - "cli": "/cli", - "references": "/references", - "troubleshooting": "/troubleshooting", - "encyclopedia": { - "overview": "/encyclopedia", - "coreConcepts": { - "workflows": "/encyclopedia/workflow-overview", - "activities": "/encyclopedia/activities", - "signals": "/encyclopedia/signals", - "queries": "/encyclopedia/queries", - "timeouts": "/encyclopedia/timeouts", - "retryPolicies": "/encyclopedia/retry-policies", - "childWorkflows": "/encyclopedia/child-workflows", - "sideEffects": "/encyclopedia/side-effects", - "determinism": "/encyclopedia/determinism" - }, - "temporalFeatures": { - "namespaces": "/encyclopedia/namespaces", - "taskQueues": "/encyclopedia/task-queues", - "searchAttributes": "/encyclopedia/search-attributes", - "visibility": "/encyclopedia/visibility", - "eventHistory": "/encyclopedia/event-history", - "workflowExecutionLifecycle": "/encyclopedia/workflow-execution-lifecycle" - }, - "advancedTopics": { - "multiRegionNamespaces": "/encyclopedia/multi-region", - "failoverStrategies": "/encyclopedia/failover-strategies", - "customDataConverters": "/encyclopedia/custom-data-converters", - "workflowVersioning": "/encyclopedia/workflow-versioning", - "cronSchedules": "/encyclopedia/cron-schedules", - "distributedTracing": "/encyclopedia/distributed-tracing", - "eventualConsistency": "/encyclopedia/eventual-consistency" - }, - "glossary": "/glossary" - }, - "community": { - "slack": "https://temporal.io/slack", - "forum": "https://community.temporal.io", - "events": "https://temporal.io/events" - }, - "externalLinks": { - "temporalHome": "https://temporal.io", - "blog": "https://temporal.io/blog", - "github": "https://github.com/temporalio", - "twitter": "https://twitter.com/temporalio", - "youtube": "https://www.youtube.com/c/Temporalio" - } - }, - "relatedContent": { - "guides": [ - { - "title": "Workflow Execution Basics", - "url": "/guides/workflow-execution" - }, - { - "title": "Temporal Cloud Overview", - "url": "/cloud/overview" - } - ], - "apiDocs": [ - { - "title": "Temporal API Reference", - "url": "/api/reference" - }, - { - "title": "SDK Documentation", - "url": "/sdk" - } - ] - }, - "trustSignals": { - "authoritative": true, - "source": "Official Temporal Documentation", - "validatedBy": ["Temporal Engineering Team"], - "citations": ["https://github.com/temporalio/documentation"] - }, - "aiOptimization": { - "aiFriendly": true, - "searchBoost": ["learn.temporal.io", "docs.temporal.io"], - "structuredMetadataLinks": true, - "suggestions": { - "crossSiteSearch": true, - "personalizedContent": true - } - } -} diff --git a/docs/best-practices/cloud-access-control.mdx b/docs/best-practices/cloud-access-control.mdx index 82a3e3ef62..9b1b9b554b 100644 --- a/docs/best-practices/cloud-access-control.mdx +++ b/docs/best-practices/cloud-access-control.mdx @@ -50,16 +50,16 @@ Select the option that aligns best with your infrastructure, security requiremen In the case that you are using multiple certificates signed by the same CA, and some of these certificates are for production environments, there are some workarounds you can employ. -One convention is to give certificates a common name that matches the namespace. If you do this when using the same CA for dev and prod, then you can leverage Certificate Filters to prevent access to production environments. This is described in detail under the [authorization section](https://docs.temporal.io/cloud/certificates#control-authorization) of the documentation. +One convention is to give certificates a common name that matches the namespace. If you do this when using the same CA for dev and prod, then you can leverage Certificate Filters to prevent access to production environments. This is described in detail under the [authorization section](/cloud/certificates#control-authorization) of the documentation. ## Best practices ### Establish clear guidelines on authentication methods -Teams should standardize on either [mTLS certificates](https://docs.temporal.io/cloud/certificates) or -[API keys](https://docs.temporal.io/cloud/api-keys) for the following operations: +Teams should standardize on either [mTLS certificates](/cloud/certificates) or +[API keys](/cloud/api-keys) for the following operations: - Connect Temporal clients to Temporal Cloud (e.g. Worker processes) -- Automation (e.g. Temporal Cloud [Operations API](https://docs.temporal.io/ops), [Terraform provider](https://docs.temporal.io/cloud/terraform-provider), [Temporal CLI](https://docs.temporal.io/cli/setup-cli)) +- Automation (e.g. Temporal Cloud [Operations API](/ops), [Terraform provider](/cloud/terraform-provider), [Temporal CLI](/cli/setup-cli)) By default, teams should use API keys with [service accounts](/cloud/manage-access/service-accounts) for both operations. API keys are generally easier to set up and rotate than mTLS certificates, and service accounts let you assign account-level and diff --git a/docs/best-practices/cost-optimization.mdx b/docs/best-practices/cost-optimization.mdx index 8b7d4b5af7..aed3fc0a99 100644 --- a/docs/best-practices/cost-optimization.mdx +++ b/docs/best-practices/cost-optimization.mdx @@ -114,7 +114,7 @@ Focus optimization efforts on what's driving costs with a specific workload: ## Measuring -Establish [baseline metrics](https://docs.temporal.io/cloud/metrics/reference) before optimizing and be sure to validate impact after implementation. +Establish [baseline metrics](/cloud/metrics/reference) before optimizing and be sure to validate impact after implementation. Specifically: - Actions consumption (per Workflow, per day/month, by Namespace) @@ -124,7 +124,7 @@ Specifically: ## Actions optimization -[Actions](https://docs.temporal.io/cloud/actions) encompass Workflow operations, Activity Executions, Signals, Queries, and other interactions with Temporal. +[Actions](/cloud/actions) encompass Workflow operations, Activity Executions, Signals, Queries, and other interactions with Temporal. Each represents a unit of consumption. ### Activity granularity diff --git a/docs/best-practices/knowledge-hub.mdx b/docs/best-practices/knowledge-hub.mdx index 70fffe8e6f..2bcabada4e 100644 --- a/docs/best-practices/knowledge-hub.mdx +++ b/docs/best-practices/knowledge-hub.mdx @@ -26,7 +26,7 @@ To bootstrap your knowledge hub, use the ## What belongs in your knowledge hub -Although Temporal itself has [thorough documentation](https://docs.temporal.io/), not all of it applies to your organization or your teams' use cases. +Although Temporal itself has [thorough documentation](/), not all of it applies to your organization or your teams' use cases. The knowledge hub distills the documentation into just the specific information your teams need. One way to organize the content is according to where developers are in their journey. The following sample outline shows what sections to include. @@ -97,7 +97,7 @@ These metrics create a feedback loop: measure, identify gaps, improve content, a ## What doesn't belong in your knowledge hub -A knowledge hub is not a mirror of [Temporal's official documentation](https://docs.temporal.io/). +A knowledge hub is not a mirror of [Temporal's official documentation](/). Avoid duplicating SDK API references, concept explanations, or release notes that Temporal already maintains. When that content changes, your copy becomes a source of confusion rather than clarity. Instead, link to the official docs and reserve your knowledge hub for organization-specific decisions, conventions, diff --git a/docs/best-practices/managing-aps-limits.mdx b/docs/best-practices/managing-aps-limits.mdx index 0c323d0fb1..bd18825f89 100644 --- a/docs/best-practices/managing-aps-limits.mdx +++ b/docs/best-practices/managing-aps-limits.mdx @@ -207,6 +207,7 @@ Use Provisioned capacity when the on-demand model can't respond quickly enough: :::note When switching back to on-demand mode, your APS limit resets to the running average from the last 7 days. +If Temporal Support has set a custom limit for your Namespace, that limit is preserved across the switch. Plan for this if your workload is sensitive to the transition. ::: diff --git a/docs/best-practices/security-controls.mdx b/docs/best-practices/security-controls.mdx index 3eae1fedca..29b1977a28 100644 --- a/docs/best-practices/security-controls.mdx +++ b/docs/best-practices/security-controls.mdx @@ -35,21 +35,21 @@ Strong identity management in Temporal Cloud is crucial for ensuring secure acce ### Best Practices: -#### 1. Enable [SAML Single Sign-on](https://docs.temporal.io/cloud/manage-access/saml) (SSO) for User Access +#### 1. Enable [SAML Single Sign-on](/cloud/manage-access/saml) (SSO) for User Access Integrate Temporal Cloud with your organization's identity provider via SAML 2.0 for centralized authentication. SSO allows you to enforce your corporate login policies (MFA, password complexity, etc.). When you configure SAML with Temporal Cloud, you can disable social logins (i.e. Microsoft, Google) by opening a support ticket. #### 2. Use Least-Privilege Roles for Temporal Cloud Users -Temporal Cloud provides [preconfigured account-level roles](https://docs.temporal.io/cloud/manage-access/users) (Account Owner, Finance Admin, Global Admin, Developer, Read-Only) and Namespace-level permissions. Assign users the lowest level of access they need. For example, give developers access only to the Namespaces they work on, and use read-only roles for auditors or reviewers. Regularly review user roles and remove or downgrade accounts that are no longer needed +Temporal Cloud provides [preconfigured account-level roles](/cloud/manage-access/users) (Account Owner, Finance Admin, Global Admin, Developer, Read-Only) and Namespace-level permissions. Assign users the lowest level of access they need. For example, give developers access only to the Namespaces they work on, and use read-only roles for auditors or reviewers. Regularly review user roles and remove or downgrade accounts that are no longer needed #### 3. Leverage SCIM or Automated User Provisioning -When applicable, use [SCIM](https://docs.temporal.io/cloud/manage-access/scim) or the Temporal Cloud user management API to automate adding and removing user accounts. This ensures timely removal of access when people change roles or leave the organization. +When applicable, use [SCIM](/cloud/manage-access/scim) or the Temporal Cloud user management API to automate adding and removing user accounts. This ensures timely removal of access when people change roles or leave the organization. #### Use Service Accounts for Automation -For non-human access (CI/CD pipelines, backend services), use [Temporal Cloud Service Accounts](https://docs.temporal.io/cloud/manage-access/service-accounts) instead of shared user logins. Service Accounts are machine identities that can be granted specific permissions without ties to an individual. Create separate Service Accounts with unique API keys for different applications or microservices, and apply least privilege to each (e.g. a service account that only has access to one Namespace). +For non-human access (CI/CD pipelines, backend services), use [Temporal Cloud Service Accounts](/cloud/manage-access/service-accounts) instead of shared user logins. Service Accounts are machine identities that can be granted specific permissions without ties to an individual. Create separate Service Accounts with unique API keys for different applications or microservices, and apply least privilege to each (e.g. a service account that only has access to one Namespace). ## Secure Application Authentication and API Access @@ -67,7 +67,7 @@ We recommend you enable mTLS for strong identity assurance of clients; it ensure #### 2. Proactively manage and rotate certificates -Track the expiration dates of your client and [Certificate Authority certificates](https://docs.temporal.io/cloud/certificates). Temporal Cloud trusts the uploaded CA; if it expires, all client authorizations will fail. Establish and automate a certificate rotation schedule (e.g. rotate client certificates quarterly and CA certificates annually, well before expiry). Temporal supports uploading a new CA certificate alongside the old one to allow seamless rollover. Always test new certificates in a staging environment if possible. +Track the expiration dates of your client and [Certificate Authority certificates](/cloud/certificates). Temporal Cloud trusts the uploaded CA; if it expires, all client authorizations will fail. Establish and automate a certificate rotation schedule (e.g. rotate client certificates quarterly and CA certificates annually, well before expiry). Temporal supports uploading a new CA certificate alongside the old one to allow seamless rollover. Always test new certificates in a staging environment if possible. #### 3. If you’re using API Keys, handle them with strict care @@ -86,11 +86,11 @@ Although Temporal Cloud is a SaaS offering, you retain control over its networki #### 1. Use Private Connectivity -Temporal Cloud supports private connectivity options such as [AWS PrivateLink](https://docs.temporal.io/cloud/connectivity/aws-connectivity) and [Google Cloud Private Service Connect](https://docs.temporal.io/cloud/connectivity/gcp-connectivity). If your infrastructure is in AWS or GCP, configure a PrivateLink/PSC endpoint for Temporal Cloud. This allows your workers and applications to reach Temporal Cloud over a private network path, avoiding traversal of the public internet. Private connectivity reduces the surface for man-in-the-middle attacks and can meet stringent network security policies. +Temporal Cloud supports private connectivity options such as [AWS PrivateLink](/cloud/connectivity/aws-connectivity) and [Google Cloud Private Service Connect](/cloud/connectivity/gcp-connectivity). If your infrastructure is in AWS or GCP, configure a PrivateLink/PSC endpoint for Temporal Cloud. This allows your workers and applications to reach Temporal Cloud over a private network path, avoiding traversal of the public internet. Private connectivity reduces the surface for man-in-the-middle attacks and can meet stringent network security policies. #### 2. Separate environments by Namespace -Use [Temporal Namespaces](https://docs.temporal.io/best-practices/managing-namespace#naming-conventions) to isolate workflows for different environments or teams (e.g. development, staging, production). Each Namespace is logically segregated and cannot interact with others by default, providing a security boundary. +Use [Temporal Namespaces](/best-practices/managing-namespace#naming-conventions) to isolate workflows for different environments or teams (e.g. development, staging, production). Each Namespace is logically segregated and cannot interact with others by default, providing a security boundary. Ensure that your production Namespace uses stricter network controls (e.g. only accessible from the prod network) and that credentials for it are separate from non-prod Namespaces. This limits the impact of any compromise in a lower environment, and as workflow data is only visible to users with access to that Namespace, separating environments by Namespace also enforces data-visibility boundaries. @@ -102,19 +102,19 @@ Temporal's data encryption capabilities ensure the security and confidentiality #### 1. Enable Client-Side Encryption for Workflow Data -Temporal provides an optional [data conversion framework](https://docs.temporal.io/dataconversion) (Data Converter) and payload codec interface; customers must implement, deploy, and operate their own custom codec and manage encryption keys. +Temporal provides an optional [data conversion framework](/dataconversion) (Data Converter) and payload codec interface; customers must implement, deploy, and operate their own custom codec and manage encryption keys. In practice, this means you can encrypt any sensitive data before it is sent to Temporal Cloud and only decrypt it on the Client/Worker side. Because encryption keys stay under your control, you are responsible for key generation, secure storage, rotation, and versioning. Implementing this involves developing a custom codec plugin in your Temporal SDK and optionally (if you need to inspect decrypted payloads in the Web UI or CLI) deploying a dedicated codec server. -#### 2. Encode Workflow Failure Details with a [Failure Converter](https://docs.temporal.io/failure-converter) +#### 2. Encode Workflow Failure Details with a [Failure Converter](/failure-converter) Temporal’s default behavior copies error messages and call stacks as plain text, and this text is directly accessible in the Message field of Workflow Executions. -If your failure messages and stack traces contain sensitive information, it is recommended that you configure the [Failure Converter](https://docs.temporal.io/failure-converter) to encrypt the error information. This would encrypt the `message` and `stack_trace` fields in the payloads. +If your failure messages and stack traces contain sensitive information, it is recommended that you configure the [Failure Converter](/failure-converter) to encrypt the error information. This would encrypt the `message` and `stack_trace` fields in the payloads. #### 3. Leverage Namespace Data Retention Policies -Temporal Cloud Namespace has a [Retention Period](https://docs.temporal.io/temporal-service/temporal-server#retention-period) setting for workflow histories (1 to 90 days). Set an appropriate retention period to balance operational needs with security. Shorter retention means completed workflow data (history, payloads) is purged sooner, reducing the amount of sensitive data stored in the cloud at any time. Document your retention choices to align with your company’s data retention policies and regulatory requirements. For retention periods over 90 days, these can be exported to your own GCS or S3 buckets. +Temporal Cloud Namespace has a [Retention Period](/temporal-service/temporal-server#retention-period) setting for workflow histories (1 to 90 days). Set an appropriate retention period to balance operational needs with security. Shorter retention means completed workflow data (history, payloads) is purged sooner, reducing the amount of sensitive data stored in the cloud at any time. Document your retention choices to align with your company’s data retention policies and regulatory requirements. For retention periods over 90 days, these can be exported to your own GCS or S3 buckets. ### Availability and Disaster Recovery @@ -137,8 +137,8 @@ Run a business-impact analysis to flag workflows where a regional outage would c For many organizations, ensuring High Availability (HA) is required because of strict uptime requirements, compliance, and regulatory needs. -For these critical use cases, enable High Availability features for specific namespaces for a [99.99% contractual SLA](https://docs.temporal.io/cloud/high-availability#high-availability-features). When choosing between [same-region, multi-region, and multi-cloud replication](https://docs.temporal.io/cloud/high-availability), it is recommended to use multi-region/multi-cloud replication to distribute your dependencies across regions. Using physically separated regions improves the fault tolerance of your application. +For these critical use cases, enable High Availability features for specific namespaces for a [99.99% contractual SLA](/cloud/high-availability#high-availability-features). When choosing between [same-region, multi-region, and multi-cloud replication](/cloud/high-availability), it is recommended to use multi-region/multi-cloud replication to distribute your dependencies across regions. Using physically separated regions improves the fault tolerance of your application. -By default, Temporal Cloud provides a [99.9% contractual SLA guarantee](https://docs.temporal.io/cloud/high-availability) against service errors for all namespaces. +By default, Temporal Cloud provides a [99.9% contractual SLA guarantee](/cloud/high-availability) against service errors for all namespaces. -Note: [enabling HA features for namespaces will 2x the consumption cost.](https://docs.temporal.io/cloud/pricing#high-availability-features) +Note: [enabling HA features for namespaces will 2x the consumption cost.](/cloud/pricing#high-availability-features) diff --git a/docs/cli/cli-basics.mdx b/docs/cli/cli-basics.mdx index af2633d552..54f5d562a5 100644 --- a/docs/cli/cli-basics.mdx +++ b/docs/cli/cli-basics.mdx @@ -18,7 +18,7 @@ keywords: hide_table_of_contents: true --- -import { SetupSteps, SetupStep, CodeSnippet } from '@site/src/components/elements/SetupSteps'; +import { SetupSteps, SetupStep, CodeSnippet } from '@site/src/components'; This page walks through the operations you're most likely to reach for when using the Temporal CLI. For the full set of commands, see the [command reference](/cli#command-reference). diff --git a/docs/cli/command-reference/activity.mdx b/docs/cli/command-reference/activity.mdx index 1512d372f5..5224835015 100644 --- a/docs/cli/command-reference/activity.mdx +++ b/docs/cli/command-reference/activity.mdx @@ -30,7 +30,7 @@ tags: --- {/* NOTE: This is an auto-generated file. Any edit to this file will be overwritten. -This file is generated from https://github.com/temporalio/cli/blob/main/internal/commandsgen/commands.yml via internal/cmd/gen-docs */} +This file is generated from the CLI command definitions via cmd/gen-docs. */} This page provides a reference for the `temporal` CLI `activity` command. The flags applicable to each subcommand are presented in a table within the heading for the subcommand. Refer to [Global Flags](#global-flags) for flags that you can use with every subcommand. @@ -199,14 +199,19 @@ Use the following options to change the behavior of this command. You can also u ## pause -Pause an Activity. Not supported for Standalone Activities. +Pause a Workflow Activity or Standalone Activity. -If the Activity is not currently running (e.g. because it previously -failed), it will not be run again until it is unpaused. +If no Worker is running the Activity, it won't be dispatched until it is Unpaused. If an attempt is running, the Pause +remains pending. The next Heartbeat response reports the Pause; otherwise, the attempt continues until it completes, +fails, or times out. However, if the Activity is currently running, it will run until the next time it fails, completes, or times out, at which point the pause will kick in. +Pause does not stop or extend the Activity's Schedule-To-Close Timeout. +A paused Activity can still time out. Use `temporal activity update-options` +to extend timeout settings before a long pause. + If the Activity is on its last retry attempt and fails, the failure will be returned to the caller, just as if the Activity had not been paused. @@ -214,46 +219,54 @@ Specify the Activity and Workflow IDs: ``` temporal activity pause \ - --activity-id YourActivityId \ - --workflow-id YourWorkflowId + --workflow-id YourWorkflowId \ + --activity-id YourActivityId +``` + +To target the latest run of a Standalone Activity, omit `--workflow-id`: + +``` +temporal activity pause \ + --activity-id YourActivityId ``` -To later unpause the activity, see [unpause](#unpause). You may also want to -[reset](#reset) the activity to unpause it while also starting it from the beginning. +Use `--run-id` to target a specific Workflow Run or Standalone Activity Run. See +[Activity Operations](/activity-operations) for behavior and state transitions. Use the following options to change the behavior of this command. You can also use any of the [global flags](#global-flags) that apply to all subcommands. | Flag | Required | Description | |------|----------|-------------| -| `--activity-id`, `-a` | No | **string** The Activity ID to pause. Required. | +| `--activity-id`, `-a` | Yes | **string** Activity ID. | | `--identity` | No | **string** The identity of the user or client submitting this request. | | `--reason` | No | **string** Reason for pausing the Activity. | -| `--run-id`, `-r` | No | **string** Run ID. | -| `--workflow-id`, `-w` | Yes | **string** Workflow ID. | +| `--run-id`, `-r` | No | **string** Workflow Run ID when `--workflow-id` is set; otherwise, Standalone Activity Run ID. If omitted for a Standalone Activity, targets the latest run. | +| `--workflow-id`, `-w` | No | **string** Workflow ID for a Workflow Activity. Omit for a Standalone Activity. | ## reset -Reset an activity. Not supported for Standalone Activities. -This restarts the activity as if it were first being -scheduled. That is, it will reset both the number of attempts and the -activity timeout, as well as, optionally, the -[heartbeat details](#reset-heartbeats). +Reset a Workflow Activity or Standalone Activity to attempt 1. Reset clears retry backoff and Heartbeat details, but it +doesn't restart the Schedule-To-Close period. -If the activity may be executing (i.e. it has not yet timed out), the -reset will take effect the next time it fails, heartbeats, or times out. -If is waiting for a retry (i.e. has failed or timed out), the reset -will apply immediately. +Reset applies immediately when no Activity Task Execution is in flight—for example, while the Activity is waiting for +its initial Start Delay or retry backoff, is dispatched but not yet picked up by a Worker, or is fully Paused. The next +attempt still waits for any applicable Start Delay, jitter, or Paused state. -If the activity is already paused, it will be unpaused by default. -You can specify `keep_paused` to prevent this. +After a Worker picks up an Activity Task, Reset remains pending until that attempt reports an outcome or times out. A +successful result wins and Reset has no effect; a failure or timeout applies Reset and schedules attempt 1 if it fits +within the unchanged Schedule-To-Close deadline. The next Heartbeat response reports the pending Reset. -If the activity is paused and the `keep_paused` flag is not provided, -it will be unpaused. If the activity is paused and `keep_paused` flag -is provided - it will stay paused. +If the Activity is Paused, Reset Unpauses it by default. Use `--keep-paused` to reset its state without dispatching it. -Either `--activity-id` (with `--workflow-id`) or `--query` must be specified. +To target a Workflow Activity, provide both IDs. To target a Standalone Activity, provide `--activity-id` and omit +`--workflow-id`. Use `--run-id` to select a specific Workflow Run or Standalone Activity Run. -### Resetting activities that heartbeat {/* #reset-heartbeats */} +``` +temporal activity reset \ + --activity-id YourActivityId +``` + +### Heartbeat behavior when resetting {/* #reset-heartbeat-behavior */} Activities that heartbeat will receive a [Canceled failure](/references/failures#cancelled-failure) the next time they heartbeat after a reset. @@ -261,19 +274,10 @@ the next time they heartbeat after a reset. If, in your Activity, you need to do any cleanup when an Activity is reset, handle this error and then re-throw it when you've cleaned up. -If the `reset_heartbeats` flag is set, the heartbeat details will also be cleared. - -Specify the Activity and Workflow IDs: +Reset always clears the last Heartbeat time and Heartbeat details when it is applied. -``` -temporal activity reset \ - --activity-id YourActivityId \ - --workflow-id YourWorkflowId - --keep-paused - --reset-heartbeats -``` - -Activities can be reset in bulk with a visibility query list filter: +Workflow Activities can be Reset in bulk with a Visibility Query List Filter. Batch mode doesn't target Standalone +Activities. ``` temporal activity reset \ @@ -284,18 +288,16 @@ Use the following options to change the behavior of this command. You can also u | Flag | Required | Description | |------|----------|-------------| -| `--activity-id`, `-a` | No | **string** The Activity ID to reset. Mutually exclusive with `--query`. Requires `--workflow-id` to be specified. | +| `--activity-id`, `-a` | No | **string** Activity ID. Mutually exclusive with `--query`. Set `--workflow-id` for a Workflow Activity; omit it for a Standalone Activity. | | `--headers` | No | **string[]** Temporal workflow headers in 'KEY=VALUE' format. Keys must be identifiers, and values must be JSON values. May be passed multiple times to set multiple Temporal headers. Note: These are workflow headers, not gRPC headers. | -| `--jitter` | No | **duration** The activity will reset at random a time within the specified duration. Can only be used with --query. | -| `--keep-paused` | No | **bool** If the activity was paused, it will stay paused. | -| `--query`, `-q` | No | **string** Content for an SQL-like `QUERY` List Filter. You must set either --workflow-id or --query. Note: Using --query for batch activity operations is an experimental feature and may change in the future. | +| `--jitter` | No | **duration** Delays the new dispatch by a random duration within this window. Ignored while `--keep-paused` prevents dispatch. | +| `--keep-paused` | No | **bool** Keep a Paused Activity Paused after Reset. | +| `--query`, `-q` | No | **string** SQL-like Visibility Query List Filter for a batch of Workflow Activities. Mutually exclusive with `--activity-id`. This feature is experimental and may change. | | `--reason` | No | **string** Reason for batch operation. Only use with --query. Defaults to user name. | -| `--reset-attempts` | No | **bool** Reset the activity attempts. | -| `--reset-heartbeats` | No | **bool** Reset the Activity's heartbeats. | -| `--restore-original-options` | No | **bool** Restore the original options of the activity. | +| `--restore-original-options` | No | **bool** Restore the Activity options captured when it was first scheduled. | | `--rps` | No | **float** Limit batch's requests per second. Only allowed if query is present. | -| `--run-id`, `-r` | No | **string** Run ID. Only use with --workflow-id. Cannot use with --query. | -| `--workflow-id`, `-w` | No | **string** Workflow ID. You must set either --workflow-id or --query. | +| `--run-id`, `-r` | No | **string** Workflow Run ID when `--workflow-id` is set; otherwise, Standalone Activity Run ID. Can't be used with `--query`. | +| `--workflow-id`, `-w` | No | **string** Workflow ID for a Workflow Activity. Omit for a Standalone Activity. Can't be used with `--query`. | | `--yes`, `-y` | No | **bool** Don't prompt to confirm signaling. Only allowed when --query is present. | ## result @@ -380,33 +382,26 @@ Use the following options to change the behavior of this command. You can also u ## unpause -Re-schedule a previously-paused Activity for execution. -Not supported for Standalone Activities. - -If the Activity is not running and is past its retry timeout, it will be -scheduled immediately. Otherwise, it will be scheduled after its retry -timeout expires. - -Use `--reset-attempts` to reset the number of previous run attempts to -zero. For example, if an Activity is near the maximum number of attempts -N specified in its retry policy, `--reset-attempts` will allow the -Activity to be retried another N times after unpausing. +Resume a Paused Workflow Activity or Standalone Activity. -Use `--reset-heartbeat` to reset the Activity's heartbeats. +By default, the Activity keeps its attempt count, Heartbeat details, and any remaining retry backoff. The next attempt is +dispatched after that backoff and any remaining Start Delay. -Either `--activity-id` (with `--workflow-id`) or `--query` must be specified. +Use `--reset-attempts` to restart at attempt 1 or `--jitter` to add a random delay before dispatch. -Specify the Activity and Workflow IDs: +To target a Workflow Activity, provide both IDs: ``` temporal activity unpause \ - --activity-id YourActivityId \ - --workflow-id YourWorkflowId - --reset-attempts - --reset-heartbeats + --workflow-id YourWorkflowId \ + --activity-id YourActivityId ``` -Activities can be unpaused in bulk via a visibility Query list filter: +To target a Standalone Activity, provide `--activity-id` and omit `--workflow-id`. Use `--run-id` to select a specific +Workflow Run or Standalone Activity Run. + +Workflow Activities can be Unpaused in bulk with a Visibility Query List Filter. Batch mode doesn't target Standalone +Activities. ``` temporal activity unpause \ @@ -417,30 +412,28 @@ Use the following options to change the behavior of this command. You can also u | Flag | Required | Description | |------|----------|-------------| -| `--activity-id`, `-a` | No | **string** The Activity ID to unpause. Mutually exclusive with `--query`. Requires `--workflow-id` to be specified. | +| `--activity-id`, `-a` | No | **string** Activity ID. Mutually exclusive with `--query`. Set `--workflow-id` for a Workflow Activity; omit it for a Standalone Activity. | | `--headers` | No | **string[]** Temporal workflow headers in 'KEY=VALUE' format. Keys must be identifiers, and values must be JSON values. May be passed multiple times to set multiple Temporal headers. Note: These are workflow headers, not gRPC headers. | -| `--jitter` | No | **duration** The activity will start at random a time within the specified duration. Can only be used with --query. | -| `--query`, `-q` | No | **string** Content for an SQL-like `QUERY` List Filter. You must set either --workflow-id or --query. Note: Using --query for batch activity operations is an experimental feature and may change in the future. | +| `--jitter` | No | **duration** Adds a random delay within this window before the next dispatch. | +| `--query`, `-q` | No | **string** SQL-like Visibility Query List Filter for a batch of Workflow Activities. Mutually exclusive with `--activity-id`. This feature is experimental and may change. | | `--reason` | No | **string** Reason for batch operation. Only use with --query. Defaults to user name. | -| `--reset-attempts` | No | **bool** Reset the activity attempts. | -| `--reset-heartbeats` | No | **bool** Reset the Activity's heartbeats. | +| `--reset-attempts` | No | **bool** Reset the Activity to attempt 1 and clear its current retry interval. | | `--rps` | No | **float** Limit batch's requests per second. Only allowed if query is present. | -| `--run-id`, `-r` | No | **string** Run ID. Only use with --workflow-id. Cannot use with --query. | -| `--workflow-id`, `-w` | No | **string** Workflow ID. You must set either --workflow-id or --query. | +| `--run-id`, `-r` | No | **string** Workflow Run ID when `--workflow-id` is set; otherwise, Standalone Activity Run ID. Can't be used with `--query`. | +| `--workflow-id`, `-w` | No | **string** Workflow ID for a Workflow Activity. Omit for a Standalone Activity. Can't be used with `--query`. | | `--yes`, `-y` | No | **bool** Don't prompt to confirm signaling. Only allowed when --query is present. | ## update-options -Update the options of a running Activity that were passed into it from -a Workflow. Updates are incremental, only changing the specified options. -Not supported for Standalone Activities. +Update the options of a Workflow Activity or Standalone Activity. Updates are incremental; options that you don't +specify remain unchanged. -For example: +To target a Workflow Activity, provide the Workflow and Activity IDs. To target a Standalone Activity, provide +`--activity-id` and omit `--workflow-id`: ``` temporal activity update-options \ --activity-id YourActivityId \ - --workflow-id YourWorkflowId \ --task-queue NewTaskQueueName \ --schedule-to-close-timeout DURATION \ --schedule-to-start-timeout DURATION \ @@ -452,11 +445,11 @@ temporal activity update-options \ --retry-maximum-attempts NewMaximumAttempts ``` -You may follow this command with `temporal activity reset`, and the new values will apply after the reset. +Use `--run-id` to select a specific Workflow Run or Standalone Activity Run. Use `--restore-original-options` without +other option flags to restore the values captured when the Activity was first scheduled. -Either `--activity-id` or `--query` must be specified. - -Activity options can be updated in bulk with a visibility query list filter: +Workflow Activity options can be updated in bulk with a Visibility Query List Filter. Batch mode doesn't target +Standalone Activities. ``` temporal activity update-options \ @@ -468,23 +461,23 @@ Use the following options to change the behavior of this command. You can also u | Flag | Required | Description | |------|----------|-------------| -| `--activity-id`, `-a` | No | **string** The Activity ID to update options. Mutually exclusive with `--query`. Requires `--workflow-id` to be specified. | +| `--activity-id`, `-a` | No | **string** Activity ID. Mutually exclusive with `--query`. Set `--workflow-id` for a Workflow Activity; omit it for a Standalone Activity. | | `--headers` | No | **string[]** Temporal workflow headers in 'KEY=VALUE' format. Keys must be identifiers, and values must be JSON values. May be passed multiple times to set multiple Temporal headers. Note: These are workflow headers, not gRPC headers. | | `--heartbeat-timeout` | No | **duration** Maximum permitted time between successful worker heartbeats. | -| `--query`, `-q` | No | **string** Content for an SQL-like `QUERY` List Filter. You must set either --workflow-id or --query. Note: Using --query for batch activity operations is an experimental feature and may change in the future. | +| `--query`, `-q` | No | **string** SQL-like Visibility Query List Filter for a batch of Workflow Activities. Mutually exclusive with `--activity-id`. This feature is experimental and may change. | | `--reason` | No | **string** Reason for batch operation. Only use with --query. Defaults to user name. | -| `--restore-original-options` | No | **bool** Restore the original options of the activity. | +| `--restore-original-options` | No | **bool** Restore the Activity options captured when it was first scheduled. Can't be combined with option changes. | | `--retry-backoff-coefficient` | No | **float** Coefficient used to calculate the next retry interval. The next retry interval is previous interval multiplied by the backoff coefficient. Must be 1 or larger. | | `--retry-initial-interval` | No | **duration** Interval of the first retry. If retryBackoffCoefficient is 1.0 then it is used for all retries. | | `--retry-maximum-attempts` | No | **int** Maximum number of attempts. When exceeded the retries stop even if not expired yet. Setting this value to 1 disables retries. Setting this value to 0 means unlimited attempts(up to the timeouts). | | `--retry-maximum-interval` | No | **duration** Maximum interval between retries. Exponential backoff leads to interval increase. This value is the cap of the increase. | | `--rps` | No | **float** Limit batch's requests per second. Only allowed if query is present. | -| `--run-id`, `-r` | No | **string** Run ID. Only use with --workflow-id. Cannot use with --query. | +| `--run-id`, `-r` | No | **string** Workflow Run ID when `--workflow-id` is set; otherwise, Standalone Activity Run ID. Can't be used with `--query`. | | `--schedule-to-close-timeout` | No | **duration** Indicates how long the caller is willing to wait for an activity completion. Limits how long retries will be attempted. | | `--schedule-to-start-timeout` | No | **duration** Limits time an activity task can stay in a task queue before a worker picks it up. This timeout is always non retryable, as all a retry would achieve is to put it back into the same queue. Defaults to the schedule-to-close timeout or workflow execution timeout if not specified. | | `--start-to-close-timeout` | No | **duration** Maximum time an activity is allowed to execute after being picked up by a worker. This timeout is always retryable. | | `--task-queue` | No | **string** Name of the task queue for the Activity. | -| `--workflow-id`, `-w` | No | **string** Workflow ID. You must set either --workflow-id or --query. | +| `--workflow-id`, `-w` | No | **string** Workflow ID for a Workflow Activity. Omit for a Standalone Activity. Can't be used with `--query`. | | `--yes`, `-y` | No | **bool** Don't prompt to confirm signaling. Only allowed when --query is present. | ## Global Flags @@ -525,4 +518,3 @@ The following options can be used with any command. | `--tls-key-data` | No | **string** Private certificate key data. Can't be used with --tls-key-path. | | | `--tls-key-path` | No | **string** Path to x509 private key. Can't be used with --tls-key-data. | | | `--tls-server-name` | No | **string** Override target TLS server name. | | - diff --git a/docs/cli/command-reference/batch.mdx b/docs/cli/command-reference/batch.mdx index 2be1afcdd4..d42bad7c43 100644 --- a/docs/cli/command-reference/batch.mdx +++ b/docs/cli/command-reference/batch.mdx @@ -18,7 +18,7 @@ tags: --- {/* NOTE: This is an auto-generated file. Any edit to this file will be overwritten. -This file is generated from https://github.com/temporalio/cli/blob/main/internal/commandsgen/commands.yml via internal/cmd/gen-docs */} +This file is generated from the CLI command definitions via cmd/gen-docs. */} This page provides a reference for the `temporal` CLI `batch` command. The flags applicable to each subcommand are presented in a table within the heading for the subcommand. Refer to [Global Flags](#global-flags) for flags that you can use with every subcommand. diff --git a/docs/cli/command-reference/cloud/account.mdx b/docs/cli/command-reference/cloud/account.mdx index 817047385e..1824d5fc26 100644 --- a/docs/cli/command-reference/cloud/account.mdx +++ b/docs/cli/command-reference/cloud/account.mdx @@ -11,13 +11,15 @@ tags: - account --- -import { ReleaseNoteHeader } from "@site/src/components"; +import { ReleaseNoteHeader } from '@site/src/components'; {/* NOTE: This is an auto-generated file. Any edit to this file will be overwritten. -This file is generated from https://github.com/temporalio/cli via cmd/gen-docs */} +This file is generated from the CLI command definitions via cmd/gen-docs. */} +Manage the Temporal Cloud account. + This page provides a reference for the `temporal cloud account` commands. The flags applicable to each subcommand are presented in a table within the heading for the subcommand. Refer to [Global Flags](#global-flags) for flags that you can use with every subcommand. ## audit-log diff --git a/docs/cli/command-reference/cloud/apikey.mdx b/docs/cli/command-reference/cloud/apikey.mdx index f68b46fded..89e2de48d2 100644 --- a/docs/cli/command-reference/cloud/apikey.mdx +++ b/docs/cli/command-reference/cloud/apikey.mdx @@ -12,13 +12,17 @@ tags: - apikeys --- -import { ReleaseNoteHeader } from "@site/src/components"; +import { ReleaseNoteHeader } from '@site/src/components'; {/* NOTE: This is an auto-generated file. Any edit to this file will be overwritten. -This file is generated from https://github.com/temporalio/cli via cmd/gen-docs */} +This file is generated from the CLI command definitions via cmd/gen-docs. */} +Commands for creating, listing, and managing Temporal Cloud API keys. + +API keys authenticate requests to the Temporal Cloud API. + This page provides a reference for the `temporal cloud apikey` commands. The flags applicable to each subcommand are presented in a table within the heading for the subcommand. Refer to [Global Flags](#global-flags) for flags that you can use with every subcommand. ## create-for-me diff --git a/docs/cli/command-reference/cloud/async-operation.mdx b/docs/cli/command-reference/cloud/async-operation.mdx index a924af9b6c..3d10e8506c 100644 --- a/docs/cli/command-reference/cloud/async-operation.mdx +++ b/docs/cli/command-reference/cloud/async-operation.mdx @@ -12,13 +12,15 @@ tags: - async-operations --- -import { ReleaseNoteHeader } from "@site/src/components"; +import { ReleaseNoteHeader } from '@site/src/components'; {/* NOTE: This is an auto-generated file. Any edit to this file will be overwritten. -This file is generated from https://github.com/temporalio/cli via cmd/gen-docs */} +This file is generated from the CLI command definitions via cmd/gen-docs. */} +Commands for working with Temporal Cloud async operations. + This page provides a reference for the `temporal cloud async-operation` commands. The flags applicable to each subcommand are presented in a table within the heading for the subcommand. Refer to [Global Flags](#global-flags) for flags that you can use with every subcommand. ## await diff --git a/docs/cli/command-reference/cloud/connectivity.mdx b/docs/cli/command-reference/cloud/connectivity.mdx index 52ca3eac78..8675d195c3 100644 --- a/docs/cli/command-reference/cloud/connectivity.mdx +++ b/docs/cli/command-reference/cloud/connectivity.mdx @@ -11,13 +11,15 @@ tags: - connectivity --- -import { ReleaseNoteHeader } from "@site/src/components"; +import { ReleaseNoteHeader } from '@site/src/components'; {/* NOTE: This is an auto-generated file. Any edit to this file will be overwritten. -This file is generated from https://github.com/temporalio/cli via cmd/gen-docs */} +This file is generated from the CLI command definitions via cmd/gen-docs. */} +Commands for managing connectivity rules for Temporal Cloud. + This page provides a reference for the `temporal cloud connectivity` commands. The flags applicable to each subcommand are presented in a table within the heading for the subcommand. Refer to [Global Flags](#global-flags) for flags that you can use with every subcommand. ## delete @@ -87,12 +89,20 @@ Commands for managing private connectivity rules. ### private create -Create a new private VPC connectivity rule. Requires --connection-id and --region. +Create a new private connectivity rule for AWS, GCP, or Azure. -Example: +For AWS, provide --connection-id (VPC endpoint ID) and --region. +For GCP, provide --connection-id (PSC connection ID), --gcp-project-id, and --region. +For Azure, provide --azure-pe-resource-id (ARM resource ID) and --region. + +Examples: ``` temporal cloud connectivity private create --connection-id vpce-12345 --region aws-us-west-2 + +temporal cloud connectivity private create \ + --azure-pe-resource-id /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Network/privateEndpoints/{name} \ + --region azure-eastus ``` Use the following options to change the behavior of this command. You can also use any of the [global flags](#global-flags) that apply to all subcommands. @@ -102,7 +112,8 @@ Use the following options to change the behavior of this command. You can also u | `--api-key` | No | **string** API key for authenticating with Temporal Cloud. Can be used instead of interactive login for automation and CI/CD pipelines. | | `--async` | No | **bool** Return immediately after initiating the operation instead of waiting for completion. Use the returned operation ID to check status later. | | `--async-operation-id` | No | **string** Custom identifier for tracking this async operation. If not provided, a unique ID is generated automatically. | -| `--connection-id` | Yes | **string** The connection ID for private connectivity. | +| `--azure-pe-resource-id` | No | **string** The ARM resource ID of the Azure Private Endpoint (only for Azure private connectivity). Example: `/subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Network/privateEndpoints/{name}`. | +| `--connection-id` | No | **string** The connection ID for private connectivity (AWS VPC endpoint ID or GCP PSC connection ID). | | `--gcp-project-id` | No | **string** The GCP project ID (only for GCP private connectivity). | | `--idempotent` | No | **bool** Succeed silently if the resource already exists or matches the specification. Without this flag, the command errors when no changes are needed. | | `--poll-interval` | No | **duration** Time to wait between status checks when waiting for operation completion. Cannot be greater than 10 minutes. Supports minutes (m) and seconds (s). | diff --git a/docs/cli/command-reference/cloud/custom-role.mdx b/docs/cli/command-reference/cloud/custom-role.mdx index e65c99a810..2011b54332 100644 --- a/docs/cli/command-reference/cloud/custom-role.mdx +++ b/docs/cli/command-reference/cloud/custom-role.mdx @@ -15,10 +15,16 @@ tags: import { ReleaseNoteHeader } from '@site/src/components'; {/* NOTE: This is an auto-generated file. Any edit to this file will be overwritten. -This file is generated from https://github.com/temporalio/cli via cmd/gen-docs */} +This file is generated from the CLI command definitions via cmd/gen-docs. */} +Commands for managing Temporal Cloud custom roles. + +Custom roles enable fine-grained authorization by binding sets of +permissions (resource + actions) to a named role that can be assigned +to users, user groups, and service accounts. + This page provides a reference for the `temporal cloud custom-role` commands. The flags applicable to each subcommand are presented in a table within the heading for the subcommand. Refer to [Global Flags](#global-flags) for flags that you can use with every subcommand. ## apply diff --git a/docs/cli/command-reference/cloud/login.mdx b/docs/cli/command-reference/cloud/login.mdx index 0a47517744..0493eee88a 100644 --- a/docs/cli/command-reference/cloud/login.mdx +++ b/docs/cli/command-reference/cloud/login.mdx @@ -12,15 +12,13 @@ tags: - login --- -import { ReleaseNoteHeader } from "@site/src/components"; +import { ReleaseNoteHeader } from '@site/src/components'; {/* NOTE: This is an auto-generated file. Any edit to this file will be overwritten. -This file is generated from https://github.com/temporalio/cli via cmd/gen-docs */} +This file is generated from the CLI command definitions via cmd/gen-docs. */} -This page provides a reference for the `temporal cloud login` command. - Authenticate with Temporal Cloud using browser-based OAuth login. This command opens your default browser to complete authentication. Once @@ -34,6 +32,8 @@ temporal cloud login For headless environments, use --disable-pop-up and follow the printed URL. +Use the following options to change the behavior of this command. You can also use any of the [global flags](#global-flags) that apply to all subcommands. + | Flag | Required | Description | |------|----------|-------------| | `--audience` | No | **string** OAuth audience parameter for token generation. | @@ -42,3 +42,15 @@ For headless environments, use --disable-pop-up and follow the printed URL. | `--redirect-url` | No | **string** Redirect URL for OAuth authentication flow. | | `--reset` | No | **bool** Clear stored login credentials and configuration, then re-authenticate. Use this if you need to switch accounts or fix authentication issues. | +## Global Flags + +The following options can be used with any command. + +| Flag | Required | Description | Default | +|------|----------|-------------|--------| +| `--api-key` | No | **string** API key for authenticating with Temporal Cloud. Can be used instead of interactive login for automation and CI/CD pipelines. | | +| `--auto-confirm` | No | **bool** Automatically confirm prompts and actions that require user confirmation. Useful for scripting and automation. | | +| `--config-dir` | No | **string** Directory path where CLI configuration files are stored, including authentication tokens and settings. | | +| `--disable-pop-up` | No | **bool** Prevent the CLI from opening a browser window during authentication. Useful for headless environments or when using alternative auth methods. | | +| `--server` | No | **string** Override the Temporal Cloud API server address. Used for connecting to non-production environments. | `saas-api.tmprl.cloud:443` | + diff --git a/docs/cli/command-reference/cloud/logout.mdx b/docs/cli/command-reference/cloud/logout.mdx index 1cd7955eed..f54091a3c0 100644 --- a/docs/cli/command-reference/cloud/logout.mdx +++ b/docs/cli/command-reference/cloud/logout.mdx @@ -12,15 +12,13 @@ tags: - login --- -import { ReleaseNoteHeader } from "@site/src/components"; +import { ReleaseNoteHeader } from '@site/src/components'; {/* NOTE: This is an auto-generated file. Any edit to this file will be overwritten. -This file is generated from https://github.com/temporalio/cli via cmd/gen-docs */} +This file is generated from the CLI command definitions via cmd/gen-docs. */} -This page provides a reference for the `temporal cloud logout` command. - Log out from Temporal Cloud by clearing stored authentication tokens and credentials from the local configuration. @@ -30,7 +28,21 @@ Example: temporal cloud logout ``` +Use the following options to change the behavior of this command. You can also use any of the [global flags](#global-flags) that apply to all subcommands. + | Flag | Required | Description | |------|----------|-------------| | `--domain` | No | **string** Authentication domain for the OAuth provider. | +## Global Flags + +The following options can be used with any command. + +| Flag | Required | Description | Default | +|------|----------|-------------|--------| +| `--api-key` | No | **string** API key for authenticating with Temporal Cloud. Can be used instead of interactive login for automation and CI/CD pipelines. | | +| `--auto-confirm` | No | **bool** Automatically confirm prompts and actions that require user confirmation. Useful for scripting and automation. | | +| `--config-dir` | No | **string** Directory path where CLI configuration files are stored, including authentication tokens and settings. | | +| `--disable-pop-up` | No | **bool** Prevent the CLI from opening a browser window during authentication. Useful for headless environments or when using alternative auth methods. | | +| `--server` | No | **string** Override the Temporal Cloud API server address. Used for connecting to non-production environments. | `saas-api.tmprl.cloud:443` | + diff --git a/docs/cli/command-reference/cloud/namespace.mdx b/docs/cli/command-reference/cloud/namespace.mdx index aec3f224ee..5dd8ae9ac2 100644 --- a/docs/cli/command-reference/cloud/namespace.mdx +++ b/docs/cli/command-reference/cloud/namespace.mdx @@ -11,33 +11,33 @@ tags: - namespaces --- -import { ReleaseNoteHeader } from "@site/src/components"; +import { ReleaseNoteHeader } from '@site/src/components'; {/* NOTE: This is an auto-generated file. Any edit to this file will be overwritten. -This file is generated from https://github.com/temporalio/cli via cmd/gen-docs */} +This file is generated from the CLI command definitions via cmd/gen-docs. */} -This page provides a reference for the `temporal cloud namespace` commands. The flags applicable to each subcommand are presented in a table within the heading for the subcommand. Refer to [Global Flags](#global-flags) for flags that you can use with every subcommand. +Commands for creating, updating, and managing Temporal Cloud namespaces. -## apply +Namespaces provide isolation for workflows and activities. Each namespace +has its own configuration including retention period, region, and access +controls. -Apply a namespace configuration to Temporal Cloud. Creates a new namespace -if it doesn't exist, or updates an existing one to match the specification. +This page provides a reference for the `temporal cloud namespace` commands. The flags applicable to each subcommand are presented in a table within the heading for the subcommand. Refer to [Global Flags](#global-flags) for flags that you can use with every subcommand. -The specification can be provided as inline JSON or loaded from a file -by prefixing the path with '@'. +## api-key -Example with inline JSON: +Commands for managing API key authentication configuration of Temporal Cloud namespaces. -``` -temporal cloud namespace apply --spec '{"name": "namespace-name", "region": "us-west-2", "retention_days": 7}' -``` +### api-key disable -Example with file path: +Disable API key authentication for a Temporal Cloud namespace. + +Example: ``` -temporal cloud namespace apply --spec @namespace-spec.json +temporal cloud namespace api-key disable --namespace my-namespace.my-account ``` Use the following options to change the behavior of this command. You can also use any of the [global flags](#global-flags) that apply to all subcommands. @@ -47,53 +47,20 @@ Use the following options to change the behavior of this command. You can also u | `--api-key` | No | **string** API key for authenticating with Temporal Cloud. Can be used instead of interactive login for automation and CI/CD pipelines. | | `--async` | No | **bool** Return immediately after initiating the operation instead of waiting for completion. Use the returned operation ID to check status later. | | `--async-operation-id` | No | **string** Custom identifier for tracking this async operation. If not provided, a unique ID is generated automatically. | -| `--idempotent` | No | **bool** Succeed silently if the namespace already matches the specification. Without this flag, the command errors when no changes are needed. | +| `--idempotent` | No | **bool** Succeed silently if the resource already exists or matches the specification. Without this flag, the command errors when no changes are needed. | +| `--namespace`, `-n` | Yes | **string** The fully qualified namespace name in the format 'namespace.account' (e.g., 'my-namespace.my-account'). | +| `--poll-interval` | No | **duration** Time to wait between status checks when waiting for operation completion. Cannot be greater than 10 minutes. Supports minutes (m) and seconds (s). | | `--resource-version`, `-v` | No | **string** Resource version for optimistic concurrency control. If not provided, the current version is fetched automatically. | | `--server` | No | **string** Override the Temporal Cloud API server address. Used for connecting to non-production environments. | -| `--spec` | Yes | **string** Namespace configuration in JSON format. Provide inline JSON directly, or use '@path/to/file.json' to load from a file. | -| `--verbose-diff` | No | **bool** Show detailed differences between the current and desired namespace configurations when changes are detected. | - -## capacity -Commands for managing the capacity of Temporal Cloud namespaces. +### api-key enable -Capacity controls whether a namespace runs in on-demand mode or -provisioned mode (with a fixed TRU allocation). - -### capacity get - -Retrieve capacity information for a Temporal Cloud namespace, including -the current mode (on-demand or provisioned), mode options, and recent usage stats. +Enable API key authentication for a Temporal Cloud namespace. Example: ``` -temporal cloud namespace capacity get --namespace my-namespace.my-account -``` - -Use the following options to change the behavior of this command. You can also use any of the [global flags](#global-flags) that apply to all subcommands. - -| Flag | Required | Description | -|------|----------|-------------| -| `--api-key` | No | **string** API key for authenticating with Temporal Cloud. Can be used instead of interactive login for automation and CI/CD pipelines. | -| `--namespace`, `-n` | Yes | **string** The fully qualified namespace name in the format 'namespace.account' (e.g., 'my-namespace.my-account'). | -| `--server` | No | **string** Override the Temporal Cloud API server address. Used for connecting to non-production environments. | - -### capacity update - -Update the capacity of a Temporal Cloud namespace. Choose either on-demand -mode or provisioned mode (with a fixed TRU allocation). - -Example (switch to on-demand): - -``` -temporal cloud namespace capacity update --namespace my-namespace.my-account --capacity-mode on_demand -``` - -Example (switch to provisioned with 4 TRUs): - -``` -temporal cloud namespace capacity update --namespace my-namespace.my-account --capacity-mode provisioned --capacity-value 4 +temporal cloud namespace api-key enable --namespace my-namespace.my-account ``` Use the following options to change the behavior of this command. You can also use any of the [global flags](#global-flags) that apply to all subcommands. @@ -103,36 +70,20 @@ Use the following options to change the behavior of this command. You can also u | `--api-key` | No | **string** API key for authenticating with Temporal Cloud. Can be used instead of interactive login for automation and CI/CD pipelines. | | `--async` | No | **bool** Return immediately after initiating the operation instead of waiting for completion. Use the returned operation ID to check status later. | | `--async-operation-id` | No | **string** Custom identifier for tracking this async operation. If not provided, a unique ID is generated automatically. | -| `--capacity-mode` | Yes | **string-enum** Capacity mode for the namespace. Must be either 'on_demand' or 'provisioned'. Accepted values: on_demand, provisioned. | -| `--capacity-value` | No | **float** The provisioned capacity in Temporal Resource Units (TRUs). Required and must be greater than 0 when --capacity-mode is 'provisioned'. Ignored when --capacity-mode is 'on_demand'. | | `--idempotent` | No | **bool** Succeed silently if the resource already exists or matches the specification. Without this flag, the command errors when no changes are needed. | | `--namespace`, `-n` | Yes | **string** The fully qualified namespace name in the format 'namespace.account' (e.g., 'my-namespace.my-account'). | | `--poll-interval` | No | **duration** Time to wait between status checks when waiting for operation completion. Cannot be greater than 10 minutes. Supports minutes (m) and seconds (s). | | `--resource-version`, `-v` | No | **string** Resource version for optimistic concurrency control. If not provided, the current version is fetched automatically. | | `--server` | No | **string** Override the Temporal Cloud API server address. Used for connecting to non-production environments. | -## cert-ca - -Commands for managing the client CA certificates of Temporal Cloud namespaces. - -### cert-ca create - -Add client CA certificates to a Temporal Cloud namespace from a PEM file -or base64 encoded string. These certificates are used to verify client -connections and enable mTLS authentication. - -Specify either --ca-certificate-file or --ca-certificate, but not both. +### api-key get -Example with file: +Retrieve the current API key authentication configuration for a Temporal Cloud namespace. -``` -temporal cloud namespace cert-ca create --namespace my-namespace.my-account --ca-certificate-file ca-cert.pem -``` - -Example with base64 encoded data: +Example: ``` -temporal cloud namespace cert-ca create --namespace my-namespace.my-account --ca-certificate +temporal cloud namespace api-key get --namespace my-namespace.my-account ``` Use the following options to change the behavior of this command. You can also use any of the [global flags](#global-flags) that apply to all subcommands. @@ -140,34 +91,27 @@ Use the following options to change the behavior of this command. You can also u | Flag | Required | Description | |------|----------|-------------| | `--api-key` | No | **string** API key for authenticating with Temporal Cloud. Can be used instead of interactive login for automation and CI/CD pipelines. | -| `--async` | No | **bool** Return immediately after initiating the operation instead of waiting for completion. Use the returned operation ID to check status later. | -| `--async-operation-id` | No | **string** Custom identifier for tracking this async operation. If not provided, a unique ID is generated automatically. | -| `--ca-certificate` | No | **string** Base64-encoded CA certificate for mTLS authentication. Mutually exclusive with --ca-certificate-file. | -| `--ca-certificate-file` | No | **string** Path to a CA certificate PEM file for mTLS authentication. Mutually exclusive with --ca-certificate. | -| `--idempotent` | No | **bool** Succeed silently if the resource already exists or matches the specification. Without this flag, the command errors when no changes are needed. | | `--namespace`, `-n` | Yes | **string** The fully qualified namespace name in the format 'namespace.account' (e.g., 'my-namespace.my-account'). | -| `--poll-interval` | No | **duration** Time to wait between status checks when waiting for operation completion. Cannot be greater than 10 minutes. Supports minutes (m) and seconds (s). | -| `--resource-version`, `-v` | No | **string** Resource version for optimistic concurrency control. If not provided, the current version is fetched automatically. | | `--server` | No | **string** Override the Temporal Cloud API server address. Used for connecting to non-production environments. | -### cert-ca delete +## apply -Delete client CA certificates from a Temporal Cloud namespace. This operation -requires confirmation and will remove the specified certificates from the -namespace configuration. +Apply a namespace configuration to Temporal Cloud. Creates a new namespace +if it doesn't exist, or updates an existing one to match the specification. -Specify either --ca-certificate-file or --ca-certificate, but not both. +The specification can be provided as inline JSON or loaded from a file +by prefixing the path with '@'. -Example with file: +Example with inline JSON: ``` -temporal cloud namespace cert-ca delete --namespace my-namespace.my-account --ca-certificate-file ca-cert.pem +temporal cloud namespace apply --spec '{"name": "namespace-name", "region": "us-west-2", "retention_days": 7}' ``` -Example with base64 encoded data: +Example with file path: ``` -temporal cloud namespace cert-ca delete --namespace my-namespace.my-account --ca-certificate +temporal cloud namespace apply --spec @namespace-spec.json ``` Use the following options to change the behavior of this command. You can also use any of the [global flags](#global-flags) that apply to all subcommands. @@ -177,23 +121,28 @@ Use the following options to change the behavior of this command. You can also u | `--api-key` | No | **string** API key for authenticating with Temporal Cloud. Can be used instead of interactive login for automation and CI/CD pipelines. | | `--async` | No | **bool** Return immediately after initiating the operation instead of waiting for completion. Use the returned operation ID to check status later. | | `--async-operation-id` | No | **string** Custom identifier for tracking this async operation. If not provided, a unique ID is generated automatically. | -| `--ca-certificate` | No | **string** Base64-encoded CA certificate for mTLS authentication. Mutually exclusive with --ca-certificate-file. | -| `--ca-certificate-file` | No | **string** Path to a CA certificate PEM file for mTLS authentication. Mutually exclusive with --ca-certificate. | -| `--idempotent` | No | **bool** Succeed silently if the resource already exists or matches the specification. Without this flag, the command errors when no changes are needed. | -| `--namespace`, `-n` | Yes | **string** The fully qualified namespace name in the format 'namespace.account' (e.g., 'my-namespace.my-account'). | -| `--poll-interval` | No | **duration** Time to wait between status checks when waiting for operation completion. Cannot be greater than 10 minutes. Supports minutes (m) and seconds (s). | +| `--idempotent` | No | **bool** Succeed silently if the namespace already matches the specification. Without this flag, the command errors when no changes are needed. | | `--resource-version`, `-v` | No | **string** Resource version for optimistic concurrency control. If not provided, the current version is fetched automatically. | | `--server` | No | **string** Override the Temporal Cloud API server address. Used for connecting to non-production environments. | +| `--spec` | Yes | **string** Namespace configuration in JSON format. Provide inline JSON directly, or use '@path/to/file.json' to load from a file. | +| `--verbose-diff` | No | **bool** Show detailed differences between the current and desired namespace configurations when changes are detected. | -### cert-ca list +## capacity -Retrieve the list of client CA certificates configured for a Temporal Cloud -namespace. These certificates are used for client authentication. +Commands for managing the capacity of Temporal Cloud namespaces. + +Capacity controls whether a namespace runs in on-demand mode or +provisioned mode (with a fixed TRU allocation). + +### capacity get + +Retrieve capacity information for a Temporal Cloud namespace, including +the current mode (on-demand or provisioned), mode options, and recent usage stats. Example: ``` -temporal cloud namespace cert-ca list --namespace my-namespace.my-account +temporal cloud namespace capacity get --namespace my-namespace.my-account ``` Use the following options to change the behavior of this command. You can also use any of the [global flags](#global-flags) that apply to all subcommands. @@ -204,39 +153,22 @@ Use the following options to change the behavior of this command. You can also u | `--namespace`, `-n` | Yes | **string** The fully qualified namespace name in the format 'namespace.account' (e.g., 'my-namespace.my-account'). | | `--server` | No | **string** Override the Temporal Cloud API server address. Used for connecting to non-production environments. | -## cert-filter - -Commands for managing certificate filters for Temporal Cloud namespaces. -Certificate filters restrict mTLS connections to client certificates with -specific distinguished name properties. - -### cert-filter create +### capacity update -Add new certificate filters to a Temporal Cloud namespace. Certificate -filters restrict mTLS connections to client certificates whose distinguished -name properties match at least one of the filters. +Update the capacity of a Temporal Cloud namespace. Choose either on-demand +mode or provisioned mode (with a fixed TRU allocation). -Use the following options to change the behavior of this command. You can also use any of the [global flags](#global-flags) that apply to all subcommands. +Example (switch to on-demand): -| Flag | Required | Description | -|------|----------|-------------| -| `--api-key` | No | **string** API key for authenticating with Temporal Cloud. Can be used instead of interactive login for automation and CI/CD pipelines. | -| `--async` | No | **bool** Return immediately after initiating the operation instead of waiting for completion. Use the returned operation ID to check status later. | -| `--async-operation-id` | No | **string** Custom identifier for tracking this async operation. If not provided, a unique ID is generated automatically. | -| `--common-name` | No | **string** The common name (CN) field from the certificate's distinguished name. | -| `--idempotent` | No | **bool** Succeed silently if the resource already exists or matches the specification. Without this flag, the command errors when no changes are needed. | -| `--namespace`, `-n` | Yes | **string** The fully qualified namespace name in the format 'namespace.account' (e.g., 'my-namespace.my-account'). | -| `--organization` | No | **string** The organization (O) field from the certificate's distinguished name. | -| `--organizational-unit` | No | **string** The organizational unit (OU) field from the certificate's distinguished name. | -| `--poll-interval` | No | **duration** Time to wait between status checks when waiting for operation completion. Cannot be greater than 10 minutes. Supports minutes (m) and seconds (s). | -| `--resource-version`, `-v` | No | **string** Resource version for optimistic concurrency control. If not provided, the current version is fetched automatically. | -| `--server` | No | **string** Override the Temporal Cloud API server address. Used for connecting to non-production environments. | -| `--subject-alternative-name` | No | **string** The subject alternative name (SAN) from the certificate. | +``` +temporal cloud namespace capacity update --namespace my-namespace.my-account --capacity-mode on_demand +``` -### cert-filter delete +Example (switch to provisioned with 4 TRUs): -Delete certificate filters from a Temporal Cloud namespace. Filters are -matched by exact field equality. +``` +temporal cloud namespace capacity update --namespace my-namespace.my-account --capacity-mode provisioned --capacity-value 4 +``` Use the following options to change the behavior of this command. You can also use any of the [global flags](#global-flags) that apply to all subcommands. @@ -245,27 +177,13 @@ Use the following options to change the behavior of this command. You can also u | `--api-key` | No | **string** API key for authenticating with Temporal Cloud. Can be used instead of interactive login for automation and CI/CD pipelines. | | `--async` | No | **bool** Return immediately after initiating the operation instead of waiting for completion. Use the returned operation ID to check status later. | | `--async-operation-id` | No | **string** Custom identifier for tracking this async operation. If not provided, a unique ID is generated automatically. | -| `--common-name` | No | **string** The common name (CN) field from the certificate's distinguished name. | +| `--capacity-mode` | Yes | **string-enum** Capacity mode for the namespace. Must be either 'on_demand' or 'provisioned'. Accepted values: on_demand, provisioned. | +| `--capacity-value` | No | **float** The provisioned capacity in Temporal Resource Units (TRUs). Required and must be greater than 0 when --capacity-mode is 'provisioned'. Ignored when --capacity-mode is 'on_demand'. | | `--idempotent` | No | **bool** Succeed silently if the resource already exists or matches the specification. Without this flag, the command errors when no changes are needed. | | `--namespace`, `-n` | Yes | **string** The fully qualified namespace name in the format 'namespace.account' (e.g., 'my-namespace.my-account'). | -| `--organization` | No | **string** The organization (O) field from the certificate's distinguished name. | -| `--organizational-unit` | No | **string** The organizational unit (OU) field from the certificate's distinguished name. | | `--poll-interval` | No | **duration** Time to wait between status checks when waiting for operation completion. Cannot be greater than 10 minutes. Supports minutes (m) and seconds (s). | | `--resource-version`, `-v` | No | **string** Resource version for optimistic concurrency control. If not provided, the current version is fetched automatically. | | `--server` | No | **string** Override the Temporal Cloud API server address. Used for connecting to non-production environments. | -| `--subject-alternative-name` | No | **string** The subject alternative name (SAN) from the certificate. | - -### cert-filter list - -List all certificate filters configured for a Temporal Cloud namespace. - -Use the following options to change the behavior of this command. You can also use any of the [global flags](#global-flags) that apply to all subcommands. - -| Flag | Required | Description | -|------|----------|-------------| -| `--api-key` | No | **string** API key for authenticating with Temporal Cloud. Can be used instead of interactive login for automation and CI/CD pipelines. | -| `--namespace`, `-n` | Yes | **string** The fully qualified namespace name in the format 'namespace.account' (e.g., 'my-namespace.my-account'). | -| `--server` | No | **string** Override the Temporal Cloud API server address. Used for connecting to non-production environments. | ## codec @@ -447,7 +365,9 @@ Use the following options to change the behavior of this command. You can also u | `--codec-pass-access-token` | No | **bool** Pass the user access token to the codec server endpoint. | | `--connection-rule-id` | No | **string[]** Private connectivity rule ID. Repeat to specify multiple. | | `--enable-delete-protection` | No | **bool** Prevent accidental deletion of this namespace. | +| `--enable-task-queue-fairness` | No | **bool** Enable task queue fairness for the namespace. | | `--idempotent` | No | **bool** Succeed silently if the resource already exists or matches the specification. Without this flag, the command errors when no changes are needed. | +| `--mtls-auth-enabled` | No | **bool** Enable mTLS authentication for the namespace. | | `--name`, `-n` | Yes | **string** The name for the new namespace (becomes part of the namespace ID). | | `--poll-interval` | No | **duration** Time to wait between status checks when waiting for operation completion. Cannot be greater than 10 minutes. Supports minutes (m) and seconds (s). | | `--region` | Yes | **string[]** Cloud region where the namespace will be hosted. Repeat to specify multiple regions for High Availability (e.g. --region aws-us-east-1 --region aws-us-west-2). | @@ -801,6 +721,52 @@ Use the following options to change the behavior of this command. You can also u | `--server` | No | **string** Override the Temporal Cloud API server address. Used for connecting to non-production environments. | | `--sink-name` | Yes | **string** The name of the export sink. | +## fairness + +Commands for managing task queue fairness configuration of Temporal Cloud namespaces. + +### fairness get + +Retrieve the current task queue fairness configuration for a Temporal Cloud namespace. + +Example: + +``` +temporal cloud namespace fairness get --namespace my-namespace.my-account +``` + +Use the following options to change the behavior of this command. You can also use any of the [global flags](#global-flags) that apply to all subcommands. + +| Flag | Required | Description | +|------|----------|-------------| +| `--api-key` | No | **string** API key for authenticating with Temporal Cloud. Can be used instead of interactive login for automation and CI/CD pipelines. | +| `--namespace`, `-n` | Yes | **string** The fully qualified namespace name in the format 'namespace.account' (e.g., 'my-namespace.my-account'). | +| `--server` | No | **string** Override the Temporal Cloud API server address. Used for connecting to non-production environments. | + +### fairness set + +Set the task queue fairness configuration for a Temporal Cloud namespace. + +Example: + +``` +temporal cloud namespace fairness set --namespace my-namespace.my-account --enable-task-queue-fairness=true +``` + +Use the following options to change the behavior of this command. You can also use any of the [global flags](#global-flags) that apply to all subcommands. + +| Flag | Required | Description | +|------|----------|-------------| +| `--api-key` | No | **string** API key for authenticating with Temporal Cloud. Can be used instead of interactive login for automation and CI/CD pipelines. | +| `--async` | No | **bool** Return immediately after initiating the operation instead of waiting for completion. Use the returned operation ID to check status later. | +| `--async-operation-id` | No | **string** Custom identifier for tracking this async operation. If not provided, a unique ID is generated automatically. | +| `--enable-task-queue-fairness` | Yes | **bool** Enable or disable task queue fairness for the namespace. | +| `--idempotent` | No | **bool** Succeed silently if the resource already exists or matches the specification. Without this flag, the command errors when no changes are needed. | +| `--namespace`, `-n` | Yes | **string** The fully qualified namespace name in the format 'namespace.account' (e.g., 'my-namespace.my-account'). | +| `--poll-interval` | No | **duration** Time to wait between status checks when waiting for operation completion. Cannot be greater than 10 minutes. Supports minutes (m) and seconds (s). | +| `--resource-version`, `-v` | No | **string** Resource version for optimistic concurrency control. If not provided, the current version is fetched automatically. | +| `--server` | No | **string** Override the Temporal Cloud API server address. Used for connecting to non-production environments. | + ## get Retrieve the configuration and status of a Temporal Cloud namespace. @@ -947,13 +913,16 @@ Use the following options to change the behavior of this command. You can also u ### ha update Update the High Availability configuration for a Temporal Cloud namespace. -Use --disable-auto-failover to toggle Temporal-managed automatic failover. -Use --disable-passive-poller-forwarding to toggle passive poller forwarding. +Use --auto-failover to enable or disable Temporal-managed automatic failover. +Use --passive-poller-forwarding to enable or disable passive poller forwarding. Example: ``` -temporal cloud namespace ha update --namespace my-namespace.my-account --disable-auto-failover true --disable-passive-poller-forwarding false +temporal cloud namespace ha update \ + --namespace my-namespace.my-account \ + --auto-failover enabled \ + --passive-poller-forwarding disabled ``` Use the following options to change the behavior of this command. You can also use any of the [global flags](#global-flags) that apply to all subcommands. @@ -963,10 +932,10 @@ Use the following options to change the behavior of this command. You can also u | `--api-key` | No | **string** API key for authenticating with Temporal Cloud. Can be used instead of interactive login for automation and CI/CD pipelines. | | `--async` | No | **bool** Return immediately after initiating the operation instead of waiting for completion. Use the returned operation ID to check status later. | | `--async-operation-id` | No | **string** Custom identifier for tracking this async operation. If not provided, a unique ID is generated automatically. | -| `--disable-auto-failover` | No | **bool** Set to true to disable Temporal-managed automatic failover for the namespace. Set to false to re-enable automatic failover. | -| `--disable-passive-poller-forwarding` | No | **bool** Set to true to disable passive poller forwarding for the namespace. Set to false to re-enable passive poller forwarding. | +| `--auto-failover` | No | **string-enum** Enable or disable Temporal-managed automatic failover for the namespace. Accepted values: enabled, disabled. | | `--idempotent` | No | **bool** Succeed silently if the resource already exists or matches the specification. Without this flag, the command errors when no changes are needed. | | `--namespace`, `-n` | Yes | **string** The fully qualified namespace name in the format 'namespace.account' (e.g., 'my-namespace.my-account'). | +| `--passive-poller-forwarding` | No | **string-enum** Enable or disable passive poller forwarding for the namespace. Accepted values: enabled, disabled. | | `--poll-interval` | No | **duration** Time to wait between status checks when waiting for operation completion. Cannot be greater than 10 minutes. Supports minutes (m) and seconds (s). | | `--resource-version`, `-v` | No | **string** Resource version for optimistic concurrency control. If not provided, the current version is fetched automatically. | | `--server` | No | **string** Override the Temporal Cloud API server address. Used for connecting to non-production environments. | @@ -1043,6 +1012,230 @@ Use the following options to change the behavior of this command. You can also u | `--page-token` | No | **string** Token for retrieving the next page of results in a paginated list. | | `--server` | No | **string** Override the Temporal Cloud API server address. Used for connecting to non-production environments. | +## mtls + +Commands for managing mTLS authentication configuration of Temporal Cloud namespaces. + +### mtls cert-ca + +Commands for managing the client CA certificates of Temporal Cloud namespaces. + +#### mtls cert-ca create + +Add client CA certificates to a Temporal Cloud namespace from a PEM file +or base64 encoded string. These certificates are used to verify client +connections and enable mTLS authentication. + +Specify either --ca-certificate-file or --ca-certificate, but not both. + +Example with file: + +``` +temporal cloud namespace mtls cert-ca create --namespace my-namespace.my-account --ca-certificate-file ca-cert.pem +``` + +Example with base64 encoded data: + +``` +temporal cloud namespace mtls cert-ca create --namespace my-namespace.my-account --ca-certificate +``` + +Use the following options to change the behavior of this command. You can also use any of the [global flags](#global-flags) that apply to all subcommands. + +| Flag | Required | Description | +|------|----------|-------------| +| `--api-key` | No | **string** API key for authenticating with Temporal Cloud. Can be used instead of interactive login for automation and CI/CD pipelines. | +| `--async` | No | **bool** Return immediately after initiating the operation instead of waiting for completion. Use the returned operation ID to check status later. | +| `--async-operation-id` | No | **string** Custom identifier for tracking this async operation. If not provided, a unique ID is generated automatically. | +| `--ca-certificate` | No | **string** Base64-encoded CA certificate for mTLS authentication. Mutually exclusive with --ca-certificate-file. | +| `--ca-certificate-file` | No | **string** Path to a CA certificate PEM file for mTLS authentication. Mutually exclusive with --ca-certificate. | +| `--idempotent` | No | **bool** Succeed silently if the resource already exists or matches the specification. Without this flag, the command errors when no changes are needed. | +| `--namespace`, `-n` | Yes | **string** The fully qualified namespace name in the format 'namespace.account' (e.g., 'my-namespace.my-account'). | +| `--poll-interval` | No | **duration** Time to wait between status checks when waiting for operation completion. Cannot be greater than 10 minutes. Supports minutes (m) and seconds (s). | +| `--resource-version`, `-v` | No | **string** Resource version for optimistic concurrency control. If not provided, the current version is fetched automatically. | +| `--server` | No | **string** Override the Temporal Cloud API server address. Used for connecting to non-production environments. | + +#### mtls cert-ca delete + +Delete client CA certificates from a Temporal Cloud namespace. This operation +requires confirmation and will remove the specified certificates from the +namespace configuration. + +Specify either --ca-certificate-file or --ca-certificate, but not both. + +Example with file: + +``` +temporal cloud namespace mtls cert-ca delete --namespace my-namespace.my-account --ca-certificate-file ca-cert.pem +``` + +Example with base64 encoded data: + +``` +temporal cloud namespace mtls cert-ca delete --namespace my-namespace.my-account --ca-certificate +``` + +Use the following options to change the behavior of this command. You can also use any of the [global flags](#global-flags) that apply to all subcommands. + +| Flag | Required | Description | +|------|----------|-------------| +| `--api-key` | No | **string** API key for authenticating with Temporal Cloud. Can be used instead of interactive login for automation and CI/CD pipelines. | +| `--async` | No | **bool** Return immediately after initiating the operation instead of waiting for completion. Use the returned operation ID to check status later. | +| `--async-operation-id` | No | **string** Custom identifier for tracking this async operation. If not provided, a unique ID is generated automatically. | +| `--ca-certificate` | No | **string** Base64-encoded CA certificate for mTLS authentication. Mutually exclusive with --ca-certificate-file. | +| `--ca-certificate-file` | No | **string** Path to a CA certificate PEM file for mTLS authentication. Mutually exclusive with --ca-certificate. | +| `--idempotent` | No | **bool** Succeed silently if the resource already exists or matches the specification. Without this flag, the command errors when no changes are needed. | +| `--namespace`, `-n` | Yes | **string** The fully qualified namespace name in the format 'namespace.account' (e.g., 'my-namespace.my-account'). | +| `--poll-interval` | No | **duration** Time to wait between status checks when waiting for operation completion. Cannot be greater than 10 minutes. Supports minutes (m) and seconds (s). | +| `--resource-version`, `-v` | No | **string** Resource version for optimistic concurrency control. If not provided, the current version is fetched automatically. | +| `--server` | No | **string** Override the Temporal Cloud API server address. Used for connecting to non-production environments. | + +#### mtls cert-ca list + +Retrieve the list of client CA certificates configured for a Temporal Cloud +namespace. These certificates are used for client authentication. + +Example: + +``` +temporal cloud namespace mtls cert-ca list --namespace my-namespace.my-account +``` + +Use the following options to change the behavior of this command. You can also use any of the [global flags](#global-flags) that apply to all subcommands. + +| Flag | Required | Description | +|------|----------|-------------| +| `--api-key` | No | **string** API key for authenticating with Temporal Cloud. Can be used instead of interactive login for automation and CI/CD pipelines. | +| `--namespace`, `-n` | Yes | **string** The fully qualified namespace name in the format 'namespace.account' (e.g., 'my-namespace.my-account'). | +| `--server` | No | **string** Override the Temporal Cloud API server address. Used for connecting to non-production environments. | + +### mtls cert-filter + +Commands for managing certificate filters for Temporal Cloud namespaces. +Certificate filters restrict mTLS connections to client certificates with +specific distinguished name properties. + +#### mtls cert-filter create + +Add new certificate filters to a Temporal Cloud namespace. Certificate +filters restrict mTLS connections to client certificates whose distinguished +name properties match at least one of the filters. + +Use the following options to change the behavior of this command. You can also use any of the [global flags](#global-flags) that apply to all subcommands. + +| Flag | Required | Description | +|------|----------|-------------| +| `--api-key` | No | **string** API key for authenticating with Temporal Cloud. Can be used instead of interactive login for automation and CI/CD pipelines. | +| `--async` | No | **bool** Return immediately after initiating the operation instead of waiting for completion. Use the returned operation ID to check status later. | +| `--async-operation-id` | No | **string** Custom identifier for tracking this async operation. If not provided, a unique ID is generated automatically. | +| `--common-name` | No | **string** The common name (CN) field from the certificate's distinguished name. | +| `--idempotent` | No | **bool** Succeed silently if the resource already exists or matches the specification. Without this flag, the command errors when no changes are needed. | +| `--namespace`, `-n` | Yes | **string** The fully qualified namespace name in the format 'namespace.account' (e.g., 'my-namespace.my-account'). | +| `--organization` | No | **string** The organization (O) field from the certificate's distinguished name. | +| `--organizational-unit` | No | **string** The organizational unit (OU) field from the certificate's distinguished name. | +| `--poll-interval` | No | **duration** Time to wait between status checks when waiting for operation completion. Cannot be greater than 10 minutes. Supports minutes (m) and seconds (s). | +| `--resource-version`, `-v` | No | **string** Resource version for optimistic concurrency control. If not provided, the current version is fetched automatically. | +| `--server` | No | **string** Override the Temporal Cloud API server address. Used for connecting to non-production environments. | +| `--subject-alternative-name` | No | **string** The subject alternative name (SAN) from the certificate. | + +#### mtls cert-filter delete + +Delete certificate filters from a Temporal Cloud namespace. Filters are +matched by exact field equality. + +Use the following options to change the behavior of this command. You can also use any of the [global flags](#global-flags) that apply to all subcommands. + +| Flag | Required | Description | +|------|----------|-------------| +| `--api-key` | No | **string** API key for authenticating with Temporal Cloud. Can be used instead of interactive login for automation and CI/CD pipelines. | +| `--async` | No | **bool** Return immediately after initiating the operation instead of waiting for completion. Use the returned operation ID to check status later. | +| `--async-operation-id` | No | **string** Custom identifier for tracking this async operation. If not provided, a unique ID is generated automatically. | +| `--common-name` | No | **string** The common name (CN) field from the certificate's distinguished name. | +| `--idempotent` | No | **bool** Succeed silently if the resource already exists or matches the specification. Without this flag, the command errors when no changes are needed. | +| `--namespace`, `-n` | Yes | **string** The fully qualified namespace name in the format 'namespace.account' (e.g., 'my-namespace.my-account'). | +| `--organization` | No | **string** The organization (O) field from the certificate's distinguished name. | +| `--organizational-unit` | No | **string** The organizational unit (OU) field from the certificate's distinguished name. | +| `--poll-interval` | No | **duration** Time to wait between status checks when waiting for operation completion. Cannot be greater than 10 minutes. Supports minutes (m) and seconds (s). | +| `--resource-version`, `-v` | No | **string** Resource version for optimistic concurrency control. If not provided, the current version is fetched automatically. | +| `--server` | No | **string** Override the Temporal Cloud API server address. Used for connecting to non-production environments. | +| `--subject-alternative-name` | No | **string** The subject alternative name (SAN) from the certificate. | + +#### mtls cert-filter list + +List all certificate filters configured for a Temporal Cloud namespace. + +Use the following options to change the behavior of this command. You can also use any of the [global flags](#global-flags) that apply to all subcommands. + +| Flag | Required | Description | +|------|----------|-------------| +| `--api-key` | No | **string** API key for authenticating with Temporal Cloud. Can be used instead of interactive login for automation and CI/CD pipelines. | +| `--namespace`, `-n` | Yes | **string** The fully qualified namespace name in the format 'namespace.account' (e.g., 'my-namespace.my-account'). | +| `--server` | No | **string** Override the Temporal Cloud API server address. Used for connecting to non-production environments. | + +### mtls disable + +Disable mTLS authentication for a Temporal Cloud namespace. + +Example: + +``` +temporal cloud namespace mtls disable --namespace my-namespace.my-account +``` + +Use the following options to change the behavior of this command. You can also use any of the [global flags](#global-flags) that apply to all subcommands. + +| Flag | Required | Description | +|------|----------|-------------| +| `--api-key` | No | **string** API key for authenticating with Temporal Cloud. Can be used instead of interactive login for automation and CI/CD pipelines. | +| `--async` | No | **bool** Return immediately after initiating the operation instead of waiting for completion. Use the returned operation ID to check status later. | +| `--async-operation-id` | No | **string** Custom identifier for tracking this async operation. If not provided, a unique ID is generated automatically. | +| `--idempotent` | No | **bool** Succeed silently if the resource already exists or matches the specification. Without this flag, the command errors when no changes are needed. | +| `--namespace`, `-n` | Yes | **string** The fully qualified namespace name in the format 'namespace.account' (e.g., 'my-namespace.my-account'). | +| `--poll-interval` | No | **duration** Time to wait between status checks when waiting for operation completion. Cannot be greater than 10 minutes. Supports minutes (m) and seconds (s). | +| `--resource-version`, `-v` | No | **string** Resource version for optimistic concurrency control. If not provided, the current version is fetched automatically. | +| `--server` | No | **string** Override the Temporal Cloud API server address. Used for connecting to non-production environments. | + +### mtls enable + +Enable mTLS authentication for a Temporal Cloud namespace. + +Example: + +``` +temporal cloud namespace mtls enable --namespace my-namespace.my-account +``` + +Use the following options to change the behavior of this command. You can also use any of the [global flags](#global-flags) that apply to all subcommands. + +| Flag | Required | Description | +|------|----------|-------------| +| `--api-key` | No | **string** API key for authenticating with Temporal Cloud. Can be used instead of interactive login for automation and CI/CD pipelines. | +| `--async` | No | **bool** Return immediately after initiating the operation instead of waiting for completion. Use the returned operation ID to check status later. | +| `--async-operation-id` | No | **string** Custom identifier for tracking this async operation. If not provided, a unique ID is generated automatically. | +| `--idempotent` | No | **bool** Succeed silently if the resource already exists or matches the specification. Without this flag, the command errors when no changes are needed. | +| `--namespace`, `-n` | Yes | **string** The fully qualified namespace name in the format 'namespace.account' (e.g., 'my-namespace.my-account'). | +| `--poll-interval` | No | **duration** Time to wait between status checks when waiting for operation completion. Cannot be greater than 10 minutes. Supports minutes (m) and seconds (s). | +| `--resource-version`, `-v` | No | **string** Resource version for optimistic concurrency control. If not provided, the current version is fetched automatically. | +| `--server` | No | **string** Override the Temporal Cloud API server address. Used for connecting to non-production environments. | + +### mtls get + +Retrieve the current mTLS authentication configuration for a Temporal Cloud namespace. + +Example: + +``` +temporal cloud namespace mtls get --namespace my-namespace.my-account +``` + +Use the following options to change the behavior of this command. You can also use any of the [global flags](#global-flags) that apply to all subcommands. + +| Flag | Required | Description | +|------|----------|-------------| +| `--api-key` | No | **string** API key for authenticating with Temporal Cloud. Can be used instead of interactive login for automation and CI/CD pipelines. | +| `--namespace`, `-n` | Yes | **string** The fully qualified namespace name in the format 'namespace.account' (e.g., 'my-namespace.my-account'). | +| `--server` | No | **string** Override the Temporal Cloud API server address. Used for connecting to non-production environments. | + ## retention Commands for managing data retention settings of Temporal Cloud namespaces. diff --git a/docs/cli/command-reference/cloud/nexus.mdx b/docs/cli/command-reference/cloud/nexus.mdx index ade31a1d18..a9021288e0 100644 --- a/docs/cli/command-reference/cloud/nexus.mdx +++ b/docs/cli/command-reference/cloud/nexus.mdx @@ -10,13 +10,15 @@ tags: - nexus --- -import { ReleaseNoteHeader } from "@site/src/components"; +import { ReleaseNoteHeader } from '@site/src/components'; {/* NOTE: This is an auto-generated file. Any edit to this file will be overwritten. -This file is generated from https://github.com/temporalio/cli via cmd/gen-docs */} +This file is generated from the CLI command definitions via cmd/gen-docs. */} +Commands for managing Nexus Operations in Temporal Cloud. + This page provides a reference for the `temporal cloud nexus` commands. The flags applicable to each subcommand are presented in a table within the heading for the subcommand. Refer to [Global Flags](#global-flags) for flags that you can use with every subcommand. ## endpoint @@ -129,6 +131,7 @@ Use the following options to change the behavior of this command. You can also u ### endpoint delete Delete a Nexus Endpoint on the Cloud Account. +Specify either `--name` or `--id` (exactly one is required). Use the following options to change the behavior of this command. You can also use any of the [global flags](#global-flags) that apply to all subcommands. @@ -137,8 +140,9 @@ Use the following options to change the behavior of this command. You can also u | `--api-key` | No | **string** API key for authenticating with Temporal Cloud. Can be used instead of interactive login for automation and CI/CD pipelines. | | `--async` | No | **bool** Return immediately after initiating the operation instead of waiting for completion. Use the returned operation ID to check status later. | | `--async-operation-id` | No | **string** Custom identifier for tracking this async operation. If not provided, a unique ID is generated automatically. | +| `--id` | No | **string** The ID of the Nexus Endpoint to delete. | | `--idempotent` | No | **bool** Succeed silently if the resource already exists or matches the specification. Without this flag, the command errors when no changes are needed. | -| `--name` | Yes | **string** The name of the Nexus Endpoint to delete. | +| `--name` | No | **string** The name of the Nexus Endpoint to delete. | | `--poll-interval` | No | **duration** Time to wait between status checks when waiting for operation completion. Cannot be greater than 10 minutes. Supports minutes (m) and seconds (s). | | `--resource-version`, `-v` | No | **string** Resource version for optimistic concurrency control. If not provided, the current version is fetched automatically. | | `--server` | No | **string** Override the Temporal Cloud API server address. Used for connecting to non-production environments. | @@ -174,6 +178,7 @@ Use the following options to change the behavior of this command. You can also u Update an existing Nexus Endpoint on the Cloud Account. An endpoint name is used in workflow code to invoke Nexus operations. +Specify either `--name` or `--id` to identify the endpoint (exactly one is required). The endpoint is patched leaving any existing fields for which flags are not provided as they were. @@ -193,8 +198,9 @@ Use the following options to change the behavior of this command. You can also u | `--async-operation-id` | No | **string** Custom identifier for tracking this async operation. If not provided, a unique ID is generated automatically. | | `--description` | No | **string** An optional endpoint description in markdown format. | | `--description-file` | No | **string** Path to a file containing an endpoint description in markdown format. Mutually exclusive with --description. | +| `--id` | No | **string** The ID of the Nexus Endpoint to update. | | `--idempotent` | No | **bool** Succeed silently if the resource already exists or matches the specification. Without this flag, the command errors when no changes are needed. | -| `--name` | Yes | **string** The name of the Nexus Endpoint to update. | +| `--name` | No | **string** The name of the Nexus Endpoint to update. | | `--poll-interval` | No | **duration** Time to wait between status checks when waiting for operation completion. Cannot be greater than 10 minutes. Supports minutes (m) and seconds (s). | | `--resource-version`, `-v` | No | **string** Resource version for optimistic concurrency control. If not provided, the current version is fetched automatically. | | `--server` | No | **string** Override the Temporal Cloud API server address. Used for connecting to non-production environments. | diff --git a/docs/cli/command-reference/cloud/region.mdx b/docs/cli/command-reference/cloud/region.mdx index d6d7b3738a..0714f20148 100644 --- a/docs/cli/command-reference/cloud/region.mdx +++ b/docs/cli/command-reference/cloud/region.mdx @@ -11,13 +11,15 @@ tags: - region --- -import { ReleaseNoteHeader } from "@site/src/components"; +import { ReleaseNoteHeader } from '@site/src/components'; {/* NOTE: This is an auto-generated file. Any edit to this file will be overwritten. -This file is generated from https://github.com/temporalio/cli via cmd/gen-docs */} +This file is generated from the CLI command definitions via cmd/gen-docs. */} +Commands for listing Temporal Cloud regions. + This page provides a reference for the `temporal cloud region` commands. The flags applicable to each subcommand are presented in a table within the heading for the subcommand. Refer to [Global Flags](#global-flags) for flags that you can use with every subcommand. ## get diff --git a/docs/cli/command-reference/cloud/service-account.mdx b/docs/cli/command-reference/cloud/service-account.mdx index 9a6800533c..5df6e1d861 100644 --- a/docs/cli/command-reference/cloud/service-account.mdx +++ b/docs/cli/command-reference/cloud/service-account.mdx @@ -11,13 +11,15 @@ tags: - service-accounts --- -import { ReleaseNoteHeader } from "@site/src/components"; +import { ReleaseNoteHeader } from '@site/src/components'; {/* NOTE: This is an auto-generated file. Any edit to this file will be overwritten. -This file is generated from https://github.com/temporalio/cli via cmd/gen-docs */} +This file is generated from the CLI command definitions via cmd/gen-docs. */} +Commands for managing Temporal Cloud service accounts. + This page provides a reference for the `temporal cloud service-account` commands. The flags applicable to each subcommand are presented in a table within the heading for the subcommand. Refer to [Global Flags](#global-flags) for flags that you can use with every subcommand. ## create diff --git a/docs/cli/command-reference/cloud/user-group.mdx b/docs/cli/command-reference/cloud/user-group.mdx index abbf9d7d8c..3edf0dfd88 100644 --- a/docs/cli/command-reference/cloud/user-group.mdx +++ b/docs/cli/command-reference/cloud/user-group.mdx @@ -11,13 +11,15 @@ tags: - user-groups --- -import { ReleaseNoteHeader } from "@site/src/components"; +import { ReleaseNoteHeader } from '@site/src/components'; {/* NOTE: This is an auto-generated file. Any edit to this file will be overwritten. -This file is generated from https://github.com/temporalio/cli via cmd/gen-docs */} +This file is generated from the CLI command definitions via cmd/gen-docs. */} +Commands for managing Temporal Cloud user groups. + This page provides a reference for the `temporal cloud user-group` commands. The flags applicable to each subcommand are presented in a table within the heading for the subcommand. Refer to [Global Flags](#global-flags) for flags that you can use with every subcommand. ## apply diff --git a/docs/cli/command-reference/cloud/user.mdx b/docs/cli/command-reference/cloud/user.mdx index e9cd2d4004..450a3f95b0 100644 --- a/docs/cli/command-reference/cloud/user.mdx +++ b/docs/cli/command-reference/cloud/user.mdx @@ -11,13 +11,15 @@ tags: - users --- -import { ReleaseNoteHeader } from "@site/src/components"; +import { ReleaseNoteHeader } from '@site/src/components'; {/* NOTE: This is an auto-generated file. Any edit to this file will be overwritten. -This file is generated from https://github.com/temporalio/cli via cmd/gen-docs */} +This file is generated from the CLI command definitions via cmd/gen-docs. */} +Commands for managing Temporal Cloud users. + This page provides a reference for the `temporal cloud user` commands. The flags applicable to each subcommand are presented in a table within the heading for the subcommand. Refer to [Global Flags](#global-flags) for flags that you can use with every subcommand. ## apply diff --git a/docs/cli/command-reference/cloud/whoami.mdx b/docs/cli/command-reference/cloud/whoami.mdx index 7e61424f1d..35f4e425cd 100644 --- a/docs/cli/command-reference/cloud/whoami.mdx +++ b/docs/cli/command-reference/cloud/whoami.mdx @@ -12,15 +12,13 @@ tags: - authentication --- -import { ReleaseNoteHeader } from "@site/src/components"; +import { ReleaseNoteHeader } from '@site/src/components'; {/* NOTE: This is an auto-generated file. Any edit to this file will be overwritten. -This file is generated from https://github.com/temporalio/cli via cmd/gen-docs */} +This file is generated from the CLI command definitions via cmd/gen-docs. */} -This page provides a reference for the `temporal cloud whoami` command. - Display information about the currently authenticated identity. Shows whether you are authenticated as a user or service account, along @@ -32,8 +30,22 @@ Example: temporal cloud whoami ``` +Use the following options to change the behavior of this command. You can also use any of the [global flags](#global-flags) that apply to all subcommands. + | Flag | Required | Description | |------|----------|-------------| | `--api-key` | No | **string** API key for authenticating with Temporal Cloud. Can be used instead of interactive login for automation and CI/CD pipelines. | | `--server` | No | **string** Override the Temporal Cloud API server address. Used for connecting to non-production environments. | +## Global Flags + +The following options can be used with any command. + +| Flag | Required | Description | Default | +|------|----------|-------------|--------| +| `--api-key` | No | **string** API key for authenticating with Temporal Cloud. Can be used instead of interactive login for automation and CI/CD pipelines. | | +| `--auto-confirm` | No | **bool** Automatically confirm prompts and actions that require user confirmation. Useful for scripting and automation. | | +| `--config-dir` | No | **string** Directory path where CLI configuration files are stored, including authentication tokens and settings. | | +| `--disable-pop-up` | No | **bool** Prevent the CLI from opening a browser window during authentication. Useful for headless environments or when using alternative auth methods. | | +| `--server` | No | **string** Override the Temporal Cloud API server address. Used for connecting to non-production environments. | `saas-api.tmprl.cloud:443` | + diff --git a/docs/cli/command-reference/config.mdx b/docs/cli/command-reference/config.mdx index bf2238fc88..86d4e76ef4 100644 --- a/docs/cli/command-reference/config.mdx +++ b/docs/cli/command-reference/config.mdx @@ -20,7 +20,7 @@ tags: --- {/* NOTE: This is an auto-generated file. Any edit to this file will be overwritten. -This file is generated from https://github.com/temporalio/cli/blob/main/internal/commandsgen/commands.yml via internal/cmd/gen-docs */} +This file is generated from the CLI command definitions via cmd/gen-docs. */} This page provides a reference for the `temporal` CLI `config` command. The flags applicable to each subcommand are presented in a table within the heading for the subcommand. Refer to [Global Flags](#global-flags) for flags that you can use with every subcommand. @@ -88,7 +88,7 @@ Assign a value to a property and store it in the config file: ``` temporal config set \ --prop address \ - --value your-namespace.a1b2c.tmprl.cloud:7233 + --value us-west-2.aws.api.temporal.io:7233 ``` Use the following options to change the behavior of this command. You can also use any of the [global flags](#global-flags) that apply to all subcommands. diff --git a/docs/cli/command-reference/env.mdx b/docs/cli/command-reference/env.mdx index 7f7b5814b3..d091904236 100644 --- a/docs/cli/command-reference/env.mdx +++ b/docs/cli/command-reference/env.mdx @@ -20,7 +20,7 @@ tags: --- {/* NOTE: This is an auto-generated file. Any edit to this file will be overwritten. -This file is generated from https://github.com/temporalio/cli/blob/main/internal/commandsgen/commands.yml via internal/cmd/gen-docs */} +This file is generated from the CLI command definitions via cmd/gen-docs. */} This page provides a reference for the `temporal` CLI `env` command. The flags applicable to each subcommand are presented in a table within the heading for the subcommand. Refer to [Global Flags](#global-flags) for flags that you can use with every subcommand. diff --git a/docs/cli/command-reference/operator.mdx b/docs/cli/command-reference/operator.mdx index 1590183c36..7936f5f2d4 100644 --- a/docs/cli/command-reference/operator.mdx +++ b/docs/cli/command-reference/operator.mdx @@ -38,7 +38,7 @@ tags: --- {/* NOTE: This is an auto-generated file. Any edit to this file will be overwritten. -This file is generated from https://github.com/temporalio/cli/blob/main/internal/commandsgen/commands.yml via internal/cmd/gen-docs */} +This file is generated from the CLI command definitions via cmd/gen-docs. */} This page provides a reference for the `temporal` CLI `operator` command. The flags applicable to each subcommand are presented in a table within the heading for the subcommand. Refer to [Global Flags](#global-flags) for flags that you can use with every subcommand. diff --git a/docs/cli/command-reference/schedule.mdx b/docs/cli/command-reference/schedule.mdx index 7945f7462e..675c6a55bc 100644 --- a/docs/cli/command-reference/schedule.mdx +++ b/docs/cli/command-reference/schedule.mdx @@ -25,7 +25,7 @@ tags: --- {/* NOTE: This is an auto-generated file. Any edit to this file will be overwritten. -This file is generated from https://github.com/temporalio/cli/blob/main/internal/commandsgen/commands.yml via internal/cmd/gen-docs */} +This file is generated from the CLI command definitions via cmd/gen-docs. */} This page provides a reference for the `temporal` CLI `schedule` command. The flags applicable to each subcommand are presented in a table within the heading for the subcommand. Refer to [Global Flags](#global-flags) for flags that you can use with every subcommand. diff --git a/docs/cli/command-reference/server.mdx b/docs/cli/command-reference/server.mdx index 1a07b5ba47..a16743b2c1 100644 --- a/docs/cli/command-reference/server.mdx +++ b/docs/cli/command-reference/server.mdx @@ -16,7 +16,7 @@ tags: --- {/* NOTE: This is an auto-generated file. Any edit to this file will be overwritten. -This file is generated from https://github.com/temporalio/cli/blob/main/internal/commandsgen/commands.yml via internal/cmd/gen-docs */} +This file is generated from the CLI command definitions via cmd/gen-docs. */} This page provides a reference for the `temporal` CLI `server` command. The flags applicable to each subcommand are presented in a table within the heading for the subcommand. Refer to [Global Flags](#global-flags) for flags that you can use with every subcommand. @@ -79,6 +79,7 @@ Use the following options to change the behavior of this command. You can also u | `--sqlite-pragma` | No | **string[]** SQLite pragma statements in "PRAGMA=VALUE" format. | | `--ui-asset-path` | No | **string** UI custom assets path. | | `--ui-codec-endpoint` | No | **string** UI remote codec HTTP endpoint. | +| `--ui-disable-news-fetch` | No | **bool** Disable the Web UI newsfeed. When set, the UI will not request the newsfeed and the button to open the newsfeed panel is hidden. | | `--ui-ip` | No | **string** IP address bound to the Web UI. Defaults to same as '--ip' value. | | `--ui-port` | No | **int** Port for the Web UI. Defaults to '--port' value + 1000. | | `--ui-public-path` | No | **string** The public base path for the Web UI. Defaults to `/`. | diff --git a/docs/cli/command-reference/task-queue.mdx b/docs/cli/command-reference/task-queue.mdx index efb4fe2289..c222e86383 100644 --- a/docs/cli/command-reference/task-queue.mdx +++ b/docs/cli/command-reference/task-queue.mdx @@ -16,7 +16,7 @@ tags: --- {/* NOTE: This is an auto-generated file. Any edit to this file will be overwritten. -This file is generated from https://github.com/temporalio/cli/blob/main/internal/commandsgen/commands.yml via internal/cmd/gen-docs */} +This file is generated from the CLI command definitions via cmd/gen-docs. */} This page provides a reference for the `temporal` CLI `task-queue` command. The flags applicable to each subcommand are presented in a table within the heading for the subcommand. Refer to [Global Flags](#global-flags) for flags that you can use with every subcommand. @@ -412,9 +412,9 @@ Use the following options to change the behavior of this command. You can also u ## versioning ``` -+---------------------------------------------------------------------+ -| CAUTION: This API has been deprecated by Worker Deployment. | -+---------------------------------------------------------------------+ ++-------------------------------------------------------------+ +| CAUTION: This API has been deprecated by Worker Deployment. | ++-------------------------------------------------------------+ ``` Provides commands to add, list, remove, or replace Worker Build ID assignment @@ -438,6 +438,12 @@ Task Queues support the following versioning rules and policies: ### add-redirect-rule +``` ++-------------------------------------------------------------+ +| CAUTION: This API has been deprecated by Worker Deployment. | ++-------------------------------------------------------------+ +``` + Add a new redirect rule for a given Task Queue. You may add at most one redirect rule for each distinct source build ID: @@ -448,12 +454,6 @@ temporal task-queue versioning add-redirect-rule \ --target-build-id "YourTargetBuildID" ``` -``` -+---------------------------------------------------------------------+ -| CAUTION: This API has been deprecated by Worker Deployment. | -+---------------------------------------------------------------------+ -``` - Use the following options to change the behavior of this command. You can also use any of the [global flags](#global-flags) that apply to all subcommands. | Flag | Required | Description | @@ -464,6 +464,12 @@ Use the following options to change the behavior of this command. You can also u ### commit-build-id +``` ++-------------------------------------------------------------+ +| CAUTION: This API has been deprecated by Worker Deployment. | ++-------------------------------------------------------------+ +``` + Complete a Build ID's rollout and clean up unnecessary rules that might have been created during a gradual rollout: @@ -485,12 +491,6 @@ Rejects requests when there have been no recent pollers for this Build ID. This prevents committing invalid Build IDs. Use the `--force` option to override this validation. -``` -+---------------------------------------------------------------------+ -| CAUTION: This API has been deprecated by Worker Deployment. | -+---------------------------------------------------------------------+ -``` - Use the following options to change the behavior of this command. You can also use any of the [global flags](#global-flags) that apply to all subcommands. | Flag | Required | Description | @@ -501,6 +501,12 @@ Use the following options to change the behavior of this command. You can also u ### delete-assignment-rule +``` ++-------------------------------------------------------------+ +| CAUTION: This API has been deprecated by Worker Deployment. | ++-------------------------------------------------------------+ +``` + Deletes a rule identified by its index in the Task Queue's list of assignment rules. @@ -514,12 +520,6 @@ By default, the Task Queue must retain one unconditional rule, such as "no hint filter" or "percentage". Otherwise, the delete operation is rejected. Use the `--force` option to override this validation. -``` -+---------------------------------------------------------------------+ -| CAUTION: This API has been deprecated by Worker Deployment. | -+---------------------------------------------------------------------+ -``` - Use the following options to change the behavior of this command. You can also use any of the [global flags](#global-flags) that apply to all subcommands. | Flag | Required | Description | @@ -530,6 +530,12 @@ Use the following options to change the behavior of this command. You can also u ### delete-redirect-rule +``` ++-------------------------------------------------------------+ +| CAUTION: This API has been deprecated by Worker Deployment. | ++-------------------------------------------------------------+ +``` + Deletes the routing rule for the given source Build ID. ``` @@ -538,12 +544,6 @@ temporal task-queue versioning delete-redirect-rule \ --source-build-id "YourBuildId" ``` -``` -+---------------------------------------------------------------------+ -| CAUTION: This API has been deprecated by Worker Deployment. | -+---------------------------------------------------------------------+ -``` - Use the following options to change the behavior of this command. You can also use any of the [global flags](#global-flags) that apply to all subcommands. | Flag | Required | Description | @@ -553,6 +553,12 @@ Use the following options to change the behavior of this command. You can also u ### get-rules +``` ++-------------------------------------------------------------+ +| CAUTION: This API has been deprecated by Worker Deployment. | ++-------------------------------------------------------------+ +``` + Retrieve all the Worker Build ID assignments and redirect rules associated with a Task Queue: @@ -571,16 +577,17 @@ Task Queues support the following versioning rules: Build ID. You may add at most one redirect rule for each source Build ID. Redirect rules require that a target Build ID is fully compatible with the source Build ID. -``` -+---------------------------------------------------------------------+ -| CAUTION: This API has been deprecated by Worker Deployment. | -+---------------------------------------------------------------------+ -``` Use [global flags](#global-flags) to customize the connection to the Temporal Service for this command. ### insert-assignment-rule +``` ++-------------------------------------------------------------+ +| CAUTION: This API has been deprecated by Worker Deployment. | ++-------------------------------------------------------------+ +``` + Inserts a new assignment rule for this Task Queue. Rules are evaluated in order, starting from index 0. The first applicable rule is applied, and the rest ignored: @@ -593,12 +600,6 @@ temporal task-queue versioning insert-assignment-rule \ If you do not specify a `--rule-index`, this command inserts at index 0. -``` -+---------------------------------------------------------------------+ -| CAUTION: This API has been deprecated by Worker Deployment. | -+---------------------------------------------------------------------+ -``` - Use the following options to change the behavior of this command. You can also use any of the [global flags](#global-flags) that apply to all subcommands. | Flag | Required | Description | @@ -610,6 +611,12 @@ Use the following options to change the behavior of this command. You can also u ### replace-assignment-rule +``` ++-------------------------------------------------------------+ +| CAUTION: This API has been deprecated by Worker Deployment. | ++-------------------------------------------------------------+ +``` + Change an assignment rule for this Task Queue. By default, this enforces one unconditional rule (no hint filter or percentage). Otherwise, the operation will be rejected. Set `force` to true to bypass this validation. @@ -636,12 +643,6 @@ temporal task-queue versioning replace-assignment-rule \ Percent may vary between 0 and 100 (default). -``` -+---------------------------------------------------------------------+ -| CAUTION: This API has been deprecated by Worker Deployment. | -+---------------------------------------------------------------------+ -``` - Use the following options to change the behavior of this command. You can also use any of the [global flags](#global-flags) that apply to all subcommands. | Flag | Required | Description | @@ -654,6 +655,12 @@ Use the following options to change the behavior of this command. You can also u ### replace-redirect-rule +``` ++-------------------------------------------------------------+ +| CAUTION: This API has been deprecated by Worker Deployment. | ++-------------------------------------------------------------+ +``` + Updates a Build ID's redirect rule on a Task Queue by replacing its target Build ID: @@ -664,12 +671,6 @@ temporal task-queue versioning replace-redirect-rule \ --target-build-id YourNewTargetBuildId ``` -``` -+---------------------------------------------------------------------+ -| CAUTION: This API has been deprecated by Worker Deployment. | -+---------------------------------------------------------------------+ -``` - Use the following options to change the behavior of this command. You can also use any of the [global flags](#global-flags) that apply to all subcommands. | Flag | Required | Description | diff --git a/docs/cli/command-reference/worker.mdx b/docs/cli/command-reference/worker.mdx index 849b7423ef..daefb0fb9f 100644 --- a/docs/cli/command-reference/worker.mdx +++ b/docs/cli/command-reference/worker.mdx @@ -14,7 +14,7 @@ tags: --- {/* NOTE: This is an auto-generated file. Any edit to this file will be overwritten. -This file is generated from https://github.com/temporalio/cli/blob/main/internal/commandsgen/commands.yml via internal/cmd/gen-docs */} +This file is generated from the CLI command definitions via cmd/gen-docs. */} This page provides a reference for the `temporal` CLI `worker` command. The flags applicable to each subcommand are presented in a table within the heading for the subcommand. Refer to [Global Flags](#global-flags) for flags that you can use with every subcommand. diff --git a/docs/cli/command-reference/workflow.mdx b/docs/cli/command-reference/workflow.mdx index 08ca40f62f..4bc69d3e4a 100644 --- a/docs/cli/command-reference/workflow.mdx +++ b/docs/cli/command-reference/workflow.mdx @@ -43,7 +43,7 @@ tags: --- {/* NOTE: This is an auto-generated file. Any edit to this file will be overwritten. -This file is generated from https://github.com/temporalio/cli/blob/main/internal/commandsgen/commands.yml via internal/cmd/gen-docs */} +This file is generated from the CLI command definitions via cmd/gen-docs. */} This page provides a reference for the `temporal` CLI `workflow` command. The flags applicable to each subcommand are presented in a table within the heading for the subcommand. Refer to [Global Flags](#global-flags) for flags that you can use with every subcommand. diff --git a/docs/cloud/audit-logs-gcp.mdx b/docs/cloud/audit-logs-gcp.mdx index cf974381ab..23f78f5da0 100644 --- a/docs/cloud/audit-logs-gcp.mdx +++ b/docs/cloud/audit-logs-gcp.mdx @@ -59,6 +59,6 @@ If everything is configured correctly, you will see a `Success` status indicatin :::info MORE INFORMATION -For more details, refer to [Audit Logs with Temporal Cloud](https://docs.temporal.io/cloud/audit-logs). +For more details, refer to [Audit Logs with Temporal Cloud](/cloud/audit-logs). ::: diff --git a/docs/cloud/capacity-modes.mdx b/docs/cloud/capacity-modes.mdx index cef90a26bc..26e684bb9d 100644 --- a/docs/cloud/capacity-modes.mdx +++ b/docs/cloud/capacity-modes.mdx @@ -100,7 +100,7 @@ Actions that are external to the core Temporal service do not contribute to your ## On-Demand Capacity {/* #on-demand-capacity */} Using On-Demand Capacity, your rate limit grows automatically along with your usage. -Each Namespace has an Actions per second (APS), Requests per second (RPS), and Operations per second (OPS) limit that scales automatically with usage. Your APS limit never falls below its [default limit](/cloud/limits#actions-per-second). +Each Namespace has an Actions per second (APS), Requests per second (RPS), and Operations per second (OPS) limit that scales automatically with usage. Your APS limit never falls below its [default limit](/cloud/limits#actions-per-second). If Temporal Support has manually set your Namespace's limit, that value becomes your floor in place of the default, and it persists across capacity mode changes. Scaling automatically adjusts based on the lesser of 4 * APS Average or 2 * APS P90 over the past 7 days. @@ -155,7 +155,7 @@ For Requests in excess of 4 TRUs in regions outside of the US, we recommend subm ### Provisioned Capacity Availability The amount of capacity available within a region may vary. Temporal will check available capacity at the time of your request and aims to provision requested capacity within two minutes. -If you need capacity beyond what is self-serviceable or available in a region, please [file a support ticket](https://docs.temporal.io/cloud/support#ticketing) indicating the limit, region, and timeframe that the capacity is needed. +If you need capacity beyond what is self-serviceable or available in a region, please [file a support ticket](/cloud/support#ticketing) indicating the limit, region, and timeframe that the capacity is needed. ### When should I use Provisioned Capacity? @@ -180,6 +180,10 @@ For the metrics to watch and how to alert on utilization, see [Provisioned capac Capacity Modes and TRUs can be set via the Temporal Cloud UI, CLI, or API. Capacity modes can be set and adjusted by Global Admin and Namespace Admin. +When you switch to Provisioned Capacity, your limit is set by the number of TRUs you select. +When you switch back to On-Demand, your limit is recalculated from the trailing 7-day usage formula and never falls below your floor. +If Temporal Support has set a custom limit for your Namespace, that limit is your floor, so switching to Provisioned and back to On-Demand preserves it. + ### Setting Capacity Modes from the UI You can set Capacity Modes for an individual Namespace by navigating to the Namespace page in the Temporal Cloud UI (`https://cloud.temporal.io/namespaces/`). diff --git a/docs/cloud/connectivity/aws-connectivity.mdx b/docs/cloud/connectivity/aws-connectivity.mdx index dcc0263a4a..c8148b1b0c 100644 --- a/docs/cloud/connectivity/aws-connectivity.mdx +++ b/docs/cloud/connectivity/aws-connectivity.mdx @@ -21,17 +21,7 @@ keywords: - term --- -import { ToolTipTerm, DiscoverableDisclosure, CaptionedImage, JsonTable } from '@site/src/components'; - - +import { CaptionedImage, JsonTable } from '@site/src/components'; [AWS PrivateLink](https://aws.amazon.com/privatelink/) allows you to open a path to Temporal without opening a public egress. It establishes a private connection between your Amazon Virtual Private Cloud (VPC) and Temporal Cloud. @@ -43,9 +33,17 @@ After creating the PrivateLink endpoint, configure your clients to use it throug ## Requirements -Your AWS PrivateLink endpoint must be in the same region as your Temporal Cloud namespace. If using [replication for High Availability](/cloud/high-availability), the PL connection must be in the same region as one of the replicas. +* Your AWS PrivateLink (PL) endpoint must be in the same region as your Temporal Cloud Namespace or one of its [High Availability](/cloud/high-availability) replicas. + See [cross-region PrivateLink connectivity](#cross-region-privatelink) to access the Namespace from a different region. +* Your Private DNS must be configured to direct Worker / Client traffic to your VPC Endpoint, as described below. +* If the Worker / Client does not use the Namespace Endpoint as the connection string in its code, it may need to set the `server_name` config to the Namespace Endpoint string, as described below. -AWS Cross Region endpoints are not supported. +### Cross-region PrivateLink Connectivity {/* #cross-region-privatelink */} + +Temporal Cloud does **not** support [cross-region connectivity for AWS PrivateLink](https://aws.amazon.com/blogs/networking-and-content-delivery/introducing-cross-region-connectivity-for-aws-privatelink/) out of the box. However, if you need to reach Temporal Cloud privately from a different region than your Namespace, you can route traffic to your VPC Endpoint in the Namespace's region using [AWS's native cross-region networking features](https://docs.aws.amazon.com/whitepapers/latest/building-scalable-secure-multi-vpc-network-infrastructure/centralized-access-to-vpc-private-endpoints.html#cross-region-endpoint-access). + + +When using High Availability on Temporal Cloud, it's best practice to have two VPC Endpoints, one in each of the Namespace's regions, to ensure at least one VPC Endpoint is accessible during a regional outage. ## Creating an AWS PrivateLink connection @@ -68,7 +66,7 @@ Individual Namespaces do not use separate services. ::: - + 7. Confirm your service by clicking on the _Verify service_ button. AWS should respond "Service name verified." @@ -118,14 +116,14 @@ This approach is **optional**; Temporal Cloud works without it. It simply stream ### Choose the override domain and endpoint -| Endpoint type | PHZ domain format | Example | Use when | -| ------------------ | ------------------------------------- | --------------------------------------- | -------- | -| Namespace endpoint | `.tmprl.cloud` | `payments.abcde.tmprl.cloud` | **Single-region Namespaces only.** Simplest pattern — one record per Namespace. Do not use this for [High Availability](/cloud/high-availability/ha-connectivity) Namespaces: the override short-circuits Temporal's regional CNAME chain and failover stops working. | -| Regional endpoint | `-.region.tmprl.cloud` | `aws-ap-northeast-2.region.tmprl.cloud` | **Single-region or HA Namespaces.** One record per Temporal Cloud region, reused by every Namespace active or replicated in that region. **Required for HA Namespaces** — see [Connectivity for High Availability](/cloud/high-availability/ha-connectivity). | +| Endpoint type | PHZ domain format | Example | Use when | +| ------------------ | ---------------------------------- | ------------------------------------ | -------- | +| Namespace endpoint | `.tmprl.cloud` | `payments.abcde.tmprl.cloud` | **Single-region Namespaces.** Simplest pattern — one record per Namespace. For [High Availability](/cloud/high-availability/ha-connectivity) Namespaces, overriding the Namespace Endpoint is nuanced — see [Connectivity for High Availability](/cloud/high-availability/ha-connectivity). | +| Regional endpoint | `..api.temporal.io` | `ap-northeast-2.aws.api.temporal.io` | You want to pin a client to a specific Temporal Cloud region. | -:::warning HA Namespaces require the regional override +:::caution HA Namespaces need a more nuanced PHZ setup -For Namespaces with [High Availability](/cloud/high-availability/ha-connectivity), use the regional-endpoint PHZ pattern only. The Namespace-endpoint override is read out of the PHZ before public DNS, so the regional CNAME that Temporal Cloud rewrites on failover is never followed and Workers stay pinned to the old region. If you're switching an existing single-region private Namespace to HA, see [How to enable HA on a Namespace using Private Connectivity](/cloud/high-availability/ha-connectivity#how-to-enable-ha-on-a-namespace-using-private-connectivity) for the PHZ migration steps. +For Namespaces with [High Availability](/cloud/high-availability/ha-connectivity), the PHZ pattern to use depends on how you want Workers to reach the active region. Overriding the Namespace Endpoint directly is read out of the PHZ before public DNS, so the regional CNAME that Temporal Cloud rewrites on failover isn't followed — which is usually not what you want, but can be the right choice in some topologies (for example, multi-cloud HA with one region per cloud, where Workers on each cloud should always reach their local region). Because the trade-offs depend on your setup, see [Connectivity for High Availability](/cloud/high-availability/ha-connectivity) before choosing a pattern. ::: @@ -229,10 +227,10 @@ title: Direct VPCE targeting (single region) --- %%{init: {'themeVariables':{'fontFamily':'Inter, ui-sans-serif, system-ui, sans-serif'},'flowchart':{'nodeSpacing':18,'rankSpacing':45,'curve':'basis','subGraphTitleMargin':{'top':6,'bottom':12}}}}%% flowchart LR - classDef worker fill:#7C3AED22,stroke:#7C3AED,stroke-width:1px; - classDef ns fill:#59FDA024,stroke:#59FDA0,stroke-width:1px; - classDef endpoint fill:transparent,stroke:#c2c8d2,stroke-width:1px; - classDef region fill:transparent,stroke:#9aa4b2,stroke-width:1.5px; + classDef worker stroke-width:1px; + classDef ns stroke-width:1px; + classDef endpoint stroke-width:1px; + classDef region stroke-width:1.5px; subgraph SREG["Your VPC"] SW["Worker(s)
Server name:
my-namespace.my-account.tmprl.cloud"]:::worker SVPCE["VPC Endpoint
vpce-…"]:::endpoint @@ -265,10 +263,10 @@ title: Direct VPCE targeting (High Availability) --- %%{init: {'themeVariables':{'fontFamily':'Inter, ui-sans-serif, system-ui, sans-serif'},'flowchart':{'nodeSpacing':55,'rankSpacing':70,'curve':'basis','subGraphTitleMargin':{'top':6,'bottom':12}}}}%% flowchart TD - classDef worker fill:#7C3AED22,stroke:#7C3AED,stroke-width:1px; - classDef ns fill:#59FDA024,stroke:#59FDA0,stroke-width:1px; - classDef endpoint fill:transparent,stroke:#c2c8d2,stroke-width:1px; - classDef region fill:transparent,stroke:#9aa4b2,stroke-width:1.5px; + classDef worker stroke-width:1px; + classDef ns stroke-width:1px; + classDef endpoint stroke-width:1px; + classDef region stroke-width:1.5px; subgraph HSEC["us-west-2 (passive)"] HWB["Worker(s)
Server name
namespace.
acct.
tmprl.cloud
"]:::worker HVPB["VPC
Endpoint

vpce-…
us-west-2"]:::endpoint diff --git a/docs/cloud/connectivity/gcp-connectivity.mdx b/docs/cloud/connectivity/gcp-connectivity.mdx index fae379994c..6a2074b703 100644 --- a/docs/cloud/connectivity/gcp-connectivity.mdx +++ b/docs/cloud/connectivity/gcp-connectivity.mdx @@ -21,7 +21,7 @@ keywords: - term --- -import { ToolTipTerm, DiscoverableDisclosure, JsonTable } from '@site/src/components'; +import { JsonTable } from '@site/src/components'; [Google Cloud Private Service Connect](https://cloud.google.com/vpc/docs/private-service-connect) allows you to open a path to Temporal without opening a public egress. It establishes a private connection between your Google Virtual Private Cloud (VPC) and Temporal Cloud. @@ -29,6 +29,8 @@ This one-way connection means Temporal cannot establish a connection back to you This is useful if normally you block traffic egress as part of your security protocols. If you use a private environment that does not allow external connectivity, you will remain isolated. + + :::warning Namespaces with High Availability features and GCP Private Service Connect Automatic failover via Temporal Cloud DNS is not currently supported with GCP Private Service Connect. @@ -38,7 +40,9 @@ If you use GCP Private Service Connect, you must manually update your workers to ## Requirements -Your GCP Private Service Connect connection must be in the same region as your Temporal Cloud namespace. If using [replication for High Availability](/cloud/high-availability), the PSC connection must be in the same region as one of the replicas. +* Your GCP Private Service Connect endpoint must be in the same region as your Temporal Cloud namespace. If using [replication for High Availability](/cloud/high-availability), the PSC connection must be in the same region as one of the replicas. +* Your Private DNS must be configured to direct Worker / Client traffic to your PSC endpoint, as described below. +* If the Worker / Client is not using the Namespace Endpoint, it may need to set the `server_name` config to the Namespace Endpoint string, as described below. ## Creating a Private Service Connect connection @@ -131,7 +135,7 @@ This approach is **optional**; Temporal Cloud works without it. It simply stream | ------------------------------------------ | ---------------------------------- | ---------------------------------------------- | | Single-region namespace with mTLS auth | `.tmprl.cloud` | `payments.abcde.tmprl.cloud` ↔ `X.X.X.X` | | Single-region namespace with API-key auth | `.api.temporal.io` | `us-central1.gcp.api.temporal.io` ↔ `X.X.X.X` | -| Multi-region namespace | `region.tmprl.cloud` | `gcp-us-central1.region.tmprl.cloud` ↔ `X.X.X.X` | +| Multi-region namespace | `.api.temporal.io` | `us-central1.gcp.api.temporal.io` ↔ `X.X.X.X` | ### Step-by-step instructions @@ -152,7 +156,7 @@ Save the internal IP -- you will point the A record at it. 1. Open _Network Services → Cloud DNS → Create zone_. 2. Select zone type **Private**. 3. Enter a **Zone name** (e.g., `temporal-cloud`). -4. Enter a **DNS name** based on the table above (e.g., `payments.abcde.tmprl.cloud` or `aws-us-east-1.region.tmprl.cloud`). +4. Enter a **DNS name** based on the table above (e.g., `payments.abcde.tmprl.cloud` or `us-east-1.aws.api.temporal.io`). 5. Select **Add networks** and choose the Project and Network that contains your PSC endpoint. 6. Click **Create**. diff --git a/docs/cloud/connectivity/index.mdx b/docs/cloud/connectivity/index.mdx index 2744c921d1..95e5820adc 100644 --- a/docs/cloud/connectivity/index.mdx +++ b/docs/cloud/connectivity/index.mdx @@ -18,7 +18,7 @@ keywords: - term --- -import { ToolTipTerm, DiscoverableDisclosure, CaptionedImage, SdkTabs } from '@site/src/components'; +import { CaptionedImage, SdkTabs } from '@site/src/components'; import { LANGUAGE_TAB_GROUP, getLanguageLabel } from '@site/src/constants/languageTabs'; ## Private network connectivity for namespaces @@ -81,15 +81,13 @@ Each Connectivity Rule specifies either generic public (internet) access or a sp | AWS PrivateLink | **Optional.** Add one only if you want to enforce private-only access (block internet traffic to that Namespace). | AWS PrivateLink connections become usable as soon as the VPC endpoint is `Available`. Adding a Connectivity Rule restricts access; it does not establish it. | | GCP Private Service Connect | **Required.** The PSC endpoint stays in `Pending` until a matching Connectivity Rule is created. | The Connectivity Rule is what tells Temporal Cloud to accept the PSC connection. | -A public Connectivity Rule has one optional parameter: - -- `enable-stable-ips`: When set, Namespaces attached to this rule resolve their Namespace endpoint to a published, fixed set of IP addresses you can allowlist in your firewall. See [Stable IPs](/cloud/connectivity/ip-addresses#stable-ip-addresses) for details. +A public Connectivity Rule can optionally enable [Stable IPs](/cloud/connectivity/ip-addresses#stable-ip-addresses), so Namespaces attached to it resolve their Namespace endpoint to a published, fixed set of IP addresses you can allowlist in your firewall. Only one public Connectivity Rule is allowed per account (see [Permissions and limits](#permissions-and-limits) below). :::caution -Connectivity Rules cannot be updated in place. If you already have a public Connectivity Rule and want to turn on Stable IPs, you must delete the existing public rule, create a new one with `enable-stable-ips` set, and re-attach it to every Namespace that needs it. Attempting to create a second public rule alongside the existing one returns an error. +Connectivity Rules cannot be updated in place. If you already have a public Connectivity Rule and want to turn on Stable IPs, you must delete the existing public rule, create a new one with Stable IPs enabled, and re-attach it to every Namespace that needs it. Attempting to create a second public rule alongside the existing one returns an error. ::: @@ -104,7 +102,7 @@ A GCP Private Service Connect (PSC) private Connectivity Rule requires: - `region`: The region of the PSC connection, prefixed with `gcp-` (ex: `gcp-us-east1`). Must be the same region as the Namespace. Refer to the [Temporal Cloud region list](/cloud/regions) for supported regions. - `gcp-project-id`: The identifier of the GCP project where you created the PSC connection (ex: `my-example-project-123`). -Connectivity Rules can be created and managed with [tcld](https://docs.temporal.io/cloud/tcld/), [Terraform](https://github.com/temporalio/terraform-provider-temporalcloud/), the Web UI (under **Connectivity** in your account settings), or the [Cloud Ops API](/ops). +Connectivity Rules can be created and managed with [tcld](/cloud/tcld/), [Terraform](https://github.com/temporalio/terraform-provider-temporalcloud/), or the [Cloud Ops API](/ops). There is no Temporal Cloud Web UI option for creating or managing Connectivity Rules at this time. :::tip Connectivity Rules give Temporal visibility into your private connections @@ -142,11 +140,7 @@ Create public connectivity rule (you only need to do this once ever in your acco tcld connectivity-rule create --connectivity-type public ``` -Create a public Connectivity Rule with [Stable IPs](/cloud/connectivity/ip-addresses#stable-ip-addresses) enabled: - -```bash -tcld connectivity-rule create --connectivity-type public --enable-stable-ips -``` +To enable [Stable IPs](/cloud/connectivity/ip-addresses#stable-ip-addresses) on the public rule, use the Cloud Ops API or Terraform. See [How to enable Stable IPs](/cloud/connectivity/ip-addresses#how-to-enable-stable-ips). The `cr` alias works the same way: @@ -239,7 +233,7 @@ The TLS server name override depends on your authentication method: | -------------- | ---------------------- | | mTLS (single-region Namespace) | The Namespace Endpoint, e.g. `my-namespace.my-account.tmprl.cloud` | | API key (single-region Namespace) | The regional API endpoint, e.g. `us-east-1.aws.api.temporal.io` or `us-central1.gcp.api.temporal.io` | -| Multi-region Namespace (mTLS or API key) | The active region endpoint, e.g. `aws-us-east-1.region.tmprl.cloud` | +| Multi-region Namespace (mTLS or API key) | The active region endpoint, e.g. `us-east-1.aws.api.temporal.io` | If you authenticate with an API key over PrivateLink/PSC and use the wrong server name, the TLS handshake will fail with errors such as `connection reset by peer` even though `nc` reports the port as open. diff --git a/docs/cloud/connectivity/ip-addresses.mdx b/docs/cloud/connectivity/ip-addresses.mdx index 450a8b5363..05fa2645e2 100644 --- a/docs/cloud/connectivity/ip-addresses.mdx +++ b/docs/cloud/connectivity/ip-addresses.mdx @@ -293,7 +293,7 @@ Take note of the following: The full list of Stable IP ranges is published as a public JSON file at: -**[https://docs.temporal.io/json/stable-ip-ranges-prod.json](https://docs.temporal.io/json/stable-ip-ranges-prod.json)** +**[https://docs.temporal.io/json/stable-ip-ranges-prod.json](/json/stable-ip-ranges-prod.json)** Click the link to inspect the list in your browser, or copy the URL into your firewall tooling, a `curl`/`wget` script, or a Terraform `http` data source: @@ -341,6 +341,11 @@ IP addresses are provided in IPv4 or IPv6 format with CIDR notation. At launch, ::: +:::note Don't depend on DNS resolution behavior + +Take a dependency on Temporal's published endpoints (the hostnames themselves), not on what they resolve to. The underlying IP addresses, CNAME chains, and resolution behavior can change at any time without notice. The **only** exception is the Namespace Endpoint for a Namespace with [High Availability features](/cloud/high-availability), which deterministically CNAMEs to its active region's regional record — see [Connectivity for High Availability](/cloud/high-availability/ha-connectivity) for details. + +::: ### How to connect using Stable IPs To connect to a Namespace with Stable IPs enabled: @@ -387,7 +392,7 @@ If your Namespace is currently reached over AWS PrivateLink and protected by a p **Steps:** -1. **Allowlist Stable IPs in your firewall first.** Fetch the IP ranges for your Namespace's region from [https://docs.temporal.io/json/stable-ip-ranges-prod.json](https://docs.temporal.io/json/stable-ip-ranges-prod.json) and add them to your egress allowlist before changing anything else. If you skip this step, traffic will be lost. +1. **Allowlist Stable IPs in your firewall first.** Fetch the IP ranges for your Namespace's region from [https://docs.temporal.io/json/stable-ip-ranges-prod.json](/json/stable-ip-ranges-prod.json) and add them to your egress allowlist before changing anything else. If you skip this step, traffic will be lost. 2. **Create (or update) the account's public Connectivity Rule with Stable IPs enabled.** This step requires the [Cloud Ops API](#how-to-enable-stable-ips-with-curl) or the [`cloud-api` client](https://github.com/temporalio/cloud-api) — `tcld` does not currently support creating a public Connectivity Rule with Stable IPs. @@ -406,7 +411,7 @@ If your Namespace is currently reached over AWS PrivateLink and protected by a p 4. **Switch client DNS resolution from PrivateLink to public.** This is the actual cutover. Remove the private DNS override — Route 53 private hosted zone, `/etc/hosts`, or VPC DNS resolver rules — so that `..tmprl.cloud` resolves through public DNS to a Stable IP. Restart Workers to drop existing PrivateLink connections and force re-resolution. -5. **Verify traffic is flowing over Stable IPs.** On a Worker host, run `dig ..tmprl.cloud` and confirm the answer is a Stable IP from the [published JSON list](https://docs.temporal.io/json/stable-ip-ranges-prod.json), not your VPC endpoint IP. Confirm Workers are polling and there are no `RESOURCE_EXHAUSTED` or connection errors in their logs. +5. **Verify traffic is flowing over Stable IPs.** On a Worker host, run `dig ..tmprl.cloud` and confirm the answer is a Stable IP from the [published JSON list](/json/stable-ip-ranges-prod.json), not your VPC endpoint IP. Confirm Workers are polling and there are no `RESOURCE_EXHAUSTED` or connection errors in their logs. 6. **Detach the private Connectivity Rule.** Once you are confident all traffic is on the public path, remove the private rule by re-attaching only the public rule: diff --git a/docs/cloud/gcp-export-gcs.mdx b/docs/cloud/gcp-export-gcs.mdx index e0b1b333be..5b1bda934e 100644 --- a/docs/cloud/gcp-export-gcs.mdx +++ b/docs/cloud/gcp-export-gcs.mdx @@ -93,7 +93,7 @@ Don't forget to click Create at the end of your setup to confirm your export. To access export-related commands in tcld, please follow these steps: -1. [Download the latest version of tcld](https://docs.temporal.io/cloud/tcld/#install-tcld). +1. [Download the latest version of tcld](/cloud/tcld#install-tcld). 2. Make sure your tcld version is v0.35.0 or above. 3. Run the command: `tcld n export gcs`: ```bash diff --git a/docs/cloud/get-started/api-keys.mdx b/docs/cloud/get-started/api-keys.mdx index 6df975736f..4d4fee0ea5 100644 --- a/docs/cloud/get-started/api-keys.mdx +++ b/docs/cloud/get-started/api-keys.mdx @@ -98,7 +98,7 @@ Check these setup details before using API keys: - The Global Administrator or Account Owner may need to [enable API keys access](#manage-api-keys) for your Temporal Account. - Have access to the [Temporal Cloud UI](https://cloud.temporal.io/) or Temporal Cloud CLI - ([tcld](https://docs.temporal.io/cloud/tcld/)) to create an API key. + ([tcld](/cloud/tcld/)) to create an API key. ## Global Administrator and Account Owner API key management {/* #manage-api-keys */} diff --git a/docs/cloud/get-started/certificates.mdx b/docs/cloud/get-started/certificates.mdx index 7be93c7af0..33fd961ed3 100644 --- a/docs/cloud/get-started/certificates.mdx +++ b/docs/cloud/get-started/certificates.mdx @@ -298,7 +298,7 @@ ones are generated and deployed. ### 3. Regenerate end-entity certificates with the new CA certificate -[Configure all clients](https://docs.temporal.io/cloud/certificates#configure-clients-to-use-client-certificates) +[Configure all clients](/cloud/certificates#configure-clients-to-use-client-certificates) (Temporal CLI, SDKs, or Workers) to use the new certificate and private key alongside the compromised key. Update the client configuration as described in [Configure clients to use client certificates](#configure-clients-to-use-client-certificates). @@ -319,7 +319,7 @@ stricter controls, such as: - Limiting end-entity certificates to specific Namespaces - Rotating certificates regularly as a preventive measure -- [Audit logs on a regular schedule](https://docs.temporal.io/cloud/audit-logs) +- [Audit logs on a regular schedule](/cloud/audit-logs) ## How to add, update, and remove certificates in a Temporal Cloud Namespace {/* #manage-certificates */} diff --git a/docs/cloud/get-started/index.mdx b/docs/cloud/get-started/index.mdx index 51b721890f..5d0d09332e 100644 --- a/docs/cloud/get-started/index.mdx +++ b/docs/cloud/get-started/index.mdx @@ -16,8 +16,6 @@ tags: - API --- -import { DiscoverableDisclosure } from '@site/src/components'; - Getting started with Temporal Cloud involves a few key steps: 1. [Sign up for Temporal Cloud](#sign-up-for-temporal-cloud) diff --git a/docs/cloud/get-started/namespaces.mdx b/docs/cloud/get-started/namespaces.mdx index 6ef959ec92..e1f3a78ba7 100644 --- a/docs/cloud/get-started/namespaces.mdx +++ b/docs/cloud/get-started/namespaces.mdx @@ -19,7 +19,7 @@ tags: - Temporal Cloud --- -import { CaptionedImage, ZoomingImage } from '@site/src/components'; +import { CaptionedImage, EnlargeImage } from '@site/src/components'; :::info Temporal Cloud This page covers namespace operations in **Temporal Cloud**. @@ -77,7 +77,7 @@ utility at a command line interface (CLI). Follow these steps. 1. Select your account avatar at the top right of the page. A profile dropdown menu appears. 1. Copy the Cloud Account ID from the menu. - @@ -249,7 +249,7 @@ accessing Namespace endpoints. Temporal Cloud supports authentication to Namespaces using [API keys](/cloud/api-keys) _or_ [mTLS](/cloud/certificates). To migrate a Namespace from one authentication method to another, or to use both API key and mTLS authentication on the same Namespace, please contact -[Support](https://docs.temporal.io/cloud/support#support-ticket). +[Support](/cloud/support#support-ticket). :::info @@ -383,7 +383,7 @@ Namespace removal is permanent. Closed Workflow Histories remain in Temporal storage until the user-defined retention period expires. This period reflects the policy in effect when the Workflow Execution was closed. -For further questions or concerns, contact [Support](https://docs.temporal.io/cloud/support#support-ticket). +For further questions or concerns, contact [Support](/cloud/support#support-ticket). ### Delete a Namespace using tcld diff --git a/docs/cloud/high-availability/architecture-patterns.mdx b/docs/cloud/high-availability/architecture-patterns.mdx new file mode 100644 index 0000000000..2e587c6711 --- /dev/null +++ b/docs/cloud/high-availability/architecture-patterns.mdx @@ -0,0 +1,671 @@ +--- +id: architecture-patterns +title: Architecture patterns for Active-Passive and Active-Active HA/DR +sidebar_label: Architecture patterns +slug: /cloud/high-availability/architecture-patterns +description: Choose a Worker deployment pattern for high availability and disaster recovery (HA/DR) on Temporal Cloud — Active/Passive, Active/Hot-Passive, and Active/Active — and plan multi-region failover for Workers, Clients, Codec Servers, and databases to meet your RTO and RPO. +tags: + - Temporal Cloud + - High Availability + - Disaster Recovery +keywords: + - high availability + - disaster recovery + - HA/DR + - failover + - multi-region failover + - worker deployment patterns + - active passive + - active active + - hot standby + - cold standby + - RTO + - RPO + - business continuity + - temporal cloud high availability + - temporal cloud +--- + +When a cloud outage strikes, a Temporal Cloud Namespace with [High Availability](/cloud/high-availability) fails over to another region automatically, providing high availability and disaster recovery for your most important Workflows. But Workers, Workflow starters, Codec Servers, databases, network load balancers, and other external systems that touch your Workflows each need their own failover story. + +In a real outage, your [recovery time](/cloud/rpo-rto) depends on your **Worker deployment pattern**: where Worker fleets run, how they failover, and which region (or regions) processes Workflows at each step. This page describes the Active/Passive, Active/Hot-Passive, and Active/Active patterns for deploying Workers and the other systems in your cloud architecture to achieve your business goals for high availability and disaster recovery (HA/DR). + +## Pieces of a highly available cloud architecture {/* #pieces-to-failover */} + +To keep your Workflows running during a cloud outage, these components need to failover to a healthy region: + +- **Temporal Cloud Namespace** - achieve this simply by enabling [High Availability](/cloud/high-availability) on the Namespace. +- **Workers** (the focus of this page) — the compute resources that execute Workflows and Activities. +- **Workflow starters and Clients** — the applications that start and Signal Workflows. +- **Codec Servers** — a critical dependency for Workers, the Web UI, and the CLI. +- **Proxies between Workers and Temporal Cloud** — any forward proxy or mTLS terminator in the connection path between Workers / Starters / Clients → Namespace. +- **Datastores** — databases, queues, and any other systems that Activities read and write. + +```mermaid +flowchart LR + classDef app stroke-width:1px; + classDef ns stroke-width:1px; + classDef endpoint stroke-width:1px; + classDef env stroke-width:1.5px; + subgraph WFENV["Application environment"] + WFW["Workers"]:::app + WFCL["Workflow starters / Clients"]:::app + WFCODEC["Codec Server"]:::app + WFDB[("DB / queue")]:::app + WFPROXY["Proxy"]:::endpoint + end + WFNS["Namespace
(Temporal Cloud)"]:::ns + WFW <--> WFCODEC + WFW <--> WFDB + WFW --> WFPROXY + WFCL --> WFPROXY + WFPROXY --> WFNS + class WFENV env +``` + +## Highly available Worker patterns {/* #ha-worker-patterns */} + +A Worker deployment pattern pairs with Namespace High Availability to achieve a disaster recovery (DR) and business continuity plan for your full Temporal architecture. + +This page covers three main patterns: **Active/Passive**, **Active/Hot-Passive**, and **Active/Active**. + +They trade off **recovery time** after an outage, **cost during normal operation**, and **operational complexity**. They are defined by where the Workers run and where Workflows process: + +- **[Active/Passive](#active-cold)** — Workflows process in one region at a time, the "active" region. The other region is "passively" waiting, without any Workers. On a failover, the passive region becomes active, and new Workers are launched (from a "cold" start) to process Workflows. +- **[Active/Hot-Passive](#active-hot)** — Workflows process in one region at a time, the "active" region. However, Workers run in **both regions** simultaneously: processing Workflows in the "active" region, and on "hot" standby in the passive region. This achieves a faster failover and lower recovery time. +- **[Active/Active](#active-active)** — Workflows process in both/multiple regions at the same time, and Workers run in all regions at all times. + +```mermaid +--- +title: Active/Passive +--- +flowchart LR + classDef worker stroke-width:1px; + classDef ns stroke-width:1px; + classDef empty stroke-width:1px,stroke-dasharray:4 3; + classDef region stroke-width:1.5px; + classDef pool stroke-width:1px; + subgraph ICPRIM["Primary"] + subgraph ICWP["Worker Pool"] + ICW1["Worker"]:::worker + ICW2["Worker"]:::worker + ICW3["Worker"]:::worker + end + ICNS["Namespace"]:::ns + ICWP <-->|Workflows| ICNS + end + subgraph ICSEC["Secondary"] + ICR["Replica"]:::ns + subgraph ICWP2["Worker Pool"] + ICE["      Empty      "]:::empty + end + ICR ~~~ ICWP2 + end + ICNS --> ICR + class ICPRIM,ICSEC region + class ICWP,ICWP2 pool +``` + +```mermaid +--- +title: Active/Hot-Passive +--- +flowchart LR + classDef worker stroke-width:1px; + classDef standby stroke-width:1px; + classDef ns stroke-width:1px; + classDef region stroke-width:1.5px; + classDef pool stroke-width:1px; + subgraph IHPRIM["Primary"] + subgraph IHWP["Worker Pool"] + IHW1["Worker
Active"]:::worker + IHW2["Worker
Active"]:::worker + IHW3["Worker
Active"]:::worker + end + IHNS["Namespace"]:::ns + IHWP <-->|Workflows| IHNS + end + subgraph IHSEC["Secondary"] + IHR["Replica"]:::ns + subgraph IHWP2["Worker Pool"] + IHS1["Worker
Standby"]:::standby + IHS2["Worker
Standby"]:::standby + IHS3["Worker
Standby"]:::standby + end + IHR <-.-> IHWP2 + end + IHNS --> IHR + class IHPRIM,IHSEC region + class IHWP,IHWP2 pool +``` + +```mermaid +--- +title: Active/Active +--- +flowchart LR + classDef worker stroke-width:1px; + classDef ns stroke-width:1px; + classDef region stroke-width:1.5px; + classDef pool stroke-width:1px; + subgraph IAPRIM["Primary"] + subgraph IAWP["Worker Pool"] + IAW1["Worker
Active"]:::worker + IAW2["Worker
Active"]:::worker + end + IANS["Namespace"]:::ns + IAWP <-->|Workflows| IANS + end + subgraph IASEC["Secondary"] + IAR["Replica"]:::ns + subgraph IAWP2["Worker Pool"] + IAS1["Worker
Active"]:::worker + IAS2["Worker
Active"]:::worker + end + IAR <-->|Workflows| IAWP2 + end + IANS --> IAR + class IAPRIM,IASEC region + class IAWP,IAWP2 pool +``` + +:::info + +**Namespaces always have a single active region, but can support an Active/Active Worker deployment pattern.** + +A Temporal Cloud Namespace with High Availability has exactly one active region at a time. The other region holds a replica that passively receives replicated state. + +However, since **Workers don't need to run in the same region as the active Namespace replica**, Temporal Cloud Namespaces can still fit into an Active/Active HA/DR strategy, as described below. + +::: + +These patterns work across two cloud regions, which could be in the same cloud provider ("multi-region") or different cloud providers ("multi-cloud"): + +- **Primary region** — the region where the Namespace is active during normal operation, also called the "preferred region." +- **Secondary region** — the region the Namespace fails over to. It can be any [Temporal Cloud region](/cloud/regions) that supports replication from the primary region. Multi-region and multi-cloud architectures use the same Worker deployment patterns. + +### Compare patterns at a glance {/* #compare-at-a-glance */} + +| Pattern | Where Workers run | Best for and benefits | Major tradeoffs | +| --- | --- | --- | --- | +| **[Active/Passive](#active-cold)** | One region at a time | Easy initial deployment; acts like a single region with no special setup | Failing over Workers is your responsibility; highest recovery time of the three | +| **[Active/Hot-Passive](#active-hot)** | Both regions; secondary on warm standby | Low RTO with strict single-region behavior; fast Worker failover that is guaranteed to act like a single region | More configuration and the cost of a full standby fleet | +| **[Active/Active](#active-active)** | All regions, all processing Workflows | Low RTO with Workers active in every region; fast failover that uses fleet capacity instead of a standby fleet | Cross-region requests add Workflow latency; external systems need a cross-region consistency story | + +## Active/Passive {/* #active-cold */} + +Workers run in only one region at a time. The secondary region stays empty until a failover, when you bring up a fresh Worker fleet there from a cold start. + +```mermaid +--- +title: Normal operation +--- +flowchart LR + classDef worker stroke-width:1px; + classDef ns stroke-width:1px; + classDef empty stroke-width:1px,stroke-dasharray:4 3; + classDef region stroke-width:1.5px; + classDef pool stroke-width:1px; + subgraph CPRIM["Primary"] + subgraph CWP["Worker Pool"] + CW1["Worker"]:::worker + CW2["Worker"]:::worker + CW3["Worker"]:::worker + end + CNS["Namespace"]:::ns + CWP <-->|Workflows| CNS + end + subgraph CSEC["Secondary"] + CR["Replica"]:::ns + subgraph CWP2["Worker Pool"] + CE["      Empty      "]:::empty + end + CR ~~~ CWP2 + end + CNS --> CR + class CPRIM,CSEC region + class CWP,CWP2 pool +``` + +```mermaid +--- +title: After failover +--- +flowchart LR + classDef worker stroke-width:1px; + classDef ns stroke-width:1px; + classDef down stroke-width:1px,stroke-dasharray:3 3; + classDef region stroke-width:1.5px; + classDef regiondown stroke-width:1.5px; + classDef pool stroke-width:1px; + subgraph FPRIM["Primary (outage)"] + subgraph FPP["Worker Pool"] + FPW1["Unavailable"]:::down + end + FPN["Namespace"]:::down + FPP ~~~ FPN + end + subgraph FSEC["Secondary"] + FSN["Namespace
(Active)"]:::ns + subgraph FSP["Worker Pool"] + FSW1["Worker
Cold start"]:::worker + FSW2["Worker
Cold start"]:::worker + FSW3["Worker
Cold start"]:::worker + end + FSN <-->|Workflows| FSP + end + FPN -->|"Failover"| FSN + class FPRIM regiondown + class FSEC region + class FPP,FSP pool +``` + +Here's how each component behaves during normal operation and after a failover: + +| Component | Normal operation | On failover | +| --- | --- | --- | +| **Workers** | Run only in the primary region, processing all Workflows. No Workers run in the secondary region. | Brought up from nothing in the secondary region — a "cold" start. No Workflows progress until they're running. | +| **Namespace** | Active replica in the primary region; passive replica in the secondary region, continuously receiving replicated Workflow state. | Temporal Cloud promotes the secondary region's replica to active automatically. To trigger or test a failover yourself, see [Failovers](/cloud/high-availability/failovers). | +| **Workflow starters and Clients** | Run with the Workers in the primary region. | Brought up in the secondary region along with the Workers. | +| **Codec Servers and proxies** | Run alongside the active Workers in the primary region. | Scaled up in the secondary region as part of the failover. | +| **Databases and queues** | Replicate to the secondary region, if your Workflows depend on that data. | Promote the secondary region's copy to active, if needed, so the new Workers can read and write it. | + +Setup is minimal: turn on Replication for your Namespace (see [Enable and manage High Availability](/cloud/high-availability/enable)) and enable replication on any databases or queues your Workflows use. At that point you're technically already running Active/Passive: the secondary region holds a ready replica, and failing over is a matter of bringing your Workers up there. + +Recovery time is dominated by two things: how quickly you detect the outage, since Workflows make no progress until you respond (see [Detect a failover or an outage](/cloud/high-availability/monitoring#detect-failover-or-outage)), and how long the secondary-region Worker fleet takes to cold start — container or VM startup, image pulls, and application warm-up. The [Active/Hot-Passive](#active-hot) pattern removes both by keeping Workers already running and warm in the secondary region. + +### Benefits {/* #active-cold-benefits */} + +Active/Passive offers the simplest operational model of the three patterns: + +- **Easy to reason about.** + - Only one region is active at a time, so traffic routing and interactions with systems (such as databases and queues) are simpler to understand, and the pattern pairs naturally with other active / passive systems. Active/Active, by contrast, requires deciding how Workers reach an active database: either a local active database in each region, or a single active / passive database that some Workers must reach cross-region. +- **Simple to operate.** + - During normal operation it resembles a single-region deployment. +- **Lowest overall architecture cost.** + - The size of the Worker fleet is simply the capacity needed to operate in one region. There are no standby Workers during steady state. + +### Tradeoffs {/* #active-cold-tradeoffs */} + +That simplicity comes at the cost of recovery time: + +- Highest overall recovery time of the three patterns, due to cold starting the Worker fleet after failover. +- Depends on tested automation to bring up the secondary-region fleet quickly. + +### Recommendations and important constraints {/* #active-cold-recommendations */} + +Keep these in mind when setting up Active/Passive: + +- **Failing over the Workers is the operator's responsibility.** The Namespace fails over automatically, but bringing up the Workers in the secondary region is up to you. Plan for these sub-considerations: + - **How do you detect an outage and decide to fail over?** Define the failover conditions and the signals (alerts, health checks) that trigger them. Because Workflows make no progress until you detect the outage and respond, detection is on the critical path of your recovery time. To monitor for an outage and a failover, see [Detect a failover or an outage](/cloud/high-availability/monitoring#detect-failover-or-outage). + - **How do you scale up the Workers?** Bring up the secondary-region fleet, ideally with tested automation, and scale down the primary region's fleet so Workers run in only one region at a time. + - **Do you need to enforce single-region processing?** This pattern relies on the operator to keep Workers in one region. To have Temporal enforce single-region processing instead, use the [Active/Hot-Passive](#active-hot) pattern. + +```mermaid +flowchart LR + FS1["Detect
outage"] + FS3["Failover
DBs / queues"] + FS4["Scale down
Primary region
Workers"] + FS5["Scale up
Secondary region
Workers"] + FS6["Confirm
Workflows run
normally"] + FS1 --> FS3 --> FS4 --> FS5 --> FS6 +``` + +- **Use the Namespace Endpoint.** + - Connect Workers through the [Namespace Endpoint](/cloud/namespaces#access-namespaces), which always connects to the Namespace in its active region and automatically fails over to the new region. + - **Rationale:** If a Temporal Cloud incident requires the Namespace to fail over while the rest of the primary region is healthy, the Workers in the primary region can still connect through the Namespace Endpoint and process Workflows. If the Workers use the Regional Endpoint for the primary region, they will not reliably connect to the Namespace during a Temporal Cloud incident in the primary region. + +```mermaid +flowchart LR + classDef worker stroke-width:1px; + classDef ns stroke-width:1px; + classDef endpoint stroke-width:1px; + classDef region stroke-width:1.5px; + NEW["Worker"]:::worker + NEEP["Namespace Endpoint"]:::endpoint + NEW --> NEEP + subgraph NEPRIM["Primary"] + NEPNS["Namespace"]:::ns + end + subgraph NESEC["Secondary"] + NESNS["Namespace"]:::ns + end + NEEP -->|"normal operation"| NEPNS + NEEP -.->|"after failover"| NESNS + class NEPRIM,NESEC region +``` + +- **Set up cross-region private connectivity.** + - If you use private connectivity, give the primary region's Workers a network route to the VPC Endpoint in the other region, so they can reach the active replica after a Namespace-only failover. If you can't provide that cross-region route, use the [Active/Hot-Passive](#active-hot) pattern instead, where each region's Workers connect to their local replica. + - For the full setup of Regional Endpoints, VPC Endpoints, and cross-region routing, see [Connectivity for High Availability](/cloud/high-availability/ha-connectivity). + +```mermaid +flowchart LR + classDef worker stroke-width:1px; + classDef ns stroke-width:1px; + classDef endpoint stroke-width:1px; + classDef region stroke-width:1.5px; + subgraph CRCPRIM["Primary Region"] + CRCPW["Worker"]:::worker + CRCPEP["VPC Endpoint"]:::endpoint + CRCPNS["Namespace"]:::ns + end + subgraph CRCSEC["Secondary Region"] + CRCSEP["VPC Endpoint"]:::endpoint + CRCSNS["Replica"]:::ns + end + CRCPW -->|"normal operation"| CRCPEP + CRCPEP --> CRCPNS + CRCSEP --> CRCSNS + CRCPW -.->|"after a Namespace failover"| CRCSEP + class CRCPRIM,CRCSEC region +``` + +- **Route Workers to the active region's Codec Server.** Two common approaches: + - Put DNS or a load balancer in front of the Codec Server address, and update it on failover to point at the new region's instance. + - Pass each Worker the Codec Server address for its own region as configuration, so a Worker always uses the service local to it. This is common in Kubernetes or with service discovery. +- **Route Workers to the active region's proxy.** Two common approaches: + - Put DNS or a load balancer in front of the proxy address, and update it on failover to point at the new region's instance. + - Pass each Worker the proxy address for its own region as configuration, so a Worker always uses the service local to it. This is common in Kubernetes or with service discovery. + +## Active/Hot-Passive {/* #active-hot */} + +A full Worker fleet runs in both regions, but only the primary region's fleet processes Workflows. The secondary region's fleet stays warm on standby, ready to take over the moment a failover promotes it, with no cold start. + +```mermaid +--- +title: Normal operation +--- +flowchart LR + classDef worker stroke-width:1px; + classDef standby stroke-width:1px; + classDef ns stroke-width:1px; + classDef region stroke-width:1.5px; + classDef pool stroke-width:1px; + subgraph HPRIM["Primary"] + subgraph HWP["Worker Pool"] + HW1["Worker
Active"]:::worker + HW2["Worker
Active"]:::worker + HW3["Worker
Active"]:::worker + end + HNS["Namespace"]:::ns + HWP <-->|Workflows| HNS + end + subgraph HSEC["Secondary"] + HR["Replica"]:::ns + subgraph HWP2["Worker Pool"] + HS1["Worker
Standby"]:::standby + HS2["Worker
Standby"]:::standby + HS3["Worker
Standby"]:::standby + end + HR <-.-> HWP2 + end + HNS --> HR + class HPRIM,HSEC region + class HWP,HWP2 pool +``` + +```mermaid +--- +title: After failover +--- +flowchart LR + classDef worker stroke-width:1px; + classDef ns stroke-width:1px; + classDef down stroke-width:1px,stroke-dasharray:3 3; + classDef region stroke-width:1.5px; + classDef regiondown stroke-width:1.5px; + classDef pool stroke-width:1px; + subgraph HFPRIM["Primary (outage)"] + subgraph HFPP["Worker Pool"] + HFPW1["Unavailable"]:::down + end + HFPN["Namespace"]:::down + HFPP ~~~ HFPN + end + subgraph HFSEC["Secondary"] + HFSN["Namespace
(Active)"]:::ns + subgraph HFSP["Worker Pool"] + HFSW1["Worker
Active"]:::worker + HFSW2["Worker
Active"]:::worker + HFSW3["Worker
Active"]:::worker + end + HFSN <-->|Workflows| HFSP + end + HFPN -->|"Failover"| HFSN + class HFPRIM regiondown + class HFSEC region + class HFPP,HFSP pool +``` + +Here's how each component behaves during normal operation and after a failover: + +| Component | Normal operation | On failover | +| --- | --- | --- | +| **Workers** | Run in both regions. The primary region's Workers are active and process all Workflows; the secondary region's Workers stay connected and warm on standby, doing no work. Forwarding is disabled for Worker polls, so the standby fleet adds no cross-region overhead. | The secondary region's standby Workers — already connected and warm — begin processing immediately. No cold start and no DNS wait. | +| **Namespace** | Active replica in the primary region; passive replica in the secondary region, continuously receiving replicated Workflow state. | The Namespace and Workers fail over together, automatically: Temporal Cloud promotes the secondary replica to active. | +| **Workflow starters and Clients** | Run in both regions alongside the Workers. | No changes needed — already running in both regions. | +| **Codec Servers and proxies** | Run in both regions continuously, not just after a failover. | No changes needed — already running in the secondary region. | +| **Databases and queues** | Workers in each region typically read and write only their local, active copy. | Promote the secondary region's copy to active, if needed, so the now-active Workers can read and write it. | + +Because a full Worker fleet is already running and warm in the secondary region, there's nothing to start or scale up before processing resumes. Along with Active/Active, this gives Active/Hot-Passive the lowest recovery time of the three patterns; because the standby fleet is already sized for full load, failover needs no scale-up. + +### Benefits {/* #active-hot-benefits */} + +Active/Hot-Passive trades steady-state cost for a faster, more predictable failover: + +- **Easy to reason about.** + - Only one region is active at a time, so traffic routing and interactions with systems (such as databases and queues) are simpler to understand, and the pattern pairs naturally with other active / passive systems. Active/Active, by contrast, requires deciding how Workers reach an active database: either a local active database in each region, or a single active / passive database that some Workers must reach cross-region. +- **Lowest recovery time, tied with Active/Active.** + - The secondary-region Workers are already connected and warm, so failover involves no cold start. Because the standby fleet is already sized for full load, it also needs no scale-up. +- **Low latency during normal operation.** + - Tasks are processed only in the active region, with no cross-region forwarding. + +### Tradeoffs {/* #active-hot-tradeoffs */} + +That speed comes at a steady-state cost: + +- Highest overall architecture cost: a full standby Worker fleet runs in the secondary region at all times, even during steady state. + +### Recommendations and important constraints {/* #active-hot-recommendations */} + +Keep this in mind when setting up Active/Hot-Passive: + +- **Use Regional or VPC Endpoints and disable forwarding.** + - Connect each Worker fleet through its region's [Regional Endpoint](/cloud/high-availability/ha-connectivity#regional-endpoint) (or VPC Endpoint) and [disable forwarding](/cloud/high-availability/enable#change-forwarding-behavior) for Worker polls. Using the Namespace Endpoint by mistake routes the standby Workers to the active region and defeats the pattern. + +```mermaid +flowchart LR + classDef worker stroke-width:1px; + classDef standby stroke-width:1px; + classDef ns stroke-width:1px; + classDef endpoint stroke-width:1px; + classDef region stroke-width:1.5px; + subgraph HEPRIM["Primary"] + HEPW["Workers
Active"]:::worker + HEPEP["Regional / VPC Endpoint"]:::endpoint + HEPNS["Namespace"]:::ns + HEPW --> HEPEP + HEPEP --> HEPNS + end + subgraph HESEC["Secondary"] + HESW["Workers
Standby"]:::standby + HESEP["Regional / VPC Endpoint"]:::endpoint + HESNS["Replica"]:::ns + HESW --> HESEP + HESEP --> HESNS + end + HEPNS -. replicates .-> HESNS + class HEPRIM,HESEC region +``` + +## Active/Active {/* #active-active */} + +Worker fleets run in as many regions as you want, all processing Workflows against the Namespace's single active replica through the Namespace Endpoint. If one region goes down, the others keep processing without interruption. + +```mermaid +--- +title: Normal operation +--- +flowchart LR + classDef worker stroke-width:1px; + classDef ns stroke-width:1px; + classDef region stroke-width:1.5px; + classDef pool stroke-width:1px; + subgraph APRIM["Primary"] + subgraph AWP["Worker Pool"] + AW1["Worker
Active"]:::worker + AW2["Worker
Active"]:::worker + end + ANS["Namespace"]:::ns + AWP <-->|Workflows| ANS + end + subgraph ASEC["Secondary"] + AR["Replica"]:::ns + subgraph AWP2["Worker Pool"] + AS1["Worker
Active"]:::worker + AS2["Worker
Active"]:::worker + end + AR <-->|Workflows| AWP2 + end + ANS --> AR + class APRIM,ASEC region + class AWP,AWP2 pool +``` + +```mermaid +--- +title: After failover +--- +flowchart LR + classDef worker stroke-width:1px; + classDef workerhollow stroke-width:1px,stroke-dasharray:4 3; + classDef ns stroke-width:1px; + classDef down stroke-width:1px,stroke-dasharray:3 3; + classDef region stroke-width:1.5px; + classDef regiondown stroke-width:1.5px; + classDef pool stroke-width:1px; + subgraph AFPRIM["Primary (outage)"] + subgraph AFPP["Worker Pool"] + AFPW1["Unavailable"]:::down + end + AFPN["Namespace"]:::down + AFPP ~~~ AFPN + end + subgraph AFSEC["Secondary"] + AFSN["Namespace
(Active)"]:::ns + subgraph AFSP["Worker Pool"] + AFSW1["Worker
Active"]:::worker + AFSW2["Worker
Active"]:::worker + AFSW3["Worker
Scaled up
(as needed)"]:::workerhollow + end + AFSN <-->|Workflows| AFSP + end + AFPN -->|"Failover"| AFSN + class AFPRIM regiondown + class AFSEC region + class AFPP,AFSP pool +``` + +Here's how each component behaves during normal operation and after a failover: + +| Component | Normal operation | On failover | +| --- | --- | --- | +| **Workers** | Run in as many regions as you want — fleets don't have to match the Namespace's regions. Every fleet connects through the Namespace Endpoint. | Fleets in unaffected regions keep processing with no cold-start gap. Scale them up if needed to carry the full load. | +| **Namespace** | One active replica; every other region holds a passive replica that receives replicated state. The Namespace Endpoint always routes to whichever region currently holds the active Namespace. | Temporal Cloud promotes a passive replica in another region to active. Every fleet follows automatically — no reconfiguration, nothing to bring up. | +| **Workflow starters and Clients** | Run wherever convenient and connect through the Namespace Endpoint, like the Workers. | Automatically follow the Namespace Endpoint to the new active region, like the Workers. | +| **Codec Servers and proxies** | Run in every region where Workers run. | Already running in every surviving region — no action needed. | +| **Databases and queues** | Accessed from every Worker region, so you need a cross-region consistency story. | Promote the active region's copy, if needed, so the Workers there can read and write it. | + +Because surviving regions keep processing without a cold start, recovery time depends mainly on how quickly you can scale them up to absorb the extra load, not on starting new Workers. + +### Benefits {/* #active-active-benefits */} + +Active/Active spreads capacity across regions instead of parking it in a standby fleet: + +- **Hands-off Worker failover.** + - With the Namespace Endpoint, Workers in every region follow the Namespace to the new active region automatically — there's no Worker failover step to run. +- **Lowest recovery time, no standby fleet.** + - Surviving regions keep processing, so there's no cold start, giving the same low recovery time as Active/Hot-Passive while spreading capacity across regions instead of parking it in a dedicated standby fleet. Surviving regions may need to scale up to absorb the failed region's load. +- **Resilient to losing a region.** + - Like spreading across Availability Zones, losing one region's fleet leaves the others running. + +### Tradeoffs {/* #active-active-tradeoffs */} + +Running Workers active in multiple regions introduces new considerations: + +- Workers outside the active region reach it across regions (directly or via forwarding), which adds latency that can matter for latency-sensitive Workflows. +- External systems are harder: Workers are active in multiple regions at once, so any databases and queues they touch need a cross-region consistency story. + +### Recommendations and important constraints {/* #active-active-recommendations */} + +Keep these in mind when setting up Active/Active: + +- **Default to the Namespace Endpoint.** + - All fleets, in any region, connect through the single Namespace Endpoint. It always routes to the active region and follows failovers automatically, so every fleet keeps reaching the active Namespace with no reconfiguration — it "just works," and Workers in all regions fail over automatically. One endpoint everywhere also keeps configuration and management simple. +- **Use a Regional Endpoint only when you need the lowest recovery time.** + - Connecting each fleet to its region's [Regional Endpoint](/cloud/high-availability/ha-connectivity#regional-endpoint) (or VPC Endpoint) removes the DNS step from the connection path, which can shave time off failover for the lowest possible RTO. The tradeoffs: more setup, and a real risk of misconfiguration (such as routing a fleet to the wrong region). Reach for it only when you absolutely need low recovery time. With Regional Endpoints, keep forwarding enabled so passive-region polls still reach the active replica. + +```mermaid +flowchart LR + classDef worker stroke-width:1px; + classDef ns stroke-width:1px; + classDef endpoint stroke-width:1px; + classDef region stroke-width:1.5px; + subgraph REPR1["Region 1"] + REPW1["Workers"]:::worker + REPE1["Regional Endpoint"]:::endpoint + REPN1["Namespace
(Active)"]:::ns + REPW1 --> REPE1 + REPE1 --> REPN1 + end + subgraph REPR2["Region 2"] + REPW2["Workers"]:::worker + REPE2["Regional Endpoint"]:::endpoint + REPN2["Replica"]:::ns + REPW2 --> REPE2 + REPE2 --> REPN2 + end + REPN2 -.->|"forwards polls"| REPN1 + class REPR1,REPR2 region +``` + +## Clients, Codec Servers, and databases {/* #clients-codec-servers-databases */} + +The Worker deployment pattern sets the approach; the supporting pieces follow it. + +- **Workflow starters and Clients.** Deploy these with the same regional pattern as the Workers, since a starter or Client often shares the same in-region dependencies (databases, queues, upstream services) and should fail over alongside them. Point Clients at the Namespace Endpoint so they follow the active region automatically with no configuration change on failover, and use a [Regional Endpoint](/cloud/high-availability/ha-connectivity#regional-endpoint) only when a Client must be pinned to a region. +- **Codec Servers and proxies.** Anything in the connection path between Workers and Temporal Cloud must be reachable from every region where Workers connect. In Active/Passive, scale them up in the secondary region as part of a failover; in the Active/Hot-Passive and Active/Active patterns, run them in both regions at all times. +- **Databases and queues.** These remain the application's responsibility, and the right approach depends on the Worker deployment pattern: a single-region-active datastore pairs naturally with the Active/Passive and Active/Hot-Passive patterns, while running Workers active in both regions (as in Active/Active) raises consistency questions that must be designed for. Detailed guidance is out of scope for this page. + +## Frequently asked questions {/* #faq */} + +### What is the difference between active-passive and active-active patterns? {/* #faq-active-passive-vs-active-active */} + +Both **Active/Passive** and **Active/Hot-Passive** keep Workflows processing in one region at a time, with the other region standing by for failover. **Active/Active** runs Workers in every region and processes Workflows in all of them at once. See [Worker deployment patterns](#ha-worker-patterns) for the full comparison. + +### How do I fail over Workers to another region? {/* #faq-fail-over-workers */} + +A Namespace with High Availability fails over automatically, but bringing up or activating Workers in the secondary region is your responsibility. The exact steps depend on your pattern; see [Active/Passive](#active-cold), [Active/Hot-Passive](#active-hot), and [Active/Active](#active-active). + +### Which pattern has the lowest recovery time (RTO)? {/* #faq-lowest-rto */} + +**Active/Hot-Passive** and **Active/Active** both achieve the lowest recovery time, because neither requires a cold start after failover. In Active/Hot-Passive, a warm standby Worker fleet in the secondary region begins processing the moment it becomes active. In Active/Active, Workers are already processing in every region, so the surviving regions keep running with no gap. By contrast, **Active/Passive** must cold start a Worker fleet in the secondary region, giving it the highest recovery time. See [Active/Hot-Passive](#active-hot), [Active/Active](#active-active), and [RPO and RTO](/cloud/rpo-rto). + +### Do I have to run Workers in both regions for high availability? {/* #faq-workers-both-regions */} + +No. **Active/Passive** runs Workers in one region at a time and is the simplest starting point for disaster recovery. Running Workers in both regions — **Active/Hot-Passive** or **Active/Active** — lowers recovery time at higher cost. + +### Does Temporal Cloud support Active/Active for HA/DR? {/* #faq-active-active */} + +Yes, as a Worker deployment pattern — not as a database-level active/active. A Temporal Cloud Namespace with High Availability always has exactly one active region and one passive replica underneath, no matter which [Worker deployment pattern](#ha-worker-patterns) you choose. But because Workers in any region reach the active replica through the Namespace Endpoint, you can run Worker fleets in every region and process Workflows in all of them at once. See [Active/Active](#active-active). + +### What special patterns are needed for multi-cloud HA/DR? {/* #faq-multi-cloud */} + +None specific to multi-cloud. Multi-region and multi-cloud HA/DR use the same [Worker deployment patterns](#ha-worker-patterns) — the secondary region can be any [Temporal Cloud region](/cloud/regions) that supports replication from the primary, whether in the same cloud provider or a different one. The same considerations apply: route Workers through the right [endpoints and private connectivity](/cloud/high-availability/ha-connectivity), and give any databases and queues a cross-region — here, cross-cloud — consistency story. + +If you use the Active/Passive pattern, it is highly recommended that you ensure Workers in the active cloud can reach the Namespace if it fails over to the passive cloud due to a Temporal-specific outage, so they can keep processing Tasks across the cross-cloud path. + +### What special considerations are there for private networking? {/* #faq-private-networking */} + +The patterns are the same. But if you use the Active/Passive pattern, it is highly recommended that you provide a network path for Workers in the active region to reach the VPC Endpoint in the passive region, so they can keep processing Tasks if the Namespace fails over due to a Temporal-specific outage. See [Connectivity for High Availability](/cloud/high-availability/ha-connectivity). diff --git a/docs/cloud/high-availability/enable.mdx b/docs/cloud/high-availability/enable.mdx index 97cb2c0c65..477cf1e745 100644 --- a/docs/cloud/high-availability/enable.mdx +++ b/docs/cloud/high-availability/enable.mdx @@ -6,12 +6,12 @@ slug: /cloud/high-availability/enable description: Add a replica to a Namespace to enable High Availability, then manage forwarding behavior and automatic failover settings. --- -import { ToolTipTerm } from '@site/src/components'; - You can enable [High Availability](/cloud/high-availability) features for a new or existing Namespace by adding a replica. When you add a replica, Temporal Cloud begins asynchronously replicating ongoing and existing Workflow Executions. +Adding a replica fails the Namespace over automatically; to plan how your Workers fail over with it, see [Worker deployment patterns for high availability and disaster recovery](/cloud/high-availability/architecture-patterns). + The replica region must be on the same continent as the primary region. Because of that, not all replication options are available in all Temporal Cloud regions. See the [Service regions](/cloud/regions) page for the supported replica regions for each active region. Using private network connectivity with a HA namespace requires extra setup. See @@ -137,6 +137,8 @@ Client APIs (Start, Signal, Cancel, Terminate, Query, and the equivalent Activit Same-region replicas are not affected by this setting. +To deploy Worker fleets in both regions that stay on standby in the passive region until failover, see [Active/Hot-Passive](/cloud/high-availability/architecture-patterns#active-hot). + :::info To see which endpoints route to which replica, see [How requests reach the replica](/cloud/high-availability/ha-connectivity#how-requests-reach-the-replica). @@ -152,10 +154,10 @@ Use the [`temporal cloud namespace ha update`](/cli/command-reference/cloud/name ```bash temporal cloud namespace ha update \ --namespace . \ - --disable-passive-poller-forwarding true + --passive-poller-forwarding disabled ``` -Set the flag to `false` to re-enable forwarding. +Set the flag to `enabled` to re-enable forwarding. ### Set the forwarding behavior with the Cloud Ops API {/* #set-forwarding-curl */} diff --git a/docs/cloud/high-availability/failovers/index.mdx b/docs/cloud/high-availability/failovers/index.mdx index 50b0018409..5d1213d9d0 100644 --- a/docs/cloud/high-availability/failovers/index.mdx +++ b/docs/cloud/high-availability/failovers/index.mdx @@ -149,7 +149,7 @@ reflected in the replica due to , particul non-graceful failover, replication lag may cause a temporary setback in Workflow progress. Namespaces that are not replicated can be configured to provide _at-most-once_ semantics for Activity execution when a -retry policy's [maximum attempts](https://docs.temporal.io/retry-policies#maximum-attempts) is set to 0. High +retry policy's [maximum attempts](/encyclopedia/retry-policies#maximum-attempts) is set to 0. High Availability Namespaces provide _at-least-once_ semantics for execution of Activities. Completed Activities _may_ be re-dispatched in a newly active Namespace, leading to repeated executions. diff --git a/docs/cloud/high-availability/failovers/manage.mdx b/docs/cloud/high-availability/failovers/manage.mdx index 42bf59eb05..2d832696cb 100644 --- a/docs/cloud/high-availability/failovers/manage.mdx +++ b/docs/cloud/high-availability/failovers/manage.mdx @@ -16,7 +16,7 @@ keywords: - temporal-cloud --- -import { ToolTipTerm, DiscoverableDisclosure } from '@site/src/components'; +import { ToolTipTerm } from '@site/src/components'; import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; @@ -178,6 +178,8 @@ the replica, the DNS redirection orchestrated by Temporal ensures that your exis Namespace without interruption. Temporal Cloud forwards their requests from the passive replica to the active region and the responses back, so Workers keep running through a failover. +To choose where your Worker fleets run across regions, see [Deployment patterns for High Availability](/cloud/high-availability/architecture-patterns). + To route Workers to the passive region's replica, see [How requests reach the replica](/cloud/high-availability/ha-connectivity#how-requests-reach-the-replica). To stop forwarding Worker polls to the active region, see [Change the forwarding behavior](/cloud/high-availability/enable#change-forwarding-behavior). @@ -211,8 +213,6 @@ runs help ensure the process runs smoothly during real incidents in production. ::: - - Failover testing (also known as "") can: - **Validate replicated deployments:** In multi-region setups, failover testing ensures your application can run from @@ -232,5 +232,3 @@ Failover testing (also known as "") can: - **Operational readiness:** Regular testing familiarizes your team with the failover process, improving their ability to handle real incidents. - - diff --git a/docs/cloud/high-availability/ha-connectivity.mdx b/docs/cloud/high-availability/ha-connectivity.mdx index 0dd79c3262..6693eceece 100644 --- a/docs/cloud/high-availability/ha-connectivity.mdx +++ b/docs/cloud/high-availability/ha-connectivity.mdx @@ -93,6 +93,10 @@ To learn what forwarding does, see [Request forwarding](/cloud/high-availability To stop forwarding Worker polls on a Namespace, see [Change the forwarding behavior](/cloud/high-availability/enable#change-forwarding-behavior). +To run Worker fleets in both regions that rely on this forwarding, see [Active/Active](/cloud/high-availability/architecture-patterns#active-active). + +To keep passive-region Workers on standby until failover by disabling this forwarding, see [Active/Hot-Passive](/cloud/high-availability/architecture-patterns#active-hot). + ## How to use PrivateLink with High Availability features :::tip @@ -116,6 +120,12 @@ Temporal Cloud expresses the active replica through DNS: Namespace DNS records have a 15-second TTL. Clients should converge to the new region within roughly 30 seconds (about twice the TTL) once their resolver cache expires. +:::note Deterministic DNS behavior is unique to HA Namespace Endpoints + +This is the **only** place in Temporal Cloud where you can depend on Temporal-managed DNS to behave in a specific, deterministic way — a Namespace Endpoint on an HA Namespace CNAMEing to its active region's regional record. Everywhere else, take a dependency on Temporal's published endpoints (the hostnames themselves), not on what they resolve to. The underlying IP addresses, CNAME chains, and resolution behavior of non-HA endpoints can change at any time without notice. + +::: + For private connectivity, your job is to make sure that: - Override the Regional Endpoint's DNS zone to resolve to a VPC Endpoint. @@ -202,19 +212,20 @@ This pattern is **different** from the long-term HA setup described in [Single-c ### How Namespace DNS records work with PrivateLink When using PrivateLink, you connect to Temporal Cloud through a VPC Endpoint, which uses addresses local to your network. -Temporal treats each `region.` as a separate zone. -This setup allows you to override the default zone, ensuring that traffic is routed internally for the regions you're using. +Temporal treats each `region.tmprl.cloud` zone as a separate zone, so you override resolution per region — this routes traffic to your VPC Endpoint internally for the regions you're using. A Namespace's active region is reflected in the target of the Namespace Endpoint's CNAME record. For example, if the active region of a Namespace is AWS us-east-1, the DNS configuration would look like this: -| ha-namespace.account-id.tmprl.cloud | CNAME | aws-us-east-1.region.tmprl.cloud | -| ----------------------------------- | ----- | -------------------------------- | +| Record name | Record type | Value | +| ----------------------------------- | ----------- | ---------------------------------- | +| ha-namespace.account-id.tmprl.cloud | CNAME | aws-us-east-1.region.tmprl.cloud | After a failover, the CNAME record is updated to point to the failover region, for example: -| ha-namespace.account-id.tmprl.cloud | CNAME | aws-us-west-2.region.tmprl.cloud | -| ----------------------------------- | ----- | -------------------------------- | +| Record name | Record type | Value | +| ----------------------------------- | ----------- | ---------------------------------- | +| ha-namespace.account-id.tmprl.cloud | CNAME | aws-us-west-2.region.tmprl.cloud | The Temporal domain did not change, but the CNAME updated from us-east-1 to us-west-2. @@ -235,7 +246,7 @@ In AWS, use a Route 53 private hosted zone for `region.tmprl.cloud` to override Link the private zone to every VPC where Workers run. -When your Workers connect to the Namespace, they first resolve `..tmprl.cloud`, which CNAMEs to `.region.tmprl.cloud`, which then resolves to your local VPC Endpoint. +When your Workers connect to the Namespace, they first resolve `..tmprl.cloud`, which CNAMEs to `aws-.region.tmprl.cloud`, which then resolves to your local VPC Endpoint. You also need to decide how Workers reach whichever region becomes active. Either: @@ -276,17 +287,6 @@ If you run Workers on Alpine and use multi-cloud HA, either: ::: -To set up the DNS override, configure specific regions to target the internal VPC Endpoint IP addresses. -For example, you might set `aws-us-west-1.region.tmprl.cloud` to target `192.168.1.2`. -In AWS, this can be done using a Route 53 private hosted zone for `region.tmprl.cloud`. -Link that private zone to the VPCs you use for Workers. - -A reasonable validation plan: - -Consider how you'll configure Workers for this setup. -You can either have Workers run in both regions continuously or establish connectivity between regions using Transit Gateway or VPC Peering. -Either approach ensures Workers can access the newly activated region once failover occurs. - ### Available regions, PrivateLink endpoints, and DNS record overrides :::caution diff --git a/docs/cloud/high-availability/index.mdx b/docs/cloud/high-availability/index.mdx index aec627a21e..2301297214 100644 --- a/docs/cloud/high-availability/index.mdx +++ b/docs/cloud/high-availability/index.mdx @@ -18,7 +18,7 @@ keywords: - temporal-cloud --- -import { ToolTipTerm, DiscoverableDisclosure, CaptionedImage } from '@site/src/components'; +import { ToolTipTerm, CaptionedImage } from '@site/src/components'; Temporal keeps your Workflows running even when a Worker crashes. But what happens when a whole data center crashes? Or a region? @@ -105,6 +105,10 @@ To route Workers to the passive region's replica, see [How requests reach the re To disable passive region replica forwarding, see [Change the forwarding behavior](/cloud/high-availability/enable#change-forwarding-behavior). +To run Worker fleets in both regions that rely on this forwarding, see [Active/Active](/cloud/high-availability/architecture-patterns#active-active). + +To keep passive-region Workers on standby until failover by disabling this forwarding, see [Active/Hot-Passive](/cloud/high-availability/architecture-patterns#active-hot). + ## Service levels and recovery objectives Namespaces using High Availability have a 99.99% [uptime SLA](/cloud/sla) with sub-1-minute [RPO](/cloud/rpo-rto) and 20-minute [RTO](/cloud/rpo-rto). For detailed information: @@ -115,6 +119,7 @@ Namespaces using High Availability have a 99.99% [uptime SLA](/cloud/sla) with s ## Failover High Availability Namespaces can automatically or manually [fail over](/cloud/high-availability/failovers) to the replica if the primary is unavailable or unhealthy. +The Namespace fails over automatically, but your Workers and the rest of your architecture need their own plan — see [Worker deployment patterns for Active-Passive and Active-Active HA/DR](/cloud/high-availability/architecture-patterns). ## Target workloads diff --git a/docs/cloud/high-availability/monitoring.mdx b/docs/cloud/high-availability/monitoring.mdx index 4e1b79b5ed..b2f4fce318 100644 --- a/docs/cloud/high-availability/monitoring.mdx +++ b/docs/cloud/high-availability/monitoring.mdx @@ -26,6 +26,24 @@ import { ToolTipTerm } from '@site/src/components'; Temporal Cloud offers several ways for you to track the health and performance of your [High Availability](/cloud/high-availability) namespaces. +## Detect a failover or an outage {/* #detect-failover-or-outage */} + +With some [Worker deployment patterns](/cloud/high-availability/architecture-patterns) — most notably [Active/Passive](/cloud/high-availability/architecture-patterns#active-cold) — detecting an outage is your responsibility, and your Workflows make no progress until you detect it and bring up Workers in the new active region. Fast, reliable detection therefore directly determines your recovery time, so it is worth monitoring for both of the following. + +### Detect a failover {/* #detect-a-failover */} + +The clearest way to detect that a failover has happened is to watch whether your Namespace's active region changed. When Temporal Cloud promotes the replica in the secondary region to active, the active region reported for the Namespace changes — a reliable, unambiguous signal that a failover occurred. To track failovers as they happen, look for the `FailoverNamespace` operation described in [Failover audit log](#failover-audit-log). + +### Detect an outage {/* #detect-an-outage */} + +A failover is not the only signal worth watching. You may want to detect a regional outage directly, before or independently of a Namespace failover, so you can begin your own response. Watch for: + +- **A spike in replication lag** between the primary and the replica. See [Monitoring replication](#monitoring-replication). +- **A drop in Workflow throughput**, such as a sudden decline in the rate of Workflows started, completed, or Tasks processed. +- **A spike in errors across your overall stack**, not just Temporal — for example, application errors, failed Activities, or connection failures. +- **A drop in throughput across your overall stack**, such as fewer requests reaching your services or fewer Activities executing. +- **Errors or failovers in other cloud systems you depend on**, such as databases, queues, or other regional services, which often signal a broader regional outage. + ## Replication status You can monitor your replica status with the Temporal Cloud UI. If the replica is unhealthy, Temporal Cloud disables the diff --git a/docs/cloud/manage-access/custom-roles.mdx b/docs/cloud/manage-access/custom-roles.mdx index db3c048ed6..3f391488c1 100644 --- a/docs/cloud/manage-access/custom-roles.mdx +++ b/docs/cloud/manage-access/custom-roles.mdx @@ -92,7 +92,7 @@ required principal-update permissions by default. ## Available permissions -Most of the Control Plane operations listed in the API references +Most of the operations listed in the Cloud Ops API references ([HTTP](https://saas-api.tmprl.cloud/docs/httpapi.html#description/introduction), [GRPC](https://saas-api.tmprl.cloud/docs/grpcapi/)) can be assigned to a Custom Role. For the operations that are supported by Custom Roles, see diff --git a/docs/cloud/manage-access/permissions-reference.mdx b/docs/cloud/manage-access/permissions-reference.mdx index 6556e67da3..7fad3a0fc5 100644 --- a/docs/cloud/manage-access/permissions-reference.mdx +++ b/docs/cloud/manage-access/permissions-reference.mdx @@ -14,7 +14,7 @@ Temporal Cloud access controls are organized across two scopes: - Account-level role permissions - Namespace-level permissions -Within each scope, permissions apply to publicly documented [Temporal Cloud Ops API](https://docs.temporal.io/ops) +Within each scope, permissions apply to publicly documented [Temporal Cloud Ops API](/ops) endpoints and to additional non-Cloud Ops capabilities, such as Temporal Cloud UI and internal automation behaviors. ## Account-level access {/* #account-level-access */} @@ -38,6 +38,7 @@ configured per user. | [AddUserGroupMember](https://saas-api.tmprl.cloud/docs/httpapi.html#tag/groups/POST/cloud/user-groups/%7BgroupId%7D/members) | | | | ✔ | ✔ | | [CreateAccountAuditLogSink](https://saas-api.tmprl.cloud/docs/httpapi.html#tag/account/POST/cloud/audit-log-sinks) | | | | ✔ | ✔ | | [CreateApiKey](https://saas-api.tmprl.cloud/docs/httpapi.html#tag/api-keys/POST/cloud/api-keys) | ✔\* | ✔\* | ✔\* | ✔\* | ✔\* | +| [CreateBillingReport](https://saas-api.tmprl.cloud/docs/httpapi.html#tag/billing/POST/cloud/billing-reports) | | | ✔ | | ✔ | | [CreateConnectivityRule](https://saas-api.tmprl.cloud/docs/httpapi.html#tag/connectivity-rules/POST/cloud/connectivity-rules) | | | | ✔ | ✔ | | [CreateNamespace](https://saas-api.tmprl.cloud/docs/httpapi.html#tag/namespaces/POST/cloud/namespaces) | | ✔ | | ✔ | ✔ | | [CreateNexusEndpoint](https://saas-api.tmprl.cloud/docs/httpapi.html#tag/nexus/POST/cloud/nexus/endpoints) | | ✔ | | ✔ | ✔ | @@ -58,6 +59,7 @@ configured per user. | [GetApiKeys](https://saas-api.tmprl.cloud/docs/httpapi.html#tag/api-keys/GET/cloud/api-keys) | ✔\* | ✔\* | ✔\* | ✔\* | ✔\* | | [GetAsyncOperation](https://saas-api.tmprl.cloud/docs/httpapi.html#tag/operations/GET/cloud/operations/%7BasyncOperationId%7D) | ✔ | ✔ | ✔ | ✔ | ✔ | | [GetAuditLogs](https://saas-api.tmprl.cloud/docs/httpapi.html#tag/account/GET/cloud/audit-logs) | | | | ✔ | ✔ | +| [GetBillingReport](https://saas-api.tmprl.cloud/docs/httpapi.html#tag/billing/GET/cloud/billing-reports/%7BbillingReportId%7D) | | | ✔ | | ✔ | | [GetConnectivityRule](https://saas-api.tmprl.cloud/docs/httpapi.html#tag/connectivity-rules/GET/cloud/connectivity-rules/%7BconnectivityRuleId%7D) | | ✔ | | ✔ | ✔ | | [GetConnectivityRules](https://saas-api.tmprl.cloud/docs/httpapi.html#tag/connectivity-rules/GET/cloud/connectivity-rules) | | ✔ | | ✔ | ✔ | | [GetCurrentIdentity](https://saas-api.tmprl.cloud/docs/httpapi.html#tag/account/GET/cloud/current-identity) | ✔ | ✔ | ✔ | ✔ | ✔ | diff --git a/docs/cloud/metrics/openmetrics/api-reference.mdx b/docs/cloud/metrics/openmetrics/api-reference.mdx index 4ef491e8dd..785ba15630 100644 --- a/docs/cloud/metrics/openmetrics/api-reference.mdx +++ b/docs/cloud/metrics/openmetrics/api-reference.mdx @@ -46,7 +46,7 @@ API keys can be created using the [Temporal Cloud UI](https://cloud.temporal.io) :::info -See the [docs](https://docs.temporal.io/cloud/api-keys#serviceaccount-api-keys) for more details on generating API keys. +See the [docs](/cloud/api-keys#serviceaccount-api-keys) for more details on generating API keys. ::: diff --git a/docs/cloud/metrics/openmetrics/metrics-reference.mdx b/docs/cloud/metrics/openmetrics/metrics-reference.mdx index e00a3fa272..4fdb21169a 100644 --- a/docs/cloud/metrics/openmetrics/metrics-reference.mdx +++ b/docs/cloud/metrics/openmetrics/metrics-reference.mdx @@ -625,12 +625,6 @@ The number of billable actions per second, broken down by action type and Workfl | `action_type` | The [action](/cloud/actions) type | | `temporal_workflow_type` | The workflow type | -:::note Public Preview - -This metric is currently in [Public Preview](/evaluate/development-production-features/release-stages#public-preview). - -::: - :::caution High Cardinality This metric could have high cardinality depending on number of action types and workflow types. @@ -777,3 +771,15 @@ Does not include the `region` label. Limits are scoped to the Namespace level. **Type**: Value +#### temporal\_cloud\_v1\_provisioned\_capacity\_tru\_count + +The number of Temporal Resource Units (TRUs) provisioned for a Namespace. This is `0` for Namespaces that are not in [provisioned capacity mode](/cloud/capacity-modes). + +:::note + +Does not include the `region` label. Provisioned capacity is scoped to the Namespace level. + +::: + +**Type**: Value + diff --git a/docs/cloud/metrics/prometheus-grafana.mdx b/docs/cloud/metrics/prometheus-grafana.mdx index dbba84ba3c..5adf3fc49f 100644 --- a/docs/cloud/metrics/prometheus-grafana.mdx +++ b/docs/cloud/metrics/prometheus-grafana.mdx @@ -25,7 +25,7 @@ tags: - Temporal Cloud --- -import { ZoomingImage } from '@site/src/components'; +import { EnlargeImage } from '@site/src/components'; :::danger PromQL endpoint deprecated @@ -242,7 +242,7 @@ To add the Temporal Cloud Prometheus HTTP API endpoint that we generated in the - Paste in your end-entity certificate and key. - Note that the end-entity certificate used here must be part of the certificate chain with the root CA certificates used in your [Temporal Cloud observability setup](/cloud/metrics/general-setup). - diff --git a/docs/cloud/metrics/sdk-metrics-setup.mdx b/docs/cloud/metrics/sdk-metrics-setup.mdx index ddc6ddc37f..259e19251d 100644 --- a/docs/cloud/metrics/sdk-metrics-setup.mdx +++ b/docs/cloud/metrics/sdk-metrics-setup.mdx @@ -20,8 +20,6 @@ tags: - Temporal Cloud --- -import { ZoomingImage } from '@site/src/components'; - SDK metrics are emitted by SDK Clients used to start your Workers and to start, signal, or query your Workflow Executions. Unlike [Temporal Cloud metrics](/cloud/metrics/), which are exposed through a Prometheus HTTP API endpoint, SDK metrics require you to set up a Prometheus scrape endpoint in your application code for Prometheus to collect and aggregate. diff --git a/docs/cloud/migrate/manual.mdx b/docs/cloud/migrate/manual.mdx index 6a1d03a5c4..f5f55438b1 100644 --- a/docs/cloud/migrate/manual.mdx +++ b/docs/cloud/migrate/manual.mdx @@ -93,4 +93,4 @@ The volume of these requests might be high to execute against all the matches to [Multi-Cluster Replication](/self-hosted-guide/multi-cluster-replication) is an experimental feature which asynchronously replicates Workflow Executions from active Clusters to other passive Clusters for backup and state reconstruction. Migrating Execution History from a self-hosted Temporal Service to Temporal Cloud is not currently supported. However, a migration tool based on Multi-Cluster Replication, which will enable this, is currently in development for Temporal Cloud. -If you have used this feature locally or you are interested in using it to migrate to Temporal Cloud, [create a support ticket](https://docs.temporal.io/cloud/support) or watch this space for more information about public availability. +If you have used this feature locally or you are interested in using it to migrate to Temporal Cloud, [create a support ticket](/cloud/support) or watch this space for more information about public availability. diff --git a/docs/cloud/operation-api.mdx b/docs/cloud/operation-api.mdx index d991f8ff22..9c2f8c83c5 100644 --- a/docs/cloud/operation-api.mdx +++ b/docs/cloud/operation-api.mdx @@ -22,14 +22,21 @@ The Temporal Cloud Operations API, or the Cloud Ops API, is an open source, publ ## Develop applications with the Cloud Ops API -You can use the HTTP API or the gRPC API depending on how you need to integrate with your platform. The URL to access both the HTTP and gRPC Cloud Ops API is `saas-api.tmprl.cloud`. +You can use the HTTP API or the gRPC API depending on how you need to integrate with your platform. + +- HTTP: `https://saas-api.tmprl.cloud` +- gRPC: `saas-api.tmprl.cloud:443` + +If you connect to Temporal Cloud over [AWS PrivateLink](/cloud/connectivity/aws-connectivity) or [GCP Private Service Connect](/cloud/connectivity/gcp-connectivity), configure private DNS for `saas-api.tmprl.cloud` so control-plane clients (Cloud Ops API, Terraform, tcld) reach the private endpoint. ### Prerequisites These prerequisites are required for using either HTTP or gRPC. -- [Temporal Cloud user account](/cloud/get-started) -- [API Key](/cloud/tcld/apikey#create) for authentication +- A Temporal Cloud [User](/cloud/manage-access/users) or [Service Account](/cloud/manage-access/service-accounts) +- An [API Key](/cloud/api-keys#manage-api-keys) for authentication (owned by that User or Service Account) + +Required [roles and permissions](/cloud/manage-access/roles-and-permissions) vary by operation. Some account-wide management operations require Account Owner or Global Admin. Others work with Developer, Finance Admin, Namespace Admin, or custom roles. ### Use cases @@ -68,17 +75,17 @@ To start using the Go SDK with the Cloud Ops API, follow these steps: 1. Install the Go SDK: ```go - go get github.com/temporalio/cloud-sdk-go + go get go.temporal.io/cloud-sdk@latest ``` 2. Import and use the SDK: ```go import ( - "github.com/temporalio/cloud-sdk-go/client" + "go.temporal.io/cloud-sdk/cloudclient" ) ``` -3. The Go SDK provides pre-built client interfaces that handle authentication and connection setup. Refer to the [Go samples](https://github.com/temporalio/cloud-samples-go) for detailed usage examples. +3. The Go SDK provides pre-built client interfaces that handle authentication and connection setup. Refer to the [Go samples](https://github.com/temporalio/cloud-samples-go) for detailed usage examples, including [Cloud Ops API client setup](https://github.com/temporalio/cloud-samples-go/blob/main/client/api/client.go). The Go SDK eliminates the need to work directly with generated protobuf files and provides a more idiomatic Go experience. @@ -88,6 +95,8 @@ For programming languages other than Go, download the gRPC protobufs from the [C Use [gRPC](https://grpc.io/docs/) to compile and generate code in your preferred [programming language](https://grpc.io/docs/#official-support). The steps below use Python as an example and require [Python's gRPC tools](https://grpc.io/docs/languages/python/quickstart/#grpc-tools) to be installed, but the approach can be adapted for other supported programming languages. +You can also generate clients from the published Buf module at [buf.build/temporalio/cloud-api](https://buf.build/temporalio/cloud-api). + 1. Clone the Temporal Cloud API repository: ```command @@ -95,32 +104,24 @@ Use [gRPC](https://grpc.io/docs/) to compile and generate code in your preferred cd cloud-api ``` -2. Copy Protobuf files: - - - Navigate to the `temporal` directory. - - Copy the protobuf files to your project directory. +2. Compile the Protobuf files from the repository root (protos live under `temporal/` and import each other): -3. Compile the Protobuf files: - - ```python - python -m grpc_tools.protoc -I./ --python_out=./ --grpc_python_out=./ *.proto + ```command + python -m grpc_tools.protoc \ + -I. \ + --python_out=. \ + --grpc_python_out=. \ + $(find temporal -name '*.proto') ``` - - `-I` specifies the directory of the `.proto` files. - - `--python_out=` sets the output directory for generated Python classes. - - `--grpc_python_out=` sets the output directory for generated gRPC service classes. - - `*.proto` processes all `.proto` files. + + - `-I.` adds the repository root to the import path. + - `--python_out=` and `--grpc_python_out=` set the output directories for generated classes. + - `find temporal -name '*.proto'` includes all Cloud Ops API protos and their dependencies under `temporal/`. After compiling the Protobuf files, you will have generated code files in your project directory. These files enable interaction with the Temporal Cloud API in your chosen programming language. -4. Import the Generated Files: - - - Locate the Python files (.py) generated in your project directory. - - Import these files into your Python application where you intend to interact with the Temporal Cloud API. - -2. Use the API: - - Use the classes and methods defined in the imported files to communicate with the Temporal Cloud services. - - Ensure to handle any required authentication or configuration as needed for Temporal Cloud. +3. Import the generated files into your application and call the Cloud Ops API. Handle authentication with an API key and set the API version header as described in [Usage guidelines](#usage-guidelines). This approach can be adapted for other programming languages by following their respective import and usage conventions for the generated code files. @@ -128,20 +129,30 @@ This approach can be adapted for other programming languages by following their When interacting with the Temporal Cloud Ops API, follow these guidelines: -- API version header: - - Always include the `temporal-cloud-api-version` header in your requests, specifying the API version identifier. - - The current API version can be found [here](https://github.com/temporalio/cloud-api/blob/main/VERSION#L1C1-L1C14). -- Connection URL: - - Connect to the Temporal Cloud using the gRPC URL: `saas-api.tmprl.cloud:443`. -- Engagement steps: - - Generate API key: - - Obtain an [API Key for authentication](/cloud/api-keys#manage-api-keys). Note that many operations may require Admin privileges. - - Set up client: - - Establish a secure connection to the Temporal Cloud. Refer to the example [Client setup in Go](https://github.com/temporalio/cloud-samples-go/blob/main/client/temporal/client.go) for guidance. - - Execute operations: - - For operation specifics, refer to the `cloudservice/v1/request_response.proto` for gRPC messages and `cloudservice/v1/service.proto` for gRPC services. - -These steps provide a structured approach to using the Temporal Cloud Ops API effectively, ensuring proper authentication and connection setup. +### API version header + +Use the `temporal-cloud-api-version` header to select an API version. The backend uses this version to safely mutate resources. The current API version is in the [`cloud-api` VERSION file](https://github.com/temporalio/cloud-api/blob/main/VERSION). + +**gRPC** + +gRPC clients must send a `temporal-cloud-api-version` header on every request. + +**HTTP** + +For HTTP clients, the version header is optional. If omitted, the HTTP gateway defaults it to the latest API version. This supports simple `curl` usage without looking up a version first. + +For production HTTP automation, still pin an explicit version so behavior does not change when the gateway’s latest version advances. + +### Connection URL + +- gRPC: `saas-api.tmprl.cloud:443` +- HTTP: `https://saas-api.tmprl.cloud` + +### Engagement steps + +1. Generate an [API Key for authentication](/cloud/api-keys#manage-api-keys). Required permissions vary by operation; see [roles and permissions](/cloud/manage-access/roles-and-permissions). +2. Establish a secure connection. For Go, see [Cloud Ops API client setup](https://github.com/temporalio/cloud-samples-go/blob/main/client/api/client.go). +3. Execute operations. For request and response messages, see `cloudservice/v1/request_response.proto`. For services, see `cloudservice/v1/service.proto`. ## Rate limits @@ -163,9 +174,9 @@ This limit applies to all requests made by each user through any client (tcld, U This limit applies to all requests made by each service account through any client (tcld, Cloud Ops API). -**Asynchronous Operations: 10 concurrent operations at a time** +**Asynchronous operations: 10 concurrent operations at a time** -This limits the number of concurrent asynchronous operations that can be in-flight at any given time. +By default, each account can have up to 10 long-running (asynchronous) mutating operations in flight at once. This limit applies to a subset of create, update, and delete operations (for example Namespace, User, API Key, Export Sink, and Service Account mutations), not every RPC that returns an async operation. ### Important considerations diff --git a/docs/cloud/references/regions/awsregions.md b/docs/cloud/references/regions/awsregions.md index a1c9f4dc53..20c1cd3f33 100644 --- a/docs/cloud/references/regions/awsregions.md +++ b/docs/cloud/references/regions/awsregions.md @@ -1,7 +1,7 @@ ### Asia Pacific - Tokyo (`ap-northeast-1`) - **Cloud API Code**: `aws-ap-northeast-1` -- **Regional Endpoint**: `aws-ap-northeast-1.region.tmprl.cloud` +- **Regional Endpoint**: `ap-northeast-1.aws.api.temporal.io:7233` - **PrivateLink Endpoint Service**: `com.amazonaws.vpce.ap-northeast-1.vpce-svc-08f34c33f9fb8a48a` - **Same Region Replication**: Not Available - **Multi-Region Replication**: @@ -16,7 +16,7 @@ ### Asia Pacific - Seoul (`ap-northeast-2`) - **Cloud API Code**: `aws-ap-northeast-2` -- **Regional Endpoint**: `aws-ap-northeast-2.region.tmprl.cloud` +- **Regional Endpoint**: `ap-northeast-2.aws.api.temporal.io:7233` - **PrivateLink Endpoint Service**: `com.amazonaws.vpce.ap-northeast-2.vpce-svc-08c4d5445a5aad308` - **Same Region Replication**: Not Available - **Multi-Region Replication**: @@ -31,7 +31,7 @@ ### Asia Pacific - Mumbai (`ap-south-1`) - **Cloud API Code**: `aws-ap-south-1` -- **Regional Endpoint**: `aws-ap-south-1.region.tmprl.cloud` +- **Regional Endpoint**: `ap-south-1.aws.api.temporal.io:7233` - **PrivateLink Endpoint Service**: `com.amazonaws.vpce.ap-south-1.vpce-svc-0ad4f8ed56db15662` - **Same Region Replication**: Not Available - **Multi-Region Replication**: @@ -46,7 +46,7 @@ ### Asia Pacific - Hyderabad (`ap-south-2`) - **Cloud API Code**: `aws-ap-south-2` -- **Regional Endpoint**: `aws-ap-south-2.region.tmprl.cloud` +- **Regional Endpoint**: `ap-south-2.aws.api.temporal.io:7233` - **PrivateLink Endpoint Service**: `com.amazonaws.vpce.ap-south-2.vpce-svc-08bcf602b646c69c1` - **Same Region Replication**: Not Available - **Multi-Region Replication**: @@ -61,7 +61,7 @@ ### Asia Pacific - Singapore (`ap-southeast-1`) - **Cloud API Code**: `aws-ap-southeast-1` -- **Regional Endpoint**: `aws-ap-southeast-1.region.tmprl.cloud` +- **Regional Endpoint**: `ap-southeast-1.aws.api.temporal.io:7233` - **PrivateLink Endpoint Service**: `com.amazonaws.vpce.ap-southeast-1.vpce-svc-05c24096fa89b0ccd` - **Same Region Replication**: Not Available - **Multi-Region Replication**: @@ -76,7 +76,7 @@ ### Asia Pacific - Sydney (`ap-southeast-2`) - **Cloud API Code**: `aws-ap-southeast-2` -- **Regional Endpoint**: `aws-ap-southeast-2.region.tmprl.cloud` +- **Regional Endpoint**: `ap-southeast-2.aws.api.temporal.io:7233` - **PrivateLink Endpoint Service**: `com.amazonaws.vpce.ap-southeast-2.vpce-svc-0634f9628e3c15b08` - **Same Region Replication**: Available - **Multi-Region Replication**: @@ -91,7 +91,7 @@ ### Europe - Frankfurt (`eu-central-1`) - **Cloud API Code**: `aws-eu-central-1` -- **Regional Endpoint**: `aws-eu-central-1.region.tmprl.cloud` +- **Regional Endpoint**: `eu-central-1.aws.api.temporal.io:7233` - **PrivateLink Endpoint Service**: `com.amazonaws.vpce.eu-central-1.vpce-svc-073a419b36663a0f3` - **Same Region Replication**: Not Available - **Multi-Region Replication**: @@ -103,7 +103,7 @@ ### Europe - Ireland (`eu-west-1`) - **Cloud API Code**: `aws-eu-west-1` -- **Regional Endpoint**: `aws-eu-west-1.region.tmprl.cloud` +- **Regional Endpoint**: `eu-west-1.aws.api.temporal.io:7233` - **PrivateLink Endpoint Service**: `com.amazonaws.vpce.eu-west-1.vpce-svc-04388e89f3479b739` - **Same Region Replication**: Not Available - **Multi-Region Replication**: @@ -115,7 +115,7 @@ ### Europe - London (`eu-west-2`) - **Cloud API Code**: `aws-eu-west-2` -- **Regional Endpoint**: `aws-eu-west-2.region.tmprl.cloud` +- **Regional Endpoint**: `eu-west-2.aws.api.temporal.io:7233` - **PrivateLink Endpoint Service**: `com.amazonaws.vpce.eu-west-2.vpce-svc-0ac7f9f07e7fb5695` - **Same Region Replication**: Not Available - **Multi-Region Replication**: @@ -128,7 +128,7 @@ - **Cloud API Code**: `aws-ca-central-1` - **PrivateLink Endpoint Service**: `com.amazonaws.vpce.ca-central-1.vpce-svc-080a781925d0b1d9d` -- **Regional Endpoint**: `aws-ca-central-1.region.tmprl.cloud` +- **Regional Endpoint**: `ca-central-1.aws.api.temporal.io:7233` - **Same Region Replication**: Not Available - **Multi-Region Replication**: - `aws-us-east-1` @@ -142,7 +142,7 @@ ### North America - Northern Virginia (`us-east-1`) - **Cloud API Code**: `aws-us-east-1` -- **Regional Endpoint**: `aws-us-east-1.region.tmprl.cloud` +- **Regional Endpoint**: `us-east-1.aws.api.temporal.io:7233` - **PrivateLink Endpoint Service**: `com.amazonaws.vpce.us-east-1.vpce-svc-0822256b6575ea37f` - **Same Region Replication**: Available - **Multi-Region Replication**: @@ -157,7 +157,7 @@ ### North America - Ohio (`us-east-2`) - **Cloud API Code**: `aws-us-east-2` -- **Regional Endpoint**: `aws-us-east-2.region.tmprl.cloud` +- **Regional Endpoint**: `us-east-2.aws.api.temporal.io:7233` - **PrivateLink Endpoint Service**: `com.amazonaws.vpce.us-east-2.vpce-svc-01b8dccfc6660d9d4` - **Same Region Replication**: Not Available - **Multi-Region Replication**: @@ -172,7 +172,7 @@ ### North America - Oregon (`us-west-2`) - **Cloud API Code**: `aws-us-west-2` -- **Regional Endpoint**: `aws-us-west-2.region.tmprl.cloud` +- **Regional Endpoint**: `us-west-2.aws.api.temporal.io:7233` - **PrivateLink Endpoint Service**: `com.amazonaws.vpce.us-west-2.vpce-svc-0f44b3d7302816b94` - **Same Region Replication**: Available - **Multi-Region Replication**: @@ -187,7 +187,7 @@ ### South America - São Paulo (`sa-east-1`) - **Cloud API Code**: `aws-sa-east-1` -- **Regional Endpoint**: `aws-sa-east-1.region.tmprl.cloud` +- **Regional Endpoint**: `sa-east-1.aws.api.temporal.io:7233` - **PrivateLink Endpoint Service**: `com.amazonaws.vpce.sa-east-1.vpce-svc-0ca67a102f3ce525a` - **Same Region Replication**: Not Available - **Multi-Region Replication**: diff --git a/docs/cloud/references/regions/gcpregions.md b/docs/cloud/references/regions/gcpregions.md index 2103d52f45..4d8bbca212 100644 --- a/docs/cloud/references/regions/gcpregions.md +++ b/docs/cloud/references/regions/gcpregions.md @@ -1,7 +1,7 @@ ### North America - Iowa (`us-central1`) - **Cloud API Code**: `gcp-us-central1` -- **Regional Endpoint**: `gcp-us-central1.region.tmprl.cloud` +- **Regional Endpoint**: `us-central1.gcp.api.temporal.io:7233` - **Private Service Connect Service Attachment URI**: `projects/prod-d9ch6v2ybver8d2a8fyf7qru9/regions/us-central1/serviceAttachments/pl-5xzng` - **Same Region Replication**: Not Available - **Multi-Region Replication**: @@ -16,7 +16,7 @@ ### North America - Oregon (`us-west1`) - **Cloud API Code**: `gcp-us-west1` -- **Regional Endpoint**: `gcp-us-west1.region.tmprl.cloud` +- **Regional Endpoint**: `us-west1.gcp.api.temporal.io:7233` - **Private Service Connect Service Attachment URI**: `projects/prod-rbe76zxxzydz4cbdz2xt5b59q/regions/us-west1/serviceAttachments/pl-94w0x` - **Same Region Replication**: Not Available - **Multi-Region Replication**: @@ -31,7 +31,7 @@ ### North America - Northern Virginia (`us-east4`) - **Cloud API Code**: `gcp-us-east4` -- **Regional Endpoint**: `gcp-us-east4.region.tmprl.cloud` +- **Regional Endpoint**: `us-east4.gcp.api.temporal.io:7233` - **Private Service Connect Service Attachment URI**: `projects/prod-y399cvr9c2b43es2w3q3e4gvw/regions/us-east4/serviceAttachments/pl-8awsy` - **Same Region Replication**: Not Available - **Multi-Region Replication**: @@ -46,7 +46,7 @@ ### Europe - Frankfurt (`europe-west3`) - **Cloud API Code**: `gcp-europe-west3` -- **Regional Endpoint**: `gcp-europe-west3.region.tmprl.cloud` +- **Regional Endpoint**: `europe-west3.gcp.api.temporal.io:7233` - **Private Service Connect Service Attachment URI**: `projects/prod-kwy7d4faxp6qgrgd9x94du36g/regions/europe-west3/serviceAttachments/pl-acgsh` - **Same Region Replication**: Not Available - **Multi-Region Replication**: @@ -59,7 +59,7 @@ ### Asia Pacific - Mumbai (`asia-south1`) - **Cloud API Code**: `gcp-asia-south1` -- **Regional Endpoint**: `gcp-asia-south1.region.tmprl.cloud` +- **Regional Endpoint**: `asia-south1.gcp.api.temporal.io:7233` - **Private Service Connect Service Attachment URI**: `projects/prod-d5spc2sfeshws33bg33vwdef7/regions/asia-south1/serviceAttachments/pl-7w7tw` - **Same Region Replication**: Not Available - **Multi-Region Replication**: diff --git a/docs/cloud/rto-rpo.mdx b/docs/cloud/rto-rpo.mdx index 032bf41a87..123c901ed4 100644 --- a/docs/cloud/rto-rpo.mdx +++ b/docs/cloud/rto-rpo.mdx @@ -20,8 +20,6 @@ tags: - Temporal Cloud --- -import { ToolTipTerm } from '@site/src/components'; - When a cloud outage disrupts a Namespace, Temporal Cloud takes measures to maintain the Namespace's availability and data durability. The time it takes to recover from the outage is called the _recovery time_. The _recovery point_ is how far back in time data must be recovered from after an outage. A durable system should have a low recovery time and a @@ -34,6 +32,7 @@ the Recovery Time Objective (RTO) and Recovery Point Objective (RPO). For detail The RTO and RPO for a Namespace depend on the type of outage and which [High Availability](/cloud/high-availability) features the Namespace has enabled. +Your real-world recovery time also depends on your [Worker deployment pattern](/cloud/high-availability/architecture-patterns) — how quickly Workers resume processing in the new region after the Namespace fails over. ## RTO and RPO summary diff --git a/docs/cloud/terraform-provider.mdx b/docs/cloud/terraform-provider.mdx index 3a0b841e88..a1f4ec9355 100644 --- a/docs/cloud/terraform-provider.mdx +++ b/docs/cloud/terraform-provider.mdx @@ -44,7 +44,7 @@ To use the Terraform provider, you'll need the following: - The [Terraform CLI](https://developer.hashicorp.com/terraform/cli) - An [API Key](/cloud/api-keys): an API Key is required to use the Terraform provider. - - See [the API docs](https://docs.temporal.io/cloud/api-keys#generate-an-api-key) for instructions on generating an + - See [the API docs](/cloud/api-keys#generate-an-api-key) for instructions on generating an API Key. :::note OpenTofu Registry @@ -56,7 +56,7 @@ managed by Temporal Technologies. ## Setup -Generate an [API Key](https://docs.temporal.io/cloud/api-keys#generate-an-api-key) to authenticate Terraform operations +Generate an [API Key](/cloud/api-keys#generate-an-api-key) to authenticate Terraform operations with your Temporal Cloud account or a Service Account. Then, either use an environment variable or pass the API Key into the provider manually to manage your Temporal Cloud Terraform resources. @@ -722,7 +722,7 @@ process of securely accessing the API Key Token in the Create section of this gu :::note Limits and Best Practices -- See the API Key [documentation](https://docs.temporal.io/cloud/api-keys) for information about the limits and best +- See the API Key [documentation](/cloud/api-keys) for information about the limits and best practices for managing API Keys. - See Terraform's documentation on working with [sensitive data](https://www.terraform.io/docs/language/values/variables.html#sensitive-values) for more information diff --git a/docs/cloud/worker-health.mdx b/docs/cloud/worker-health.mdx index 7a0bfb8c9b..9e65f49c78 100644 --- a/docs/cloud/worker-health.mdx +++ b/docs/cloud/worker-health.mdx @@ -27,8 +27,7 @@ keywords: - temporal worker resources --- import { LANGUAGE_TAB_GROUP, getLanguageLabel } from '@site/src/constants/languageTabs'; -import SdkTabs from '@site/src/components'; -import { ReleaseNoteHeader } from '@site/src/components'; +import { ReleaseNoteHeader, SdkTabs } from '@site/src/components'; import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; @@ -210,7 +209,7 @@ Consider #### High Schedule To Start latency and low sync match rate -Verify that you have not set a value for `ScheduleToStartTimeout` in your Activity Options. This may skew your observations. +Verify that you have not set a value for `ScheduleToStartTimeout` in your Activity Options. This may skew your observations and note that this timeout is non-retryable. It may be acceptable for your use case to have low sync match rate. For example, if you have known workloads or you intentionally throttle tasks. diff --git a/docs/design-patterns/activity-dependency-injection.mdx b/docs/design-patterns/activity-dependency-injection.mdx new file mode 100644 index 0000000000..d21b9485ef --- /dev/null +++ b/docs/design-patterns/activity-dependency-injection.mdx @@ -0,0 +1,787 @@ +--- +id: activity-dependency-injection +title: "Activity Dependency Injection" +sidebar_label: "Activity Dependency Injection" +description: "Injects external dependencies into Activities at Worker startup, keeping Workflow code deterministic and Activities testable." +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +## Overview + +The Activity Dependency Injection pattern separates the creation of external dependencies (database connections, API clients, configuration) from Activity business logic by injecting them at Worker startup. +This approach keeps Workflow code deterministic, makes Activities testable in isolation, and ensures expensive resources are initialized once per Worker process rather than once per Activity execution. + +## Problem + +Activities often need access to external resources such as database connection pools, HTTP clients, third-party API credentials, or shared caches. +Without a structured approach, you face several challenges: + +- **Reinitializing resources per execution.** Creating a new database connection or API client on every Activity invocation wastes resources and increases latency. +- **Hardcoded dependencies.** Embedding connection logic directly inside Activity functions couples business logic to infrastructure, making it difficult to swap implementations across environments. +- **Difficult testing.** When Activities construct their own dependencies internally, you cannot substitute test doubles without modifying production code. +- **Non-determinism risk.** Passing dependencies directly into Workflow code breaks Temporal's determinism guarantees, because dependency state can change between replays. + +## Solution + +You define Activities as methods on a struct or class that holds dependencies as fields. At Worker startup, you instantiate the struct or class with real implementations and register it with the Worker. The Workflow references Activity methods without knowing about the underlying dependencies. + +```mermaid +flowchart LR + subgraph Worker Startup + D[Dependencies
DB, API Client, Config] --> S[Activity Struct / Class] + S --> R[Register with Worker] + end + + subgraph Workflow Execution + W[Workflow] -->|"execute activity"| A[Activity Method] + A -->|"uses"| S + end + + subgraph Testing + M[Mock / Stub] --> T[Test Environment] + T -->|"execute activity"| A2[Activity Method] + end +``` + +The following describes each path in the diagram: + +1. At Worker startup, you create dependency instances (database pools, API clients) and inject them into an Activity struct or class, which you then register with the Worker. +2. During Workflow execution, the Workflow calls Activity methods by reference. The Temporal runtime routes the call to the registered instance on the Worker, where the method accesses the injected dependencies. +3. During testing, you substitute mock or stub implementations into the same Activity struct or class, allowing you to verify behavior without external services. + +## Implementation + +### Define Activities with dependencies + +Define Activities as methods on a struct or class that accepts dependencies through its constructor or fields. Each method acts as a separate Activity Type. + + + + +```go +// activities.go +package payment + +import ( + "context" + + "go.temporal.io/sdk/activity" +) + +type Activities struct { + DBClient DBClient + EmailClient EmailClient +} + +func (a *Activities) ChargeCustomer(ctx context.Context, orderID string, amount int) (string, error) { + logger := activity.GetLogger(ctx) + logger.Info("Charging customer", "orderID", orderID, "amount", amount) + + receiptID, err := a.DBClient.ProcessPayment(orderID, amount) + if err != nil { + return "", err + } + + return receiptID, nil +} + +func (a *Activities) SendReceipt(ctx context.Context, email string, receiptID string) error { + return a.EmailClient.Send(email, "Payment Receipt", receiptID) +} +``` + + + + +```python +# activities.py +from dataclasses import dataclass +from temporalio import activity + + +@dataclass +class PaymentActivities: + db_client: DBClient + email_client: EmailClient + + @activity.defn + async def charge_customer(self, order_id: str, amount: int) -> str: + activity.logger.info( + "Charging customer", extra={"order_id": order_id, "amount": amount} + ) + receipt_id = await self.db_client.process_payment(order_id, amount) + return receipt_id + + @activity.defn + async def send_receipt(self, email: str, receipt_id: str) -> None: + await self.email_client.send(email, "Payment Receipt", receipt_id) +``` + + + + +```java +// PaymentActivities.java +@ActivityInterface +public interface PaymentActivities { + String chargeCustomer(String orderId, int amount); + void sendReceipt(String email, String receiptId); +} + +// PaymentActivitiesImpl.java +public class PaymentActivitiesImpl implements PaymentActivities { + private final DBClient dbClient; + private final EmailClient emailClient; + + public PaymentActivitiesImpl(DBClient dbClient, EmailClient emailClient) { + this.dbClient = dbClient; + this.emailClient = emailClient; + } + + @Override + public String chargeCustomer(String orderId, int amount) { + return dbClient.processPayment(orderId, amount); + } + + @Override + public void sendReceipt(String email, String receiptId) { + emailClient.send(email, "Payment Receipt", receiptId); + } +} +``` + + + + +```typescript +// activities.ts +export interface DB { + processPayment(orderId: string, amount: number): Promise; +} + +export interface EmailClient { + send(to: string, subject: string, body: string): Promise; +} + +export const createActivities = (db: DB, emailClient: EmailClient) => ({ + async chargeCustomer(orderId: string, amount: number): Promise { + const receiptId = await db.processPayment(orderId, amount); + return receiptId; + }, + + async sendReceipt(email: string, receiptId: string): Promise { + await emailClient.send(email, 'Payment Receipt', receiptId); + }, +}); +``` + + + + +Each SDK uses a different mechanism to group Activities with their dependencies: + +- **Go**: Methods on a struct. The struct fields hold dependencies. +- **Python**: A `@dataclass` with `@activity.defn` methods. Fields hold dependencies. +- **Java**: An `@ActivityInterface` with a separate implementation class. Dependencies are passed through the constructor. +- **TypeScript**: A factory function that closes over dependencies and returns an object of Activity functions. + +### Reference Activities from the Workflow + +The Workflow references Activity methods without any knowledge of the injected dependencies. Each SDK provides a type-safe way to call Activities. + + + + +```go +// workflow.go +package payment + +import ( + "time" + + "go.temporal.io/sdk/workflow" +) + +func PaymentWorkflow(ctx workflow.Context, orderID string, amount int, email string) error { + ao := workflow.ActivityOptions{ + StartToCloseTimeout: 30 * time.Second, + } + ctx = workflow.WithActivityOptions(ctx, ao) + + // Use a nil struct pointer to reference Activity methods. + // This provides compile-time type safety without instantiating the struct. + var a *Activities + var receiptID string + err := workflow.ExecuteActivity(ctx, a.ChargeCustomer, orderID, amount).Get(ctx, &receiptID) + if err != nil { + return err + } + + return workflow.ExecuteActivity(ctx, a.SendReceipt, email, receiptID).Get(ctx, nil) +} +``` + + + + +```python +# workflows.py +from datetime import timedelta +from temporalio import workflow + +with workflow.unsafe.imports_passed_through(): + from activities import PaymentActivities + + +@workflow.defn +class PaymentWorkflow: + @workflow.run + async def run(self, order_id: str, amount: int, email: str) -> None: + receipt_id = await workflow.execute_activity_method( + PaymentActivities.charge_customer, + args=[order_id, amount], + start_to_close_timeout=timedelta(seconds=30), + ) + + await workflow.execute_activity_method( + PaymentActivities.send_receipt, + args=[email, receipt_id], + start_to_close_timeout=timedelta(seconds=30), + ) +``` + + + + +```java +// PaymentWorkflowImpl.java +public class PaymentWorkflowImpl implements PaymentWorkflow { + private final PaymentActivities activities = Workflow.newActivityStub( + PaymentActivities.class, + ActivityOptions.newBuilder() + .setStartToCloseTimeout(Duration.ofSeconds(30)) + .build() + ); + + @Override + public void processPayment(String orderId, int amount, String email) { + String receiptId = activities.chargeCustomer(orderId, amount); + activities.sendReceipt(email, receiptId); + } +} +``` + + + + +```typescript +// workflows.ts +import { proxyActivities } from '@temporalio/workflow'; +import type { createActivities } from './activities'; + +// Use ReturnType to extract the Activity types from the factory function +const { chargeCustomer, sendReceipt } = proxyActivities< + ReturnType +>({ + startToCloseTimeout: '30s', +}); + +export async function paymentWorkflow( + orderId: string, + amount: number, + email: string +): Promise { + const receiptId = await chargeCustomer(orderId, amount); + await sendReceipt(email, receiptId); +} +``` + + + + +Key points for each SDK: + +- **Go**: A nil pointer of the Activity struct type (`var a *Activities`) provides compile-time method references without instantiating the struct. The Temporal runtime resolves the actual registered instance at execution time. +- **Python**: `workflow.execute_activity_method` references the class method directly and resolves to the registered instance on the Worker. +- **Java**: `Workflow.newActivityStub` creates a typed proxy from the Activity interface. The Temporal runtime routes calls to the registered implementation. +- **TypeScript**: `proxyActivities>` infers the Activity types from the factory function's return type. Activities are always referenced by name at runtime. + +### Register Activities with the Worker + +At Worker startup, you instantiate the Activity struct or class with real dependency implementations and register it. + + + + +```go +// worker/main.go +package main + +import ( + "log" + + "go.temporal.io/sdk/client" + "go.temporal.io/sdk/worker" + + "example/payment" +) + +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, "payment", worker.Options{}) + + w.RegisterWorkflow(payment.PaymentWorkflow) + + // Inject real dependencies at Worker startup + w.RegisterActivity(&payment.Activities{ + DBClient: payment.NewPostgresClient("postgres://localhost:5432/payments"), + EmailClient: payment.NewSMTPClient("smtp://mail.example.com"), + }) + + err = w.Run(worker.InterruptCh()) + if err != nil { + log.Fatalln("Unable to start worker", err) + } +} +``` + + + + +```python +# worker.py +import asyncio +from temporalio.client import Client +from temporalio.worker import Worker + +from activities import PaymentActivities +from workflows import PaymentWorkflow + + +async def main(): + client = await Client.connect("localhost:7233") + + # Inject real dependencies at Worker startup + payment_activities = PaymentActivities( + db_client=PostgresClient("postgres://localhost:5432/payments"), + email_client=SMTPClient("smtp://mail.example.com"), + ) + + worker = Worker( + client, + task_queue="payment", + workflows=[PaymentWorkflow], + activities=[ + payment_activities.charge_customer, + payment_activities.send_receipt, + ], + ) + await worker.run() + + +if __name__ == "__main__": + asyncio.run(main()) +``` + + + + +```java +// PaymentWorker.java +public class PaymentWorker { + public static void main(String[] args) { + WorkflowServiceStubs service = WorkflowServiceStubs.newLocalServiceStubs(); + WorkflowClient client = WorkflowClient.newInstance(service); + WorkerFactory factory = WorkerFactory.newInstance(client); + + Worker worker = factory.newWorker("payment"); + worker.registerWorkflowImplementationTypes(PaymentWorkflowImpl.class); + + // Inject real dependencies at Worker startup + worker.registerActivitiesImplementations( + new PaymentActivitiesImpl( + new PostgresClient("postgres://localhost:5432/payments"), + new SMTPClient("smtp://mail.example.com") + ) + ); + + factory.start(); + } +} +``` + + + + +```typescript +// worker.ts +import { Worker } from '@temporalio/worker'; +import { createActivities } from './activities'; + +async function run() { + // Initialize dependencies at Worker startup + const db = new PostgresClient('postgres://localhost:5432/payments'); + const emailClient = new SMTPClient('smtp://mail.example.com'); + + const worker = await Worker.create({ + taskQueue: 'payment', + workflowsPath: require.resolve('./workflows'), + // Inject dependencies through the factory function + activities: createActivities(db, emailClient), + }); + + await worker.run(); +} + +run().catch((err) => { + console.error(err); + process.exit(1); +}); +``` + + + + +Dependencies are initialized once when the Worker process starts. All Activity executions on that Worker share the same instances, which is appropriate for thread-safe resources like connection pools and HTTP clients. + +### Inject a circuit breaker + +A circuit breaker is a natural fit for Worker-level injection because it is a *stateful* dependency. It tracks recent failures for a downstream service and, once the failure rate crosses a threshold, trips to an open state that rejects calls immediately for a cool-down period before allowing a probe. That state only protects the service if it is shared across every Activity execution on the Worker. Constructing a new breaker inside each Activity method would reset the counters on every call, so the breaker would never trip. + +By injecting a single breaker instance alongside the client it guards, all executions of the Activity feed the same failure window. When the downstream service degrades, the breaker opens and Activities fail fast instead of piling up latency against an unhealthy dependency, which in turn lets Temporal's retry policy back off rather than hammering the service. + +The following examples wrap an outbound payment API call in a circuit breaker, using an established library for each language: + + + + +```go +// activities.go — github.com/sony/gobreaker/v2 +package payment + +import ( + "context" + + "github.com/sony/gobreaker/v2" + "go.temporal.io/sdk/activity" +) + +type Activities struct { + PaymentAPI PaymentAPI + Breaker *gobreaker.CircuitBreaker[string] +} + +func (a *Activities) ChargeCustomer(ctx context.Context, orderID string, amount int) (string, error) { + logger := activity.GetLogger(ctx) + logger.Info("Charging customer", "orderID", orderID, "amount", amount) + + // The breaker rejects the call immediately when it is open, + // returning gobreaker.ErrOpenState without touching the API. + return a.Breaker.Execute(func() (string, error) { + return a.PaymentAPI.Charge(orderID, amount) + }) +} +``` + + + + +```python +# activities.py — pybreaker +import pybreaker +from dataclasses import dataclass +from temporalio import activity + + +@dataclass +class PaymentActivities: + payment_api: PaymentAPI + breaker: pybreaker.CircuitBreaker + + @activity.defn + async def charge_customer(self, order_id: str, amount: int) -> str: + activity.logger.info( + "Charging customer", extra={"order_id": order_id, "amount": amount} + ) + # Raises pybreaker.CircuitBreakerError when the breaker is open. + return await self.breaker.call_async( + self.payment_api.charge, order_id, amount + ) +``` + + + + +```java +// PaymentActivitiesImpl.java — resilience4j +import io.github.resilience4j.circuitbreaker.CircuitBreaker; + +public class PaymentActivitiesImpl implements PaymentActivities { + private final PaymentAPI paymentApi; + private final CircuitBreaker breaker; + + public PaymentActivitiesImpl(PaymentAPI paymentApi, CircuitBreaker breaker) { + this.paymentApi = paymentApi; + this.breaker = breaker; + } + + @Override + public String chargeCustomer(String orderId, int amount) { + // Throws CallNotPermittedException when the breaker is open. + return breaker.executeSupplier(() -> paymentApi.charge(orderId, amount)); + } +} +``` + + + + +```typescript +// activities.ts — opossum +import CircuitBreaker from 'opossum'; + +export const createActivities = (breaker: CircuitBreaker) => ({ + async chargeCustomer(orderId: string, amount: number): Promise { + // Rejects with an EOPENBREAKER error when the breaker is open. + return breaker.fire(orderId, amount) as Promise; + }, +}); +``` + + + + +You construct the breaker at Worker startup and inject it the same way as any other dependency, so its state lives for the lifetime of the Worker process. + + + + +```go +// worker/main.go +package main + +import ( + "log" + "time" + + "github.com/sony/gobreaker/v2" + "go.temporal.io/sdk/client" + "go.temporal.io/sdk/worker" + + "example/payment" +) + +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, "payment", worker.Options{}) + + w.RegisterWorkflow(payment.PaymentWorkflow) + + // Construct the breaker once at Worker startup so its failure + // counters are shared across all Activity executions. + breaker := gobreaker.NewCircuitBreaker[string](gobreaker.Settings{ + Name: "payment-api", + MaxRequests: 3, // probes allowed while half-open + Interval: 60 * time.Second, // window for counting failures + Timeout: 30 * time.Second, // cool-down before half-open + }) + + w.RegisterActivity(&payment.Activities{ + PaymentAPI: payment.NewPaymentAPI("https://api.example.com"), + Breaker: breaker, + }) + + err = w.Run(worker.InterruptCh()) + if err != nil { + log.Fatalln("Unable to start worker", err) + } +} +``` + + + + +```python +# worker.py +import asyncio + +import pybreaker +from temporalio.client import Client +from temporalio.worker import Worker + +from activities import PaymentActivities, PaymentAPI +from workflows import PaymentWorkflow + + +async def main(): + client = await Client.connect("localhost:7233") + + # Construct the breaker once at Worker startup so its failure + # counters are shared across all Activity executions. + breaker = pybreaker.CircuitBreaker(fail_max=5, reset_timeout=30) + payment_activities = PaymentActivities( + payment_api=PaymentAPI("https://api.example.com"), + breaker=breaker, + ) + + worker = Worker( + client, + task_queue="payment", + workflows=[PaymentWorkflow], + activities=[ + payment_activities.charge_customer, + payment_activities.send_receipt, + ], + ) + await worker.run() + + +if __name__ == "__main__": + asyncio.run(main()) +``` + + + + +```java +// PaymentWorker.java +import io.github.resilience4j.circuitbreaker.CircuitBreaker; +import io.github.resilience4j.circuitbreaker.CircuitBreakerConfig; +import io.temporal.client.WorkflowClient; +import io.temporal.serviceclient.WorkflowServiceStubs; +import io.temporal.worker.Worker; +import io.temporal.worker.WorkerFactory; +import java.time.Duration; + +public class PaymentWorker { + public static void main(String[] args) { + WorkflowServiceStubs service = WorkflowServiceStubs.newLocalServiceStubs(); + WorkflowClient client = WorkflowClient.newInstance(service); + WorkerFactory factory = WorkerFactory.newInstance(client); + + Worker worker = factory.newWorker("payment"); + worker.registerWorkflowImplementationTypes(PaymentWorkflowImpl.class); + + // Construct the breaker once at Worker startup so its failure + // counters are shared across all Activity executions. + CircuitBreakerConfig config = CircuitBreakerConfig.custom() + .failureRateThreshold(50) // percent + .waitDurationInOpenState(Duration.ofSeconds(30)) // cool-down + .slidingWindowSize(20) + .build(); + CircuitBreaker breaker = CircuitBreaker.of("payment-api", config); + + worker.registerActivitiesImplementations( + new PaymentActivitiesImpl(new PaymentAPI("https://api.example.com"), breaker) + ); + + factory.start(); + } +} +``` + + + + +```typescript +// worker.ts +import { Worker } from '@temporalio/worker'; +import CircuitBreaker from 'opossum'; +import { createActivities } from './activities'; +import { PaymentAPI } from './payment-api'; + +async function run() { + // Construct the breaker once at Worker startup so its failure + // counters are shared across all Activity executions. + const paymentApi = new PaymentAPI('https://api.example.com'); + const breaker = new CircuitBreaker( + (orderId: string, amount: number) => paymentApi.charge(orderId, amount), + { errorThresholdPercentage: 50, resetTimeout: 30000 }, // cool-down in ms + ); + + const worker = await Worker.create({ + taskQueue: 'payment', + workflowsPath: require.resolve('./workflows'), + activities: createActivities(breaker), + }); + + await worker.run(); +} + +run().catch((err) => { + console.error(err); + process.exit(1); +}); +``` + + + + +Circuit breaker libraries by language: + +- **Go**: [`sony/gobreaker`](https://github.com/sony/gobreaker) — the generic (`v2`) API returns the wrapped result type directly. +- **Python**: [`pybreaker`](https://github.com/danielfm/pybreaker) — `call_async` wraps coroutine-based clients. +- **Java**: [`resilience4j`](https://resilience4j.readme.io/docs/circuitbreaker) — the successor to Netflix Hystrix, built around functional decorators. +- **TypeScript**: [`opossum`](https://nodeshift.dev/opossum/) — wraps a single async action bound at construction time. + +Because the breaker counts failures, size the Activity retry policy accordingly. A breaker that opens after a burst of failures pairs well with a retry policy that backs off, so an open breaker returns fast errors that Temporal retries later rather than each attempt waiting on a timeout. + +## When to use + +This pattern is a good fit when your Activities access external services such as databases, message queues, or third-party APIs. It is appropriate when you want to initialize expensive resources once per Worker process, when you need to test Activity logic without connecting to real services, or when you operate in multiple environments (development, staging, production) that require different dependency configurations. + +This pattern is not necessary for Activities that are pure functions with no external dependencies, or for Activities that only use Temporal-provided context like heartbeating and logging. + +## Benefits and trade-offs + +Injecting dependencies at the Worker level provides several advantages. Resources like connection pools are initialized once and shared across all Activity executions, reducing overhead. Substituting mock implementations in tests requires no changes to Activity or Workflow code. Switching between environments involves changing only the Worker configuration. + +The trade-off is that all Activity executions on a given Worker share the same dependency instances. If an Activity requires per-execution isolation (for example, a database transaction scoped to a single Activity), you need to manage that within the Activity method itself. Dependencies must also be thread-safe, since multiple Activity executions may run concurrently on the same Worker. + +## Best practices + +- **Keep dependencies thread-safe.** Multiple Activity executions run concurrently on the same Worker. Use connection pools rather than single connections, and avoid mutable shared state. +- **Define dependencies as interfaces.** In Go, Python, and Java, using interfaces (or protocols in Python) for dependencies makes it possible to swap implementations for testing or different environments. +- **Do not inject dependencies into Workflows.** Workflow code must remain deterministic. If a Workflow needs configuration, retrieve it through a Local Activity so the value gets recorded in the Event History. +- **Initialize dependencies before Worker startup.** Create and validate all connections before calling `worker.Run()` or its equivalent. This ensures that the Worker does not start accepting tasks until all dependencies are ready. +- **Group related Activities on a single struct or class.** Activities that share the same dependencies belong together. If two groups of Activities have different dependencies, use separate structs or classes for each group. + +## Common pitfalls + +- **Constructing dependencies inside Activity methods.** Creating a new database connection or API client per execution leads to resource exhaustion and increased latency. +- **Injecting dependencies into Workflows.** This breaks determinism because dependency state can change between the original execution and a replay. The Temporal Java SDK documentation explicitly warns against this. +- **Using non-thread-safe dependencies.** A single mutable object shared across concurrent Activity executions causes race conditions. Use connection pools and ensure all injected objects are safe for concurrent use. +- **Registering class methods as static in Python.** If you register `BotService.send_message` (the unbound method) instead of `bot_service.send_message` (a method on an instance), the `self` parameter is not bound, causing a missing argument error at runtime. +- **Forgetting to bind methods in TypeScript.** When using a class instead of a factory function, class methods must be defined as arrow functions or explicitly bound in the constructor. Otherwise, `this` is `undefined` when Temporal invokes the Activity. + +## Related patterns + +- **[Entity Workflow](/design-patterns/entity-workflow)**: Long-lived Workflows that manage stateful entities, often using Activities with injected dependencies. +- **[Worker-Specific Task Queues](/design-patterns/worker-specific-taskqueue)**: Routing Activities to specific Workers, which can have different injected dependencies. + +## Sample code + +### Go +- [Greetings](https://github.com/temporalio/samples-go/tree/main/greetings) — Activities as struct methods with injected dependencies. +- [Large Payload Fixture](https://github.com/temporalio/samples-go/tree/main/temporal-fixtures/largepayload) — The reference sample using struct-based Activity dependency injection. + +### Python +- [Hello Activity Method](https://github.com/temporalio/samples-python/blob/main/hello/hello_activity_method.py) — Activities defined as class methods with dependency injection. + +### Java +- [Hello World](https://github.com/temporalio/hello-world-project-template-java) — Activity interface and implementation with Worker registration. + +### TypeScript +- [Activities Dependency Injection](https://github.com/temporalio/samples-typescript/tree/main/activities-dependency-injection) — Factory function pattern for sharing dependencies between Activities. diff --git a/docs/design-patterns/approval.mdx b/docs/design-patterns/approval.mdx new file mode 100644 index 0000000000..8881a6262b --- /dev/null +++ b/docs/design-patterns/approval.mdx @@ -0,0 +1,882 @@ +--- +id: approval +title: "Approval Pattern" +sidebar_label: "Approval" +description: "Human-in-the-loop Workflows that block until external approval decisions are made. Uses Signals to capture approval data with metadata." +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +## Overview + +The Approval pattern implements human-in-the-loop Workflows where execution blocks until an external decision is made. +It uses Workflow Signals with custom input data to unblock Workflows, enabling approval processes, manual reviews, and decision gates in automated business processes. + +
+ +
+ +## Problem + +In many business processes, you need Workflows that wait for human approval before proceeding. +These Workflows must capture approval decisions along with metadata such as the approver's identity, a reason, and a timestamp. +They must also support multiple outcomes — approval, rejection, or escalation — and handle timeout scenarios when no decision arrives. + +Without a structured approval pattern, you are forced to poll external systems for approval status, implement complex state machines by hand, and manage race conditions between timeouts and incoming approvals. +You also risk losing approval context and metadata, and you must build custom audit logging to meet compliance requirements. + +## Solution + +The Approval pattern uses a blocking wait with timeout to pause execution until a Signal is received. +The Signal carries custom data — the approval decision, approver details, and comments — that the Workflow captures and uses to determine next steps. + +```mermaid +sequenceDiagram + participant Requester + participant Workflow + participant Approver + + Requester->>+Workflow: Start approval request + activate Workflow + Workflow->>Workflow: Wait with timeout + Note over Workflow: Waiting for approval... + + alt Approval received + Approver->>Workflow: Signal: submitApproval(data) + Workflow->>Workflow: Process approval data + Workflow-->>Requester: Approved + else Timeout + Note over Workflow: Timeout expires + Workflow-->>Requester: Timeout/Rejected + end + deactivate Workflow +``` + +The following describes each step in the diagram: + +1. The requester starts the Workflow with an approval request. +2. The Workflow blocks execution with a timeout — using `Workflow.await()` in Java, `condition()` in TypeScript, `workflow.wait_condition()` in Python, or `workflow.AwaitWithTimeout()` in Go. +3. If an approver sends a Signal before the timeout expires, the Workflow receives the approval data, processes the decision, and returns the result to the requester. +4. If the timeout expires before any Signal arrives, the Workflow unblocks and follows the timeout path, which typically results in rejection or escalation. + +## Implementation + +### Basic approval with timeout + +The basic implementation captures the approval decision in a structured data object rather than a plain boolean. +Define a type to hold the approver's identity, the decision, any comments, and a timestamp: + + + + +```python +# models.py +from dataclasses import dataclass + +@dataclass +class ApprovalData: + approver: str + decision: str # "APPROVED", "REJECTED", "ESCALATED" + comments: str + timestamp: int +``` + + + + +```go +// types.go +type ApprovalData struct { + Approver string + Decision string // "APPROVED", "REJECTED", "ESCALATED" + Comments string + Timestamp int64 +} +``` + + + + +```java +// ApprovalData.java +public class ApprovalData { + private String approver; + private String decision; // "APPROVED", "REJECTED", "ESCALATED" + private String comments; + private long timestamp; + + // Constructor, getters, setters +} +``` + + + + +```typescript +// types.ts +export interface ApprovalData { + approver: string; + decision: 'APPROVED' | 'REJECTED' | 'ESCALATED'; + comments: string; + timestamp: number; +} +``` + + + + +This type gives you a structured way to pass rich context through the Signal rather than a plain boolean. + +The implementation ties everything together. +The Workflow blocks until either the approval data arrives via Signal or the timeout expires: + + + + +```python +# workflows.py +import asyncio +from datetime import timedelta +from temporalio import workflow +from models import ApprovalData + +@workflow.defn +class ApprovalWorkflow: + def __init__(self) -> None: + self.approval_data: ApprovalData | None = None + self.status = "PENDING" + + @workflow.run + async def run(self, request_id: str, timeout_seconds: int) -> str: + try: + # Block until the Signal sets approval_data, or raise on timeout + await workflow.wait_condition( + lambda: self.approval_data is not None, + timeout=timedelta(seconds=timeout_seconds), + ) + self.status = self.approval_data.decision + return f"Request {request_id} {self.status} by {self.approval_data.approver}" + except asyncio.TimeoutError: + self.status = "TIMEOUT" + return f"Request {request_id} timed out" + + # Signal handler: an external approver submits the decision + @workflow.signal + def submit_approval(self, data: ApprovalData) -> None: + self.approval_data = data + + # Query handler: read current status without mutating state + @workflow.query + def get_status(self) -> str: + return self.status +``` + + + + +```go +// workflow.go +func ApprovalWorkflow(ctx workflow.Context, requestId string, timeout time.Duration) (string, error) { + var approvalData *ApprovalData + status := "PENDING" + + // Query handler: expose current status without mutating state + err := workflow.SetQueryHandler(ctx, "getStatus", func() (string, error) { + return status, nil + }) + if err != nil { + return "", err + } + + // Receive the approval signal in a goroutine + workflow.Go(ctx, func(ctx workflow.Context) { + signalChan := workflow.GetSignalChannel(ctx, "submitApproval") + signalChan.Receive(ctx, &approvalData) + }) + + // Block until the signal sets approvalData, or time out (ok=false) + approved, err := workflow.AwaitWithTimeout(ctx, timeout, func() bool { + return approvalData != nil + }) + if err != nil { + return "", err + } + + if approved { + status = approvalData.Decision + return fmt.Sprintf("Request %s %s by %s", requestId, status, approvalData.Approver), nil + } + status = "TIMEOUT" + return fmt.Sprintf("Request %s timed out", requestId), nil +} +``` + + + + +```java +// ApprovalWorkflowImpl.java +public class ApprovalWorkflowImpl implements ApprovalWorkflow { + private ApprovalData approvalData; + private String status = "PENDING"; + + @Override + public String execute(String requestId, Duration timeout) { + // Block until the Signal sets approvalData, or time out (returns false) + boolean approved = Workflow.await(timeout, () -> approvalData != null); + + if (approved) { + status = approvalData.getDecision(); + return "Request " + requestId + " " + status + " by " + approvalData.getApprover(); + } else { + status = "TIMEOUT"; + return "Request " + requestId + " timed out"; + } + } + + // Signal handler: an external approver submits the decision + @Override + public void submitApproval(ApprovalData data) { + this.approvalData = data; + } + + // Query handler: read current status without mutating state + @Override + public String getStatus() { + return status; + } +} +``` + + + + +```typescript +// workflows.ts +import * as wf from '@temporalio/workflow'; +import { ApprovalData } from './types'; + +export const submitApprovalSignal = wf.defineSignal<[ApprovalData]>('submitApproval'); +export const getStatusQuery = wf.defineQuery('getStatus'); + +export async function approvalWorkflow( + requestId: string, + timeout: string | number, // ms or Duration string +): Promise { + let approvalData: ApprovalData | undefined; + let status = 'PENDING'; + + // Signal handler: an external approver submits the decision + wf.setHandler(submitApprovalSignal, (data: ApprovalData) => { + approvalData = data; + }); + + // Query handler: read current status without mutating state + wf.setHandler(getStatusQuery, () => status); + + // Block until the Signal sets approvalData, or time out (returns false) + const approved = await wf.condition(() => approvalData !== undefined, timeout); + + if (approved) { + status = approvalData!.decision; + return `Request ${requestId} ${status} by ${approvalData!.approver}`; + } else { + status = 'TIMEOUT'; + return `Request ${requestId} timed out`; + } +} +``` + + + + +Each SDK uses a different mechanism to block with a timeout, but the core pattern is the same. +In Java, `Workflow.await()` takes a timeout and a condition lambda, returning `false` on timeout. +In TypeScript, `condition()` takes a predicate and a timeout, returning `false` on timeout. +In Python, `workflow.wait_condition()` takes a lambda and a timeout, raising `asyncio.TimeoutError` on timeout. +In Go, `workflow.AwaitWithTimeout()` takes a timeout and a condition function, returning `ok=false` on timeout. + +The condition is evaluated on every state transition, so it must not call blocking operations, mutate Workflow state, or use time-based checks. +When the Signal handler sets the approval data, the condition evaluates to `true` and the Workflow unblocks. + +### Multi-level approval chain + +Some business processes require approvals from multiple levels of authority in sequence. +The following implementation iterates through a list of required approval levels, waiting for a Signal at each level before proceeding to the next: + + + + +```python +# models.py +from dataclasses import dataclass + +@dataclass +class MultiLevelApprovalData: + level: str # "L1", "L2", "L3" + approver: str + decision: str + comments: str +``` + + + + +```go +// types.go +type MultiLevelApprovalData struct { + Level string // "L1", "L2", "L3" + Approver string + Decision string + Comments string +} +``` + + + + +```java +// MultiLevelApprovalData.java +public class MultiLevelApprovalData { + private String level; // "L1", "L2", "L3" + private String approver; + private String decision; + private String comments; +} +``` + + + + +```typescript +// types.ts +export interface MultiLevelApprovalData { + level: 'L1' | 'L2' | 'L3'; + approver: string; + decision: string; + comments: string; +} +``` + + + + +This data type extends the basic approval data with a `level` field that identifies which approval tier the decision belongs to. + + + + +```python +# workflows.py +import asyncio +from datetime import timedelta +from temporalio import workflow +from models import MultiLevelApprovalData + +@workflow.defn +class MultiLevelApprovalWorkflow: + def __init__(self) -> None: + self.approvals: list[MultiLevelApprovalData] = [] + + @workflow.run + async def run(self, request_id: str, timeout_per_level_seconds: int) -> str: + required_levels = ["L1", "L2", "L3"] + timeout = timedelta(seconds=timeout_per_level_seconds) + + # Require approval at each level in sequence + for level in required_levels: + try: + # Wait for a Signal carrying this level's approval + await workflow.wait_condition( + lambda lv=level: any(a.level == lv for a in self.approvals), + timeout=timeout, + ) + except asyncio.TimeoutError: + return f"Timeout at {level}" + + approval = next(a for a in self.approvals if a.level == level) + # Stop the chain early if any level rejects + if approval.decision == "REJECTED": + return f"Rejected at {level} by {approval.approver}" + + return "Fully approved through all levels" + + # Signal handler: collect each level's approval as it arrives + @workflow.signal + def submit_approval(self, data: MultiLevelApprovalData) -> None: + self.approvals.append(data) +``` + + + + +```go +// workflow.go +func MultiLevelApprovalWorkflow(ctx workflow.Context, requestId string, timeoutPerLevel time.Duration) (string, error) { + var approvals []MultiLevelApprovalData + requiredLevels := []string{"L1", "L2", "L3"} + + // Collect every approval Signal as it arrives + workflow.Go(ctx, func(ctx workflow.Context) { + signalChan := workflow.GetSignalChannel(ctx, "submitApproval") + for { + var data MultiLevelApprovalData + signalChan.Receive(ctx, &data) + approvals = append(approvals, data) + } + }) + + // Require approval at each level in sequence + for _, level := range requiredLevels { + lv := level + // Wait for a Signal carrying this level's approval + ok, err := workflow.AwaitWithTimeout(ctx, timeoutPerLevel, func() bool { + for _, a := range approvals { + if a.Level == lv { + return true + } + } + return false + }) + if err != nil { + return "", err + } + if !ok { + return fmt.Sprintf("Timeout at %s", lv), nil + } + + var approval MultiLevelApprovalData + for _, a := range approvals { + if a.Level == lv { + approval = a + break + } + } + // Stop the chain early if any level rejects + if approval.Decision == "REJECTED" { + return fmt.Sprintf("Rejected at %s by %s", lv, approval.Approver), nil + } + } + + return "Fully approved through all levels", nil +} +``` + + + + +```java +// MultiLevelApprovalWorkflowImpl.java +public class MultiLevelApprovalWorkflowImpl implements ApprovalWorkflow { + private List approvals = new ArrayList<>(); + private String[] requiredLevels = {"L1", "L2", "L3"}; + + @Override + public String execute(String requestId, Duration timeoutPerLevel) { + // Require approval at each level in sequence + for (String level : requiredLevels) { + // Wait for a Signal carrying this level's approval + boolean received = Workflow.await( + timeoutPerLevel, + () -> hasApprovalForLevel(level)); + + if (!received) { + return "Timeout at " + level; + } + + MultiLevelApprovalData approval = getApprovalForLevel(level); + // Stop the chain early if any level rejects + if (approval.getDecision().equals("REJECTED")) { + return "Rejected at " + level + " by " + approval.getApprover(); + } + } + + return "Fully approved through all levels"; + } + + // Signal handler: collect each level's approval as it arrives + @Override + public void submitApproval(MultiLevelApprovalData data) { + approvals.add(data); + } + + private boolean hasApprovalForLevel(String level) { + return approvals.stream().anyMatch(a -> a.getLevel().equals(level)); + } + + private MultiLevelApprovalData getApprovalForLevel(String level) { + return approvals.stream() + .filter(a -> a.getLevel().equals(level)) + .findFirst() + .orElse(null); + } +} +``` + + + + +```typescript +// workflows.ts +import * as wf from '@temporalio/workflow'; +import { MultiLevelApprovalData } from './types'; + +export const submitApprovalSignal = wf.defineSignal<[MultiLevelApprovalData]>('submitApproval'); + +export async function multiLevelApprovalWorkflow( + requestId: string, + timeoutPerLevelMs: number, +): Promise { + const approvals: MultiLevelApprovalData[] = []; + const requiredLevels = ['L1', 'L2', 'L3'] as const; + + // Signal handler: collect each level's approval as it arrives + wf.setHandler(submitApprovalSignal, (data: MultiLevelApprovalData) => { + approvals.push(data); + }); + + // Require approval at each level in sequence + for (const level of requiredLevels) { + // Wait for a Signal carrying this level's approval + const received = await wf.condition( + () => approvals.some((a) => a.level === level), + timeoutPerLevelMs, + ); + + if (!received) { + return `Timeout at ${level}`; + } + + const approval = approvals.find((a) => a.level === level)!; + // Stop the chain early if any level rejects + if (approval.decision === 'REJECTED') { + return `Rejected at ${level} by ${approval.approver}`; + } + } + + return 'Fully approved through all levels'; +} +``` + + + + +The Workflow loops through each required level and waits with a per-level timeout. +The helper logic checks whether a Signal has arrived for the current level. +If a timeout occurs at any level, the Workflow exits with a timeout result. +If any level returns a rejection, the Workflow exits immediately without proceeding to subsequent levels. + +### Approval with escalation + +When an initial approval times out, you may want to escalate the request to a manager rather than rejecting it outright. +The following implementation adds an escalation step with an extended timeout: + + + + +```python +# workflows.py +import asyncio +from datetime import timedelta +from temporalio import workflow +from models import ApprovalData + +with workflow.unsafe.imports_passed_through(): + from activities import send_escalation_email + +@workflow.defn +class EscalatingApprovalWorkflow: + def __init__(self) -> None: + self.approval_data: ApprovalData | None = None + self.escalated = False + + @workflow.run + async def run(self, request_id: str, initial_timeout_seconds: int) -> str: + try: + # Wait for the first approval within the initial timeout + await workflow.wait_condition( + lambda: self.approval_data is not None, + timeout=timedelta(seconds=initial_timeout_seconds), + ) + except asyncio.TimeoutError: + # No response in time: escalate to a manager, then wait longer + self.escalated = True + await workflow.execute_activity( + send_escalation_email, + start_to_close_timeout=timedelta(seconds=10), + ) + + try: + # Extended wait for the escalated approval + await workflow.wait_condition( + lambda: self.approval_data is not None, + timeout=timedelta(hours=24), + ) + except asyncio.TimeoutError: + return "Escalation timeout - auto-rejected" + + decision = self.approval_data.decision + approver = self.approval_data.approver + escalation_note = " (escalated)" if self.escalated else "" + + return f"{decision} by {approver}{escalation_note}" + + @workflow.signal + def submit_approval(self, data: ApprovalData) -> None: + self.approval_data = data +``` + + + + +```go +// workflow.go +func EscalatingApprovalWorkflow(ctx workflow.Context, requestId string, initialTimeout time.Duration) (string, error) { + var approvalData *ApprovalData + escalated := false + + workflow.Go(ctx, func(ctx workflow.Context) { + signalChan := workflow.GetSignalChannel(ctx, "submitApproval") + signalChan.Receive(ctx, &approvalData) + }) + + // Wait for the first approval within the initial timeout + ok, err := workflow.AwaitWithTimeout(ctx, initialTimeout, func() bool { + return approvalData != nil + }) + if err != nil { + return "", err + } + + if !ok { + // No response in time: escalate to a manager, then wait longer + escalated = true + + ao := workflow.ActivityOptions{ + StartToCloseTimeout: 10 * time.Second, + } + actCtx := workflow.WithActivityOptions(ctx, ao) + // Notify the manager via an Activity + err = workflow.ExecuteActivity(actCtx, SendEscalationEmail).Get(ctx, nil) + if err != nil { + return "", err + } + + // Extended wait for the escalated approval + ok, err = workflow.AwaitWithTimeout(ctx, 24*time.Hour, func() bool { + return approvalData != nil + }) + if err != nil { + return "", err + } + if !ok { + return "Escalation timeout - auto-rejected", nil + } + } + + escalationNote := "" + if escalated { + escalationNote = " (escalated)" + } + + return fmt.Sprintf("%s by %s%s", approvalData.Decision, approvalData.Approver, escalationNote), nil +} +``` + + + + +```java +// EscalatingApprovalWorkflowImpl.java +public class EscalatingApprovalWorkflowImpl implements ApprovalWorkflow { + private ApprovalData approvalData; + private boolean escalated = false; + + @Override + public String execute(String requestId, Duration initialTimeout) { + // Wait for the first approval within the initial timeout + boolean received = Workflow.await(initialTimeout, () -> approvalData != null); + + if (!received) { + // No response in time: escalate to a manager, then wait longer + escalated = true; + sendEscalationNotification(); + + // Extended wait for the escalated approval + received = Workflow.await( + Duration.ofHours(24), + () -> approvalData != null); + + if (!received) { + return "Escalation timeout - auto-rejected"; + } + } + + String decision = approvalData.getDecision(); + String approver = approvalData.getApprover(); + String escalationNote = escalated ? " (escalated)" : ""; + + return decision + " by " + approver + escalationNote; + } + + @Override + public void submitApproval(ApprovalData data) { + this.approvalData = data; + } + + private void sendEscalationNotification() { + ActivityOptions options = ActivityOptions.newBuilder() + .setStartToCloseTimeout(Duration.ofSeconds(10)) + .build(); + NotificationActivities activities = + Workflow.newActivityStub(NotificationActivities.class, options); + activities.sendEscalationEmail(); + } +} +``` + + + + +```typescript +// workflows.ts +import * as wf from '@temporalio/workflow'; +import type * as activities from './activities'; +import { ApprovalData } from './types'; + +const { sendEscalationEmail } = wf.proxyActivities({ + startToCloseTimeout: '10 seconds', +}); + +export const submitApprovalSignal = wf.defineSignal<[ApprovalData]>('submitApproval'); + +export async function escalatingApprovalWorkflow( + requestId: string, + initialTimeoutMs: number, +): Promise { + let approvalData: ApprovalData | undefined; + let escalated = false; + + wf.setHandler(submitApprovalSignal, (data: ApprovalData) => { + approvalData = data; + }); + + // Wait for the first approval within the initial timeout + let received = await wf.condition( + () => approvalData !== undefined, + initialTimeoutMs, + ); + + if (!received) { + // No response in time: escalate to a manager, then wait longer + escalated = true; + await sendEscalationEmail(); + + // Extended wait for the escalated approval + received = await wf.condition( + () => approvalData !== undefined, + '24 hours', + ); + + if (!received) { + return 'Escalation timeout - auto-rejected'; + } + } + + const { decision, approver } = approvalData!; + const escalationNote = escalated ? ' (escalated)' : ''; + + return `${decision} by ${approver}${escalationNote}`; +} +``` + + + + +The Workflow first waits for the initial timeout. +If no Signal arrives, it sets the `escalated` flag, executes a notification Activity to alert the manager, and then waits again with a 24-hour extended timeout. +The notification Activity uses a short start-to-close timeout, since sending an email should complete quickly. +The final result includes an escalation note so the caller knows the request was escalated before approval. + +## When to use + +The Approval pattern is a good fit for purchase order approvals, expense report reviews, code deployment gates, contract signing Workflows, manual quality checks, compliance reviews, budget authorization, and access request approvals. + +It is not a good fit for fully automated processes that require no human input, real-time decisions that need synchronous API responses, or processes that require sub-second response times. +If you only need a boolean yes/no without any context, a plain boolean Signal may be sufficient. + +## Benefits and trade-offs + +The Approval pattern captures rich context — approver identity, reasons, and timestamps — alongside each decision. +All approval data is recorded in the Workflow history as Signal events, giving you a built-in audit trail. +Timeout handling is automatic: you define the maximum wait time and the Workflow handles the fallback. +The pattern supports multi-level, conditional, and escalating approval chains, and you can check approval status at any time through Query methods without modifying Workflow state. +Because all decisions are recorded in the event history, the Workflow is deterministic and replay-safe. + +The trade-offs to consider are that the pattern requires an external system to send approval Signals, which means you need a separate approval interface. +The Workflow blocks until the approval arrives or the timeout expires, so you must define a maximum wait time. +Large approval data objects increase the size of the Workflow history. + +## Comparison with alternatives + +| Approach | Rich data | Built-in wait | Caller gets result | Complexity | Use case | +| :--- | :--- | :--- | :--- | :--- | :--- | +| Signal with data | Yes | Yes | No | Low | Approval Workflows | +| Update | Yes | No | Yes | Low | Synchronous validation with immediate confirmation | +| Boolean Signal | No | Yes | No | Low | Yes/no decisions | +| Polling Activity | Yes | Yes | Yes | High | External approval systems | + +Signals are fire-and-forget: the caller receives an acknowledgement from the server but cannot wait for the Workflow to process the Signal or receive a result. +Updates are synchronous: the caller blocks until the handler completes and can receive a return value or error. +If the approver's interface needs immediate confirmation that the approval was accepted and valid, consider using an Update with a validator instead of a Signal. + +## Best practices + +- **Use custom data objects.** Capture rich approval context — approver identity, comments, timestamps — rather than a plain boolean. +- **Set reasonable timeouts.** Balance responsiveness with the time approvers realistically need to respond. +- **Add Query methods.** Expose the current approval status so external systems can check progress without sending a Signal. +- **Validate Signal data.** Verify approver permissions and data completeness before accepting an approval. +- **Log approval events.** Record each decision for audit trails and compliance. +- **Handle timeouts gracefully.** Define clear timeout behavior such as rejection, escalation, or notification. +- **Support cancellation.** Allow Workflows to be cancelled if the request is withdrawn. +- **Ensure idempotency.** Handle duplicate approval Signals safely so that re-delivery does not corrupt state. Signals [may be duplicated in rare cases](/handling-messages#exactly-once-message-processing), so use idempotency keys when necessary. +- **Include timestamps.** Record when each approval was submitted to support time-based auditing. +- **Expose approval history.** Provide a Query method that returns all approval attempts, not only the final decision. + +## Common pitfalls + +- **No timeout.** Without a timeout, the Workflow waits indefinitely for an approval that may never arrive. +- **Missing validation.** Accepting approvals from unauthorized users compromises the integrity of the process. +- **Lost context.** Failing to capture the approver's identity or reason makes audit trails incomplete. +- **Assuming non-deterministic races.** Temporal processes events in a deterministic, single-threaded order, so a Signal and a timer cannot truly "race." However, if the Signal arrives after the timer fires in the event history, the wait will have already returned with a timeout result. Design your timeout path to account for late-arriving Signals. +- **No audit trail.** Skipping approval logging makes it difficult to meet compliance requirements. +- **Tight timeouts.** Setting the timeout too short causes legitimate approvals to be rejected. +- **Boolean-only Signals.** Using a plain boolean instead of a rich data object limits your ability to capture decision context. +- **No status Query.** Without a Query method, external systems have no way to check approval progress. +- **No duplicate handling.** Receiving multiple approval Signals without deduplication can overwrite earlier decisions. +- **No escalation path.** Without a fallback when the initial approval times out, requests stall or are silently rejected. + +## Related patterns + +- [Signal-Based Event Handling](/design-patterns/signal-with-start): Receiving external events through Signals. +- [Updatable Timer](/design-patterns/updatable-timer): Extending approval deadlines dynamically. +- [Saga Pattern](/design-patterns/saga-pattern): Executing compensating actions on rejection. + +## Sample code + +**Java** +- [Hello Signal](https://github.com/temporalio/samples-java/tree/main/core/src/main/java/io/temporal/samples/hello/HelloSignal.java) — Basic Signal handling in a Workflow. +- [Safe Message Passing](https://github.com/temporalio/samples-java/tree/main/core/src/main/java/io/temporal/samples/safemessagepassing) — Concurrent Signal handling with validation. + +**TypeScript** +- [Signals and Queries](https://github.com/temporalio/samples-typescript/tree/main/signals-queries) — Signal and Query usage in a Workflow. +- [Message Passing](https://github.com/temporalio/samples-typescript/tree/main/message-passing) — Introduction to message passing with Signals, Queries, and Updates. + +**Python** +- [Hello Signal](https://github.com/temporalio/samples-python/tree/main/hello/hello_signal.py) — Basic Signal handling in a Workflow. +- [Message Passing](https://github.com/temporalio/samples-python/tree/main/message_passing/introduction) — Introduction to message passing with Signals, Queries, and Updates. + +**Go** +- [Await Signals](https://github.com/temporalio/samples-go/tree/main/await-signals) — Waiting for Signals with timeout using `AwaitWithTimeout`. +- [Message Passing](https://github.com/temporalio/samples-go/tree/message-passing/message-passing-intro) — Introduction to message passing with Signals, Queries, and Updates. diff --git a/docs/design-patterns/batch-iterator.mdx b/docs/design-patterns/batch-iterator.mdx new file mode 100644 index 0000000000..430d3914cf --- /dev/null +++ b/docs/design-patterns/batch-iterator.mdx @@ -0,0 +1,253 @@ +--- +id: batch-iterator +title: "Batch Iterator" +sidebar_label: "Batch Iterator" +description: "Pages through unbounded datasets using Continue-As-New to prevent history overflow while maintaining exactly-once processing guarantees." +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +:::info[TLDR] +**Process one page at a time** and call Continue-as-New with the next offset after each page so the Workflow's event history never grows without bound. With this method you can process infinite pages. Use this when your record set is arbitrarily large, you need a durable checkpoint after every page, and sequential page-by-page throughput is acceptable. +::: + +## Overview + +The Batch Iterator pattern processes a large record set one page at a time. Each Workflow run processes a single page and then calls Continue-as-New with the next offset, producing a chain of short-lived runs that together cover the entire record set without accumulating unbounded event history. + +## Problem + +A single Workflow run is limited to 50,000 history events (aim for 2,000) and 2,000 in-flight Activities. Processing millions of records in one run is not possible within these bounds. + +You need a way to process an arbitrarily large record set reliably, with the ability to resume from a checkpoint if the Workflow is interrupted, and without overwhelming downstream systems with a burst of concurrent requests. + +## Solution + +Each Workflow run fetches one page of records using a persistent `offset` parameter, processes each record sequentially, and then calls `continueAsNew` with the incremented offset. The next run picks up exactly where the previous one left off. + +Because each run processes only a bounded number of records, history stays well within limits. The offset acts as a durable checkpoint: if the Workflow is interrupted mid-page, the next run replays only from the start of the current page. + +```mermaid +flowchart TD + DB[("Data Source\n(paginated)")] + WF1["Workflow Run 1\n(offset=0)"] + WF2["Workflow Run 2\n(offset=PAGE_SIZE)"] + WF3["Workflow Run N\n(offset=N×PAGE_SIZE)"] + Done(["Complete"]) + + DB -->|"fetch page 1"| WF1 + WF1 -->|"processRecord ×PAGE_SIZE"| Acts1["Activities"] + WF1 -->|"continueAsNew\n(offset=PAGE_SIZE)"| WF2 + + DB -->|"fetch page 2"| WF2 + WF2 -->|"processRecord ×PAGE_SIZE"| Acts2["Activities"] + WF2 -->|"continueAsNew\n(offset=N×PAGE_SIZE)"| WF3 + + DB -->|"fetch page N"| WF3 + WF3 -->|"processRecord ×PAGE_SIZE"| Acts3["Activities"] + WF3 -->|"last page → return"| Done +``` + +The following describes each step in the diagram: + +1. The Workflow starts with `offset=0` and calls `fetchPage(offset, pageSize)` to retrieve the first page of records. +2. It processes each record in the page by executing the `processRecord` Activity. +3. After the page is fully processed, it calls `continueAsNew` with `offset + pageSize`, passing the updated offset to the next run. +4. The next run begins with a clean history and repeats the same steps for the next page. +5. When `fetchPage` returns fewer records than `pageSize`, the Workflow knows it has reached the last page and returns normally. + +## Implementation + +The following examples show how each SDK implements the Batch Iterator pattern. + + + + +```typescript +// workflows.ts +import { continueAsNew, log, proxyActivities } from "@temporalio/workflow"; +import type * as activities from "./activities"; +import { PAGE_SIZE } from "./shared"; + +const { fetchPage, processRecord } = proxyActivities({ + startToCloseTimeout: "10 seconds", +}); + +export async function batchIteratorWorkflow( + offset: number = 0, + totalProcessed: number = 0 +): Promise { + const page = await fetchPage(offset, PAGE_SIZE); + + for (const record of page) { + await processRecord(record); + totalProcessed++; + } + + log.info(`Processed page at offset ${offset} (${page.length} records, running total: ${totalProcessed})`); + + if (page.length === PAGE_SIZE) { + await continueAsNew(offset + PAGE_SIZE, totalProcessed); + } + + return totalProcessed; +} +``` + + + + +```python +# workflows.py +from temporalio import workflow +from temporalio.workflow import continue_as_new +from datetime import timedelta +from activities import fetch_page, process_record +from shared import PAGE_SIZE + + +@workflow.defn +class BatchIteratorWorkflow: + @workflow.run + async def run(self, offset: int = 0, total_processed: int = 0) -> int: + page = await workflow.execute_activity( + fetch_page, + args=[offset, PAGE_SIZE], + start_to_close_timeout=timedelta(seconds=10), + ) + + for record in page: + await workflow.execute_activity( + process_record, + record, + start_to_close_timeout=timedelta(seconds=10), + ) + total_processed += 1 + + workflow.logger.info( + f"Processed page at offset {offset} ({len(page)} records, running total: {total_processed})" + ) + + if len(page) == PAGE_SIZE: + continue_as_new(args=[offset + PAGE_SIZE, total_processed]) + + return total_processed +``` + + + + +```go +// workflows.go +package main + +import ( + "time" + + "go.temporal.io/sdk/workflow" +) + +func BatchIteratorWorkflow(ctx workflow.Context, offset int, totalProcessed int) (int, error) { + ao := workflow.ActivityOptions{ + StartToCloseTimeout: 10 * time.Second, + } + ctx = workflow.WithActivityOptions(ctx, ao) + + var page []Record + if err := workflow.ExecuteActivity(ctx, FetchPage, offset, PageSize).Get(ctx, &page); err != nil { + return totalProcessed, err + } + + for _, record := range page { + if err := workflow.ExecuteActivity(ctx, ProcessRecord, record).Get(ctx, nil); err != nil { + return totalProcessed, err + } + totalProcessed++ + } + + workflow.GetLogger(ctx).Info("Processed page", + "offset", offset, + "pageSize", len(page), + "totalProcessed", totalProcessed) + + if len(page) == PageSize { + return totalProcessed, workflow.NewContinueAsNewError(ctx, BatchIteratorWorkflow, offset+PageSize, totalProcessed) + } + + return totalProcessed, nil +} +``` + + + + +```java +// BatchIteratorWorkflow.java +import io.temporal.activity.ActivityOptions; +import io.temporal.workflow.*; +import java.time.Duration; +import java.util.List; + +@WorkflowInterface +public interface BatchIteratorWorkflow { + @WorkflowMethod + int run(int offset, int totalProcessed); +} + +// BatchIteratorWorkflowImpl.java +public class BatchIteratorWorkflowImpl implements BatchIteratorWorkflow { + private final Activities activities = Workflow.newActivityStub( + Activities.class, + ActivityOptions.newBuilder() + .setStartToCloseTimeout(Duration.ofSeconds(10)) + .build() + ); + + @Override + public int run(int offset, int totalProcessed) { + List page = activities.fetchPage(offset, Shared.PAGE_SIZE); + + for (Record record : page) { + activities.processRecord(record); + totalProcessed++; + } + + Workflow.getLogger(BatchIteratorWorkflowImpl.class).info( + "Processed page at offset " + offset + " (" + page.size() + " records, total: " + totalProcessed + ")" + ); + + if (page.size() == Shared.PAGE_SIZE) { + BatchIteratorWorkflow next = Workflow.newContinueAsNewStub(BatchIteratorWorkflow.class); + next.run(offset + Shared.PAGE_SIZE, totalProcessed); + } + + return totalProcessed; + } +} +``` + + + + +## Best practices + +- **Choose a page size that keeps history under 2,000 events.** Each page produces roughly `3 × pageSize` history events (`ActivityTaskScheduled` + `ActivityTaskStarted` + `ActivityTaskCompleted`). A page size of 500–800 records is a safe target. +- **Include `totalProcessed` (or a similar counter) in the `continueAsNew` args.** This lets you observe overall progress via the Workflow input visible in the UI without querying internal state. +- **Fetch inside an Activity, not the Workflow.** The `fetchPage` call must be an Activity — not inline Workflow code — so it can interact with external systems and be retried independently. +- **Make `processRecord` idempotent.** Activities have at-least-once execution semantics. If a worker crashes after an Activity completes externally but before the completion is recorded in history, Temporal will retry it. Your downstream system must tolerate receiving the same record more than once. +- **Avoid accumulating large local state between pages.** `continueAsNew` does not carry over in-memory state; only the arguments you pass are available in the next run. + +## Common pitfalls + +- **Forgetting `continueAsNew` on the last page.** If you call `continueAsNew` unconditionally, the Workflow loops forever even when the data source is exhausted. Check whether the returned page is shorter than `pageSize` before continuing. +- **Passing unnecessary state into `continueAsNew`.** All arguments are serialized and stored in history. Pass only the minimal state needed (offset, counters) — not accumulated result lists or large collections that grow with each page. +- **Sequential processing bottlenecks.** The default implementation processes one record at a time per page. You can fan out Activities concurrently within a page using the SDK's async primitives for higher per-page throughput — note this increases per-page event count accordingly. If record-set-wide throughput matters more than rate limiting, consider [Sliding Window](/design-patterns/sliding-window) or [MapReduce Tree](/design-patterns/mapreduce-tree). + +## Related resources + +- [Continue-as-New pattern](/design-patterns/continue-as-new) — core concepts for history management via `continueAsNew` +- [Sliding Window](/design-patterns/sliding-window) — bounded concurrency that progresses at the rate of the fastest processor +- [MapReduce Tree](/design-patterns/mapreduce-tree) — fully parallel processing for maximum speed +- [Temporal limits reference](/cloud/limits) +- [Batch samples (Java)](https://github.com/temporalio/samples-java/tree/main/core/src/main/java/io/temporal/samples/batch/iterator) diff --git a/docs/design-patterns/batch-processing-patterns.mdx b/docs/design-patterns/batch-processing-patterns.mdx new file mode 100644 index 0000000000..de30bf7f71 --- /dev/null +++ b/docs/design-patterns/batch-processing-patterns.mdx @@ -0,0 +1,144 @@ +--- +id: batch-processing-patterns +title: "Batch Processing Patterns" +sidebar_label: "Batch Processing Patterns" +description: "Batch Processing Patterns" +--- + +import PatternCards from '@site/src/components/PatternCards'; + +These patterns process large volumes of records reliably, at scale, and without overwhelming downstream systems. Choose based on your throughput requirements, record set size, and whether you need rate limiting or maximum parallelism. + +## When to use which pattern + +| Pattern | Record set size | Parallelism model | Workflow-based rate control | +|---|---|---|---| +| [Basic Workflow](#basic-workflow-single-tier-fan-out) | Small (up to a few hundred records) | Sequential or parallel activities in one Workflow | No | +| [Fan-Out with Child Workflows](/design-patterns/fanout-child-workflows) | Up to ~4M records | Fixed concurrency (one child per chunk) | No | +| [Batch Iterator](/design-patterns/batch-iterator) | Unlimited | Limited (activities per page) | Yes — fixed page rate | +| [Sliding Window](/design-patterns/sliding-window) | Unlimited | Bounded window of concurrent children | Yes — configurable window | +| [MapReduce Tree](/design-patterns/mapreduce-tree) | Unlimited | Fully parallel recursive tree | No — maximum speed | + +## Patterns in this section + + + +--- + +## Schedules + +Schedules allow Workflows to be executed on a recurring basis — think of them as a more flexible cron with start, pause, stop, update, and backfill controls. + +- Supports `start` / `pause` / `stop` / `update` / `backfill` of scheduled Workflow executions +- Configurable **Overlap Policies** control what happens when the previous run is still running +- Full execution history visibility in the Temporal UI +- Schedules can be created via the UI, CLI, or SDK + +```bash +temporal schedule create \ + --schedule-id 'your-schedule-id' \ + --workflow-id 'your-workflow-id' \ + --task-queue 'your-task-queue' \ + --workflow-type 'YourWorkflowType' +``` + +**References:** +- [Temporal Schedules](/schedule) +- [CLI schedule commands](/cli/command-reference/schedule) + +--- + +## Basic Workflow (single-tier fan-out) + +The most direct form of batch processing: the Workflow fetches or receives record IDs and executes one Activity per record. + +- Activities can be executed sequentially or concurrently (using the SDK's async primitives) +- **Limit: 2,000 in-flight Activities per Workflow run** (aim for 500) +- If total event count is likely to exceed 2,000 (hard limit: 51,200), use the [Batch Iterator](/design-patterns/batch-iterator) instead + +**Pros:** Minimal code and orchestration overhead +**Cons:** Hard cap on concurrent Activities; all-or-nothing failure model; can overwhelm downstream systems + +```mermaid +flowchart TD + Records["📋 Record IDs\n(fetched or passed in)"] + WF["Workflow"] + A1["Activity"] + A2["Activity"] + AN["Activity ..."] + + Records --> WF + WF --> A1 + WF --> A2 + WF --> AN +``` + +--- + +## Batch signalling + +The Temporal CLI lets you signal, reset, cancel, or terminate multiple Workflows with a single command using a visibility query. + +- 1 running batch job per namespace +- 50 Workflows per second per batch + +```bash +# Signal all running Workflows of a given type +temporal workflow signal \ + --name MySignal \ + --input '{"Input": "As-JSON"}' \ + --query 'ExecutionStatus = "Running" AND WorkflowType="YourWorkflow"' \ + --reason "Testing" + +# Terminate all running Workflows of a given type +temporal workflow terminate \ + --query 'ExecutionStatus = "Running" AND WorkflowType="SomeWorkflowType"' \ + --reason "Terminate Test Workflows" +``` + +**Reference:** [CLI batch commands](/cli/command-reference/batch) + +--- + +## Key limits + +Full reference: [Temporal Cloud limits](/cloud/limits) + +| Limit | Value | +|---|---| +| Unfinished actions per Workflow | 2,000 max (aim for 500). Includes Activities, Signals, Child Workflows, cancellation requests | +| Events per Workflow history | 51,200 events max (aim for a few thousand) **or** 50 MB total history size; warns at 10,240 events / 10 MB | +| Signals per Workflow | 10,000 | +| Updates per Workflow | 10 in-flight, 2,000 total | +| Batch Signalling | 1 batch job per namespace; 50 Workflows/sec per batch | + +## Related sections + +- [Task Orchestration Patterns](/design-patterns/task-orchestration-patterns) — the child-Workflow and parallel primitives these patterns scale up +- [QoS & Throughput Patterns](/design-patterns/qos-throughput-patterns) — rate-limit and prioritize the work a batch generates +- [Performance & Latency Patterns](/design-patterns/performance-latency-patterns) — reduce the latency of each record's processing diff --git a/docs/design-patterns/child-workflows.mdx b/docs/design-patterns/child-workflows.mdx new file mode 100644 index 0000000000..0923c743b6 --- /dev/null +++ b/docs/design-patterns/child-workflows.mdx @@ -0,0 +1,725 @@ +--- +id: child-workflows +title: "Child Workflows Pattern" +sidebar_label: "Child Workflows" +description: "Decomposes complex Workflows into smaller, reusable units. Each child has an independent Workflow ID, history, and lifecycle." +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +## Overview + +Child Workflows enable decomposition of complex business logic into smaller, reusable Workflow units. +Each child executes as an independent Workflow with its own Workflow ID, event history (50K event limit), and lifecycle. +Unlike Activities which execute code, Child Workflows orchestrate processes and provide Workflow-level semantics: independent tracking, querying, timeouts, and the ability to outlive the parent. + +Key capabilities: + +- **Independent identity**: Each child has a unique Workflow ID visible in the UI for tracking and querying. +- **Separate history**: Each child maintains its own event history, preventing parent history bloat. +- **Flexible invocation**: Synchronous (blocking) or asynchronous (non-blocking) execution. +- **Lifecycle control**: Parent close policies (TERMINATE, ABANDON, REQUEST_CANCEL) determine child behavior when the parent completes. +- **Task Queue routing**: Children can execute on different Task Queues with specialized Workers. +- **Reusability**: The same Child Workflow logic can be invoked by multiple different parent Workflows. + +## Problem + +In distributed systems, you often need Workflows that break down complex processes into modular, reusable components, execute sub-processes that may outlive the parent Workflow, coordinate multiple independent Workflows with different lifecycles, isolate failure domains while maintaining orchestration control, and reuse Workflow logic across different parent Workflows. + +Without Child Workflows, you must implement all logic in a single monolithic Workflow, manually coordinate separate Workflows via Signals and Queries, duplicate Workflow logic across multiple implementations, and manage complex state machines for sub-process coordination. + +## Solution + +You invoke Child Workflows from within parent Workflows using the SDK's Child Workflow API. +You can call them synchronously (blocking until completion) or asynchronously (fire-and-forget). +The `ParentClosePolicy` determines what happens to children when the parent completes. + +```mermaid +sequenceDiagram + participant Parent + participant Child1 + participant Child2 + + Parent->>+Child1: Start (sync) + activate Parent + Child1->>Child1: Execute + Child1-->>-Parent: Result + + Parent->>+Child2: Start (async) + Note over Parent,Child2: Parent continues immediately + Parent->>Parent: Do other work + Child2->>Child2: Execute independently + + alt Parent completes first + Parent->>Parent: Complete + deactivate Parent + Note over Child2: Policy: ABANDON
Child continues + Child2->>Child2: Keep running + Child2-->>-Child2: Complete + end +``` + +The following describes each step in the diagram: + +1. The parent starts Child 1 synchronously and blocks until it completes. +2. The parent starts Child 2 asynchronously and continues doing other work immediately. +3. If the parent completes before Child 2, the ABANDON policy allows Child 2 to continue running independently. + +### Synchronous Child Workflow + +The following example creates a Child Workflow and calls it synchronously. +The parent blocks until the child completes and returns a result: + + + + +```python +# workflows.py +from temporalio import workflow + +from child_workflows import ChildWorkflow + +@workflow.defn +class ParentWorkflow: + @workflow.run + async def run(self, input: str) -> str: + # Synchronous call - awaits until child completes + result = await workflow.execute_child_workflow( + ChildWorkflow.run, + input, + id=f"child-{workflow.uuid4()}", + ) + + return f"Parent received: {result}" +``` + + + + +```go +// parent_workflow.go +func ParentWorkflow(ctx workflow.Context, input string) (string, error) { + cwo := workflow.ChildWorkflowOptions{} + ctx = workflow.WithChildOptions(ctx, cwo) + + // Synchronous call - blocks until child completes + var result string + err := workflow.ExecuteChildWorkflow(ctx, ChildWorkflow, input).Get(ctx, &result) + if err != nil { + return "", err + } + + return "Parent received: " + result, nil +} +``` + + + + +```java +// ParentWorkflowImpl.java +@WorkflowInterface +public interface ParentWorkflow { + @WorkflowMethod + String execute(String input); +} + +public class ParentWorkflowImpl implements ParentWorkflow { + @Override + public String execute(String input) { + ChildWorkflow child = Workflow.newChildWorkflowStub(ChildWorkflow.class); + + // Synchronous call - blocks until child completes + String result = child.processData(input); + + return "Parent received: " + result; + } +} +``` + + + + +```typescript +// workflows.ts +import { executeChild } from '@temporalio/workflow'; +import { childWorkflow } from './child-workflows'; + +export async function parentWorkflow(input: string): Promise { + // Synchronous call - awaits until child completes + const result = await executeChild(childWorkflow, { + args: [input], + }); + + return `Parent received: ${result}`; +} +``` + + + + +In Java, `Workflow.newChildWorkflowStub()` creates a typed stub and calling a method on it blocks the parent. +In TypeScript, `executeChild()` starts the child and awaits its completion. +In Python, `workflow.execute_child_workflow()` starts the child and awaits its completion. +In Go, `workflow.ExecuteChildWorkflow()` returns a `ChildWorkflowFuture`, and calling `.Get()` blocks until the child completes. + +### Asynchronous Child Workflow + +The following example starts a Child Workflow asynchronously with an ABANDON policy. +The parent receives the child's execution info without waiting for completion: + + + + +```python +# workflows.py +from temporalio import workflow +from temporalio.workflow import ParentClosePolicy + +from child_workflows import ChildWorkflow + +@workflow.defn +class ParentWorkflow: + @workflow.run + async def run(self, input: str) -> str: + # Async call - returns handle once child starts + handle = await workflow.start_child_workflow( + ChildWorkflow.run, + input, + id=f"child-{workflow.uuid4()}", + parent_close_policy=ParentClosePolicy.ABANDON, + ) + + # Parent continues without waiting for child completion + return handle.id +``` + + + + +```go +// parent_workflow.go +import ( + enumspb "go.temporal.io/api/enums/v1" + "go.temporal.io/sdk/workflow" +) + +func ParentWorkflow(ctx workflow.Context, input string) (string, error) { + cwo := workflow.ChildWorkflowOptions{ + ParentClosePolicy: enumspb.PARENT_CLOSE_POLICY_ABANDON, + } + ctx = workflow.WithChildOptions(ctx, cwo) + + childFuture := workflow.ExecuteChildWorkflow(ctx, ChildWorkflow, input) + + // Wait for child to start, not complete + var childWE workflow.Execution + if err := childFuture.GetChildWorkflowExecution().Get(ctx, &childWE); err != nil { + return "", err + } + + // Parent continues without waiting for child completion + return childWE.ID, nil +} +``` + + + + +```java +// ParentWorkflowImpl.java +public class ParentWorkflowImpl implements ParentWorkflow { + @Override + public WorkflowExecution execute(String input) { + ChildWorkflowOptions options = ChildWorkflowOptions.newBuilder() + .setWorkflowId("child-" + Workflow.randomUUID()) + .setParentClosePolicy(ParentClosePolicy.PARENT_CLOSE_POLICY_ABANDON) + .build(); + + ChildWorkflow child = Workflow.newChildWorkflowStub(ChildWorkflow.class, options); + + // Async call - returns immediately + Async.function(child::processData, input); + + // Get child execution info without waiting for completion + Promise childExecution = Workflow.getWorkflowExecution(child); + return childExecution.get(); // Blocks only until child starts + } +} +``` + + + + +```typescript +// workflows.ts +import { startChild, ParentClosePolicy } from '@temporalio/workflow'; +import { childWorkflow } from './child-workflows'; + +export async function parentWorkflow(input: string): Promise { + const childHandle = await startChild(childWorkflow, { + args: [input], + parentClosePolicy: ParentClosePolicy.PARENT_CLOSE_POLICY_ABANDON, + }); + + // Parent continues without waiting for child completion + // childHandle.workflowId and childHandle.firstExecutionRunId are available + return childHandle.workflowId; +} +``` + + + + +In Java, `Async.function()` starts the child asynchronously. +`Workflow.getWorkflowExecution(child)` returns a Promise that resolves when the child starts (not when it completes). +In TypeScript, `startChild()` returns a handle once the child has started. +In Python, `workflow.start_child_workflow()` returns a handle once the child has started, without waiting for completion. +In Go, `childFuture.GetChildWorkflowExecution().Get()` blocks until the child has started. +The ABANDON policy ensures the child continues running even if the parent completes first. + +## Parent Close Policy + +The `ParentClosePolicy` determines Child Workflow behavior when the parent closes: + +| Policy | Behavior | Use case | +| :--- | :--- | :--- | +| `TERMINATE` | Child is terminated when parent closes | Tightly coupled processes | +| `ABANDON` | Child continues independently | Fire-and-forget, long-running tasks | +| `REQUEST_CANCEL` | Child receives cancellation request | Graceful cleanup | + +## Implementation + +### Parallel Child Workflows + +The following example starts multiple Child Workflows in parallel and waits for all of them to complete: + + + + +```python +# workflows.py +import asyncio +from temporalio import workflow + +from child_workflows import ChildWorkflow + +@workflow.defn +class ParallelParentWorkflow: + @workflow.run + async def run(self, items: list[str]) -> str: + # Start all children concurrently using asyncio.gather + results = await asyncio.gather( + *[ + workflow.execute_child_workflow( + ChildWorkflow.run, + item, + id=f"child-{workflow.uuid4()}", + ) + for item in items + ] + ) + + return ", ".join(results) +``` + + + + +```go +// parallel_parent_workflow.go +func ParallelParentWorkflow(ctx workflow.Context, items []string) (string, error) { + cwo := workflow.ChildWorkflowOptions{} + ctx = workflow.WithChildOptions(ctx, cwo) + + // Start all children - ExecuteChildWorkflow returns immediately + var futures []workflow.ChildWorkflowFuture + for _, item := range items { + futures = append(futures, workflow.ExecuteChildWorkflow(ctx, ChildWorkflow, item)) + } + + // Wait for all children to complete + var results []string + for _, future := range futures { + var result string + if err := future.Get(ctx, &result); err != nil { + return "", err + } + results = append(results, result) + } + + return strings.Join(results, ", "), nil +} +``` + + + + +```java +// ParallelParentWorkflowImpl.java +public class ParallelParentWorkflowImpl implements ParentWorkflow { + @Override + public String execute(List items) { + List> promises = new ArrayList<>(); + + for (String item : items) { + ChildWorkflow child = Workflow.newChildWorkflowStub(ChildWorkflow.class); + promises.add(Async.function(child::process, item)); + } + + // Wait for all children to complete + Promise.allOf(promises).get(); + + return promises.stream() + .map(Promise::get) + .collect(Collectors.joining(", ")); + } +} +``` + + + + +```typescript +// workflows.ts +import { executeChild } from '@temporalio/workflow'; +import { childWorkflow } from './child-workflows'; + +export async function parallelParentWorkflow(items: string[]): Promise { + // Start all children concurrently using Promise.all + const results = await Promise.all( + items.map((item) => + executeChild(childWorkflow, { + args: [item], + }) + ) + ); + + return results.join(', '); +} +``` + + + + +In Java, each child starts asynchronously via `Async.function()`, and `Promise.allOf(promises).get()` blocks until every child completes. +In TypeScript, `Promise.all()` starts all children concurrently and awaits all results. +In Python, `asyncio.gather()` starts all children concurrently and awaits all results. +In Go, `workflow.ExecuteChildWorkflow()` returns a Future immediately without blocking, so starting all children in a loop launches them in parallel. Calling `.Get()` on each Future afterward collects the results. + +### Fire-and-forget + +The following example starts a Child Workflow with the ABANDON policy and returns immediately without waiting: + + + + +```python +# workflows.py +from temporalio import workflow +from temporalio.workflow import ParentClosePolicy + +from child_workflows import LongRunningChildWorkflow + +@workflow.defn +class FireAndForgetParentWorkflow: + @workflow.run + async def run(self, data: str) -> None: + # Start child with ABANDON policy - child survives parent completion + await workflow.start_child_workflow( + LongRunningChildWorkflow.run, + data, + id=f"child-{workflow.uuid4()}", + parent_close_policy=ParentClosePolicy.ABANDON, + ) + + # start_child_workflow resolves once the child has started + # Parent completes, child continues independently +``` + + + + +```go +// fire_and_forget_workflow.go +import ( + enumspb "go.temporal.io/api/enums/v1" + "go.temporal.io/sdk/workflow" +) + +func FireAndForgetParentWorkflow(ctx workflow.Context, data string) error { + cwo := workflow.ChildWorkflowOptions{ + ParentClosePolicy: enumspb.PARENT_CLOSE_POLICY_ABANDON, + } + ctx = workflow.WithChildOptions(ctx, cwo) + + childFuture := workflow.ExecuteChildWorkflow(ctx, LongRunningChildWorkflow, data) + + // Wait for child to start before parent completes + if err := childFuture.GetChildWorkflowExecution().Get(ctx, nil); err != nil { + return err + } + + // Parent completes, child continues independently + return nil +} +``` + + + + +```java +// FireAndForgetParentWorkflowImpl.java +public class FireAndForgetParentWorkflowImpl implements ParentWorkflow { + @Override + public void execute(String data) { + ChildWorkflowOptions options = ChildWorkflowOptions.newBuilder() + .setParentClosePolicy(ParentClosePolicy.PARENT_CLOSE_POLICY_ABANDON) + .build(); + + ChildWorkflow child = Workflow.newChildWorkflowStub(ChildWorkflow.class, options); + + // Start child and don't wait for completion + Async.function(child::longRunningProcess, data); + + // Wait for child to start before parent completes + Workflow.getWorkflowExecution(child).get(); + + // Parent completes, child continues independently + } +} +``` + + + + +```typescript +// workflows.ts +import { startChild, ParentClosePolicy } from '@temporalio/workflow'; +import { longRunningChildWorkflow } from './child-workflows'; + +export async function fireAndForgetParentWorkflow(data: string): Promise { + // Start child with ABANDON policy - child survives parent completion + await startChild(longRunningChildWorkflow, { + args: [data], + parentClosePolicy: ParentClosePolicy.PARENT_CLOSE_POLICY_ABANDON, + }); + + // startChild resolves once the child has started + // Parent completes, child continues independently +} +``` + + + + +You must wait for the child to start before the parent completes. +Without this, the parent could complete before the child is scheduled, and the child would never execute. +The ABANDON policy ensures the child continues running after the parent completes. + +### Conditional child execution + +The following example conditionally starts different Child Workflows based on business logic: + + + + +```python +# workflows.py +from temporalio import workflow + +from child_workflows import ApprovalWorkflow, FulfillmentWorkflow + +@workflow.defn +class ConditionalParentWorkflow: + @workflow.run + async def run(self, order: Order) -> str: + if order.requires_approval: + approved = await workflow.execute_child_workflow( + ApprovalWorkflow.run, + order, + id=f"approval-{order.id}", + ) + + if not approved: + return "Order rejected" + + return await workflow.execute_child_workflow( + FulfillmentWorkflow.run, + order, + id=f"fulfillment-{order.id}", + ) +``` + + + + +```go +// conditional_parent_workflow.go +func ConditionalParentWorkflow(ctx workflow.Context, order Order) (string, error) { + cwo := workflow.ChildWorkflowOptions{} + ctx = workflow.WithChildOptions(ctx, cwo) + + if order.RequiresApproval { + var approved bool + err := workflow.ExecuteChildWorkflow(ctx, ApprovalWorkflow, order).Get(ctx, &approved) + if err != nil { + return "", err + } + + if !approved { + return "Order rejected", nil + } + } + + var result string + err := workflow.ExecuteChildWorkflow(ctx, FulfillmentWorkflow, order).Get(ctx, &result) + if err != nil { + return "", err + } + + return result, nil +} +``` + + + + +```java +// ConditionalParentWorkflowImpl.java +public class ConditionalParentWorkflowImpl implements ParentWorkflow { + @Override + public String execute(Order order) { + if (order.requiresApproval()) { + ApprovalWorkflow approval = Workflow.newChildWorkflowStub(ApprovalWorkflow.class); + boolean approved = approval.requestApproval(order); + + if (!approved) { + return "Order rejected"; + } + } + + FulfillmentWorkflow fulfillment = Workflow.newChildWorkflowStub(FulfillmentWorkflow.class); + return fulfillment.fulfill(order); + } +} +``` + + + + +```typescript +// workflows.ts +import { executeChild } from '@temporalio/workflow'; +import { approvalWorkflow, fulfillmentWorkflow } from './child-workflows'; + +export async function conditionalParentWorkflow(order: Order): Promise { + if (order.requiresApproval) { + const approved = await executeChild(approvalWorkflow, { + args: [order], + }); + + if (!approved) { + return 'Order rejected'; + } + } + + return await executeChild(fulfillmentWorkflow, { + args: [order], + }); +} +``` + + + + +The parent checks whether the order requires approval and only starts the approval Child Workflow when needed. + +## When to use + +Child Workflows and Activities serve different purposes. + +Use Child Workflows when: + +- You need a separate Workflow ID for tracking and querying. +- The operation may outlive the parent Workflow. +- You need to reuse Workflow logic across multiple parents. +- You want to execute Workflows on different Task Queues. +- You need independent history and event limits. +- You want to apply different timeouts or retry policies at the Workflow level. + +Use Activities when: + +- You are executing external operations (API calls, database queries). +- The operation is short-lived. +- You do not need independent Workflow tracking. +- The operation is tightly coupled to the parent Workflow lifecycle. +- Lower overhead is important. + +The key distinction is that Activities are for executing code (especially external operations), while Child Workflows are for orchestrating processes that benefit from independent Workflow semantics. + +## Benefits and trade-offs + +Child Workflows provide modularity by breaking complex logic into reusable units. +Each child is a first-class Workflow with its own ID for tracking, its own 50K event history limit, and its own execution timeout configuration. +Children can outlive parents with the ABANDON policy, and you can start multiple children concurrently. +Child failures do not automatically fail the parent, and the same Child Workflow can be reused by multiple parents. + +The trade-offs to consider are that each child is a separate Workflow execution with its own history (overhead). +There are more moving parts than a single Workflow. +Child execution details are not in the parent history (but are queryable independently). +Async children require explicit synchronization if needed. +More Workflow executions mean higher resource usage. +Starting a Child Workflow has more overhead than starting an Activity. + +## Comparison with alternatives + +| Approach | Modularity | Independent history | Can outlive parent | Overhead | Separate Workflow ID | +| :--- | :--- | :--- | :--- | :--- | :--- | +| Child Workflow | High | Yes | Yes (ABANDON) | Medium | Yes | +| Activity | Medium | No | No | Low | No | +| Separate Workflow + Signals | High | Yes | Yes | High | Yes | +| Async Lambda | Low | No | No | Very Low | No | + +## Best practices + +- **Use unique Workflow IDs.** Generate unique IDs for Child Workflows to avoid conflicts. +- **Choose the appropriate policy.** Use TERMINATE for tightly coupled children, ABANDON for independent children. +- **Handle child failures.** Catch and handle Child Workflow exceptions appropriately. +- **Limit parallelism.** Do not spawn unlimited children; use batch patterns for large datasets. +- **Consider Activities first.** Use Activities for operations that do not need independent Workflow tracking. +- **Set timeouts.** Configure appropriate Workflow execution timeouts for children. +- **Use typed stubs.** Prefer typed stubs over untyped for compile-time safety. +- **Monitor child executions.** Track Child Workflow IDs for observability and debugging. + +## Common pitfalls + +- **Treating Child Workflows like Activities.** Child Workflows are for orchestration, not for executing external code. If you only need to call an API or run a function, use an Activity instead. +- **Spawning unbounded children in a loop.** Starting thousands of Child Workflows without batching can overwhelm the Temporal Service and bloat the parent's event history. Use fixed-size batches or a sliding window. +- **Ignoring the Parent Close Policy.** The default policy is TERMINATE, which kills children when the parent closes. If children must outlive the parent, set the policy to ABANDON explicitly. +- **Using synchronous calls when async is needed.** Calling a Child Workflow synchronously blocks the parent until the child completes. For long-running children, use the async API (`Async.function()` in Java, `startChild()` in TypeScript, `start_child_workflow()` in Python, or collect Futures without calling `.Get()` in Go) to avoid stalling the parent. +- **Omitting Workflow IDs.** Without explicit Workflow IDs, you lose the ability to deduplicate or look up Child Workflows by a meaningful identifier. Generate deterministic IDs based on business keys. +- **Not handling child failures.** Child Workflow failures propagate to the parent as a Child Workflow Failure (`ChildWorkflowFailure` in TypeScript and Java, `ChildWorkflowError` in Python, `ChildWorkflowExecutionError` in Go), with the underlying cause in its `cause` field. If you do not catch and handle them, the parent Workflow fails as well. + +## Related patterns + +- **[Parallel Execution](/design-patterns/parallel-execution)**: Running multiple children concurrently. +- **[Continue-As-New](/design-patterns/continue-as-new)**: Child Workflows can use Continue-As-New independently. +- **[Saga Pattern](/design-patterns/saga-pattern)**: Children as compensatable transactions. + +## Sample code + +**Java:** +- [HelloChild](https://github.com/temporalio/samples-java/tree/main/core/src/main/java/io/temporal/samples/hello/HelloChild.java) — Basic synchronous Child Workflow. +- [Async Child Workflow](https://github.com/temporalio/samples-java/tree/main/core/src/main/java/io/temporal/samples/asyncchild) — Asynchronous child with ABANDON policy. +- [Async Untyped Child](https://github.com/temporalio/samples-java/tree/main/core/src/main/java/io/temporal/samples/asyncuntypedchild) — Untyped async Child Workflow. + +**TypeScript:** +- [Child Workflows](https://github.com/temporalio/samples-typescript/tree/main/child-workflows) — Parent and child Workflow using `executeChild` and `startChild`. + +**Python:** +- [Child Workflows](https://github.com/temporalio/samples-python/tree/main/hello/hello_child_workflow.py) — Basic Child Workflow using `execute_child_workflow`. + +**Go:** +- [Child Workflow](https://github.com/temporalio/samples-go/tree/main/child-workflow) — Synchronous and async Child Workflow patterns. diff --git a/docs/design-patterns/continue-as-new.mdx b/docs/design-patterns/continue-as-new.mdx new file mode 100644 index 0000000000..608b8216c5 --- /dev/null +++ b/docs/design-patterns/continue-as-new.mdx @@ -0,0 +1,437 @@ +--- +id: continue-as-new +title: "Continue-As-New Pattern" +sidebar_label: "Continue-As-New" +description: "Prevents unbounded history growth by completing the current execution and starting a new one with fresh history." +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +## Overview + +The Continue-As-New pattern allows long-running Workflows to reset their event history by completing the current execution and immediately starting a new one with fresh state. +This prevents Workflows from hitting Temporal's event history limits while maintaining logical continuity, making it essential for periodic tasks, infinite loops, and Workflows that process unbounded data streams. +By archiving old event history and starting fresh, Continue-As-New also reduces active storage costs — only the current execution's history remains in active storage while previous runs are moved to cheaper archived storage. + +## Problem + +In long-running Workflows, you often need to execute periodic tasks indefinitely, process unbounded streams of data without accumulating history, implement infinite loops that run for months or years, avoid hitting the 50,000 event history limit, and maintain Workflow state across logical restarts. + +Without Continue-As-New, you must manually stop and restart Workflows (losing continuity), risk hitting history limits and Workflow failures, implement external orchestration to manage Workflow lifecycle, and accept degraded performance as history grows large. + +## Solution + +Continue-As-New completes the current Workflow execution and atomically starts a new one with the same Workflow ID. +The new execution begins with a fresh event history while preserving logical continuity. +You pass state as arguments to the new execution. + +```mermaid +flowchart LR + Start([Start]) --> Exec1[Execution 1
Process batch] + Exec1 --> Check1{History
large?} + Check1 -->|Yes| CAN1[Continue-As-New] + Check1 -->|No| More1{More
data?} + More1 -->|Yes| Exec1 + More1 -->|No| End1([Complete]) + + CAN1 -.->|Fresh history
Same Workflow ID| Exec2[Execution 2
Process batch] + Exec2 --> Check2{History
large?} + Check2 -->|Yes| CAN2[Continue-As-New] + Check2 -->|No| More2{More
data?} + More2 -->|Yes| Exec2 + More2 -->|No| End2([Complete]) + + CAN2 -.-> Etc[...] + classDef highlight stroke-width:1px + class CAN1,CAN2 highlight +``` + +The following describes each step in the diagram: + +1. The Workflow starts Execution 1 and processes a batch of data. +2. After each batch, the Workflow checks whether the history is getting large. +3. If the history is large, the Workflow calls Continue-As-New, which starts Execution 2 with a fresh history and the same Workflow ID. +4. If the history is not large and more data remains, the Workflow loops and processes the next batch. +5. If no more data remains, the Workflow completes normally. + +The following implementation shows a data processor that passes a cursor and a running total across executions. +When the batch is full (indicating more data to process), the Workflow calls Continue-As-New with the updated state: + + + + +```python +# workflows.py +from datetime import timedelta +from temporalio import workflow + +with workflow.unsafe.imports_passed_through(): + from activities import fetchBatch, process + +BATCH_SIZE = 100 + +@workflow.defn +class DataProcessorWorkflow: + @workflow.run + async def run(self, cursor: str, total_processed: int = 0) -> None: + batch = await workflow.execute_activity( + fetchBatch, args=[cursor, BATCH_SIZE], + start_to_close_timeout=timedelta(seconds=60), + ) + + for record in batch: + await workflow.execute_activity( + process, record, + start_to_close_timeout=timedelta(seconds=60), + ) + total_processed += 1 + cursor = record.id + + if len(batch) == BATCH_SIZE: + # More data to process - continue as new with updated state + workflow.continue_as_new(args=[cursor, total_processed]) + # Otherwise complete normally +``` + + + + +```go +// data_processor_workflow.go +const BatchSize = 100 + +func DataProcessorWorkflow(ctx workflow.Context, cursor string, totalProcessed int) error { + ao := workflow.ActivityOptions{StartToCloseTimeout: time.Minute} + ctx = workflow.WithActivityOptions(ctx, ao) + + var batch []Record + err := workflow.ExecuteActivity(ctx, FetchBatch, cursor, BatchSize).Get(ctx, &batch) + if err != nil { + return err + } + + for _, record := range batch { + err = workflow.ExecuteActivity(ctx, Process, record).Get(ctx, nil) + if err != nil { + return err + } + totalProcessed++ + cursor = record.ID + } + + if len(batch) == BatchSize { + // More data to process - continue as new with updated state + return workflow.NewContinueAsNewError(ctx, DataProcessorWorkflow, cursor, totalProcessed) + } + // Otherwise complete normally + return nil +} +``` + + + + +```java +// DataProcessorWorkflowImpl.java +@WorkflowInterface +public interface DataProcessorWorkflow { + @WorkflowMethod + void processData(String cursor, int totalProcessed); +} + +public class DataProcessorWorkflowImpl implements DataProcessorWorkflow { + private static final int BATCH_SIZE = 100; + + @Override + public void processData(String cursor, int totalProcessed) { + List batch = activities.fetchBatch(cursor, BATCH_SIZE); + + for (Record record : batch) { + activities.process(record); + totalProcessed++; + cursor = record.getId(); + } + + if (batch.size() == BATCH_SIZE) { + // More data to process - continue as new with updated state + DataProcessorWorkflow continueAsNew = + Workflow.newContinueAsNewStub(DataProcessorWorkflow.class); + continueAsNew.processData(cursor, totalProcessed); + } + // Otherwise complete normally + } +} +``` + + + + +```typescript +// workflows.ts +import { continueAsNew, proxyActivities } from '@temporalio/workflow'; +import type * as activities from './activities'; + +const { fetchBatch, process } = proxyActivities({ + startToCloseTimeout: '1 minute', +}); + +const BATCH_SIZE = 100; + +export async function dataProcessorWorkflow( + cursor: string, + totalProcessed: number = 0 +): Promise { + const batch = await fetchBatch(cursor, BATCH_SIZE); + + for (const record of batch) { + await process(record); + totalProcessed++; + cursor = record.id; + } + + if (batch.length === BATCH_SIZE) { + // More data to process - continue as new with updated state + await continueAsNew(cursor, totalProcessed); + } + // Otherwise complete normally +} +``` + + + + +The Workflow fetches a batch of records, processes each one, and updates the cursor. +If the batch is full, the Workflow triggers Continue-As-New with the updated cursor and total. +In Java, `Workflow.newContinueAsNewStub()` creates a typed stub. +In TypeScript, `continueAsNew()` throws a special error that the runtime intercepts. +In Python, `workflow.continue_as_new()` immediately stops the current execution and starts a new one. +In Go, `workflow.NewContinueAsNewError()` returns a special error that signals the runtime to continue as new. +If the batch is smaller than `BATCH_SIZE`, no more data remains and the Workflow completes. + +## Implementation + +### Use the Continue-As-New suggestion + +Instead of tracking iteration counts manually, you can use the SDK's built-in suggestion to let Temporal tell you when the history is getting large: + + + + +```python +# workflows.py +from datetime import timedelta +from temporalio import workflow + +@workflow.defn +class DataProcessorWorkflow: + @workflow.run + async def run(self, cursor: str, total_processed: int = 0) -> None: + batch = await workflow.execute_activity( + fetchBatch, args=[cursor, BATCH_SIZE], + start_to_close_timeout=timedelta(seconds=60), + ) + + for record in batch: + await workflow.execute_activity( + process, record, + start_to_close_timeout=timedelta(seconds=60), + ) + total_processed += 1 + cursor = record.id + + # Check if history is getting large + if workflow.info().is_continue_as_new_suggested(): + workflow.continue_as_new(args=[cursor, total_processed]) + + # Continue processing or complete +``` + + + + +```go +// data_processor_workflow.go +func DataProcessorWorkflow(ctx workflow.Context, cursor string, totalProcessed int) error { + ao := workflow.ActivityOptions{StartToCloseTimeout: time.Minute} + ctx = workflow.WithActivityOptions(ctx, ao) + + var batch []Record + err := workflow.ExecuteActivity(ctx, FetchBatch, cursor, BatchSize).Get(ctx, &batch) + if err != nil { + return err + } + + for _, record := range batch { + err = workflow.ExecuteActivity(ctx, Process, record).Get(ctx, nil) + if err != nil { + return err + } + totalProcessed++ + cursor = record.ID + + // Check if history is getting large + if workflow.GetInfo(ctx).GetContinueAsNewSuggested() { + return workflow.NewContinueAsNewError(ctx, DataProcessorWorkflow, cursor, totalProcessed) + } + } + + // Continue processing or complete + return nil +} +``` + + + + +```java +// DataProcessorWorkflowImpl.java +public class DataProcessorWorkflowImpl implements DataProcessorWorkflow { + + @Override + public void processData(String cursor, int totalProcessed) { + List batch = activities.fetchBatch(cursor, BATCH_SIZE); + + for (Record record : batch) { + activities.process(record); + totalProcessed++; + cursor = record.getId(); + + // Check if history is getting large + if (Workflow.getInfo().isContinueAsNewSuggested()) { + DataProcessorWorkflow continueAsNew = + Workflow.newContinueAsNewStub(DataProcessorWorkflow.class); + continueAsNew.processData(cursor, totalProcessed); + return; + } + } + + // Continue processing or complete + } +} +``` + + + + +```typescript +// workflows.ts +import { continueAsNew, workflowInfo, proxyActivities } from '@temporalio/workflow'; +import type * as activities from './activities'; + +const { fetchBatch, process } = proxyActivities({ + startToCloseTimeout: '1 minute', +}); + +export async function dataProcessorWorkflow( + cursor: string, + totalProcessed: number = 0 +): Promise { + const batch = await fetchBatch(cursor, BATCH_SIZE); + + for (const record of batch) { + await process(record); + totalProcessed++; + cursor = record.id; + + // Check if history is getting large + if (workflowInfo().continueAsNewSuggested) { + await continueAsNew(cursor, totalProcessed); + } + } + + // Continue processing or complete +} +``` + + + + +Each SDK provides a method to check if the history is approaching the limit: +- Java: `Workflow.getInfo().isContinueAsNewSuggested()` +- TypeScript: `workflowInfo().continueAsNewSuggested` +- Python: `workflow.info().is_continue_as_new_suggested()` +- Go: `workflow.GetInfo(ctx).GetContinueAsNewSuggested()` + +This approach is more reliable than a fixed iteration count because it accounts for the actual number of events generated per iteration. + +## When to use + +The Continue-As-New pattern is a good fit for periodic Workflows running indefinitely (cron-like behavior), processing unbounded data streams, long-running Workflows with repetitive patterns, Workflows that accumulate state over many iterations, and preventing event history from growing too large. + +It is not a good fit for short-lived Workflows (under 1000 events), Workflows that naturally complete, one-time batch processing, or Workflows that require full history for audit purposes. + +## Benefits and trade-offs + +Continue-As-New allows you to run Workflows indefinitely without history limits. +Fresh history keeps Workflow execution fast. +It reduces active storage costs by archiving old event history — more aggressive iteration limits mean more frequent archiving, keeping active storage minimal. +The transition is atomic with no gap between old and new execution. +You pass state as arguments to the new execution, and the Workflow ID remains the same, maintaining logical continuity for Queries and Signals. + +The trade-offs to consider are that previous execution history is archived separately. +You must explicitly pass state as arguments (manual state management). +Queries only see the current execution's state. +Debugging requires tracing across multiple execution runs. +You cannot undo Continue-As-New once triggered. + +## Comparison with alternatives + +| Approach | History reset | State continuity | Use case | +| :--- | :--- | :--- | :--- | +| Continue-As-New | Yes | Manual | Long-running periodic | +| Child Workflows | Per child | Automatic | Parallel processing | +| Cron Schedule | Yes | None | Fixed schedule tasks | +| Manual Restart | Yes | None | One-time Workflows | + +## Best practices + +- **Use the continue-as-new suggestion.** Check the SDK's built-in suggestion (`isContinueAsNewSuggested()` in Java, `continueAsNewSuggested` in TypeScript, `is_continue_as_new_suggested()` in Python, `GetContinueAsNewSuggested()` in Go) to automatically detect when history is large. +- **Set aggressive iteration limits.** Continue as new every 100–1000 iterations to prevent history buildup and reduce storage costs. Balance frequency with the overhead of creating new executions. +- **Pass minimal state.** Only pass necessary state to keep arguments small. +- **Add exit Signals.** Allow graceful termination via Signals. +- **Log transitions.** Log when continuing as new for observability. +- **Version carefully.** Ensure new code can handle state from old executions. +- **Monitor history size.** Track event count and continue before hitting limits. +- **Use typed APIs.** In Java, prefer `newContinueAsNewStub()` over untyped `continueAsNew()`. In TypeScript, use the generic `continueAsNew()` for type safety. +- **Consider cron.** For fixed Schedules, use Temporal Schedules instead. +- **Test state transfer.** Verify state correctly passes between executions. + +## Common pitfalls + +- **Passing too much state.** Continue-As-New arguments are serialized into the first event of the new execution. Large payloads slow down startup and increase storage costs. Pass only the minimal state needed. +- **Forgetting to drain Signals before continuing.** Any Signals received but not yet processed are lost when Continue-As-New starts a fresh execution. Drain your Signal channel and carry pending Signals forward as arguments. +- **Using a fixed iteration count instead of the built-in suggestion.** Different Workflow paths generate different numbers of events per iteration. A fixed count may continue too early or too late. Use the SDK's built-in continue-as-new suggestion for accurate detection. +- **Not versioning state arguments.** When you change the Workflow method signature or state shape, in-flight executions may continue as new into code that cannot deserialize the old arguments. Use versioning or backward-compatible argument types. +- **Calling Continue-As-New from a Signal handler.** Triggering Continue-As-New inside a Signal handler can cause Signal loss because the handler may preempt other pending Signals. Always set a flag in the Signal handler and call Continue-As-New from the main Workflow thread, where all Signal handlers are guaranteed to have run first. +- **Not accounting for Child Workflows.** Continue-As-New closes the current Workflow Execution, which triggers the Parent Close Policy on all Child Workflows. By default, children are terminated. If children must survive, set `ParentClosePolicy` to `ABANDON` and pass their Workflow IDs to the new execution so you can interact with them via external handles. +- **Caching Run IDs for external interaction.** Continue-As-New creates a new Run ID. If external callers cache the old Run ID for Signals or Queries, they will get a "workflow execution already completed" error. Always use Workflow ID without a Run ID (or an empty Run ID) so the request routes to the currently running execution. +- **Catching the Continue-As-New exception.** In TypeScript and Python, Continue-As-New is implemented by throwing a special exception. Wrapping it in a try-catch or try-except can suppress the transition and cause unexpected behavior. Let the exception propagate unhandled. In Go, return the `ContinueAsNewError` from the Workflow function without wrapping it. + +## Related patterns + +- **[Entity Workflow](/design-patterns/entity-workflow)**: Long-lived Workflows that model business entities, relying on Continue-As-New to prevent unbounded history. +- **[Child Workflows](/design-patterns/child-workflows)**: Decomposing work into sub-Workflows. Consider Parent Close Policy when combining with Continue-As-New. +- **[Signal with Start](/design-patterns/signal-with-start)**: Idempotent Workflow start with an initial Signal — use Workflow ID without Run ID to interact with continued executions. + +## Sample code + +**Java:** +- [Cron Workflow](https://github.com/temporalio/samples-java/tree/main/core/src/main/java/io/temporal/samples/hello/HelloCron.java) — Periodic Workflow using Continue-As-New. +- [Heartbeating Activity Batch](https://github.com/temporalio/samples-java/tree/main/core/src/main/java/io/temporal/samples/batch/heartbeatingactivity) — Batch processing with Continue-As-New for large datasets. + +**TypeScript:** +- [Continue-As-New](https://github.com/temporalio/samples-typescript/tree/main/continue-as-new) — Basic Continue-As-New with `continueAsNew()`. +- [Safe Message Handlers](https://github.com/temporalio/samples-typescript/tree/main/message-passing/safe-message-handlers) — Entity Workflow with Continue-As-New and `continueAsNewSuggested`. + +**Python:** +- [Safe Message Handlers](https://github.com/temporalio/samples-python/tree/main/message_passing/safe_message_handlers) — Entity Workflow with Continue-As-New and `is_continue_as_new_suggested()`. + +**Go:** +- [Safe Message Handlers](https://github.com/temporalio/samples-go/tree/main/safe_message_handler) — Entity Workflow with Continue-As-New and `GetContinueAsNewSuggested()`. + +## References + +- [Continue-As-New](/workflow-execution/continue-as-new) — Concept reference for the Continue-As-New mechanism. +- [Temporal Blog: How to Keep a Workflow Running Indefinitely Long](https://temporal.io/blog/very-long-running-workflows) — Detailed guidance on managing Workflows that run forever. +- [Temporal Blog: Workflows as Actors](https://temporal.io/blog/workflows-as-actors-is-it-really-possible) — Using Continue-As-New with the Entity Workflow / Actor pattern. diff --git a/docs/design-patterns/delayed-callback.mdx b/docs/design-patterns/delayed-callback.mdx new file mode 100644 index 0000000000..44027553d7 --- /dev/null +++ b/docs/design-patterns/delayed-callback.mdx @@ -0,0 +1,754 @@ +--- +id: delayed-callback +title: "Delayed Callback (Webhooks)" +sidebar_label: "Delayed Callback" +description: "Integrates webhooks durably: receive inbound webhooks via Signals, fire delayed outbound callbacks with durable timers, and complete Activities asynchronously via task tokens." +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +## Overview +Delayed Callback patterns in Temporal manage delayed completion notification between two systems. They use durable timers, and you define the behavior entirely in code. + +### Webhooks +You build and configure [Webhooks](https://www.redhat.com/en/topics/automation/what-is-a-webhook) with the Delayed Callback patterns. There are two recommended patterns for integrating Temporal Workflows to communicate through HTTP callbacks: +- waiting and receiving inbound webhooks +- firing delayed outbound callbacks + +Included is a pattern for completing activities asynchronously via a callback token and some guidance on when to use it. + +Temporal lets you build durable, observable webhook-based integrations without ad hoc queues, cron jobs, or fragile state machines. + +## Problem + +Waiting for another system without Durable Execution is hard. You must implement your own: +- durable timers (e.g. with a cron per timer) +- retry queues +- state stores +- reconciliation jobs + +All to support a delayed callback. + +Webhook-based integrations have several failure modes that are difficult to handle without durable infrastructure: + +- An inbound message fires before the target system is ready or at the proper state, causing the message to be ignored or lost. +- An outbound HTTP callback fails midway through a multi-step cross-system process, and there is no record of what was sent, retried, or skipped. +- An external job (payment processor, ML pipeline, etc.) completes and calls back, but the in-process state that was waiting for the callback has been lost to poor state management or an application restart. +- A delayed callback is scheduled via a cron job or message queue, but the scheduling system and the application process have no shared recovery mechanism. + +## Solution + +Temporal solves these problems with [durable timers](/workflow-execution/timers-delays) in a Workflow. You use [Signals or Updates](/encyclopedia/workflow-message-passing) to send events to a Workflow. All of these are Temporal code — no extra infrastructure to deploy or manage. + +- **Pattern 1 — Inbound Callback:** Route the incoming HTTP request to a Temporal Signal-with-Start. The Workflow is the durable recipient; if it is not running yet, Temporal creates it and delivers the Signal atomically. +- **Pattern 2 — Delayed Outbound Callbacks:** Use a durable `workflow.sleep()` to set the proper delay before executing the outbound HTTP activity. The sleep timer survives worker and server restarts; the activity retries automatically on failure. +- **Pattern 3 — Async Activity Completion:** The activity records a task token before returning, and your callback endpoint uses that token to complete the activity from the outside. The Workflow resumes with the result as if the activity had returned normally. + +### Pattern 1 — inbound webhooks + +```mermaid +sequenceDiagram + participant E as External Service + participant A as Your API Handler + participant T as Temporal Cluster + participant W as Workflow + + E->>A: POST /webhook (payload) + A->>T: signal_with_start(workflow_id,
"payment_received", payload) + Note over T: Atomic: start if not running, then signal + T->>W: Start workflow (if not running)
+ deliver signal + W->>W: Wake up, process payload + W->>W: Execute follow-on activities +``` + +The following describes each step in the diagram: + +1. An external service sends an HTTP POST to your API handler — this is the inbound webhook. +2. Your handler calls `signal_with_start` on the Temporal client with the Workflow ID and payload. The handler can return an HTTP 200 immediately after this call; Temporal takes responsibility for delivery. +3. Temporal atomically starts the Workflow if it is not already running, then delivers the Signal — no race condition between "start" and "signal." +4. The Workflow wakes up exactly where it was blocked waiting (or begins execution if newly created) and processes the payload. + +### Pattern 2 — delayed outbound callbacks + +```mermaid +sequenceDiagram + participant C as Client + participant W as Workflow + participant T as Temporal Cluster + participant E as External Service + + C->>T: Start DelayedCallbackWorkflow
(url, data, delay) + T->>W: Schedule first task + W->>T: workflow.sleep(delay) + Note over T: Durable timer (survives restarts) + T->>W: Timer fires after delay + W->>W: execute_activity(send_callback, url, data) + W->>E: POST callback_url (data) + E-->>W: HTTP 200 + W-->>C: Workflow complete +``` + +The following describes each step in the diagram: + +1. The client starts the Workflow with a target URL, payload, and delay duration. +2. The Workflow calls `workflow.sleep()`. This stores a durable timer in the Temporal cluster — not in process memory. +3. If any worker restarts during the delay, the timer continues. When it fires, Temporal schedules the next Workflow Task on a healthy worker. +4. The Workflow executes an activity that performs the outbound HTTP POST. If the POST fails, Temporal retries it with the configured retry policy. + +### Pattern 3 — async Activity Completion + +```mermaid +sequenceDiagram + participant W as Workflow + participant A as Activity + participant E as External Service + participant CB as Your Callback API + + W->>A: execute_activity(submit_job) + A->>E: Submit job, record task_token + A-->>W: Return (workflow paused waiting) + Note over W: Waiting for external completion... + E->>CB: POST /callback (result) + CB->>W: complete_async_activity
(task_token, result) + W->>W: Resume with result +``` + +The following describes each step in the diagram: + +1. The Workflow executes an activity that submits a job to an external system. +2. The activity records its task token (an opaque handle Temporal provides) alongside the submitted job ID — for example, in a database row. +3. The activity returns without waiting; the Workflow is now paused waiting for the activity to complete externally. +4. When the external system finishes, it calls your callback endpoint with the result. +5. Your callback handler retrieves the task token from the database and completes the activity through the Temporal client using that token. The Workflow resumes immediately with the result. + +## Implementation + +### Pattern 1 — inbound webhooks via Signal-With-Start + +The following examples show an `OrderWorkflow` that waits for a `payment_received` Signal. The starter uses Signal-with-Start to atomically create the Workflow and deliver the payment signal in one call — exactly what your HTTP handler would do on a real POST. + + + + +```python +# workflows.py +import asyncio +from dataclasses import dataclass +from datetime import timedelta +from typing import Optional + +from temporalio import workflow + +with workflow.unsafe.imports_passed_through(): + from activities import process_payment + from shared import OrderInput, PaymentPayload + + +@workflow.defn +class OrderWorkflow: + def __init__(self) -> None: + self._payment: Optional[PaymentPayload] = None + + @workflow.run + async def run(self, order: OrderInput) -> str: + workflow.logger.info(f"Order {order.order_id}: waiting for payment webhook") + + # Block until the inbound webhook signal arrives (or timeout after 24 hours) + await workflow.wait_condition( + lambda: self._payment is not None, + timeout=timedelta(hours=24), + ) + + if self._payment is None: + return f"Order {order.order_id}: timed out waiting for payment" + + result = await workflow.execute_activity( + process_payment, + self._payment, + start_to_close_timeout=timedelta(seconds=30), + ) + return result + + @workflow.signal + async def payment_received(self, payload: PaymentPayload) -> None: + workflow.logger.info(f"Payment signal received: {payload.payment_id}") + self._payment = payload +``` + + + + +```java +// OrderWorkflow.java +import io.temporal.activity.ActivityOptions; +import io.temporal.workflow.SignalMethod; +import io.temporal.workflow.Workflow; +import io.temporal.workflow.WorkflowInterface; +import io.temporal.workflow.WorkflowMethod; + +import java.time.Duration; + +@WorkflowInterface +public interface OrderWorkflow { + @WorkflowMethod + String run(Shared.OrderInput order); + + @SignalMethod + void paymentReceived(Shared.PaymentPayload payload); + + final class Impl implements OrderWorkflow { + private final Activities activities = Workflow.newActivityStub( + Activities.class, + ActivityOptions.newBuilder() + .setStartToCloseTimeout(Duration.ofSeconds(30)) + .build()); + + private Shared.PaymentPayload payment = null; + + @Override + public String run(Shared.OrderInput order) { + System.out.println("Order " + order.orderId() + ": waiting for payment webhook"); + + // Block until the inbound webhook signal arrives (or timeout after 24 hours) + boolean received = Workflow.await(Duration.ofHours(24), () -> payment != null); + if (!received) { + return "Order " + order.orderId() + ": timed out waiting for payment"; + } + + return activities.processPayment(payment); + } + + @Override + public void paymentReceived(Shared.PaymentPayload payload) { + System.out.println("Payment signal received: " + payload.paymentId()); + this.payment = payload; + } + } +} +``` + + + + +```go +// workflows.go +package main + +import ( + "time" + + "go.temporal.io/sdk/workflow" +) + +func OrderWorkflow(ctx workflow.Context, order OrderInput) (string, error) { + workflow.GetLogger(ctx).Info("Order waiting for payment webhook", "order_id", order.OrderID) + + var payment *PaymentPayload + + // Block until the inbound webhook signal arrives (or timeout after 24 hours) + selector := workflow.NewSelector(ctx) + timerFired := false + + timerCtx, cancelTimer := workflow.WithCancel(ctx) + timer := workflow.NewTimer(timerCtx, 24*time.Hour) + + signalCh := workflow.GetSignalChannel(ctx, SignalName) + selector.AddReceive(signalCh, func(ch workflow.ReceiveChannel, more bool) { + ch.Receive(ctx, &payment) + cancelTimer() + }) + selector.AddFuture(timer, func(f workflow.Future) { + if err := f.Get(ctx, nil); err == nil { + timerFired = true + } + }) + + selector.Select(ctx) + + if timerFired || payment == nil { + return "Order " + order.OrderID + ": timed out waiting for payment", nil + } + + ao := workflow.WithActivityOptions(ctx, workflow.ActivityOptions{ + StartToCloseTimeout: 30 * time.Second, + }) + + var result string + err := workflow.ExecuteActivity(ao, ProcessPayment, payment).Get(ao, &result) + return result, err +} +``` + + + + +The Starter uses Signal-with-Start to atomically create the Workflow (if needed) and deliver the simulated webhook payload: + + + + +```python +# starter.py +import asyncio +import time + +from temporalio.client import Client + +from shared import TASK_QUEUE, OrderInput, PaymentPayload +from workflows import OrderWorkflow + + +async def main() -> None: + client = await Client.connect("localhost:7233") + + order_id = f"order-{int(time.time() * 1000)}" + order = OrderInput(order_id=order_id, amount=99.99) + payment = PaymentPayload(payment_id=f"pay-{int(time.time() * 1000)}", amount=99.99) + + print(f"Sending webhook for order {order_id}") + + # Signal-with-Start: atomically starts the workflow (if not running) and + # delivers the payment signal — this is exactly what your HTTP handler would do. + handle = await client.start_workflow( + OrderWorkflow.run, + order, + id=f"order-{order_id}", + task_queue=TASK_QUEUE, + start_signal="payment_received", + start_signal_args=[payment], + ) + print(f"Webhook signal sent: {payment.payment_id}") + + result = await handle.result() + print(f"Order completed: {result}") + + +if __name__ == "__main__": + asyncio.run(main()) +``` + + + + +```java +// Starter.java +import io.temporal.client.BatchRequest; +import io.temporal.client.WorkflowClient; +import io.temporal.client.WorkflowOptions; +import io.temporal.client.WorkflowStub; +import io.temporal.serviceclient.WorkflowServiceStubs; + +public class Starter { + public static void main(String[] args) throws Exception { + WorkflowServiceStubs service = WorkflowServiceStubs.newLocalServiceStubs(); + WorkflowClient client = WorkflowClient.newInstance(service); + + String orderId = "order-" + System.currentTimeMillis(); + Shared.PaymentPayload payment = new Shared.PaymentPayload( + "pay-" + System.currentTimeMillis(), 99.99); + + OrderWorkflow workflow = client.newWorkflowStub( + OrderWorkflow.class, + WorkflowOptions.newBuilder() + .setTaskQueue(Shared.TASK_QUEUE) + .setWorkflowId("order-" + orderId) + .build()); + + System.out.println("Sending webhook for order " + orderId); + + // Signal-with-Start: atomically starts the workflow (if not running) and + // delivers the payment signal — this is exactly what your HTTP handler would do. + BatchRequest request = client.newSignalWithStartRequest(); + request.add(workflow::run, new Shared.OrderInput(orderId, 99.99)); + request.add(workflow::paymentReceived, payment); + client.signalWithStart(request); + System.out.println("Webhook signal sent: " + payment.paymentId()); + + // Wait for the workflow to complete + String result = WorkflowStub.fromTyped(workflow).getResult(String.class); + System.out.println("Order completed: " + result); + + System.exit(0); + } +} +``` + + + + +```go +// starter.go +package main + +import ( + "context" + "fmt" + "log" + "time" + + "go.temporal.io/sdk/client" +) + +func main() { + c, err := client.Dial(client.Options{HostPort: "localhost:7233"}) + if err != nil { + log.Fatalln("Unable to create client:", err) + } + defer c.Close() + + ctx := context.Background() + orderID := fmt.Sprintf("order-%d", time.Now().UnixMilli()) + workflowID := "order-" + orderID + + order := OrderInput{OrderID: orderID, Amount: 99.99} + payment := PaymentPayload{ + PaymentID: fmt.Sprintf("pay-%d", time.Now().UnixMilli()), + Amount: 99.99, + } + + fmt.Printf("Sending webhook for order %s\n", orderID) + + // Signal-with-Start: atomically starts the workflow (if not running) and + // delivers the payment signal — this is exactly what your HTTP handler would do. + we, err := c.SignalWithStartWorkflow( + ctx, + workflowID, + SignalName, + payment, + client.StartWorkflowOptions{ + ID: workflowID, + TaskQueue: TaskQueue, + }, + OrderWorkflow, + order, + ) + if err != nil { + log.Fatalln("SignalWithStart failed:", err) + } + fmt.Printf("Webhook signal sent: %s\n", payment.PaymentID) + + var result string + if err := we.Get(ctx, &result); err != nil { + log.Fatalln("Workflow result failed:", err) + } + fmt.Printf("Order completed: %s\n", result) +} +``` + + + + +### Pattern 2 — delayed outbound callbacks + +Use a durable `workflow.sleep()` before the outbound activity. The timer is stored in the Temporal cluster, not in process memory — it survives any number of worker restarts. + + + + +```python +# delayed_callback_workflow.py +from datetime import timedelta + +from temporalio import workflow + +with workflow.unsafe.imports_passed_through(): + from activities import send_webhook_callback + from shared import CallbackInput + + +@workflow.defn +class DelayedCallbackWorkflow: + @workflow.run + async def run(self, input: CallbackInput) -> str: + workflow.logger.info( + f"Sleeping {input.delay_seconds}s before calling {input.callback_url}" + ) + + # Durable sleep — survives worker restarts, server restarts, everything + await workflow.sleep(timedelta(seconds=input.delay_seconds)) + + # Fire the outbound callback; Temporal retries on HTTP failure + result = await workflow.execute_activity( + send_webhook_callback, + input, + start_to_close_timeout=timedelta(minutes=5), + ) + workflow.logger.info(f"Callback delivered to {input.callback_url}") + return result +``` + + + + +```java +// DelayedCallbackWorkflow.java +import io.temporal.activity.ActivityOptions; +import io.temporal.workflow.Workflow; +import io.temporal.workflow.WorkflowInterface; +import io.temporal.workflow.WorkflowMethod; + +import java.time.Duration; + +@WorkflowInterface +public interface DelayedCallbackWorkflow { + @WorkflowMethod + void run(Shared.CallbackInput input); + + final class Impl implements DelayedCallbackWorkflow { + private final Activities activities = Workflow.newActivityStub( + Activities.class, + ActivityOptions.newBuilder() + .setStartToCloseTimeout(Duration.ofMinutes(5)) + .build()); + + @Override + public void run(Shared.CallbackInput input) { + System.out.println("Sleeping " + input.delaySeconds() + "s before calling " + input.callbackUrl()); + + // Durable sleep — survives worker restarts, server restarts, everything + Workflow.sleep(Duration.ofSeconds(input.delaySeconds())); + + // Fire the outbound callback; Temporal retries on HTTP failure + activities.sendWebhookCallback(input); + System.out.println("Callback delivered to " + input.callbackUrl()); + } + } +} +``` + + + + +```go +// delayed_callback.go (add to workflows.go) +package main + +import ( + "time" + + "go.temporal.io/sdk/workflow" +) + +func DelayedCallbackWorkflow(ctx workflow.Context, input CallbackInput) error { + workflow.GetLogger(ctx).Info("Sleeping before callback", + "delay", input.DelaySeconds, "url", input.CallbackURL) + + // Durable sleep — survives worker restarts, server restarts, everything + if err := workflow.Sleep(ctx, time.Duration(input.DelaySeconds)*time.Second); err != nil { + return err + } + + // Fire the outbound callback; Temporal retries on HTTP failure + ao := workflow.WithActivityOptions(ctx, workflow.ActivityOptions{ + StartToCloseTimeout: 5 * time.Minute, + }) + return workflow.ExecuteActivity(ao, SendWebhookCallback, input).Get(ao, nil) +} +``` + + + + +### Pattern 3 — async Activity Completion + +The activity records a task token before returning. Your callback endpoint later uses that token to complete the activity and unblock the Workflow. + + + + +```python +# async_completion_activities.py +import asyncio +from temporalio import activity +from temporalio.client import Client + +from shared import JobInput, JobResult + + +@activity.defn +async def submit_job(input: JobInput) -> str: + """Submit job to external system and return immediately. + The activity completes asynchronously when the callback arrives.""" + + # Get the task token — this is the claim ticket + task_token = activity.info().task_token + + # Submit the job to the external system, persisting the task token + # so your callback handler can retrieve it later + job_id = await external_service.submit( + payload=input.payload, + callback_url=f"https://your-api.example.com/callback", + task_token_hex=task_token.hex(), # store alongside job_id + ) + activity.logger.info(f"Job {job_id} submitted; waiting for async callback") + + # Tell Temporal not to mark the activity complete on return; the external + # callback will complete it later using the task token. + activity.raise_complete_async() + + +# In your webhook callback handler (e.g., FastAPI route): +async def handle_callback(result: str, task_token_hex: str) -> None: + token = bytes.fromhex(task_token_hex) + client = await Client.connect("localhost:7233") + handle = client.get_async_activity_handle(task_token=token) + await handle.complete(result) + # Workflow resumes with `result` immediately +``` + + + + +```java +// AsyncCompletionActivity.java — submit side +import io.temporal.activity.Activity; +import io.temporal.activity.ActivityExecutionContext; +import io.temporal.activity.ActivityInterface; +import io.temporal.activity.ActivityMethod; +import io.temporal.client.ActivityCompletionClient; + +@ActivityInterface +public interface AsyncJobActivity { + @ActivityMethod + String submitJob(Shared.JobInput input); +} + +public class AsyncJobActivityImpl implements AsyncJobActivity { + private final ActivityCompletionClient completionClient; + + public AsyncJobActivityImpl(ActivityCompletionClient completionClient) { + this.completionClient = completionClient; + } + + @Override + public String submitJob(Shared.JobInput input) { + ActivityExecutionContext context = Activity.getExecutionContext(); + + // Get the task token — this is the claim ticket + byte[] taskToken = context.getTaskToken(); + + // Submit to external system, persisting the task token so the callback can retrieve it + String jobId = ExternalService.submit(input.payload(), taskToken); + System.out.println("Job " + jobId + " submitted; waiting for async callback"); + + // Tell Temporal not to mark the activity complete yet + context.doNotCompleteOnReturn(); + return null; // ignored + } +} + +// In your callback handler: +// completionClient.complete(taskToken, result); +``` + + + + +```go +// async_completion.go +package main + +import ( + "context" + "encoding/hex" + "fmt" + + "go.temporal.io/sdk/activity" + "go.temporal.io/sdk/client" +) + +// SubmitJob submits a job and returns immediately; the activity completes +// when the external callback arrives and calls CompleteAsyncActivity. +func SubmitJob(ctx context.Context, input JobInput) (string, error) { + info := activity.GetInfo(ctx) + + // Persist the task token so the callback handler can retrieve it by job ID + taskToken := info.TaskToken + jobID := fmt.Sprintf("job-%d", info.StartedTime.UnixMilli()) + + if err := persistTaskToken(jobID, hex.EncodeToString(taskToken)); err != nil { + return "", err + } + + fmt.Printf("Job %s submitted; waiting for async callback\n", jobID) + + // Return ErrResultPending to tell Temporal not to mark the activity complete yet + return "", activity.ErrResultPending +} + +// CompleteJob is called by your webhook callback handler to unblock the workflow. +func CompleteJob(ctx context.Context, c client.Client, jobID string, result string) error { + tokenHex, err := loadTaskToken(jobID) + if err != nil { + return err + } + taskToken, _ := hex.DecodeString(tokenHex) + return c.CompleteActivity(ctx, taskToken, result, nil) +} +``` + + + + +## When to use + +| Scenario | Pattern | +| :--- | :--- | +| External service POSTs a webhook (Workflow may or may not be running) | Signal-with-Start (Pattern 1) | +| Fire an outbound HTTP callback after a delay (seconds to years) | `workflow.sleep()` + activity (Pattern 2) | +| Submit a job to an external system; wait for its completion webhook | Async activity completion (Pattern 3) | +| Poll an external system that does not support webhooks | [Polling External Services](/design-patterns/polling) pattern | + +**Do not use** Pattern 2 for delays shorter than one second. Timer durations range from one second to several years, and your Workflows should not rely on [sub-second accuracy for Timers](/workflow-execution/timers-delays). + +## Benefits and trade-offs + +**Benefits** + +- Retries and backoff on outbound HTTP calls come for free via Temporal's retry policy — no custom retry queues needed. +- Workflow state survives worker restarts, deploys, and infrastructure failures; durable timers continue without a running process. +- Every in-flight delayed callback is visible in the Temporal UI with its scheduled time, payload, and retry count. +- Signal-with-Start eliminates the race condition between "does the Workflow exist?" and "deliver the event." +- Async activity completion decouples job submission from job completion without polling. + +**Trade-offs** + +- Your inbound webhook handler requires a Temporal client; you need the client library in the service receiving webhooks. +- Task tokens for async completion must be persisted outside Temporal (e.g., in a database); if that store is unavailable the callback cannot complete. +- Workflow IDs must be deterministic and stable across webhook deliveries (order ID, user ID, etc.) so that Signal-with-Start routes to the correct instance. + +## Comparison with alternatives + +| Approach | Durability | Retries | Observability | Complexity | +| :--- | :--- | :--- | :--- | :--- | +| Temporal Signals + Workflows | Durable — survives restarts | Built-in, configurable | Full Temporal UI | Low — primitives compose naturally | +| Message queue (SQS, Kafka) | Durable (queue level) | Limited, manual DLQ | Requires external tooling | Medium — must handle ordering, DLQ | +| Redis `SET` + cron job | In-memory/volatile | Manual | None | High — cron + polling + error handling | +| Direct HTTP retry loops | Process lifetime only | Manual with `time.sleep` | None | High — fragile without process supervisor | + +## Best practices + +- Use stable, business-meaningful Workflow IDs (for example, `order-{order_id}`) so that Signal-with-Start and queries always route to the right Workflow. +- Return HTTP 200 from your inbound webhook handler as soon as you have called `signal_with_start`; do not wait for the Workflow to process the payload. +- Set a realistic `start_to_close_timeout` on outbound callback activities — long enough for the destination to respond, short enough to surface failures quickly. +- For async activity completion, persist the task token in a transactional write alongside the job submission so you never lose the token. +- Add a timeout to the `workflow.wait_condition` / `Workflow.await` call in inbound webhook Workflows so they do not wait indefinitely if the webhook is never delivered. +- For Pattern 3, use `heartbeat` if the external job takes longer than the activity heartbeat timeout to report back — heartbeating keeps the activity lease alive. + +## Common pitfalls + +- **Sending a plain Signal to a Workflow that does not exist** causes an error. Use Signal-with-Start when the Workflow may not be running. +- **Using `time.sleep()` (non-durable)** in Pattern 2 instead of `workflow.sleep()`. A process sleep disappears on restart; only Temporal's timer is durable. +- **Non-deterministic Workflow IDs** — generating IDs from timestamps or random values means Signal-with-Start creates a new Workflow on every webhook delivery instead of routing to the existing one. +- **Losing the task token** in Pattern 3. If the service storing task tokens is unavailable when the callback arrives, the activity can never complete. Store the token durably (database, not in-process cache). +- **Forgetting to signal async completion** in Pattern 3 (`raise_complete_async()` in Python, `ErrResultPending` in Go, `doNotCompleteOnReturn()` in Java). Without this, Temporal marks the activity as completed immediately when the function returns, before the external callback arrives. + +## Related patterns + +- [Signal with Start](/design-patterns/signal-with-start) — deeper coverage of the Signal-with-Start API for entity Workflows +- [Approval](/design-patterns/approval) — blocking wait for an external human decision using Signals +- [Polling External Services](/design-patterns/polling) — alternative to callbacks when the external system does not support webhooks +- [Delayed Start](/design-patterns/delayed-start) — defer Workflow execution to a future time without `workflow.sleep()` +- [Long-Running Activity](/design-patterns/long-running-activity) — heartbeating pattern for activities that run for extended periods +- **In the future** - Org-to-Org Nexus, stay tuned. diff --git a/docs/design-patterns/delayed-retry.mdx b/docs/design-patterns/delayed-retry.mdx new file mode 100644 index 0000000000..c704152085 --- /dev/null +++ b/docs/design-patterns/delayed-retry.mdx @@ -0,0 +1,266 @@ +--- +id: delayed-retry +title: "Delayed Retry" +sidebar_label: "Delayed Retry" +description: "Override the next retry interval for a specific failure using nextRetryDelay on ApplicationFailure. Use when an error carries information about how long to wait before retrying." +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +:::info[TLDR] +Throw an `ApplicationFailure` with `nextRetryDelay` set inside the Activity to **delay the next retry for a fixed time.** Use this when an error carries its own timing information — such as an HTTP 429 `Retry-After` header or a known maintenance window — so Temporal waits exactly as long as needed instead of following the generic backoff schedule. +::: + +## Overview + +The Delayed Retry pattern overrides the next retry interval for a specific failure by throwing an `ApplicationFailure` with a `nextRetryDelay` field set from inside the Activity. +Use it when a particular error carries information about how long to wait before retrying — such as a rate-limit response with a `Retry-After` header, or a known maintenance window with a fixed end time. + +## Problem + +A `RetryPolicy` applies a single backoff schedule to all failures from an Activity. +This works well for generic transient errors, but some errors carry specific information about how long the caller must wait: + +- An HTTP 429 response includes a `Retry-After: 60` header telling you exactly when the quota resets. +- A downstream system returns an error message saying "maintenance until 02:00 UTC" — a precise, known delay. +- A database error includes a lock timeout duration that indicates when the resource will be available. + +With a global `RetryPolicy`, you have two options, neither of which is what you need: set a short interval and retry too early (wasting quota and adding load), or set a long interval and wait longer than necessary. +What you need is to set the next retry delay *specific to this failure*, based on the information the error itself provides. + +## Solution + +Throw an `ApplicationFailure` with the `nextRetryDelay` field set from inside the Activity. +Temporal replaces the RetryPolicy-calculated interval for that single retry with the value you specify. +Subsequent retries (if the next attempt also fails) return to the normal RetryPolicy schedule unless you set `nextRetryDelay` again. + +```mermaid +sequenceDiagram + participant Workflow + participant Temporal as Temporal Service + participant API as Rate-Limited API + + Workflow->>Temporal: Schedule activity
(RetryPolicy: initialInterval=1s) + Temporal->>+API: Attempt 1 + API-->>-Temporal: HTTP 429 — Retry-After: 60s + Note over Temporal: Activity throws
ApplicationFailure(nextRetryDelay=60s) + Note over Temporal: Override: wait 60s
(ignoring RetryPolicy interval) + Temporal->>+API: Attempt 2 + API-->>-Temporal: Success + Temporal-->>Workflow: Result +``` + +The following describes each step: + +1. The Activity calls the API. It receives an HTTP 429 with a `Retry-After: 60` header. +2. The Activity extracts the retry delay from the response and throws `ApplicationFailure` with `nextRetryDelay=60s`. +3. Temporal ignores the RetryPolicy's calculated interval for this retry and waits exactly 60 seconds instead. +4. The next attempt succeeds and Temporal delivers the result to the Workflow. + +## Implementation + +### Override the retry delay from the response + +Extract the wait duration from the error or response and pass it to `ApplicationFailure`. +The RetryPolicy's `MaximumAttempts` and `ScheduleToCloseTimeout` still apply — only the interval for the next retry is overridden. + + + + +```java +// RateLimitedActivityImpl.java +import io.temporal.activity.Activity; +import io.temporal.failure.ApplicationFailure; +import java.time.Duration; + +public class RateLimitedActivityImpl implements RateLimitedActivity { + @Override + public String callApi(String endpoint) { + ApiResponse response = httpClient.get(endpoint); + + if (response.getStatusCode() == 429) { + int retryAfterSeconds = response.getHeaderInt("Retry-After", 0); + if (retryAfterSeconds > 0) { + throw ApplicationFailure.newFailureWithCauseAndDelay( + "Rate limited — retrying after " + retryAfterSeconds + "s", + "RateLimitError", + null, + Duration.ofSeconds(retryAfterSeconds) + ); + } + throw ApplicationFailure.newFailure("Rate limited — retrying per RetryPolicy", "RateLimitError"); + } + + return response.getBody(); + } +} +``` + + + + +```typescript +// activities.ts +import { ApplicationFailure } from '@temporalio/activity'; + +export async function callApi(endpoint: string): Promise { + const response = await fetch(endpoint); + + if (response.status === 429) { + const retryAfterHeader = response.headers.get('Retry-After'); + const retryAfterSeconds = retryAfterHeader != null ? parseInt(retryAfterHeader, 10) : undefined; + throw ApplicationFailure.create({ + message: retryAfterSeconds != null + ? `Rate limited — retrying after ${retryAfterSeconds}s` + : 'Rate limited — retrying per RetryPolicy', + type: 'RateLimitError', + // Only override the interval when the header is present; fall back to RetryPolicy otherwise + nextRetryDelay: retryAfterSeconds != null ? `${retryAfterSeconds}s` : undefined, + }); + } + + return response.text(); +} +``` + + + + +### Attempt-proportional delay + +You can also set the delay dynamically based on the attempt number — for example, to implement a custom backoff that differs from exponential, or to add a known base delay on top of the standard backoff. + + + + +```java +// BackoffActivityImpl.java +import io.temporal.activity.Activity; +import io.temporal.failure.ApplicationFailure; +import java.time.Duration; + +public class BackoffActivityImpl implements BackoffActivity { + @Override + public String process(String input) { + int attempt = Activity.getExecutionContext().getInfo().getAttempt(); + + try { + return downstreamService.call(input); + } catch (ServiceUnavailableException e) { + // Custom delay: 3 seconds × attempt number (3s, 6s, 9s, …) + throw ApplicationFailure.newFailureWithCauseAndDelay( + "Service unavailable on attempt " + attempt, + "ServiceUnavailable", + e, + Duration.ofSeconds(3L * attempt) + ); + } + } +} +``` + + + + +```typescript +// activities.ts +import { ApplicationFailure, activityInfo } from '@temporalio/activity'; + +export async function process(input: string): Promise { + const { attempt } = activityInfo(); + + try { + return await downstreamService.call(input); + } catch (e) { + // Custom delay: 3 seconds × attempt number (3s, 6s, 9s, …) + throw ApplicationFailure.create({ + message: `Service unavailable on attempt ${attempt}`, + type: 'ServiceUnavailable', + cause: e as Error, + nextRetryDelay: `${3 * attempt}s`, + }); + } +} +``` + + + + +### Workflow configuration + +The Workflow sets a normal `RetryPolicy`. +The `nextRetryDelay` set in the Activity overrides the interval only for the retry following that specific failure — subsequent attempts fall back to the RetryPolicy schedule if `nextRetryDelay` is not set again. + + + + +```java +// ApiWorkflowImpl.java +public class ApiWorkflowImpl implements ApiWorkflow { + private final RateLimitedActivity activities = Workflow.newActivityStub( + RateLimitedActivity.class, + ActivityOptions.newBuilder() + .setStartToCloseTimeout(Duration.ofSeconds(10)) + .setRetryOptions(RetryOptions.newBuilder() + .setInitialInterval(Duration.ofSeconds(1)) + .setBackoffCoefficient(2.0) + .setMaximumAttempts(10) + .build()) + .build() + ); + + @Override + public String run(String endpoint) { + return activities.callApi(endpoint); + } +} +``` + + + + +```typescript +// workflows.ts +import * as wf from '@temporalio/workflow'; +import type * as activities from './activities'; + +const { callApi } = wf.proxyActivities({ + startToCloseTimeout: '10s', + retry: { + initialInterval: '1s', + backoffCoefficient: 2, + maximumAttempts: 10, + }, +}); + +export async function apiWorkflow(endpoint: string): Promise { + return await callApi(endpoint); +} +``` + + + + +## Best practices + +- **Use the error's own delay information when available.** HTTP 429 `Retry-After`, database lock timeouts, and API-provided backoff hints are more accurate than any value you could configure statically. +- **Fall back to the RetryPolicy for unknown errors.** Only set `nextRetryDelay` for error types where you have reliable delay information. Let the RetryPolicy handle all other failures normally. +- **Still set a meaningful RetryPolicy.** `nextRetryDelay` overrides the interval for a single retry; the RetryPolicy still governs maximum attempts and the intervals for attempts where `nextRetryDelay` is not set. Also ensure `scheduleToCloseTimeout` is long enough to accommodate the maximum possible `nextRetryDelay` value — a tight budget can cause the Activity to expire before the delayed retry executes. +- **Surface the delay in the failure message.** Include the delay value and its source in the `ApplicationFailure` message (for example, `"Rate limited — retrying after 60s (Retry-After header)"`) so it appears directly in the Workflow history - Activity failure details. This makes it clear why the Activity waited an unusual amount of time without requiring separate log correlation. + +## Common pitfalls + +- **Assuming `nextRetryDelay` persists across all retries.** It only applies to the immediate next retry. If the following attempt also fails without setting `nextRetryDelay`, the RetryPolicy interval resumes. +- **Setting `nextRetryDelay` longer than `ScheduleToCloseTimeout`.** If the override delay exceeds the remaining `ScheduleToCloseTimeout` budget, the retry will never execute — Temporal will expire the Activity before the delay elapses. + +## Related patterns + +- [Fixed Count of Retries](/design-patterns/fixed-count-retries): Cap total attempts to prevent unbounded retry cost. +- [Fixed Wall-Time Retries](/design-patterns/fixed-wall-time-retries): Enforce a total elapsed time budget across all attempts. +- [Fast/Slow Retries](/design-patterns/fast-slow-retries): Shift from a fast retry cadence to a slow one after initial attempts are exhausted. +- [Error Handling & Retry Patterns](/design-patterns/error-handling-patterns): Overview and decision tree for all retry patterns. + +## References + +- [Per-error next Retry delay](/encyclopedia/retry-policies#per-error-next-retry-delay) diff --git a/docs/design-patterns/delayed-start.mdx b/docs/design-patterns/delayed-start.mdx new file mode 100644 index 0000000000..34166f25d8 --- /dev/null +++ b/docs/design-patterns/delayed-start.mdx @@ -0,0 +1,455 @@ +--- +id: delayed-start +title: "Delayed Start Pattern" +sidebar_label: "Delayed Start" +description: "Creates Workflows immediately but defers execution until a specified delay expires. Fits one-time scheduled operations and grace periods." +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +## Overview + +The Delayed Start pattern enables Workflows to be created immediately but begin execution after a specified delay. +The Workflow execution is registered in Temporal right away, but the first Workflow Task is scheduled to run only after the delay period expires, making it suitable for scheduled operations, grace periods, and deferred processing. + +## Problem + +In business processes, you often need Workflows that start execution at a future time, are created immediately for tracking but execute later, avoid external scheduling systems or cron jobs for one-time delays, and maintain Workflow identity and queryability before execution begins. + +Without delayed start, you must use external schedulers to trigger Workflow creation later, start Workflows immediately and sleep as the first operation (which wastes resources), implement complex queueing systems for deferred execution, or use Temporal Schedules for one-time delays (which is more than you need). + +## Solution + +The Delayed Start uses a start delay option in WorkflowOptions to defer the first Workflow Task. +The Workflow execution is created immediately with a `firstWorkflowTaskBackoff` set to the delay duration, but no Workflow code runs until the delay expires. + +```mermaid +sequenceDiagram + participant Client + participant Temporal + participant Workflow + + Client->>Temporal: Start with setStartDelay(30s) + Temporal->>Temporal: Create execution + Note over Temporal: Execution visible
but not running + Temporal-->>Client: Workflow ID + + Note over Temporal: Delay period (30s)... + + opt During delay + Client->>Temporal: Signal-With-Start + Note over Temporal: Bypasses remaining delay + end + + Note over Temporal: Delay expires (if not bypassed) + Temporal->>+Workflow: Schedule first task + Workflow->>Workflow: Execute + Workflow-->>-Temporal: Complete +``` + +The following describes each step in the diagram: + +1. The client starts the Workflow with a 30-second delay. Temporal creates the execution immediately. +2. The execution is visible and queryable, but no Workflow code runs during the delay. +3. If the client sends a Signal-With-Start or Update-With-Start during the delay, the remaining delay is bypassed and a Workflow Task is dispatched immediately. Regular Signals do not interrupt the delay. +4. After the delay expires, Temporal schedules the first Workflow Task and the Workflow begins execution. + +The following example creates a Workflow with a 30-second start delay: + + + + +```python +# client.py +from datetime import timedelta + +handle = await client.start_workflow( + DelayedStartWorkflow.run, + id=WORKFLOW_ID, + task_queue=TASK_QUEUE, + start_delay=timedelta(seconds=30), +) +# Created now, executes in 30 seconds +``` + + + + +```go +// starter/main.go +workflowOptions := client.StartWorkflowOptions{ + ID: WorkflowID, + TaskQueue: TaskQueue, + StartDelay: 30 * time.Second, +} + +we, err := c.ExecuteWorkflow(context.Background(), workflowOptions, DelayedStartWorkflow) +// Created now, executes in 30 seconds +``` + + + + +```java +// Client.java +DelayedStartWorkflow workflow = client.newWorkflowStub( + DelayedStartWorkflow.class, + WorkflowOptions.newBuilder() + .setWorkflowId(WORKFLOW_ID) + .setTaskQueue(TASK_QUEUE) + .setStartDelay(Duration.ofSeconds(30)) + .build()); + +workflow.start(); // Created now, executes in 30 seconds +``` + + + + +```typescript +// client.ts +const handle = await client.workflow.start(delayedStartWorkflow, { + workflowId: WORKFLOW_ID, + taskQueue: TASK_QUEUE, + startDelay: '30 seconds', +}); +// Created now, executes in 30 seconds +``` + + + + +The start delay option sets the `firstWorkflowTaskBackoff` on the execution. +The Workflow is created and visible in the UI immediately, but the Worker does not receive a Task until the delay expires. + +## Implementation + +### Basic delayed notification + +The following implementation sends a notification after a one-hour delay. +The Workflow code runs only after the delay expires: + + + + +```python +# workflows.py +from temporalio import workflow + +@workflow.defn +class NotificationWorkflow: + @workflow.run + async def run(self, message: str) -> None: + workflow.logger.info(f"Sending notification: {message}") + +# client.py +from datetime import timedelta + +handle = await client.start_workflow( + NotificationWorkflow.run, + "Your trial expires soon", + task_queue=TASK_QUEUE, + start_delay=timedelta(hours=1), +) +``` + + + + +```go +// workflow.go +func NotificationWorkflow(ctx workflow.Context, message string) error { + logger := workflow.GetLogger(ctx) + logger.Info("Sending notification: " + message) + return nil +} + +// starter/main.go +workflowOptions := client.StartWorkflowOptions{ + TaskQueue: TaskQueue, + StartDelay: 1 * time.Hour, +} + +we, err := c.ExecuteWorkflow( + context.Background(), workflowOptions, NotificationWorkflow, "Your trial expires soon", +) +``` + + + + +```java +// NotificationWorkflowImpl.java +@WorkflowInterface +public interface NotificationWorkflow { + @WorkflowMethod + void sendNotification(String message); +} + +public class NotificationWorkflowImpl implements NotificationWorkflow { + @Override + public void sendNotification(String message) { + Workflow.getLogger(NotificationWorkflowImpl.class) + .info("Sending notification: " + message); + } +} + +// Client.java +NotificationWorkflow workflow = client.newWorkflowStub( + NotificationWorkflow.class, + WorkflowOptions.newBuilder() + .setTaskQueue(TASK_QUEUE) + .setStartDelay(Duration.ofHours(1)) + .build()); + +workflow.sendNotification("Your trial expires soon"); +``` + + + + +```typescript +// workflows.ts +import * as wf from '@temporalio/workflow'; + +export async function notificationWorkflow(message: string): Promise { + wf.log.info(`Sending notification: ${message}`); +} + +// client.ts +const handle = await client.workflow.start(notificationWorkflow, { + args: ['Your trial expires soon'], + taskQueue: TASK_QUEUE, + startDelay: '1 hour', +}); +``` + + + + +The Workflow is created immediately, but the notification logic does not execute until one hour later. + +### Cancellable delayed execution + +The following implementation adds Signal handlers for cancellation and a Query for status. +You can cancel the Workflow before it runs or check its status during the delay: + + + + +```python +# workflows.py +from temporalio import workflow + +@workflow.defn +class DelayedOrderWorkflow: + def __init__(self) -> None: + self._cancelled = False + self._status = "SCHEDULED" + + @workflow.run + async def run(self, order_id: str) -> None: + if self._cancelled: + self._status = "CANCELLED" + return + + self._status = "PROCESSING" + # Process order logic + self._status = "COMPLETED" + + @workflow.signal + async def cancel(self) -> None: + self._cancelled = True + + @workflow.query + def get_status(self) -> str: + return self._status +``` + + + + +```go +// workflow.go +func DelayedOrderWorkflow(ctx workflow.Context, orderID string) error { + logger := workflow.GetLogger(ctx) + cancelled := false + status := "SCHEDULED" + + // Register Signal handler for cancellation + cancelCh := workflow.GetSignalChannel(ctx, "cancel") + // Drain any pending signals without blocking + for { + var signal interface{} + ok := cancelCh.ReceiveAsync(&signal) + if !ok { + break + } + cancelled = true + } + + // Register Query handler for status + err := workflow.SetQueryHandler(ctx, "getStatus", func() (string, error) { + return status, nil + }) + if err != nil { + return err + } + + if cancelled { + logger.Info("Order cancelled before processing", "orderId", orderID) + return nil + } + + status = "PROCESSING" + // Process order logic + status = "COMPLETED" + return nil +} +``` + + + + +```java +// DelayedOrderWorkflowImpl.java +@WorkflowInterface +public interface DelayedOrderWorkflow { + @WorkflowMethod + void processOrder(String orderId); + + @SignalMethod + void cancel(); + + @QueryMethod + String getStatus(); +} + +public class DelayedOrderWorkflowImpl implements DelayedOrderWorkflow { + private boolean cancelled = false; + private String status = "SCHEDULED"; + + @Override + public void processOrder(String orderId) { + if (cancelled) { + status = "CANCELLED"; + return; + } + + status = "PROCESSING"; + // Process order logic + status = "COMPLETED"; + } + + @Override + public void cancel() { + cancelled = true; + } + + @Override + public String getStatus() { + return status; + } +} +``` + + + + +```typescript +// workflows.ts +import * as wf from '@temporalio/workflow'; + +const cancelSignal = wf.defineSignal('cancel'); +const getStatusQuery = wf.defineQuery('getStatus'); + +export async function delayedOrderWorkflow(orderId: string): Promise { + let cancelled = false; + let status = 'SCHEDULED'; + + wf.setHandler(cancelSignal, () => { + cancelled = true; + }); + + wf.setHandler(getStatusQuery, () => status); + + if (cancelled) { + status = 'CANCELLED'; + return; + } + + status = 'PROCESSING'; + // Process order logic + status = 'COMPLETED'; +} +``` + + + + +The `cancel` Signal handler sets a flag that the Workflow checks when it starts executing. +Note that Signal handlers and Query handlers only run after the delay expires and the first Workflow Task is dispatched. +To cancel before execution, use `Signal-With-Start` to bypass the delay, or cancel the Workflow Execution directly. + +## When to use + +The Delayed Start pattern is a good fit for scheduled one-time operations (send a reminder in 24 hours), grace periods before processing (cancel a subscription in 7 days), delayed notifications and alerts, deferred batch processing, and trial expiration Workflows. + +It is not a good fit for recurring Schedules (use Temporal Schedules), immediate execution with internal delays (use Workflow sleep — `Workflow.sleep()` in Java, `wf.sleep()` in TypeScript, `workflow.sleep()` in Python, `workflow.Sleep()` in Go), complex scheduling logic (use Schedules with cron), or sub-second delays (minimal benefit). + +## Benefits and trade-offs + +The Workflow is queryable before execution starts (immediate visibility). +No Worker resources are consumed during the delay. +You can cancel the Workflow Execution before it runs. +A Signal-With-Start or Update-With-Start bypasses the remaining delay. +Regular Signals sent during the delay do not interrupt it. +The API is a single configuration option with no external schedulers needed. +The delay is managed by Temporal, ensuring deterministic behavior. + +The trade-offs to consider are that you cannot dynamically adjust the delay after creation (use the Updatable Timer pattern for that). +The pattern is for one-time delays only — for recurring Schedules, use Temporal Schedules. +Very short delays (sub-second) provide minimal benefit — Temporal does not guarantee sub-second timer accuracy, and the delay is rounded up to account for scheduling latency. +The delay is time-based only, not condition-based. +Regular Signals sent during the delay are not delivered until the first Workflow Task fires, so Query and Signal handlers are not available until execution begins. + +## Comparison with alternatives + +| Approach | Immediate visibility | Resource usage | Cancellable | Use case | +| :--- | :--- | :--- | :--- | :--- | +| Delayed Start | Yes | None during delay | Yes | One-time future execution | +| Workflow sleep | Yes | Worker resources | Yes | Internal delays | +| Temporal Schedules | Yes | None | Yes | Recurring Schedules | +| External Scheduler | No | External system | Depends | Complex scheduling | + +## Best practices + +- **Use for one-time delays.** For recurring Schedules, use Temporal Schedules instead. +- **Set Workflow ID.** Always set an explicit Workflow ID for tracking and cancellation. +- **Add Query methods.** Expose status via Queries to check state during the delay. +- **Enable cancellation.** Add Signal handlers to cancel before execution. +- **Validate delay duration.** Ensure the delay is reasonable (not too short or too long). +- **Monitor backoff.** Check `firstWorkflowTaskBackoff` in history for verification. +- **Consider time zones.** Use absolute timestamps if the delay depends on a specific time. +- **Document behavior.** Clearly indicate that the Workflow does not execute immediately. + +## Common pitfalls + +- **Using Signals during the delay.** Regular Signals do not interrupt the Start Delay. Only Signal-With-Start or Update-With-Start bypass the delay. Signals sent to a delayed Workflow are buffered but the Workflow code has not started, so there is no handler to process them until the delay expires. +- **Querying before the Workflow starts.** Queries have no state to return during the delay because no Workflow code has executed yet. Clients may receive errors or empty results. +- **Relying on sub-second delays.** Temporal does not guarantee sub-second timer accuracy, and the delay is rounded up due to scheduling latency. Treat the configured duration as a minimum, not an exact value. +- **Forgetting that the Workflow ID is reserved.** A delayed Workflow reserves its Workflow ID immediately. Starting another Workflow with the same ID will fail depending on the ID reuse policy. + +## Related patterns + +- **Temporal Schedules**: For recurring Workflow execution. +- **[Updatable Timer](/design-patterns/updatable-timer)**: For dynamically adjustable delays within Workflows. +- **[Signal with Start](/design-patterns/signal-with-start)**: Interacting with Workflows before execution. + +## Sample code + +- [Java Sample](https://github.com/temporalio/samples-java/tree/main/core/src/main/java/io/temporal/samples/hello/HelloDelayedStart.java) — Delayed start with `setStartDelay()`. +- [TypeScript Sample](https://github.com/temporalio/samples-typescript/tree/main/start-delay) — Delayed start with `startDelay` option. +- [Python Sample](https://github.com/temporalio/samples-python/tree/main/start_delay) — Delayed start with `start_delay` parameter. +- [Go Sample](https://github.com/temporalio/samples-go/tree/main/start-delay) — Delayed start with `StartDelay` option. diff --git a/docs/design-patterns/distributed-transaction-patterns.mdx b/docs/design-patterns/distributed-transaction-patterns.mdx new file mode 100644 index 0000000000..ac319bb60d --- /dev/null +++ b/docs/design-patterns/distributed-transaction-patterns.mdx @@ -0,0 +1,39 @@ +--- +id: distributed-transaction-patterns +title: "Distributed Transaction Patterns" +sidebar_label: "Distributed Transaction Patterns" +description: "Pattern selection guide for distributed transactions, with a decision tree for choosing between Saga and Early Return." +--- + +import PatternCards from '@site/src/components/PatternCards'; + +Distributed transactions span multiple services that each own their own data, with no shared database transaction to roll back. These patterns coordinate the steps, undo completed work when a later step fails, and keep external side effects correct under retries. + +## Patterns in this section + + + +## Choosing a pattern + +**You need to undo completed steps when a later step fails**: use the [Saga Pattern](/design-patterns/saga-pattern) and define a compensation for every step that has an external effect. + +**You need to respond to the caller before the transaction finishes**: use [Early Return](/design-patterns/early-return) to acknowledge after initialization and continue processing in the background. + +## Related sections + +- [Error Handling & Retry Patterns](/design-patterns/error-handling-patterns) — control how each step retries before a compensation triggers +- [Workflow Messaging Patterns](/design-patterns/workflow-messaging-patterns) — the Update-with-Start mechanism behind Early Return +- [Performance & Latency Patterns](/design-patterns/performance-latency-patterns) — combine Early Return with Local Activities for the lowest first-response latency diff --git a/docs/design-patterns/downstream-rate-limiting.mdx b/docs/design-patterns/downstream-rate-limiting.mdx new file mode 100644 index 0000000000..b154ab4ce2 --- /dev/null +++ b/docs/design-patterns/downstream-rate-limiting.mdx @@ -0,0 +1,295 @@ +--- +id: downstream-rate-limiting +title: "Downstream Rate Limiting" +sidebar_label: "Downstream Rate Limiting" +description: "Caps Activity execution rate against a downstream service by routing throttled Activities to a dedicated Task Queue backed by Workers configured with a throughput limit." +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +:::info[TLDR] +Used to **rate limit outbound requests to a downstream service**. Use this to limit the rate of requests, such as to a third-party API, or payment processor, or other external system, that concurrent Workflows would otherwise exceed. +::: + +## Overview + +The Downstream Rate Limiting pattern, also known as Task Queue rate limiting, caps how many Activities execute per second against a downstream service. +You place throttled Activities on a dedicated Task Queue backed by Workers configured with `MaxTaskQueueActivitiesPerSecond`. +The Temporal matching service enforces this limit before dispatching tasks, so the downstream service receives a controlled request rate regardless of how many Worker instances or Workflow executions are running concurrently. + +## Problem + +Many downstream systems — LLM providers, payment processors, third-party REST APIs — enforce requests-per-second limits. Some systems cannot handle more than a defined level of requests per second. +When many Temporal Workflows schedule Activities concurrently, the resulting burst can saturate those limits, causing request failures, cascading retries, and increased latency for all callers. + +Without centralized throttling, each Activity implementation must manage backpressure independently, which scatters policy across the codebase and provides no enforcement at the Temporal scheduling layer. + +## Solution + +You assign rate-limited Activities to a dedicated Task Queue and Worker set and configure the Workers on that queue with a throughput cap. +Because the limit applies to the Task Queue, it is enforced before any Worker executes an Activity, and it holds across all Worker replicas without coordination. + +The Workflow routes the throttled Activity to the dedicated queue by specifying an explicit `task_queue` override in the Activity options. + +```mermaid +flowchart LR + subgraph Workflows + WA[Workflow A] + WB[Workflow B] + WC[Workflow C] + end + + subgraph Temporal Server + TQ["rate-limited-tq\n─────────────\ntask 1\ntask 2\ntask 3\ntask 4\n…"] + end + + subgraph Workers + WK1["Worker 1\nMaxTaskQueueActivitiesPerSecond\n= 5 RPS"] + WK2["Worker 2\nMaxTaskQueueActivitiesPerSecond\n= 5 RPS"] + end + + DS["Downstream API\n(rate limit: 5 RPS)"] + + WA -->|"schedule callApi\ntask_queue=rate-limited-tq"| TQ + WB -->|"schedule callApi\ntask_queue=rate-limited-tq"| TQ + WC -->|"schedule callApi\ntask_queue=rate-limited-tq"| TQ + TQ -->|"dispatch ≤5/sec across both"| WK1 + TQ --> WK2 + WK1 -->|"≤5 req/sec combined"| DS + WK2 --> DS +``` + +The following describes each step in the diagram: + +1. Any number of Workflows schedule `callApi` Activities to the dedicated `rate-limited-tq` Task Queue via an explicit `task_queue` override in their Activity options. +2. The Temporal server holds tasks in `rate-limited-tq`. The queue depth grows if submission rate exceeds dispatch capacity. +3. Two Workers poll the queue. Each is configured with `MaxTaskQueueActivitiesPerSecond = 5`, so together they dispatch at most 5 Activity tasks per second — matching the downstream API's rate limit. +4. The downstream API receives a steady, controlled request rate regardless of how many Workflows are running concurrently. + +## Implementation + +### Worker configured with a throughput cap + + + + +```python +# worker.py +# This is a dedicated worker for rate-limited activities. +# You will also need a separate worker registered on your workflow task queue. +from temporalio.worker import Worker +from activities import call_api + +async def run_worker(client): + worker = Worker( + client, + task_queue="rate-limited-tq", + activities=[call_api], + max_task_queue_activities_per_second=5.0, + ) + await worker.run() +``` + + + + +```go +// main.go +// This is a dedicated worker for rate-limited activities. +// You will also need a separate worker registered on your workflow task queue. +w := worker.New(c, "rate-limited-tq", worker.Options{ + TaskQueueActivitiesPerSecond: 5.0, +}) +w.RegisterActivity(CallApi) +if err := w.Run(worker.InterruptCh()); err != nil { + log.Fatalf("worker error: %v", err) +} +``` + + + + +```java +// WorkerSetup.java +// This is a dedicated worker for rate-limited activities. +// You will also need a separate worker registered on your workflow task queue. +WorkerOptions rateLimitedOptions = WorkerOptions.newBuilder() + .setMaxTaskQueueActivitiesPerSecond(5.0) + .build(); + +Worker rateLimitedWorker = factory.newWorker("rate-limited-tq", rateLimitedOptions); +rateLimitedWorker.registerActivitiesImplementations(new RateLimitedActivitiesImpl()); +factory.start(); +``` + + + + +### Activity Definition + + + + +```python +# activities.py +from temporalio import activity + +@activity.defn +async def call_api(input: str) -> str: + return await downstream_api.call(input) +``` + + + + +```go +// activities.go +func CallApi(ctx context.Context, input string) (string, error) { + return downstreamApi.Call(input) +} +``` + + + + +```java +// RateLimitedActivities.java +@ActivityInterface +public interface RateLimitedActivities { + @ActivityMethod + String callApi(String input); +} + +public class RateLimitedActivitiesImpl implements RateLimitedActivities { + @Override + public String callApi(String input) { + return downstreamApi.call(input); + } +} +``` + + + + +### Workflow routing to the rate-limited queue + + + + +```python +# workflows.py +from datetime import timedelta +from temporalio import workflow +from activities import call_api + +@workflow.defn +class MyWorkflow: + @workflow.run + async def run(self, input: str) -> str: + return await workflow.execute_activity( + call_api, + input, + task_queue="rate-limited-tq", + start_to_close_timeout=timedelta(seconds=30), + ) +``` + + + + +```go +// workflow.go +func MyWorkflow(ctx workflow.Context, input string) (string, error) { + ao := workflow.ActivityOptions{ + TaskQueue: "rate-limited-tq", + StartToCloseTimeout: 30 * time.Second, + } + ctx = workflow.WithActivityOptions(ctx, ao) + + var result string + err := workflow.ExecuteActivity(ctx, CallApi, input).Get(ctx, &result) + return result, err +} +``` + + + + +```java +// MyWorkflowImpl.java +public class MyWorkflowImpl implements MyWorkflow { + private final RateLimitedActivities rateLimitedActivities = + Workflow.newActivityStub(RateLimitedActivities.class, + ActivityOptions.newBuilder() + .setTaskQueue("rate-limited-tq") + .setStartToCloseTimeout(Duration.ofSeconds(30)) + .build() + ); + + @Override + public String run(String input) { + return rateLimitedActivities.callApi(input); + } +} +``` + + + + +## When to use + +This pattern is a good fit when your Workflow calls a downstream service with explicit requests-per-second limits, when you need throughput enforcement that holds across many concurrent Workflow instances without per-Activity logic, and when only a subset of Activity types require throttling and others should run without restriction. + +It is not a good fit when you need concurrency limits rather than throughput limits (see [Priority Task Queues](/design-patterns/priority-task-queues)), when the downstream system has no rate limit and throughput is bounded only by Workflow logic, or when all Activities require the same limit and a single shared queue suffices. + +## Benefits and trade-offs + +Centralizing rate limiting at the Task Queue ensures enforcement even when any number of Workflow instances run in parallel. +Because the Temporal server controls dispatch, the limit holds regardless of how many Worker replicas are running — provided you account for Worker count when setting the per-worker cap. + +Dedicated Task Queues require operating additional Workers. +If the throughput cap is set too low relative to demand, the queue depth grows and scheduling latency increases. +You must size the Worker pool so that slot availability does not become the bottleneck before the rate limit is reached. + +## Comparison with alternatives + +| Approach | Enforcement point | Works across Workers | Runtime adjustable | Complexity | +| :--- | :--- | :--- | :--- | :--- | +| `MaxTaskQueueActivitiesPerSecond` | Temporal matching service (server-side) | Yes | No (requires redeploy) | Low | +| `MaxWorkerActivitiesPerSecond` | Worker SDK poller (worker-side) | No — per-worker only | No (requires redeploy) | Low | +| Concurrency slots (`MaxConcurrentActivityExecutionSize`, `MaxConcurrentWorkflowTaskExecutionSize`, `MaxConcurrentLocalActivityExecutionSize`) | Worker executor | No — per-worker only | No (requires redeploy) | Low | +| Sleep-based throttle in Workflow | Workflow scheduler | No | Via signal | Low | +| Client-side token bucket in Activity | Activity execution | Per-worker only | No | Medium | +| API gateway rate limiting | Network layer | Yes | Yes | High | + +Three distinct layers of worker-side control exist alongside the server-side queue limit. `MaxWorkerActivitiesPerSecond` instructs the SDK to self-throttle its polling — the Worker will not request a new Activity task if doing so would push it over this rate. Because the limit is per-process, multiple Workers on the same queue each apply it independently, so the effective queue throughput is the per-worker cap multiplied by Worker count. By contrast, `MaxTaskQueueActivitiesPerSecond` is a server-side instruction: the Temporal matching service slows dispatch for the entire queue regardless of how many Workers are polling, making it the correct tool for protecting a shared downstream service. + +The concurrency slots (`MaxConcurrentActivityExecutionSize`, `MaxConcurrentWorkflowTaskExecutionSize`, `MaxConcurrentLocalActivityExecutionSize`) are not throughput limits but define the number of execution slots available on a Worker. A Worker will not accept more tasks than it has open slots, so a low slot count acts as an indirect throughput ceiling. + +## Best practices + +- **Use a separate Task Queue for each rate limit.** `MaxTaskQueueActivitiesPerSecond` applies to every Activity on the queue. Mixing rate-limited and unrestricted Activities on the same queue will throttle the unrestricted ones too. +- **Run at least two Worker processes per queue for availability.** A single Worker process is a single point of failure. Because `MaxTaskQueueActivitiesPerSecond` is a server-side per-queue limit rather than a per-worker one, set the same value on every Worker that polls the queue. Set each Worker to the target RPS — for example, 5 on each of two Workers yields a combined queue limit of 5, not 10. If Workers report different values, the server applies the value from the last Worker that polled. +- **Monitor queue depth and schedule latency.** Track the `temporal_activity_schedule_to_start_latency` metric on the rate-limited queue; sustained growth signals that demand consistently exceeds the configured cap. You can also query the Task Queue's `ApproximateBacklogCount` via the `DescribeTaskQueue` API — a steadily growing backlog count is a direct indicator that the configured RPS cap is too low for the current submission rate. + +## Common pitfalls + +- **Forgetting to override the task queue in Activity options.** If the Workflow does not explicitly specify `task_queue` in the Activity options, the Activity runs on the Workflow's default queue and bypasses the rate-limited Worker entirely. +- **Setting conflicting MaxTaskQueueActivitiesPerSecond limits in workers.** This setting is set in Workers and sent to the Task Queue when a Worker polls. If you have multiple Workers with conflicting settings, the Workers will overwrite each other as they poll. +- **Confusing throughput limits with concurrency limits.** `MaxTaskQueueActivitiesPerSecond` controls starts per second; `MaxConcurrentActivityExecutionSize` controls simultaneous executions. Long-running Activities that hold slots for minutes may exhaust concurrency before the RPS cap applies. +- **Setting the cap far below actual demand.** A cap much lower than actual submission rate causes the queue to grow unboundedly. Monitor queue depth and raise the cap or add more Workers when throughput requirements grow. +- **Expecting a perfectly even per-second rate.** The limit is enforced across the queue's partitions, default four. The server maintains the configured rate as an average over time but can dispatch a short burst above it, up to roughly the rate divided across partitions. If the downstream service rejects any momentary overshoot, set the cap below the hard limit to leave headroom, or reduce the partition count for the queue. + +## Related patterns + +- **[Priority Task Queues](/design-patterns/priority-task-queues)**: Route work to separate queues by urgency, with different concurrency budgets per tier. +- **[Fairness](/design-patterns/fairness)**: Give each tenant an equal throughput share when multiple tenants share capacity. +- **[Worker-Specific Task Queues](/design-patterns/worker-specific-taskqueue)**: Route Activities to a specific Worker host for resource or data affinity. + +## References + +- **Python** — [`max_task_queue_activities_per_second`](https://python.temporal.io/temporalio.worker.WorkerConfig.html#max_task_queue_activities_per_second) on [`Worker`](https://python.temporal.io/temporalio.worker.Worker.html) +- **Go** — [`TaskQueueActivitiesPerSecond`](https://pkg.go.dev/go.temporal.io/sdk/internal#WorkerOptions) in [`worker.Options`](https://pkg.go.dev/go.temporal.io/sdk/worker#Options) +- **Java** — [`setMaxTaskQueueActivitiesPerSecond`](https://www.javadoc.io/doc/io.temporal/temporal-sdk/latest/io/temporal/worker/WorkerOptions.Builder.html) on `WorkerOptions.Builder` +- **Temporal Community** — [Rate limit configuration and best practices](https://community.temporal.io/t/rate-limit-configuration-and-best-practices/5498) diff --git a/docs/design-patterns/eager-workflow-start.mdx b/docs/design-patterns/eager-workflow-start.mdx new file mode 100644 index 0000000000..3fafd2a9cb --- /dev/null +++ b/docs/design-patterns/eager-workflow-start.mdx @@ -0,0 +1,230 @@ +--- +id: eager-workflow-start +title: "Eager Workflow Start" +sidebar_label: "Eager Workflow Start" +description: "Dispatch the first Workflow Task directly to a co-located Worker, bypassing the Temporal Matching Service. Requires the starter and Worker to share the same process and client connection." +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +:::info[TLDR] +**Bypass the Temporal Matching Service by dispatching the first Workflow Task directly to a co-located Worker.** The Worker and the client that starts the Workflow must share the same process and server connection. Eager Workflow Start eliminates the Matching Service round-trip, saving approximately 30–50 ms per Workflow start. When combined with Local Activities, this pattern achieves ~265 ms total-workflow latency (vs ~850 ms baseline). The TypeScript SDK does not support Eager Workflow Start. +::: + +## Overview + +When you call `ExecuteWorkflow`, the Temporal server normally stores the new Workflow execution, then routes the first Workflow Task through its Matching Service to an available Worker. **Eager Workflow Start** short-circuits this routing: the server returns the first Workflow Task inline in the `StartWorkflowExecution` response, and the co-located Worker processes it immediately—without a separate polling round-trip. + +```mermaid +sequenceDiagram + participant CW as Client + Worker (same process) + participant S as Temporal Server + + rect rgb(230, 235, 250) + Note over CW,S: Normal Workflow Start + CW->>S: StartWorkflowExecution + S->>S: Matching Service routes
task to available Worker + S-->>CW: WorkflowTask dispatched via polling + CW->>CW: Execute WorkflowTask + CW->>S: Complete WorkflowTask + end + + rect rgb(220, 245, 225) + Note over CW,S: Eager Workflow Start (co-located Worker) + CW->>S: StartWorkflowExecution (EnableEagerStart=true) + S-->>CW: WorkflowTask returned inline
— no Matching step + CW->>CW: Execute WorkflowTask immediately + CW->>S: Complete WorkflowTask + end +``` + +**Numbered walkthrough:** + +1. In a normal start, the server queues the Workflow execution and the Matching Service waits for an available Worker slot. The Worker polls, picks up the task, runs it, and reports back—adding an extra server round-trip. +2. With Eager Workflow Start enabled, the server detects that the requesting client has a co-located Worker with an available slot. Instead of queuing the task, the server attaches the first Workflow Task to the `StartWorkflowExecution` response. +3. The Worker processes the Workflow Task immediately upon receiving the response. No separate poll is required. +4. If the server cannot fulfill the eager request (e.g., no local slot is available), it falls back silently to normal dispatch. Your code does not need to handle this case explicitly. + +## Problem + +Even with Local Activities eliminating per-Activity server round-trips, the Workflow's first Workflow Task still requires a scheduling round-trip through the Temporal Matching Service. This adds latency that is unavoidable in a distributed deployment where Workers are separate from the caller. + +For applications where the starter and Worker share the same deployment unit—such as a request-handling service that also runs Workers—this Matching overhead can be eliminated. + +## Solution + +Start a Worker in the same process as the workflow starter, using the same client connection. Set `EnableEagerStart: true` (Go), `setDisableEagerExecution(false)` (Java), or `request_eager_start=True` (Python) on the `StartWorkflowOptions`. The SDK signals to the server that a local Worker is available, and the server returns the first Workflow Task inline. + +:::warning[Feature flag for self-hosted Temporal] +On self-hosted Temporal Server, Eager Workflow Start may require enabling a dynamic config flag: + +``` +--dynamic-config-value system.enableEagerWorkflowStart=true +``` + +Temporal Cloud and recent versions of the open-source server may enable this by default. Check your server's release notes or documentation to confirm. +::: + + + + +```python +# starter.py — starts the Worker in the same process, then executes the Workflow eagerly +import asyncio +from temporalio.client import Client +from temporalio.worker import Worker +from workflows import TransactionWorkflow +from activities import validate_transaction, settle_transaction +from shared import TASK_QUEUE, TransactionRequest + +async def main(): + client = await Client.connect("localhost:7233") + + # The Worker must share this process and client for eager dispatch to work. + async with Worker( + client, + task_queue=TASK_QUEUE, + workflows=[TransactionWorkflow], + activities=[validate_transaction, settle_transaction], + ): + result = await client.execute_workflow( + TransactionWorkflow.run, + TransactionRequest(amount=100.00, currency="USD"), + id="eager-workflow-start-demo", + task_queue=TASK_QUEUE, + request_eager_start=True, # Dispatch first WorkflowTask inline + ) + print(f"Transaction complete: ID={result.id} Status={result.status}") + +if __name__ == "__main__": + asyncio.run(main()) +``` + + + + +```go +// starter.go — starts the Worker in the same process, then executes the Workflow eagerly +func main() { + c, err := client.Dial(client.Options{}) + if err != nil { + log.Fatalln("Unable to create Temporal client:", err) + } + defer c.Close() + + // Start the Worker non-blocking — it must share this process and client. + w := worker.New(c, TaskQueue, worker.Options{}) + w.RegisterWorkflow(TransactionWorkflow) + w.RegisterActivity(ValidateTransaction) + w.RegisterActivity(SettleTransaction) + if err := w.Start(); err != nil { + log.Fatalln("Unable to start worker:", err) + } + defer w.Stop() + + run, err := c.ExecuteWorkflow(context.Background(), client.StartWorkflowOptions{ + ID: "eager-workflow-start-demo", + TaskQueue: TaskQueue, + EnableEagerStart: true, // Dispatch first WorkflowTask inline + }, TransactionWorkflow, TransactionRequest{Amount: 100.00, Currency: "USD"}) + if err != nil { + log.Fatalln("Failed to start workflow:", err) + } + + var result Transaction + if err := run.Get(context.Background(), &result); err != nil { + log.Fatalln("Workflow failed:", err) + } + fmt.Printf("Transaction complete: ID=%s Status=%s\n", result.ID, result.Status) +} +``` + + + + +```java +// Starter.java — starts the Worker in the same process, then executes the Workflow eagerly +public class Starter { + public static void main(String[] args) { + WorkflowServiceStubs service = WorkflowServiceStubs.newLocalServiceStubs(); + WorkflowClient client = WorkflowClient.newInstance(service); + + // The Worker must share this process and client for eager dispatch. + WorkerFactory factory = WorkerFactory.newInstance(client); + io.temporal.worker.Worker worker = factory.newWorker(Shared.TASK_QUEUE); + worker.registerWorkflowImplementationTypes(TransactionWorkflow.Impl.class); + worker.registerActivitiesImplementations(new Activities.Impl()); + factory.start(); + + TransactionWorkflow workflow = client.newWorkflowStub( + TransactionWorkflow.class, + WorkflowOptions.newBuilder() + .setTaskQueue(Shared.TASK_QUEUE) + .setWorkflowId("eager-workflow-start-demo") + .setDisableEagerExecution(false) // false = enable eager dispatch + .build() + ); + + Shared.Transaction result = workflow.processTransaction( + new Shared.TransactionRequest(100.00, "USD")); + System.out.printf("Transaction complete: ID=%s Status=%s%n", + result.id(), result.status()); + + factory.shutdown(); + } +} +``` + + + + +:::info[TypeScript SDK] +The TypeScript SDK does not currently support Eager Workflow Start. Use [Local Activities](/design-patterns/local-activities) or [Early Return + Local Activities](/design-patterns/early-return-local-activities) for latency-sensitive TypeScript workflows. +::: + +## When to use + +**Good fit:** + +- The workflow starter and Worker run in the same deployment unit (e.g., a single service that both handles API requests and runs Workers) +- You need the absolute minimum total-workflow latency and are already using Local Activities +- The language is Go, Java, or Python + +**Poor fit:** + +- Workers are deployed independently from starters (the eager request falls back to normal dispatch, which is harmless but provides no benefit) +- You are using the TypeScript SDK +- First-response latency matters more than total latency—combine with [Early Return](/design-patterns/early-return) or [Early Return + Local Activities](/design-patterns/early-return-local-activities) for that use case + +## Benefits and trade-offs + +| | Normal Start | Eager Workflow Start | +|---|---|---| +| Matching Service round-trip | Yes (~30–50 ms) | No (eliminated) | +| Worker co-location required | No | Yes (same process + client) | +| Fallback behavior | N/A | Graceful fallback to normal dispatch | +| TypeScript SDK support | Yes | No | +| Configuration required | None | `EnableEagerStart`/`request_eager_start`/`setDisableEagerExecution(false)` | +| Self-hosted server flag | N/A | May need `system.enableEagerWorkflowStart=true` | + +## Best practices + +- **Combine with Local Activities.** Eager Workflow Start eliminates the Matching overhead on the first Workflow Task; Local Activities eliminate server round-trips within each Workflow Task. Together they provide the greatest total latency reduction. +- **Use a non-blocking Worker start.** Start the Worker before executing the Workflow so it has an available slot. In Go, use `w.Start()` and defer `w.Stop()`. In Python, use `async with Worker(...)`. In Java, call `factory.start()` before creating the workflow stub. +- **Do not rely on eager dispatch always firing.** The server falls back to normal dispatch if no local slot is available (e.g., the Worker is at capacity). Design the Workflow to work correctly in both cases. +- **Share the same client and connection.** The Worker and the workflow starter must use the same `WorkflowClient` instance (Java), `client.Client` (Go), or `Client` (Python). A Worker using a different connection cannot receive eager tasks from another client. +- **Be mindful of resource sharing in co-located deployments.** When a Worker runs in the same process as a request handler, they share CPU, memory, and failure domains. A spike in activity execution can slow request handling, and vice versa. Monitor Worker CPU, Workflow Task execution latency, and task queue depth to ensure Worker load does not affect client-facing latency. + +## Common pitfalls + +- **Starting the Worker after `ExecuteWorkflow`.** If the Worker is not registered and running before the eager start call, no local slot exists and the request falls back to normal dispatch. +- **Expecting eager dispatch in distributed deployments.** If the process that calls `ExecuteWorkflow` is not the same process running the Worker, eager dispatch will never succeed. The call still works, but it provides no latency benefit. +- **Missing the feature flag on self-hosted servers.** If the server dynamic config flag is not set, eager dispatch requests are silently ignored and the execution falls back to normal dispatch. Verify the flag is set if you do not observe the expected latency improvement. +- **Using TypeScript.** The TypeScript SDK does not support Eager Workflow Start. Switch to Python, Go, or Java for this optimization. + +## Related patterns + +- [Local Activities](/design-patterns/local-activities) — eliminates per-Activity server round-trips; pairs naturally with Eager Workflow Start +- [Early Return + Local Activities](/design-patterns/early-return-local-activities) — minimum first-response latency via Update-with-Start plus Local Activities +- [Early Return](/design-patterns/early-return) — returns early to the client via Update-with-Start diff --git a/docs/design-patterns/early-return-local-activities.mdx b/docs/design-patterns/early-return-local-activities.mdx new file mode 100644 index 0000000000..568ad7b17f --- /dev/null +++ b/docs/design-patterns/early-return-local-activities.mdx @@ -0,0 +1,319 @@ +--- +id: early-return-local-activities +title: "Early Return + Local Activities" +sidebar_label: "Early Return + Local Activities" +description: "Extends Early Return by running Phase 1 Activities as Local Activities. The client receives its response after Phase 1 completes entirely in-process, achieving the lowest possible first-response latency." +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +:::info[TLDR] +**Combine Update-with-Start with Local Activities in the synchronous Phase 1 to reduce first-response latency from ~265 ms to ~160 ms.** Phase 1 (initialization) runs as Local Activities with no server round-trips. Phase 2 (settlement) runs as regular Activities in the background. The client receives its response as soon as Phase 1 completes, so the in-process execution speed of Local Activities directly improves the time-to-first-byte. +::: + +## Overview + +The [Early Return](/design-patterns/early-return) pattern uses Update-with-Start to send the client an early response after a fast Phase 1 completes, while slow Phase 2 work continues in the background. This pattern extends Early Return by running Phase 1 Activities as **Local Activities**, eliminating all server round-trips on the synchronous hot path. + +Because the client waits only for Phase 1, and Phase 1 now runs entirely in-process, the end-to-end first-response time drops from approximately 265 ms (Early Return with regular Activities) to approximately 160 ms. + +```mermaid +sequenceDiagram + participant C as Client + participant S as Temporal Server + participant W as Worker + + C->>S: UpdateWithStart (start workflow + deliver update) + S->>W: Schedule WorkflowTask + + rect rgb(220, 245, 225) + Note over S,W: Phase 1 — Local Activities
(in-process, zero server round-trips) + W->>W: validateTransaction (local) + W->>W: initTransaction (local) + W->>S: Complete WorkflowTask
(Phase 1 done, Update result ready) + end + + S-->>C: Update result returned (~160 ms first response) + + rect rgb(230, 235, 250) + Note over S,W: Phase 2 — Regular Activities (background settlement) + W->>S: Schedule completeTransaction + S-->>W: Dispatch completeTransaction + W->>W: Execute (slow settlement) + W->>S: Complete ActivityTask + end + + Note over C,W: Client has its response and Phase 2 continues in the background +``` + +**Numbered walkthrough:** + +1. The client sends a single `UpdateWithStart` RPC, which atomically starts the Workflow and delivers the Update in one server call. +2. The Worker picks up the first Workflow Task and executes Phase 1. Because all Phase 1 Activities are Local Activities, they run in-process with no additional server calls. The entire phase completes inside a single Workflow Task. +3. When the Workflow Task completes, the server marks the Update as fulfilled and returns the result to the waiting client. This happens as soon as Phase 1 finishes—approximately 160 ms after the initial request. +4. The Workflow continues in a new Workflow Task to execute Phase 2 using regular Activities. These run in the background. The client is not blocked by this work. + +## Problem + +The plain Early Return pattern reduces first-response latency significantly compared to waiting for the full Workflow. However, if Phase 1 uses regular Activities, each Activity still incurs server scheduling overhead (~50 ms per call on Temporal Cloud). With two or three Phase 1 Activities, this overhead alone can account for 100–150 ms of the first-response time. + +## Solution + +Run all Phase 1 Activities as Local Activities. They execute in-process within the first Workflow Task, so their results are available to the Update handler as soon as the task completes—with no additional server calls. Phase 2 Activities remain regular Activities, which is acceptable because Phase 2 runs in the background after the client has already received its response. + + + + +```python +# workflows.py +from temporalio import workflow +from datetime import timedelta +from activities import validate_transaction, init_transaction, complete_transaction, cancel_transaction + +LOCAL_TIMEOUT = timedelta(seconds=5) +ACTIVITY_TIMEOUT = timedelta(seconds=30) + +@workflow.defn +class TransactionWorkflow: + def __init__(self) -> None: + self._tx: Transaction | None = None + self._phase1_done = False + self._phase1_error: Exception | None = None + + @workflow.update + async def get_result(self, req: TransactionRequest) -> Transaction: + # Wait for Phase 1 to finish before returning to the caller. + await workflow.wait_condition(lambda: self._phase1_done) + if self._phase1_error: + raise self._phase1_error + return self._tx + + @workflow.run + async def run(self, req: TransactionRequest) -> None: + try: + # Phase 1: Local Activities — zero server round-trips on the hot path. + tx = await workflow.execute_local_activity( + validate_transaction, req, + schedule_to_close_timeout=LOCAL_TIMEOUT, + ) + self._tx = await workflow.execute_local_activity( + init_transaction, tx, + schedule_to_close_timeout=LOCAL_TIMEOUT, + ) + except Exception as e: + self._phase1_error = e + finally: + self._phase1_done = True + + if self._phase1_error: + if self._tx is not None: + await workflow.execute_activity( + cancel_transaction, self._tx, + start_to_close_timeout=ACTIVITY_TIMEOUT, + ) + return + + # Phase 2: Regular Activities — background settlement (client already has response). + await workflow.execute_activity( + complete_transaction, self._tx, + start_to_close_timeout=ACTIVITY_TIMEOUT, + ) +``` + + + + +```typescript +// workflows.ts +import { proxyLocalActivities, proxyActivities, defineUpdate, setHandler, condition } from "@temporalio/workflow"; +import type * as activities from "./activities"; +import type { TransactionRequest, Transaction } from "./shared"; + +// Phase 1: local activities — no server round-trips on the hot path. +const { validateTransaction, initTransaction } = + proxyLocalActivities({ scheduleToCloseTimeout: "5s" }); + +// Phase 2: regular activities — background settlement. +const { completeTransaction, cancelTransaction } = + proxyActivities({ startToCloseTimeout: "30s" }); + +export const getResultUpdate = defineUpdate("getResult"); + +export async function transactionWorkflow(req: TransactionRequest): Promise { + let tx: Transaction | undefined; + let phase1Done = false; + let phase1Error: unknown; + + setHandler(getResultUpdate, async () => { + // The Update handler waits for Phase 1 before returning. + await condition(() => phase1Done); + if (phase1Error) throw phase1Error; + return tx!; + }); + + try { + // Phase 1: Local Activities run in-process. + tx = await validateTransaction(req); + tx = await initTransaction(tx); + } catch (err) { + phase1Error = err; + } finally { + phase1Done = true; + } + + if (phase1Error) { + if (tx !== undefined) { + await cancelTransaction(tx); + } + return; + } + + // Phase 2: Regular Activity runs in the background. + await completeTransaction(tx!); +} +``` + + + + +```go +// workflows.go +func TransactionWorkflow(ctx workflow.Context, req TransactionRequest) error { + var tx Transaction + var initDone bool + var initErr error + + // Register Update handler — returns to the client as soon as Phase 1 is done. + if err := workflow.SetUpdateHandler(ctx, "getResult", + func(ctx workflow.Context, r TransactionRequest) (Transaction, error) { + _ = workflow.Await(ctx, func() bool { return initDone }) + return tx, initErr + }); err != nil { + return err + } + + // Phase 1: Local Activities — in-process, zero server round-trips. + localCtx := workflow.WithLocalActivityOptions(ctx, workflow.LocalActivityOptions{ + ScheduleToCloseTimeout: 5 * time.Second, + }) + if err := workflow.ExecuteLocalActivity(localCtx, ValidateTransaction, req).Get(localCtx, &tx); err == nil { + initErr = workflow.ExecuteLocalActivity(localCtx, InitTransaction, tx).Get(localCtx, &tx) + } else { + initErr = err + } + initDone = true + + activityCtx := workflow.WithActivityOptions(ctx, workflow.ActivityOptions{ + StartToCloseTimeout: 30 * time.Second, + }) + if initErr != nil { + // Phase 2 (cancel): regular Activity runs in the background. + return workflow.ExecuteActivity(activityCtx, CancelTransaction, tx).Get(activityCtx, nil) + } + // Phase 2 (complete): regular Activity runs in the background. + return workflow.ExecuteActivity(activityCtx, CompleteTransaction, tx).Get(activityCtx, nil) +} +``` + + + + +```java +// TransactionWorkflow.java +public class Impl implements TransactionWorkflow { + // Phase 1: local activities — zero server round-trips on the hot path. + private final Activities localActivities = Workflow.newLocalActivityStub( + Activities.class, + LocalActivityOptions.newBuilder() + .setScheduleToCloseTimeout(Duration.ofSeconds(5)) + .build() + ); + // Phase 2: regular activities — background settlement. + private final Activities activities = Workflow.newActivityStub( + Activities.class, + ActivityOptions.newBuilder() + .setStartToCloseTimeout(Duration.ofSeconds(30)) + .build() + ); + + private Shared.Transaction tx; + private boolean phase1Done = false; + private RuntimeException phase1Error = null; + + @Override + public Shared.Transaction getResult(Shared.TransactionRequest req) { + // Update handler: wait for Phase 1 before returning. + Workflow.await(() -> phase1Done); + if (phase1Error != null) throw phase1Error; + return tx; + } + + @Override + public void processTransaction(Shared.TransactionRequest req) { + try { + tx = localActivities.validateTransaction(req); + tx = localActivities.initTransaction(tx); + } catch (RuntimeException e) { + phase1Error = e; + } finally { + phase1Done = true; + } + + if (phase1Error != null) { + activities.cancelTransaction(tx); + return; + } + // Phase 2: regular activity in the background. + activities.completeTransaction(tx); + } +} +``` + + + + +## When to use + +**Good fit:** + +- User-facing workflows where the first response is more latency-critical than total execution time +- Phase 1 consists of short, idempotent validation and initialization steps that fit naturally as Local Activities +- Phase 2 is slow (network I/O, external systems) and does not need to be on the client's critical path +- You already use or plan to use the Early Return pattern + +**Poor fit:** + +- Phase 1 Activities are long-running or require heartbeating—Local Activities cannot heartbeat +- The Workflow's total latency matters more than first-response latency +- Phase 1 and Phase 2 cannot be cleanly separated + +## Benefits and trade-offs + +| Pattern | First Response | Total Latency | Complexity | +|---|---|---|---| +| Synchronous workflow | Same as total | ~850 ms | Low | +| Early Return (regular activities) | ~265 ms | ~850 ms | Medium | +| Local Activities only | Same as total | ~275 ms | Medium | +| **Early Return + Local Activities** | **~160 ms** | **~275 ms** | **Medium** | +| Eager Workflow Start + Local Activities | ~160 ms | ~265 ms | High | + +## Best practices + +- **Keep Phase 1 Local Activities short.** Each must complete well within the Workflow Task timeout (default 10 seconds). Aim for under 5 seconds total for all Phase 1 work. +- **Design Phase 1 for at-least-once execution.** If the Workflow Task that runs Phase 1 fails and retries, all Phase 1 Local Activities re-execute. Phase 1 operations must be idempotent. +- **Separate Phase 1 and Phase 2 concerns cleanly.** The Update handler should wait only on the Phase 1 sentinel flag, not on any Phase 2 state. Phase 2 should be independent enough to proceed without client involvement. +- **Set appropriate timeouts for Phase 2.** Phase 2 regular Activities run in the background and should have a `startToCloseTimeout` that reflects the maximum acceptable settlement time. + +## Common pitfalls + +- **Putting slow operations in Phase 1.** If any Phase 1 Local Activity takes too long, the Workflow Task times out and retries. The client also waits longer for its early response, defeating the purpose of the pattern. +- **Non-idempotent Phase 1.** A retried Workflow Task re-executes all Local Activities in that task. Ensure Phase 1 operations (e.g., creating a record in an external system) are safe to re-run. +- **Ignoring Phase 1 errors in Phase 2.** Always check Phase 1 error state before proceeding to Phase 2. If Phase 1 failed, Phase 2 should run a compensating Activity (cancel, rollback) rather than complete. +- **Mixing Local and regular Activity stubs incorrectly.** In Java, `Workflow.newLocalActivityStub` and `Workflow.newActivityStub` return distinct objects. Make sure Phase 1 uses the local stub and Phase 2 uses the regular stub. + +## Related patterns + +- [Early Return](/design-patterns/early-return) — the baseline Update-with-Start pattern without Local Activity optimization +- [Local Activities](/design-patterns/local-activities) — using Local Activities for full-workflow latency reduction without early return +- [Eager Workflow Start](/design-patterns/eager-workflow-start) — eliminates the Matching step when starting the Workflow for additional total latency improvement diff --git a/docs/design-patterns/early-return.mdx b/docs/design-patterns/early-return.mdx new file mode 100644 index 0000000000..12885949ca --- /dev/null +++ b/docs/design-patterns/early-return.mdx @@ -0,0 +1,437 @@ +--- +id: early-return +title: "Early Return (Update with Start)" +sidebar_label: "Early Return" +description: "Synchronous initialization with asynchronous completion. Returns results immediately while processing continues in the background." +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +## Overview + +The Early Return pattern returns initialization results to the caller immediately while continuing asynchronous processing in the background. + +## Problem + +Clients need immediate feedback on whether an operation can proceed, but the full operation takes significant time to complete. +Blocking the client for the entire operation duration creates a poor user experience and ties up resources. + +## Solution + +You use Update-with-Start to split operations into two phases: a fast synchronous initialization phase that validates and returns results immediately, and a slower asynchronous completion phase that runs in the background. +The Workflow uses local Activities for quick initialization, Signals completion via Update handlers, then either completes or cancels the operation based on initialization success. + +```mermaid +sequenceDiagram + participant Client + participant Workflow + participant Activity + + Client->>+Workflow: Update-with-Start + activate Workflow + Workflow->>+Activity: Phase 1: Init (fast) + Activity-->>-Workflow: Result + Workflow-->>Client: Init Result (early return) + deactivate Workflow + + Note over Workflow: Workflow continues executing + Workflow->>+Activity: Phase 2: Complete (slow) + Activity-->>-Workflow: Done + deactivate Workflow +``` + +The following describes each step in the diagram: + +1. The client sends an Update-with-Start request to the Workflow. +2. The Workflow executes a fast initialization Activity (Phase 1) and returns the result to the client immediately. +3. The client receives the initialization result while the Workflow continues executing. +4. The Workflow executes the slower completion Activity (Phase 2) in the background. + +## Implementation + +The following examples show how each SDK implements this pattern. +The Workflow registers an Update handler that blocks until initialization completes, then returns the result to the caller. +The client receives the initialization result in a single round trip while the Workflow continues processing. + + + + +```python +# workflow.py +from dataclasses import dataclass +from datetime import timedelta + +from temporalio import workflow + +with workflow.unsafe.imports_passed_through(): + from activities import init_transaction, complete_transaction, cancel_transaction + + +@dataclass +class TransactionRequest: + amount: float + currency: str + + +@dataclass +class Transaction: + id: str + status: str + + +@workflow.defn +class TransactionWorkflow: + def __init__(self) -> None: + self.tx: Transaction | None = None + self.init_done = False + self.init_err: Exception | None = None + + @workflow.run + async def run(self, tx_request: TransactionRequest) -> Transaction | None: + # Phase 1: Fast synchronous initialization (local activity) + try: + self.tx = await workflow.execute_local_activity( + init_transaction, + tx_request, + schedule_to_close_timeout=timedelta(seconds=5), + ) + except Exception as e: + self.init_err = e + finally: + self.init_done = True # Signal update handler + + # Phase 2: Slow asynchronous completion + if self.init_err is not None: + await workflow.execute_activity( + cancel_transaction, + self.tx, + start_to_close_timeout=timedelta(seconds=30), + ) + return None + + await workflow.execute_activity( + complete_transaction, + self.tx, + start_to_close_timeout=timedelta(seconds=30), + ) + return self.tx + + @workflow.update + async def return_init_result(self) -> Transaction: + await workflow.wait_condition(lambda: self.init_done) + if self.init_err is not None: + raise self.init_err + return self.tx + + +# client.py +from temporalio.client import ( + Client, + WithStartWorkflowOperation, + WorkflowUpdateStage, +) +from temporalio.common import WorkflowIDConflictPolicy + +client = await Client.connect("localhost:7233") + +start_op = WithStartWorkflowOperation( + TransactionWorkflow.run, + tx_request, + id="transaction-123", + task_queue="transactions", + id_conflict_policy=WorkflowIDConflictPolicy.FAIL, +) + +update_handle = await client.start_update_with_start_workflow( + TransactionWorkflow.return_init_result, + wait_for_stage=WorkflowUpdateStage.COMPLETED, + start_workflow_operation=start_op, +) + +# Get initialization result immediately +tx = await update_handle.result() + +# Use transaction ID immediately while workflow continues +print(f"Transaction initialized: {tx.id}") +``` + + + + +```go +// workflow.go +func Workflow(ctx workflow.Context, txRequest TransactionRequest) (*Transaction, error) { + var tx *Transaction + var initDone bool + var initErr error + + // Register update handler that waits for initialization + workflow.SetUpdateHandler(ctx, UpdateName, + func(ctx workflow.Context) (*Transaction, error) { + workflow.Await(ctx, func() bool { return initDone }) + return tx, initErr + }, + ) + + // Phase 1: Fast synchronous initialization (local activity) + localOpts := workflow.WithLocalActivityOptions(ctx, workflow.LocalActivityOptions{ + ScheduleToCloseTimeout: 5 * time.Second, + }) + initErr = workflow.ExecuteLocalActivity(localOpts, txRequest.Init).Get(ctx, &tx) + initDone = true // Signal update handler + + // Phase 2: Slow asynchronous completion + activityCtx := workflow.WithActivityOptions(ctx, workflow.ActivityOptions{ + StartToCloseTimeout: 30 * time.Second, + }) + + if initErr != nil { + // Cancel on initialization failure + return nil, workflow.ExecuteActivity(activityCtx, CancelTransaction, tx).Get(ctx, nil) + } + + // Complete on initialization success + return tx, workflow.ExecuteActivity(activityCtx, CompleteTransaction, tx).Get(ctx, nil) +} + +// client.go +startOp := client.NewWithStartWorkflowOperation( + client.StartWorkflowOptions{ + ID: "transaction-123", + TaskQueue: "transactions", + WorkflowIDConflictPolicy: enumspb.WORKFLOW_ID_CONFLICT_POLICY_FAIL, + }, + Workflow, + txRequest, +) + +updateHandle, err := client.UpdateWithStartWorkflow(ctx, + client.UpdateWithStartWorkflowOptions{ + StartWorkflowOperation: startOp, + UpdateOptions: client.UpdateWorkflowOptions{ + UpdateName: UpdateName, + WaitForStage: client.WorkflowUpdateStageCompleted, + }, + }, +) + +// Get initialization result immediately +var tx Transaction +err = updateHandle.Get(ctx, &tx) +if err != nil { + return err +} + +// Use transaction ID immediately while workflow continues +fmt.Printf("Transaction initialized: %s\n", tx.ID) +``` + + + + +```java +// TransactionWorkflowImpl.java +public class TransactionWorkflowImpl implements TransactionWorkflow { + private boolean initDone = false; + private Transaction tx; + private Exception initError = null; + + @Override + public TxResult processTransaction(TransactionRequest txRequest) { + this.tx = activities.mintTransactionId(txRequest); + + // Phase 1: Fast synchronous initialization + try { + this.tx = activities.initTransaction(this.tx); + } catch (Exception e) { + initError = e; + } finally { + initDone = true; // Signal update handler + } + + // Phase 2: Slow asynchronous completion + if (initError != null) { + activities.cancelTransaction(this.tx); + return new TxResult("", "Transaction cancelled."); + } else { + activities.completeTransaction(this.tx); + return new TxResult(this.tx.getId(), "Transaction completed successfully."); + } + } + + @Override + public TxResult returnInitResult() { + Workflow.await(() -> initDone); // Wait for initialization + if (initError != null) { + throw Workflow.wrap(initError); + } + return new TxResult(tx.getId(), "Initialization successful"); + } +} + +// Client.java +TransactionWorkflow workflow = client.newWorkflowStub( + TransactionWorkflow.class, + WorkflowOptions.newBuilder() + .setWorkflowId("transaction-123") + .setTaskQueue("transactions") + .setWorkflowIdConflictPolicy( + WorkflowIdConflictPolicy.WORKFLOW_ID_CONFLICT_POLICY_FAIL) + .build()); + +WorkflowUpdateHandle updateHandle = + WorkflowClient.startUpdateWithStart( + workflow::returnInitResult, + UpdateOptions.newBuilder().build(), + new WithStartWorkflowOperation<>(workflow::processTransaction, txRequest)); + +// Get initialization result immediately +TxResult result = updateHandle.getResultAsync().get(); + +// Use transaction ID immediately while workflow continues +System.out.println("Transaction initialized: " + result.getId()); +``` + + + + +```typescript +// workflow.ts +import { defineUpdate, setHandler, condition } from '@temporalio/workflow'; +import * as activities from './activities'; + +const { initTransaction, completeTransaction, cancelTransaction } = + proxyLocalActivities({ + scheduleToCloseTimeout: '5s', + }); + +export const returnInitResultUpdate = defineUpdate('returnInitResult'); + +export async function transactionWorkflow(txRequest: TransactionRequest): Promise { + let tx: Transaction | undefined; + let initDone = false; + let initError: Error | undefined; + + // Register update handler that waits for initialization + setHandler(returnInitResultUpdate, async () => { + await condition(() => initDone); + if (initError) { + throw initError; + } + return tx!; + }); + + // Phase 1: Fast synchronous initialization (local activity) + try { + tx = await initTransaction(txRequest); + } catch (err) { + initError = err as Error; + } finally { + initDone = true; // Signal update handler + } + + // Phase 2: Slow asynchronous completion + if (initError) { + await cancelTransaction(tx!); + throw initError; + } + + await completeTransaction(tx); + return tx; +} + +// client.ts +const startWorkflowOperation = new WithStartWorkflowOperation( + transactionWorkflow, + { + workflowId: 'transaction-123', + args: [txRequest], + taskQueue: 'transactions', + workflowIdConflictPolicy: 'FAIL', + }, +); + +const tx = await client.workflow.executeUpdateWithStart( + returnInitResultUpdate, + { startWorkflowOperation }, +); + +const wfHandle = await startWorkflowOperation.workflowHandle(); + +// Use transaction ID immediately while workflow continues +console.log(`Transaction initialized: ${tx.id}`); + +// Optionally wait for the workflow to complete +const finalResult = await wfHandle.result(); +``` + + + + +The key points across all SDKs are: + +- **Update-with-Start** is a single API call that starts the Workflow and returns the initialization result. +- The Workflow uses an Update handler with a condition/await to block until initialization completes. +- The client receives the result immediately while the Workflow continues executing in the background. +- A `WorkflowIdConflictPolicy` must be specified. For early return, use `FAIL` to assert a new Workflow is created. +- Update-with-Start is **not atomic**. If the Update cannot be delivered (for example, no Worker is available), the Workflow Execution will still start. The SDKs will retry the Update request, but there is no guarantee the Update will succeed. + +## When to use + +The Early Return pattern is a good fit when clients need immediate feedback but operations take time to complete, validation or initialization can be done quickly (under 5 seconds), the operation can be safely cancelled if initialization fails, and the initialization result determines whether to proceed or abort. +Common use cases include e-commerce payment processing (immediate authorization while settlement runs in the background), user onboarding and KYC verification (quick user ID return while background checks continue), resource provisioning (fast validation results while infrastructure is set up), document processing (immediate receipt confirmation while OCR and content analysis continue), and order processing (fast inventory check while fulfillment runs in the background). + +It is not a good fit for fully automated processes that require no intermediate feedback, operations that cannot be split into fast and slow phases, or fire-and-forget operations where no immediate response is needed (use Signals). + +## Benefits and trade-offs + +The Early Return pattern provides immediate client feedback via Update-with-Start in a single round trip. +Clients do not wait for full operation completion. +Local Activities avoid extra server roundtrips during initialization, and there is a clear separation between validation and execution phases. +Automatic cancellation handling runs on initialization failure. + +The trade-offs to consider are that you must tune timeouts carefully for local Activities. +There is a concurrent Update limit (10 per Workflow) that can bottleneck high-throughput scenarios requiring multiple simultaneous Updates. +Clients must handle asynchronous completion separately, and initialization must complete within a single Workflow Task. +The pattern is limited to operations that you can split into fast and slow phases. + +## Comparison with alternatives + +| Approach | Immediate response | Consistency | Complexity | Use case | +| :--- | :--- | :--- | :--- | :--- | +| Early Return (Update-with-Start) | Yes (typed) | Strong | Medium | Synchronous init + async completion | +| Signal + Query polling | Yes (eventual) | Eventual | High | Fire-and-forget with status checks | +| Child Workflow split | Yes (Workflow ID) | Strong | High | Separate init and completion Workflows | +| Blocking until completion | Yes (final) | Strong | Low | Short operations only | + +## Best practices + +- **Set WorkflowIdConflictPolicy to FAIL.** For early return, use `FAIL` to assert a new Workflow is created per request. Use `USE_EXISTING` only for lazy initialization patterns. +- **Use Workflow.await in the Update handler.** Keep the Update handler lightweight — block on a condition flag (`workflow.Await` in Go, `Workflow.await` in Java, `condition` in TypeScript, `workflow.wait_condition` in Python) and let the main Workflow method do the real work. +- **Use local Activities for initialization.** Local Activities avoid extra server roundtrips, keeping the synchronous phase fast (under 5 seconds). +- **Handle Update-with-Start non-atomicity.** Update-with-Start is not atomic. The Workflow may start even if the Update fails. Ensure Workers are running and handle the case where the Update is not delivered. +- **Set a timeout on the Update result.** Use a timeout when waiting for the Update result to avoid blocking the client indefinitely if the Worker is unavailable. +- **Be aware of the concurrent Update limit.** The default `maxInFlightUpdates` is 10 per Workflow. If you expect high concurrency, design accordingly or use separate Workflows. +- **Provide a unique Update ID.** Use a unique Update ID for idempotency so retried requests attach to the same Update rather than creating duplicates. +- **Avoid Workflow timeouts.** Do not set Workflow Execution timeouts when using early return, as the background phase may take longer than expected. + +## Common pitfalls + +- **Assuming Update-with-Start is atomic.** Unlike Signal-with-Start, Update-with-Start is not atomic. The Workflow may start even if the Update fails (for example, if no Worker is available). Handle this by checking Workflow state after the call. +- **Missing WorkflowIdConflictPolicy.** Update-with-Start requires a `WorkflowIdConflictPolicy`. Omitting it causes an error. Use `FAIL` for early return (one Workflow per request) or `USE_EXISTING` for lazy initialization. +- **Blocking too long in the Update handler.** The Update handler should return quickly. Perform long-running work in the main Workflow method and use `Workflow.await` in the Update handler to wait for a result. +- **Swallowing the ContinueAsNew exception.** In TypeScript, `continueAsNew` throws a special exception. Catching it in a try-catch without re-throwing (or returning in a `finally` block) silently prevents Continue-As-New. + +## Related patterns + +- **[Saga Pattern](/design-patterns/saga-pattern)**: You can combine this with the Saga pattern to add compensation for failed completions. +- **[Signal with Start](/design-patterns/signal-with-start)**: For fire-and-forget operations that do not need an immediate response. +- **[Request-Response via Updates](/design-patterns/request-response-via-updates)**: For synchronous state modifications on already-running Workflows. + +## Sample code + +- [Python Sample](https://github.com/temporalio/samples-python/tree/main/early_return) — Early return with Update-with-Start. +- [Go Sample](https://github.com/temporalio/samples-go/tree/main/early-return) — Early return with Update-with-Start. +- [TypeScript Sample](https://github.com/temporalio/samples-typescript/tree/main/early-return) — Early return with local Activities. +- [Java Sample](https://github.com/temporalio/samples-java/tree/main/core/src/main/java/io/temporal/samples/earlyreturn) — Early return with Update handler. diff --git a/docs/design-patterns/entity-lifecycle-patterns.mdx b/docs/design-patterns/entity-lifecycle-patterns.mdx new file mode 100644 index 0000000000..3531ce53f9 --- /dev/null +++ b/docs/design-patterns/entity-lifecycle-patterns.mdx @@ -0,0 +1,46 @@ +--- +id: entity-lifecycle-patterns +title: "Entity & Lifecycle Patterns" +sidebar_label: "Entity & Lifecycle Patterns" +description: "Pattern selection guide for modeling long-lived stateful entities and managing Workflow history growth over time." +--- + +import PatternCards from '@site/src/components/PatternCards'; + +These patterns model long-lived business entities as Workflows and keep those Workflows healthy as they run for days, months, or indefinitely. They cover how an entity holds and mutates state, how you bound Workflow history growth, and how you manage timers that change over time. + +## Patterns in this section + + + +## Choosing a pattern + +**You are modeling something with an ongoing lifecycle** — an account, a device, a subscription: use an [Entity Workflow](/design-patterns/entity-workflow) as the single source of truth for that entity. + +**Your Workflow runs long enough to grow a large history**: apply [Continue-As-New](/design-patterns/continue-as-new) to reset history while preserving state. + +**You need a wait that responds to new information**: use an [Updatable Timer](/design-patterns/updatable-timer) instead of a fixed sleep. + +## Related sections + +- [Workflow Messaging Patterns](/design-patterns/workflow-messaging-patterns) — the Signals and Updates that drive entity state transitions +- [Task Orchestration Patterns](/design-patterns/task-orchestration-patterns) — decompose a large entity into child Workflows diff --git a/docs/design-patterns/entity-workflow.mdx b/docs/design-patterns/entity-workflow.mdx new file mode 100644 index 0000000000..8648743b9f --- /dev/null +++ b/docs/design-patterns/entity-workflow.mdx @@ -0,0 +1,556 @@ +--- +id: entity-workflow +title: "Entity Workflow Pattern" +sidebar_label: "Entity Workflow" +description: "Models long-lived business entities as individual Workflows that persist for the entity's entire lifetime, handling all state transitions through Signals and Updates." +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +## Overview + +The Entity Workflow pattern models long-lived business entities (users, accounts, devices, orders) as individual Workflows that persist for the entity's entire lifetime — potentially months or years. +Each entity gets its own Workflow instance identified by the entity ID, handling all state transitions and operations through Signals and Updates. + +## Problem + +Many business domains have entities that exist for extended periods, undergo multiple state transitions over their lifetime, need to maintain consistent state across operations, require audit trails of all changes, and must handle concurrent operations safely. + +Traditional approaches struggle with these requirements: + +- **Database-centric**: Complex locking, race conditions, scattered business logic. +- **Event Sourcing**: Requires rebuilding state from events, complex infrastructure. +- **Stateless Services**: No built-in consistency, must coordinate state externally. +- **Short-lived Workflows**: Do not model the full entity lifecycle. + +## Solution + +You create one Workflow per entity, using the entity ID as the Workflow ID. +The Workflow runs for the entity's entire lifetime, maintaining state in Workflow variables and handling operations via Signals and Updates. +You use Continue-As-New periodically to prevent unbounded history growth. + +```mermaid +sequenceDiagram + participant Client + participant UserWorkflow + participant NotificationWorkflow + participant Activities + + Client->>UserWorkflow: Start(userId="user-123") + activate UserWorkflow + Note over UserWorkflow: State: ACTIVE + + Client->>UserWorkflow: Update: updateProfile(data) + UserWorkflow->>Activities: validateProfile(data) + Activities-->>UserWorkflow: valid + UserWorkflow->>Activities: updateDatabase(userId, data) + Activities-->>UserWorkflow: success + Note over UserWorkflow: Profile updated + UserWorkflow-->>Client: Success + + Client->>UserWorkflow: Signal: suspend() + Note over UserWorkflow: State: SUSPENDED + UserWorkflow->>NotificationWorkflow: Start child workflow + activate NotificationWorkflow + NotificationWorkflow->>Activities: sendEmail(userId, "suspended") + deactivate NotificationWorkflow + + Client->>UserWorkflow: Update: reactivate() + Note over UserWorkflow: State: ACTIVE + UserWorkflow-->>Client: Success + + Note over UserWorkflow: After 1000 operations... + UserWorkflow->>UserWorkflow: Continue-As-New + Note over UserWorkflow: Fresh history, same state + + Client->>UserWorkflow: Signal: delete() + Note over UserWorkflow: State: DELETED + UserWorkflow-->>UserWorkflow: Complete + deactivate UserWorkflow +``` + +The following describes each step in the diagram: + +1. The client starts the Workflow with a user ID. The Workflow initializes in the ACTIVE state. +2. The client sends an Update to modify the profile. The Workflow validates the data via an Activity, persists the change, and returns success. +3. The client sends a Signal to suspend the account. The Workflow transitions to SUSPENDED and starts a Child Workflow to send a notification email. +4. The client sends an Update to reactivate the account. The Workflow transitions back to ACTIVE. +5. After 1000 operations, the Workflow calls Continue-As-New to reset its history while preserving state. +6. The client sends a Signal to delete the account. The Workflow transitions to DELETED and completes. + +## Implementation + +The following examples show how each SDK implements the Entity Workflow pattern. +Each implementation defines Update handlers for synchronous operations, Signal handlers for asynchronous events, and Query handlers for state inspection. + + + + +```python +# workflows.py +from dataclasses import dataclass +from datetime import datetime, timedelta +from temporalio import workflow + +with workflow.unsafe.imports_passed_through(): + from activities import validate_profile + +@dataclass +class UserState: + status: str = "ACTIVE" + profile: ProfileData | None = None + pending_email: str | None = None + created_at: datetime | None = None + updated_at: datetime | None = None + +@dataclass +class UserAccountInput: + user_id: str + # Unset for the original caller; carries state across Continue-As-New + state: UserState | None = None + +@workflow.defn +class UserAccountWorkflow: + def __init__(self) -> None: + self.state = UserState(created_at=datetime.utcnow()) + self.deleted = False + self.operation_count = 0 + + @workflow.run + async def run(self, input: UserAccountInput) -> None: + # On Continue-As-New, restore the state carried from the previous run + if input.state is not None: + self.state = input.state + + # Block until deleted or Continue-As-New is suggested + await workflow.wait_condition( + lambda: self.deleted or workflow.info().is_continue_as_new_suggested() + ) + + if not self.deleted and workflow.info().is_continue_as_new_suggested(): + await workflow.wait_condition(workflow.all_handlers_finished) + # Carry current state forward so it is not reset on the new run + workflow.continue_as_new( + UserAccountInput(user_id=input.user_id, state=self.state) + ) + + self.state.status = "DELETED" + + @workflow.update + async def update_profile(self, data: ProfileData) -> None: + if self.deleted: + raise ValueError("User account is deleted") + + await workflow.execute_activity( + validate_profile, data, + start_to_close_timeout=timedelta(seconds=30), + ) + + self.state.profile = data + self.state.updated_at = datetime.utcnow() + self.operation_count += 1 + + @workflow.update + async def suspend(self) -> None: + if not self.deleted and self.state.status != "SUSPENDED": + self.state.status = "SUSPENDED" + self.state.updated_at = datetime.utcnow() + self.operation_count += 1 + + @workflow.update + async def reactivate(self) -> None: + if not self.deleted and self.state.status == "SUSPENDED": + self.state.status = "ACTIVE" + self.state.updated_at = datetime.utcnow() + self.operation_count += 1 + + @workflow.signal + def delete(self) -> None: + self.deleted = True + + @workflow.query + def get_state(self) -> UserState: + return self.state +``` + + + + +```go +// workflow.go +type UserAccountWorkflow struct{} + +type UserState struct { + Status string + Profile ProfileData + PendingEmail string + CreatedAt time.Time + UpdatedAt time.Time +} + +type UserAccountInput struct { + UserID string + // Nil for the original caller; carries state across Continue-As-New + State *UserState +} + +func (w *UserAccountWorkflow) Run(ctx workflow.Context, input UserAccountInput) error { + // On Continue-As-New, restore the state carried from the previous run + var state UserState + if input.State != nil { + state = *input.State + } else { + state = UserState{ + Status: "ACTIVE", + CreatedAt: workflow.Now(ctx), + } + } + deleted := false + operationCount := 0 + + err := workflow.SetUpdateHandler(ctx, "updateProfile", func(ctx workflow.Context, data ProfileData) error { + if deleted { + return errors.New("user account is deleted") + } + + if err := workflow.ExecuteActivity(ctx, ValidateProfile, data).Get(ctx, nil); err != nil { + return err + } + + state.Profile = data + state.UpdatedAt = workflow.Now(ctx) + operationCount++ + + return nil + }) + if err != nil { + return err + } + + err = workflow.SetUpdateHandler(ctx, "suspend", func(ctx workflow.Context) error { + if !deleted && state.Status != "SUSPENDED" { + state.Status = "SUSPENDED" + state.UpdatedAt = workflow.Now(ctx) + operationCount++ + } + return nil + }) + if err != nil { + return err + } + + // Block until deleted or Continue-As-New is suggested + for { + selector := workflow.NewSelector(ctx) + selector.AddReceive(workflow.GetSignalChannel(ctx, "delete"), func(c workflow.ReceiveChannel, more bool) { + c.Receive(ctx, nil) + deleted = true + }) + selector.Select(ctx) + + if deleted { + state.Status = "DELETED" + return nil + } + if workflow.GetInfo(ctx).GetContinueAsNewSuggested() { + // Carry current state forward so it is not reset on the new run + return workflow.NewContinueAsNewError(ctx, w.Run, UserAccountInput{UserID: input.UserID, State: &state}) + } + } +} +``` + + + + +```java +// UserAccountWorkflow.java + +// userId is always set; state is empty for the original caller and +// carries state across Continue-As-New. +public record UserAccountInput(String userId, Optional state) {} + +@WorkflowInterface +public interface UserAccountWorkflow { + @WorkflowMethod + void run(UserAccountInput input); + + @UpdateMethod + void updateProfile(ProfileData data); + + @UpdateMethod + void changeEmail(String newEmail); + + @SignalMethod + void suspend(); + + @SignalMethod + void reactivate(); + + @SignalMethod + void delete(); + + @QueryMethod + UserState getState(); +} + +public class UserAccountWorkflowImpl implements UserAccountWorkflow { + private String userId; + private UserState state = new UserState(); + private boolean deleted = false; + private int operationCount = 0; + private static final int CONTINUE_AS_NEW_THRESHOLD = 1000; + + @Override + public void run(UserAccountInput input) { + this.userId = input.userId(); + // On Continue-As-New, restore the state carried from the previous run + if (input.state().isPresent()) { + this.state = input.state().get(); + } else { + state.setStatus("ACTIVE"); + state.setCreatedAt(Workflow.currentTimeMillis()); + } + + // Run until deleted or Continue-As-New is needed + Workflow.await(() -> deleted || Workflow.getInfo().isContinueAsNewSuggested()); + + if (!deleted && Workflow.getInfo().isContinueAsNewSuggested()) { + // Carry current state forward so it is not reset on the new run + Workflow.continueAsNew(new UserAccountInput(userId, Optional.of(state))); + } + + state.setStatus("DELETED"); + state.setDeletedAt(Workflow.currentTimeMillis()); + } + + @Override + public void updateProfile(ProfileData data) { + validateNotDeleted(); + Activities.validateProfile(data); + state.setProfile(data); + state.setUpdatedAt(Workflow.currentTimeMillis()); + incrementOperationCount(); + } + + @Override + public void changeEmail(String newEmail) { + validateNotDeleted(); + Activities.sendVerificationEmail(userId, newEmail); + state.setPendingEmail(newEmail); + state.setUpdatedAt(Workflow.currentTimeMillis()); + incrementOperationCount(); + } + + @Override + public void suspend() { + if (!deleted && !"SUSPENDED".equals(state.getStatus())) { + state.setStatus("SUSPENDED"); + state.setUpdatedAt(Workflow.currentTimeMillis()); + incrementOperationCount(); + } + } + + @Override + public void reactivate() { + if (!deleted && "SUSPENDED".equals(state.getStatus())) { + state.setStatus("ACTIVE"); + state.setUpdatedAt(Workflow.currentTimeMillis()); + incrementOperationCount(); + } + } + + @Override + public void delete() { + deleted = true; + } + + @Override + public UserState getState() { + return state; + } + + private void validateNotDeleted() { + if (deleted) { + throw new IllegalStateException("User account is deleted"); + } + } + + private void incrementOperationCount() { + operationCount++; + } +} +``` + + + + +```typescript +// workflow.ts +import { condition, allHandlersFinished, defineUpdate, defineSignal, defineQuery, setHandler, continueAsNew, workflowInfo, proxyActivities } from '@temporalio/workflow'; +import type * as activities from './activities'; + +const { validateProfile } = proxyActivities({ + startToCloseTimeout: '30s', +}); + +interface UserState { + status: string; + profile?: ProfileData; + pendingEmail?: string; + createdAt: number; + updatedAt: number; +} + +interface UserAccountInput { + userId: string; + // Unset for the original caller; carries state across Continue-As-New + state?: UserState; +} + +export const updateProfileUpdate = defineUpdate('updateProfile'); +export const suspendSignal = defineSignal('suspend'); +export const deleteSignal = defineSignal('delete'); +export const getStateQuery = defineQuery('getState'); + +export async function userAccountWorkflow(input: UserAccountInput): Promise { + // On Continue-As-New, restore the state carried from the previous run + const state: UserState = input.state ?? { + status: 'ACTIVE', + createdAt: Date.now(), + updatedAt: Date.now(), + }; + + let deleted = false; + let operationCount = 0; + + setHandler(updateProfileUpdate, async (data: ProfileData) => { + if (deleted) { + throw new Error('User account is deleted'); + } + + await validateProfile(data); + + state.profile = data; + state.updatedAt = Date.now(); + operationCount++; + + + }); + + setHandler(suspendSignal, () => { + if (!deleted && state.status !== 'SUSPENDED') { + state.status = 'SUSPENDED'; + state.updatedAt = Date.now(); + operationCount++; + } + }); + + setHandler(deleteSignal, () => { + deleted = true; + }); + + setHandler(getStateQuery, () => state); + + // Block until deleted or Continue-As-New is suggested + await condition(() => deleted || workflowInfo().continueAsNewSuggested); + + if (!deleted && workflowInfo().continueAsNewSuggested) { + await condition(allHandlersFinished); + // Carry current state forward so it is not reset on the new run + await continueAsNew({ userId: input.userId, state }); + } + + state.status = 'DELETED'; +} +``` + + + + +The Workflow blocks on `Workflow.await(() -> deleted)` (Java), `condition(() => deleted)` (TypeScript), `workflow.wait_condition(lambda: self.deleted)` (Python), or `selector.Select(ctx)` (Go) until the delete Signal arrives. +All state transitions happen through Signal and Update handlers, ensuring that every operation on the entity goes through a single Workflow with no race conditions. +Continue-As-New is triggered from the main Workflow method (not from handlers) when `isContinueAsNewSuggested()` returns true. +The Workflow takes a single input object that carries both the entity ID and the current state, so state is passed forward on Continue-As-New rather than reset. The state field is unset for the original caller and populated only on continuation; the new run restores it before processing further operations. +All SDK docs explicitly warn: do not call Continue-As-New from Update or Signal handlers. +Instead, handlers set state, and the main Workflow method checks whether to Continue-As-New. + +## When to use + +The Entity Workflow pattern is a good fit for user accounts and profiles, IoT devices and sensors, customer relationships (CRM), shopping carts and orders, financial accounts, subscription management, device provisioning and lifecycle, and multi-tenant resources. + +It is not a good fit for short-lived processes (use regular Workflows), stateless operations (use Activities), high-frequency updates (more than 100 per second per entity), or entities with only CRUD operations (use a database). + +## Benefits and trade-offs + +Benefits: + +- All operations on an entity go through a single Workflow, eliminating race conditions. +- The Workflow history provides a complete audit trail of all state changes. +- All entity logic lives in one place, and state survives process crashes and restarts. +- Temporal provides exactly-once execution and automatic retries. +- You can inspect current state through Queries without side effects. + +Trade-offs: + +- You must use Continue-As-New to prevent unbounded history growth. +- A single Workflow handles all operations for one entity, which limits throughput. +- State is kept in Workflow memory, so you should use Activities for large data. +- One Workflow per entity means you should consider costs at scale. +- The first operation after an idle period may have latency. + +## Comparison with alternatives + +| Approach | Consistency | Audit trail | Complexity | Scalability | +| :--- | :--- | :--- | :--- | :--- | +| Entity Workflow | Strong | Complete | Low | High (per entity) | +| Database + Locks | Eventual | Manual | High | Very High | +| Event Sourcing | Strong | Complete | High | High | +| Stateless Service | Weak | Manual | Medium | Very High | + +## Best practices + +- **Use entity ID as Workflow ID.** This ensures uniqueness and idempotent starts. +- **Implement Continue-As-New.** Use `isContinueAsNewSuggested()` to check when to continue. Always call Continue-As-New from the main Workflow method, never from handlers. Wait for all handlers to finish before continuing. +- **Validate in Updates.** Use Updates for operations that require validation and a return value. +- **Use Signals for events.** Use Signals for asynchronous notifications that do not need responses. +- **Keep state minimal.** Store large data externally and reference it in the Workflow. +- **Add Queries.** Expose state for monitoring and debugging. +- **Handle deletion.** Implement an explicit deletion or decommission Signal. +- **Version carefully.** Use Worker versioning for Workflow code changes. +- **Set timeouts.** Use Workflow execution timeout as a safety net. +- **Monitor history size.** Alert when approaching the Continue-As-New threshold. + +## Common pitfalls + +- **Calling Continue-As-New from Signal or Update handlers.** Continue-As-New must be called from the main Workflow method, never from inside a handler. Calling it from a handler causes non-determinism errors. +- **Not waiting for handlers to finish before Continue-As-New.** Use `allHandlersFinished` (TypeScript), `Workflow.isEveryHandlerFinished()` (Java), or `workflow.all_handlers_finished()` (Python) to ensure in-flight handlers complete before transitioning. +- **Losing Update ID deduplication across Continue-As-New.** Update IDs are scoped to a single Workflow Execution. After Continue-As-New, the same Update ID can be accepted again. Carry processed IDs in the Continue-As-New input if deduplication is needed. +- **Exceeding the 2 MB payload limit on Continue-As-New input.** State passed to Continue-As-New is subject to the same 2 MB blob size limit as Workflow inputs. Use external storage for large state. +- **Using a hardcoded counter instead of `isContinueAsNewSuggested`.** The SDK provides `isContinueAsNewSuggested()` which accounts for actual history size. Hardcoded thresholds may be too aggressive or too lenient. + +## Related patterns + +- **[Continue-As-New](/design-patterns/continue-as-new)**: Essential for preventing unbounded history. +- **[Request-Response via Updates](/design-patterns/request-response-via-updates)**: Synchronous operations with validation. +- **[Signal with Start](/design-patterns/signal-with-start)**: Idempotent Workflow start with an initial Signal. + +## Sample code + +**Python:** +- [Safe Message Handlers](https://github.com/temporalio/samples-python/tree/main/message_passing/safe_message_handlers) — Entity Workflow with Updates, Signals, and Continue-As-New. + +**Go:** +- [Safe Message Handlers](https://github.com/temporalio/samples-go/tree/main/safe_message_handler) — Entity Workflow with Updates, Signals, and Continue-As-New. + +**Java:** +- [Safe Message Handlers](https://github.com/temporalio/samples-java/tree/main/core/src/main/java/io/temporal/samples/safemessagehandler) — Entity Workflow with Updates, Signals, and Continue-As-New. + +**TypeScript:** +- [Safe Message Handlers](https://github.com/temporalio/samples-typescript/tree/main/message-passing/safe-message-handlers) — Entity Workflow with Updates, Signals, and Continue-As-New. + +## References + +- [Temporal Blog: Very Long-Running Workflows](https://temporal.io/blog/very-long-running-workflows) — Guidance on managing Workflows that run for extended periods. +- [Continue-As-New](/workflow-execution/continue-as-new) — Concept reference for the Continue-As-New mechanism. diff --git a/docs/design-patterns/error-handling-patterns.mdx b/docs/design-patterns/error-handling-patterns.mdx new file mode 100644 index 0000000000..26b75bb4af --- /dev/null +++ b/docs/design-patterns/error-handling-patterns.mdx @@ -0,0 +1,117 @@ +--- +id: error-handling-patterns +title: "Error Handling & Retry Patterns" +sidebar_label: "Error Handling & Retry Patterns" +description: "Pattern selection guide and decision tree for choosing the right retry strategy based on your error type, cost constraints, and recovery requirements." +--- + +import PatternCards from '@site/src/components/PatternCards'; + +These patterns control how Temporal retries Activities, surfaces persistent failures, and recovers from errors that require human intervention. + +## Patterns in this section + + + +## Choosing a pattern + +The following decision tree helps you select the appropriate retry strategy for your use case. + +```mermaid +flowchart TD + Start([Activity failing]) --> Q1{Each attempt\ncosts money or\nconsumes quota?} + Q1 -->|Yes| FixedCount[Fixed Count of Retries\nCap MaximumAttempts] + Q1 -->|No| Q2{Will this error\never succeed\nautomatically?} + Q2 -->|No| Q2a{Can a human\ncorrect and retry?} + Q2a -->|Yes| Resumable[Resumable Activity\nPark and await signal] + Q2a -->|No| NonRetryable[Non-Retryable Errors\nFail fast] + Q2 -->|Yes| Q3{Downstream has\na predictable\nunavailability window?} + Q3 -->|Yes| Delayed[Delayed Retry\nFixed interval backoff] + Q3 -->|No| Q4{Must resolve\nwithin a\ntime budget?} + Q4 -->|Yes| WallTime[Fixed Wall-Time Retries\nScheduleToCloseTimeout] + Q4 -->|No| Q5{Want aggressive\ninitial retries then\npatient recovery?} + Q5 -->|Yes| FastSlow[Fast/Slow Retries\nTwo-phase retry policy] + Q5 -->|No| Metrics[Retry Alerting via Metrics\nEmit metrics at attempt threshold] +``` + +The following describes each decision point: + +1. If each attempt consumes a paid API call, a rate-limited token, or another scarce resource, use **Fixed Count of Retries** to cap total consumption. +2. If the error is structural — a missing record, invalid input, or authorization failure — and cannot be corrected automatically, ask whether a human can fix it: if so, use **Resumable Activity** to park the Workflow and await a correction signal; otherwise use **Non-Retryable Errors** to fail fast. +3. If the downstream system has a scheduled maintenance window and you know approximately how long it will be unavailable, use **Delayed Retry** with a fixed interval. +4. If the process must resolve (one way or another) within a business SLA window such as 24 hours, use **Fixed Wall-Time Retries** with `ScheduleToCloseTimeout`. +5. If you want to recover from transient errors quickly but also wait indefinitely for the downstream system to come back, use **Fast/Slow Retries**. +6. For any long-running retry scenario, add **Retry Alerting via Metrics** to surface persistent failures before they breach an SLA. + +## How Temporal retries work + +Temporal's default `RetryPolicy` retries Activities indefinitely with exponential backoff. +Unless you configure a policy, a failing Activity will keep retrying until the `ScheduleToCloseTimeout` or the Workflow itself completes. + +The key `RetryPolicy` fields are: + +| Field | Default | Effect | +| :--- | :--- | :--- | +| `MaximumAttempts` | 0 (unlimited) | Caps total attempts including the first | +| `InitialInterval` | 1 second | Delay before the first retry | +| `BackoffCoefficient` | 2.0 | Multiplier applied after each retry | +| `MaximumInterval` | 100× InitialInterval | Upper bound on the backoff delay | +| `NonRetryableErrorTypes` | `[]` | Error types that skip retries entirely | + +`ScheduleToCloseTimeout` is set on the Activity call options, not in `RetryPolicy`. +It caps the total wall-clock time from when the Activity is first scheduled to when it must complete — across all retry attempts. + +## Related sections + +- [External Interaction Patterns](/design-patterns/external-interaction-patterns) — heartbeating, polling, and approval gates for the external calls that fail +- [Distributed Transaction Patterns](/design-patterns/distributed-transaction-patterns) — compensate completed steps when retries finally give up +- [Performance & Latency Patterns](/design-patterns/performance-latency-patterns) — where retry configuration fits in the latency budget + +## References + +- [Temporal Retry Policies](/encyclopedia/retry-policies) +- [Understanding Workflow Retries and Failures](https://community.temporal.io/t/understanding-workflow-retries-and-failures/122) +- [Failure Handling in Practice](https://temporal.io/blog/failure-handling-in-practice) diff --git a/docs/design-patterns/event-accumulator.mdx b/docs/design-patterns/event-accumulator.mdx new file mode 100644 index 0000000000..8285f1b327 --- /dev/null +++ b/docs/design-patterns/event-accumulator.mdx @@ -0,0 +1,408 @@ +--- +id: event-accumulator +title: "Event Accumulator Pattern" +sidebar_label: "Event Accumulator" +description: "Event Accumulator Pattern" +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +:::info[TL;DR] +Use the Event Accumulator pattern to **durably collect and process events from multiple senders over an unlimited time.** The workflow accumulates signals, deduplicates by a stable item key, and processes the batch after a sliding inactivity timeout — no external coordination, no lost events on retry. +::: + +## Overview + +The Accumulator pattern groups a stream of incoming events by a key and processes them together as a batch. +A *group key* is a stable, domain-specific identifier — for example, an order ID, customer ID, or session token — that logically binds related events belonging to the same accumulation window. +A single workflow instance per group key receives signals as events arrive, deduplicates them, and waits with a sliding inactivity timer. +When no new events arrive within the timeout window, the workflow calls a batch processing activity and completes. + +## Problem + +In distributed systems, events for the same logical entity arrive asynchronously from multiple producers at unpredictable rates. +Processing each event individually wastes downstream resources and makes throughput harder to control. +Ensuring exactly one active collector per group key — even under concurrent producers — requires coordination logic that is difficult to build reliably without distributed state. + +Without the Accumulator pattern, you must: + +- Handle race conditions when multiple producers try to start the same collection workflow simultaneously. +- Implement deduplication externally, because at-least-once delivery is common in event streams. +- Manage a reset timer that extends the collection window each time a new event arrives, without a reliable durable timer primitive. +- Persist collection state externally across restarts and failures. +- Handle gracefully the case where a long accumulation period grows the workflow history beyond safe limits. + +## Solution + +You assign each group a deterministic workflow ID derived from the group key (for example, `accumulator-order-123`). +Producers call Signal-With-Start so the workflow is created on first use and receives additional signals on subsequent calls — without any client-side coordination. +Inside the workflow, `Workflow.await()` with a timeout implements a sliding inactivity window: each arriving signal resets the countdown. +When the countdown expires — or when an explicit flush signal is sent — the workflow passes all accumulated, deduplicated events to a batch processing activity and completes. +If the accumulation period is long enough to grow the workflow history near its limit, the workflow uses Continue-As-New to carry its state forward into a fresh run. + +```mermaid +sequenceDiagram + participant P as Producers + participant T as Temporal + participant W as Accumulator Workflow + participant A as Batch Activity + + P->>T: SignalWithStart(order-A, item-1) + T->>W: Start workflow + deliver add-item(item-1) + activate W + Note over W: Accumulate item-1 · reset timer + + P->>T: SignalWithStart(order-A, item-2) + T->>W: Deliver add-item(item-2) + Note over W: Accumulate item-2 · reset timer + + P->>T: SignalWithStart(order-A, item-1) ← duplicate + T->>W: Deliver add-item(item-1) + Note over W: Deduplicate: item-1 already seen + + Note over W: Timer expires — no new signals + + W->>A: processItems(order-A, [item-1, item-2]) + A-->>W: "Order order-A: 2 items fulfilled" + deactivate W +``` + +The following describes each step in the diagram: + +1. A producer calls Signal-With-Start with `order-A` as the bucket key. Temporal starts `accumulator-order-A` and delivers the first `add-item` signal. The workflow adds `item-1` to its list and starts the inactivity timer. +2. A second producer calls Signal-With-Start for the same order. Temporal finds the workflow already running and delivers the signal — no new instance is created. The workflow adds `item-2` and resets the timer. +3. The first producer retries `item-1` due to at-least-once delivery. The workflow checks its deduplication set, finds `item-1` already recorded, and discards the duplicate. Alternatively, you can replace the existing record with the new payload — useful when a producer may resend an updated version of the same event under the same key. +4. No new signals arrive within the inactivity window. The `Workflow.await()` condition times out. +5. The workflow calls `processItems` with all accumulated items. The activity returns a result, and the workflow completes. + +## Implementation + +The following examples highlight the key mechanics of the accumulator loop. +Full implementations including the worker, starter, and shared types are available in the runner above. + + + + +```typescript +// workflows.ts +export async function accumulatorWorkflow( + bucketKey: string, + accumulated: OrderItem[] = [], + seenKeys: string[] = [], +): Promise { + const seenSet = new Set(seenKeys); + const items: OrderItem[] = [...accumulated]; + const unprocessed: OrderItem[] = []; + let flushRequested = false; + + setHandler(addItemSignal, (item: OrderItem) => { + unprocessed.push(item); + }); + setHandler(flushSignal, () => { + flushRequested = true; + }); + + do { + // Sliding window: wait for a signal or let the inactivity timer fire + const timedOut = !(await condition( + () => unprocessed.length > 0 || flushRequested, + "10 seconds", + )); + + // Drain and deduplicate incoming signals + while (unprocessed.length > 0) { + const item = unprocessed.shift()!; + if (item.orderId === bucketKey && !seenSet.has(item.itemId)) { + seenSet.add(item.itemId); + items.push(item); + } + } + + if (timedOut || flushRequested) { + const result = await processItems(bucketKey, items); + if (unprocessed.length === 0) return result; + // More signals arrived after timeout/flush — loop to process them + } + } while (unprocessed.length > 0 || !workflowInfo().continueAsNewSuggested); + + // History growing large — continue as new, carrying accumulated state forward + await continueAsNew(bucketKey, items, [...seenSet]); + return ""; // unreachable +} +``` + + + + +```python +# workflows.py +@workflow.defn +class AccumulatorWorkflow: + def __init__(self) -> None: + self._unprocessed: deque[OrderItem] = deque() + self._flush_requested = False + + @workflow.signal(name="add-item") + async def add_item(self, item: OrderItem) -> None: + self._unprocessed.append(item) + + @workflow.signal(name="flush") + async def flush(self) -> None: + self._flush_requested = True + + @workflow.run + async def run( + self, + bucket_key: str, + accumulated: list[OrderItem] | None = None, + seen_keys: list[str] | None = None, + ) -> str: + items = list(accumulated or []) + seen_set = set(seen_keys or []) + + while True: + # Sliding window: wait for a signal or let the inactivity timer fire + timed_out = not await workflow.wait_condition( + lambda: bool(self._unprocessed) or self._flush_requested, + timeout=timedelta(seconds=10), + ) + + # Drain and deduplicate the signal queue + while self._unprocessed: + item = self._unprocessed.popleft() + if item.order_id == bucket_key and item.item_id not in seen_set: + seen_set.add(item.item_id) + items.append(item) + + if timed_out or self._flush_requested: + result = await workflow.execute_activity( + process_items, + args=[bucket_key, items], + start_to_close_timeout=timedelta(seconds=10), + ) + if not self._unprocessed: + return result + # More signals arrived after timeout/flush — loop to process them + + if not self._unprocessed and workflow.info().is_continue_as_new_suggested(): + workflow.continue_as_new(args=[bucket_key, items, sorted(seen_set)]) +``` + + + + +```go +// workflows.go +func AccumulatorWorkflow(ctx workflow.Context, bucketKey string, items []OrderItem, seenKeys []string) (string, error) { + seenSet := make(map[string]bool) + for _, k := range seenKeys { + seenSet[k] = true + } + accumulated := append([]OrderItem{}, items...) + + addItemCh := workflow.GetSignalChannel(ctx, "add-item") + flushCh := workflow.GetSignalChannel(ctx, "flush") + flushRequested := false + + for { + // Drain any signals buffered before this iteration + for { + var item OrderItem + if !addItemCh.ReceiveAsync(&item) { + break + } + if item.OrderID == bucketKey && !seenSet[item.ItemID] { + seenSet[item.ItemID] = true + accumulated = append(accumulated, item) + } + } + var voidFlush interface{} + if flushCh.ReceiveAsync(&voidFlush) { + flushRequested = true + } + if flushRequested { + break + } + if workflow.GetInfo(ctx).GetContinueAsNewSuggested() { + keys := make([]string, 0, len(seenSet)) + for k := range seenSet { + keys = append(keys, k) + } + sort.Strings(keys) // deterministic order for replay + return "", workflow.NewContinueAsNewError(ctx, AccumulatorWorkflow, bucketKey, accumulated, keys) + } + + // Sliding window: wait for a signal or let the inactivity timer fire + timedOut := false + timerCtx, cancelTimer := workflow.WithCancel(ctx) + timer := workflow.NewTimer(timerCtx, maxAwaitTime) + selector := workflow.NewSelector(ctx) + selector.AddFuture(timer, func(f workflow.Future) { timedOut = true }) + selector.AddReceive(addItemCh, func(c workflow.ReceiveChannel, _ bool) { + var item OrderItem + c.Receive(ctx, &item) + if item.OrderID == bucketKey && !seenSet[item.ItemID] { + seenSet[item.ItemID] = true + accumulated = append(accumulated, item) + } + }) + selector.AddReceive(flushCh, func(c workflow.ReceiveChannel, _ bool) { + var void interface{} + c.Receive(ctx, &void) + flushRequested = true + }) + selector.Select(ctx) + cancelTimer() // no-op if timer already fired; cancels timer if a signal arrived + + if timedOut || flushRequested { + break + } + } + + ao := workflow.ActivityOptions{StartToCloseTimeout: 10 * time.Second} + actCtx := workflow.WithActivityOptions(ctx, ao) + var result string + if err := workflow.ExecuteActivity(actCtx, ProcessItems, bucketKey, accumulated).Get(ctx, &result); err != nil { + return "", err + } + workflow.GetLogger(ctx).Info("Processed order batch", "bucketKey", bucketKey, "count", len(accumulated)) + return result, nil +} +``` + + + + +```java +// AccumulatorWorkflow.java +@WorkflowInterface +public interface AccumulatorWorkflow { + @WorkflowMethod + String accumulate(String bucketKey, List items, List seenKeys); + + @SignalMethod + void addItem(Shared.OrderItem item); + + @SignalMethod + void flush(); + + class Impl implements AccumulatorWorkflow { + private final Activities activities = Workflow.newActivityStub( + Activities.class, + ActivityOptions.newBuilder() + .setStartToCloseTimeout(Shared.MAX_AWAIT_TIME.plusSeconds(10)) + .build()); + + private final ArrayDeque unprocessed = new ArrayDeque<>(); + private boolean flushRequested = false; + + @Override + public String accumulate(String bucketKey, List itemsInput, List seenKeysInput) { + List items = new ArrayList<>(itemsInput); + Set seenSet = new HashSet<>(seenKeysInput); + + do { + // Sliding window: wait for a signal or let the inactivity timer fire + boolean timedOut = !Workflow.await( + Shared.MAX_AWAIT_TIME, + () -> !unprocessed.isEmpty() || flushRequested); + + // Drain and deduplicate the signal queue + while (!unprocessed.isEmpty()) { + Shared.OrderItem item = unprocessed.removeFirst(); + if (item.orderId.equals(bucketKey) && seenSet.add(item.itemId)) { + items.add(item); + } + } + + if (timedOut || flushRequested) { + String result = activities.processItems(bucketKey, items); + if (unprocessed.isEmpty()) return result; + // More signals arrived after timeout/flush — loop to process them + } + } while (!unprocessed.isEmpty() || !Workflow.getInfo().isContinueAsNewSuggested()); + + // History growing large — continue as new, carrying accumulated state forward + AccumulatorWorkflow continueAsNew = Workflow.newContinueAsNewStub(AccumulatorWorkflow.class); + List seenKeysList = new ArrayList<>(seenSet); + java.util.Collections.sort(seenKeysList); // deterministic order for replay + continueAsNew.accumulate(bucketKey, items, seenKeysList); + return ""; // unreachable + } + + @Override + public void addItem(Shared.OrderItem item) { + unprocessed.add(item); + } + + @Override + public void flush() { + flushRequested = true; + } + } +} +``` + + + + +The key differences between SDKs: + +- **TypeScript**: Uses `setHandler` to register signal handlers and `condition()` with a string duration for the sliding window. `continueAsNew()` carries accumulated state to the next run. +- **Python**: Uses `@workflow.signal(name=...)` decorators and `workflow.wait_condition()` with a `timedelta` timeout. `workflow.continue_as_new()` accepts the new run arguments. +- **Go**: Uses `workflow.GetSignalChannel` to obtain named signal channels, then builds a `Selector` per iteration with a `NewTimer` future and two channel receivers. Cancels the old timer whenever a signal arrives before it fires. +- **Java**: Uses `@SignalMethod` annotations and `Workflow.await(Duration, Supplier)`. `Workflow.newContinueAsNewStub` carries state to the next run. + +Producers in all SDKs call Signal-With-Start to atomically start the workflow if not running and deliver the first signal without any client-side coordination. + +## When to use + +The Accumulator pattern is well suited when events related to the same entity or group arrive from multiple producers — including a single consumer (for example, a Kafka consumer) polling a topic that carries events for multiple groups simultaneously — and can be processed together as batches, when downstream systems prefer batched calls rather than one call per event, and when at-least-once event delivery makes deduplication necessary at the collection layer. + +It is not a good fit for use cases that require processing every event individually in _strict order_, for cases where the batch size is known in advance and all events arrive within a short deterministic window (a standard workflow is sufficient), or when events for different keys must be correlated at processing time (consider fan-in with e.g. Child Workflows or multiple levels of Accumulator). + +## Benefits and trade-offs + +The Accumulator pattern reduces downstream load by consolidating many individual events into a single batch and activity call. +Signal-With-Start eliminates client-side coordination logic for starting or locating the collector workflow. +Temporal's durable execution guarantees that accumulated state survives Worker restarts, and Continue-As-New prevents history growth from becoming a long-term problem. + +The trade-offs to consider are that events are not processed until the inactivity timeout fires or a flush signal is sent, introducing intentional latency. +The timeout value is a domain-specific trade-off between latency and batch size. +If producers stop sending signals but never send a flush, the workflow holds open resources until the timeout fires. + +## Best practices + +- **Use a deterministic workflow ID per bucket key.** Encode the group key and, optionally, an accumulation period (for example, `accumulator-order-123-2026-05-14`) to control when a new window starts. +- **Always use Signal-With-Start from producers.** Calling start and signal separately is not atomic: a signal sent between the two calls can be lost if the workflow has not yet started. +- **Include a deduplication key in every signal payload.** At-least-once delivery is common in event streams; without a dedup key, retried events add duplicate entries to the batch. +- **Size the inactivity timeout to your domain's quiet period.** The timeout should reflect how long you are confident no more events will arrive for this batch. Tune it based on observed producer behavior, not an arbitrary constant. +- **Pass accumulated state as workflow arguments into each Continue-As-New run.** Workflow state does not survive a Continue-As-New transition automatically; always pass items and the seen-keys list as arguments to the new run. If you omit the seen-keys list, any signal delivered to both the old and new run during the Continue-As-New handoff will be processed again in the new run, producing duplicate entries in the batch. +- **Keep signal handlers fast and side-effect free.** Signal handlers must not call activities or yield to the scheduler. Buffer incoming signals and process them in the main workflow loop. +- **Add a flush signal for testing and operational runbooks.** An explicit flush signal lets you trigger early batch processing without waiting for the timeout, which is useful for end-to-end tests and manual intervention. +- **Keep producer signal rate below 5/sec per workflow instance.** Each signal briefly locks the workflow execution. A sustained rate above roughly 5 signals/second causes workflow task backlog, limits throughput, and can eventually prevent Continue-As-New from completing. If your producer rate is higher, partition by a finer-grained key so each accumulator workflow receives a manageable share of the total signal volume. +- **Account for the 10,000-signal-per-run limit on Temporal Cloud.** A single workflow run in Temporal Cloud can receive at most 10,000 signals. If your accumulation window is long and producers are active, ensure your Continue-As-New trigger fires well before the per-run signal count reaches this limit. + +## Common pitfalls + +- **Non-deterministic or random workflow IDs.** If the workflow ID is not derived deterministically from the group key, multiple accumulator instances are created for the same group, splitting the batch. +- **Calling start followed by a separate signal.** These are not atomic. A signal sent between the two calls will be lost if the workflow has not yet started. Use Signal-With-Start instead. +- **Omitting a deduplication key.** Retried or re-delivered events add duplicate entries to the batch. Every signal payload must carry a stable, unique key. +- **Timeout set too short.** The workflow processes a partial batch while more events are still in flight, forcing producers to re-send unprocessed events. Profile your producer arrival rate before choosing a timeout. +- **Forgetting to pass accumulated state into Continue-As-New.** The new run starts with empty state and re-processes events from scratch, producing duplicate batches. +- **Calling activities inside signal handlers.** Signal handlers run synchronously in the workflow thread and must not block or call activities. Buffer the item and let the main loop handle activity calls. +- **Assuming workflow completion means all events were captured.** Producers that send signals after the workflow completes will start a new accumulator instance. Decide whether this is intentional (a new accumulation window) or an error. +- **Signal rate too high to allow Continue-As-New to complete.** Continue-As-New requires a brief window (~100 ms) with no unhandled signals. If producers send signals continuously without pause, the workflow can never enter that window, history grows without bound, and Temporal will eventually terminate the workflow. Partition by a finer-grained key, throttle producers, or batch multiple events into a single signal payload to keep the per-instance signal rate low enough for CAN to succeed. +- **Not draining the signal queue before calling Continue-As-New.** Any signal that arrives between the CAN decision and the actual CAN execution can be lost if the signal buffer is not empty when CAN fires. All SDK implementations in this pattern guard against this by re-checking the unprocessed queue before continuing; do not remove that guard or call CAN unconditionally on the history-size trigger. + +## Related patterns + +- **[Signal with Start](/design-patterns/signal-with-start)** — the atomic start-and-signal primitive this pattern uses to create or locate the accumulator workflow. +- **[Continue-As-New](/design-patterns/continue-as-new)** — used to reset workflow history when the accumulation period is long. +- **[Updatable Timer](/design-patterns/updatable-timer)** — an alternative approach for a resettable timer that does not require signals. +- **[Entity Workflow](/design-patterns/entity-workflow)** — a broader pattern for long-lived, keyed workflow instances. + +## Sample code + +- [Java Sample](https://github.com/temporalio/samples-java/blob/main/core/src/main/java/io/temporal/samples/hello/HelloAccumulator.java) — the canonical accumulator example from the Temporal Java samples repository. diff --git a/docs/design-patterns/external-interaction-patterns.mdx b/docs/design-patterns/external-interaction-patterns.mdx new file mode 100644 index 0000000000..7c073be6b1 --- /dev/null +++ b/docs/design-patterns/external-interaction-patterns.mdx @@ -0,0 +1,62 @@ +--- +id: external-interaction-patterns +title: "External Interaction Patterns" +sidebar_label: "External Interaction Patterns" +description: "Pattern selection guide for waiting on or interacting with systems and actors outside the Workflow." +--- + +import PatternCards from '@site/src/components/PatternCards'; + +These patterns cover how a Workflow waits on or interacts with the world outside it — external APIs, human decisions, scheduled delays, and inbound or outbound callbacks — while staying durable across failures. + +## Patterns in this section + + + +## Choosing a pattern + +**The external system offers no notification**: use [Polling External Services](/design-patterns/polling) and tune the interval to the expected latency. + +**One Activity runs for a long time**: use a [Long Running Activity](/design-patterns/long-running-activity) with heartbeats so failures resume instead of restarting. + +**A person or external system must decide**: use [Approval](/design-patterns/approval) to block on a Signal. + +**Execution should begin later**: use [Delayed Start](/design-patterns/delayed-start). + +**You send or receive HTTP callbacks**: use [Delayed Callback (Webhooks)](/design-patterns/delayed-callback). + +## Related sections + +- [Error Handling & Retry Patterns](/design-patterns/error-handling-patterns) — retry strategies for the external calls these patterns make +- [Workflow Messaging Patterns](/design-patterns/workflow-messaging-patterns) — the Signals that deliver external decisions into a Workflow diff --git a/docs/design-patterns/fairness.mdx b/docs/design-patterns/fairness.mdx new file mode 100644 index 0000000000..f606c7e6a2 --- /dev/null +++ b/docs/design-patterns/fairness.mdx @@ -0,0 +1,300 @@ +--- +id: fairness +title: "Fairness" +sidebar_label: "Fairness" +description: "Distributes Worker capacity evenly across tenants or users so that a burst from one caller does not starve the others." +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +:::info[TLDR] +Assign a `FairnessKey` and weight to Workflows and Activities so each tenant or group receives the **correct proportional share of Worker capacity** on a shared Task Queue. Use this when a high-volume caller would otherwise starve other tenants without requiring separate queues per tenant. +::: + +## Overview + +The Fairness pattern distributes Worker capacity proportionally across tenants or user groups within a single Task Queue so that a burst from one caller cannot starve others. Each group is assigned a fairness key and an optional weight; the Temporal matching service dispatches tasks in weighted round-robin order across all keys. + +## Problem + +When multiple tenants (e.g. customers) share a single Task Queue, a high-volume tenant can fill the queue and occupy all Worker slots. Other tenants receive no service until the dominant tenant's backlog drains. This starvation violates throughput guarantees and makes latency for lower-volume tenants unpredictable under burst conditions. + +The classic workaround—assigning one Task Queue per tenant—scales poorly: each new tenant requires a new Worker deployment, idle capacity on low-traffic tenants cannot be used by busy ones, and queue management complexity grows with tenant count. + +## Solution + +Temporal's native Fairness feature lets you assign a `FairnessKey` (a string identifier such as a tenant name or tier) and an optional `FairnessWeight` (a positive float, default 1.0) to Workflows, Activities, and Child Workflows. The Temporal matching service creates a virtual queue for each key and dispatches tasks in proportion to their weights. A single shared Worker pool serves all keys; no extra queues or routing logic is required. + +For example, assigning weights of 5.0, 3.0, and 2.0 to `premium`, `basic`, and `free` tiers causes 50% of dispatched tasks to come from `premium`, 30% from `basic`, and 20% from `free`—regardless of backlog depth. Within a single fairness key, tasks are dispatched in FIFO order. + +```mermaid +flowchart TD + WA["Workflow\nfairness_key=tenant-big\n(weight 1.0)"] --> TQ["my-task-queue"] + WB["Workflow\nfairness_key=tenant-mid\n(weight 1.0)"] --> TQ + WC["Workflow\nfairness_key=tenant-small\n(weight 1.0)"] --> TQ + TQ --> VQ1["Virtual Queue\ntenant-big"] + TQ --> VQ2["Virtual Queue\ntenant-mid"] + TQ --> VQ3["Virtual Queue\ntenant-small"] + VQ1 -->|round-robin| W["Shared Workers"] + VQ2 -->|round-robin| W + VQ3 -->|round-robin| W + W --> DS["Downstream\nService"] +``` + +The following describes each step in the diagram: + +1. Workflows start with a `FairnessKey` matching the tenant or group identity. +2. The Temporal matching service routes each task to the corresponding virtual queue inside the single Task Queue. +3. Workers poll the Task Queue and receive tasks in weighted round-robin order across all fairness keys. +4. Tenant-big's large backlog does not prevent tenant-mid or tenant-small from receiving service. + +## Implementation + +### Enable fairness + +**Temporal Cloud:** Navigate to the Namespace's Overview page in the UI and activate the Fairness toggle. Fairness is a paid feature in Temporal Cloud. + +**Self-hosted Temporal:** Set `matching.enableFairness` to `true` in the [dynamic config](/temporal-service/configuration#dynamic-configuration) for the relevant Task Queues or Namespaces. + +### Set fairness key and weight at Workflow start + + + + +```python +from temporalio.common import Priority + +handle = await client.start_workflow( + ProcessOrder.run, + id="process-order-wf", + task_queue="my-task-queue", + priority=Priority( + fairness_key="tenant-a", + fairness_weight=2.0, + ), +) +``` + + + + +```go +we, err := c.ExecuteWorkflow( + context.Background(), + client.StartWorkflowOptions{ + ID: "process-order-wf", + TaskQueue: "my-task-queue", + Priority: temporal.Priority{ + FairnessKey: "tenant-a", + FairnessWeight: 2.0, + }, + }, + ProcessOrder, +) +``` + + + + +```java +WorkflowOptions options = WorkflowOptions.newBuilder() + .setWorkflowId("process-order-wf") + .setTaskQueue("my-task-queue") + .setPriority(Priority.newBuilder() + .setFairnessKey("tenant-a") + .setFairnessWeight(2.0f) + .build()) + .build(); +ProcessOrder workflow = client.newWorkflowStub(ProcessOrder.class, options); +WorkflowClient.start(workflow::run); +``` + + + + +### Set fairness key and weight on Activities + +Activities inherit the parent Workflow's fairness key and weight. Override them in `ActivityOptions` when an Activity should belong to a different fairness group than its Workflow. Each field (`priority_key`, `fairness_key`, `fairness_weight`) is resolved independently in this order: Task Queue weight overrides (highest precedence), value set explicitly in the options, value inherited from the calling Workflow, then the default. Workflows started with Continue-As-New inherit the current execution's priority values unless you pass explicit values. See [Inheritance](/develop/task-queue-priority-fairness#inheritance) in the Temporal docs for the full resolution diagram. + + + + +```python +from temporalio.common import Priority + +# inside the workflow +result = await workflow.execute_activity( + process_for_tenant, + tenant_request, + start_to_close_timeout=timedelta(minutes=1), + priority=Priority( + fairness_key="tenant-a", + fairness_weight=2.0, + ), +) +``` + + + + +```go +ao := workflow.ActivityOptions{ + StartToCloseTimeout: time.Minute, + Priority: temporal.Priority{ + FairnessKey: "tenant-a", + FairnessWeight: 2.0, + }, +} +ctx = workflow.WithActivityOptions(ctx, ao) +err := workflow.ExecuteActivity(ctx, ProcessForTenant, req).Get(ctx, nil) +``` + + + + +```java +ActivityOptions options = ActivityOptions.newBuilder() + .setStartToCloseTimeout(Duration.ofMinutes(1)) + .setPriority(Priority.newBuilder() + .setFairnessKey("tenant-a") + .setFairnessWeight(2.0f) + .build()) + .build(); +TenantActivity activity = Workflow.newActivityStub(TenantActivity.class, options); +activity.processForTenant(request); +``` + + + + +### Set queue-level and per-key rate limits via CLI + +You can rate-limit the entire Task Queue and set a default per-fairness-key limit. The per-key limit is scaled by the fairness weight for that key, so a key with weight 2.5 and a default per-key limit of 10 gets an effective limit of 25 tasks/second. + +```sh +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" +``` + +### Override fairness weights via CLI + +When it is more convenient to manage weights through configuration than to embed them in client code, you can override weights for up to 1000 keys per Task Queue. Overrides take precedence over the weight attached to a task's options and can be updated without a code deploy. + +```sh +temporal task-queue config set \ + --task-queue my-task-queue \ + --task-queue-type workflow \ + --namespace my-namespace \ + --fairness-key-weight premium=5.0 \ + --fairness-key-weight basic=3.0 \ + --fairness-key-weight free=2.0 +``` + +### Use priority and fairness together + +Priority and Fairness can be combined. Priority determines which sub-queue (1–5) a task enters; Fairness determines the dispatch order within each priority level. Set both `PriorityKey` and `FairnessKey` on the same options object. + + + + +```python +from temporalio.common import Priority + +handle = await client.start_workflow( + ChargeCustomer.run, + id="charge-customer-wf", + task_queue="my-task-queue", + priority=Priority( + priority_key=1, + fairness_key="tenant-a", + fairness_weight=2.0, + ), +) +``` + + + + +```go +we, err := c.ExecuteWorkflow( + context.Background(), + client.StartWorkflowOptions{ + ID: "charge-customer-wf", + TaskQueue: "my-task-queue", + Priority: temporal.Priority{ + PriorityKey: 1, + FairnessKey: "tenant-a", + FairnessWeight: 2.0, + }, + }, + ChargeCustomer, +) +``` + + + + +```java +WorkflowOptions options = WorkflowOptions.newBuilder() + .setWorkflowId("charge-customer-wf") + .setTaskQueue("my-task-queue") + .setPriority(Priority.newBuilder() + .setPriorityKey(1) + .setFairnessKey("tenant-a") + .setFairnessWeight(2.0f) + .build()) + .build(); +``` + + + + +## When to use + +This pattern is a good fit for multi-tenant applications where large tenants should not block small tenants, for workloads that need proportional capacity allocation across groups without hard rate limits, and when the set of tenants or groups is dynamic (new keys can be introduced without deploying new Workers). For a broader look at multi-tenancy strategies in Temporal, see [Multi-Tenant Patterns](/production-deployment/multi-tenant-patterns). + +It is not a good fit when absolute throughput isolation is required (dedicated queues per tenant remain or [task queue priorities](/design-patterns/priority-task-queues) are the appropriate choice). + +## Benefits and trade-offs + +A single Worker pool serves all tenants; idle capacity from a low-traffic tenant automatically benefits high-traffic tenants rather than going to waste. New tenants require no Worker deployment—add a fairness key and Temporal starts dispatching their tasks immediately. Weights can be updated via CLI without redeploying application code. + +Fairness requires explicit enablement on Temporal Cloud and self-hosted deployments. Accuracy can degrade with a very large number of fairness keys. Fairness weight applies at schedule time, not dispatch time: changing a weight does not retroactively reorder tasks already in the backlog. + +## Comparison with alternatives + +| Approach | Tenant isolation | Dynamic tenants | Shares idle capacity | Complexity | +| :--- | :--- | :--- | :--- | :--- | +| Temporal FairnessKey (native) | Soft | Yes | Yes | Low | +| Dedicated queue per tenant | Hard | No | No | Medium | +| Single shared queue (no control) | None | Yes | Yes | Lowest | +| External queue with per-tenant consumer groups | Hard | Yes | No | High | + +## Best practices + +- **Use stable, consistent naming for fairness keys.** Use account IDs or tenant slugs rather than display names. Key names cannot be changed retroactively on tasks already in the backlog. +- **Combine Priority and Fairness for multi-class, multi-tenant workloads.** Priority separates urgent from batch work; Fairness prevents any single tenant from dominating within each priority level. +- **Monitor queue depth by fairness key.** Sustained backlog growth for a particular key means its weight fraction of Worker capacity cannot drain its submission rate. + +## Common pitfalls + +- **Expecting Fairness to reorder the existing backlog.** Fairness weight is evaluated at schedule time. Enabling Fairness on a Namespace with an existing backlog drains that backlog in its original order first; the fairness-aware dispatch mode takes effect only for newly submitted tasks. +- **Using Fairness as a hard rate limiter.** Fairness controls proportional dispatch but does not cap the absolute throughput of any one key. For hard throughput caps, combine Fairness with per-fairness-key RPS limits via the CLI. +- **Unkeyed tasks bypassing Fairness.** Tasks without a `FairnessKey` are grouped under an implicit empty-string key and participate in round-robin dispatch alongside named keys with a weight of 1.0. They do not bypass Fairness and compete as one group. +- **Task Queue partitioning reducing accuracy.** Task Queues are internally partitioned and tasks are distributed to partitions randomly, which can interfere with fair dispatch proportions. If your workload requires higher accuracy, contact Temporal Support to configure a single-partition Task Queue. +- **Assuming Fairness applies across Worker Versioning boundaries.** When using Worker Versioning and moving Workflows between versions, Priority still applies across versions but Fairness is only guaranteed within tasks originally queued on the same Worker version. Tasks moved from one version to another may not dispatch in fairness order relative to tasks on the destination version. +- **Expecting consistent fairness immediately after a server restart.** Fairness ordering is preserved across restarts for the most active keys. Less active keys may briefly dispatch new tasks ahead of their existing backlog until ordering normalizes. +- **Expecting the running task mix to immediately reflect fair dispatch.** Fairness governs which task is dispatched next; it does not account for tasks already running on Workers. The mix of in-flight tasks at any moment may not match the configured weight ratios. + +## Related patterns + +- **[Priority Task Queues](/design-patterns/priority-task-queues)**: Order tasks by urgency level within the same Task Queue using `PriorityKey`. +- **[Downstream Rate Limiting](/design-patterns/downstream-rate-limiting)**: Cap absolute throughput to a downstream service with a queue RPS setting. +- **[Worker-Specific Task Queues](/design-patterns/worker-specific-taskqueue)**: Route Activities to a specific Worker host for resource or data affinity. diff --git a/docs/design-patterns/fanout-child-workflows.mdx b/docs/design-patterns/fanout-child-workflows.mdx new file mode 100644 index 0000000000..99bf604431 --- /dev/null +++ b/docs/design-patterns/fanout-child-workflows.mdx @@ -0,0 +1,325 @@ +--- +id: fanout-child-workflows +title: "Fan-Out with Child Workflows" +sidebar_label: "Fan-Out with Child Workflows" +description: "Distributes a large record set across parallel Child Workflows for concurrent processing with automatic scaling." +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +:::info[TLDR] +Split your record set into fixed-size chunks and start **one child Workflow per chunk** so that each chunk's history stays within Temporal's limits. Use this when you want maximum concurrency with no rate control and you can pre-compute how many chunks you need before the job starts. Keep the number of in-flight children per parent well under the default limit of 2,000; use [Sliding Window](/design-patterns/sliding-window) or [Batch Iterator](/design-patterns/batch-iterator) for larger workloads. +::: + +## Overview + +The Fan-Out pattern distributes a large record set across multiple independent child Workflows, each responsible for processing a fixed-size chunk. The parent Workflow assigns work by offset and length so that no record IDs need to be passed over the wire — only two integers per child. + +## Problem + +A single Workflow run can have at most 2,000 in-flight Activities (aim for 500) and at most 50,000 history events. Processing millions of records in a single Workflow run is therefore not possible. + +You need a way to partition a large record set, process each partition independently, and coordinate the overall job while keeping each Workflow's history within safe bounds. + +## Solution + +You split the total record count into fixed-size chunks and start one child Workflow per chunk. Each child is given an `offset` and a `length` so it knows which slice of the record set to fetch and process independently. + +The parent Workflow starts all children concurrently and waits for them all to complete. If a child fails the parent can retry that child without re-processing the records handled by other children. + +```mermaid +flowchart TD + Records["📋 Total record set\n(N records)"] + Parent["Parent Workflow\n(fanOutWorkflow)"] + C1["Child Workflow\n(offset=0, length=chunk)"] + C2["Child Workflow\n(offset=chunk, length=chunk)"] + C3["Child Workflow\n(offset=2×chunk, length=chunk)"] + + Records --> Parent + Parent -->|"start child 1"| C1 + Parent -->|"start child 2"| C2 + Parent -->|"start child 3"| C3 + + C1 --> A1["processRecord ×chunk"] + C2 --> A2["processRecord ×chunk"] + C3 --> A3["processRecord ×chunk"] + + A1 -->|"done"| Parent + A2 -->|"done"| Parent + A3 -->|"done"| Parent +``` + +The following describes each step in the diagram: + +1. The parent Workflow receives the total record count and a configured chunk size. +2. It divides the total into chunks and starts one child Workflow per chunk, passing only `offset` and `length`. +3. Each child independently fetches its slice of records (using the offset and length) and calls `processRecord` for each one. +4. Each child completes and returns its result to the parent. +5. The parent blocks until all children have completed, then returns the aggregated result. + +## Implementation + +The following examples show how each SDK implements the Fan-Out pattern. + + + + +```typescript +// workflows.ts +import { + executeChild, + proxyActivities, + workflowInfo, +} from "@temporalio/workflow"; +import type * as activities from "./activities"; +import { TASK_QUEUE, CHUNK_SIZE } from "./shared"; + +const { processRecord } = proxyActivities({ + startToCloseTimeout: "10 seconds", +}); + +export async function fanOutWorkflow( + totalRecords: number, + chunkSize: number = CHUNK_SIZE +): Promise { + const children: Promise[] = []; + + for (let offset = 0; offset < totalRecords; offset += chunkSize) { + const length = Math.min(chunkSize, totalRecords - offset); + children.push( + executeChild(recordBatchWorkflow, { + args: [offset, length], + taskQueue: TASK_QUEUE, + workflowId: `${workflowInfo().workflowId}/batch-${offset}`, + }) + ); + } + + const results = await Promise.all(children); + return results.reduce((sum, n) => sum + n, 0); +} + +export async function recordBatchWorkflow( + offset: number, + length: number +): Promise { + let processed = 0; + for (let i = offset; i < offset + length; i++) { + await processRecord(i); + processed++; + } + return processed; +} +``` + + + + +```python +# workflows.py +from datetime import timedelta +from temporalio import workflow +from temporalio.workflow import ChildWorkflowHandle +import asyncio +from activities import process_record +from shared import TASK_QUEUE, CHUNK_SIZE + + +@workflow.defn +class RecordBatchWorkflow: + @workflow.run + async def run(self, offset: int, length: int) -> int: + processed = 0 + for i in range(offset, offset + length): + await workflow.execute_activity( + process_record, + i, + start_to_close_timeout=timedelta(seconds=10), + ) + processed += 1 + return processed + + +@workflow.defn +class FanOutWorkflow: + @workflow.run + async def run(self, total_records: int, chunk_size: int = CHUNK_SIZE) -> int: + handles: list[ChildWorkflowHandle] = [] + parent_id = workflow.info().workflow_id + + offset = 0 + while offset < total_records: + length = min(chunk_size, total_records - offset) + handle = await workflow.start_child_workflow( + RecordBatchWorkflow.run, + args=[offset, length], + id=f"{parent_id}/batch-{offset}", + task_queue=TASK_QUEUE, + ) + handles.append(handle) + offset += chunk_size + + results = await asyncio.gather(*handles) + return sum(results) +``` + + + + +```go +// workflows.go +package main + +import ( + "fmt" + "time" + + "go.temporal.io/sdk/workflow" +) + +func FanOutWorkflow(ctx workflow.Context, totalRecords int, chunkSize int) (int, error) { + if chunkSize <= 0 { + chunkSize = ChunkSize + } + + var futures []workflow.Future + parentID := workflow.GetInfo(ctx).WorkflowExecution.ID + + for offset := 0; offset < totalRecords; offset += chunkSize { + length := chunkSize + if offset+chunkSize > totalRecords { + length = totalRecords - offset + } + off := offset // capture loop variable + cwo := workflow.ChildWorkflowOptions{ + WorkflowID: parentID + "/batch-" + fmt.Sprintf("%d", off), + TaskQueue: TaskQueue, + } + cctx := workflow.WithChildOptions(ctx, cwo) + futures = append(futures, workflow.ExecuteChildWorkflow(cctx, RecordBatchWorkflow, off, length)) + } + + total := 0 + for _, f := range futures { + var n int + if err := f.Get(ctx, &n); err != nil { + return total, err + } + total += n + } + return total, nil +} + +func RecordBatchWorkflow(ctx workflow.Context, offset int, length int) (int, error) { + ao := workflow.ActivityOptions{ + StartToCloseTimeout: 10 * time.Second, + } + ctx = workflow.WithActivityOptions(ctx, ao) + + processed := 0 + for i := offset; i < offset+length; i++ { + if err := workflow.ExecuteActivity(ctx, ProcessRecord, i).Get(ctx, nil); err != nil { + return processed, err + } + processed++ + } + return processed, nil +} +``` + + + + +```java +// FanOutWorkflow.java +import io.temporal.activity.ActivityOptions; +import io.temporal.workflow.*; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; + +@WorkflowInterface +public interface FanOutWorkflow { + @WorkflowMethod + int run(int totalRecords, int chunkSize); +} + +// FanOutWorkflowImpl.java +public class FanOutWorkflowImpl implements FanOutWorkflow { + @Override + public int run(int totalRecords, int chunkSize) { + if (chunkSize <= 0) chunkSize = Shared.CHUNK_SIZE; + + List> promises = new ArrayList<>(); + String parentId = Workflow.getInfo().getWorkflowId(); + + for (int offset = 0; offset < totalRecords; offset += chunkSize) { + int length = Math.min(chunkSize, totalRecords - offset); + ChildWorkflowOptions opts = ChildWorkflowOptions.newBuilder() + .setWorkflowId(parentId + "/batch-" + offset) + .setTaskQueue(Shared.TASK_QUEUE) + .build(); + RecordBatchWorkflow child = Workflow.newChildWorkflowStub(RecordBatchWorkflow.class, opts); + promises.add(Async.function(child::run, offset, length)); + } + + int total = 0; + for (Promise p : promises) { + total += p.get(); + } + return total; + } +} + +// RecordBatchWorkflow.java +@WorkflowInterface +public interface RecordBatchWorkflow { + @WorkflowMethod + int run(int offset, int length); +} + +// RecordBatchWorkflowImpl.java +public class RecordBatchWorkflowImpl implements RecordBatchWorkflow { + private final Activities activities = Workflow.newActivityStub( + Activities.class, + ActivityOptions.newBuilder() + .setStartToCloseTimeout(Duration.ofSeconds(10)) + .build() + ); + + @Override + public int run(int offset, int length) { + int processed = 0; + for (int i = offset; i < offset + length; i++) { + activities.processRecord(i); + processed++; + } + return processed; + } +} +``` + + + + +## Best practices + +- **Use offset and length, not explicit IDs.** Pass only two integers to each child rather than a full slice of IDs. The child fetches its own records. This keeps history events small. +- **Size chunks to stay under the Activity limit.** Each child Workflow can have at most 2,000 in-flight Activities. Aim for chunks of 500 records or fewer if each record maps to one Activity. +- **Cap concurrent children in the parent.** Starting thousands of child Workflows simultaneously puts pressure on the namespace. Consider batching child starts or using [Sliding Window](/design-patterns/sliding-window) if you need tighter concurrency control. +- **Set `PARENT_CLOSE_POLICY_ABANDON`** for fire-and-forget fan-outs where the parent does not need to collect results. With the default `TERMINATE` policy, cancelling or timing out the parent will terminate all in-flight children. +- **Give each child a deterministic Workflow ID** (`parentId/batch-`). This makes it safe to re-run the parent: Temporal deduplicates child starts by Workflow ID, so already-completed children are not re-executed. + +## Common pitfalls + +- **Starting too many children at once.** Each child start adds to the parent's history. Temporal enforces a default limit of 2,000 pending (in-flight) child Workflows per parent; keep well under it. See [Temporal guidance](/child-workflows#when-to-use-child-workflows). If you need more children, switch to [MapReduce Tree](/design-patterns/mapreduce-tree) or [Sliding Window](/design-patterns/sliding-window). +- **Passing large lists of IDs.** Workflow inputs are stored in event history. Passing millions of record IDs as a list will blow the history size limit. Use offset + length instead. +- **Ignoring child failures.** A failed child does not automatically fail the parent unless you await all results. Always await child handles and handle errors explicitly. + +## Related resources + +- [Child Workflows pattern](/design-patterns/child-workflows) — core concepts for parent/child Workflow coordination +- [Batch Iterator](/design-patterns/batch-iterator) — unbounded record sets with Continue-as-New pagination +- [Sliding Window](/design-patterns/sliding-window) — bounded concurrency with maximum throughput +- [Temporal limits reference](/cloud/limits) diff --git a/docs/design-patterns/fast-slow-retries.mdx b/docs/design-patterns/fast-slow-retries.mdx new file mode 100644 index 0000000000..a35919f106 --- /dev/null +++ b/docs/design-patterns/fast-slow-retries.mdx @@ -0,0 +1,309 @@ +--- +id: fast-slow-retries +title: "Fast/Slow Retries" +sidebar_label: "Fast/Slow Retries" +description: "Try aggressively with a short interval first, then shift to a long interval when fast retries are exhausted, keeping the Workflow alive until the downstream system recovers." +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +:::info[TLDR] +Orchestrate two retry phases in the Workflow: a fast phase with short intervals and bounded attempts for transient errors, followed by a slow phase with long intervals and unlimited retries for extended outages. **Use this when a single `RetryPolicy` should not cover both brief blips and hour-long outages or maintenance windows.** +::: + +## Overview + +The Fast/Slow Retries pattern runs an Activity through two distinct retry phases: a fast phase with a short interval and bounded attempt count, followed by an unlimited slow phase with a long fixed interval managed by the Temporal Service. +Use it when transient errors are common and worth recovering from quickly, but the downstream system may also suffer extended outages that require patient, indefinite waiting. + +## Problem + +Conventional retry policies force you to choose between: + +- **Low `MaximumAttempts`**: Recovers from transient errors quickly but abandons the request when the downstream system has a longer outage. +- **Unlimited or High `MaximumAttempts` with short interval**: Floods a degraded downstream system with retries and accumulates noisy failures in logs. Incurs processing cost with each attempt. +- **Long fixed interval with unlimited retries**: Recovers from outages eventually, but is too slow to recover from transient errors that would have resolved in seconds. + +None of these options handles both scenarios well — a downstream system that sometimes has brief 503 errors *and* occasionally goes down for an extended maintenance window. + +## Solution + +Use the Workflow itself as a retry orchestrator across two phases: + +**Phase 1 — Fast retries**: Execute the Activity with a short `InitialInterval` and a bounded `MaximumAttempts`. This phase recovers from transient errors within seconds or minutes. + +**Phase 2 — Slow retries**: When the fast retry policy is exhausted, catch the `ActivityError` in the Workflow and execute the Activity again with a long `InitialInterval` and unlimited `MaximumAttempts`. The Temporal Service owns the slow retry management; the Workflow blocks until the Activity eventually succeeds. + +This design is invisible in conventional retry libraries because it requires the retry orchestrator to be a durable, resumable process — exactly what a Temporal Workflow is. + +```mermaid +flowchart TD + Start([Workflow starts]) --> Phase1 + + subgraph Phase1 [Phase 1 — Fast Retries] + F1[Execute Activity\ninitialInterval=1s\nmaxAttempts=10] -->|Success| Done + F1 -->|Failure| FCheck{Attempts\nexhausted?} + FCheck -->|No| F1 + FCheck -->|Yes| Log[Log: switching to slow phase] + end + + Log --> Phase2 + + subgraph Phase2 [Phase 2 — Slow Retries, Unlimited] + S1[Execute Activity\ninitialInterval=5m\nunlimited attempts] -->|Success| Done + S1 -->|Failure| SWait[Temporal waits 5m\nthen retries] + SWait --> S1 + end + + Done([Return result]) +``` + +The following describes each step: + +1. The Workflow first tries the Activity with a fast policy: 1-second initial interval and a maximum of 10 total attempts. +2. If the Activity succeeds during the fast phase, the Workflow returns the result immediately. +3. If all fast attempts are exhausted, the Workflow logs a warning and transitions to the slow phase. +4. In the slow phase, the Workflow executes the Activity with a 5-minute fixed interval and unlimited retries. The Temporal Service manages the wait between attempts. +5. When the Activity eventually succeeds — after the downstream system recovers — the Workflow returns the result. + +## Implementation + +### Two-phase workflow retry management + +The key change between phases is the retry interval and attempt count. +In Phase 1, the Temporal Service manages a fast set of retries: short interval, bounded attempts. +In Phase 2, the Temporal Service manages a slow set of retries: long fixed interval, unlimited attempts. + + + + +```python +# workflows.py +from datetime import timedelta +from temporalio import workflow +from temporalio.common import RetryPolicy +from temporalio.exceptions import ActivityError +import activities + +@workflow.defn +class FastSlowRetryWorkflow: + @workflow.run + async def run(self, request: str) -> str: + # Phase 1: fast retries + fast_policy = RetryPolicy( + initial_interval=timedelta(seconds=1), + backoff_coefficient=1.5, + maximum_interval=timedelta(seconds=30), + maximum_attempts=10, + ) + try: + return await workflow.execute_activity( + activities.call_downstream, + request, + start_to_close_timeout=timedelta(seconds=30), + retry_policy=fast_policy, + ) + except ActivityError: + workflow.logger.warning( + "Fast retries exhausted — switching to slow retry phase", + extra={"request": request}, + ) + + # Phase 2: slow retries + slow_policy = RetryPolicy( + initial_interval=timedelta(minutes=5), + backoff_coefficient=1.0, + ) + return await workflow.execute_activity( + activities.call_downstream, + request, + start_to_close_timeout=timedelta(seconds=30), + retry_policy=slow_policy, + ) +``` + + + + +```go +// workflow.go +package downstream + +import ( + "time" + + "go.temporal.io/sdk/temporal" + "go.temporal.io/sdk/workflow" +) + +func FastSlowRetryWorkflow(ctx workflow.Context, request string) (string, error) { + log := workflow.GetLogger(ctx) + + // Phase 1: fast retries + fastCtx := workflow.WithActivityOptions(ctx, workflow.ActivityOptions{ + StartToCloseTimeout: 30 * time.Second, + RetryPolicy: &temporal.RetryPolicy{ + InitialInterval: time.Second, + BackoffCoefficient: 1.5, + MaximumInterval: 30 * time.Second, + MaximumAttempts: 10, + }, + }) + + var result string + err := workflow.ExecuteActivity(fastCtx, CallDownstream, request).Get(fastCtx, &result) + if err != nil { + log.Warn("Fast retries exhausted — switching to slow retry phase", + "request", request) + + // Phase 2: slow retries + slowCtx := workflow.WithActivityOptions(ctx, workflow.ActivityOptions{ + StartToCloseTimeout: 30 * time.Second, + RetryPolicy: &temporal.RetryPolicy{ + InitialInterval: 5 * time.Minute, + BackoffCoefficient: 1.0, + // MaximumAttempts defaults to 0 (unlimited) + }, + }) + + err = workflow.ExecuteActivity(slowCtx, CallDownstream, request).Get(slowCtx, &result) + } + return result, err +} +``` + + + + +```java +// FastSlowRetryWorkflowImpl.java +import io.temporal.activity.ActivityOptions; +import io.temporal.common.RetryOptions; +import io.temporal.failure.ActivityFailure; +import io.temporal.workflow.Workflow; +import java.time.Duration; + +public class FastSlowRetryWorkflowImpl implements FastSlowRetryWorkflow { + @Override + public String run(String request) { + // Phase 1: fast retries + DownstreamActivities fastActivities = Workflow.newActivityStub( + DownstreamActivities.class, + ActivityOptions.newBuilder() + .setStartToCloseTimeout(Duration.ofSeconds(30)) + .setRetryOptions(RetryOptions.newBuilder() + .setInitialInterval(Duration.ofSeconds(1)) + .setBackoffCoefficient(1.5) + .setMaximumInterval(Duration.ofSeconds(30)) + .setMaximumAttempts(10) + .build()) + .build() + ); + + try { + return fastActivities.callDownstream(request); + } catch (ActivityFailure e) { + Workflow.getLogger(getClass()).warn( + "Fast retries exhausted — switching to slow retry phase: " + request + ); + + // Phase 2: slow retries + DownstreamActivities slowActivities = Workflow.newActivityStub( + DownstreamActivities.class, + ActivityOptions.newBuilder() + .setStartToCloseTimeout(Duration.ofSeconds(30)) + .setRetryOptions(RetryOptions.newBuilder() + .setInitialInterval(Duration.ofMinutes(5)) + .setBackoffCoefficient(1.0) + // setMaximumAttempts not set — defaults to unlimited + .build()) + .build() + ); + + return slowActivities.callDownstream(request); + } + } +} +``` + + + + +```typescript +// workflows.ts +import * as wf from '@temporalio/workflow'; +import type * as activities from './activities'; + +const fastDownstream = wf.proxyActivities({ + startToCloseTimeout: '30s', + retry: { + initialInterval: '1s', + backoffCoefficient: 1.5, + maximumInterval: '30s', + maximumAttempts: 10, + }, +}); + +const slowDownstream = wf.proxyActivities({ + startToCloseTimeout: '30s', + retry: { + initialInterval: '5m', + backoffCoefficient: 1, + // maximumAttempts defaults to unlimited + }, +}); + +export async function fastSlowRetryWorkflow(request: string): Promise { + // Phase 1: fast retries + try { + return await fastDownstream.callDownstream(request); + } catch { + wf.log.warn('Fast retries exhausted — switching to slow retry phase', { request }); + + // Phase 2: slow retries + return await slowDownstream.callDownstream(request); + } +} +``` + + + + +## Tuning the phases + +Adjust the phase parameters to match the characteristics of your downstream system: + +| Parameter | Typical value | Rationale | +| :--- | :--- | :--- | +| Phase 1 `InitialInterval` | 1–5 seconds | Recover from transient errors within seconds | +| Phase 1 `BackoffCoefficient` | 1.5–2.0 | Spread retries to avoid overwhelming a briefly degraded system | +| Phase 1 `MaximumAttempts` | 5–20 | Enough attempts to cover a short transient period | +| Phase 2 `InitialInterval` | 1–15 minutes | Long enough to avoid hammering a down system; short enough to recover promptly | +| Phase 2 `BackoffCoefficient` | 1.0 | Keeps the interval fixed; the default 2.0 would exponentially increase delays between slow-phase attempts | + +Phase 2 runs indefinitely by default. +If the business process has a maximum wait time, add a `ScheduleToCloseTimeout` or use a Workflow execution timeout to impose an outer bound. + +## Best practices + +- **Log the phase transition.** The transition from fast to slow is a meaningful signal that the downstream system may have a sustained problem. Log it with enough context — request identifier, attempt count, timestamp — to aid diagnosis. +- **Leave `MaximumAttempts` unset in Phase 2.** Omitting `MaximumAttempts` (or setting it to 0) gives the slow phase unlimited retries. The Temporal Service manages the wait between attempts via `InitialInterval`; the Workflow blocks until the Activity eventually succeeds. +- **Combine with Retry Alerting via Metrics.** Add a metric counter inside the Activity to surface slow-phase attempts to on-call teams. See [Retry Alerting via Metrics](/design-patterns/retry-metrics). + +## Common pitfalls + +- **Catching too broadly in Phase 1.** Catch `ActivityError` specifically. Catching all exceptions in Phase 1 may swallow errors that should propagate immediately (such as `CancelledError` in Python or a `PanicError` in Go). +- **Setting `MaximumAttempts` in Phase 2.** If you set a finite `MaximumAttempts` in the slow phase, it will eventually exhaust and propagate a failure to the Workflow. Only add a limit if the business process has a defined maximum wait time; in that case, pair it with a `ScheduleToCloseTimeout` to make the budget explicit. +- **Using exponential backoff in Phase 2.** The default `BackoffCoefficient` is 2.0, which doubles the interval with each attempt. Set `BackoffCoefficient=1.0` in the slow phase to keep the interval fixed and predictable. + +## Related patterns + +- [Retry Alerting via Metrics](/design-patterns/retry-metrics): Emit a metric in the slow-phase Activity to surface sustained failures to on-call teams. +- [Delayed Retry](/design-patterns/delayed-retry): Override the retry interval per error type using `nextRetryDelay` on `ApplicationFailure`. +- [Error Handling & Retry Patterns](/design-patterns/error-handling-patterns): Overview and decision tree for all retry patterns. + +## References + +- [Understanding Workflow Retries and Failures](https://community.temporal.io/t/understanding-workflow-retries-and-failures/122) +- [Failure Handling in Practice](https://temporal.io/blog/failure-handling-in-practice) diff --git a/docs/design-patterns/fixed-count-retries.mdx b/docs/design-patterns/fixed-count-retries.mdx new file mode 100644 index 0000000000..415df0c959 --- /dev/null +++ b/docs/design-patterns/fixed-count-retries.mdx @@ -0,0 +1,302 @@ +--- +id: fixed-count-retries +title: "Fixed Count of Retries" +sidebar_label: "Fixed Count of Retries" +description: "Cap the number of Activity retry attempts to control cost when each attempt consumes a paid or limited resource." +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +:::info[TLDR] +Set `MaximumAttempts` on the `RetryPolicy` to **cap how many times Temporal will attempt an Activity**. Use this when each attempt consumes a paid API call, a rate-limited token, or any scarce resource where unbounded retries translate directly to unbounded cost. +::: + +## Overview + +The Fixed Count of Retries pattern caps the total number of Activity execution attempts by setting `MaximumAttempts` on the `RetryPolicy`. +Use it when each attempt consumes a paid API call, a rate-limited token, or any scarce resource where unbounded retries translate directly to unbounded cost. + +## Problem + +Temporal's default retry policy retries Activities indefinitely with exponential backoff. +This is appropriate for most infrastructure failures, but it creates problems when the Activity calls a paid third-party API: + +- A credit card authorization that fails due to a transient network error will be retried dozens of times, each attempt charging a per-call fee. +- A generative AI API with a per-token pricing model will accumulate costs silently while the Workflow waits. +- A rate-limited partner API will exhaust its quota across all callers if one Workflow retries without bound. + +Without a cap, a single stuck Workflow can generate costs that are orders of magnitude larger than the intended spend. + +## Solution + +Set `MaximumAttempts` on the `RetryPolicy` passed to the Activity call. +Temporal counts the initial attempt and each retry toward the limit. +When the limit is reached, Temporal stops retrying and delivers an `ActivityError` to the Workflow. +The Workflow can catch that error and decide whether to fail, alert, or escalate. + +```mermaid +sequenceDiagram + participant Workflow + participant Temporal as Temporal Service + participant API as Payment API + + Workflow->>Temporal: Schedule activity (MaximumAttempts=3) + Temporal->>+API: Attempt 1 + API-->>-Temporal: Failure + Note over Temporal: Retry 1 of 2 + Temporal->>+API: Attempt 2 + API-->>-Temporal: Failure + Note over Temporal: Retry 2 of 2 + Temporal->>+API: Attempt 3 + API-->>-Temporal: Failure + Note over Temporal: MaximumAttempts reached — no more retries + Temporal-->>Workflow: ActivityError + Workflow->>Workflow: Handle failure (alert, compensate, or escalate) +``` + +The following describes each step: + +1. The Workflow schedules the Activity with a `RetryPolicy` that caps attempts at 3. +2. The Temporal Service executes the Activity. On failure, it schedules a retry. +3. After 3 total attempts (1 initial + 2 retries), Temporal delivers an `ActivityError` to the Workflow. +4. The Workflow catches the error and handles it — logging, compensating, or escalating — rather than accumulating further cost. + +## Implementation + +### Capping attempts + +Set `maximum_attempts` (Python), `MaximumAttempts` (Go / Java), or `maximumAttempts` (TypeScript) on the retry policy. +The count includes the initial attempt, so `maximum_attempts=3` means one attempt plus two retries. + + + + +```python +# workflows.py +from datetime import timedelta +from temporalio import workflow +from temporalio.common import RetryPolicy +from temporalio.exceptions import ActivityError, RetryState +import activities + +@workflow.defn +class PaymentWorkflow: + @workflow.run + async def run(self, order_id: str) -> str: + try: + return await workflow.execute_activity( + activities.charge_payment_api, + order_id, + start_to_close_timeout=timedelta(seconds=10), + retry_policy=RetryPolicy(maximum_attempts=3), + ) + except ActivityError as e: + if e.retry_state == RetryState.MAXIMUM_ATTEMPTS_REACHED: + # All retries exhausted — handle the failure here. + # Options: alert on-call, trigger a compensation activity, or escalate to a human. + workflow.logger.error( + "Payment failed: all 3 attempts exhausted", + extra={"order_id": order_id}, + ) + raise +``` + + + + +```go +// workflow.go +package payments + +import ( + "errors" + "time" + + enumspb "go.temporal.io/api/enums/v1" + "go.temporal.io/sdk/temporal" + "go.temporal.io/sdk/workflow" +) + +func PaymentWorkflow(ctx workflow.Context, orderID string) (string, error) { + ao := workflow.ActivityOptions{ + StartToCloseTimeout: 10 * time.Second, + RetryPolicy: &temporal.RetryPolicy{ + MaximumAttempts: 3, + }, + } + ctx = workflow.WithActivityOptions(ctx, ao) + + var result string + err := workflow.ExecuteActivity(ctx, ChargePaymentAPI, orderID).Get(ctx, &result) + if err != nil { + var actErr *temporal.ActivityError + if errors.As(err, &actErr) && actErr.RetryState() == enumspb.RETRY_STATE_MAXIMUM_ATTEMPTS_REACHED { + // All retries exhausted — handle the failure here. + // Options: alert on-call, trigger a compensation activity, or escalate to a human. + workflow.GetLogger(ctx).Error("Payment failed: all 3 attempts exhausted", + "orderID", orderID) + } + return "", err + } + return result, nil +} +``` + + + + +```java +// PaymentWorkflowImpl.java +import io.temporal.activity.ActivityOptions; +import io.temporal.api.enums.v1.RetryState; +import io.temporal.common.RetryOptions; +import io.temporal.failure.ActivityFailure; +import io.temporal.workflow.Workflow; +import java.time.Duration; + +public class PaymentWorkflowImpl implements PaymentWorkflow { + private final PaymentActivities activities = Workflow.newActivityStub( + PaymentActivities.class, + ActivityOptions.newBuilder() + .setStartToCloseTimeout(Duration.ofSeconds(10)) + .setRetryOptions(RetryOptions.newBuilder() + .setMaximumAttempts(3) + .build()) + .build() + ); + + @Override + public String run(String orderId) { + try { + return activities.chargePaymentApi(orderId); + } catch (ActivityFailure e) { + if (e.getRetryState() == RetryState.RETRY_STATE_MAXIMUM_ATTEMPTS_REACHED) { + // All retries exhausted — handle the failure here. + // Options: alert on-call, trigger a compensation activity, or escalate to a human. + Workflow.getLogger(getClass()).error( + "Payment failed: all 3 attempts exhausted: " + orderId, e); + } + throw e; + } + } +} +``` + + + + +```typescript +// workflows.ts +import * as wf from '@temporalio/workflow'; +import type * as activities from './activities'; + +const { chargePaymentApi } = wf.proxyActivities({ + startToCloseTimeout: '10s', + retry: { maximumAttempts: 3 }, +}); + +export async function paymentWorkflow(orderId: string): Promise { + try { + return await chargePaymentApi(orderId); + } catch (err) { + if (err instanceof wf.ActivityFailure && err.retryState === wf.RetryState.MAXIMUM_ATTEMPTS_REACHED) { + // All retries exhausted — handle the failure here. + // Options: alert on-call, trigger a compensation activity, or escalate to a human. + wf.log.error('Payment failed: all 3 attempts exhausted', { orderId }); + } + throw err; + } +} +``` + + + + +### Disabling retries entirely + +Set `maximum_attempts=1` to disable retries. +The Activity starts once and any failure is immediately delivered to the Workflow. +This is appropriate when the operation is not idempotent and a second attempt would cause a duplicate side effect such as a double charge or a duplicate email. + + + + +```python +# workflows.py +result = await workflow.execute_activity( + activities.send_welcome_email, + user_id, + start_to_close_timeout=timedelta(seconds=10), + retry_policy=RetryPolicy(maximum_attempts=1), +) +``` + + + + +```go +// workflow.go +ao := workflow.ActivityOptions{ + StartToCloseTimeout: 10 * time.Second, + RetryPolicy: &temporal.RetryPolicy{ + MaximumAttempts: 1, + }, +} +``` + + + + +```java +// Workflow.java +ActivityOptions.newBuilder() + .setStartToCloseTimeout(Duration.ofSeconds(10)) + .setRetryOptions(RetryOptions.newBuilder() + .setMaximumAttempts(1) + .build()) + .build() +``` + + + + +```typescript +// workflows.ts +const { sendWelcomeEmail } = wf.proxyActivities({ + startToCloseTimeout: '10s', + retry: { maximumAttempts: 1 }, +}); +``` + + + + +If a Worker crashes after the API call succeeds but before the result is recorded, Temporal will not retry — the call is lost. +An idempotency key (a stable identifier derived from the Workflow and Activity IDs) lets the downstream system detect and discard duplicates if a retry is needed in future. If you have idempotency keys, there's little need to cap retries at 1. + +## Best practices + +- **Match the cap to the cost model.** If the API charges per call, set `maximum_attempts` to the maximum number of calls you are willing to pay for per Workflow execution. +- **Combine with `StartToCloseTimeout`.** A per-attempt timeout prevents a slow response from consuming the entire retry budget on a single hanging call. +- **Catch `ActivityError` in the Workflow.** Handle the exhausted-retries case explicitly — log, alert, compensate, or escalate — rather than letting it fail the Workflow silently. +- **Use idempotency keys.** When retrying, it's vital to have downstream systems detect and discard duplicate calls to avoid duplicate downstream effects. +- **Prefer non-retryable errors for structural failures.** If the failure is not transient (for example, invalid input), mark it as non-retryable rather than relying solely on `maximum_attempts`. + +## Common pitfalls + +- **Confusing `MaximumAttempts` with allowed retry count.** `MaximumAttempts=3` means 3 total attempts (1 initial + 2 retries), not 3 retries after the initial attempt. +- **Setting no timeout alongside a low attempt cap.** Without `StartToCloseTimeout`, a single hanging attempt can block all retries for minutes or hours. +- **Ignoring the `ActivityError` in the Workflow.** Exhausted retries raise an error in the Workflow. If you do not catch it, the Workflow fails without any compensation or alerting. +- **Disabling retries on operations without safeguards.** `maximum_attempts=1` on a call means any failure — including a Worker crash after the API responded — results in a permanent gap. + +## Related patterns + +- [Non-Retryable Errors](/design-patterns/non-retryable-errors): Fail immediately for errors that will never succeed regardless of how many times you try. +- [Fixed Wall-Time Retries](/design-patterns/fixed-wall-time-retries): Bound by total elapsed time rather than attempt count. +- [Error Handling & Retry Patterns](/design-patterns/error-handling-patterns): Overview and decision tree for all retry patterns. + +## References + +- [Temporal Retry Policies](/encyclopedia/retry-policies) +- [Idempotency and Durable Execution](https://temporal.io/blog/idempotency-and-durable-execution) diff --git a/docs/design-patterns/fixed-wall-time-retries.mdx b/docs/design-patterns/fixed-wall-time-retries.mdx new file mode 100644 index 0000000000..fe6ab03429 --- /dev/null +++ b/docs/design-patterns/fixed-wall-time-retries.mdx @@ -0,0 +1,322 @@ +--- +id: fixed-wall-time-retries +title: "Fixed Wall-Time Retries" +sidebar_label: "Fixed Wall-Time Retries" +description: "Bound the total elapsed time across all retry attempts to enforce a business SLA, regardless of how many individual attempts occur." +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +:::info[TLDR] +Set `ScheduleToCloseTimeout` on the Activity call to enforce a hard time budget across all retry attempts. Use this when a business SLA requires the Activity to **succeed or fail within a defined window**, regardless of how many individual attempts occur. +::: + +## Overview + +The Fixed Wall-Time Retries pattern enforces a maximum total elapsed time across all Activity retry attempts using `ScheduleToCloseTimeout`. +Use it when a business process must _succeed or fail_ within a defined time budget, regardless of how many individual attempts occur. + +## Problem + +`StartToCloseTimeout` limits how long a single Activity attempt may run before Temporal cancels it and schedules a retry. +It does not limit how long retries collectively may run. + +A process with `StartToCloseTimeout=5m` and the default unlimited retry policy can run for days — each attempt times out at 5 minutes, then Temporal waits for the backoff delay and tries again, indefinitely. + +When a business SLA exists and violating that SLA is a failure such as a payment must charge in two minutes or less, an authorization check must complete within 30 seconds — you need a hard outer boundary that Temporal enforces automatically without requiring the Workflow to track elapsed time itself. + +## Solution + +Set `ScheduleToCloseTimeout` on the Activity call options. +It starts when the Activity is first scheduled and expires when the clock runs out, regardless of how many attempts have occurred. +When the timeout expires, the Temporal Service marks the Activity Execution as timed out and delivers an `ActivityError` to the Workflow. +No further retries are scheduled. +A timeout does not forcibly stop Activity code that is already running, so an Activity that runs past the budget must heartbeat and handle cancellation to stop cooperatively. + +```mermaid +sequenceDiagram + participant Workflow + participant Temporal as Temporal Service + participant Service as Downstream Service + + Note over Temporal: ScheduleToCloseTimeout = 2m starts + Workflow->>Temporal: Schedule activity + Temporal->>+Service: Attempt 1 (StartToClose = 30s) + Service-->>-Temporal: Failure + Note over Temporal: Backoff delay (5s) + Temporal->>+Service: Attempt 2 (StartToClose = 30s) + Service-->>-Temporal: Failure + Note over Temporal: Backoff delay (5s), ...retries continue + Note over Temporal: 2m elapsed — ScheduleToClose exceeded + Temporal-->>Workflow: ActivityError (schedule-to-close timeout) + Workflow->>Workflow: Handle SLA breach +``` + +The following describes each step: + +1. The two minute budget clock starts the moment the Workflow schedules the Activity. +2. Each attempt runs up to 30 seconds (`StartToCloseTimeout`). On failure, Temporal waits the backoff delay and retries. +3. Retries continue until either the Activity succeeds or the two minute budget is exhausted. +4. When the budget expires, Temporal delivers an `ActivityError` to the Workflow, which can log, alert, or compensate. + +## Implementation + +### Enforcing a 2-minute SLA + +Set both `schedule_to_close_timeout` (the total budget) and `start_to_close_timeout` (the per-attempt cap). +The retry policy controls the interval between attempts. +Temporal stops retrying automatically when the budget runs out. + + + + +```python +# workflows.py +from datetime import timedelta +from temporalio import workflow +from temporalio.common import RetryPolicy +from temporalio.exceptions import ActivityError, TimeoutError, TimeoutType +import activities + +@workflow.defn +class PaymentAuthWorkflow: + @workflow.run + async def run(self, transaction_id: str) -> str: + try: + return await workflow.execute_activity( + activities.authorize_transaction, + transaction_id, + schedule_to_close_timeout=timedelta(minutes=2), # total budget + start_to_close_timeout=timedelta(seconds=30), # per attempt + retry_policy=RetryPolicy( + initial_interval=timedelta(seconds=5), + backoff_coefficient=1.5, + maximum_interval=timedelta(seconds=30), + ), + ) + except ActivityError as e: + cause = e.__cause__ + if isinstance(cause, TimeoutError) and cause.type == TimeoutType.SCHEDULE_TO_CLOSE: + workflow.logger.error( + "Authorization failed — 2-minute SLA breached", + extra={"transaction_id": transaction_id}, + ) + raise +``` + + + + +```go +// workflow.go +package shipment + +import ( + "errors" + "time" + + enumspb "go.temporal.io/api/enums/v1" + "go.temporal.io/sdk/temporal" + "go.temporal.io/sdk/workflow" +) + +func PaymentAuthWorkflow(ctx workflow.Context, transactionID string) (string, error) { + ao := workflow.ActivityOptions{ + ScheduleToCloseTimeout: 2 * time.Minute, // total budget + StartToCloseTimeout: 30 * time.Second, // per attempt + RetryPolicy: &temporal.RetryPolicy{ + InitialInterval: 5 * time.Second, + BackoffCoefficient: 1.5, + MaximumInterval: 30 * time.Second, + }, + } + ctx = workflow.WithActivityOptions(ctx, ao) + + var result string + err := workflow.ExecuteActivity(ctx, AuthorizeTransaction, transactionID).Get(ctx, &result) + if err != nil { + var timeoutErr *temporal.TimeoutError + if errors.As(err, &timeoutErr) && timeoutErr.TimeoutType() == enumspb.TIMEOUT_TYPE_SCHEDULE_TO_CLOSE { + workflow.GetLogger(ctx).Error( + "Authorization failed — 2-minute SLA breached", + "transactionID", transactionID, + ) + } + return "", err + } + return result, nil +} +``` + + + + +```java +// ShipmentNotificationWorkflowImpl.java +import io.temporal.activity.ActivityOptions; +import io.temporal.api.enums.v1.TimeoutType; +import io.temporal.common.RetryOptions; +import io.temporal.failure.ActivityFailure; +import io.temporal.failure.TimeoutFailure; +import io.temporal.workflow.Workflow; +import java.time.Duration; + +public class PaymentAuthWorkflowImpl implements PaymentAuthWorkflow { + private final PaymentActivities activities = Workflow.newActivityStub( + PaymentActivities.class, + ActivityOptions.newBuilder() + .setScheduleToCloseTimeout(Duration.ofMinutes(2)) // total budget + .setStartToCloseTimeout(Duration.ofSeconds(30)) // per attempt + .setRetryOptions(RetryOptions.newBuilder() + .setInitialInterval(Duration.ofSeconds(5)) + .setBackoffCoefficient(1.5) + .setMaximumInterval(Duration.ofSeconds(30)) + .build()) + .build() + ); + + @Override + public String run(String transactionId) { + try { + return activities.authorizeTransaction(transactionId); + } catch (ActivityFailure e) { + if (e.getCause() instanceof TimeoutFailure tf + && tf.getTimeoutType() == TimeoutType.TIMEOUT_TYPE_SCHEDULE_TO_CLOSE) { + Workflow.getLogger(getClass()).error( + "Authorization failed — 2-minute SLA breached: " + transactionId, e + ); + } + throw e; + } + } +} +``` + + + + +```typescript +// workflows.ts +import * as wf from '@temporalio/workflow'; +import type * as activities from './activities'; + +const { authorizeTransaction } = wf.proxyActivities({ + scheduleToCloseTimeout: '2m', // total budget + startToCloseTimeout: '30s', // per attempt + retry: { + initialInterval: '5s', + backoffCoefficient: 1.5, + maximumInterval: '30s', + }, +}); + +export async function paymentAuthWorkflow(transactionId: string): Promise { + try { + return await authorizeTransaction(transactionId); + } catch (err) { + if (err instanceof wf.ActivityFailure) { + const cause = err.cause; + if (cause instanceof wf.TimeoutFailure && cause.type === wf.TimeoutType.SCHEDULE_TO_CLOSE) { + wf.log.error('Authorization failed — 2-minute SLA breached', { transactionId }); + } + } + throw err; + } +} +``` + + + + +### Short SLA without a per-attempt timeout + +For tighter budgets — such as a 30 second authorization window — you may omit `StartToCloseTimeout` and let `ScheduleToCloseTimeout` act as the only bound. +Temporal requires at least one timeout to be set; `ScheduleToCloseTimeout` alone satisfies that requirement. + + + + +```python +# workflows.py +result = await workflow.execute_activity( + activities.authorize_transaction, + transaction_id, + schedule_to_close_timeout=timedelta(seconds=30), + retry_policy=RetryPolicy( + initial_interval=timedelta(seconds=3), + backoff_coefficient=1.5, + ), +) +``` + + + + +```go +// workflow.go +ao := workflow.ActivityOptions{ + ScheduleToCloseTimeout: 30 * time.Second, + RetryPolicy: &temporal.RetryPolicy{ + InitialInterval: 3 * time.Second, + BackoffCoefficient: 1.5, + }, +} +``` + + + + +```java +// Workflow.java +ActivityOptions.newBuilder() + .setScheduleToCloseTimeout(Duration.ofSeconds(30)) + .setRetryOptions(RetryOptions.newBuilder() + .setInitialInterval(Duration.ofSeconds(3)) + .setBackoffCoefficient(1.5) + .build()) + .build() +``` + + + + +```typescript +// workflows.ts +const { authorizeTransaction } = wf.proxyActivities({ + scheduleToCloseTimeout: '30s', + retry: { + initialInterval: '3s', + backoffCoefficient: 1.5, + }, +}); +``` + + + + +## Best practices + +- **Set both timeouts for clarity.** Use `ScheduleToCloseTimeout` as the total SLA and `StartToCloseTimeout` as a per-attempt safety valve. Omitting `StartToCloseTimeout` means a single slow response can consume the entire budget. +- **Cap `MaximumInterval` well below the SLA.** If `MaximumInterval` is 2 hours and the SLA is 24 hours, only 12 retries are possible. Tune the interval so the backoff plateaus at a value that allows meaningful retries within the budget. +- **Handle `ActivityError` explicitly.** When the SLA expires, Temporal delivers an error to the Workflow. Catch it to send an alert, trigger a compensation, or record a breach in an audit log. +- **Distinguish SLA breaches from transient errors.** Inspect the error cause — check that the `ActivityError`'s cause is a `TimeoutError` with `TimeoutType.SCHEDULE_TO_CLOSE` (Python) or a `TimeoutFailure` with `TimeoutType.SCHEDULE_TO_CLOSE` (TypeScript) or `TIMEOUT_TYPE_SCHEDULE_TO_CLOSE` (Go/Java) to separate an SLA breach from an application failure. This lets you log or alert specifically on SLA violations rather than treating all activity errors the same way. + +## Common pitfalls + +- **Not accounting for `ScheduleToStart` delay in the budget.** `ScheduleToCloseTimeout` begins when the Activity is first scheduled, which includes the time the task waits in the queue before a Worker picks it up. Under high load or insufficient Worker capacity, tasks can sit in the queue for seconds or minutes before the first attempt starts — consuming SLA budget before any work is done. Provision Workers with enough capacity for peak traffic, or use autoscaling, to keep `ScheduleToStart` latency negligible relative to the SLA window. +- **Using `StartToCloseTimeout` alone for SLA enforcement.** A downstream system that responds slowly but never fully times out can keep resetting the per-attempt clock indefinitely. +- **Setting `ScheduleToCloseTimeout` shorter than `StartToCloseTimeout`.** If the total budget is shorter than a single attempt's maximum, the first attempt cannot finish within the budget — the Temporal Service times out the Activity Execution and returns an error before any attempt can succeed. +- **Ignoring the breach in the Workflow.** Letting the `ActivityError` propagate without handling it means SLA breaches go unlogged and uncompensated. +- **Not accounting for backoff delays in the budget.** The total time includes both attempt durations and the backoff delays between them. A 1-hour budget with a 30-minute initial interval and coefficient 2.0 leaves room for only one or two attempts. + +## Related patterns + +- [Fixed Count of Retries](/design-patterns/fixed-count-retries): Bound by attempt count rather than elapsed time. +- [Delayed Retry](/design-patterns/delayed-retry): Fixed-interval retry when the downstream unavailability window is known. +- [Error Handling & Retry Patterns](/design-patterns/error-handling-patterns): Overview and decision tree for all retry patterns. + +## References + +- [Activity Timeouts](https://temporal.io/blog/activity-timeouts) +- [Temporal Retry Policies](/encyclopedia/retry-policies) diff --git a/docs/design-patterns/index.mdx b/docs/design-patterns/index.mdx new file mode 100644 index 0000000000..074897f481 --- /dev/null +++ b/docs/design-patterns/index.mdx @@ -0,0 +1,267 @@ +--- +id: index +title: "Temporal Design Patterns" +sidebar_label: "Overview" +description: "A catalog of common, reusable, and proven design patterns for Temporal Workflows, organized by problem domain." +slug: /design-patterns +displayed_sidebar: designPatterns +--- + +import PatternCards from '@site/src/components/PatternCards'; + +Temporal provides a set of durable execution primitives that you can compose into common, reusable, and proven patterns. +Having these patterns in your toolbox helps you solve recurring problems in a battle-tested way. + +## Task orchestration patterns + + + +## Workflow messaging patterns + + + +## Entity & lifecycle patterns + + + +## External interaction patterns + + + +## Distributed transaction patterns + + + +## Error handling & retry patterns + + + +## Batch processing patterns + + + +## QoS & throughput patterns + + + +## Performance & latency patterns + + + +## Worker configuration patterns + + diff --git a/docs/design-patterns/local-activities.mdx b/docs/design-patterns/local-activities.mdx new file mode 100644 index 0000000000..c05594f363 --- /dev/null +++ b/docs/design-patterns/local-activities.mdx @@ -0,0 +1,206 @@ +--- +id: local-activities +title: "Local Activities" +sidebar_label: "Local Activities" +description: "Run Activity functions in-process inside the Workflow Task, eliminating all server scheduling round-trips. Best for short, idempotent Activities on a latency-sensitive path." +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +:::info[TLDR] +**Replace regular Activities with Local Activities to eliminate Temporal server round-trips and reduce per-Activity overhead to near zero.** This is best for short-lived, idempotent Activities that complete well within the Workflow Task timeout. Each Activity you convert saves approximately 50 ms of scheduling overhead on Temporal Cloud. If you haven't measured a latency problem, start with regular Activities—they are easier to debug, rate-limit, and monitor. +::: + +## Overview + +A **Local Activity** executes the Activity function directly inside the Worker process that is currently running the Workflow Task. The result is recorded as part of the same Workflow Task completion event, so no additional server calls occur between Activity invocations. + +```mermaid +sequenceDiagram + participant W as Worker + participant S as Temporal Server + + rect rgb(230, 235, 250) + Note over W,S: Regular Activity —
server round-trips on each call + W->>S: Schedule ActivityTask + S-->>W: Dispatch ActivityTask + W->>W: Execute activity + W->>S: Complete ActivityTask + S-->>W: Resume WorkflowTask + end + + rect rgb(220, 245, 225) + Note over W,S: Local Activity —
runs in-process, zero server calls + W->>W: Execute activity function directly + Note over W: Result bundled in WorkflowTask completion + end +``` + +**Numbered walkthrough:** + +1. A regular Activity follows a five-step exchange with the Temporal server: schedule, dispatch, execute, complete, then resume the Workflow Task. On Temporal Cloud, each round-trip adds approximately 50 ms. +2. A Local Activity bypasses all of that. The Workflow Task scheduler on the Worker invokes the Activity function directly. The result is folded into the WorkflowTask completion event sent at the end of the task. +3. For a Workflow with three serial Activities, switching all three to Local Activities can save 150 ms or more while keeping the exact same business logic. + +## Problem + +Workflows that call several short Activities in sequence accumulate significant latency from server scheduling overhead. A validation-and-reservation flow with three Activities may complete in 850 ms even when the actual computation takes only milliseconds. Every regular Activity incurs at least two server round-trips (schedule + complete), which becomes a bottleneck on latency-sensitive paths. + +## Solution + +Use `workflow.execute_local_activity` (Python), `proxyLocalActivities` (TypeScript), `workflow.ExecuteLocalActivity` (Go), or `Workflow.newLocalActivityStub` with `LocalActivityOptions` (Java) to run Activity functions in-process. The Activity executes inside the same Workflow Task and its result is available immediately—no scheduling overhead. + +Local Activities are subject to the Workflow Task timeout (default 10 seconds) rather than an independent start-to-close timeout. Always configure the `scheduleToCloseTimeout` option (not `startToCloseTimeout`) to set an upper bound. + + + + +```python +# workflows.py +from temporalio import workflow +from datetime import timedelta +from activities import validate_transaction, reserve_funds, settle_transaction + +LOCAL_ACTIVITY_TIMEOUT = timedelta(seconds=10) + +@workflow.defn +class TransactionWorkflow: + @workflow.run + async def run(self, req: TransactionRequest) -> Transaction: + # All three activities run in-process — no server round-trips. + tx = await workflow.execute_local_activity( + validate_transaction, req, + schedule_to_close_timeout=LOCAL_ACTIVITY_TIMEOUT, + ) + tx = await workflow.execute_local_activity( + reserve_funds, tx, + schedule_to_close_timeout=LOCAL_ACTIVITY_TIMEOUT, + ) + return await workflow.execute_local_activity( + settle_transaction, tx, + schedule_to_close_timeout=LOCAL_ACTIVITY_TIMEOUT, + ) +``` + + + + +```typescript +// workflows.ts +import { proxyLocalActivities } from "@temporalio/workflow"; +import type * as activities from "./activities"; + +const { validateTransaction, reserveFunds, settleTransaction } = + proxyLocalActivities({ scheduleToCloseTimeout: "10s" }); + +export async function transactionWorkflow( + req: TransactionRequest, +): Promise { + // All three activities run in-process — no server round-trips. + let tx = await validateTransaction(req); + tx = await reserveFunds(tx); + return settleTransaction(tx); +} +``` + + + + +```go +// workflows.go +func TransactionWorkflow(ctx workflow.Context, req TransactionRequest) (Transaction, error) { + localCtx := workflow.WithLocalActivityOptions(ctx, workflow.LocalActivityOptions{ + ScheduleToCloseTimeout: 10 * time.Second, + }) + + // All three activities run in-process — no server round-trips. + var tx Transaction + if err := workflow.ExecuteLocalActivity(localCtx, ValidateTransaction, req).Get(localCtx, &tx); err != nil { + return Transaction{}, err + } + if err := workflow.ExecuteLocalActivity(localCtx, ReserveFunds, tx).Get(localCtx, &tx); err != nil { + return Transaction{}, err + } + if err := workflow.ExecuteLocalActivity(localCtx, SettleTransaction, tx).Get(localCtx, &tx); err != nil { + return Transaction{}, err + } + return tx, nil +} +``` + + + + +```java +// TransactionWorkflow.java +public class Impl implements TransactionWorkflow { + // All activities run as local — no server round-trips. + private final Activities activities = Workflow.newLocalActivityStub( + Activities.class, + LocalActivityOptions.newBuilder() + .setScheduleToCloseTimeout(Duration.ofSeconds(10)) + .build() + ); + + @Override + public Shared.Transaction processTransaction(Shared.TransactionRequest req) { + Shared.Transaction tx = activities.validateTransaction(req); + tx = activities.reserveFunds(tx); + return activities.settleTransaction(tx); + } +} +``` + + + + +## When to use + +**Good fit:** + +- Short-lived Activities that complete in milliseconds or a few seconds +- Idempotent operations safe to re-execute if a Workflow Task fails +- Hot-path Workflows where end-to-end latency is a product requirement +- CPU-bound or in-memory computations that do not need a separate worker pool + +**Poor fit:** + +- Activities that may run longer than the Workflow Task timeout (default 10 seconds) +- Activities that require heartbeating to detect stuck executions +- Operations with long retry back-off intervals—retry timers still schedule server events, reducing the latency benefit +- Non-idempotent operations where re-execution on Worker crash would cause harm +- Operations that need rate limiting or routing through task queue capacity controls—Local Activities bypass the task queue entirely and cannot be throttled by the server + +## Benefits and trade-offs + +| | Regular Activity | Local Activity | +|---|---|---| +| Server round-trips | 2–4 per call | 0 | +| Latency overhead | ~50 ms per call (Temporal Cloud) | Near zero | +| Heartbeat support | Yes | No | +| Execution timeout | `StartToCloseTimeout` | `ScheduleToCloseTimeout` | +| Retry semantics | Independent per attempt | Entire Workflow Task re-executes on failure | +| Dedicated worker pool | Yes (separate poller) | No (shares Workflow Task thread) | +| Rate limiting / routing | Yes (task queue capacity) | No (bypasses task queue) | +| Visible in Temporal UI | Full Activity history event | Recorded in Workflow Task event; no standalone Activity task in UI | + +## Best practices + +- **Design for at-least-once execution.** If a Workflow Task fails after a Local Activity completes but before the task is persisted, all Local Activities in that task re-execute on the next attempt. Your Activity logic must tolerate this. +- **Keep each Local Activity short.** Aim for well under 5 seconds to leave headroom for retries within the same Workflow Task, which has a 10-second default timeout. +- **Avoid blocking signal and update handlers.** While a Local Activity executes, the Workflow Task is occupied. Incoming signals and updates accumulate in the server buffer and are not processed until the next task begins. +- **Set retry policy carefully.** Large `initialInterval` or `maximumInterval` values in a retry policy still cause the SDK to schedule server-side timer events, which partially defeats the latency benefit. + +## Common pitfalls + +- **Exceeding the Workflow Task timeout.** If a Local Activity takes longer than the Workflow Task timeout (default 10 seconds), the entire task times out and retries—including any Local Activities that already completed in memory during that task. +- **Assuming exactly-once semantics.** Unlike regular Activities, a Local Activity does not get its own persisted history event until the Workflow Task completes. A crashed Worker causes the whole task to re-run. This compounds when Local Activities are chained: if a Worker crashes after the third of five sequential Local Activities, all five re-execute on the next attempt. If you need a durable checkpoint between each step, use regular Activities instead. +- **Long retry intervals.** Each retry attempt with back-off creates a server-side timer event. For truly short Activities, use a tight `scheduleToCloseTimeout` and allow immediate retries rather than spaced-out back-off. + +## Related patterns + +- [Early Return + Local Activities](/design-patterns/early-return-local-activities) — adds an Update-with-Start early-response path on top of Local Activities for minimum first-response latency +- [Early Return](/design-patterns/early-return) — returns a response to the caller before the Workflow finishes, independent of Local Activities +- [Eager Workflow Start](/design-patterns/eager-workflow-start) — eliminates the server Matching step when starting a Workflow for additional latency reduction +- [Long Running Activity](/design-patterns/long-running-activity) — the right choice when Activities need heartbeating and long execution windows diff --git a/docs/design-patterns/long-running-activity.mdx b/docs/design-patterns/long-running-activity.mdx new file mode 100644 index 0000000000..bf41023b21 --- /dev/null +++ b/docs/design-patterns/long-running-activity.mdx @@ -0,0 +1,619 @@ +--- +id: long-running-activity +title: "Long-Running Activity - Tracking Progress and Handling Cancellation with Heartbeats" +sidebar_label: "Long Running Activity" +description: "Long-running Activities report progress via heartbeats and enable resumption after failures with cancellation support." +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +## Overview + +The Activity Heartbeat pattern enables long-running Activities to report progress, handle cancellation gracefully, and resume from the last checkpoint after failures. +Heartbeats inform Temporal that the Activity is still alive and allow storing progress details that survive Worker restarts. + +## Problem + +In long-running operations, you often need Activities that process large datasets or perform time-consuming operations (minutes to hours), report progress to avoid appearing stuck or timing out, resume from the last checkpoint after Worker crashes or restarts, handle cancellation requests gracefully and clean up resources, and avoid reprocessing already-completed work. + +Without heartbeats, you must set very long Activity timeouts that delay failure detection, reprocess entire batches from the beginning on failures, accept no visibility into Activity progress, risk zombie Activities that appear alive but are stuck, and implement custom checkpointing and recovery logic. + +## Solution + +Activity heartbeats periodically report progress to the Temporal Service. +The heartbeat details are persisted and available to retry attempts, enabling resumption from the last checkpoint. +Heartbeat timeouts detect stuck Activities faster than execution timeouts. + +```mermaid +sequenceDiagram + participant Workflow + participant Activity + participant Temporal + + Workflow->>+Activity: Start (with heartbeat timeout) + loop Process items + Activity->>Activity: Process item + Activity->>Temporal: heartbeat(progress) + Note over Temporal: Store progress + end + + alt Activity completes + Activity-->>-Workflow: Result + else Worker crashes + Note over Activity: Heartbeat timeout expires + Temporal->>+Activity: Retry on new worker + Activity->>Temporal: getHeartbeatDetails() + Temporal-->>Activity: Last progress + Activity->>Activity: Resume from checkpoint + Activity-->>-Workflow: Result + end +``` + +The following describes each step in the diagram: + +1. The Workflow starts the Activity with a heartbeat timeout. +2. The Activity processes items in a loop, heartbeating progress after each batch. +3. If the Activity completes normally, it returns the result to the Workflow. +4. If the Worker crashes, the heartbeat timeout expires and Temporal retries the Activity on a new Worker. The new attempt retrieves the last heartbeat details and resumes from the checkpoint. + +## Implementation + +### Basic progress tracking + +The following implementation processes a large file line by line, heartbeating every 100 lines. +On retry, it retrieves the last processed line number and skips ahead: + + + + +```python +# activities.py +from temporalio import activity + +@activity.defn +async def process_large_file(file_path: str) -> None: + details = activity.info().heartbeat_details + start_line = details[0] if details else 0 + + with open(file_path, "r") as f: + for i, line in enumerate(f): + if i < start_line: + continue + + process_line(line) + + if (i + 1) % 100 == 0: + activity.heartbeat(i + 1) +``` + + + + +```go +// activities.go +func ProcessLargeFile(ctx context.Context, filePath string) error { + startLine := 0 + if activity.HasHeartbeatDetails(ctx) { + if err := activity.GetHeartbeatDetails(ctx, &startLine); err != nil { + return err + } + } + + file, err := os.Open(filePath) + if err != nil { + return err + } + defer file.Close() + + scanner := bufio.NewScanner(file) + currentLine := 0 + for scanner.Scan() { + if currentLine < startLine { + currentLine++ + continue + } + + processLine(scanner.Text()) + currentLine++ + + if currentLine%100 == 0 { + activity.RecordHeartbeat(ctx, currentLine) + } + } + return scanner.Err() +} +``` + + + + +```java +// FileProcessingActivityImpl.java +@ActivityInterface +public interface FileProcessingActivity { + void processLargeFile(String filePath); +} + +public class FileProcessingActivityImpl implements FileProcessingActivity { + @Override + public void processLargeFile(String filePath) { + ActivityExecutionContext context = Activity.getExecutionContext(); + Optional lastProcessedLine = context.getHeartbeatDetails(Integer.class); + int startLine = lastProcessedLine.orElse(0); + + try (BufferedReader reader = new BufferedReader(new FileReader(filePath))) { + for (int i = 0; i < startLine; i++) { + reader.readLine(); + } + + String line; + int currentLine = startLine; + while ((line = reader.readLine()) != null) { + processLine(line); + currentLine++; + + if (currentLine % 100 == 0) { + context.heartbeat(currentLine); + } + } + } + } +} +``` + + + + +```typescript +// activities.ts +import { heartbeat, activityInfo } from '@temporalio/activity'; +import { createReadStream } from 'fs'; +import { createInterface } from 'readline'; + +export async function processLargeFile(filePath: string): Promise { + const startLine = activityInfo().heartbeatDetails ?? 0; + + const rl = createInterface({ + input: createReadStream(filePath), + }); + + let currentLine = 0; + for await (const line of rl) { + if (currentLine < startLine) { + currentLine++; + continue; + } + + processLine(line); + currentLine++; + + if (currentLine % 100 === 0) { + heartbeat(currentLine); + } + } +} +``` + + + + +The heartbeat details call retrieves the last heartbeat value from a previous attempt. +If this is the first attempt, there are no details and the Activity starts from line 0. +The Activity heartbeats every 100 lines, storing the current line number as the checkpoint. + +### Handle cancellation + +The following implementation adds cancellation support. +The Activity checks for cancellation on each heartbeat and cleans up resources before exiting: + + + + +```python +# activities.py +import asyncio +from temporalio import activity + +@activity.defn +async def process_large_file(file_path: str) -> None: + details = activity.info().heartbeat_details + current_line = details[0] if details else 0 + + try: + with open(file_path, "r") as f: + for i, line in enumerate(f): + if i < current_line: + continue + + activity.heartbeat(i) + process_line(line) + current_line = i + 1 + except asyncio.CancelledError: + cleanup_resources() + raise +``` + + + + +```go +// activities.go +func ProcessLargeFile(ctx context.Context, filePath string) error { + currentLine := 0 + if activity.HasHeartbeatDetails(ctx) { + if err := activity.GetHeartbeatDetails(ctx, ¤tLine); err != nil { + return err + } + } + + file, err := os.Open(filePath) + if err != nil { + return err + } + defer file.Close() + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + if currentLine > 0 { + currentLine-- + continue + } + + activity.RecordHeartbeat(ctx, currentLine) + + // Check if the Activity has been cancelled + select { + case <-ctx.Done(): + cleanupResources() + return ctx.Err() + default: + } + + processLine(scanner.Text()) + currentLine++ + } + return scanner.Err() +} +``` + + + + +```java +// FileProcessingActivityImpl.java +public class FileProcessingActivityImpl implements FileProcessingActivity { + @Override + public void processLargeFile(String filePath) { + ActivityExecutionContext context = Activity.getExecutionContext(); + Optional lastProcessedLine = context.getHeartbeatDetails(Integer.class); + int currentLine = lastProcessedLine.orElse(0); + + try (BufferedReader reader = new BufferedReader(new FileReader(filePath))) { + for (int i = 0; i < currentLine; i++) { + reader.readLine(); + } + + String line; + while ((line = reader.readLine()) != null) { + context.heartbeat(currentLine); + processLine(line); + currentLine++; + } + } catch (ActivityCompletionException e) { + cleanupResources(); + throw e; + } + } +} +``` + + + + +```typescript +// activities.ts +import { heartbeat, activityInfo, sleep } from '@temporalio/activity'; +import { CancelledFailure } from '@temporalio/common'; +import { createReadStream } from 'fs'; +import { createInterface } from 'readline'; + +export async function processLargeFile(filePath: string): Promise { + const startLine = activityInfo().heartbeatDetails ?? 0; + + const rl = createInterface({ + input: createReadStream(filePath), + }); + + let currentLine = 0; + try { + for await (const line of rl) { + if (currentLine < startLine) { + currentLine++; + continue; + } + + heartbeat(currentLine); + processLine(line); + currentLine++; + } + } catch (err) { + if (err instanceof CancelledFailure) { + cleanupResources(); + } + throw err; + } +} +``` + + + + +Cancellation is delivered to the Activity when it heartbeats. +In Java, the next `heartbeat()` call throws an `ActivityCompletionException` (an `ActivityCanceledException` for cancellation). +In TypeScript, cancellation is delivered as a `CancelledFailure` via `sleep()` or `Context.current().cancelled`. +In Python, cancellation is delivered as an `asyncio.CancelledError`. +In Go, the context is cancelled and `ctx.Done()` becomes readable. +The catch/error handling block performs cleanup before re-throwing the error. + +### Complex progress state + +The following implementation tracks multiple progress fields -- processed count, failed count, and the last processed ID: + + + + +```python +# activities.py +from dataclasses import dataclass +from temporalio import activity + +@dataclass +class ProgressState: + processed_count: int = 0 + failed_count: int = 0 + last_processed_id: str = "" + +@activity.defn +async def process_batch(item_ids: list[str]) -> dict: + details = activity.info().heartbeat_details + progress = details[0] if details else ProgressState() + + start_index = ( + item_ids.index(progress.last_processed_id) + 1 + if progress.last_processed_id + else 0 + ) + + for i in range(start_index, len(item_ids)): + item_id = item_ids[i] + + try: + await process_item(item_id) + progress.processed_count += 1 + except Exception: + progress.failed_count += 1 + + progress.last_processed_id = item_id + activity.heartbeat(progress) + + return { + "processed_count": progress.processed_count, + "failed_count": progress.failed_count, + } +``` + + + + +```go +// activities.go +type ProgressState struct { + ProcessedCount int `json:"processedCount"` + FailedCount int `json:"failedCount"` + LastProcessedID string `json:"lastProcessedId"` +} + +type BatchResult struct { + ProcessedCount int `json:"processedCount"` + FailedCount int `json:"failedCount"` +} + +func ProcessBatch(ctx context.Context, itemIDs []string) (BatchResult, error) { + progress := ProgressState{} + if activity.HasHeartbeatDetails(ctx) { + if err := activity.GetHeartbeatDetails(ctx, &progress); err != nil { + return BatchResult{}, err + } + } + + startIndex := 0 + if progress.LastProcessedID != "" { + for i, id := range itemIDs { + if id == progress.LastProcessedID { + startIndex = i + 1 + break + } + } + } + + for i := startIndex; i < len(itemIDs); i++ { + itemID := itemIDs[i] + + if err := processItem(ctx, itemID); err != nil { + progress.FailedCount++ + } else { + progress.ProcessedCount++ + } + + progress.LastProcessedID = itemID + activity.RecordHeartbeat(ctx, progress) + } + + return BatchResult{ + ProcessedCount: progress.ProcessedCount, + FailedCount: progress.FailedCount, + }, nil +} +``` + + + + +```java +// BatchProcessingActivityImpl.java +public class BatchProcessingActivityImpl implements BatchProcessingActivity { + + static class ProgressState { + int processedCount; + int failedCount; + String lastProcessedId; + } + + @Override + public BatchResult processBatch(List itemIds) { + ActivityExecutionContext context = Activity.getExecutionContext(); + Optional details = context.getHeartbeatDetails(ProgressState.class); + ProgressState progress = details.orElse(new ProgressState()); + + int startIndex = itemIds.indexOf(progress.lastProcessedId) + 1; + + for (int i = startIndex; i < itemIds.size(); i++) { + String itemId = itemIds.get(i); + + try { + processItem(itemId); + progress.processedCount++; + } catch (Exception e) { + progress.failedCount++; + } + + progress.lastProcessedId = itemId; + context.heartbeat(progress); + } + + return new BatchResult(progress.processedCount, progress.failedCount); + } +} +``` + + + + +```typescript +// activities.ts +import { heartbeat, activityInfo } from '@temporalio/activity'; + +interface ProgressState { + processedCount: number; + failedCount: number; + lastProcessedId: string; +} + +export async function processBatch(itemIds: string[]): Promise { + const saved: ProgressState = activityInfo().heartbeatDetails ?? { + processedCount: 0, + failedCount: 0, + lastProcessedId: '', + }; + + const startIndex = saved.lastProcessedId + ? itemIds.indexOf(saved.lastProcessedId) + 1 + : 0; + + const progress = { ...saved }; + + for (let i = startIndex; i < itemIds.length; i++) { + const itemId = itemIds[i]; + + try { + await processItem(itemId); + progress.processedCount++; + } catch { + progress.failedCount++; + } + + progress.lastProcessedId = itemId; + heartbeat(progress); + } + + return { processedCount: progress.processedCount, failedCount: progress.failedCount }; +} +``` + + + + +The progress state object stores all the checkpoint data needed to resume. +On retry, the Activity finds the index of the last processed ID and starts from the next item. +Each heartbeat stores the full progress state, so the next attempt has everything it needs to resume. + +## When to use + +The Heartbeat pattern is a good fit for batch processing of large datasets, file uploads and downloads with progress tracking, database migrations or bulk operations, long-running computations (ML training, video encoding), external API polling with multiple attempts, and any Activity running longer than 30 seconds. + +It is not a good fit for quick operations (under 10 seconds), operations that cannot be checkpointed, Activities requiring exact-once semantics without idempotency, or real-time streaming (use Workflows instead). + +## Benefits and trade-offs + +Heartbeats enable fault tolerance by resuming from the last checkpoint after failures. +Heartbeat timeouts detect stuck Activities faster than execution timeouts. +You gain visibility into Activity progress in real-time. +Activities can handle cancellation gracefully and clean up resources. +Completed work is not reprocessed, and Activities can move between Workers. + +The trade-offs to consider are that frequent heartbeats increase network traffic. +You must implement checkpointing logic and state management. +You must handle partial reprocessing of the last checkpoint (idempotency). +You need to balance heartbeat frequency between responsiveness and overhead. +Heartbeat details have size limits, so you should avoid large objects. + +## Comparison with alternatives + +| Approach | Progress tracking | Resumable | Cancellation | Complexity | +| :--- | :--- | :--- | :--- | :--- | +| Heartbeat | Yes | Yes | Graceful | Medium | +| Long Timeout | No | No | Delayed | Low | +| Child Workflows | Yes | Yes | Immediate | High | +| Local Activity | No | No | N/A | Low | + +## Best practices + +- **Set heartbeat timeout.** Configure to 2-3x the expected heartbeat interval. +- **Heartbeat at regular intervals.** Balance between responsiveness (every 10-30 seconds) and overhead. +- **Checkpoint strategically.** Save progress at meaningful boundaries (records, pages, chunks). +- **Keep details small.** Store minimal state (IDs, offsets, counts), not full objects. +- **Handle idempotency.** Ensure reprocessing the last checkpoint is safe. +- **Check cancellation.** Heartbeat regularly to detect cancellation quickly. +- **Clean up on cancel.** Handle cancellation errors appropriately: catch `ActivityCompletionException` (Java), `CancelledFailure` (TypeScript), `asyncio.CancelledError` (Python), or check `ctx.Done()` (Go). +- **Log progress.** Log heartbeat details for debugging and monitoring. +- **Test resumption.** Verify Activities resume correctly after simulated failures. +- **Avoid heartbeat spam.** Do not heartbeat on every iteration of tight loops. + +## Common pitfalls + +- **Missing HeartbeatTimeout.** Without a HeartbeatTimeout, Temporal cannot detect a stuck or crashed Worker until the StartToCloseTimeout expires. Always set HeartbeatTimeout shorter than StartToCloseTimeout. +- **Heartbeating too infrequently.** Cancellation is only delivered on the next heartbeat. If the Activity heartbeats every 5 minutes, cancellation takes up to 5 minutes to propagate. +- **Not resuming from heartbeat progress on retry.** When an Activity retries, retrieve the last heartbeat details -- `context.getHeartbeatDetails()` (Java), `activityInfo().heartbeatDetails` (TypeScript), `activity.info().heartbeat_details` (Python), or `activity.GetHeartbeatDetails()` (Go) -- and resume from the last checkpoint instead of restarting from scratch. +- **Catching the wrong exception for cancellation.** Cancellation is SDK-specific. Inside the Activity, the `heartbeat()` call throws an `ActivityCompletionException` (Java), cancellation surfaces as a `CancelledFailure` (TypeScript) or an `asyncio.CancelledError` (Python), and the context reports `ctx.Err()` returning `context.Canceled` (Go). The `CanceledFailure` type is what the Workflow observes as the cause of the resulting `ActivityFailure`, not what the Activity body catches. + +## Related patterns + +- **[Saga Pattern](/design-patterns/saga-pattern)**: Compensating transactions with long-running steps. +- **[Polling](/design-patterns/polling)**: Heartbeating Activity for frequent polling. + +## Sample code + +### Java +- [Heartbeating Activity Batch](https://github.com/temporalio/samples-java/tree/main/core/src/main/java/io/temporal/samples/batch/heartbeatingactivity) -- Complete batch processing implementation. +- [Auto-Heartbeating](https://github.com/temporalio/samples-java/tree/main/core/src/main/java/io/temporal/samples/autoheartbeat) -- Automatic heartbeating via interceptor. + +### TypeScript +- [Activities Cancellation and Heartbeating](https://github.com/temporalio/samples-typescript/tree/main/activities-cancellation-heartbeating) -- Activity cancellation and heartbeat-based resumption. + +### Python +- [Hello Cancellation](https://github.com/temporalio/samples-python/blob/main/hello/hello_cancellation.py) -- Activity heartbeating with cancellation handling. +- [Custom Decorator Heartbeat](https://github.com/temporalio/samples-python/blob/main/custom_decorator/activity_utils.py) -- Automatic heartbeating via decorator. + +### Go +- [Cancellation](https://github.com/temporalio/samples-go/tree/main/cancellation) -- Workflow and Activity cancellation with heartbeating. diff --git a/docs/design-patterns/mapreduce-tree.mdx b/docs/design-patterns/mapreduce-tree.mdx new file mode 100644 index 0000000000..c56aca3bde --- /dev/null +++ b/docs/design-patterns/mapreduce-tree.mdx @@ -0,0 +1,439 @@ +--- +id: mapreduce-tree +title: "MapReduce Tree" +sidebar_label: "MapReduce Tree" +description: "Recursively splits a dataset into a binary tree of Child Workflows, processes leaves in parallel, then aggregates results back up the tree." +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +:::info[TLDR] +Recursively split a record set into a tree of child Workflows — each node fans out to N sub-slices (two by default) — process every leaf in parallel, and signal results back up the tree to the root. Use this when you need **maximum throughput** for an embarrassingly parallel workload and downstream systems can absorb an unbounded burst of concurrent requests. +::: + +## Overview + +The MapReduce Tree pattern processes a large record set with maximum parallelism by recursively splitting it into smaller chunks and distributing each chunk to a child Workflow. Results are signalled back up the tree to the parent. It is best suited for embarrassingly parallel workloads where speed matters more than rate limiting. + +## Problem + +Both the [Batch Iterator](/design-patterns/batch-iterator) and [Sliding Window](/design-patterns/sliding-window) patterns bound concurrency, which limits throughput. When you need to process a large record set as fast as possible and downstream systems can handle the load, you want to fan out work across as many concurrent processors as possible without a fixed window. + +You also need a way to handle record sets larger than what a single Workflow's concurrency limits allow, without pre-partitioning data into fixed chunks before the job starts. + +## Solution + +A Node Workflow receives a slice of records. If the slice is small enough (at or below a configurable `leafThreshold`), it starts one Leaf Workflow per record. Otherwise it splits the slice into `n` sub-slices and starts `n` Node child Workflows recursively. + +Each Leaf Workflow runs the actual processing Activity and signals its result back to its parent Node. Each Node aggregates the results it receives and signals them up to its own parent. The Root Node returns the final aggregated result. + +```mermaid +flowchart TD + Records["📋 Full record set"] + Root["Root Node Workflow\n(depth=0)"] + Node1["Node Workflow\n(depth=1, chunk 1)"] + Node2["Node Workflow\n(depth=1, chunk 2)"] + L1["Leaf Workflow\n(record A)"] + L2["Leaf Workflow\n(record B)"] + L3["Leaf Workflow\n(record C)"] + L4["Leaf Workflow\n(record D)"] + L5["Leaf Workflow\n(record E)"] + L6["Leaf Workflow\n(record F)"] + + Records --> Root + Root -->|"split → chunk 1"| Node1 + Root -->|"split → chunk 2"| Node2 + + Node1 --> L1 + Node1 --> L2 + Node1 --> L3 + + Node2 --> L4 + Node2 --> L5 + Node2 --> L6 + + L1 -->|"Signal result"| Node1 + L2 -->|"Signal result"| Node1 + L3 -->|"Signal result"| Node1 + + L4 -->|"Signal result"| Node2 + L5 -->|"Signal result"| Node2 + L6 -->|"Signal result"| Node2 + + Node1 -->|"Signal result"| Root + Node2 -->|"Signal result"| Root +``` + +The following describes each step in the diagram: + +1. The Root Node Workflow receives the full record set and `depth=0`. +2. Because the record set is larger than `leafThreshold`, the Root splits it into N chunks and starts N child Node Workflows (two in this example, but the split factor is configurable). +3. Each Node Workflow receives its chunk and checks its size against `leafThreshold`. In this example, each chunk is small enough, so each Node starts one Leaf Workflow per record. +4. Each Leaf Workflow calls the `processLeaf` Activity and, when complete, signals its result back to its parent Node using `signalExternalWorkflow`. +5. Each Node collects all leaf results via signal handlers, aggregates them, and signals the aggregated result back to the Root. +6. The Root collects both node results and returns the final aggregate. + +## Implementation + +The following examples show how each SDK implements the MapReduce Tree pattern. + + + + +```typescript +// workflows.ts +import { + condition, + defineSignal, + getExternalWorkflowHandle, + proxyActivities, + setHandler, + startChild, + workflowInfo, +} from "@temporalio/workflow"; +import type * as activities from "./activities"; +import { TASK_QUEUE, LEAF_THRESHOLD, MAX_DEPTH, RESULT_SIGNAL } from "./shared"; + +const { processLeaf } = proxyActivities({ + startToCloseTimeout: "30 seconds", +}); + +interface ResultPayload { + id: string; + results: string[]; +} + +export const resultSignal = defineSignal<[ResultPayload]>(RESULT_SIGNAL); + +export async function leafWorkflow( + record: string, + parentWorkflowId: string +): Promise { + const result = await processLeaf(record); + // Signal result back to parent node. + const parent = getExternalWorkflowHandle(parentWorkflowId); + await parent.signal(resultSignal, { id: record, results: [result] }); +} + +export async function nodeWorkflow( + records: string[], + depth: number = 0, + parentWorkflowId: string = "" +): Promise { + if (depth > MAX_DEPTH) { + throw new Error(`Tree depth exceeded ${MAX_DEPTH}`); + } + + const myId = workflowInfo().workflowId; + const collectedResults: string[] = []; + let received = 0; + let expected = 0; + + setHandler(resultSignal, (payload: ResultPayload) => { + collectedResults.push(...payload.results); + received++; + }); + + if (records.length <= LEAF_THRESHOLD) { + // Start one leaf per record. + expected = records.length; + for (const record of records) { + void startChild(leafWorkflow, { + args: [record, myId], + workflowId: `${myId}/leaf-${record}`, + taskQueue: TASK_QUEUE, + }); + } + } else { + // Split and recurse. + const mid = Math.floor(records.length / 2); + const chunks = [records.slice(0, mid), records.slice(mid)]; + expected = chunks.length; + for (let i = 0; i < chunks.length; i++) { + void startChild(nodeWorkflow, { + args: [chunks[i], depth + 1, myId], + workflowId: `${myId}/node-d${depth + 1}-${i}`, + taskQueue: TASK_QUEUE, + }); + } + } + + // Wait until all expected signals have arrived. + await condition(() => received >= expected); + + // Signal aggregated results up to parent (if this is not the root). + if (parentWorkflowId) { + const parent = getExternalWorkflowHandle(parentWorkflowId); + await parent.signal(resultSignal, { id: myId, results: collectedResults }); + } + + return collectedResults; +} +``` + + + + +```python +# workflows.py +from dataclasses import dataclass +from datetime import timedelta +from temporalio import workflow +from activities import process_leaf +from shared import TASK_QUEUE, LEAF_THRESHOLD, MAX_DEPTH, RESULT_SIGNAL + + +@dataclass +class ResultPayload: + id: str + results: list[str] + + +@workflow.defn +class LeafWorkflow: + @workflow.run + async def run(self, record: str, parent_workflow_id: str) -> None: + result = await workflow.execute_activity( + process_leaf, + record, + start_to_close_timeout=timedelta(seconds=30), + ) + parent = workflow.get_external_workflow_handle(parent_workflow_id) + await parent.signal(RESULT_SIGNAL, ResultPayload(id=record, results=[result])) + + +@workflow.defn +class NodeWorkflow: + def __init__(self) -> None: + self._collected: list[str] = [] + self._received = 0 + + @workflow.signal(name=RESULT_SIGNAL) + def node_result(self, payload: ResultPayload) -> None: + self._collected.extend(payload.results) + self._received += 1 + + @workflow.run + async def run( + self, + records: list[str], + depth: int = 0, + parent_workflow_id: str = "", + ) -> list[str]: + if depth > MAX_DEPTH: + raise RuntimeError(f"Tree depth exceeded {MAX_DEPTH}") + + my_id = workflow.info().workflow_id + expected = 0 + + if len(records) <= LEAF_THRESHOLD: + expected = len(records) + for record in records: + await workflow.start_child_workflow( + LeafWorkflow.run, + args=[record, my_id], + id=f"{my_id}/leaf-{record}", + task_queue=TASK_QUEUE, + ) + else: + mid = len(records) // 2 + chunks = [records[:mid], records[mid:]] + expected = len(chunks) + for i, chunk in enumerate(chunks): + await workflow.start_child_workflow( + NodeWorkflow.run, + args=[chunk, depth + 1, my_id], + id=f"{my_id}/node-d{depth+1}-{i}", + task_queue=TASK_QUEUE, + ) + + await workflow.wait_condition(lambda: self._received >= expected) + + if parent_workflow_id: + parent = workflow.get_external_workflow_handle(parent_workflow_id) + await parent.signal( + RESULT_SIGNAL, ResultPayload(id=my_id, results=self._collected) + ) + + return self._collected +``` + + + + +```go +// workflows.go +package main + +import ( + "fmt" + "time" + + "go.temporal.io/sdk/workflow" +) + +// ResultSignal and ResultPayload are declared in shared.go. + +func LeafWorkflow(ctx workflow.Context, record string, parentWorkflowID string) error { + ao := workflow.ActivityOptions{StartToCloseTimeout: 30 * time.Second} + ctx = workflow.WithActivityOptions(ctx, ao) + + var result string + if err := workflow.ExecuteActivity(ctx, ProcessLeaf, record).Get(ctx, &result); err != nil { + return err + } + + payload := ResultPayload{ID: record, Results: []string{result}} + return workflow.SignalExternalWorkflow(ctx, parentWorkflowID, "", ResultSignal, payload).Get(ctx, nil) +} + +func NodeWorkflow(ctx workflow.Context, records []string, depth int, parentWorkflowID string) ([]string, error) { + if depth > MaxDepth { + return nil, fmt.Errorf("tree depth exceeded %d", MaxDepth) + } + + myID := workflow.GetInfo(ctx).WorkflowExecution.ID + resultCh := workflow.GetSignalChannel(ctx, ResultSignal) + + var collected []string + expected := 0 + + if len(records) <= LeafThreshold { + expected = len(records) + for _, record := range records { + cwo := workflow.ChildWorkflowOptions{ + WorkflowID: myID + "/leaf-" + record, + TaskQueue: TaskQueue, + } + workflow.ExecuteChildWorkflow(workflow.WithChildOptions(ctx, cwo), LeafWorkflow, record, myID) + } + } else { + mid := len(records) / 2 + chunks := [][]string{records[:mid], records[mid:]} + expected = len(chunks) + for i, chunk := range chunks { + cwo := workflow.ChildWorkflowOptions{ + WorkflowID: fmt.Sprintf("%s/node-d%d-%d", myID, depth+1, i), + TaskQueue: TaskQueue, + } + workflow.ExecuteChildWorkflow(workflow.WithChildOptions(ctx, cwo), NodeWorkflow, chunk, depth+1, myID) + } + } + + for i := 0; i < expected; i++ { + var payload ResultPayload + resultCh.Receive(ctx, &payload) + collected = append(collected, payload.Results...) + } + + if parentWorkflowID != "" { + payload := ResultPayload{ID: myID, Results: collected} + if err := workflow.SignalExternalWorkflow(ctx, parentWorkflowID, "", ResultSignal, payload).Get(ctx, nil); err != nil { + return collected, err + } + } + + return collected, nil +} +``` + + + + +```java +// NodeWorkflow.java +import io.temporal.workflow.*; +import java.util.*; + +@WorkflowInterface +public interface NodeWorkflow { + @WorkflowMethod + List run(List records, int depth, String parentWorkflowId); + + @SignalMethod + void nodeResult(String id, List results); +} + +// NodeWorkflowImpl.java +public class NodeWorkflowImpl implements NodeWorkflow { + private final List collected = new ArrayList<>(); + private int received = 0; + + @Override + public void nodeResult(String id, List results) { + collected.addAll(results); + received++; + } + + @Override + public List run(List records, int depth, String parentWorkflowId) { + if (depth > Shared.MAX_DEPTH) { + throw new RuntimeException("Tree depth exceeded " + Shared.MAX_DEPTH); + } + + String myId = Workflow.getInfo().getWorkflowId(); + int expected; + + if (records.size() <= Shared.LEAF_THRESHOLD) { + for (String record : records) { + ChildWorkflowOptions opts = ChildWorkflowOptions.newBuilder() + .setWorkflowId(myId + "/leaf-" + record) + .setTaskQueue(Shared.TASK_QUEUE) + .build(); + LeafWorkflow leaf = Workflow.newChildWorkflowStub(LeafWorkflow.class, opts); + Async.procedure(leaf::run, record, myId); + } + expected = records.size(); + } else { + int mid = records.size() / 2; + List> chunks = List.of(records.subList(0, mid), records.subList(mid, records.size())); + for (int i = 0; i < chunks.size(); i++) { + ChildWorkflowOptions opts = ChildWorkflowOptions.newBuilder() + .setWorkflowId(String.format("%s/node-d%d-%d", myId, depth + 1, i)) + .setTaskQueue(Shared.TASK_QUEUE) + .build(); + NodeWorkflow child = Workflow.newChildWorkflowStub(NodeWorkflow.class, opts); + Async.function(child::run, chunks.get(i), depth + 1, myId); + } + expected = chunks.size(); + } + + final int exp = expected; + Workflow.await(() -> received >= exp); + + if (parentWorkflowId != null && !parentWorkflowId.isEmpty()) { + ExternalWorkflowStub parent = Workflow.newUntypedExternalWorkflowStub(parentWorkflowId, ""); + parent.signal(Shared.RESULT_SIGNAL, myId, new ArrayList<>(collected)); + } + + return collected; + } +} +``` + + + + +## Best practices + +- **Set a `leafThreshold` to control tree depth.** A threshold of 3–10 records per leaf is typical. Too small a threshold creates excessive Workflow overhead; too large prevents full parallelism. +- **Set a `MAX_DEPTH` guard.** Recursive fan-out without a depth limit can produce extremely deep trees for large record sets. Fail fast if depth exceeds your expected maximum (e.g. `log2(totalRecords / leafThreshold) + 2`). +- **Avoid external writes in Node Workflows.** Node Workflows only aggregate results from children. Leaf Workflows perform the actual work. Keeping the roles separate prevents duplicate external writes if a Node is retried. +- **Use signals for result aggregation, not return values.** A parent cannot directly await a child started in a previous Workflow run. Signals decouple the result delivery from the parent-child lifetime, making the pattern resilient to replays. +- **Skip the reduce phase if results are not needed.** If you only need the side effects of processing each record (writes to a database, messages sent), omit the signal-back entirely and set `PARENT_CLOSE_POLICY_ABANDON` on all children. +- **Consider replacing Leaf Workflows with Activities for lighter workloads.** Leaf Workflows give each record its own Event History, independent cancellation, and dedicated visibility in the UI — useful when per-record observability matters. If those properties are not required, executing the `processLeaf` Activity directly from a Node Workflow reduces overhead. A successful child workflow and a successful Activity each add three Events to the parent's history, but a Leaf Workflow also maintains a separate Event History of its own and emits an extra signal back to the Node, so child workflows produce more overall Events than Activities. The Temporal documentation recommends starting with Activities and adopting child workflows only when there is a clear need. + +## Common pitfalls + +- **Thundering herd.** The MapReduce Tree fans out exponentially. For large record sets, all leaf Activities start nearly simultaneously. Ensure your downstream system can absorb the burst, or switch to [Sliding Window](/design-patterns/sliding-window) for rate limiting. +- **Signal storms.** If thousands of leaves all signal a single Node at the same time, the Node's signal queue can become a bottleneck. A two-level tree (Root → Nodes → Leaves) distributes this load; a deeper tree helps even more. +- **History bloat in the Root Workflow.** Each child start and signal received adds events to the Root's history. For very large record sets, consider adding an extra tree level to keep the Root from receiving too many direct signals. +- **Attempting external/downstream writes from Node Workflows.** Nodes may be retried. Any external write in a Node Workflow will be executed multiple times. Keep all side effects in Leaf Workflows (or Activities called by Leaves). + +## Related resources + +- [Fan-Out with Child Workflows](/design-patterns/fanout-child-workflows) — simpler flat fan-out for smaller record sets +- [Sliding Window](/design-patterns/sliding-window) — bounded concurrency with rate limiting +- [Child Workflows pattern](/design-patterns/child-workflows) — core concepts for parent/child coordination +- [Temporal limits reference](/cloud/limits) diff --git a/docs/design-patterns/non-retryable-errors.mdx b/docs/design-patterns/non-retryable-errors.mdx new file mode 100644 index 0000000000..e566addfd1 --- /dev/null +++ b/docs/design-patterns/non-retryable-errors.mdx @@ -0,0 +1,486 @@ +--- +id: non-retryable-errors +title: "Non-Retryable Errors" +sidebar_label: "Non-Retryable Errors" +description: "Mark error types that will never succeed — such as validation failures or missing records — so Temporal fails fast instead of retrying indefinitely." +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +:::info[TLDR] +Raise a non-retryable `ApplicationError` from the Activity — or list error types in the `RetryPolicy` — so **the Temporal Activity fails fast instead of retrying indefinitely**. Use this for permanent failures such as invalid input, missing records, or authorization errors where repeating the same call will never succeed. +::: + +## Overview + +The Non-Retryable Errors pattern marks specific error types so Temporal stops retrying immediately when one is raised. +Use it for failures where the root cause is structural — invalid input, a missing record, an authorization problem — where repeating the same call will never produce a different result. + +## Problem + +Temporal retries all Activity failures by default. +For transient infrastructure errors such as network timeouts or service restarts, this is the right behavior. +But some failures are permanent: no amount of retrying will fix them. + +Retrying a permanent failure wastes time and resources: + +- A transfer to a non-existent account number will fail on attempt 1, 2, and 3 in exactly the same way. +- An API call with a malformed request body will be rejected every time. +- A request from a revoked API key will receive an authorization error on every attempt. + +With the default unlimited retry policy, the Workflow waits through exponential backoff delays — minutes to hours — before eventually delivering the error to the Workflow, when it could have failed in milliseconds. + +## Solution + +Raise a non-retryable error from inside the Activity when the failure is known to be permanent. +Temporal detects the error type and skips all remaining retries, delivering the failure to the Workflow immediately. + +There are two complementary mechanisms: + +1. **Mark the error as non-retryable at the throw site** — the Activity explicitly signals that this specific failure should not be retried. +2. **Register non-retryable error types in the `RetryPolicy`** — the Workflow declares which error type names should never be retried, regardless of how the Activity raises them. + +Both mechanisms can be used together. + +```mermaid +flowchart TD + Workflow -->|Schedule Activity| Activity + Activity -->|Raises error| Check{Is error type\nnon-retryable?} + Check -->|Yes — marked at throw site\nor listed in RetryPolicy| Fail([Deliver ActivityError\nto Workflow immediately]) + Check -->|No| Retry[Schedule retry\nwith backoff] + Retry --> Activity + Fail --> Handle[Workflow handles error:\nlog, compensate, or escalate] +``` + +The following describes each path: + +1. The Activity raises an error. Temporal inspects whether the error type is non-retryable. +2. If non-retryable — either because the Activity flagged it or the `RetryPolicy` lists the type — Temporal delivers the `ActivityError` to the Workflow without delay. +3. If retryable, Temporal schedules another attempt after the configured backoff. +4. The Workflow catches the `ActivityError` and handles it according to the business logic. + +## Implementation + +### Marking an error as non-retryable at the throw site + +Use the SDK's `ApplicationError` (or equivalent) with the non-retryable flag. +Temporal propagates the error type name and the flag to the Workflow without retrying. + + + + +```python +# activities.py +from temporalio import activity +from temporalio.exceptions import ApplicationError + +@activity.defn +async def process_order(order_id: str) -> str: + response = await http_client.post(f"/orders/{order_id}/process") + if response.status_code == 404: + raise ApplicationError( + f"Order {order_id} not found", + type="OrderNotFoundError", + non_retryable=True, + ) + if response.status_code == 422: + raise ApplicationError( + f"Order {order_id} rejected: {response.json().get('detail', 'validation error')}", + type="ValidationError", + non_retryable=True, + ) + + response.raise_for_status() + return response.json()["confirmation_id"] +``` + + + + +```go +// activities.go +package orders + +import ( + "context" + "fmt" + + "go.temporal.io/sdk/temporal" +) + +func ProcessOrder(ctx context.Context, orderID string) (string, error) { + resp, err := httpClient.Post(fmt.Sprintf("/orders/%s/process", orderID)) + if err != nil { + return "", err + } + if resp.StatusCode == 404 { + return "", temporal.NewNonRetryableApplicationError( + fmt.Sprintf("order %s not found", orderID), + "OrderNotFoundError", + nil, + ) + } + if resp.StatusCode == 422 { + return "", temporal.NewNonRetryableApplicationError( + fmt.Sprintf("order %s rejected: %s", orderID, resp.ErrorDetail), + "ValidationError", + nil, + ) + } + return resp.ConfirmationID, nil +} +``` + + + + +```java +// ProcessOrderActivityImpl.java +import io.temporal.failure.ApplicationFailure; + +public class ProcessOrderActivityImpl implements ProcessOrderActivity { + @Override + public String processOrder(String orderId) { + HttpResponse response = httpClient.post("/orders/" + orderId + "/process"); + if (response.getStatusCode() == 404) { + throw ApplicationFailure.newNonRetryableFailure( + "Order " + orderId + " not found", + "OrderNotFoundError" + ); + } + if (response.getStatusCode() == 422) { + throw ApplicationFailure.newNonRetryableFailure( + "Order " + orderId + " rejected: " + response.getErrorDetail(), + "ValidationError" + ); + } + return response.getConfirmationId(); + } +} +``` + + + + +```typescript +// activities.ts +import { ApplicationFailure } from '@temporalio/activity'; + +export async function processOrder(orderId: string): Promise { + const response = await fetch(`/orders/${orderId}/process`, { method: 'POST' }); + if (response.status === 404) { + throw ApplicationFailure.nonRetryable( + `Order ${orderId} not found`, + 'OrderNotFoundError', + ); + } + if (response.status === 422) { + const body = await response.json(); + throw ApplicationFailure.nonRetryable( + `Order ${orderId} rejected: ${body.detail ?? 'validation error'}`, + 'ValidationError', + ); + } + + if (!response.ok) throw new Error(`API error ${response.status}`); + const body = await response.json(); + return body.confirmation_id; +} +``` + + + + +### Declaring non-retryable types in the RetryPolicy + +Alternatively, list error type names in the `RetryPolicy` at the Workflow call site. +Temporal stops retrying when the Activity raises an error whose type matches any name in the list. +This approach separates the retry decision from the Activity code, which is useful when the Activity is shared and the non-retryable classification depends on the caller's context. + +The Activity raises a standard `ApplicationError` with a type name but without the non-retryable flag — the retry decision is delegated to the Workflow's `RetryPolicy`: + + + + +```python +# activities.py +@activity.defn +async def process_order(order_id: str) -> str: + response = await http_client.post(f"/orders/{order_id}/process") + if response.status_code == 404: + # No non_retryable=True — the RetryPolicy in the Workflow controls retry behavior + raise ApplicationError(f"Order {order_id} not found", type="OrderNotFoundError") + if response.status_code == 422: + raise ApplicationError( + f"Order {order_id} rejected: {response.json().get('detail', 'validation error')}", + type="ValidationError", + ) + response.raise_for_status() + return response.json()["confirmation_id"] +``` + + + + +```go +// activities.go +func ProcessOrder(ctx context.Context, orderID string) (string, error) { + resp, err := httpClient.Post(fmt.Sprintf("/orders/%s/process", orderID)) + if err != nil { + return "", err + } + if resp.StatusCode == 404 { + // Use NewApplicationError, not NewNonRetryableApplicationError + return "", temporal.NewApplicationError( + fmt.Sprintf("order %s not found", orderID), "OrderNotFoundError", + ) + } + if resp.StatusCode == 422 { + return "", temporal.NewApplicationError( + fmt.Sprintf("order %s rejected: %s", orderID, resp.ErrorDetail), "ValidationError", + ) + } + return resp.ConfirmationID, nil +} +``` + + + + +```java +// ProcessOrderActivityImpl.java +public String processOrder(String orderId) { + HttpResponse response = httpClient.post("/orders/" + orderId + "/process"); + if (response.getStatusCode() == 404) { + // Use newFailure, not newNonRetryableFailure + throw ApplicationFailure.newFailure("Order " + orderId + " not found", "OrderNotFoundError"); + } + if (response.getStatusCode() == 422) { + throw ApplicationFailure.newFailure( + "Order " + orderId + " rejected: " + response.getErrorDetail(), "ValidationError"); + } + return response.getConfirmationId(); +} +``` + + + + +```typescript +// activities.ts +export async function processOrder(orderId: string): Promise { + const response = await fetch(`/orders/${orderId}/process`, { method: 'POST' }); + if (response.status === 404) { + // Use ApplicationFailure.create, not .nonRetryable + throw ApplicationFailure.create({ message: `Order ${orderId} not found`, type: 'OrderNotFoundError' }); + } + if (response.status === 422) { + const body = await response.json(); + throw ApplicationFailure.create({ + message: `Order ${orderId} rejected: ${body.detail ?? 'validation error'}`, + type: 'ValidationError', + }); + } + if (!response.ok) throw new Error(`API error ${response.status}`); + const body = await response.json(); + return body.confirmation_id; +} +``` + + + + +The Workflow lists which error type names to never retry: + + + + +```python +# workflows.py +from datetime import timedelta +from temporalio import workflow +from temporalio.common import RetryPolicy +import activities + +@workflow.defn +class OrderWorkflow: + @workflow.run + async def run(self, order_id: str) -> str: + return await workflow.execute_activity( + activities.process_order, + order_id, + start_to_close_timeout=timedelta(seconds=10), + retry_policy=RetryPolicy( + non_retryable_error_types=["OrderNotFoundError", "ValidationError"], + ), + ) +``` + + + + +```go +// workflow.go +ao := workflow.ActivityOptions{ + StartToCloseTimeout: 10 * time.Second, + RetryPolicy: &temporal.RetryPolicy{ + NonRetryableErrorTypes: []string{"OrderNotFoundError", "ValidationError"}, + }, +} +ctx = workflow.WithActivityOptions(ctx, ao) +``` + + + + +```java +// OrderWorkflowImpl.java +private final ProcessOrderActivity activities = Workflow.newActivityStub( + ProcessOrderActivity.class, + ActivityOptions.newBuilder() + .setStartToCloseTimeout(Duration.ofSeconds(10)) + .setRetryOptions(RetryOptions.newBuilder() + .setDoNotRetry("OrderNotFoundError", "ValidationError") + .build()) + .build() +); +``` + + + + +```typescript +// workflows.ts +const { processOrder } = wf.proxyActivities({ + startToCloseTimeout: '10s', + retry: { + nonRetryableErrorTypes: ['OrderNotFoundError', 'ValidationError'], + }, +}); +``` + + + + +### Handle non-retryable errors in the Workflow + +Catch the `ActivityError` in the Workflow to distinguish between permanent failures and transient ones. +Inspect the underlying cause to route to the appropriate compensation or escalation path. + + + + +```python +# workflows.py +from temporalio.exceptions import ActivityError, ApplicationError + +@workflow.defn +class OrderWorkflow: + @workflow.run + async def run(self, order_id: str) -> str: + try: + return await workflow.execute_activity( + activities.process_order, + order_id, + start_to_close_timeout=timedelta(seconds=10), + retry_policy=RetryPolicy( + non_retryable_error_types=["OrderNotFoundError", "ValidationError"], + ), + ) + except ActivityError as e: + cause = e.__cause__ + if isinstance(cause, ApplicationError): + if cause.type == "ValidationError": + return f"Order rejected: {cause}" + if cause.type == "OrderNotFoundError": + return f"Order not found: {cause}" + raise +``` + + + + +```go +// workflow.go +var result string +err := workflow.ExecuteActivity(ctx, ProcessOrder, orderID).Get(ctx, &result) +if err != nil { + var appErr *temporal.ApplicationError + if errors.As(err, &appErr) { + switch appErr.Type() { + case "ValidationError": + return "", fmt.Errorf("order rejected: %w", appErr) + case "OrderNotFoundError": + return "", fmt.Errorf("order not found: %w", appErr) + } + } + return "", err +} +``` + + + + +```java +// OrderWorkflowImpl.java +try { + return activities.processOrder(orderId); +} catch (ActivityFailure e) { + if (e.getCause() instanceof ApplicationFailure appFailure) { + switch (appFailure.getType()) { + case "ValidationError" -> { return "Order rejected: " + appFailure.getMessage(); } + case "OrderNotFoundError" -> { return "Order not found: " + appFailure.getMessage(); } + } + } + throw e; +} +``` + + + + +```typescript +// workflows.ts +try { + return await processOrder(orderId); +} catch (err) { + if (err instanceof wf.ActivityFailure && err.cause instanceof wf.ApplicationFailure) { + if (err.cause.type === 'ValidationError') { + return `Order rejected: ${err.cause.message}`; + } + if (err.cause.type === 'OrderNotFoundError') { + return `Order not found: ${err.cause.message}`; + } + } + throw err; +} +``` + + + + +## Best practices + +- **Validate input before scheduling the Activity.** If the Workflow can detect invalid input upfront — using an `Update` validator or by inspecting the input data — fail fast in the Workflow rather than paying the cost of an Activity execution. +- **Use specific error type names.** Generic names like `"Error"` or `"Failure"` match too broadly. Use domain-specific names like `"OrderNotFoundError"` or `"InsufficientFundsError"` so the Workflow can distinguish between failure causes. +- **Reserve non-retryable for truly permanent failures.** A rate-limit error (HTTP 429) is transient — the same call will succeed after a delay. A not-found error (HTTP 404) is typically permanent. Match the non-retryable classification to the nature of the error. +- **Combine both mechanisms for defence in depth.** Mark the error as non-retryable at the throw site so the Activity is self-describing, and also list the type in the `RetryPolicy` so the classification is enforced even if the Activity code changes. + +## Common pitfalls + +- **Marking transient errors as non-retryable.** Network timeouts and service unavailability are transient. Marking them non-retryable removes Temporal's ability to recover automatically. +- **Using the error message instead of a type name.** `RetryPolicy.NonRetryableErrorTypes` matches on type names, not message strings. Without a type name, the policy cannot identify the error. +- **Swallowing the `ActivityError` without logging.** Non-retryable errors fail fast and silently if you do not catch and log them. Always log the failure before re-raising or returning an error result. +- **Confusing non-retryable errors with Workflow failures.** A non-retryable `ActivityError` fails the Activity and delivers the error to the Workflow. The Workflow itself does not fail unless it re-raises the error without catching it. + +## Related patterns + +- [Fixed Count of Retries](/design-patterns/fixed-count-retries): Limit retries for transient errors that are worth retrying a bounded number of times. +- [Resumable Activity](/design-patterns/resumable-activity): Park the Workflow and accept a corrected input via Signal when retries are exhausted. +- [Error Handling & Retry Patterns](/design-patterns/error-handling-patterns): Overview and decision tree for all retry patterns. + +## References + +- [Temporal Retry Policies](/encyclopedia/retry-policies) +- [Failure Handling in Practice](https://temporal.io/blog/failure-handling-in-practice) diff --git a/docs/design-patterns/parallel-execution.mdx b/docs/design-patterns/parallel-execution.mdx new file mode 100644 index 0000000000..47d743b821 --- /dev/null +++ b/docs/design-patterns/parallel-execution.mdx @@ -0,0 +1,542 @@ +--- +id: parallel-execution +title: "Parallel Execution" +sidebar_label: "Parallel Execution" +description: "Executes multiple Activities concurrently for maximum throughput with error handling and controlled parallelism." +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +## Overview + +The Parallel Execution pattern enables concurrent execution of multiple Activities or Child Workflows to maximize throughput and minimize total execution time. +Using Temporal's async APIs, you can launch multiple operations asynchronously and wait for their completion. + +## Problem + +In sequential execution, operations run one after another, causing unnecessary delays when multiple independent operations could run simultaneously. +Total execution time equals the sum of all operation durations, resources sit idle while waiting, and batch processing takes hours when it could take minutes. + +Without parallel execution, you must accept slow sequential processing, implement complex threading or async logic manually, risk inconsistent state management across threads, and handle thread safety and synchronization issues. + +## Solution + +Each SDK provides its own mechanism for launching Activities concurrently and waiting for the results: + +- **Java**: `Async.function()` schedules Activities that return `Promise` objects. `Promise.allOf()` waits for all of them. +- **TypeScript**: Activity proxy functions return native `Promise` objects. `Promise.all()` waits for all of them. +- **Python**: `workflow.execute_activity()` returns awaitables. `asyncio.gather()` waits for all of them. +- **Go**: `workflow.ExecuteActivity()` returns `Future` objects. You call `.Get()` on each Future to collect results. + +```mermaid +sequenceDiagram + participant Workflow + participant Activity1 + participant Activity2 + participant Activity3 + + Workflow->>+Activity1: Start async + Workflow->>+Activity2: Start async + Workflow->>+Activity3: Start async + Note over Workflow: Returns immediately with Futures/Promises + + par Parallel Execution + Activity1->>Activity1: Execute + Activity2->>Activity2: Execute + Activity3->>Activity3: Execute + end + + Activity1-->>-Workflow: Result 1 + Activity2-->>-Workflow: Result 2 + Activity3-->>-Workflow: Result 3 + + Workflow->>Workflow: Await all results + Note over Workflow: Collect all results +``` + +The following describes each step in the diagram: + +1. The Workflow starts three Activities asynchronously, which returns immediately with Futures or Promises. +2. All three Activities execute in parallel on available Workers. +3. As each Activity completes, its Future or Promise resolves with the result. +4. The Workflow waits until all results are available, then collects them. + +## Implementation + +### Basic parallel Activities + +The following implementation starts one Activity per item in a list and waits for all of them to complete: + + + + +```python +# workflows.py +import asyncio +from datetime import timedelta +from temporalio import workflow + +with workflow.unsafe.imports_passed_through(): + from activities import process + +@workflow.defn +class ParallelWorkflow: + @workflow.run + async def run(self, items: list[str]) -> list[str]: + results = await asyncio.gather( + *[ + workflow.execute_activity( + process, + item, + start_to_close_timeout=timedelta(seconds=30), + ) + for item in items + ] + ) + return list(results) +``` + + + + +```go +// parallel_workflow.go +func ProcessInParallel(ctx workflow.Context, items []string) ([]string, error) { + ao := workflow.ActivityOptions{ + StartToCloseTimeout: 30 * time.Second, + } + ctx = workflow.WithActivityOptions(ctx, ao) + + futures := make([]workflow.Future, len(items)) + for i, item := range items { + futures[i] = workflow.ExecuteActivity(ctx, Process, item) + } + + 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 +} +``` + + + + +```java +// ParallelWorkflowImpl.java +@WorkflowInterface +public interface ParallelWorkflow { + @WorkflowMethod + List processInParallel(List items); +} + +public class ParallelWorkflowImpl implements ParallelWorkflow { + private final ProcessingActivity activity = + Workflow.newActivityStub(ProcessingActivity.class, + ActivityOptions.newBuilder() + .setStartToCloseTimeout(Duration.ofSeconds(30)) + .build()); + + @Override + public List processInParallel(List items) { + List> promises = new ArrayList<>(); + + for (String item : items) { + Promise promise = Async.function(activity::process, item); + promises.add(promise); + } + + Promise.allOf(promises).get(); + return promises.stream().map(Promise::get).collect(Collectors.toList()); + } +} +``` + + + + +```typescript +// workflows.ts +import { proxyActivities } from '@temporalio/workflow'; +import type * as activities from './activities'; + +const { process } = proxyActivities({ + startToCloseTimeout: '30s', +}); + +export async function processInParallel(items: string[]): Promise { + const promises = items.map((item) => process(item)); + return await Promise.all(promises); +} +``` + + + + +Each SDK schedules all Activities before waiting for any results. +In Java, `Async.function()` returns a `Promise`; in TypeScript, calling the activity proxy without `await` returns a native `Promise`; in Python, `workflow.execute_activity()` returns an awaitable; and in Go, `workflow.ExecuteActivity()` returns a `Future`. +The Workflow then waits for all of them to complete and collects the results. + +### Controlled parallelism + +The following implementation limits the number of concurrent Activities by processing items in batches: + + + + +```python +# workflows.py +import asyncio +from datetime import timedelta +from temporalio import workflow + +with workflow.unsafe.imports_passed_through(): + from activities import process + +@workflow.defn +class BatchWorkflow: + @workflow.run + async def run(self, items: list[str], max_parallel: int) -> list[str]: + results: list[str] = [] + + for i in range(0, len(items), max_parallel): + batch = items[i : i + max_parallel] + batch_results = await asyncio.gather( + *[ + workflow.execute_activity( + process, + item, + start_to_close_timeout=timedelta(seconds=30), + ) + for item in batch + ] + ) + results.extend(batch_results) + + return results +``` + + + + +```go +// batch_workflow.go +func ProcessBatch(ctx workflow.Context, items []string, maxParallel int) ([]string, error) { + ao := workflow.ActivityOptions{ + StartToCloseTimeout: 30 * time.Second, + } + ctx = workflow.WithActivityOptions(ctx, ao) + + var results []string + + for i := 0; i < len(items); i += maxParallel { + end := i + maxParallel + if end > len(items) { + end = len(items) + } + batch := items[i:end] + + futures := make([]workflow.Future, len(batch)) + for j, item := range batch { + futures[j] = workflow.ExecuteActivity(ctx, Process, item) + } + + for _, future := range futures { + var result string + if err := future.Get(ctx, &result); err != nil { + return nil, err + } + results = append(results, result) + } + } + + return results, nil +} +``` + + + + +```java +// BatchWorkflowImpl.java +public class BatchWorkflowImpl implements BatchWorkflow { + private final ProcessingActivity activity = + Workflow.newActivityStub(ProcessingActivity.class, + ActivityOptions.newBuilder() + .setStartToCloseTimeout(Duration.ofSeconds(30)) + .build()); + + @Override + public BatchResult processBatch(List items, int maxParallel) { + List results = new ArrayList<>(); + + for (int i = 0; i < items.size(); i += maxParallel) { + int end = Math.min(i + maxParallel, items.size()); + List batch = items.subList(i, end); + + List> promises = batch.stream() + .map(item -> Async.function(activity::process, item)) + .collect(Collectors.toList()); + + Promise.allOf(promises).get(); + results.addAll(promises.stream().map(Promise::get).collect(Collectors.toList())); + } + + return new BatchResult(results); + } +} +``` + + + + +```typescript +// workflows.ts +import { proxyActivities } from '@temporalio/workflow'; +import type * as activities from './activities'; + +const { process } = proxyActivities({ + startToCloseTimeout: '30s', +}); + +export async function processBatch( + items: string[], + maxParallel: number +): Promise { + const results: string[] = []; + + for (let i = 0; i < items.length; i += maxParallel) { + const batch = items.slice(i, i + maxParallel); + const batchResults = await Promise.all(batch.map((item) => process(item))); + results.push(...batchResults); + } + + return results; +} +``` + + + + +The Workflow processes items in chunks of `maxParallel`. +Each chunk runs in parallel, and the Workflow waits for the entire chunk to complete before starting the next one. +This prevents overwhelming Workers or external services. + +### Error handling + +The following implementation wraps each Activity in error handling so that individual failures do not prevent other Activities from completing: + + + + +```python +# workflows.py +import asyncio +from dataclasses import dataclass +from datetime import timedelta +from temporalio import workflow + +with workflow.unsafe.imports_passed_through(): + from activities import process + +@dataclass +class Result: + item: str + output: str | None = None + error: str | None = None + +@workflow.defn +class ResilientParallelWorkflow: + @workflow.run + async def run(self, items: list[str]) -> list[Result]: + tasks = [ + workflow.execute_activity( + process, + item, + start_to_close_timeout=timedelta(seconds=30), + ) + for item in items + ] + outcomes = await asyncio.gather(*tasks, return_exceptions=True) + + results: list[Result] = [] + for item, outcome in zip(items, outcomes): + if isinstance(outcome, BaseException): + results.append(Result(item=item, error=str(outcome))) + else: + results.append(Result(item=item, output=outcome)) + return results +``` + + + + +```go +// resilient_parallel_workflow.go +func ProcessWithErrorHandling(ctx workflow.Context, items []string) ([]Result, error) { + ao := workflow.ActivityOptions{ + StartToCloseTimeout: 30 * time.Second, + } + ctx = workflow.WithActivityOptions(ctx, ao) + + futures := make([]workflow.Future, len(items)) + for i, item := range items { + futures[i] = workflow.ExecuteActivity(ctx, Process, item) + } + + results := make([]Result, len(items)) + for i, future := range futures { + var output string + if err := future.Get(ctx, &output); err != nil { + results[i] = Result{Item: items[i], Error: err.Error()} + } else { + results[i] = Result{Item: items[i], Output: output} + } + } + return results, nil +} +``` + + + + +```java +// ResilientParallelWorkflowImpl.java +public class ResilientParallelWorkflowImpl implements ParallelWorkflow { + private final ProcessingActivity activity = + Workflow.newActivityStub(ProcessingActivity.class, + ActivityOptions.newBuilder() + .setStartToCloseTimeout(Duration.ofSeconds(30)) + .build()); + + @Override + public ProcessingReport processWithErrorHandling(List items) { + List> promises = new ArrayList<>(); + + for (String item : items) { + Promise promise = Async.function(() -> { + try { + return activity.process(item); + } catch (Exception e) { + return Result.failed(item, e.getMessage()); + } + }); + promises.add(promise); + } + + Promise.allOf(promises).get(); + + List results = promises.stream().map(Promise::get).collect(Collectors.toList()); + return new ProcessingReport(results); + } +} +``` + + + + +```typescript +// workflows.ts +import { proxyActivities } from '@temporalio/workflow'; +import type * as activities from './activities'; + +const { process } = proxyActivities({ + startToCloseTimeout: '30s', +}); + +interface Result { + item: string; + output?: string; + error?: string; +} + +export async function processWithErrorHandling(items: string[]): Promise { + const settled = await Promise.allSettled(items.map((item) => process(item))); + + return settled.map((outcome, i) => { + if (outcome.status === 'fulfilled') { + return { item: items[i], output: outcome.value }; + } + return { item: items[i], error: String(outcome.reason) }; + }); +} +``` + + + + +Each Activity handles its own errors so that the Workflow can collect results from all Activities, including those that failed. +In TypeScript, `Promise.allSettled()` is especially convenient for this pattern. +In Python, `asyncio.gather()` with `return_exceptions=True` captures errors alongside successes. +In Go, each `Future.Get()` call is checked individually for errors. + +## When to use + +The Parallel Execution pattern is a good fit for processing independent items in batches, calling multiple external services simultaneously, fan-out/fan-in patterns, parallel data transformations, concurrent API requests, and multi-step pipelines with independent stages. + +It is not a good fit for operations with dependencies between them, resource-constrained environments (use controlled parallelism), operations requiring strict ordering, or a single fast operation (the overhead is not worth it). + +## Benefits and trade-offs + +Parallel execution reduces total execution time dramatically and maximizes Worker and external service usage. +Temporal handles retries and failures per operation, and you do not need manual thread management or synchronization. + +The trade-offs to consider are that more concurrent operations require more Workers. +Error handling across parallel operations is harder. +Parallel execution makes tracing more difficult. +You may overwhelm external services without throttling, and storing many Futures or Promises consumes Workflow memory. + +## Comparison with alternatives + +| Approach | Parallelism | Complexity | Control | Use case | +| :--- | :--- | :--- | :--- | :--- | +| Async Activities | High | Low | Medium | Independent operations | +| Sequential | None | Very Low | Full | Dependent operations | +| Child Workflows | High | Medium | High | Complex sub-processes | +| ContinueAsNew | None | Medium | Full | Large iterations | + +## Best practices + +- **Limit concurrency.** Use batching to avoid overwhelming Workers or external services. +- **Handle failures.** Wrap operations in error handling or use Activity retry policies. +- **Set timeouts.** Configure appropriate Activity timeouts for parallel operations. +- **Monitor resources.** Ensure sufficient Workers for the desired parallelism. +- **Aggregate carefully.** Consider memory when collecting large result sets. +- **Use Child Workflows.** For complex parallel operations with their own state. +- **Test scalability.** Verify performance with realistic parallel loads. +- **Rate limit.** Implement throttling for external API calls. +- **Support partial results.** Consider returning partial results on some failures. +- **Avoid premature blocking.** Schedule all Activities before waiting for any results. + +## Common pitfalls + +- **Exceeding the pending Activities limit.** A single Workflow Execution can have at most 2,000 pending (concurrently running) Activities. Scheduling more causes Workflow Task failures. Batch Activities or use child Workflows for higher concurrency. +- **Ignoring errors from individual Activities.** Waiting for all results (e.g., `Promise.allOf()` in Java, `Promise.all()` in TypeScript, `asyncio.gather()` in Python) fails on the first error by default. If you need partial results, catch errors inside each async function or use `Promise.allSettled()` / `return_exceptions=True` / per-Future error checking. +- **Blowing the 4 MB gRPC message limit.** Scheduling hundreds of Activities in a single Workflow Task can exceed the 4 MB gRPC message size limit if their combined inputs are large. Batch scheduling across multiple Workflow Tasks. +- **Not using Continue-As-New for large fan-outs.** Each Activity adds events to history. Hundreds of parallel Activities can quickly approach the 50K event limit. Use Continue-As-New or child Workflows to partition work. + +## Related patterns + +- **[Child Workflows](/design-patterns/child-workflows)**: For complex parallel operations with their own state. +- **[Saga Pattern](/design-patterns/saga-pattern)**: Parallel operations with compensation. + +## Sample code + +**Java:** +- [HelloParallelActivity](https://github.com/temporalio/samples-java/tree/main/core/src/main/java/io/temporal/samples/hello/HelloParallelActivity.java) — Basic parallel Activity execution. +- [HelloAsync](https://github.com/temporalio/samples-java/tree/main/core/src/main/java/io/temporal/samples/hello/HelloAsync.java) — Async execution with Promises. +- [Sliding Window Batch](https://github.com/temporalio/samples-java/tree/main/core/src/main/java/io/temporal/samples/batch/slidingwindow) — Controlled parallel Child Workflows. + +**TypeScript:** +- [activities-examples](https://github.com/temporalio/samples-typescript/tree/main/activities-examples) — Activity patterns including parallel execution with `Promise.all()`. + +**Python:** +- [hello_parallel_activity](https://github.com/temporalio/samples-python/blob/main/hello/hello_parallel_activity.py) — Basic parallel Activity execution with `asyncio.gather()`. + +**Go:** +- [splitmerge-future](https://github.com/temporalio/samples-go/tree/main/splitmerge-future) — Parallel Activity execution with Futures. +- [splitmerge-selector](https://github.com/temporalio/samples-go/tree/main/splitmerge-selector) — Parallel Activities with Selector for first-completion handling. diff --git a/docs/design-patterns/performance-latency-patterns.mdx b/docs/design-patterns/performance-latency-patterns.mdx new file mode 100644 index 0000000000..31b600a245 --- /dev/null +++ b/docs/design-patterns/performance-latency-patterns.mdx @@ -0,0 +1,89 @@ +--- +id: performance-latency-patterns +title: "Performance & Latency Patterns" +sidebar_label: "Performance & Latency Patterns" +description: "Pattern selection guide for reducing Workflow latency, with a comparison of the round-trips each pattern removes and their combined effect." +--- + +import PatternCards from '@site/src/components/PatternCards'; + +Temporal Workflows are durable and reliable, but a default implementation—using regular Activities scheduled through the Temporal server—carries inherent latency. Each regular Activity incurs multiple server round-trips, and each new Workflow begins with a Matching Service routing step. On Temporal Cloud, this baseline can reach 850 ms or more for a typical three-Activity workflow. + +This section covers three complementary patterns that each target a different source of latency. They can be applied individually or combined depending on your requirements. + +## Latency sources in a typical Workflow + +```mermaid +flowchart LR + A[Client\nExecuteWorkflow] --> B[Matching Service\nroutes first WFT] + B --> C[Worker\nexecutes WFT] + C --> D[Server\nschedules Activity] + D --> E[Worker\nexecutes Activity] + E --> F[Server\nrecords completion] + F --> G[Worker\nresumes WFT] + G --> H[Repeat per\nActivity] +``` + +| Source | Overhead | Pattern that removes it | +|---|---|---| +| Matching Service (first Workflow Task) | ~30–50 ms | [Eager Workflow Start](/design-patterns/eager-workflow-start) | +| Activity scheduling round-trip | ~50 ms per Activity | [Local Activities](/design-patterns/local-activities) | +| Client waiting for full workflow | Total duration | [Early Return](/design-patterns/early-return) | + +## Pattern comparison + +The numbers below are approximate benchmarks based on a three-Activity transaction workflow running on Temporal Cloud. Actual results vary by region, Activity implementation, and server load. + +| Pattern | First Response | Total Latency | SDK Support | +|---|---|---|---| +| Baseline (regular Activities) | ~850 ms | ~850 ms | All | +| [Early Return](/design-patterns/early-return) | ~265 ms | ~850 ms | All | +| [Local Activities](/design-patterns/local-activities) | ~275 ms | ~275 ms | All | +| [Early Return + Local Activities](/design-patterns/early-return-local-activities) | ~160 ms | ~275 ms | All | +| [Eager Workflow Start](/design-patterns/eager-workflow-start) + Local Activities | ~265 ms | ~265 ms | Go, Java, Python | +| Early Return + Local Activities + Eager Start | ~160 ms | ~265 ms | Go, Java, Python | + +**First Response** is the time until the client receives an actionable result. **Total Latency** is the time until the Workflow fully completes. + +:::tip[TypeScript users] +Eager Workflow Start is not available in the TypeScript SDK, but the latency gap is small (~30–50 ms per Workflow start). [Local Activities](/design-patterns/local-activities) and [Early Return + Local Activities](/design-patterns/early-return-local-activities) are fully supported and achieve competitive results: ~275 ms total latency and ~160 ms first-response latency respectively. +::: + +## Patterns in this section + + + +## Choosing a pattern + +**You only care about total workflow latency** (not first-response time): use [Local Activities](/design-patterns/local-activities). If co-location is feasible, add [Eager Workflow Start](/design-patterns/eager-workflow-start) for the maximum reduction. + +**You care most about first-response latency**: use [Early Return + Local Activities](/design-patterns/early-return-local-activities). The client gets its response in ~160 ms; background work continues independently. + +**You are using TypeScript**: use [Local Activities](/design-patterns/local-activities) and [Early Return + Local Activities](/design-patterns/early-return-local-activities). Eager Workflow Start is not available in the TypeScript SDK. + +**You want to start simple**: begin with [Local Activities](/design-patterns/local-activities). It requires minimal structural change and provides the most straightforward per-Activity improvement. + +## Related sections + +- [Distributed Transaction Patterns](/design-patterns/distributed-transaction-patterns) — the [Early Return](/design-patterns/early-return) pattern lives there, describing the Update-with-Start mechanism in detail +- [Worker Configuration Patterns](/design-patterns/worker-configuration-patterns) — tuning Worker concurrency and task queue assignments that affect throughput +- [QoS & Throughput Patterns](/design-patterns/qos-throughput-patterns) — rate limiting and fairness patterns for high-volume workloads diff --git a/docs/design-patterns/pick-first.mdx b/docs/design-patterns/pick-first.mdx new file mode 100644 index 0000000000..56395fb1a3 --- /dev/null +++ b/docs/design-patterns/pick-first.mdx @@ -0,0 +1,558 @@ +--- +id: pick-first +title: "Pick First Pattern" +sidebar_label: "Pick First (Race)" +description: "Starts multiple Activities in parallel and uses the first result, cancelling the rest." +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +## Overview + +The Pick First pattern executes multiple Activities in parallel and returns the result of whichever completes first, then cancels the remaining Activities. +It is suitable for racing multiple approaches to the same task, implementing timeout alternatives, or optimizing for fastest response when multiple options are available. + +## Problem + +In distributed systems, you often need Workflows that execute multiple Activities that can accomplish the same goal, return as soon as any one succeeds (fastest wins), cancel remaining Activities to avoid wasted resources, and handle scenarios where speed matters more than trying all options. + +Without the Pick First pattern, you must wait for all Activities to complete even when only one result is needed, manually track which Activity finished first, implement complex cancellation logic for remaining Activities, and waste compute resources on Activities whose results will not be used. + +## Solution + +The Pick First pattern races multiple Activities simultaneously, captures the first result, then cancels remaining Activities using a cancellation mechanism provided by each SDK. + +```mermaid +sequenceDiagram + participant Workflow + participant Activity1 + participant Activity2 + participant Activity3 + + Workflow->>+Activity1: Start (shared ctx) + Workflow->>+Activity2: Start (shared ctx) + Workflow->>+Activity3: Start (shared ctx) + Note over Workflow: Selector waits for first + + par Race + Activity1->>Activity1: Execute (slow) + Activity2->>Activity2: Execute (fast) + Activity3->>Activity3: Execute (medium) + end + + Activity2-->>Workflow: Result (FIRST!) + Note over Workflow: Selector returns + Workflow->>Workflow: cancelHandler() + + Workflow->>Activity1: Cancel + Workflow->>Activity3: Cancel + Activity1-->>-Workflow: Cancelled + Activity3-->>-Workflow: Cancelled + deactivate Activity2 +``` + +The following describes each step in the diagram: + +1. The Workflow starts three Activities in parallel using a shared cancellable context. +2. The Workflow waits for the first Activity to complete. +3. Activity 2 completes first. The Workflow captures its result. +4. The Workflow cancels the shared context, which cancels Activities 1 and 3. + +The following implementation shows the core pattern. +The Workflow creates a cancellable context, starts two Activities, and captures the first result: + + + + +```python +# workflows.py +import asyncio +from datetime import timedelta +from temporalio import workflow + +with workflow.unsafe.imports_passed_through(): + from activities import sample_activity + +@workflow.defn +class PickFirstWorkflow: + @workflow.run + async def run(self) -> str: + task1 = asyncio.create_task( + workflow.execute_activity( + sample_activity, + "option1", + start_to_close_timeout=timedelta(minutes=2), + heartbeat_timeout=timedelta(seconds=10), + ) + ) + task2 = asyncio.create_task( + workflow.execute_activity( + sample_activity, + "option2", + start_to_close_timeout=timedelta(minutes=2), + heartbeat_timeout=timedelta(seconds=10), + ) + ) + + done, pending = await workflow.wait( + [task1, task2], return_when=asyncio.FIRST_COMPLETED + ) + + for task in pending: + task.cancel() + + return done.pop().result() +``` + + + + +```go +// workflow.go +func PickFirstWorkflow(ctx workflow.Context) (string, error) { + selector := workflow.NewSelector(ctx) + var firstResponse string + + childCtx, cancelHandler := workflow.WithCancel(ctx) + childCtx = workflow.WithActivityOptions(childCtx, activityOptions) + + f1 := workflow.ExecuteActivity(childCtx, Activity, "option1") + f2 := workflow.ExecuteActivity(childCtx, Activity, "option2") + + selector.AddFuture(f1, func(f workflow.Future) { + _ = f.Get(ctx, &firstResponse) + }).AddFuture(f2, func(f workflow.Future) { + _ = f.Get(ctx, &firstResponse) + }) + + selector.Select(ctx) // Blocks until first completes + cancelHandler() // Cancel remaining activities + + return firstResponse, nil +} +``` + + + + +```java +// PickFirstWorkflow.java +public class PickFirstWorkflowImpl implements PickFirstWorkflow { + + private final SampleActivities activities = + Workflow.newActivityStub( + SampleActivities.class, + ActivityOptions.newBuilder() + .setStartToCloseTimeout(Duration.ofMinutes(2)) + .setHeartbeatTimeout(Duration.ofSeconds(10)) + .build()); + + @Override + public String pickFirst() { + List> results = new ArrayList<>(); + + CancellationScope scope = + Workflow.newCancellationScope( + () -> { + results.add(Async.function(activities::sampleActivity, "option1")); + results.add(Async.function(activities::sampleActivity, "option2")); + }); + + scope.run(); + + String firstResponse = Promise.anyOf(results).get(); + + scope.cancel(); + + return firstResponse; + } +} +``` + + + + +```typescript +// workflows.ts +import { proxyActivities, CancellationScope } from '@temporalio/workflow'; +import type * as activities from './activities'; + +const { sampleActivity } = proxyActivities({ + startToCloseTimeout: '2m', + heartbeatTimeout: '10s', +}); + +export async function pickFirstWorkflow(): Promise { + return await CancellationScope.cancellable(async () => { + const p1 = sampleActivity('option1'); + const p2 = sampleActivity('option2'); + const firstResponse = await Promise.race([p1, p2]); + CancellationScope.current().cancel(); + return firstResponse; + }); +} +``` + + + + +Each SDK provides a different mechanism for racing Activities and cancelling the rest: + +- **Go** uses `workflow.NewSelector()` with `AddFuture()` callbacks and `workflow.WithCancel(ctx)` for cancellation. +- **TypeScript** uses `Promise.race()` within a `CancellationScope.cancellable()`. Calling `CancellationScope.current().cancel()` cancels all Activities started in that scope. +- **Python** uses `asyncio.create_task()` to start Activities concurrently, then `workflow.wait()` with `return_when=asyncio.FIRST_COMPLETED` to get the first result. Pending tasks are cancelled explicitly. +- **Java** uses `Async.function()` to start Activities inside a `CancellationScope`, then `Promise.anyOf()` to wait for the first result. Calling `scope.cancel()` cancels the remaining Activities. + +## Implementation + +### Activity with cancellation support + +For the Pick First pattern to work efficiently, Activities must detect cancellation via heartbeats and respond to the cancellation signal provided by their SDK: + + + + +```python +# activities.py +import asyncio +from temporalio import activity + +@activity.defn +async def sample_activity(branch_id: str) -> str: + try: + for elapsed in range(60): + await asyncio.sleep(1) + activity.heartbeat("status-report") + return f"Branch {branch_id} completed" + except asyncio.CancelledError: + activity.logger.info(f"Branch {branch_id} cancelled") + raise +``` + + + + +```go +// activity.go +func SampleActivity(ctx context.Context, branchID int, duration time.Duration) (string, error) { + logger := activity.GetLogger(ctx) + elapsed := time.Nanosecond + + for elapsed < duration { + time.Sleep(time.Second) + elapsed += time.Second + + activity.RecordHeartbeat(ctx, "status-report") + + select { + case <-ctx.Done(): + msg := fmt.Sprintf("Branch %d cancelled", branchID) + logger.Info(msg) + return msg, ctx.Err() + default: + // Continue working + } + } + + return fmt.Sprintf("Branch %d completed", branchID), nil +} +``` + + + + +```java +// SampleActivityImpl.java +public class SampleActivityImpl implements SampleActivities { + + @Override + public String sampleActivity(String branchID) { + for (int elapsed = 0; elapsed < 60; elapsed++) { + try { + Thread.sleep(1000); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw Activity.wrap(e); + } + Activity.getExecutionContext().heartbeat("status-report"); + // Heartbeat throws CanceledFailure if cancellation was requested + } + return "Branch " + branchID + " completed"; + } +} +``` + + + + +```typescript +// activities.ts +import { Context, heartbeat } from '@temporalio/activity'; + +export async function sampleActivity(branchID: string): Promise { + for (let elapsed = 0; elapsed < 60; elapsed++) { + await new Promise((resolve) => setTimeout(resolve, 1000)); + heartbeat('status-report'); + Context.current().cancellationSignal.throwIfAborted(); + } + return `Branch ${branchID} completed`; +} +``` + + + + +The Activity heartbeats on each iteration, which allows the Temporal Server to deliver cancellation notifications promptly. +When cancellation is detected, the Activity performs any necessary cleanup and exits. + +### Wait for cancellation completion + +The following implementation waits for all Activities to finish their cleanup before returning: + + + + +```python +# workflows.py +import asyncio +from datetime import timedelta +from temporalio import workflow +from temporalio.common import RetryPolicy + +with workflow.unsafe.imports_passed_through(): + from activities import sample_activity + +@workflow.defn +class PickFirstWithCleanup: + @workflow.run + async def run(self) -> str: + task1 = asyncio.create_task( + workflow.execute_activity( + sample_activity, + "branch1", + start_to_close_timeout=timedelta(minutes=2), + heartbeat_timeout=timedelta(seconds=10), + cancellation_type=workflow.ActivityCancellationType.WAIT_CANCELLATION_COMPLETED, + ) + ) + task2 = asyncio.create_task( + workflow.execute_activity( + sample_activity, + "branch2", + start_to_close_timeout=timedelta(minutes=2), + heartbeat_timeout=timedelta(seconds=10), + cancellation_type=workflow.ActivityCancellationType.WAIT_CANCELLATION_COMPLETED, + ) + ) + + done, pending = await workflow.wait( + [task1, task2], return_when=asyncio.FIRST_COMPLETED + ) + + for task in pending: + task.cancel() + + # Wait for all activities to finish cancellation + for task in pending: + try: + await task + except asyncio.CancelledError: + pass + + return done.pop().result() +``` + + + + +```go +// workflow.go +func PickFirstWithCleanup(ctx workflow.Context) (string, error) { + selector := workflow.NewSelector(ctx) + var firstResponse string + + childCtx, cancelHandler := workflow.WithCancel(ctx) + childCtx = workflow.WithActivityOptions(childCtx, workflow.ActivityOptions{ + StartToCloseTimeout: 2 * time.Minute, + WaitForCancellation: true, + }) + + f1 := workflow.ExecuteActivity(childCtx, Activity, "branch1") + f2 := workflow.ExecuteActivity(childCtx, Activity, "branch2") + pendingFutures := []workflow.Future{f1, f2} + + selector.AddFuture(f1, func(f workflow.Future) { + _ = f.Get(ctx, &firstResponse) + }).AddFuture(f2, func(f workflow.Future) { + _ = f.Get(ctx, &firstResponse) + }) + + selector.Select(ctx) + cancelHandler() + + // Wait for all activities to finish cancellation + for _, f := range pendingFutures { + _ = f.Get(ctx, nil) + } + + return firstResponse, nil +} +``` + + + + +```java +// PickFirstWithCleanupImpl.java +public class PickFirstWithCleanupImpl implements PickFirstWorkflow { + + private final SampleActivities activities = + Workflow.newActivityStub( + SampleActivities.class, + ActivityOptions.newBuilder() + .setStartToCloseTimeout(Duration.ofMinutes(2)) + .setHeartbeatTimeout(Duration.ofSeconds(10)) + .setCancellationType( + ActivityCancellationType.WAIT_CANCELLATION_COMPLETED) + .build()); + + @Override + public String pickFirst() { + List> results = new ArrayList<>(); + + CancellationScope scope = + Workflow.newCancellationScope( + () -> { + results.add(Async.function(activities::sampleActivity, "branch1")); + results.add(Async.function(activities::sampleActivity, "branch2")); + }); + + scope.run(); + + String firstResponse = Promise.anyOf(results).get(); + + scope.cancel(); + + // Wait for all activities to finish cancellation + for (Promise activityResult : results) { + try { + activityResult.get(); + } catch (ActivityFailure e) { + if (!(e.getCause() instanceof CanceledFailure)) { + throw e; + } + } + } + + return firstResponse; + } +} +``` + + + + +```typescript +// workflows.ts +import { + proxyActivities, + CancellationScope, + ActivityCancellationType, + isCancellation, +} from '@temporalio/workflow'; +import type * as activities from './activities'; + +const { sampleActivity } = proxyActivities({ + startToCloseTimeout: '2m', + heartbeatTimeout: '10s', + cancellationType: ActivityCancellationType.WAIT_CANCELLATION_COMPLETED, +}); + +export async function pickFirstWithCleanup(): Promise { + return await CancellationScope.cancellable(async () => { + const p1 = sampleActivity('branch1'); + const p2 = sampleActivity('branch2'); + const firstResponse = await Promise.race([p1, p2]); + CancellationScope.current().cancel(); + + // Wait for all activities to finish cancellation + const results = [p1, p2]; + for (const p of results) { + try { + await p; + } catch (err) { + if (!isCancellation(err)) throw err; + } + } + + return firstResponse; + }); +} +``` + + + + +Each SDK provides a way to wait for cancelled Activities to finish their cleanup: + +- **Go** sets `WaitForCancellation: true` in the Activity options, then calls `Get` on all futures after cancelling. +- **TypeScript** sets `cancellationType: ActivityCancellationType.WAIT_CANCELLATION_COMPLETED` in the Activity options, then awaits all promises while catching cancellation errors with `isCancellation()`. +- **Python** sets `cancellation_type=workflow.ActivityCancellationType.WAIT_CANCELLATION_COMPLETED`, then awaits pending tasks while catching `asyncio.CancelledError`. +- **Java** sets `setCancellationType(ActivityCancellationType.WAIT_CANCELLATION_COMPLETED)`, then calls `get()` on all promises while catching `ActivityFailure` with a `CanceledFailure` cause. + +## When to use + +The Pick First pattern is a good fit for racing multiple data sources (primary vs backup), trying multiple algorithms and picking the fastest, implementing fallback strategies with timeout, optimizing for latency when multiple options exist, and testing multiple service endpoints for fastest response. + +It is not a good fit when you need results from all Activities (use parallel execution), Activities have side effects that should not be cancelled, order matters (use sequential execution), or all Activities must complete. + +## Benefits and trade-offs + +The pattern returns as soon as the fastest option completes, optimizing for latency. +Unnecessary work is cancelled automatically. +Each SDK's race mechanism ensures replay consistency, and cancellation cleanup is handled properly. + +The trade-offs to consider are that cancelled Activities may have done partial work. +Activities need heartbeats to detect cancellation quickly. +Activities do not cancel instantly (they wait for the next heartbeat). +You must implement proper cancellation handling in Activities. +Only the first result is used; others are discarded. + +## Comparison with alternatives + +| Approach | Returns first | Cancels others | Complexity | Use case | +| :--- | :--- | :--- | :--- | :--- | +| Pick First | Yes | Yes | Medium | Race for fastest | +| Parallel Execution | No | No | Low | All must complete | +| Sequential | No | N/A | Low | Order matters | +| Split/Merge | No | No | Medium | Aggregate results | + +## Best practices + +- **Use heartbeats.** Activities must heartbeat to detect cancellation quickly. +- **Configure cancellation wait behavior.** Decide if the Workflow should wait for cleanup to complete before returning. +- **Handle cancellation in Activities.** Activities must check for cancellation signals and exit cleanly. +- **Use a shared cancellable context.** Use a single cancellable context or scope for all raced Activities. +- **Track futures or tasks.** Keep references to all futures or tasks if waiting for cleanup. +- **Set Activity timeouts.** Configure appropriate StartToCloseTimeout and HeartbeatTimeout. +- **Log cancellations.** Log when Activities are cancelled for observability. +- **Design idempotent Activities.** Ensure Activities handle cancellation safely. + +## Common pitfalls + +- **Missing heartbeats in Activities.** Activities must heartbeat to detect cancellation. Without heartbeats, cancelled Activities continue running until their StartToCloseTimeout expires, wasting resources. +- **Not waiting for cancellation cleanup.** Without configuring the cancellation type to wait for completion (e.g., `WaitForCancellation: true` in Go, `WAIT_CANCELLATION_COMPLETED` in other SDKs), fetching a cancelled Activity's result returns a cancellation error immediately, before the Activity has finished cleanup. Configure this setting if you need to wait for cleanup to complete. +- **Ignoring errors from the winning Activity.** The first Activity to complete might return an error. Always check the result for errors rather than assuming success. +- **Forgetting to cancel remaining Activities.** If you forget to cancel the shared context or scope after receiving the first result, the remaining Activities continue running indefinitely. + +## Related patterns + +- **[Parallel Execution](/design-patterns/parallel-execution)**: Execute in parallel and combine all results. + +## Sample code + +- [Go Sample](https://github.com/temporalio/samples-go/tree/main/pickfirst) — Complete implementation with Worker and starter. +- [TypeScript Sample](https://github.com/temporalio/samples-typescript/tree/main/activities-cancellation-heartbeating) — Activities with cancellation and heartbeating. +- [Java Sample](https://github.com/temporalio/samples-java/tree/main/core/src/main/java/io/temporal/samples/hello) — Hello samples including cancellation scope patterns. +- [Python Sample](https://github.com/temporalio/samples-python) — Python SDK samples with async patterns. diff --git a/docs/design-patterns/polling.mdx b/docs/design-patterns/polling.mdx new file mode 100644 index 0000000000..a6bf2d5371 --- /dev/null +++ b/docs/design-patterns/polling.mdx @@ -0,0 +1,742 @@ +--- +id: polling +title: "Polling External Services" +sidebar_label: "Polling External Services" +description: "Strategies for polling external resources with varying frequencies: frequent, infrequent, and periodic patterns." +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +## Overview + +The Polling External Services pattern implements strategies for periodically checking external systems until a desired state is reached. +It enables Workflows to wait for asynchronous operations in third-party services that do not support callbacks, making it essential for integrating with REST APIs, job queues, and batch processing systems. + +## Problem + +In distributed systems, you often need Workflows that wait for external jobs to complete, poll REST APIs that do not provide webhooks, check the status of long-running operations in third-party systems, handle varying poll frequencies, and avoid overwhelming external services with requests. + +Without proper polling strategies, you must implement complex retry logic manually, risk unbounded Workflow history growth, choose between responsiveness and resource efficiency, and handle heartbeating and timeout management yourself. + +## Solution + +You can use Temporal to implement three distinct polling strategies, each optimized for different polling frequencies and requirements: + +1. **Frequent Polling (1 second or faster)**: Loop inside an Activity with heartbeats. +2. **Infrequent Polling (1 minute or slower)**: Use Activity retries with fixed backoff. +3. **Periodic Sequence**: Use Child Workflows for complex polling sequences. + +```mermaid +flowchart TD + Start([Polling Required]) --> Freq{Polling
Frequency?} + + Freq -->|≤1 second| Fast[Frequent Polling] + Freq -->|≥1 minute| Slow[Infrequent Polling] + Freq -->|Complex| Complex[Periodic Sequence] + + Fast --> FastImpl[Activity with loop
+ heartbeats] + Slow --> SlowImpl[Activity retries
backoffCoefficient=1] + Complex --> ComplexImpl[Child workflow
+ Continue-As-New] +``` + +The following describes each path in the diagram: + +1. If you need polling at 1-second intervals or faster, use frequent polling with an Activity loop and heartbeats. +2. If you need polling at 1-minute intervals or slower, use infrequent polling with Activity retries and a fixed backoff coefficient. +3. If you need complex multi-step polling or changing parameters between attempts, use a periodic sequence with Child Workflows and Continue-As-New. + +## Implementation + +### Frequent polling (fast response required) + +For polling intervals of 1 second or faster, implement the polling loop inside the Activity with heartbeats. +The heartbeat reports progress and enables Temporal to detect stuck Activities: + + + + +```python +# activities.py +from temporalio import activity +import asyncio + +@activity.defn +async def do_poll() -> str: + while True: + activity.heartbeat() + + result = await external_service.check_status() + + if result == "COMPLETED": + return result + + await asyncio.sleep(1) +``` + + + + +```go +// activities.go +func DoPoll(ctx context.Context) (string, error) { + for { + activity.RecordHeartbeat(ctx) + + result, err := externalService.CheckStatus() + if err != nil { + return "", err + } + + if result == "COMPLETED" { + return result, nil + } + + select { + case <-ctx.Done(): + return "", ctx.Err() + case <-time.After(1 * time.Second): + } + } +} +``` + + + + +```java +// FrequentPollingActivityImpl.java +@ActivityInterface +public interface PollingActivities { + String doPoll(); +} + +public class FrequentPollingActivityImpl implements PollingActivities { + @Override + public String doPoll() { + while (true) { + Activity.getExecutionContext().heartbeat(null); + + String result = externalService.checkStatus(); + + if (result.equals("COMPLETED")) { + return result; + } + + try { + Thread.sleep(1000); + } catch (InterruptedException e) { + throw Activity.wrap(e); + } + } + } +} +``` + + + + +```typescript +// activities.ts +import { heartbeat, sleep } from '@temporalio/activity'; + +export async function doPoll(): Promise { + while (true) { + heartbeat(); + + const result = await externalService.checkStatus(); + + if (result === 'COMPLETED') { + return result; + } + + await sleep('1s'); + } +} +``` + + + + +The Activity loops indefinitely, heartbeating on each iteration. +If the Worker crashes, the heartbeat timeout expires and Temporal retries the Activity on another Worker. + +The Workflow configures the Activity with a heartbeat timeout shorter than the start-to-close timeout: + + + + +```python +# workflows.py +from datetime import timedelta +from temporalio import workflow + +with workflow.unsafe.imports_passed_through(): + from activities import do_poll + +@workflow.defn +class FrequentPollingWorkflow: + @workflow.run + async def run(self) -> str: + return await workflow.execute_activity( + do_poll, + start_to_close_timeout=timedelta(seconds=60), + heartbeat_timeout=timedelta(seconds=2), + ) +``` + + + + +```go +// workflow.go +func FrequentPollingWorkflow(ctx workflow.Context) (string, error) { + ao := workflow.ActivityOptions{ + StartToCloseTimeout: 60 * time.Second, + HeartbeatTimeout: 2 * time.Second, + } + ctx = workflow.WithActivityOptions(ctx, ao) + + var result string + err := workflow.ExecuteActivity(ctx, DoPoll).Get(ctx, &result) + return result, err +} +``` + + + + +```java +// FrequentPollingWorkflowImpl.java +public class FrequentPollingWorkflowImpl implements PollingWorkflow { + @Override + public String exec() { + ActivityOptions options = ActivityOptions.newBuilder() + .setStartToCloseTimeout(Duration.ofSeconds(60)) + .setHeartbeatTimeout(Duration.ofSeconds(2)) + .build(); + + PollingActivities activities = Workflow.newActivityStub(PollingActivities.class, options); + return activities.doPoll(); + } +} +``` + + + + +```typescript +// workflows.ts +import { proxyActivities } from '@temporalio/workflow'; +import type * as activities from './activities'; + +const { doPoll } = proxyActivities({ + startToCloseTimeout: '60s', + heartbeatTimeout: '2s', +}); + +export async function frequentPollingWorkflow(): Promise { + return await doPoll(); +} +``` + + + + +The heartbeat timeout (2 seconds) is shorter than the start-to-close timeout (60 seconds). +If the Activity misses a heartbeat, Temporal detects the failure and retries the Activity. + +### Infrequent polling (resource efficient) + +For polling intervals of 1 minute or slower, use Activity retries with a backoff coefficient of 1. +The Activity throws an exception when the external service is not ready, and Temporal retries after the configured interval: + + + + +```python +# activities.py +from temporalio import activity +from temporalio.exceptions import ApplicationError + +@activity.defn +async def do_poll() -> str: + result = await external_service.check_status() + + if result != "COMPLETED": + raise ApplicationError("Service not ready, will retry") + + return result +``` + + + + +```go +// activities.go +func DoPoll(ctx context.Context) (string, error) { + result, err := externalService.CheckStatus() + if err != nil { + return "", err + } + + if result != "COMPLETED" { + return "", fmt.Errorf("service not ready, will retry") + } + + return result, nil +} +``` + + + + +```java +// InfrequentPollingActivityImpl.java +public class InfrequentPollingActivityImpl implements PollingActivities { + @Override + public String doPoll() { + String result = externalService.checkStatus(); + + if (!result.equals("COMPLETED")) { + throw new RuntimeException("Service not ready, will retry"); + } + + return result; + } +} +``` + + + + +```typescript +// activities.ts +import { ApplicationFailure } from '@temporalio/activity'; + +export async function doPoll(): Promise { + const result = await externalService.checkStatus(); + + if (result !== 'COMPLETED') { + throw ApplicationFailure.retryable('Service not ready, will retry'); + } + + return result; +} +``` + + + + +The Activity performs a single poll and throws if the service is not ready. +Temporal handles the retry scheduling. + +The Workflow configures the retry policy with a fixed interval: + + + + +```python +# workflows.py +from datetime import timedelta +from temporalio import workflow +from temporalio.common import RetryPolicy + +with workflow.unsafe.imports_passed_through(): + from activities import do_poll + +@workflow.defn +class InfrequentPollingWorkflow: + @workflow.run + async def run(self) -> str: + return await workflow.execute_activity( + do_poll, + start_to_close_timeout=timedelta(seconds=2), + retry_policy=RetryPolicy( + backoff_coefficient=1, + initial_interval=timedelta(seconds=60), + ), + ) +``` + + + + +```go +// workflow.go +func InfrequentPollingWorkflow(ctx workflow.Context) (string, error) { + ao := workflow.ActivityOptions{ + StartToCloseTimeout: 2 * time.Second, + RetryPolicy: &temporal.RetryPolicy{ + BackoffCoefficient: 1, + InitialInterval: 60 * time.Second, + }, + } + ctx = workflow.WithActivityOptions(ctx, ao) + + var result string + err := workflow.ExecuteActivity(ctx, DoPoll).Get(ctx, &result) + return result, err +} +``` + + + + +```java +// InfrequentPollingWorkflowImpl.java +public class InfrequentPollingWorkflowImpl implements PollingWorkflow { + @Override + public String exec() { + ActivityOptions options = ActivityOptions.newBuilder() + .setStartToCloseTimeout(Duration.ofSeconds(2)) + .setRetryOptions( + RetryOptions.newBuilder() + .setBackoffCoefficient(1) + .setInitialInterval(Duration.ofSeconds(60)) + .build()) + .build(); + + PollingActivities activities = Workflow.newActivityStub(PollingActivities.class, options); + return activities.doPoll(); + } +} +``` + + + + +```typescript +// workflows.ts +import { proxyActivities } from '@temporalio/workflow'; +import type * as activities from './activities'; + +const { doPoll } = proxyActivities({ + startToCloseTimeout: '2s', + retry: { + backoffCoefficient: 1, + initialInterval: '60s', + }, +}); + +export async function infrequentPollingWorkflow(): Promise { + return await doPoll(); +} +``` + + + + +Setting the backoff coefficient to 1 creates a fixed retry interval. +The initial interval of 60 seconds sets the polling frequency. +Retries do not add events to the Workflow history, keeping it small. + +### Periodic sequence (complex polling) + +For polling that requires multiple Activities or changing parameters between attempts, use Child Workflows with Continue-As-New. +The Child Workflow polls in a loop and calls Continue-As-New to prevent unbounded history: + + + + +```python +# workflows.py +from datetime import timedelta +from temporalio import workflow + +with workflow.unsafe.imports_passed_through(): + from activities import do_poll + +@workflow.defn +class PollingChildWorkflow: + @workflow.run + async def run(self, polling_interval_seconds: int) -> str: + max_attempts = 10 + + for _ in range(max_attempts): + result = await workflow.execute_activity( + do_poll, + start_to_close_timeout=timedelta(seconds=10), + ) + + if result == "COMPLETED": + return result + + await workflow.sleep(polling_interval_seconds) + + # Continue-as-new to prevent unbounded history + workflow.continue_as_new(polling_interval_seconds) +``` + + + + +```go +// workflow.go +func PollingChildWorkflow(ctx workflow.Context, pollingIntervalSeconds int) (string, error) { + ao := workflow.ActivityOptions{ + StartToCloseTimeout: 10 * time.Second, + } + ctx = workflow.WithActivityOptions(ctx, ao) + + maxAttempts := 10 + for i := 0; i < maxAttempts; i++ { + var result string + err := workflow.ExecuteActivity(ctx, DoPoll).Get(ctx, &result) + if err != nil { + return "", err + } + + if result == "COMPLETED" { + return result, nil + } + + workflow.Sleep(ctx, time.Duration(pollingIntervalSeconds)*time.Second) + } + + // Continue-as-new to prevent unbounded history + return "", workflow.NewContinueAsNewError(ctx, PollingChildWorkflow, pollingIntervalSeconds) +} +``` + + + + +```java +// PeriodicPollingChildWorkflowImpl.java +@WorkflowInterface +public interface PollingChildWorkflow { + @WorkflowMethod + String exec(int pollingIntervalInSeconds); +} + +public class PeriodicPollingChildWorkflowImpl implements PollingChildWorkflow { + @Override + public String exec(int pollingIntervalInSeconds) { + ActivityOptions options = ActivityOptions.newBuilder() + .setStartToCloseTimeout(Duration.ofSeconds(10)) + .build(); + + PollingActivities activities = Workflow.newActivityStub(PollingActivities.class, options); + + int maxAttempts = 10; + for (int i = 0; i < maxAttempts; i++) { + String result = activities.doPoll(); + + if (result.equals("COMPLETED")) { + return result; + } + + Workflow.sleep(Duration.ofSeconds(pollingIntervalInSeconds)); + } + + // Continue-as-new to prevent unbounded history + PollingChildWorkflow continueAsNew = Workflow.newContinueAsNewStub(PollingChildWorkflow.class); + continueAsNew.exec(pollingIntervalInSeconds); + return null; + } +} +``` + + + + +```typescript +// workflows.ts +import { proxyActivities, sleep, continueAsNew } from '@temporalio/workflow'; +import type * as activities from './activities'; + +const { doPoll } = proxyActivities({ + startToCloseTimeout: '10s', +}); + +export async function pollingChildWorkflow( + pollingIntervalSeconds: number +): Promise { + const maxAttempts = 10; + + for (let i = 0; i < maxAttempts; i++) { + const result = await doPoll(); + + if (result === 'COMPLETED') { + return result; + } + + await sleep(`${pollingIntervalSeconds}s`); + } + + // Continue-as-new to prevent unbounded history + await continueAsNew(pollingIntervalSeconds); + return ''; // unreachable +} +``` + + + + +The Child Workflow polls up to 10 times, sleeping between attempts. +After 10 attempts, it calls Continue-As-New to start a fresh execution with the same parameters. + +The parent Workflow starts the Child Workflow and waits for its result: + + + + +```python +# workflows.py +from temporalio import workflow + +@workflow.defn +class PeriodicPollingWorkflow: + @workflow.run + async def run(self) -> str: + return await workflow.execute_child_workflow( + PollingChildWorkflow.run, + 5, + id="ChildWorkflowPoll", + ) +``` + + + + +```go +// workflow.go +func PeriodicPollingWorkflow(ctx workflow.Context) (string, error) { + cwo := workflow.ChildWorkflowOptions{ + WorkflowID: "ChildWorkflowPoll", + } + ctx = workflow.WithChildOptions(ctx, cwo) + + var result string + err := workflow.ExecuteChildWorkflow(ctx, PollingChildWorkflow, 5).Get(ctx, &result) + return result, err +} +``` + + + + +```java +// PeriodicPollingWorkflowImpl.java +public class PeriodicPollingWorkflowImpl implements PollingWorkflow { + @Override + public String exec() { + PollingChildWorkflow childWorkflow = Workflow.newChildWorkflowStub( + PollingChildWorkflow.class, + ChildWorkflowOptions.newBuilder() + .setWorkflowId("ChildWorkflowPoll") + .build()); + + return childWorkflow.exec(5); + } +} +``` + + + + +```typescript +// workflows.ts +import { executeChild } from '@temporalio/workflow'; +import { pollingChildWorkflow } from './polling-child-workflow'; + +export async function periodicPollingWorkflow(): Promise { + return await executeChild(pollingChildWorkflow, { + args: [5], + workflowId: 'ChildWorkflowPoll', + }); +} +``` + + + + +The parent remains blocked and is unaware of the child's Continue-As-New calls. +When the child completes, the parent receives the result. + +## When to use + +### Frequent polling (1 second or faster) + +This strategy is a good fit for real-time status checks, high-priority operations requiring fast response, and short-lived external operations (minutes, not hours). +It is not a good fit for long-running operations (hours or days), rate-limited APIs, or resource-constrained external services. + +### Infrequent polling (1 minute or slower) + +This strategy is a good fit for batch job completion checks, long-running external processes, rate-limited APIs, and operations that may take hours or days. +It is not a good fit for sub-minute polling requirements or operations requiring immediate response. + +### Periodic sequence + +This strategy is a good fit for multi-step polling sequences, changing Activity parameters between polls, and very long-running polls requiring Continue-As-New. +It is not a good fit when the frequent or infrequent patterns are sufficient. + +## Benefits and trade-offs + +All three strategies work with any external service that does not support callbacks. +Temporal handles retry scheduling and fault tolerance automatically. +All timing is based on Workflow time, ensuring deterministic behavior. + +Frequent polling provides fast response but consumes more resources and requires heartbeating. +Infrequent polling is resource-efficient with minimal history growth but has a minimum 1-minute interval. +Periodic sequence is the most flexible but adds complexity through Child Workflow management. + +## Comparison with alternatives + +| Strategy | Poll frequency | History impact | Complexity | Best for | +| :--- | :--- | :--- | :--- | :--- | +| Frequent Polling | 1 second or faster | Medium | Low | Real-time checks | +| Infrequent Polling | 1 minute or slower | Minimal | Low | Long operations | +| Periodic Sequence | Any | Low (with CAN) | Medium | Complex sequences | +| Workflow Timer Loop | Any | High | Medium | Avoid this approach | + +## Best practices + +- **Choose the right strategy.** Match polling frequency to the pattern. +- **Set appropriate timeouts.** HeartbeatTimeout must be shorter than StartToCloseTimeout for frequent polling. +- **Handle failures gracefully.** Distinguish transient from permanent failures. +- **Add exponential backoff.** Use backoff for error cases (not normal polling). +- **Implement circuit breakers.** Protect external services from overload. +- **Use Continue-As-New.** Prevent unbounded history in periodic sequences. +- **Monitor polling metrics.** Track poll attempts, success rates, and durations. +- **Respect rate limits.** Adjust polling frequency to API constraints. +- **Add jitter.** Prevent thundering herd when many Workflows poll simultaneously. +- **Consider webhooks.** If the external service supports callbacks, use async completion instead. + +## Common pitfalls + +- **Wrong pattern choice.** Using frequent polling for hour-long operations wastes resources. +- **Missing heartbeats.** Frequent polling without heartbeats causes delayed failure detection. +- **Unbounded history.** Not using Continue-As-New in periodic sequences leads to history limit failures. +- **Tight polling loops.** Polling too frequently overwhelms external services. +- **No timeout.** Polling indefinitely without max attempts or a deadline risks runaway Workflows. +- **Ignoring errors.** Not distinguishing between retryable and permanent failures leads to wasted retries. +- **Workflow timer loops.** Using Workflow timers instead of proper polling patterns bloats history. + +## Related patterns + +- **[Long-Running Activity](/design-patterns/long-running-activity)**: Reporting progress in long Activities. +- **[Continue-As-New](/design-patterns/continue-as-new)**: Managing unbounded Workflow history. + +## Sample code + +### Java +- [Frequent Polling](https://github.com/temporalio/samples-java/tree/main/core/src/main/java/io/temporal/samples/polling/frequent) — Fast polling with heartbeats. +- [Infrequent Polling](https://github.com/temporalio/samples-java/tree/main/core/src/main/java/io/temporal/samples/polling/infrequent) — Efficient long-interval polling. +- [Periodic Sequence](https://github.com/temporalio/samples-java/tree/main/core/src/main/java/io/temporal/samples/polling/periodicsequence) — Complex polling with Child Workflows. + +### TypeScript +- [Frequent Polling](https://github.com/temporalio/samples-typescript/tree/main/polling/frequent) — Fast polling with heartbeats. +- [Infrequent Polling](https://github.com/temporalio/samples-typescript/tree/main/polling/infrequent) — Efficient long-interval polling. +- [Periodic Sequence](https://github.com/temporalio/samples-typescript/tree/main/polling/periodic-sequence) — Complex polling with Child Workflows. + +### Python +- [Frequent Polling](https://github.com/temporalio/samples-python/tree/main/polling/frequent) — Fast polling with heartbeats. +- [Infrequent Polling](https://github.com/temporalio/samples-python/tree/main/polling/infrequent) — Efficient long-interval polling. +- [Periodic Sequence](https://github.com/temporalio/samples-python/tree/main/polling/periodic_sequence) — Complex polling with Child Workflows. + +### Go +- [Frequent Polling](https://github.com/temporalio/samples-go/tree/main/polling/frequent) — Fast polling with heartbeats. +- [Infrequent Polling](https://github.com/temporalio/samples-go/tree/main/polling/infrequent) — Efficient long-interval polling. +- [Periodic Sequence](https://github.com/temporalio/samples-go/tree/main/polling/periodicsequence) — Complex polling with Child Workflows. diff --git a/docs/design-patterns/priority-task-queues.mdx b/docs/design-patterns/priority-task-queues.mdx new file mode 100644 index 0000000000..ef03151f77 --- /dev/null +++ b/docs/design-patterns/priority-task-queues.mdx @@ -0,0 +1,248 @@ +--- +id: priority-task-queues +title: "Priority Task Queues" +sidebar_label: "Priority Task Queues" +description: "Assigns a priority level to Workflows and Activities so that time-sensitive work executes ahead of lower-priority work within a single Task Queue." +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +:::info[TLDR] +Assign a `PriorityKey` (1–5) to Workflows and Activities so **high priority work executes ahead of lower priority work** on a shared Task Queue. Use this when a flood of batch or background tasks would otherwise delay high-urgency requests. +::: + +## Overview + +The Priority Task Queues pattern assigns a `PriorityKey` to Workflows, Activities, and Child Workflows so that time-sensitive work executes ahead of lower-priority work within a single Task Queue—without requiring separate queues or routing logic. + +## Problem + +In a shared Task Queue, tasks execute in generally first-in-first-out (FIFO) order. When a large batch of low-priority work—nightly reports, bulk imports, background processing—floods the queue before time-sensitive requests arrive, the higher-priority requests wait behind the entire batch. A single queue with no ordering mechanism gives equal treatment to all tasks regardless of business urgency. + +## Solution + +Temporal's native Priority feature lets you assign a `PriorityKey` (an integer from 1 to 5, where 1 is the highest priority and 5 is the lowest) to any Workflow, Activity, or Child Workflow. The Temporal matching service maintains a sub-queue for each priority level and exhausts all tasks at a given level before dispatching to the next. Tasks default to priority 3 when no key is set. Activities and Child Workflows inherit the parent Workflow's priority unless they set their own. + +```mermaid +flowchart TD + WF1["Workflow\nPriorityKey=1\n(payment)"] --> TQ["my-task-queue"] + WF2["Workflow\nPriorityKey=3\n(default)"] --> TQ + WF3["Workflow\nPriorityKey=5\n(batch report)"] --> TQ + TQ --> P1["Priority 1\nsub-queue"] + TQ --> P3["Priority 3\nsub-queue"] + TQ --> P5["Priority 5\nsub-queue"] + P1 -->|dispatched first| W["Shared Workers"] + P3 -->|dispatched second| W + P5 -->|dispatched last| W + W --> DS["Downstream\nService"] +``` + +The following describes each step in the diagram: + +1. Workflows start with a `PriorityKey` in their start options. Payment workflows use priority 1; routine workflows default to 3; nightly batch reports use priority 5. +2. The Temporal matching service routes each task to the corresponding priority sub-queue inside the single Task Queue. +3. Workers poll the Task Queue and receive tasks in priority order: all priority-1 tasks are dispatched before any priority-2 task, and so on. +4. Activities and Child Workflows inherit the parent Workflow's `PriorityKey` unless they explicitly set their own. + +## Implementation + +Priority is enabled by default in Temporal Cloud and self-hosted Temporal. + +### Set Workflow priority at start + + + + +```python +from temporalio.common import Priority + +handle = await client.start_workflow( + ChargeCustomer.run, + id="charge-customer-wf", + task_queue="my-task-queue", + priority=Priority(priority_key=1), +) +``` + + + + +```go +we, err := c.ExecuteWorkflow( + context.Background(), + client.StartWorkflowOptions{ + ID: "charge-customer-wf", + TaskQueue: "my-task-queue", + Priority: temporal.Priority{PriorityKey: 1}, + }, + ChargeCustomer, +) +``` + + + + +```java +WorkflowOptions options = WorkflowOptions.newBuilder() + .setWorkflowId("charge-customer-wf") + .setTaskQueue("my-task-queue") + .setPriority(Priority.newBuilder().setPriorityKey(1).build()) + .build(); +ChargeCustomer workflow = client.newWorkflowStub(ChargeCustomer.class, options); +WorkflowClient.start(workflow::run); +``` + + + + +### Set Activity priority + +Activities inherit the parent Workflow's priority. Override the `PriorityKey` in `ActivityOptions` when an individual Activity should run at a different level than its Workflow. + + + + +```python +from temporalio.common import Priority + +# inside the workflow +result = await workflow.execute_activity( + process_payment, + start_to_close_timeout=timedelta(minutes=1), + priority=Priority(priority_key=1), +) +``` + + + + +```go +ao := workflow.ActivityOptions{ + StartToCloseTimeout: time.Minute, + Priority: temporal.Priority{PriorityKey: 1}, +} +ctx = workflow.WithActivityOptions(ctx, ao) +err := workflow.ExecuteActivity(ctx, ProcessPayment).Get(ctx, nil) +``` + + + + +```java +ActivityOptions options = ActivityOptions.newBuilder() + .setStartToCloseTimeout(Duration.ofMinutes(1)) + .setPriority(Priority.newBuilder().setPriorityKey(1).build()) + .build(); +PaymentActivities activities = Workflow.newActivityStub(PaymentActivities.class, options); +activities.processPayment(); +``` + + + + +### Set Child Workflow priority + + + + +```python +from temporalio.common import Priority + +# inside the parent workflow +result = await workflow.execute_child_workflow( + ProcessOrder.run, + id="process-order-child", + task_queue="my-task-queue", + priority=Priority(priority_key=2), +) +``` + + + + +```go +cwo := workflow.ChildWorkflowOptions{ + WorkflowID: "process-order-child", + TaskQueue: "my-task-queue", + Priority: temporal.Priority{PriorityKey: 2}, +} +ctx = workflow.WithChildOptions(ctx, cwo) +err := workflow.ExecuteChildWorkflow(ctx, ProcessOrder).Get(ctx, nil) +``` + + + + +```java +ChildWorkflowOptions options = ChildWorkflowOptions.newBuilder() + .setWorkflowId("process-order-child") + .setTaskQueue("my-task-queue") + .setPriority(Priority.newBuilder().setPriorityKey(2).build()) + .build(); +ProcessOrder child = Workflow.newChildWorkflowStub(ProcessOrder.class, options); +child.run(); +``` + + + + +### Set priority via CLI + +```sh +temporal workflow start \ + --type ChargeCustomer \ + --task-queue my-task-queue \ + --workflow-id charge-customer-wf \ + --input '{"customerId":"12345"}' \ + --priority-key 1 +``` + +## When to use + +This pattern is a good fit when your system mixes time-sensitive operations (payment processing, user-facing requests) with background or batch work (reporting, data imports, inventory management), and you want urgent tasks to proceed even during periods of high load. It also works well when you need to mark urgent tasks that should override normal processing—for example, triggering immediate re-runs of failed critical tasks. + +It is not a good fit when all work is effectively equal in urgency, when a continuously replenished high-priority backlog could starve lower-priority work indefinitely, or when you need hard capacity isolation between tiers (see dedicated queues per tier as a supplementary measure). If your concern is prioritizing work amongst tenants or customers, consider the [Fairness](/design-patterns/fairness) pattern, which distributes capacity proportionally using weighted fairness keys rather than strict ordering. + +## Benefits and trade-offs + +Native priority requires no extra queues, routing logic, or additional Worker pools. A single pool of Workers serves all priority levels, so idle capacity at low-priority levels is automatically used by higher-priority work without any additional configuration. + +Lower-priority tasks are blocked until all higher-priority tasks have started. In an environment with a continuously replenished high-priority backlog, low-priority tasks may be significantly delayed. The built-in `PriorityKey` range is 1–5; if more than five distinct levels are needed, the feature cannot accommodate them. + +## Comparison with alternatives + +| Approach | Isolation | Dynamic priority | Complexity | Scales to many priorities | +| :--- | :--- | :--- | :--- | :--- | +| Temporal PriorityKey (native) | Soft | Yes | Low | Yes (1–5 levels) | +| [Fairness](/design-patterns/fairness) | Soft | Yes | Low | Yes (unlimited keys) | +| Separate Task Queues per tier | Hard | No | Medium | No (static tiers) | +| Single queue (no control) | None | N/A | Lowest | N/A | +| External queue (Kafka, SQS) | Hard | Yes | High | Yes | + +## Best practices + +- **Use no more than five priority levels.** The `PriorityKey` range is 1–5. Keep levels coarse—for example, 1 = urgent, 3 = normal, 5 = batch—rather than mapping fine-grained business importance to many values. +- **Reserve priority 1 for genuinely urgent work.** If high priority is the fallback when no priority is specified, the highest level fills with routine work and the feature provides no benefit. The default is 3 when no key is set. +- **Set `PriorityKey` at Workflow start, not inside Workflow code.** Workflow code cannot change its own priority after it starts. Set the priority in the start options before execution begins. +- **Override Activity priority deliberately.** Activities inherit the parent Workflow's priority by default. Override only when a specific Activity must run at a different level than its Workflow. +- **Monitor queue depth per priority level.** Sustained backlog growth at a priority level signals that Worker capacity is insufficient for the submitted load at that level. + +## Common pitfalls + +- **Assigning priority 1 to all work by default.** When every caller sets the highest priority, the feature provides no ordering benefit. Establish an explicit policy for which work types qualify for each level. +- **Neglecting low-priority starvation.** Under sustained high load, priority-5 tasks may wait indefinitely. Use `ScheduleToStartTimeout` on low-priority activities to surface starvation as a visible failure. +- **Changing priority after scheduling.** `PriorityKey` is evaluated when a task enters the queue and cannot be changed while it waits. To re-prioritize an already-queued task, cancel it and reschedule with the new priority. +- **Assuming hard isolation between priority levels.** Priority controls dispatch order, not Worker capacity allocation. A priority-5 task may still consume a Worker slot that is then unavailable for a priority-1 task arriving a moment later. + +## Related patterns + +- **[Fairness](/design-patterns/fairness)**: Distribute capacity proportionally across tenants within a priority level using fairness keys. +- **[Downstream Rate Limiting](/design-patterns/downstream-rate-limiting)**: Cap absolute throughput to a downstream service regardless of task priority. +- **[Worker-Specific Task Queues](/design-patterns/worker-specific-taskqueue)**: Route Activities to a specific Worker host for resource or data affinity. + +## Sample code + +The official Temporal documentation provides SDK code examples for setting priority keys on Workflows, Activities, and Child Workflows across all supported languages: + +- [Task Queue Priority and Fairness — Temporal docs](/develop/task-queue-priority-fairness#task-queue-priority) diff --git a/docs/design-patterns/qos-throughput-patterns.mdx b/docs/design-patterns/qos-throughput-patterns.mdx new file mode 100644 index 0000000000..1842483a41 --- /dev/null +++ b/docs/design-patterns/qos-throughput-patterns.mdx @@ -0,0 +1,47 @@ +--- +id: qos-throughput-patterns +title: "QoS & Throughput Patterns" +sidebar_label: "QoS & Throughput Patterns" +description: "Pattern selection guide for controlling execution rate, protecting downstream services from overload, and ensuring fair capacity distribution across tenants." +--- + +import PatternCards from '@site/src/components/PatternCards'; + +These patterns control how fast work executes, protect downstream services from overload, and make sure no single caller or tenant monopolizes Worker capacity at the expense of others. + +## Patterns in this section + + + +## Choosing a pattern + +**A downstream dependency has a fixed rate limit**: use [Downstream Rate Limiting](/design-patterns/downstream-rate-limiting) to cap throughput at the Worker. + +**Urgent work must not wait behind bulk work**: use [Priority Task Queues](/design-patterns/priority-task-queues). + +**Multiple tenants share the same Workers**: use [Fairness](/design-patterns/fairness) to keep one tenant's burst from starving others. + +## Related sections + +- [Worker Configuration Patterns](/design-patterns/worker-configuration-patterns) — the Task Queue and Worker setup these patterns route through +- [Batch Processing Patterns](/design-patterns/batch-processing-patterns) — rate-control patterns for large record sets +- [Error Handling & Retry Patterns](/design-patterns/error-handling-patterns) — back off and retry when a rate limit is hit diff --git a/docs/design-patterns/request-response-via-updates.mdx b/docs/design-patterns/request-response-via-updates.mdx new file mode 100644 index 0000000000..fbb2a1518b --- /dev/null +++ b/docs/design-patterns/request-response-via-updates.mdx @@ -0,0 +1,310 @@ +--- +id: request-response-via-updates +title: "Request-Response via Updates" +sidebar_label: "Request-Response via Updates" +description: "Synchronous request-response with validation. Updates modify state and return results directly." +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +## Overview + +Workflow Updates enable synchronous request-response interactions where clients receive immediate, typed responses while the Workflow continues processing. +Updates modify Workflow state, validate inputs, and return results directly to the caller with strong consistency guarantees. + +## Problem + +In distributed systems, you often need Workflows that provide immediate feedback to clients (validation results, confirmation IDs), require strong consistency guarantees for operations, need typed error handling for validation failures, should validate inputs before accepting work, and allow external systems to modify Workflow state synchronously. + +Without Updates, clients must use Signals and poll via Queries (complex, eventually consistent), wait for entire Workflow completion (slow), implement complex coordination logic, and handle race conditions between Signals and Queries. + +## Solution + +Temporal's Update API executes an Update handler that can validate inputs, modify state, and return values synchronously. +The Update is recorded in Workflow history before returning, providing strong consistency. + +```mermaid +sequenceDiagram + participant Client + participant Workflow + participant State + + Client->>+Workflow: Update Request + Workflow->>Workflow: Validate Input + alt Validation Fails + Workflow-->>Client: Error Response + else Success + Workflow->>State: Modify State + State-->>Workflow: Updated + Workflow-->>Client: Typed Response + end + deactivate Workflow +``` + +The following describes each step in the diagram: + +1. The client sends an Update request to the Workflow. +2. The Workflow validates the input. If validation fails, it returns a typed error response. +3. If validation succeeds, the Workflow modifies its state and returns a typed response to the client. + +## Implementation + +The following examples show a task assignment Workflow that accepts tasks via Updates with validation. +The Update validator rejects requests when the task limit is reached, and the Update handler assigns the task and returns a result. + + + + +```python +# workflows.py +import uuid +from dataclasses import dataclass +from temporalio import workflow + +MAX_TASKS = 10 + +@dataclass +class AssignmentResult: + assignment_id: str + task_name: str + total_tasks: int + +@workflow.defn +class TaskWorkflow: + def __init__(self) -> None: + self.tasks: list[str] = [] + + @workflow.run + async def run(self) -> None: + await workflow.wait_condition(lambda: False) + + @workflow.update + async def assign_task(self, task_name: str) -> AssignmentResult: + assignment_id = str(uuid.uuid4()) + self.tasks.append(task_name) + return AssignmentResult( + assignment_id=assignment_id, + task_name=task_name, + total_tasks=len(self.tasks), + ) + + @assign_task.validator + def validate_assign_task(self, task_name: str) -> None: + if len(self.tasks) >= MAX_TASKS: + raise ValueError("Task limit reached") + + @workflow.query + def get_tasks(self) -> list[str]: + return list(self.tasks) +``` + + + + +```go +// workflow.go +type TaskWorkflow struct{} + +const MaxTasks = 10 + +func (w *TaskWorkflow) Run(ctx workflow.Context) error { + tasks := []string{} + + err := workflow.SetUpdateHandlerWithOptions( + ctx, + "AssignTask", + func(ctx workflow.Context, taskName string) (AssignmentResult, error) { + assignmentID := uuid.New().String() + tasks = append(tasks, taskName) + return AssignmentResult{ + AssignmentID: assignmentID, + TaskName: taskName, + TotalTasks: len(tasks), + }, nil + }, + workflow.UpdateHandlerOptions{ + Validator: func(taskName string) error { + if len(tasks) >= MaxTasks { + return fmt.Errorf("task limit reached") + } + return nil + }, + }, + ) + if err != nil { + return err + } + + err = workflow.SetQueryHandler(ctx, "GetTasks", func() ([]string, error) { + return tasks, nil + }) + if err != nil { + return err + } + + workflow.GetSignalChannel(ctx, "").Receive(ctx, nil) + return nil +} +``` + + + + +```java +// TaskWorkflow.java +@WorkflowInterface +public interface TaskWorkflow { + @WorkflowMethod + void run(); + + @UpdateMethod + AssignmentResult assignTask(String taskName); + + @QueryMethod + List getTasks(); +} + +public class TaskWorkflowImpl implements TaskWorkflow { + private static final int MAX_TASKS = 10; + private List tasks = new ArrayList<>(); + + @Override + public void run() { + Workflow.await(() -> false); + } + + @UpdateValidatorMethod(updateName = "assignTask") + protected void validateAssignTask(String taskName) { + if (tasks.size() >= MAX_TASKS) { + throw new IllegalStateException("Task limit reached"); + } + } + + @Override + public AssignmentResult assignTask(String taskName) { + String assignmentId = UUID.randomUUID().toString(); + tasks.add(taskName); + + return new AssignmentResult(assignmentId, taskName, tasks.size()); + } + + @Override + public List getTasks() { + return new ArrayList<>(tasks); + } +} +``` + + + + +```typescript +// workflow.ts +import * as wf from '@temporalio/workflow'; + +interface AssignmentResult { + assignmentId: string; + taskName: string; + totalTasks: number; +} + +export const assignTaskUpdate = wf.defineUpdate('assignTask'); +export const getTasksQuery = wf.defineQuery('getTasks'); + +const MAX_TASKS = 10; + +export async function taskWorkflow(): Promise { + const tasks: string[] = []; + + wf.setHandler( + assignTaskUpdate, + (taskName: string): AssignmentResult => { + const assignmentId = wf.uuid4(); + tasks.push(taskName); + return { assignmentId, taskName, totalTasks: tasks.length }; + }, + { + validator: (taskName: string): void => { + if (tasks.length >= MAX_TASKS) { + throw new Error('Task limit reached'); + } + }, + } + ); + + wf.setHandler(getTasksQuery, (): string[] => tasks); + + await wf.condition(() => false); +} +``` + + + + +In all SDKs, the validator runs before the Update handler. +If the validator throws an exception, the Update is rejected and the client receives a typed error. +If the validator passes, the Update handler modifies state and returns a typed result. +The Update is recorded in Workflow history before the response is returned to the client. + +## When to use + +The Update pattern is a good fit for request-response patterns requiring immediate confirmation, input validation before accepting work, synchronous state modifications with typed responses, operations requiring strong consistency guarantees, and entity Workflows that need external state Updates. + +It is not a good fit for fire-and-forget operations (use Signals), read-only operations (use Queries), high-throughput scenarios where latency matters (Updates are slower than Signals), or operations that do not need an immediate response. + +## Benefits and trade-offs + +Benefits: + +- Updates provide a synchronous response — the client receives a typed return value immediately. +- Validation failures return as typed exceptions. +- The Update is recorded in history before returning, providing strong consistency. +- You can modify Workflow state directly from external systems. + +Trade-offs: + +- Updates are slower than Signals (they require a history write). +- The Update handler blocks Workflow Task execution and consumes Workflow Task execution time. +- For fire-and-forget messages that need no response, Updates require more machinery than Signals. +- Update arguments and return values are limited by the Workflow history event size (typically 2 MB per event). +- Each Update adds events to Workflow history, contributing to the 50K event limit. +- There is a maximum of 10 in-flight Updates per Workflow execution and a maximum of 2,000 total Updates in Workflow history. + +## Comparison with alternatives + +| Approach | Use case | Response type | Latency | Consistency | +| :--- | :--- | :--- | :--- | :--- | +| Update | Request-response | Sync typed value | Higher | Strong | +| Signal | Fire-and-forget | None | Lower | Eventual | +| Query | Read-only | Sync typed value | Lowest | Eventual | + +## Best practices + +- **Validate early.** Check inputs at the start of the Update handler to fail fast. +- **Handle errors.** Throw typed exceptions for validation failures. +- **Return quickly.** Do not perform long operations in the Update handler. +- **Track Update IDs only across Continue-As-New.** Within a single Workflow Execution, the Server deduplicates retried Updates automatically by Update ID, so a retry does not run the handler twice. You only need to track processed Update IDs in Workflow state when carrying them across a Continue-As-New boundary, because Update ID deduplication is scoped to a single Workflow Run. +- **Set timeouts.** Configure appropriate Update timeouts. +- **Maintain state consistency.** Ensure state modifications are atomic within the handler. + +## Common pitfalls + +- **Performing long operations in the Update handler.** Update handlers block Workflow Task execution. Offload long-running work to Activities and use `Workflow.await` in the handler to wait for results. +- **Exceeding the 2,000 total Updates limit.** Each accepted Update adds events to history. Use Continue-As-New before reaching the limit. The server sets `SuggestContinueAsNew` at 90% of the limit. +- **Not setting Update timeouts.** Without a client-side timeout, the caller blocks indefinitely if the Worker is unavailable. Always set a context timeout or deadline. +- **Assuming you must set an Update ID for retry safety.** The SDK auto-generates a unique `updateId` when you do not provide one, and retried client calls are deduplicated automatically, so a transient retry will not run the handler twice. Set a stable, business-meaningful `updateId` when you want a retried Update-with-Start to attach to an existing in-flight Update instead of starting duplicate work. +- **Using Updates for fire-and-forget.** Updates require a Worker to be online and responsive. For fire-and-forget operations, use Signals instead. + +## Related patterns + +- **Signal**: Fire-and-forget state modifications. +- **Query**: Read-only state inspection. +- **[Entity Workflow](/design-patterns/entity-workflow)**: Long-running Workflows representing business entities. +- **[Early Return](/design-patterns/early-return)**: Returning intermediate results before Workflow completion. + +## Sample code + +- [Safe Message Handlers (Python)](https://github.com/temporalio/samples-python/tree/main/message_passing/safe_message_handlers) — Concurrent Update handling with validation. +- [Safe Message Passing (Java)](https://github.com/temporalio/samples-java/tree/main/core/src/main/java/io/temporal/samples/safemessagepassing) — Concurrent Update handling with validation. +- [Update with Start - Shopping Cart (Go)](https://github.com/temporalio/samples-go/tree/main/shoppingcart) — Update-with-Start for lazy initialization. diff --git a/docs/design-patterns/resumable-activity.mdx b/docs/design-patterns/resumable-activity.mdx new file mode 100644 index 0000000000..60f5a9d9c5 --- /dev/null +++ b/docs/design-patterns/resumable-activity.mdx @@ -0,0 +1,562 @@ +--- +id: resumable-activity +title: "Resumable Activity (AKA Pause On Failure)" +sidebar_label: "Resumable Activity" +description: "Park the Workflow after retries are exhausted and wait for a human to signal a correction, then resume execution from where it left off." +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +:::info[TLDR] +After retries are exhausted, **park the Workflow in a waiting state and block on a Signal that notifies the Workflow to proceed or optionally delivers corrected input from a human operator, then re-execute the Activity.** Use this when failures are caused by bad input that can be fixed externally — so the Workflow resumes exactly where it left off instead of being restarted from scratch. +::: + +## Overview + +The Resumable Activity pattern parks a Workflow, durably waiting, after Activity retries are exhausted, waits for a corrective Signal from a human operator, then re-executes the Activity with the corrected input. +Use it when failures are caused by bad input data that can be corrected externally — a wrong account number, an invalid reference, a missing record that will be created — and abandoning the Workflow is worse than pausing it. + +## Problem + +When an Activity fails due to bad input, retrying with the same input will never succeed. +The standard options are: + +- **Fail the Workflow immediately.** The client must restart the process from scratch, often re-entering the same data that caused the failure. +- **Mark the error as non-retryable.** Same result — the Workflow fails and the client has no way to inject a correction. +- **Poll for the correction from inside the Workflow.** Wastes resources for a fix that may never come. + +What you actually want is for the Workflow to *pause* — consuming zero resources — until an authorized operator provides corrected data, then *resume* exactly where it left off. +Temporal's durable execution model makes this possible without any external database, queue, or polling mechanism. + +## Solution + +Use a bounded `RetryPolicy` to allow a few automatic retries (in case the failure is transient), then catch the exhausted `ActivityError` in the Workflow. +Transition to an `AWAITING_CORRECTION` state and block on `workflow.wait_condition` (or equivalent). +Register a Signal handler that accepts the corrected input and unblocks the condition. +When the Signal arrives, re-execute the Activity with the corrected input. +A second Signal gates the final approval before completing. + +```mermaid +sequenceDiagram + participant Client + participant Admin + participant Workflow + participant Activity as Transfer Activity + + Client->>+Workflow: Start transfer(from, invalid-account, $500) + Workflow->>Workflow: status = TRANSFERRING + + loop maxAttempts=3 + Workflow->>+Activity: executeTransfer(invalid-account) + Activity-->>-Workflow: Failure — account not found + end + + Note over Workflow: Retries exhausted + Workflow->>Workflow: status = AWAITING_CORRECTION + Note over Workflow: Parked in Temporal — zero cost, no polling + + Admin->>Workflow: Signal: retryWithCorrection("account-123") + Workflow->>Workflow: status = TRANSFERRING + + Workflow->>+Activity: executeTransfer(account-123) + Activity-->>-Workflow: Success + + Workflow->>Workflow: status = AWAITING_APPROVAL + Note over Workflow: Parked again — waiting for client approval + + Client->>Workflow: Signal: approve(true) + Workflow-->>-Client: Transfer completed +``` + +The following describes each step: + +1. The client starts the Workflow with an invalid account number. +2. The Activity fails. Temporal retries automatically up to the configured `maxAttempts`. +3. When retries are exhausted, the Workflow catches the `ActivityError` and sets its status to `AWAITING_CORRECTION`. +4. The Workflow parks itself using `wait_condition` — it consumes no CPU, no polling, no timers. Its state is fully persisted in Temporal. +5. An admin notices the problem (via Temporal UI, an alert, or an operations dashboard) and sends a `retryWithCorrection` Signal with the corrected account number. +6. The Workflow wakes up, applies the correction, and re-executes the Activity — which now succeeds. +7. The Workflow transitions to `AWAITING_APPROVAL` and parks again, waiting for the client to approve the transfer. +8. The client sends an `approve` Signal. The Workflow completes and returns the result. + +The key insight: **the Workflow never died**. It survived bad input, waited indefinitely without polling, accepted an external correction, and completed cleanly. Its entire state — status, corrected account, approval decision — is durable in Temporal throughout. + +## Implementation + +### Workflow with correction and approval signals + +The Workflow maintains state as named fields. +Signal handlers set the fields, and `wait_condition` blocks until they are non-null. + + + + +```python +# workflows.py +from dataclasses import dataclass +from datetime import timedelta +from temporalio import workflow +from temporalio.common import RetryPolicy, SearchAttributeKey +from temporalio.exceptions import ActivityError +import activities + +TRANSFER_STATUS_KEY = SearchAttributeKey.for_keyword("TransferStatus") + +@dataclass +class TransferInput: + from_account: str + to_account: str + amount: float + +@workflow.defn +class TransferWorkflow: + def __init__(self) -> None: + self._status = "PENDING" + self._corrected_account: str | None = None + self._approval: bool | None = None + + @workflow.run + async def run(self, transfer: TransferInput) -> str: + account = transfer.to_account + correction_attempts = 0 + + while True: + self._status = "TRANSFERRING" + try: + result = await workflow.execute_activity( + activities.execute_transfer, + TransferInput(transfer.from_account, account, transfer.amount), + start_to_close_timeout=timedelta(seconds=30), + retry_policy=RetryPolicy(maximum_attempts=3), + ) + break # Activity succeeded — exit the correction loop + except ActivityError: + correction_attempts += 1 + if correction_attempts > 5: + self._status = "FAILED" + workflow.upsert_search_attributes([TRANSFER_STATUS_KEY.value_set(self._status)]) + raise + self._status = "AWAITING_CORRECTION" + workflow.upsert_search_attributes([TRANSFER_STATUS_KEY.value_set(self._status)]) + workflow.logger.warning( + "Transfer failed — waiting for account correction", + extra={"to_account": account}, + ) + # Park until the admin sends a correction signal + await workflow.wait_condition( + lambda: self._corrected_account is not None + ) + account = self._corrected_account + self._corrected_account = None + + self._status = "AWAITING_APPROVAL" + await workflow.wait_condition(lambda: self._approval is not None) + + if self._approval: + self._status = "COMPLETED" + return f"Transfer of {transfer.amount} to {account} completed" + self._status = "REJECTED" + return "Transfer rejected by client" + + @workflow.signal + def retry_with_correction(self, corrected_account: str) -> None: + self._corrected_account = corrected_account + + @workflow.signal + def approve(self, approved: bool) -> None: + self._approval = approved + + @workflow.query + def get_status(self) -> str: + return self._status +``` + + + + +```go +// workflow.go +package transfer + +import ( + "fmt" + "time" + + "go.temporal.io/sdk/temporal" + "go.temporal.io/sdk/workflow" +) + +type TransferInput struct { + FromAccount string + ToAccount string + Amount float64 +} + +func TransferWorkflow(ctx workflow.Context, input TransferInput) (string, error) { + status := "PENDING" + if err := workflow.SetQueryHandler(ctx, "getStatus", func() (string, error) { + return status, nil + }); err != nil { + return "", err + } + + correctionCh := workflow.GetSignalChannel(ctx, "retryWithCorrection") + approvalCh := workflow.GetSignalChannel(ctx, "approve") + + ao := workflow.ActivityOptions{ + StartToCloseTimeout: 30 * time.Second, + RetryPolicy: &temporal.RetryPolicy{MaximumAttempts: 3}, + } + actCtx := workflow.WithActivityOptions(ctx, ao) + + account := input.ToAccount + correctionCount := 0 + for { + status = "TRANSFERRING" + err := workflow.ExecuteActivity(actCtx, ExecuteTransfer, TransferInput{ + FromAccount: input.FromAccount, + ToAccount: account, + Amount: input.Amount, + }).Get(actCtx, nil) + + if err == nil { + break // Activity succeeded — exit the correction loop + } + + correctionCount++ + if correctionCount > 5 { // for long correction cycles, consider Continue As New + status = "FAILED" + _ = workflow.UpsertSearchAttributes(ctx, map[string]interface{}{"TransferStatus": status}) + return "", err + } + status = "AWAITING_CORRECTION" + _ = workflow.UpsertSearchAttributes(ctx, map[string]interface{}{"TransferStatus": status}) + workflow.GetLogger(ctx).Warn("Transfer failed — waiting for account correction", + "to_account", account) + + // Park until the admin sends a correction signal + var corrected string + _ = workflow.Await(ctx, func() bool { + return correctionCh.ReceiveAsync(&corrected) + }) + account = corrected + } + + status = "AWAITING_APPROVAL" + var approved bool + _ = workflow.Await(ctx, func() bool { + return approvalCh.ReceiveAsync(&approved) + }) + + if approved { + status = "COMPLETED" + return fmt.Sprintf("Transfer of %.2f to %s completed", input.Amount, account), nil + } + status = "REJECTED" + return "Transfer rejected by client", nil +} +``` + + + + +```java +// TransferWorkflowImpl.java +import io.temporal.activity.ActivityOptions; +import io.temporal.common.RetryOptions; +import io.temporal.common.SearchAttributeKey; +import io.temporal.failure.ActivityFailure; +import io.temporal.workflow.SignalMethod; +import io.temporal.workflow.QueryMethod; +import io.temporal.workflow.WorkflowInterface; +import io.temporal.workflow.WorkflowMethod; +import io.temporal.workflow.Workflow; +import java.time.Duration; + +@WorkflowInterface +public interface TransferWorkflow { + @WorkflowMethod + String run(TransferInput input); + + @SignalMethod + void retryWithCorrection(String correctedAccount); + + @SignalMethod + void approve(boolean approved); + + @QueryMethod + String getStatus(); +} + +public class TransferWorkflowImpl implements TransferWorkflow { + private String status = "PENDING"; + private String correctedAccount; + private Boolean approval; + + private final TransferActivities activities = Workflow.newActivityStub( + TransferActivities.class, + ActivityOptions.newBuilder() + .setStartToCloseTimeout(Duration.ofSeconds(30)) + .setRetryOptions(RetryOptions.newBuilder() + .setMaximumAttempts(3) + .build()) + .build() + ); + + @Override + public String run(TransferInput input) { + String account = input.getToAccount(); + int correctionCount = 0; + + while (true) { + status = "TRANSFERRING"; + try { + activities.executeTransfer( + new TransferInput(input.getFromAccount(), account, input.getAmount()) + ); + break; // Activity succeeded — exit the correction loop + } catch (ActivityFailure e) { + correctionCount++; + if (correctionCount > 5) { // for long correction cycles, consider Continue As New + status = "FAILED"; + Workflow.upsertTypedSearchAttributes( + SearchAttributeKey.forKeyword("TransferStatus").valueSet(status) + ); + throw e; + } + status = "AWAITING_CORRECTION"; + Workflow.upsertTypedSearchAttributes( + SearchAttributeKey.forKeyword("TransferStatus").valueSet(status) + ); + Workflow.getLogger(getClass()).warn( + "Transfer failed — waiting for account correction: " + account + ); + // Park until the admin sends a correction signal + Workflow.await(() -> correctedAccount != null); + account = correctedAccount; + correctedAccount = null; + } + } + + status = "AWAITING_APPROVAL"; + Workflow.await(() -> approval != null); + + if (approval) { + status = "COMPLETED"; + return String.format("Transfer of %.2f to %s completed", input.getAmount(), account); + } + status = "REJECTED"; + return "Transfer rejected by client"; + } + + @Override + public void retryWithCorrection(String account) { + this.correctedAccount = account; + } + + @Override + public void approve(boolean decision) { + this.approval = decision; + } + + @Override + public String getStatus() { + return status; + } +} +``` + + + + +```typescript +// workflows.ts +import * as wf from '@temporalio/workflow'; +import type * as activities from './activities'; + +export interface TransferInput { + fromAccount: string; + toAccount: string; + amount: number; +} + +export const retryWithCorrectionSignal = wf.defineSignal<[string]>('retryWithCorrection'); +export const approveSignal = wf.defineSignal<[boolean]>('approve'); +export const getStatusQuery = wf.defineQuery('getStatus'); + +const { executeTransfer } = wf.proxyActivities({ + startToCloseTimeout: '30s', + retry: { maximumAttempts: 3 }, +}); + +export async function transferWorkflow(input: TransferInput): Promise { + let status = 'PENDING'; + let correctedAccount: string | undefined; + let approval: boolean | undefined; + + wf.setHandler(retryWithCorrectionSignal, (account: string) => { + correctedAccount = account; + }); + wf.setHandler(approveSignal, (decision: boolean) => { + approval = decision; + }); + wf.setHandler(getStatusQuery, () => status); + + let account = input.toAccount; + let correctionCount = 0; + + while (true) { + status = 'TRANSFERRING'; + try { + await executeTransfer({ ...input, toAccount: account }); + break; // Activity succeeded — exit the correction loop + } catch (err) { + correctionCount++; + if (correctionCount > 5) { + status = 'FAILED'; + wf.upsertSearchAttributes({ TransferStatus: [status] }); + throw err; + } + status = 'AWAITING_CORRECTION'; + wf.upsertSearchAttributes({ TransferStatus: [status] }); + wf.log.warn('Transfer failed — waiting for account correction', { account }); + // Park until the admin sends a correction signal + await wf.condition(() => correctedAccount !== undefined); + account = correctedAccount!; + correctedAccount = undefined; + } + } + + status = 'AWAITING_APPROVAL'; + await wf.condition(() => approval !== undefined); + + if (approval) { + status = 'COMPLETED'; + return `Transfer of ${input.amount} to ${account} completed`; + } + status = 'REJECTED'; + return 'Transfer rejected by client'; +} +``` + + + + +### Send signals + +An operator sends the correction Signal using the Temporal CLI or any SDK client. +The Workflow wakes immediately when the Signal is delivered. + +```bash +# Correct the account number +temporal workflow signal \ + --workflow-id transfer-wf-001 \ + --name retryWithCorrection \ + --input '"account-123"' + +# Approve the transfer +temporal workflow signal \ + --workflow-id transfer-wf-001 \ + --name approve \ + --input 'true' +``` + +### Activity implementation + +The `executeTransfer` Activity must distinguish between permanent failures — such as an invalid account number — and transient failures that Temporal should retry automatically. +Throw a non-retryable `ApplicationFailure` for permanent input errors so the Workflow catches the `ActivityError` immediately and transitions to `AWAITING_CORRECTION` instead of exhausting all retry attempts first. +Let all other exceptions propagate so the RetryPolicy handles transient failures. + + + + +```python +# activities.py +from temporalio import activity +from temporalio.exceptions import ApplicationError + +@activity.defn +async def execute_transfer(transfer: TransferInput) -> str: + # Non-retryable: bad account number requires a human correction, not a retry. + if not await account_service.exists(transfer.to_account): + raise ApplicationError( + f"Account {transfer.to_account} not found", + type="AccountNotFoundError", + non_retryable=True, + ) + # Other exceptions propagate as retryable so the RetryPolicy handles them. + return await payment_service.transfer( + transfer.from_account, transfer.to_account, transfer.amount + ) +``` + + + + +```typescript +// activities.ts +import { ApplicationFailure } from '@temporalio/activity'; +import type { TransferInput } from './workflows'; + +export async function executeTransfer(transfer: TransferInput): Promise { + // Non-retryable: bad account number requires a human correction, not a retry. + const accountExists = await accountService.exists(transfer.toAccount); + if (!accountExists) { + throw ApplicationFailure.nonRetryable( + `Account ${transfer.toAccount} not found`, + 'AccountNotFoundError', + ); + } + // Other exceptions propagate as retryable so the RetryPolicy handles them. + return paymentService.transfer(transfer.fromAccount, transfer.toAccount, transfer.amount); +} +``` + + + + +## State diagram + +The Workflow transitions through a well-defined set of states. +Query `getStatus` at any time to observe the current state. + +```mermaid +stateDiagram-v2 + [*] --> PENDING + PENDING --> TRANSFERRING : Workflow starts + TRANSFERRING --> AWAITING_CORRECTION : Activity retries exhausted + AWAITING_CORRECTION --> TRANSFERRING : retryWithCorrection signal received + AWAITING_CORRECTION --> FAILED : 5 correction attempts exceeded + TRANSFERRING --> AWAITING_APPROVAL : Activity succeeds + AWAITING_APPROVAL --> COMPLETED : approve(true) signal + AWAITING_APPROVAL --> REJECTED : approve(false) signal + COMPLETED --> [*] + REJECTED --> [*] + FAILED --> [*] +``` + +## Best practices + +- **Use a bounded `MaximumAttempts` before parking.** Allow a few automatic retries to recover from transient failures. Parking immediately on the first failure forces operators to intervene for problems that would have resolved on their own. +- **Manage history growth in long-running correction loops.** Each correction cycle — park, receive signal, re-execute Activity — adds events to the Workflow history (signal received, state transitions, Activity scheduled/completed). For workflows that may receive many corrections over time, use [Continue-As-New](/design-patterns/continue-as-new) to carry the current state into a fresh execution before the history grows too large, rather than relying solely on an arbitrary correction counter. +- **Expose status via a Query method.** The `getStatus` Query gives operations tooling visibility into where the Workflow is parked without requiring access to the Workflow history. +- **Validate the correction in the Signal handler.** Check that the corrected account is non-empty and matches the expected format before setting the state. An invalid correction parks the Workflow again, but a clear error message helps operators. +- **Log and record state at every transition.** The `AWAITING_CORRECTION` and `AWAITING_APPROVAL` states can last hours or days. Structured log lines at each transition make the audit trail clear. For operational visibility, also update a [Search Attribute](/visibility) at each transition (for example, a `Keyword` attribute storing the current status) so operators can filter and query workflows by state directly from the Temporal UI or CLI. +- **Notify the operator proactively.** The `AWAITING_CORRECTION` transition is a good point to send an alert — an email, a Slack message, or a ticket — rather than waiting for the operator to notice in the Temporal UI. +- **Distinguish this from the Approval pattern.** The [Approval](/design-patterns/approval) pattern gates forward progress on a human decision. This pattern recovers from failure with a human-supplied data correction. Both use Signals and `wait_condition`, but serve different roles in a process. + +## Common pitfalls + +- **Waiting without a timeout.** If operators never send the correction Signal, the Workflow waits indefinitely. Add durable timer if the process must resolve within a time bound. +- **Not clearing the correction state before re-entering the loop.** After applying the correction, set `corrected_account = None` (or equivalent) before the next Activity attempt. Otherwise, if the corrected activity also fails, the Workflow immediately re-uses the previous correction instead of waiting for a new one. +- **Accepting corrections in the wrong state.** If a Signal arrives while the Activity is running (not parked), the correction should be queued and applied after the current attempt completes. The Signal handler always runs — the condition check (`wait_condition`) determines when the Workflow acts on it. +- **Conflating the correction loop with a general retry loop.** This pattern is for correcting *input data*. For retrying the same call against a temporarily unavailable system, use [Fast/Slow Retries](/design-patterns/fast-slow-retries) instead. + +## Related patterns + +- [Approval](/design-patterns/approval): Human-in-the-loop gate for forward progress rather than failure recovery. +- [Non-Retryable Errors](/design-patterns/non-retryable-errors): Fail immediately without parking when the error is structural and no correction is expected. +- [Fast/Slow Retries](/design-patterns/fast-slow-retries): Infinite patient retries when the downstream system is temporarily unavailable. +- [Signal with Start](/design-patterns/signal-with-start): Start the Workflow and send the correction Signal atomically. +- [Error Handling & Retry Patterns](/design-patterns/error-handling-patterns): Overview and decision tree for all retry patterns. diff --git a/docs/design-patterns/retry-metrics.mdx b/docs/design-patterns/retry-metrics.mdx new file mode 100644 index 0000000000..63512d2d05 --- /dev/null +++ b/docs/design-patterns/retry-metrics.mdx @@ -0,0 +1,374 @@ +--- +id: retry-metrics +title: "Retry Alerting via Metrics" +sidebar_label: "Retry Alerting via Metrics" +description: "Emit a custom metric from inside the Activity when the attempt count crosses a threshold, surfacing silent persistent failures to on-call teams before an SLA breach." +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +:::info[TLDR] +Emit a counter metric from inside the Activity when the attempt number exceeds a threshold, using the SDK's built-in metrics scope. **Use this to surface silent, persistent failures to on-call teams before they breach an SLA** — without changing retry behavior or adding Workflow-level tracking. +::: + +## Overview + +The Retry Alerting via Metrics pattern emits a custom metric counter from inside the Activity whenever the attempt number exceeds a threshold. +Use it to surface silent, persistent failures to on-call teams before they breach an SLA — without modifying retry behavior or adding Workflow-level tracking. + +## Problem + +When an Activity retries indefinitely, failures are invisible at the system level until something breaks. +The Temporal UI shows the current attempt number, but on-call teams do not watch the UI continuously. +Without a metric or alert, a downstream system can be down for hours while the Workflow keeps retrying silently — and the first sign of a problem is an SLA breach or a user complaint. + +Common gaps: + +- A payment gateway is down. Workflows are retrying every 5 minutes. No alert fires until the merchant escalates. +- An email provider is rejecting requests. Activities are on attempt 50. No metric has been emitted. On-call has no signal. +- A third-party API degraded. Retries are accumulating. The engineering team learns about the problem from a customer, not from their own alerting. + +## Solution + +Read the current attempt number from the Activity execution context and emit a counter metric when it exceeds a threshold. +The metric is sent through the Temporal SDK's built-in metrics scope — the same pipeline used for SDK-internal metrics — so it flows to whatever metrics backend your Workers are already configured to use (Prometheus, StatsD, etc.) without additional setup. + +```mermaid +sequenceDiagram + participant Temporal as Temporal Service + participant Activity + participant Metrics as Metrics Backend + + loop Each retry attempt + Temporal->>+Activity: Execute (attempt N) + Activity->>Activity: Check attempt number + alt attempt > threshold (e.g. 5) + Activity->>Metrics: increment high_activity_error_count + end + Activity-->>-Temporal: Failure + Note over Temporal: Wait backoff interval + end + Note over Metrics: Alert fires when counter crosses threshold +``` + +The following describes each step: + +1. Temporal executes the Activity, passing the current attempt number in the execution context. +2. The Activity checks whether the attempt number exceeds the threshold. +3. If it does, the Activity increments a counter metric using the SDK's built-in metrics scope. +4. On failure, Temporal waits the backoff interval and retries. +5. The metrics backend accumulates the counter. Your alerting system fires when the counter or rate crosses a configured threshold. + +## Implementation + +### Emit a counter at high attempt counts + +Read the attempt number from the Activity info and emit a counter through the SDK metrics scope. +Configure the retry policy separately — the metric emission does not change retry behavior. + + + + +```python +# activities.py +from temporalio import activity +from temporalio.exceptions import ApplicationError + +ALERT_THRESHOLD = 5 + +@activity.defn +async def call_downstream_service(endpoint: str) -> str: + info = activity.info() + + if info.attempt > ALERT_THRESHOLD: + meter = activity.metric_meter() + meter.create_counter( + "high_activity_error_count", + "Activity has exceeded the failure attempt threshold", + ).add(1) + + # Attempt the actual work — raises on failure, triggering a retry + response = await downstream.call(endpoint) + return response.data +``` + + + + +```go +// activities.go +package downstream + +import ( + "context" + + "go.temporal.io/sdk/activity" +) + +const alertThreshold = 5 + +func CallDownstreamService(ctx context.Context, endpoint string) (string, error) { + info := activity.GetInfo(ctx) + + if info.Attempt > alertThreshold { + activity.GetMetricsHandler(ctx). + Counter("high_activity_error_count"). + Inc(1) + } + + // Attempt the actual work — returns an error on failure, triggering a retry + response, err := downstream.Call(endpoint) + if err != nil { + return "", err + } + return response.Data, nil +} +``` + + + + +```java +// CallDownstreamActivityImpl.java +import io.temporal.activity.Activity; +import io.temporal.activity.ActivityExecutionContext; + +public class CallDownstreamActivityImpl implements CallDownstreamActivity { + private static final int ALERT_THRESHOLD = 5; + + @Override + public String callDownstreamService(String endpoint) { + ActivityExecutionContext ctx = Activity.getExecutionContext(); + + if (ctx.getInfo().getAttempt() > ALERT_THRESHOLD) { + ctx.getMetricsScope() + .counter("HighActivityErrorCount") + .inc(1); + } + + // Attempt the actual work — throws on failure, triggering a retry + return downstream.call(endpoint).getData(); + } +} +``` + + + + +```typescript +// activities.ts +import { Context } from '@temporalio/activity'; + +const ALERT_THRESHOLD = 5; + +export async function callDownstreamService(endpoint: string): Promise { + const ctx = Context.current(); + + if (ctx.info.attempt > ALERT_THRESHOLD) { + ctx.metricMeter + .createCounter('high_activity_error_count') + .add(1); + } + + // Attempt the actual work — throws on failure, triggering a retry + const response = await downstream.call(endpoint); + return response.data; +} +``` + + + + +### Workflow configuration + +Configure the Activity in the Workflow with the desired retry policy. +The metric emission inside the Activity is independent of the retry configuration. + + + + +```python +# workflows.py +from datetime import timedelta +from temporalio import workflow +from temporalio.common import RetryPolicy +import activities + +@workflow.defn +class MonitoredRetryWorkflow: + @workflow.run + async def run(self, endpoint: str) -> str: + return await workflow.execute_activity( + activities.call_downstream_service, + endpoint, + start_to_close_timeout=timedelta(seconds=30), + retry_policy=RetryPolicy( + initial_interval=timedelta(seconds=5), + backoff_coefficient=2.0, + maximum_interval=timedelta(minutes=5), + # No maximum_attempts — retries indefinitely until success + ), + ) +``` + + + + +```go +// workflow.go +func MonitoredRetryWorkflow(ctx workflow.Context, endpoint string) (string, error) { + ao := workflow.ActivityOptions{ + StartToCloseTimeout: 30 * time.Second, + RetryPolicy: &temporal.RetryPolicy{ + InitialInterval: 5 * time.Second, + BackoffCoefficient: 2.0, + MaximumInterval: 5 * time.Minute, + // No MaximumAttempts — retries indefinitely until success + }, + } + ctx = workflow.WithActivityOptions(ctx, ao) + + var result string + err := workflow.ExecuteActivity(ctx, CallDownstreamService, endpoint).Get(ctx, &result) + return result, err +} +``` + + + + +```java +// MonitoredRetryWorkflowImpl.java +public class MonitoredRetryWorkflowImpl implements MonitoredRetryWorkflow { + private final CallDownstreamActivity activities = Workflow.newActivityStub( + CallDownstreamActivity.class, + ActivityOptions.newBuilder() + .setStartToCloseTimeout(Duration.ofSeconds(30)) + .setRetryOptions(RetryOptions.newBuilder() + .setInitialInterval(Duration.ofSeconds(5)) + .setBackoffCoefficient(2.0) + .setMaximumInterval(Duration.ofMinutes(5)) + // No setMaximumAttempts — retries indefinitely until success + .build()) + .build() + ); + + @Override + public String run(String endpoint) { + return activities.callDownstreamService(endpoint); + } +} +``` + + + + +```typescript +// workflows.ts +import * as wf from '@temporalio/workflow'; +import type * as activities from './activities'; + +const { callDownstreamService } = wf.proxyActivities({ + startToCloseTimeout: '30s', + retry: { + initialInterval: '5s', + backoffCoefficient: 2, + maximumInterval: '5m', + // No maximumAttempts — retries indefinitely until success + }, +}); + +export async function monitoredRetryWorkflow(endpoint: string): Promise { + return await callDownstreamService(endpoint); +} +``` + + + + +### Add dimension labels to the metric + +Add labels (tags) to the metric to identify which Activity type, endpoint, or Workflow is producing the high attempt counts. +This makes the metric actionable in dashboards and alerts. + + + + +```python +# activities.py +if info.attempt > ALERT_THRESHOLD: + meter = activity.metric_meter() + meter.create_counter( + "high_activity_error_count", + "Activity has exceeded the failure attempt threshold", + ).add(1, {"activity_type": info.activity_type, "endpoint": endpoint}) +``` + + + + +```go +// activities.go +if info.Attempt > alertThreshold { + activity.GetMetricsHandler(ctx). + WithTags(map[string]string{ + "activity_type": info.ActivityType.Name, + "endpoint": endpoint, + }). + Counter("high_activity_error_count"). + Inc(1) +} +``` + + + + +```java +// CallDownstreamActivityImpl.java +if (ctx.getInfo().getAttempt() > ALERT_THRESHOLD) { + ctx.getMetricsScope() + .tagged(ImmutableMap.of( + "activity_type", ctx.getInfo().getActivityType(), + "endpoint", endpoint + )) + .counter("HighActivityErrorCount") + .inc(1); +} +``` + + + + +```typescript +// activities.ts +if (ctx.info.attempt > ALERT_THRESHOLD) { + ctx.metricMeter + .createCounter('high_activity_error_count') + .add(1, { activity_type: ctx.info.activityType, endpoint }); +} +``` + + + + +## Best practices + +- **Choose a threshold above normal transient noise.** If your downstream system occasionally has 1–2 retry attempts under normal conditions, set the threshold at 5 or 10 so the metric only fires for genuinely sustained failures. +- **Emit on every attempt above the threshold, not only once.** Incrementing the counter on each high-attempt invocation allows alerting systems to detect both the onset and the duration of a problem by watching the counter rate. +- **Use the SDK metrics scope, not a third-party library.** The SDK scope integrates with your Worker's existing metrics pipeline and adds default labels such as namespace and task queue automatically. +- **Set up rate-based alerts, not count-based.** A count alert requires resetting or remembering the baseline. A rate alert (e.g., "more than 3 increments per minute") fires when the problem is active and clears when it resolves. +- **Combine with Fast/Slow Retries.** Emit the metric in the slow-phase Activity of a [Fast/Slow Retries](/design-patterns/fast-slow-retries) pattern to alert when the Workflow has been in the slow phase long enough to be a concern. + +## Common pitfalls + +- **Emitting the metric in the Workflow instead of the Activity.** The Workflow does not have access to the Activity's attempt number without passing it explicitly. The Activity context always has the current attempt number — use it there. +- **Alerting on the total counter value instead of the rate.** If the counter is cumulative, a single high-attempt event in the past will keep the counter elevated forever. Alert on the increment rate (events per minute) rather than the absolute count. +- **Not resetting alerting context on success.** If the Activity eventually succeeds after 50 attempts, the high-attempt metric has already fired. Ensure your alerting system can resolve the alert when the metric rate drops to zero. +- **Setting the threshold too low.** A threshold of 1 means the metric fires on the very first retry — which is normal behavior. Calibrate the threshold to your system's expected transient error rate. + +## Related patterns + +- [Fast/Slow Retries](/design-patterns/fast-slow-retries): Combine by emitting this metric inside the slow-phase Activity to alert when patient waiting has gone on too long. +- [Fixed Count of Retries](/design-patterns/fixed-count-retries): Cap attempts at a fixed number instead of alerting at a threshold. +- [Error Handling & Retry Patterns](/design-patterns/error-handling-patterns): Overview and decision tree for all retry patterns. diff --git a/docs/design-patterns/saga-pattern.mdx b/docs/design-patterns/saga-pattern.mdx new file mode 100644 index 0000000000..0131d99342 --- /dev/null +++ b/docs/design-patterns/saga-pattern.mdx @@ -0,0 +1,357 @@ +--- +id: saga-pattern +title: "Saga Pattern" +sidebar_label: "Saga Pattern" +description: "Manages distributed transactions with compensating actions. Each step has a compensation that undoes its effects if subsequent steps fail." +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +## Overview + +The Saga pattern manages distributed transactions across multiple services by coordinating a sequence of local transactions, each with a compensating action that can undo its effects if subsequent steps fail. + +## Problem + +In distributed systems, you need to maintain data consistency across multiple services or databases without using traditional ACID transactions. +When a multi-step business process fails partway through, you must undo the effects of completed steps to maintain system consistency. +Traditional two-phase commit does not scale well and creates tight coupling between services. + +## Solution + +You implement each step as a local transaction with a corresponding compensation transaction. +If any step fails, you execute compensation transactions in reverse order to undo the effects of all completed steps. +You register compensations as each step completes, then automatically trigger them when errors occur to ensure cleanup happens reliably. + +The following diagram shows the worked example used by the runner: opening a customer account in four steps, where `addBankAccount` simulates a downstream failure to trigger compensation. + +```mermaid +flowchart TD + Start([Start Saga]) --> Step1[Step 1: createAccount] + Step1 -->|Success| Step2[Step 2: addAddress] + Step1 -->|Failure| End([End: Failed]) + + Step2 -->|Success| Step3[Step 3: addClient] + Step2 -->|Failure| Comp1[clearPostalAddresses] + + Step3 -->|Success| Step4[Step 4: addBankAccount] + Step3 -->|Failure| Comp2[removeClient] + + Step4 -->|Success| Complete([End: Success]) + Step4 -->|Failure| Comp3[disconnectBankAccounts] + + Comp3 --> Comp2 + Comp2 --> Comp1 + Comp1 --> End + classDef success stroke-width:1px + classDef compensation stroke-width:1px + classDef complete stroke-width:1px + classDef fail stroke-width:1px + class Step1,Step2,Step3,Step4 success + class Comp1,Comp2,Comp3 compensation + class Complete complete + class End fail +``` + +The following describes each step in the diagram: + +1. The Saga begins by executing Step 1 (`createAccount`). +2. If Step 1 succeeds, the Workflow proceeds to Step 2 (`addAddress`). If it fails, the Saga ends immediately — no compensations are registered yet. +3. If Step 2 succeeds, the Workflow proceeds to Step 3 (`addClient`). If it fails, the Workflow runs `clearPostalAddresses`. +4. If Step 3 succeeds, the Workflow proceeds to Step 4 (`addBankAccount`). If it fails, the Workflow runs `removeClient`, then `clearPostalAddresses`. +5. If Step 4 succeeds, the Saga completes. If it fails, the Workflow runs all three compensations in reverse: `disconnectBankAccounts`, `removeClient`, `clearPostalAddresses`. Note that `disconnectBankAccounts` is registered before `addBankAccount` runs, so it executes even if `addBankAccount` failed mid-flight — its implementation must be idempotent. + +## Implementation + +The following examples show how each SDK implements the Saga pattern. +Each language uses a different mechanism to register and execute compensations, but the core principle is the same: register a compensation before or after each step, and run all compensations in reverse order on failure. + + + + +```python +# workflows.py +from temporalio import workflow + +@workflow.defn +class OpenAccountWorkflow: + @workflow.run + async def run(self, req: OpenAccountRequest) -> str: + compensations = [] + + try: + # Step 1: createAccount has no compensation — leaving an empty + # account stub on later failure is acceptable. + await workflow.execute_activity( + create_account, req, + start_to_close_timeout=timedelta(seconds=10), + ) + + # Register compensation for Step 2 BEFORE execution + compensations.append( + lambda: workflow.execute_activity( + clear_postal_addresses, req, + start_to_close_timeout=timedelta(seconds=10), + ) + ) + # Step 2: Add postal address + await workflow.execute_activity( + add_address, req, + start_to_close_timeout=timedelta(seconds=10), + ) + + # Register compensation for Step 3 BEFORE execution + compensations.append( + lambda: workflow.execute_activity( + remove_client, req, + start_to_close_timeout=timedelta(seconds=10), + ) + ) + # Step 3: Add client record + await workflow.execute_activity( + add_client, req, + start_to_close_timeout=timedelta(seconds=10), + ) + + # Register compensation for Step 4 BEFORE execution + compensations.append( + lambda: workflow.execute_activity( + disconnect_bank_accounts, req, + start_to_close_timeout=timedelta(seconds=10), + ) + ) + # Step 4: Link bank account (this step fails in the demo) + await workflow.execute_activity( + add_bank_account, req, + start_to_close_timeout=timedelta(seconds=10), + ) + except Exception: + # On error, run compensations in reverse order + for compensation in reversed(compensations): + await compensation() + raise +``` + + + + +```go +// open_account_workflow.go +func OpenAccountWorkflow(ctx workflow.Context, req OpenAccountRequest) error { + var compensations []func() + runCompensations := func() { + for i := len(compensations) - 1; i >= 0; i-- { + compensations[i]() + } + } + + // Step 1: CreateAccount has no compensation — leaving an empty account + // stub on later failure is acceptable. + if err := workflow.ExecuteActivity(ctx, CreateAccount, req).Get(ctx, nil); err != nil { + return err + } + + // Register compensation for Step 2 BEFORE execution + compensations = append(compensations, func() { + _ = workflow.ExecuteActivity(ctx, ClearPostalAddresses, req).Get(ctx, nil) + }) + if err := workflow.ExecuteActivity(ctx, AddAddress, req).Get(ctx, nil); err != nil { + runCompensations() + return err + } + + // Register compensation for Step 3 BEFORE execution + compensations = append(compensations, func() { + _ = workflow.ExecuteActivity(ctx, RemoveClient, req).Get(ctx, nil) + }) + if err := workflow.ExecuteActivity(ctx, AddClient, req).Get(ctx, nil); err != nil { + runCompensations() + return err + } + + // Register compensation for Step 4 BEFORE execution + compensations = append(compensations, func() { + _ = workflow.ExecuteActivity(ctx, DisconnectBankAccounts, req).Get(ctx, nil) + }) + if err := workflow.ExecuteActivity(ctx, AddBankAccount, req).Get(ctx, nil); err != nil { + runCompensations() + return err + } + + return nil +} +``` + + + + +```java +// OpenAccountWorkflow.java +@WorkflowInterface +public interface OpenAccountWorkflow { + @WorkflowMethod + String openAccount(OpenAccountRequest req); +} + +public class OpenAccountWorkflowImpl implements OpenAccountWorkflow { + private final Activities activities = Workflow.newActivityStub( + Activities.class, + ActivityOptions.newBuilder() + .setStartToCloseTimeout(Duration.ofSeconds(10)) + .build()); + + @Override + public String openAccount(OpenAccountRequest req) { + // Create a Saga instance with compensation options + Saga saga = new Saga(new Saga.Options.Builder() + .setParallelCompensation(false) // Run compensations sequentially + .build()); + + try { + // Step 1: createAccount has no compensation — leaving an empty + // account stub on later failure is acceptable. + activities.createAccount(req); + + // Register compensation for Step 2 BEFORE execution + saga.addCompensation(activities::clearPostalAddresses, req); + activities.addAddress(req); + + // Register compensation for Step 3 BEFORE execution + saga.addCompensation(activities::removeClient, req); + activities.addClient(req); + + // Register compensation for Step 4 BEFORE execution + saga.addCompensation(activities::disconnectBankAccounts, req); + activities.addBankAccount(req); + + return "Account " + req.accountId() + " opened"; + + } catch (Exception e) { + // On any error, run all registered compensations in reverse order + saga.compensate(); + throw e; + } + } +} +``` + + + + +```typescript +// workflows.ts +type Compensation = () => Promise; + +export async function openAccount(req: OpenAccountRequest): Promise { + const compensations: Compensation[] = []; + + try { + // Step 1: createAccount has no compensation — leaving an empty account + // stub on later failure is acceptable. + await acts.createAccount(req); + + // Register compensation for Step 2 BEFORE execution + compensations.unshift(() => acts.clearPostalAddresses(req)); + await acts.addAddress(req); + + // Register compensation for Step 3 BEFORE execution + compensations.unshift(() => acts.removeClient(req)); + await acts.addClient(req); + + // Register compensation for Step 4 BEFORE execution + compensations.unshift(() => acts.disconnectBankAccounts(req)); + await acts.addBankAccount(req); + + return `Account ${req.accountId} opened`; + } catch (err) { + // On error, run all compensations in reverse order (unshift keeps them in LIFO already) + for (const compensate of compensations) { + await compensate(); + } + throw err; + } +} +``` + + + + +The key differences between SDKs are: + +- **Go**: Uses a slice of closures and iterates from the end on error. (Some samples use `defer` instead — both achieve LIFO; the slice form makes the rollback trigger explicit.) +- **Python**: Uses a list with `reversed()` to iterate compensations in LIFO order on error. +- **TypeScript**: Uses an array with `unshift()` to maintain LIFO order, and manually iterates on error. +- **Java**: Uses the SDK's `Saga` helper to track compensations and trigger them with `saga.compensate()`. + +In all SDKs, compensations are registered before Activity execution and run in reverse order of registration. +All compensations must be idempotent and able to handle cases where the forward Activity never executed. + +### When to register compensations + +There are two approaches for when to register compensation Activities: + +1. **Register before Activity execution** (recommended for safety): This ensures the compensation runs even if the Activity fails after partial completion. For example, a credit card may be charged but the Activity fails before returning success. The compensation must be idempotent and handle cases where the forward Activity never executed (no-op). This is the safer default when Activities have side effects that may occur before failure. + +2. **Register after Activity execution** (appropriate when safe): This only compensates Activities that completed successfully. The compensation logic is simpler because you do not need to check whether the forward action occurred. This approach is appropriate when Activities are truly atomic (all-or-nothing). The risk is partial completion without compensation if the Activity fails mid-execution. + +The choice depends on your Activity's failure characteristics and whether the compensation can safely handle cases where the forward Activity never executed. +When in doubt, register compensations before execution and ensure they are idempotent. + +## When to use + +The Saga pattern is a good fit when you need to maintain consistency across multiple services or databases, traditional distributed transactions (two-phase commit) are too slow or unavailable, you can define compensating actions for each step in your business process, eventual consistency is acceptable for your use case, and you need to handle long-running transactions that may span hours or days. + +It is not a good fit for operations that require strong ACID consistency, single-service transactions that can use a local database transaction, processes where compensations cannot be defined, or operations that must appear atomic to external observers. + +## Benefits and trade-offs + +The Saga pattern maintains eventual consistency without distributed locks, and each service can use its own database and transaction model. +Temporal's durable execution guarantees that compensations will execute even after Worker failures. +The pattern scales better than two-phase commit protocols. + +The trade-offs to consider are that only eventual consistency is provided — intermediate states are visible to other processes. +You must design idempotent compensation Activities, and compensation logic must be maintained alongside forward logic. +Some operations may not have meaningful compensations. + +## Comparison with alternatives + +| Approach | Consistency | Rollback mechanism | Coupling | Scalability | +| :--- | :--- | :--- | :--- | :--- | +| Saga (orchestration) | Eventual | Compensating transactions | Loose | High | +| Two-phase commit | Strong (ACID) | Distributed lock/rollback | Tight | Low | +| Saga (choreography) | Eventual | Event-driven compensations | Very loose | High | +| Local transaction | Strong (ACID) | Database rollback | None | Single service | + +## Best practices + +- **Make all compensations idempotent.** Compensations may run even when the forward Activity never executed (if registered before execution) or may run multiple times on retry. Use idempotency keys to ensure safe re-execution. +- **Register compensations before Activity execution.** This ensures cleanup runs even if the Activity fails after partial completion. The compensation must handle the case where the forward action never occurred (no-op). +- **Use idempotency keys for forward Activities.** Pass a unique identifier (such as a client ID or Workflow ID) to each Activity so retries do not create duplicate side effects. +- **Set StartToCloseTimeout on compensation Activities.** Set a `StartToCloseTimeout` but avoid `ScheduleToCloseTimeout` on compensations. Do not set Workflow-level timeouts — let compensations retry until they succeed. +- **Use a disconnected context for cancellation compensation.** In Go, use `NewDisconnectedContext` to run compensation Activities after Workflow cancellation, since the original context is already cancelled. +- **Keep compensation payloads small.** Pass references (IDs, URLs) instead of full data objects to avoid exceeding the 2 MB payload limit. +- **Log compensation failures but continue.** If a compensation fails, log the error and continue executing remaining compensations. In production, alert for manual intervention on persistent compensation failures. +- **Re-throw the original error after compensating.** Always re-throw the original exception after running compensations so the Workflow reports the correct failure reason. + +## Common pitfalls + +- **Non-idempotent compensations.** Compensations may run even when the forward Activity never executed (if registered before execution) or may run multiple times on retry. All compensations must be idempotent. +- **Forgetting to register a compensation.** If a step succeeds but its compensation was never registered, a later failure leaves that step's effects permanently in place. +- **Compensations that can fail permanently.** If a compensation Activity fails with a non-retryable error, the Saga cannot fully roll back. Design compensations with generous retry policies. +- **Large payloads in compensation state.** Passing large objects through the compensation chain can exceed the 2 MB payload limit. Use references (IDs, URLs) instead of full data. +- **Swallowing the ContinueAsNew exception in TypeScript.** In TypeScript, `continueAsNew` works by throwing a special exception. A `catch` block that does not re-throw it, or a `finally` block that returns a value, silently prevents Continue-As-New. + +## Related patterns + +- **Retry Policies**: Often combined with the Saga pattern to handle transient failures before compensating. +- **[Child Workflows](/design-patterns/child-workflows)**: You can use Child Workflows to organize complex Sagas with multiple sub-Sagas. +- **[Long-Running Activity](/design-patterns/long-running-activity)**: Heartbeats work well with long-running compensation Activities. +- **[Early Return](/design-patterns/early-return)**: You can combine Early Return with the Saga pattern to return initialization results before compensation runs. + +## Sample code + +- [Go Sample](https://github.com/temporalio/samples-go/tree/main/saga) — Saga with `defer`-based compensations. +- [Java Sample](https://github.com/temporalio/samples-java/tree/main/core/src/main/java/io/temporal/samples/hello/HelloSaga.java) — Saga with the `Saga` API. +- [TypeScript Sample](https://github.com/temporalio/samples-typescript/tree/main/saga) — Saga with array-based compensations. +- [Python Sample](https://github.com/temporalio/samples-python) — Saga with list-based compensations. diff --git a/docs/design-patterns/signal-with-start.mdx b/docs/design-patterns/signal-with-start.mdx new file mode 100644 index 0000000000..9285e7d3a9 --- /dev/null +++ b/docs/design-patterns/signal-with-start.mdx @@ -0,0 +1,324 @@ +--- +id: signal-with-start +title: "Signal with Start Pattern" +sidebar_label: "Signal with Start" +description: "Starts a Workflow when Signaling it if it does not already exist. If already running, it receives the Signal directly." +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +## Overview + +Signal with Start is a pattern that lazily creates Workflows when Signaling them. +If the Workflow is already running, it receives the Signal; if not, the Workflow starts first and then receives the Signal. +This enables entity Workflows that only exist when needed and can receive operations throughout their lifetime. + +## Problem + +In distributed systems, you often need Workflows that represent long-lived entities (accounts, shopping carts, user sessions), consume events from streams (Kafka, SQS) and trigger certain behaviors of an aggregate or entity, receive multiple operations over time, should only exist when there is work to do, and need to handle the first operation without special client logic. + +Without Signal with Start, clients must check if the Workflow exists before Signaling, start the Workflow if it does not exist and then Signal it, handle race conditions when multiple clients try to start the same Workflow, and write complex coordination logic. + +## Solution + +Temporal's Signal with Start API atomically starts a Workflow (if not running) and delivers a Signal in a single operation. +The client does not need to know whether the Workflow exists — the platform handles it automatically. + +```mermaid +sequenceDiagram + participant C as Client + participant T as Temporal + participant W as Workflow + + C->>T: SignalWithStart(workflowId, signal, args) + T->>T: Check if workflow exists + alt Workflow does not exist + T->>W: Start Workflow + activate W + T->>W: Deliver Signal + W->>W: Process Signal + else Workflow already running + T->>W: Deliver Signal only (no start) + W->>W: Process Signal + end + deactivate W +``` + +The following describes each step in the diagram: + +1. The client calls SignalWithStart with a Workflow ID, Signal name, and arguments. +2. Temporal checks whether a Workflow with that ID is already running. +3. If the Workflow does not exist, Temporal starts it and then delivers the Signal. +4. If the Workflow is already running, Temporal delivers the Signal without starting a new instance. + +## Implementation + +### Basic Signal-With-Start + +The following examples show a shopping cart entity Workflow that is created lazily when the first item is added. +Each SDK uses its own API to atomically start the Workflow (if needed) and deliver the Signal. + + + + +```python +# client.py +from temporalio.client import Client +from workflows import ShoppingCartWorkflow, AddItemSignal + +async def add_item(client: Client, cart_id: str, item_id: str, product_id: str, quantity: int) -> None: + # Atomically start workflow (if needed) and deliver signal + await client.start_workflow( + ShoppingCartWorkflow.run, + id=f"cart-{cart_id}", + task_queue="carts", + start_signal="add_item", + start_signal_args=[AddItemSignal(item_id=item_id, product_id=product_id, quantity=quantity)], + ) + +# workflows.py +from dataclasses import dataclass +from temporalio import workflow + +@dataclass +class AddItemSignal: + item_id: str + product_id: str + quantity: int + +@dataclass +class CartItem: + product_id: str + quantity: int + +@workflow.defn +class ShoppingCartWorkflow: + def __init__(self) -> None: + self.processed_items: set[str] = set() + self.items: list[CartItem] = [] + + @workflow.run + async def run(self) -> None: + await workflow.wait_condition(lambda: False) # Run forever (entity workflow) + + @workflow.signal + def add_item(self, sig: AddItemSignal) -> None: + if sig.item_id in self.processed_items: + return # Idempotency: ignore duplicate signals + self.processed_items.add(sig.item_id) + self.items.append(CartItem(product_id=sig.product_id, quantity=sig.quantity)) +``` + + + + +```go +// client.go +func AddItem(ctx context.Context, cartID, itemID, productID string, quantity int) error { + opts := client.StartWorkflowOptions{ + ID: "cart-" + cartID, + TaskQueue: "carts", + } + + // Atomically start workflow (if needed) and deliver signal + sig := AddItemSignal{ItemID: itemID, ProductID: productID, Quantity: quantity} + _, err := c.SignalWithStartWorkflow(ctx, "cart-"+cartID, "addItem", sig, opts, ShoppingCartWorkflow) + return err +} + +// workflow.go +func ShoppingCartWorkflow(ctx workflow.Context) error { + processedItems := make(map[string]bool) + var items []CartItem + + addItemCh := workflow.GetSignalChannel(ctx, "addItem") + workflow.Go(ctx, func(ctx workflow.Context) { + for { + var sig AddItemSignal + addItemCh.Receive(ctx, &sig) + if processedItems[sig.ItemID] { + continue // Idempotency: ignore duplicate signals + } + processedItems[sig.ItemID] = true + items = append(items, CartItem{ProductID: sig.ProductID, Quantity: sig.Quantity}) + } + }) + + workflow.Await(ctx, func() bool { return false }) // Run forever (entity workflow) + return nil +} +``` + + + + +```java +// ShoppingCartManager.java +public class ShoppingCartManager { + public void addItem(String cartId, String itemId, String productId, int quantity) { + WorkflowOptions options = WorkflowOptions.newBuilder() + .setWorkflowId("cart-" + cartId) + .setTaskQueue("carts") + .build(); + + ShoppingCartWorkflow workflow = + workflowClient.newWorkflowStub(ShoppingCartWorkflow.class, options); + + // Atomically start workflow (if needed) and deliver signal + BatchRequest request = workflowClient.newSignalWithStartRequest(); + request.add(workflow::run); + request.add(workflow::addItem, itemId, productId, quantity); + workflowClient.signalWithStart(request); + } +} + +// ShoppingCartWorkflow.java +@WorkflowInterface +public interface ShoppingCartWorkflow { + @WorkflowMethod + void run(); + + @SignalMethod + void addItem(String itemId, String productId, int quantity); +} + +public class ShoppingCartWorkflowImpl implements ShoppingCartWorkflow { + private Set processedItems = new HashSet<>(); + private List items = new ArrayList<>(); + + @Override + public void run() { + Workflow.await(() -> false); // Run forever (entity workflow) + } + + @Override + public void addItem(String itemId, String productId, int quantity) { + if (!processedItems.add(itemId)) { + return; // Idempotency: ignore duplicate signals + } + items.add(new CartItem(productId, quantity)); + } +} +``` + + + + +```typescript +// client.ts +export async function addItem( + cartId: string, + itemId: string, + productId: string, + quantity: number +) { + // Atomically start workflow (if needed) and deliver signal + const handle = await client.workflow.signalWithStart(shoppingCartWorkflow, { + workflowId: `cart-${cartId}`, + taskQueue: 'carts', + signal: 'addItem', + signalArgs: [itemId, productId, quantity], + }); +} + +// workflow.ts +export async function shoppingCartWorkflow(): Promise { + const processedItems = new Set(); + const items: CartItem[] = []; + + setHandler(addItemSignal, (itemId: string, productId: string, quantity: number) => { + if (processedItems.has(itemId)) { + return; // Idempotency: ignore duplicate signals + } + processedItems.add(itemId); + items.push({ productId, quantity }); + }); + + await condition(() => false); // Run forever (entity workflow) +} + +export const addItemSignal = defineSignal<[string, string, number]>('addItem'); +``` + + + + +In all SDKs, the Workflow ID is derived from the business entity (the cart ID), ensuring one Workflow per entity. +The Signal handler checks a set of processed item IDs to prevent duplicate processing. +The Workflow blocks indefinitely, acting as a long-lived entity that receives operations over its lifetime. + +## When to use + +The Signal with Start pattern is a good fit for entity Workflows (accounts, shopping carts, user sessions, clusters), event-driven architectures (Kafka consumers, message queue processors), Workflows that receive multiple operations over their lifetime, lazy entity creation where you only create when the first operation arrives, and fire-and-forget operations where immediate response is not needed. + +It is not a good fit for one-time operations (use REJECT_DUPLICATE policy instead), request-response patterns requiring synchronous confirmation (use Update with Start), or operations that need immediate return values. + +## Benefits and trade-offs + +Signal with Start provides an atomic operation — start and Signal happen atomically with no race conditions. +Workflows only exist when needed (lazy creation). +The client does not need to check if the Workflow exists. +The operation is safe to retry because duplicate starts are handled by the Workflow ID. +The pattern is a natural fit for long-lived business entities. + +The trade-offs to consider are that Signals are fire-and-forget with no immediate confirmation that the Signal was processed. +You still need to track processed operation IDs in the Workflow for Signal idempotency. +Workflows must handle unbounded execution (use Continue-As-New). +Signals do not return values — use Queries or Updates for that. + +Both ALLOW_DUPLICATE and ALLOW_DUPLICATE_FAILED_ONLY work well with Signal with Start: + +- **ALLOW_DUPLICATE** (default): Allows a new Workflow Execution with the same ID after the previous one has closed (completed, failed, timed out, terminated, or cancelled). Does not affect a currently running Workflow — Signal with Start delivers the Signal to the running execution. +- **ALLOW_DUPLICATE_FAILED_ONLY**: Allows restart only if the previous run failed — prevents accidental restarts of running Workflows. +- **REJECT_DUPLICATE**: Prevents any duplicate starts — useful for one-time operations, not entity Workflows. +- **TERMINATE_IF_RUNNING**: Terminates the running Workflow and starts a new one — use with caution. + +## Comparison with alternatives + +| Approach | Use case | Response type | Idempotency | +| :--- | :--- | :--- | :--- | +| Signal with Start | Entity Workflows | Fire-and-forget | Signal-level | +| Update with Start | Request-response | Sync return value | Update-level | +| REJECT_DUPLICATE | One-time operations | Async (Workflow ID) | Workflow-level | + +## Best practices + +- **Derive Workflow ID from entity.** Use stable business identifiers (account ID, user ID). +- **Implement Signal idempotency.** Track processed operation IDs to prevent duplicates. +- **Use WorkflowInit.** Initialize state before Signals are delivered (Java, .NET, and Python's `__init__`). +- **Handle unbounded execution.** Use Continue-As-New for long-running entity Workflows. +- **Choose the right Workflow ID policy.** Use ALLOW_DUPLICATE_FAILED_ONLY for entity Workflows. +- **Include operation IDs.** Every Signal should include a unique operation or reference ID. +- **Return early.** Check for duplicates at the start of Signal handlers. + +## Common pitfalls + +- **Not implementing Signal idempotency.** Signals can be delivered more than once (for example, client retries). Without tracking processed operation IDs, the Workflow processes duplicates. +- **Unbounded history growth.** Entity Workflows that receive many Signals without calling Continue-As-New will hit the 50K event or 10K Signal limit. Use `isContinueAsNewSuggested()` to trigger Continue-As-New. +- **Losing pending Signals on Continue-As-New.** Drain all pending Signals before calling Continue-As-New, and pass unprocessed ones as input to the new execution. +- **Expecting a return value from Signals.** Signals are fire-and-forget. If you need a synchronous response, use Updates or Update-with-Start instead. +- **Race between SignalWithStart and Continue-As-New.** Temporal prevents this race — if a Signal arrives while the Workflow is completing via Continue-As-New, the Workflow rewinds to process the Signal first. + +## Related patterns + +- **[Entity Workflow](/design-patterns/entity-workflow)**: Long-running Workflows representing business entities. +- **[Continue-As-New](/design-patterns/continue-as-new)**: Managing unbounded Workflow history. +- **[Request-Response via Updates](/design-patterns/request-response-via-updates)**: When you need synchronous responses instead of fire-and-forget. +- **[Early Return](/design-patterns/early-return)**: Update-with-Start for request-response with lazy initialization. + +## Sample code + +**Python** +- [Hello Signal](https://github.com/temporalio/samples-python/tree/main/hello/hello_signal.py) — Basic Signal handling in a Workflow. +- [Message Passing](https://github.com/temporalio/samples-python/tree/main/message_passing/introduction) — Introduction to message passing with Signals, Queries, and Updates. + +**Java** +- [Hello Signal](https://github.com/temporalio/samples-java/tree/main/core/src/main/java/io/temporal/samples/hello/HelloSignal.java) — Basic Signal handling in a Workflow. +- [Safe Message Passing](https://github.com/temporalio/samples-java/tree/main/core/src/main/java/io/temporal/samples/safemessagepassing) — Concurrent Signal handling with validation. + +**TypeScript** +- [Signals and Queries](https://github.com/temporalio/samples-typescript/tree/main/signals-queries) — Signal and Query usage in a Workflow. + +**Go** +- [Await Signals](https://github.com/temporalio/samples-go/tree/main/await-signals) — Waiting for Signals with timeout. diff --git a/docs/design-patterns/sliding-window.mdx b/docs/design-patterns/sliding-window.mdx new file mode 100644 index 0000000000..cd0be869dc --- /dev/null +++ b/docs/design-patterns/sliding-window.mdx @@ -0,0 +1,507 @@ +--- +id: sliding-window +title: "Sliding Window" +sidebar_label: "Sliding Window" +description: "Maintains a fixed number of concurrently active Child Workflows, starting a new one each time an existing one completes." +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +:::info[TLDR] +Keep exactly `windowSize` child Workflows running at all times — each completion signal triggers the next record to start immediately. Use this when your record set is arbitrarily large, you need **bounded concurrency** to protect downstream systems, and you want higher throughput than a sequential Batch Iterator provides. +::: + +## Overview + +The Sliding Window pattern maintains a fixed-size pool of concurrently running child Workflows. As each child completes it signals the parent, which immediately starts a replacement — keeping the concurrency level constant and progressing at the rate of the fastest processor. Continue-as-New prevents the parent's history from growing without bound. + +## Problem + +The [Batch Iterator](/design-patterns/batch-iterator) processes records sequentially — the overall throughput is limited by the slowest record in each page. The [Fan-Out](/design-patterns/fanout-child-workflows) pattern starts all children at once, which can overwhelm downstream systems when the record set is large. + +You need a way to process an arbitrarily large record set with bounded concurrency, maximum throughput within that bound, and protection against history bloat. + +## Solution + +The parent Workflow keeps a live count of in-flight children (`active`) and runs a single loop that starts a child whenever a slot is free. The first `windowSize` slots are free, so those children start immediately; after that, a backpressure condition blocks each start until an in-flight child signals completion and frees a slot. Each child processes one record and, when finished, signals the parent, which decrements `active` and starts the next record's child. + +Continue-as-New is called after the parent has started `windowSize` children. Because child Workflows have stable Workflow IDs and Continue-as-New preserves the parent's Workflow ID, children started by a previous run can still signal the current run. The parent carries `active` into the next run so it knows how many carried-over children will still signal it. + +```mermaid +flowchart TD + Records["📋 Record IDs\n[r0, r1, r2, ...]"] + Parent["Parent Workflow\n(window size = W)"] + C1["Child r0\n✅ done"] + C2["Child r1\n⏳ running"] + C3["Child r2\n⏳ running"] + C4["Child r3\n🆕 started"] + CAN["continueAsNew\n(startIndex + W)"] + + Records --> Parent + Parent <-->|"start W children /
signal: complete"| C1 + Parent --> C2 + Parent --> C3 + + Parent -->|"slot free → start next"| C4 + + Parent -->|"after W children started"| CAN +``` + +The following describes each step in the diagram: + +1. The parent Workflow starts with a list of record IDs and a configured `windowSize`. +2. It starts the first `windowSize` children concurrently, one per record. Each child reads the parent's Workflow ID from its own Workflow metadata, so it knows where to signal. +3. As each child completes, it sends a completion signal to the parent. +4. The parent receives the signal, decrements its in-flight counter (`active`), and starts the next child (the next record in the list). +5. After starting `windowSize` children in total, the parent calls `continueAsNew` with the updated start index. The window slides forward without gaps because the parent's Workflow ID is preserved across runs. +6. Children from previous runs that have not yet signalled will find the new run when they send the signal, because the parent Workflow ID remains the same. + +## Implementation + +The following examples show how each SDK implements the Sliding Window pattern. + + + + +```typescript +// workflows.ts +import { + ApplicationFailure, + ParentClosePolicy, + condition, + continueAsNew, + defineSignal, + getExternalWorkflowHandle, + proxyActivities, + setHandler, + startChild, + workflowInfo, +} from "@temporalio/workflow"; +import type * as activities from "./activities"; +import { COMPLETION_SIGNAL, TASK_QUEUE, WINDOW_SIZE, type SlidingWindowInput } from "./shared"; + +const { processRecord } = proxyActivities({ + startToCloseTimeout: "30 seconds", +}); + +export const completionSignal = defineSignal<[string]>(COMPLETION_SIGNAL); + +// Child Workflow: processes one record and signals the parent on completion. +export async function recordProcessorWorkflow(recordId: string): Promise { + await processRecord(recordId); + // Read the parent's Workflow ID from context. It is stable across the parent's + // Continue-as-New runs, so signaling by Workflow ID (no run ID) always reaches + // the current run. Ignore NOT_FOUND — the parent's final run may have completed. + try { + const parent = getExternalWorkflowHandle(workflowInfo().parent!.workflowId); + await parent.signal(completionSignal, recordId); + } catch (err) { + if (!(err instanceof ApplicationFailure && err.type === "NOT_FOUND")) throw err; + } +} + +// Parent Workflow: maintains a fixed window of concurrent Child Workflows and calls +// Continue-as-New after dispatching windowSize children so history stays bounded. +export async function slidingWindowWorkflow(input: SlidingWindowInput): Promise { + const { recordIds, windowSize = WINDOW_SIZE, startIndex = 0 } = input; + const parentId = workflowInfo().workflowId; + // Total records completed across all runs, carried over via Continue-as-New. + let totalProcessed = input.totalProcessed ?? 0; + // Children started in this run; triggers Continue-as-New once it hits windowSize. + let dispatched = 0; + // Live in-flight count: +1 per start, -1 per completion signal, carried across runs. + let active = input.active ?? 0; + + setHandler(completionSignal, () => { + active--; + totalProcessed++; + }); + + // Slide the window: keep it full, starting one child per free slot. The first + // (windowSize - active) slots are already free, so those children start without + // waiting; after that, each start waits for an in-flight child to free a slot. + let nextIndex = startIndex; + while (nextIndex < recordIds.length) { + // Backpressure: block until the window has a free slot. + await condition(() => active < windowSize); + await startChild(recordProcessorWorkflow, { + args: [recordIds[nextIndex]], + workflowId: `${parentId}/record-${recordIds[nextIndex]}`, + taskQueue: TASK_QUEUE, + parentClosePolicy: ParentClosePolicy.ABANDON, + }); + nextIndex++; + dispatched++; + active++; + + // Once this run has filled the window with fresh children, Continue-as-New so + // history stays bounded. Carry active so the next run knows how many children + // will still signal it. + if (dispatched >= windowSize) { + await continueAsNew({ recordIds, windowSize, startIndex: nextIndex, totalProcessed, active }); + } + } + + // Wait for all remaining in-flight children to complete. + await condition(() => active === 0); + return totalProcessed; +} +``` + + + + +```python +# workflows.py +from datetime import timedelta +from temporalio import workflow +from temporalio.exceptions import ApplicationError +from temporalio.workflow import ParentClosePolicy, continue_as_new +from activities import process_record +from shared import COMPLETION_SIGNAL, TASK_QUEUE, WINDOW_SIZE, SlidingWindowInput + + +@workflow.defn +class RecordProcessorWorkflow: + """Child Workflow: processes one record and signals the parent on completion.""" + + @workflow.run + async def run(self, record_id: str) -> None: + await workflow.execute_activity( + process_record, + record_id, + start_to_close_timeout=timedelta(seconds=30), + ) + # Read the parent's Workflow ID from context. It is stable across the parent's + # continue_as_new runs, so signaling by Workflow ID always reaches the current + # run. Ignore NOT_FOUND — the parent's final run may have already completed. + parent = workflow.get_external_workflow_handle(workflow.info().parent.workflow_id) + try: + await parent.signal(COMPLETION_SIGNAL, record_id) + except ApplicationError as e: + if "not found" not in str(e).lower(): + raise + + +@workflow.defn +class SlidingWindowWorkflow: + """Parent Workflow: maintains a fixed window of concurrent Child Workflows.""" + + def __init__(self) -> None: + # Live in-flight count: +1 per start, -1 per completion signal, carried across + # runs. An instance field (not a run() local) because the signal handler is a + # separate method and completions can signal before run() starts. + self._active = 0 + # Total records completed across all runs, carried over via continue_as_new. + self._total_processed = 0 + + @workflow.signal(name=COMPLETION_SIGNAL) + def record_completed(self, record_id: str) -> None: + self._active -= 1 + self._total_processed += 1 + + @workflow.run + async def run(self, input: SlidingWindowInput) -> int: + # Use += so completions that signal before run() starts are preserved. + self._total_processed += input.total_processed + self._active += input.active + record_ids = input.record_ids + window_size = input.window_size + parent_id = workflow.info().workflow_id + next_index = input.start_index + # Children started in this run; triggers continue_as_new once it hits window_size. + dispatched = 0 + + # Slide the window: keep it full, starting one child per free slot. The first + # (window_size - active) slots are already free, so those children start without + # waiting; after that, each start waits for an in-flight child to free a slot. + while next_index < len(record_ids): + # Backpressure: block until the window has a free slot. + await workflow.wait_condition(lambda: self._active < window_size) + await workflow.start_child_workflow( + RecordProcessorWorkflow.run, + record_ids[next_index], + id=f"{parent_id}/record-{record_ids[next_index]}", + task_queue=TASK_QUEUE, + parent_close_policy=ParentClosePolicy.ABANDON, + ) + next_index += 1 + dispatched += 1 + self._active += 1 + + # Once this run has filled the window with fresh children, continue_as_new so + # history stays bounded. Carry _active so the next run knows how many children + # will still signal it. + if dispatched >= window_size: + continue_as_new(args=[SlidingWindowInput( + record_ids=record_ids, + window_size=window_size, + start_index=next_index, + total_processed=self._total_processed, + active=self._active, + )]) + + # Wait for all remaining in-flight children to complete. + await workflow.wait_condition(lambda: self._active == 0) + return self._total_processed +``` + + + + +```go +// workflows.go +package main + +import ( + "fmt" + "strings" + "time" + + enums "go.temporal.io/api/enums/v1" + "go.temporal.io/sdk/workflow" +) + +const CompletionSignal = "recordCompleted" + +// RecordProcessorWorkflow is the child Workflow: it processes one record and +// signals the parent on completion. +func RecordProcessorWorkflow(ctx workflow.Context, recordID string) error { + ao := workflow.ActivityOptions{StartToCloseTimeout: 30 * time.Second} + ctx = workflow.WithActivityOptions(ctx, ao) + + if err := workflow.ExecuteActivity(ctx, ProcessRecord, recordID).Get(ctx, nil); err != nil { + return err + } + + // Read the parent's Workflow ID from context. It is stable across the parent's + // ContinueAsNew runs, so signaling by Workflow ID (empty run ID) always reaches + // the current run. Ignore not-found — the parent's final run may have completed. + parentID := workflow.GetInfo(ctx).ParentWorkflowExecution.ID + err := workflow.SignalExternalWorkflow(ctx, parentID, "", CompletionSignal, recordID).Get(ctx, nil) + if err != nil && strings.Contains(err.Error(), "not found") { + return nil + } + return err +} + +// SlidingWindowWorkflow is the parent Workflow: it maintains a fixed window of +// concurrent Child Workflows and calls ContinueAsNew after dispatching windowSize +// children so history stays bounded. +func SlidingWindowWorkflow(ctx workflow.Context, input SlidingWindowInput) (int, error) { + windowSize := input.WindowSize + if windowSize <= 0 { + windowSize = WindowSize + } + recordIDs := input.RecordIDs + parentID := workflow.GetInfo(ctx).WorkflowExecution.ID + completedCh := workflow.GetSignalChannel(ctx, CompletionSignal) + + nextIndex := input.StartIndex + // Total records completed across all runs, carried over via ContinueAsNew. + totalProcessed := input.TotalProcessed + // Children started in this run; triggers ContinueAsNew once it hits windowSize. + dispatched := 0 + // Live in-flight count: +1 per start, -1 per completion signal, carried across runs. + active := input.Active + + startChild := func(recordID string) error { + cwo := workflow.ChildWorkflowOptions{ + WorkflowID: fmt.Sprintf("%s/record-%s", parentID, recordID), + TaskQueue: TaskQueue, + ParentClosePolicy: enums.PARENT_CLOSE_POLICY_ABANDON, + } + future := workflow.ExecuteChildWorkflow(workflow.WithChildOptions(ctx, cwo), RecordProcessorWorkflow, recordID) + // Wait for the child to start so the command commits before any ContinueAsNew. + return future.GetChildWorkflowExecution().Get(ctx, nil) + } + + // Slide the window: keep it full, starting one child per free slot. + for nextIndex < len(recordIDs) { + // Backpressure: if the window is full, block on the completion channel until an + // in-flight child signals, freeing a slot. Otherwise start without waiting. + if active >= windowSize { + completedCh.Receive(ctx, nil) + totalProcessed++ + active-- + } + if err := startChild(recordIDs[nextIndex]); err != nil { + return 0, err + } + nextIndex++ + dispatched++ + active++ + + // Once this run has filled the window with fresh children, ContinueAsNew so + // history stays bounded. Carry active so the next run knows how many children + // will still signal it. + if dispatched >= windowSize { + return 0, workflow.NewContinueAsNewError(ctx, SlidingWindowWorkflow, SlidingWindowInput{ + RecordIDs: recordIDs, + WindowSize: windowSize, + StartIndex: nextIndex, + TotalProcessed: totalProcessed, + Active: active, + }) + } + } + + // Drain all remaining in-flight children. + for active > 0 { + completedCh.Receive(ctx, nil) + totalProcessed++ + active-- + } + return totalProcessed, nil +} +``` + + + + +```java +// SlidingWindowWorkflow.java +import io.temporal.activity.ActivityOptions; +import io.temporal.api.enums.v1.ParentClosePolicy; +import io.temporal.workflow.*; +import java.time.Duration; +import java.util.List; + +public interface SlidingWindowWorkflow { + + /** Parent Workflow: maintains a fixed window of concurrent Child Workflows. */ + @WorkflowInterface + interface Parent { + @WorkflowMethod + int run(Shared.SlidingWindowInput input); + + @SignalMethod + void recordCompleted(String recordId); + } + + /** Child Workflow: processes one record and signals the parent on completion. */ + @WorkflowInterface + interface Child { + @WorkflowMethod + void run(String recordId); + } + + final class ParentImpl implements Parent { + // Live in-flight count: +1 per start, -1 per completion signal, carried across + // runs. An instance field (not a run() local) because the signal handler is a + // separate method and completions can signal before run() starts. + private int active = 0; + // Total records completed across all runs, carried over via Continue-as-New. + private int totalProcessed = 0; + + @Override + public void recordCompleted(String recordId) { + active--; + totalProcessed++; + } + + @Override + public int run(Shared.SlidingWindowInput input) { + // Use += so completions that signal before run() starts are preserved. + this.totalProcessed += input.totalProcessed; + this.active += input.active; + int windowSize = input.windowSize > 0 ? input.windowSize : Shared.WINDOW_SIZE; + List recordIds = input.recordIds; + String parentId = Workflow.getInfo().getWorkflowId(); + int nextIndex = input.startIndex; + // Children started in this run; triggers Continue-as-New once it hits windowSize. + int dispatched = 0; + + // Slide the window: keep it full, starting one child per free slot. The first + // (windowSize - active) slots are already free, so those children start without + // waiting; after that, each start waits for an in-flight child to free a slot. + while (nextIndex < recordIds.size()) { + // Backpressure: block until the window has a free slot. + Workflow.await(() -> active < windowSize); + + String recordId = recordIds.get(nextIndex); + ChildWorkflowOptions opts = ChildWorkflowOptions.newBuilder() + .setWorkflowId(parentId + "/record-" + recordId) + .setTaskQueue(Shared.TASK_QUEUE) + .setParentClosePolicy(ParentClosePolicy.PARENT_CLOSE_POLICY_ABANDON) + .build(); + Child child = Workflow.newChildWorkflowStub(Child.class, opts); + Async.procedure(child::run, recordId); + // Wait until the child has started before counting it (and before any + // Continue-as-New, which would otherwise race child startup). + Workflow.getWorkflowExecution(child).get(); + nextIndex++; + dispatched++; + active++; + + // Once this run has filled the window with fresh children, Continue-as-New + // so history stays bounded. Carry active so the next run knows how many + // children will still signal it. + if (dispatched >= windowSize) { + Workflow.newContinueAsNewStub(Parent.class) + .run(new Shared.SlidingWindowInput( + recordIds, windowSize, nextIndex, this.totalProcessed, active)); + return 0; // unreachable; Continue-as-New throws + } + } + + // Wait for all remaining in-flight children to complete. + Workflow.await(() -> active == 0); + return this.totalProcessed; + } + } + + final class ChildImpl implements Child { + private final Activities activities = Workflow.newActivityStub( + Activities.class, + ActivityOptions.newBuilder() + .setStartToCloseTimeout(Duration.ofSeconds(30)) + .build()); + + @Override + public void run(String recordId) { + activities.processRecord(recordId); + + // Read the parent's Workflow ID from context. It is stable across the parent's + // Continue-as-New runs, so signaling by Workflow ID always reaches the current + // run. Ignore not-found — the parent's final run may have already completed. + String parentWorkflowId = Workflow.getInfo().getParentWorkflowId().orElseThrow(); + ExternalWorkflowStub parent = Workflow.newUntypedExternalWorkflowStub(parentWorkflowId); + try { + parent.signal(Shared.COMPLETION_SIGNAL, recordId); + } catch (Exception e) { + String msg = e.getMessage() != null ? e.getMessage() : ""; + if (!msg.toLowerCase().contains("not found")) { + throw e; + } + } + } + } +} +``` + + + + +## Best practices + +- **Preserve the parent Workflow ID across Continue-as-New.** The parent's Workflow ID is stable across `continueAsNew` runs — do not generate a new one. Children read the parent's Workflow ID from their own Workflow metadata (`workflowInfo().parent` in TypeScript, `workflow.info().parent` in Python, `workflow.GetInfo(ctx).ParentWorkflowExecution` in Go, `Workflow.getInfo().getParentWorkflowId()` in Java) rather than receiving it as an argument, then signal by Workflow ID (with no run ID) so they always reach the current run. +- **Use `PARENT_CLOSE_POLICY_ABANDON` on child Workflows.** This lets children that were started by a previous run complete normally even after the parent has continued as new. +- **Size the window conservatively at first.** Each in-flight child counts toward the 2,000 unfinished-actions limit for the parent. A window of 50–200 is a reasonable starting point depending on child duration and downstream capacity. +- **Pass only IDs (not full records) to child Workflows.** Workflow inputs are stored in event history. Keep them small. +- **Carry minimal state into `continueAsNew`.** Pass `windowSize`, `startIndex`, the live in-flight count (`active`), a running `totalProcessed`, and the record ID list (or a reference to it). Do not accumulate results in the parent — collect them out-of-band if needed. + +## Common pitfalls + +- **Losing signals across Continue-as-New.** If a child signals before the parent's new run has registered the signal handler, the signal can be buffered and delivered correctly — Temporal buffers signals for existing Workflow IDs. However, ensure the signal handler is registered before any await, not conditionally. +- **Race between CAN and remaining signal draining.** After `continueAsNew`, the new run must handle signals from children started by the previous run. Pass `startIndex` (the next *unstarted* record) and `active` (the live in-flight count at the moment of CAN) to the new run so it knows how many carried-over children to expect signals from, without re-starting them. The new run folds `active` in with `+=`, so a completion that arrives before `run()` executes is still counted correctly. +- **Thundering herd on startup.** Starting hundreds of children simultaneously causes a burst of Activity polls. Ramp up the window gradually or use the [Batch Iterator](/design-patterns/batch-iterator) if rate limiting is more important than throughput. + +## Related resources + +- [Continue-as-New pattern](/design-patterns/continue-as-new) — history management fundamentals +- [Batch Iterator](/design-patterns/batch-iterator) — sequential alternative when ordered, one-at-a-time processing is acceptable +- [MapReduce Tree](/design-patterns/mapreduce-tree) — fully parallel alternative when rate limiting is not needed +- [Temporal limits reference](/cloud/limits) +- [Sliding window sample (Java)](https://github.com/temporalio/samples-java/tree/main/core/src/main/java/io/temporal/samples/batch/slidingwindow) diff --git a/docs/design-patterns/task-orchestration-patterns.mdx b/docs/design-patterns/task-orchestration-patterns.mdx new file mode 100644 index 0000000000..d0395a5f6b --- /dev/null +++ b/docs/design-patterns/task-orchestration-patterns.mdx @@ -0,0 +1,46 @@ +--- +id: task-orchestration-patterns +title: "Task Orchestration Patterns" +sidebar_label: "Task Orchestration Patterns" +description: "Pattern selection guide for composing and coordinating multiple units of work within a Workflow." +--- + +import PatternCards from '@site/src/components/PatternCards'; + +These patterns compose and coordinate multiple units of work within a Workflow — decomposing large processes into reusable pieces, running work concurrently, and racing alternatives against each other. + +## Patterns in this section + + + +## Choosing a pattern + +**A process is large or reused across Workflows**: break it into [Child Workflows](/design-patterns/child-workflows) with independent histories. + +**Independent work can run at the same time**: use [Parallel Execution](/design-patterns/parallel-execution) with a concurrency bound. + +**Several approaches compete and you want the first to finish**: use [Pick First (Race)](/design-patterns/pick-first) and cancel the losers. + +## Related sections + +- [Batch Processing Patterns](/design-patterns/batch-processing-patterns) — fan-out orchestration scaled to large record sets +- [Performance & Latency Patterns](/design-patterns/performance-latency-patterns) — reduce the latency of the work each task performs diff --git a/docs/design-patterns/updatable-timer.mdx b/docs/design-patterns/updatable-timer.mdx new file mode 100644 index 0000000000..f63558d4d5 --- /dev/null +++ b/docs/design-patterns/updatable-timer.mdx @@ -0,0 +1,612 @@ +--- +id: updatable-timer +title: "Updatable / Debounced Timer Pattern" +sidebar_label: "Updatable Timer" +description: "Dynamically adjustable timers that respond to Signals or Updates. Extend, shorten, or cancel timers based on external events." +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +## Overview + +The Updatable / Debounced Timer pattern implements a sleep operation that can be interrupted and dynamically adjusted via Signals. +It enables Workflows to wait for deadlines that can be extended or shortened based on external events, making it suitable for approval processes, SLA management, and time-sensitive business operations. + +## Problem + +In business processes, you often need Workflows that wait for a deadline (approval timeout, SLA expiration, grace period), allow the deadline to be extended or shortened dynamically, react immediately when the deadline changes, and continue waiting with the new deadline without restarting. + +Without an updatable timer, you must use fixed timeouts that cannot be adjusted, cancel and restart Workflows to change deadlines, poll frequently to check for deadline changes, or implement complex state machines to handle timing updates. + +## Solution + +The Updatable / Debounced Timer uses a blocking wait with both a time limit and an update condition. +When a Signal updates the wake-up time, the condition becomes true, the Workflow recalculates the sleep duration, and blocks again with the new deadline. + +Each SDK provides a different mechanism for this: +- **Java**: `Workflow.await(Duration, condition)` returns `false` when the duration expires, or `true` when the condition is met. +- **TypeScript**: `wf.condition(fn, timeout)` returns `false` when the timeout expires, or `true` when the function returns `true`. +- **Python**: `workflow.wait_condition(fn, timeout=duration)` returns normally when the condition is met, or raises `asyncio.TimeoutError` on timeout. +- **Go**: `workflow.NewTimer()` combined with `workflow.NewSelector()` to race a timer against a Signal channel. + +```mermaid +sequenceDiagram + participant Client + participant Workflow + participant Timer + + Client->>Workflow: Start with deadline + activate Workflow + Workflow->>Timer: sleepUntil(deadline) + activate Timer + Note over Timer: Waiting... + + Client->>Workflow: Signal: extendDeadline(newTime) + Workflow->>Timer: Update wakeUpTime + Timer->>Timer: Recalculate duration + Note over Timer: Waiting with new deadline... + + Timer-->>Workflow: Timer expired + deactivate Timer + Workflow-->>Client: Complete + deactivate Workflow +``` + +The following describes each step in the diagram: + +1. The client starts the Workflow with an initial deadline. +2. The Workflow calls `sleepUntil(deadline)`, which blocks until the deadline. +3. The client sends a Signal to extend the deadline. +4. The timer recalculates the remaining duration based on the new deadline and continues waiting. +5. When the timer expires, the Workflow completes. + +The core of the pattern is a reusable timer helper that loops on a blocking wait, recalculating the sleep duration each time the wake-up time is updated: + + + + +```python +# updatable_timer.py +import asyncio +from datetime import timedelta +from temporalio import workflow + + +class UpdatableTimer: + def __init__(self, wake_up_time: float) -> None: + self._wake_up_time = wake_up_time + self._wake_up_time_updated = False + + async def sleep_until(self, wake_up_time: float) -> None: + self._wake_up_time = wake_up_time + while True: + self._wake_up_time_updated = False + sleep_secs = self._wake_up_time - workflow.time() + try: + await workflow.wait_condition( + lambda: self._wake_up_time_updated, + timeout=timedelta(seconds=max(sleep_secs, 0)), + ) + # Condition met: wake-up time was updated, loop to recalculate + except asyncio.TimeoutError: + break # Timer expired + + def update_wake_up_time(self, wake_up_time: float) -> None: + self._wake_up_time = wake_up_time + self._wake_up_time_updated = True # Unblocks wait_condition + + @property + def wake_up_time(self) -> float: + return self._wake_up_time +``` + + + + +```go +// updatable_timer.go +func sleepUntil(ctx workflow.Context, wakeUpTime time.Time, wakeUpChannel workflow.ReceiveChannel) error { + for { + timerCtx, cancelTimer := workflow.WithCancel(ctx) + duration := wakeUpTime.Sub(workflow.Now(ctx)) + if duration <= 0 { + cancelTimer() + break + } + timer := workflow.NewTimer(timerCtx, duration) + + selector := workflow.NewSelector(ctx) + timerFired := false + + selector.AddFuture(timer, func(f workflow.Future) { + timerFired = true + }) + + selector.AddReceive(wakeUpChannel, func(c workflow.ReceiveChannel, more bool) { + c.Receive(ctx, &wakeUpTime) + // Cancel the current timer so it can be recreated with the new deadline + cancelTimer() + }) + + selector.Select(ctx) + + if timerFired { + break // Timer expired + } + // Signal received with new wakeUpTime, loop to recalculate + } + return nil +} +``` + + + + +```java +// UpdatableTimer.java +public class UpdatableTimer { + private long wakeUpTime; + private boolean wakeUpTimeUpdated; + + public void sleepUntil(long wakeUpTime) { + this.wakeUpTime = wakeUpTime; + while (true) { + wakeUpTimeUpdated = false; + Duration sleepInterval = Duration.ofMillis(this.wakeUpTime - Workflow.currentTimeMillis()); + if (!Workflow.await(sleepInterval, () -> wakeUpTimeUpdated)) { + break; // Timer expired + } + // Timer was updated, loop to recalculate + } + } + + public void updateWakeUpTime(long wakeUpTime) { + this.wakeUpTime = wakeUpTime; + this.wakeUpTimeUpdated = true; // Unblocks await + } +} +``` + + + + +```typescript +// updatable-timer.ts +import * as wf from '@temporalio/workflow'; + +export class UpdatableTimer implements PromiseLike { + deadlineUpdated = false; + #deadline: number; + + constructor(deadline: number) { + this.#deadline = deadline; + } + + private async run(): Promise { + while (true) { + this.deadlineUpdated = false; + if ( + !(await wf.condition( + () => this.deadlineUpdated, + this.#deadline - Date.now(), + )) + ) { + break; // Timer expired + } + // Timer was updated, loop to recalculate + } + } + + then( + onfulfilled?: (value: void) => TResult1 | PromiseLike, + onrejected?: (reason: any) => TResult2 | PromiseLike, + ): PromiseLike { + return this.run().then(onfulfilled, onrejected); + } + + set deadline(value: number) { + this.#deadline = value; + this.deadlineUpdated = true; + } + + get deadline(): number { + return this.#deadline; + } +} +``` + + + + +In Java and TypeScript, the `sleepUntil` method calculates the sleep interval and calls a blocking wait with both a duration and a condition. +If the duration expires first, the wait returns `false` (Java/TypeScript) or raises `asyncio.TimeoutError` (Python), and the timer completes. +If the update flag is set via a Signal, the condition becomes true, the wait unblocks, and the loop recalculates the interval with the new deadline. +In Go, a `Selector` races a `Timer` against a Signal channel; when the Signal arrives, the current timer is cancelled and a new one is created with the updated deadline. + +## Implementation + +### Basic approval Workflow + +The following implementation combines the updatable timer with an approval flag. +The Workflow waits for either an approval Signal or the deadline to expire: + + + + +```python +# workflows.py +import asyncio +from datetime import timedelta +from temporalio import workflow + + +@workflow.defn +class ApprovalWorkflow: + def __init__(self) -> None: + self._approved = False + self._status = "PENDING" + + @workflow.run + async def run(self, approval_deadline: float) -> None: + timeout_secs = approval_deadline - workflow.time() + try: + await workflow.wait_condition( + lambda: self._approved, + timeout=timedelta(seconds=max(timeout_secs, 0)), + ) + self._status = "APPROVED" + except asyncio.TimeoutError: + self._status = "REJECTED" + + @workflow.signal + def approve(self) -> None: + self._approved = True + + @workflow.query + def get_status(self) -> str: + return self._status +``` + + + + +```go +// workflow.go +func ApprovalWorkflow(ctx workflow.Context, approvalDeadline time.Time) (string, error) { + logger := workflow.GetLogger(ctx) + status := "PENDING" + approved := false + + // Listen for the approve signal in a goroutine + workflow.Go(ctx, func(ctx workflow.Context) { + ch := workflow.GetSignalChannel(ctx, "approve") + ch.Receive(ctx, nil) + approved = true + }) + + // Wait for approval or timeout + duration := approvalDeadline.Sub(workflow.Now(ctx)) + ok, _ := workflow.AwaitWithTimeout(ctx, duration, func() bool { + return approved + }) + + if ok { + status = "APPROVED" + } else { + status = "REJECTED" + } + + logger.Info("Approval workflow completed", "status", status) + return status, nil +} +``` + + + + +```java +// ApprovalWorkflowImpl.java +@WorkflowInterface +public interface ApprovalWorkflow { + @WorkflowMethod + void execute(long approvalDeadline); + + @SignalMethod + void extendDeadline(long newDeadline); + + @SignalMethod + void approve(); + + @QueryMethod + String getStatus(); +} + +public class ApprovalWorkflowImpl implements ApprovalWorkflow { + private UpdatableTimer timer = new UpdatableTimer(); + private boolean approved = false; + private String status = "PENDING"; + + @Override + public void execute(long approvalDeadline) { + Workflow.await( + Duration.ofMillis(approvalDeadline - Workflow.currentTimeMillis()), + () -> approved); + + if (approved) { + status = "APPROVED"; + } else { + status = "REJECTED"; + } + } + + @Override + public void extendDeadline(long newDeadline) { + timer.updateWakeUpTime(newDeadline); + } + + @Override + public void approve() { + approved = true; + } + + @Override + public String getStatus() { + return status; + } +} +``` + + + + +```typescript +// workflows.ts +import * as wf from '@temporalio/workflow'; + +export const extendDeadlineSignal = wf.defineSignal<[number]>('extendDeadline'); +export const approveSignal = wf.defineSignal('approve'); +export const getStatusQuery = wf.defineQuery('getStatus'); + +export async function approvalWorkflow(approvalDeadline: number): Promise { + let approved = false; + let status = 'PENDING'; + + wf.setHandler(approveSignal, () => { + approved = true; + }); + + wf.setHandler(getStatusQuery, () => status); + + // Wait for approval or deadline expiration + const approvedBeforeDeadline = await wf.condition( + () => approved, + approvalDeadline - Date.now(), + ); + + status = approvedBeforeDeadline ? 'APPROVED' : 'REJECTED'; +} +``` + + + + +The Workflow waits with both a deadline duration and a condition that checks the `approved` flag. +If the `approve` Signal arrives before the deadline, the condition becomes true and the Workflow sets the status to APPROVED. +If the deadline expires first, the Workflow sets the status to REJECTED. + +### Multiple deadline extensions + +The following implementation uses the `UpdatableTimer` directly to support multiple deadline extensions. +The Workflow blocks on the timer helper and checks the approval flag after the timer completes: + + + + +```python +# workflows.py +from temporalio import workflow +from .updatable_timer import UpdatableTimer + + +@workflow.defn +class MultiExtensionApprovalWorkflow: + def __init__(self) -> None: + self._timer = UpdatableTimer(0) + self._approved = False + self._rejected = False + + @workflow.run + async def run(self, initial_deadline: float) -> None: + await self._timer.sleep_until(initial_deadline) + + if not self._approved: + self._rejected = True + + @workflow.signal + def extend_deadline(self, new_deadline: float) -> None: + if not self._approved and not self._rejected: + self._timer.update_wake_up_time(new_deadline) + + @workflow.signal + def approve(self) -> None: + self._approved = True +``` + + + + +```go +// workflow.go +func MultiExtensionApprovalWorkflow(ctx workflow.Context, initialDeadline time.Time) (string, error) { + approved := false + rejected := false + wakeUpTime := initialDeadline + wakeUpChannel := workflow.NewChannel(ctx) + + // Listen for approval signal + workflow.Go(ctx, func(ctx workflow.Context) { + ch := workflow.GetSignalChannel(ctx, "approve") + ch.Receive(ctx, nil) + approved = true + }) + + // Listen for deadline extension signals + workflow.Go(ctx, func(ctx workflow.Context) { + ch := workflow.GetSignalChannel(ctx, "extendDeadline") + for { + var newDeadline time.Time + ch.Receive(ctx, &newDeadline) + if !approved && !rejected { + wakeUpChannel.Send(ctx, newDeadline) + } + } + }) + + // Block on the updatable timer + _ = sleepUntil(ctx, wakeUpTime, wakeUpChannel) + + if !approved { + rejected = true + } + + if rejected { + return "REJECTED", nil + } + return "APPROVED", nil +} +``` + + + + +```java +// MultiExtensionApprovalWorkflowImpl.java +public class MultiExtensionApprovalWorkflowImpl implements ApprovalWorkflow { + private UpdatableTimer timer = new UpdatableTimer(); + private boolean approved = false; + private boolean rejected = false; + + @Override + public void execute(long initialDeadline) { + timer.sleepUntil(initialDeadline); + + if (!approved) { + rejected = true; + } + } + + @Override + public void extendDeadline(long newDeadline) { + if (!approved && !rejected) { + timer.updateWakeUpTime(newDeadline); + } + } + + @Override + public void approve() { + approved = true; + } +} +``` + + + + +```typescript +// workflows.ts +import * as wf from '@temporalio/workflow'; +import { UpdatableTimer } from './updatable-timer'; + +export const extendDeadlineSignal = wf.defineSignal<[number]>('extendDeadline'); +export const approveSignal = wf.defineSignal('approve'); + +export async function multiExtensionApprovalWorkflow( + initialDeadline: number, +): Promise { + let approved = false; + let rejected = false; + const timer = new UpdatableTimer(initialDeadline); + + wf.setHandler(extendDeadlineSignal, (newDeadline: number) => { + if (!approved && !rejected) { + timer.deadline = newDeadline; + } + }); + + wf.setHandler(approveSignal, () => { + approved = true; + }); + + await timer; // Blocks until the timer expires + + if (!approved) { + rejected = true; + } +} +``` + + + + +The `extendDeadline` Signal handler checks that the Workflow has not already been approved or rejected before updating the timer. +Each update unblocks the timer loop, which recalculates the remaining duration and blocks again. + +## When to use + +The Updatable Timer pattern is a good fit for approval Workflows with deadline extensions, SLA management with grace periods, time-based escalations that can be postponed, auction bidding with extended closing times, and payment grace periods that can be adjusted. + +It is not a good fit for fixed timeouts that never change (use a fixed sleep), immediate cancellation (use cancellation scopes), or complex scheduling (use Temporal Schedules). + +## Benefits and trade-offs + +The pattern allows you to adjust deadlines without restarting Workflows. +Changes take effect instantly. +The timer helper is reusable across multiple Workflows. +All timing is based on Workflow time, ensuring replay consistency. +You can Query the current deadline at any time. + +The trade-offs to consider are that the pattern requires an external process to send update Signals. +Each timer instance manages one deadline. +Previous deadlines are not tracked (add tracking if needed). +You must calculate absolute timestamps rather than relative durations. + +## Comparison with alternatives + +| Approach | Dynamic updates | Complexity | Use case | +| :--- | :--- | :--- | :--- | +| Updatable / Debounced Timer | Yes | Medium | Adjustable deadlines | +| Fixed sleep | No | Low | Fixed delays | +| Cancellation Scope | Yes (cancel only) | Medium | Abort operations | +| Polling Loop | Yes | High | Frequent checks | + +## Best practices + +- **Use absolute timestamps.** Store wake-up time as an absolute value (epoch millis in Java/TypeScript, epoch seconds in Python, `time.Time` in Go), not relative durations. +- **Validate updates.** Ensure new deadlines are in the future. +- **Add Queries.** Expose the current deadline via Query methods. +- **Handle edge cases.** Check if the timer already expired before updating. +- **Consider max extensions.** Limit how many times or how far deadlines can be extended. +- **Log changes.** Log each deadline update for observability. +- **Reuse the timer helper.** Extract to a helper class or function for use across Workflows. +- **Combine with conditions.** Use a blocking wait with both time and business conditions. + +## Common pitfalls + +- **Using time-based conditions without a duration.** A wait without a timeout does not create a timer. The condition is only re-evaluated on state changes (Signals, Activity completions). Always provide a timeout for time-based waits. +- **Expecting the wait to re-evaluate its duration.** The timer duration is set once when the wait is called. Changing the duration variable afterward has no effect. This is why the timer helper loops and recalculates. +- **Not validating new deadlines.** Accepting a deadline in the past causes the timer to expire immediately. Always check that the new deadline is in the future before updating. +- **Accumulating uncancelled timers in Java.** In the Java SDK, `Workflow.await(Duration, condition)` does not automatically cancel its internal timer when the condition is met. Repeated calls in a loop accumulate timers. Wrap in a `CancellationScope` if this is a concern. +- **Not cancelling timers in Go.** In the Go SDK, always cancel the previous timer (via `workflow.WithCancel`) before creating a new one. Uncancelled timers wake up the Workflow unnecessarily, creating extra Worker load. + +## Related patterns + +- **[Signal with Start](/design-patterns/signal-with-start)**: Receiving external events to modify behavior. +- **[Approval Pattern](/design-patterns/approval)**: Approval Workflows with adjustable deadlines. + +## Sample code + +- [Java](https://github.com/temporalio/samples-java/tree/main/core/src/main/java/io/temporal/samples/updatabletimer) -- Complete implementation with starter and updater. +- [TypeScript](https://github.com/temporalio/samples-typescript/tree/main/timer-examples) -- Updatable timer with `condition` and `UpdatableTimer` class. +- [Python](https://github.com/temporalio/samples-python/tree/main/updatable_timer) -- Updatable timer with `wait_condition` and helper class. +- [Go](https://github.com/temporalio/samples-go/tree/main/updatabletimer) -- Timer cancellation with Selector, Signal channel, and `WithCancel`. diff --git a/docs/design-patterns/worker-configuration-patterns.mdx b/docs/design-patterns/worker-configuration-patterns.mdx new file mode 100644 index 0000000000..95e430366e --- /dev/null +++ b/docs/design-patterns/worker-configuration-patterns.mdx @@ -0,0 +1,38 @@ +--- +id: worker-configuration-patterns +title: "Worker Configuration Patterns" +sidebar_label: "Worker Configuration Patterns" +description: "Pattern selection guide for configuring how Workers are set up, how work is routed, and how Activities access external dependencies." +--- + +import PatternCards from '@site/src/components/PatternCards'; + +These patterns cover how you set up Workers, route work to them, and give Activities the external dependencies they need — while keeping Workflow code deterministic and testable. + +## Patterns in this section + + + +## Choosing a pattern + +**A sequence of Activities must run on the same Worker host**: use [Worker-Specific Task Queues](/design-patterns/worker-specific-taskqueue) to pin them to one Worker. + +**Activities depend on external resources**: use [Activity Dependency Injection](/design-patterns/activity-dependency-injection) to supply them at startup rather than constructing them inside each Activity. + +## Related sections + +- [QoS & Throughput Patterns](/design-patterns/qos-throughput-patterns) — Task Queue routing for rate control and fairness +- [Performance & Latency Patterns](/design-patterns/performance-latency-patterns) — run Activities in-process with Local Activities diff --git a/docs/design-patterns/worker-specific-taskqueue.mdx b/docs/design-patterns/worker-specific-taskqueue.mdx new file mode 100644 index 0000000000..c4e0fe8da4 --- /dev/null +++ b/docs/design-patterns/worker-specific-taskqueue.mdx @@ -0,0 +1,787 @@ +--- +id: worker-specific-taskqueue +title: "Worker-Specific Task Queues Pattern" +sidebar_label: "Worker-Specific Task Queues" +description: "Routes Activities to specific Workers using unique Task Queues for Worker affinity and host-specific processing." +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +## Overview + +The Worker-Specific Task Queues pattern enables routing Activities to specific Worker hosts when Activities must execute on the same machine. +This is essential for Workflows where subsequent Activities depend on local state, files, or resources created by previous Activities on a particular host. + +## Problem + +In distributed systems, you often need Workflows that download a file to a Worker's local disk and then process and upload it from the same location, establish a connection or session that subsequent Activities must reuse, create temporary resources on one host that later Activities need to access, or maintain affinity to a specific Worker for performance or data locality. + +Without Worker-specific routing, Activities execute on different hosts and cannot access local files or state. +You must set up complex distributed file systems or shared storage, handle race conditions when multiple Workers access the same resources, and accept that you cannot guarantee Activity colocation. + +## Solution + +You use a two-tier Task Queue architecture: a default shared Task Queue for initial Activities, and dynamically-named host-specific Task Queues for Activities that must run on the same Worker. +The first Activity returns its host-specific Task Queue name, and subsequent Activities use that queue. + +```mermaid +sequenceDiagram + participant Workflow + participant Worker1 + participant Worker2 + participant Worker3 + + Note over Workflow: Default Task Queue: "FileProcessing" + Workflow->>Worker2: download() - any worker + activate Worker2 + Worker2-->>Workflow: {hostQueue: "FileProcessing-host2", file: "/tmp/data"} + deactivate Worker2 + + Note over Workflow: Switch to host-specific queue + Note over Workflow: Task Queue: "FileProcessing-host2" + + Workflow->>Worker2: process(file) - MUST be Worker2 + activate Worker2 + Worker2-->>Workflow: processed file + deactivate Worker2 + + Workflow->>Worker2: upload(file) - MUST be Worker2 + activate Worker2 + Worker2-->>Workflow: done + deactivate Worker2 + + Note over Worker1,Worker3: Workers 1 & 3 never see
host-specific activities +``` + +The following describes each step in the diagram: + +1. The Workflow dispatches the download Activity on the default Task Queue. Any available Worker picks it up. +2. Worker 2 downloads the file and returns both the local file path and its host-specific Task Queue name. +3. The Workflow creates new Activity options targeting Worker 2's host-specific Task Queue. +4. The process and upload Activities execute on Worker 2, where the file is already on disk. +5. Workers 1 and 3 never see the host-specific Activities. + +The following snippet shows how the Workflow switches from the default Task Queue to the host-specific queue: + + + + +```python +# workflows.py +downloaded = await workflow.execute_activity( + download, + source, + start_to_close_timeout=timedelta(seconds=20), +) + +processed = await workflow.execute_activity( + process, + downloaded.file_name, + task_queue=downloaded.host_task_queue, + schedule_to_start_timeout=timedelta(seconds=10), + start_to_close_timeout=timedelta(seconds=20), +) +await workflow.execute_activity( + upload, + args=[processed, destination], + task_queue=downloaded.host_task_queue, + schedule_to_start_timeout=timedelta(seconds=10), + start_to_close_timeout=timedelta(seconds=20), +) +``` + + + + +```go +// workflow.go +var defaultActivities *StoreActivities +downloaded, err := defaultActivities.Download(ctx, source) +if err != nil { + return err +} + +hostOptions := workflow.ActivityOptions{ + TaskQueue: downloaded.HostTaskQueue, + ScheduleToStartTimeout: 10 * time.Second, + StartToCloseTimeout: 20 * time.Second, +} +hostCtx := workflow.WithActivityOptions(ctx, hostOptions) + +var hostActivities *StoreActivities +processed, err := hostActivities.Process(hostCtx, downloaded.FileName) +if err != nil { + return err +} +err = hostActivities.Upload(hostCtx, processed, destination) +``` + + + + +```java +// FileProcessingWorkflowImpl.java +TaskQueueFileNamePair downloaded = defaultTaskQueueActivities.download(source); + +ActivityOptions hostOptions = ActivityOptions.newBuilder() + .setTaskQueue(downloaded.getHostTaskQueue()) + .setScheduleToStartTimeout(Duration.ofSeconds(10)) + .setStartToCloseTimeout(Duration.ofSeconds(20)) + .build(); +StoreActivities hostSpecificActivities = + Workflow.newActivityStub(StoreActivities.class, hostOptions); + +String processed = hostSpecificActivities.process(downloaded.getFileName()); +hostSpecificActivities.upload(processed, destination); +``` + + + + +```typescript +// workflows.ts +const { download } = proxyActivities({ + startToCloseTimeout: '20s', +}); + +const downloaded = await download(source); + +const hostSpecificActivities = proxyActivities({ + taskQueue: downloaded.hostTaskQueue, + scheduleToStartTimeout: '10s', + startToCloseTimeout: '20s', +}); + +const processed = await hostSpecificActivities.process(downloaded.fileName); +await hostSpecificActivities.upload(processed, destination); +``` + + + + +The `taskQueue` option (or `setTaskQueue()` in Java) routes subsequent Activities to the specific Worker that downloaded the file. +The `scheduleToStartTimeout` (or `setScheduleToStartTimeout()` in Java) is critical — if the specific Worker is unavailable, the Activity Task cannot sit in the host-specific queue indefinitely. +This timeout is non-retryable: when it expires, the Activity fails rather than retrying, because a retry would only place the Task back on the same queue. +The Workflow catches that failure and retries the entire sequence so the work can restart on a different host. + +## Implementation + +### Activity Definition with host-specific return + +The download Activity returns both the file path and the host-specific Task Queue name: + + + + +```python +# activities.py +from dataclasses import dataclass +from temporalio import activity + +@dataclass +class TaskQueueFileNamePair: + host_task_queue: str + file_name: str + +@activity.defn +async def download(source: str) -> TaskQueueFileNamePair: + local_file = await download_to_local_disk(source) + return TaskQueueFileNamePair( + host_task_queue=host_specific_task_queue, + file_name=local_file, + ) + +@activity.defn +async def process(file_name: str) -> str: + return await process_local_file(file_name) + +@activity.defn +async def upload(file_name: str, destination: str) -> None: + await upload_from_local_disk(file_name, destination) +``` + + + + +```go +// activities.go +type TaskQueueFileNamePair struct { + HostTaskQueue string + FileName string +} + +type StoreActivities struct { + HostSpecificTaskQueue string +} + +func (a *StoreActivities) Download(ctx context.Context, source string) (*TaskQueueFileNamePair, error) { + localFile, err := downloadToLocalDisk(source) + if err != nil { + return nil, err + } + return &TaskQueueFileNamePair{ + HostTaskQueue: a.HostSpecificTaskQueue, + FileName: localFile, + }, nil +} + +func (a *StoreActivities) Process(ctx context.Context, fileName string) (string, error) { + return processLocalFile(fileName) +} + +func (a *StoreActivities) Upload(ctx context.Context, fileName string, destination string) error { + return uploadFromLocalDisk(fileName, destination) +} +``` + + + + +```java +// StoreActivities.java +public interface StoreActivities { + + class TaskQueueFileNamePair { + private final String hostTaskQueue; + private final String fileName; + + public TaskQueueFileNamePair(String hostTaskQueue, String fileName) { + this.hostTaskQueue = hostTaskQueue; + this.fileName = fileName; + } + + public String getHostTaskQueue() { return hostTaskQueue; } + public String getFileName() { return fileName; } + } + + TaskQueueFileNamePair download(URL source); + String process(String fileName); + void upload(String fileName, URL destination); +} +``` + + + + +```typescript +// activities.ts +export interface TaskQueueFileNamePair { + hostTaskQueue: string; + fileName: string; +} + +export async function download(source: string): Promise { + const localFile = await downloadToLocalDisk(source); + return { + hostTaskQueue: getHostSpecificTaskQueue(), + fileName: localFile, + }; +} + +export async function process(fileName: string): Promise { + return await processLocalFile(fileName); +} + +export async function upload(fileName: string, destination: string): Promise { + await uploadFromLocalDisk(fileName, destination); +} +``` + + + + +The download Activity bundles the local file path with the Task Queue name so the Workflow knows where to route subsequent Activities. + +### Activity implementation + +The Activity implementation receives the host-specific Task Queue name at construction time and includes it in the download result: + + + + +```python +# activities.py +# In Python, the host-specific Task Queue name is injected at Worker +# startup and captured by the activity closure or class instance. + +host_specific_task_queue: str = "" + +@activity.defn +async def download(source: str) -> TaskQueueFileNamePair: + local_file = await download_to_local_disk(source) + return TaskQueueFileNamePair( + host_task_queue=host_specific_task_queue, + file_name=local_file, + ) + +@activity.defn +async def process(file_name: str) -> str: + processed = await process_local_file(file_name) + return processed + +@activity.defn +async def upload(file_name: str, destination: str) -> None: + await upload_from_local_disk(file_name, destination) +``` + + + + +```go +// activities.go +// In Go, the host-specific Task Queue name is set on the struct +// at Worker startup and returned by the Download method. + +func (a *StoreActivities) Download(ctx context.Context, source string) (*TaskQueueFileNamePair, error) { + localFile, err := downloadToLocalDisk(source) + if err != nil { + return nil, err + } + return &TaskQueueFileNamePair{ + HostTaskQueue: a.HostSpecificTaskQueue, + FileName: localFile, + }, nil +} + +func (a *StoreActivities) Process(ctx context.Context, fileName string) (string, error) { + return processLocalFile(fileName) +} + +func (a *StoreActivities) Upload(ctx context.Context, fileName string, destination string) error { + return uploadFromLocalDisk(fileName, destination) +} +``` + + + + +```java +// StoreActivitiesImpl.java +public class StoreActivitiesImpl implements StoreActivities { + private final String hostSpecificTaskQueue; + + public StoreActivitiesImpl(String hostSpecificTaskQueue) { + this.hostSpecificTaskQueue = hostSpecificTaskQueue; + } + + @Override + public TaskQueueFileNamePair download(URL source) { + File localFile = downloadToLocalDisk(source); + return new TaskQueueFileNamePair( + hostSpecificTaskQueue, + localFile.getAbsolutePath()); + } + + @Override + public String process(String fileName) { + File processed = processLocalFile(new File(fileName)); + return processed.getAbsolutePath(); + } + + @Override + public void upload(String fileName, URL destination) { + uploadFromLocalDisk(new File(fileName), destination); + } +} +``` + + + + +```typescript +// activities.ts +// In TypeScript, the host-specific Task Queue name is captured via +// closure when defining the activity functions. A common approach is +// to initialize it at Worker startup and reference it from activities. + +let hostSpecificTaskQueue: string; + +export function initActivities(taskQueue: string) { + hostSpecificTaskQueue = taskQueue; +} + +function getHostSpecificTaskQueue(): string { + return hostSpecificTaskQueue; +} + +export async function download(source: string): Promise { + const localFile = await downloadToLocalDisk(source); + return { + hostTaskQueue: getHostSpecificTaskQueue(), + fileName: localFile, + }; +} + +export async function process(fileName: string): Promise { + return await processLocalFile(fileName); +} + +export async function upload(fileName: string, destination: string): Promise { + await uploadFromLocalDisk(fileName, destination); +} +``` + + + + +The `download` method returns the host-specific Task Queue name alongside the file path. +The `process` and `upload` methods operate on local files, which are guaranteed to exist because they run on the same host. + +### Workflow implementation + +The Workflow uses the default Task Queue for the initial download and switches to the host-specific queue for subsequent Activities: + + + + +```python +# workflows.py +from datetime import timedelta +from temporalio import workflow + +with workflow.unsafe.imports_passed_through(): + from activities import download, process, upload + +@workflow.defn +class FileProcessingWorkflow: + @workflow.run + async def run(self, source: str, destination: str) -> None: + downloaded = await workflow.execute_activity( + download, + source, + start_to_close_timeout=timedelta(seconds=20), + ) + + processed = await workflow.execute_activity( + process, + downloaded.file_name, + task_queue=downloaded.host_task_queue, + schedule_to_start_timeout=timedelta(seconds=10), + start_to_close_timeout=timedelta(seconds=20), + ) + + await workflow.execute_activity( + upload, + args=[processed, destination], + task_queue=downloaded.host_task_queue, + schedule_to_start_timeout=timedelta(seconds=10), + start_to_close_timeout=timedelta(seconds=20), + ) +``` + + + + +```go +// workflow.go +func FileProcessingWorkflow(ctx workflow.Context, source string, destination string) error { + defaultOptions := workflow.ActivityOptions{ + StartToCloseTimeout: 20 * time.Second, + } + defaultCtx := workflow.WithActivityOptions(ctx, defaultOptions) + + var activities *StoreActivities + var downloaded TaskQueueFileNamePair + err := workflow.ExecuteActivity(defaultCtx, activities.Download, source).Get(ctx, &downloaded) + if err != nil { + return err + } + + hostOptions := workflow.ActivityOptions{ + TaskQueue: downloaded.HostTaskQueue, + ScheduleToStartTimeout: 10 * time.Second, + StartToCloseTimeout: 20 * time.Second, + } + hostCtx := workflow.WithActivityOptions(ctx, hostOptions) + + var processed string + err = workflow.ExecuteActivity(hostCtx, activities.Process, downloaded.FileName).Get(ctx, &processed) + if err != nil { + return err + } + + return workflow.ExecuteActivity(hostCtx, activities.Upload, processed, destination).Get(ctx, nil) +} +``` + + + + +```java +// FileProcessingWorkflowImpl.java +public class FileProcessingWorkflowImpl implements FileProcessingWorkflow { + private final StoreActivities defaultTaskQueueActivities; + + public FileProcessingWorkflowImpl() { + ActivityOptions defaultOptions = ActivityOptions.newBuilder() + .setStartToCloseTimeout(Duration.ofSeconds(20)) + .build(); + this.defaultTaskQueueActivities = + Workflow.newActivityStub(StoreActivities.class, defaultOptions); + } + + @Override + public void processFile(URL source, URL destination) { + TaskQueueFileNamePair downloaded = + defaultTaskQueueActivities.download(source); + + ActivityOptions hostOptions = ActivityOptions.newBuilder() + .setTaskQueue(downloaded.getHostTaskQueue()) + .setScheduleToStartTimeout(Duration.ofSeconds(10)) + .setStartToCloseTimeout(Duration.ofSeconds(20)) + .build(); + StoreActivities hostSpecificActivities = + Workflow.newActivityStub(StoreActivities.class, hostOptions); + + String processed = hostSpecificActivities.process(downloaded.getFileName()); + hostSpecificActivities.upload(processed, destination); + } +} +``` + + + + +```typescript +// workflows.ts +import { proxyActivities } from '@temporalio/workflow'; +import type * as activities from './activities'; + +const { download } = proxyActivities({ + startToCloseTimeout: '20s', +}); + +export async function fileProcessingWorkflow( + source: string, + destination: string, +): Promise { + const downloaded = await download(source); + + const hostSpecificActivities = proxyActivities({ + taskQueue: downloaded.hostTaskQueue, + scheduleToStartTimeout: '10s', + startToCloseTimeout: '20s', + }); + + const processed = await hostSpecificActivities.process(downloaded.fileName); + await hostSpecificActivities.upload(processed, destination); +} +``` + + + + +The Workflow creates two sets of Activity options: one for the default Task Queue and one for the host-specific queue returned by the download Activity. + +### Worker setup + +Each Worker registers with both the default Task Queue and its own host-specific Task Queue: + + + + +```python +# worker.py +import asyncio +import uuid +import socket +from temporalio.client import Client +from temporalio.worker import Worker +from workflows import FileProcessingWorkflow +from activities import download, process, upload, host_specific_task_queue +import activities as act_module + +async def main(): + client = await Client.connect("localhost:7233") + + default_task_queue = "FileProcessing" + host_task_queue = f"FileProcessing-{socket.gethostname()}-{uuid.uuid4()}" + + act_module.host_specific_task_queue = host_task_queue + + default_worker = Worker( + client, + task_queue=default_task_queue, + workflows=[FileProcessingWorkflow], + activities=[download, process, upload], + ) + host_worker = Worker( + client, + task_queue=host_task_queue, + activities=[download, process, upload], + ) + + await asyncio.gather(default_worker.run(), host_worker.run()) + +if __name__ == "__main__": + asyncio.run(main()) +``` + + + + +```go +// worker/main.go +func main() { + c, err := client.Dial(client.Options{}) + if err != nil { + log.Fatalln("Unable to create client", err) + } + defer c.Close() + + defaultTaskQueue := "FileProcessing" + hostTaskQueue := fmt.Sprintf("FileProcessing-%s-%s", getHostName(), uuid.New().String()) + + activities := &StoreActivities{HostSpecificTaskQueue: hostTaskQueue} + + defaultWorker := worker.New(c, defaultTaskQueue, worker.Options{}) + defaultWorker.RegisterWorkflow(FileProcessingWorkflow) + defaultWorker.RegisterActivity(activities) + + hostWorker := worker.New(c, hostTaskQueue, worker.Options{}) + hostWorker.RegisterActivity(activities) + + err = defaultWorker.Start() + if err != nil { + log.Fatalln("Unable to start default worker", err) + } + err = hostWorker.Start() + if err != nil { + log.Fatalln("Unable to start host worker", err) + } + + // Block until interrupted + select {} +} +``` + + + + +```java +// FileProcessingWorker.java +public class FileProcessingWorker { + public static void main(String[] args) { + WorkflowClient client = WorkflowClient.newInstance(service); + + String defaultTaskQueue = "FileProcessing"; + String hostTaskQueue = "FileProcessing-" + getHostName(); + + WorkerFactory factory = WorkerFactory.newInstance(client); + + Worker defaultWorker = factory.newWorker(defaultTaskQueue); + defaultWorker.registerWorkflowImplementationTypes( + FileProcessingWorkflowImpl.class); + defaultWorker.registerActivitiesImplementations( + new StoreActivitiesImpl(hostTaskQueue)); + + Worker hostWorker = factory.newWorker(hostTaskQueue); + hostWorker.registerActivitiesImplementations( + new StoreActivitiesImpl(hostTaskQueue)); + + factory.start(); + } +} +``` + + + + +```typescript +// worker.ts +import { Worker, NativeConnection } from '@temporalio/worker'; +import * as activities from './activities'; +import { v4 as uuid } from 'uuid'; +import os from 'os'; + +async function run() { + const defaultTaskQueue = 'FileProcessing'; + const hostTaskQueue = `FileProcessing-${os.hostname()}-${uuid()}`; + + activities.initActivities(hostTaskQueue); + + const defaultWorker = await Worker.create({ + workflowsPath: require.resolve('./workflows'), + activities, + taskQueue: defaultTaskQueue, + }); + + const hostWorker = await Worker.create({ + activities, + taskQueue: hostTaskQueue, + }); + + await Promise.all([defaultWorker.run(), hostWorker.run()]); +} + +run().catch((err) => { + console.error(err); + process.exit(1); +}); +``` + + + + +The default Worker handles Workflows and initial Activities. +The host-specific Worker handles only Activities that require Worker affinity. +Both Workers receive the same Activity implementation, but only the host-specific Worker receives Activities routed to its queue. + +## When to use + +The Worker-Specific Task Queues pattern is a good fit for file processing Workflows (download, process, upload on the same host), database connection pooling (maintain a connection across Activities), GPU-bound operations (route to Workers with specific hardware), session-based external API calls, and temporary resource management (cache, temp files, locks). + +It is not a good fit for stateless Activities that can run anywhere, Activities that use shared storage (S3, databases), high-availability requirements (host failure blocks the Workflow), or Workflows without local state dependencies. + +## Benefits and trade-offs + +Activities access local files and state without network overhead. +You do not need distributed file systems or state management. +Data transfer between Workers is eliminated. +The first Activity can run on any Worker; only subsequent ones are pinned. +Task Queue routing is recorded in Workflow history, ensuring deterministic behavior. + +The trade-offs to consider are that if the specific Worker crashes, Activities cannot proceed until the ScheduleToStartTimeout expires. +Host-specific queues may have uneven load distribution. +You must manage multiple Task Queues per Worker. +You must set ScheduleToStartTimeout to handle Worker unavailability. +You need to handle cleanup if the Workflow fails mid-process. + +## Comparison with alternatives + +| Approach | Locality | Complexity | Availability | +| :--- | :--- | :--- | :--- | +| Worker-Specific Queues | Guaranteed | Medium | Lower | +| Shared Storage (S3) | None | Low | Higher | +| Session Framework (Go) | Guaranteed | Low | Lower | + +## Best practices + +- **Set ScheduleToStartTimeout.** Always configure this for host-specific queues to handle Worker failures. +- **Implement cleanup.** Use try-finally or cancellation scopes to clean up local resources. +- **Use unique queue names.** Use hostname, IP, or UUID to ensure unique Task Queue names. +- **Monitor queue depth.** Alert on growing host-specific queue backlogs. +- **Drain gracefully.** Drain host-specific queues before stopping Workers. +- **Retry the entire sequence.** Wrap the sequence in retry logic to restart on a different host if needed. +- **Limit concurrent Workflows.** Limit concurrent Workflows per Worker to prevent resource exhaustion. +- **Add health checks.** Verify Worker health before accepting work on host-specific queues. + +## Common pitfalls + +- **Missing ScheduleToStartTimeout on host-specific queues.** Without this timeout, if the target Worker is down, the Activity waits indefinitely. Always set `ScheduleToStartTimeout` so the Workflow can detect unavailability and retry on a different host. +- **Not registering the Worker on both queues.** Each Worker must listen on both the default shared Task Queue (for Workflows and initial Activities) and its own host-specific queue. Forgetting the host-specific queue means routed Activities are never picked up. +- **Assuming the host-specific Worker is always available.** The pinned Worker can crash or be restarted. Design the Workflow to retry the entire sequence on a different host when the `ScheduleToStartTimeout` expires. +- **Leaking temporary files on failure.** If the Workflow fails after downloading but before uploading, temporary files remain on disk. Use cleanup logic (defer, try-finally, or cancellation scopes) to remove local resources. +- **Using host-specific queues when shared storage suffices.** If all Workers can access the same storage (S3, NFS), Worker-specific routing adds unnecessary complexity and reduces availability. + +## Related patterns + +- **[Long-Running Activity](/design-patterns/long-running-activity)**: For tracking progress and handling cancellation when the colocated Activities run for minutes or hours. + +## Sample code + +- [Java Sample](https://github.com/temporalio/samples-java/tree/main/core/src/main/java/io/temporal/samples/fileprocessing) — Complete file processing implementation. +- [TypeScript Sample](https://github.com/temporalio/samples-typescript/tree/main/worker-specific-task-queues) — Worker-specific Task Queues with file processing. +- [Python Sample](https://github.com/temporalio/samples-python/tree/main/worker_specific_task_queues) — Worker-specific Task Queues with file processing. +- [Go Sample](https://github.com/temporalio/samples-go/tree/main/worker-specific-task-queues) — Worker-specific Task Queues with file processing. diff --git a/docs/design-patterns/workflow-messaging-patterns.mdx b/docs/design-patterns/workflow-messaging-patterns.mdx new file mode 100644 index 0000000000..4a4fcdfcf6 --- /dev/null +++ b/docs/design-patterns/workflow-messaging-patterns.mdx @@ -0,0 +1,46 @@ +--- +id: workflow-messaging-patterns +title: "Workflow Messaging Patterns" +sidebar_label: "Workflow Messaging Patterns" +description: "Pattern selection guide for sending data into running Workflows and receiving responses or triggering behavior changes." +--- + +import PatternCards from '@site/src/components/PatternCards'; + +These patterns cover how external callers communicate with running Workflows — starting them on demand, sending data in, reading results back, and collecting streams of events. They build on Temporal's Signals and Updates. + +## Patterns in this section + + + +## Choosing a pattern + +**You want to send a message without checking whether the Workflow is running**: use [Signal with Start](/design-patterns/signal-with-start). + +**You need a result back from the Workflow, with validation**: use [Request-Response via Updates](/design-patterns/request-response-via-updates). + +**You receive many events and want to process them in batches**: use the [Event Accumulator](/design-patterns/event-accumulator) to buffer and flush. + +## Related sections + +- [Entity & Lifecycle Patterns](/design-patterns/entity-lifecycle-patterns) — long-lived Workflows that consume these messages over time +- [Distributed Transaction Patterns](/design-patterns/distributed-transaction-patterns) — Early Return builds on the Update-with-Start mechanism diff --git a/docs/develop/dotnet/activities/index.mdx b/docs/develop/dotnet/activities/index.mdx index e9b6b7a3ab..a015ec7f6d 100644 --- a/docs/develop/dotnet/activities/index.mdx +++ b/docs/develop/dotnet/activities/index.mdx @@ -13,8 +13,12 @@ tags: import * as Components from '@site/src/components'; + + ![.NET SDK Banner](/img/assets/banner-dotnet-temporal.png) + + ## Activities - [Activity basics](/develop/dotnet/activities/basics) diff --git a/docs/develop/dotnet/activities/standalone-activities.mdx b/docs/develop/dotnet/activities/standalone-activities.mdx index 0e0dd1c9d7..ef49ffa310 100644 --- a/docs/develop/dotnet/activities/standalone-activities.mdx +++ b/docs/develop/dotnet/activities/standalone-activities.mdx @@ -58,7 +58,7 @@ Prerequisites: - **[.NET](https://dotnet.microsoft.com/download)** 8.0+ -- **Temporal .NET SDK** (v1.12.0 or higher). See the [.NET Quickstart](https://docs.temporal.io/develop/dotnet/set-up-your-local-dotnet) for install instructions. +- **Temporal .NET SDK** (v1.12.0 or higher). See the [.NET Quickstart](/develop/dotnet/set-up-your-local-dotnet) for install instructions. - **Temporal CLI** v1.7.0 or higher diff --git a/docs/develop/dotnet/activities/timeouts.mdx b/docs/develop/dotnet/activities/timeouts.mdx index 8cbb8eb309..e15301d5cf 100644 --- a/docs/develop/dotnet/activities/timeouts.mdx +++ b/docs/develop/dotnet/activities/timeouts.mdx @@ -25,7 +25,7 @@ The following Timeouts are available in the Activity Options. - **[Schedule-To-Close Timeout](/encyclopedia/detecting-activity-failures#schedule-to-close-timeout):** is the maximum amount of time allowed for the overall [Activity Execution](/activity-execution). - **[Start-To-Close Timeout](/encyclopedia/detecting-activity-failures#start-to-close-timeout):** is the maximum time allowed for a single [Activity Task Execution](/tasks#activity-task-execution). -- **[Schedule-To-Start Timeout](/encyclopedia/detecting-activity-failures#schedule-to-start-timeout):** is the maximum amount of time that is allowed from when an [Activity Task](/tasks#activity-task) is scheduled to when a [Worker](/workers#worker) starts that Activity Task. +- **[Schedule-To-Start Timeout](/encyclopedia/detecting-activity-failures#schedule-to-start-timeout):** is the maximum amount of time that is allowed from when an [Activity Task](/tasks#activity-task) is scheduled to when a [Worker](/workers#worker) starts that Activity Task. This timeout is non-retryable by design. An Activity Execution must have either the Start-To-Close or the Schedule-To-Close Timeout set. @@ -34,8 +34,8 @@ These values can be set in the `ActivityOptions` when calling `ExecuteActivityAs Available timeouts are: - ScheduleToCloseTimeout -- ScheduleToStartTimeout - StartToCloseTimeout +- ScheduleToStartTimeout ```csharp return await Workflow.ExecuteActivityAsync( diff --git a/docs/develop/dotnet/best-practices/error-handling.mdx b/docs/develop/dotnet/best-practices/error-handling.mdx index 7780ca75f0..d57b90faed 100644 --- a/docs/develop/dotnet/best-practices/error-handling.mdx +++ b/docs/develop/dotnet/best-practices/error-handling.mdx @@ -97,6 +97,6 @@ catch (ActivityFailureException err) This works differently in a Workflow than raising exceptions from Activities. In an Activity, any C# exceptions or custom exceptions are converted to a Temporal `ApplicationError`. -In a Workflow, any exceptions that are raised other than an explicit Temporal `ApplicationError` will only fail that particular [Workflow Task](https://docs.temporal.io/tasks#workflow-task-execution) and be retried. +In a Workflow, any exceptions that are raised other than an explicit Temporal `ApplicationError` will only fail that particular [Workflow Task](/tasks#workflow-task-execution) and be retried. This includes any typical C# `RuntimeError`s that are raised automatically. These errors are treated as bugs that can be corrected with a fixed deployment, rather than a reason for a Temporal Workflow Execution to return unexpectedly. diff --git a/docs/develop/dotnet/best-practices/index.mdx b/docs/develop/dotnet/best-practices/index.mdx index 213723e27f..5cb9b95f4a 100644 --- a/docs/develop/dotnet/best-practices/index.mdx +++ b/docs/develop/dotnet/best-practices/index.mdx @@ -13,8 +13,12 @@ tags: import * as Components from '@site/src/components'; + + ![.NET SDK Banner](/img/assets/banner-dotnet-temporal.png) + + ## Best practices - [Error handling](/develop/dotnet/best-practices/error-handling) diff --git a/docs/develop/dotnet/client/index.mdx b/docs/develop/dotnet/client/index.mdx index 19a9c8fcdf..86d7917f36 100644 --- a/docs/develop/dotnet/client/index.mdx +++ b/docs/develop/dotnet/client/index.mdx @@ -13,8 +13,12 @@ tags: import * as Components from '@site/src/components'; + + ![.NET SDK Banner](/img/assets/banner-dotnet-temporal.png) + + ## Temporal Client - [Temporal Client](/develop/dotnet/client/temporal-client) diff --git a/docs/develop/dotnet/client/temporal-client.mdx b/docs/develop/dotnet/client/temporal-client.mdx index e1a75fe5c0..3ac3bfc119 100644 --- a/docs/develop/dotnet/client/temporal-client.mdx +++ b/docs/develop/dotnet/client/temporal-client.mdx @@ -43,7 +43,7 @@ configuration. You can provide these options directly in code, or load them from configuration file for secure, repeatable configuration. When you’re running a Temporal Service locally (such as with the -[Temporal CLI dev server](https://docs.temporal.io/cli/command-reference/server#start-dev)), the required options are minimal. If you +[Temporal CLI dev server](/cli/command-reference/server#start-dev)), the required options are minimal. If you don't specify a host/port, most connections default to `127.0.0.1:7233` and the `default` Namespace. diff --git a/docs/develop/dotnet/index.mdx b/docs/develop/dotnet/index.mdx index 2b6bd32954..6f2e6a0fdf 100644 --- a/docs/develop/dotnet/index.mdx +++ b/docs/develop/dotnet/index.mdx @@ -16,8 +16,12 @@ tags: import * as Components from '@site/src/components'; + + ![.NET](/img/assets/banner-dotnet-temporal.png) + + ## Install and get started You can find detailed installation instructions for the .NET SDK in the [Quickstart](/develop/dotnet/set-up-your-local-dotnet). @@ -82,7 +86,7 @@ From there, you can dive deeper into any of the Temporal primitives to start bui - [Debugging](/develop/dotnet/best-practices/debugging) - [Converters and encryption](/develop/dotnet/best-practices/converters-and-encryption) -## Temporal .NET Technical Resources +## Temporal .NET technical resources - [.NET Quickstart](/develop/dotnet/set-up-your-local-dotnet) - [.NET API Documentation](https://dotnet.temporal.io/api/) diff --git a/docs/develop/dotnet/nexus/feature-guide.mdx b/docs/develop/dotnet/nexus/feature-guide.mdx index 02025bf94b..d2fc2b8719 100644 --- a/docs/develop/dotnet/nexus/feature-guide.mdx +++ b/docs/develop/dotnet/nexus/feature-guide.mdx @@ -87,7 +87,7 @@ Defining a clear contract for the Nexus Service is crucial for smooth communicat In this example, there is a service package that describes the Service and Operation names along with input/output types for caller Workflows to use the Nexus Endpoint. -Each [Temporal SDK includes and uses a default Data Converter](https://docs.temporal.io/dataconversion). +Each [Temporal SDK includes and uses a default Data Converter](/dataconversion). The default data converter encodes payloads in the following order: Null, Byte array, Protobuf JSON, and JSON. In a polyglot environment, that is where more than one language and SDK is being used to develop a Temporal solution, Protobuf and JSON are common choices. This example uses .NET classes serialized into JSON. diff --git a/docs/develop/dotnet/nexus/index.mdx b/docs/develop/dotnet/nexus/index.mdx index 73f10b5d53..0237e6f61d 100644 --- a/docs/develop/dotnet/nexus/index.mdx +++ b/docs/develop/dotnet/nexus/index.mdx @@ -17,9 +17,14 @@ import { ReleaseNoteHeader } from '@site/src/components'; featureName="nexus" /> + + ![.NET SDK Banner](/img/assets/banner-dotnet-temporal.png) + + ## Temporal Nexus - [Quickstart](/develop/dotnet/nexus/quickstart) - [Feature guide](/develop/dotnet/nexus/feature-guide) +- [Standalone Operations](/develop/dotnet/nexus/standalone-operations) diff --git a/docs/develop/dotnet/nexus/quickstart.mdx b/docs/develop/dotnet/nexus/quickstart.mdx index f1f0fb6940..2b69e4126a 100644 --- a/docs/develop/dotnet/nexus/quickstart.mdx +++ b/docs/develop/dotnet/nexus/quickstart.mdx @@ -16,8 +16,7 @@ tags: hide_table_of_contents: true --- -import { SetupSteps, SetupStep, CodeSnippet } from '@site/src/components/elements/SetupSteps'; -import { ReleaseNoteHeader } from '@site/src/components'; +import { SetupSteps, SetupStep, CodeSnippet, ReleaseNoteHeader } from '@site/src/components'; + Requires .NET SDK `1.16.0` or above. All APIs are experimental and may be subject to backwards-incompatible changes. + + +[Standalone Nexus Operations](/standalone-nexus-operation) let you run Nexus Operation Executions independently, without +being orchestrated by a Workflow. Instead of calling a Nexus Operation from within a Workflow Definition using +`Workflow.CreateNexusWorkflowClient()`, you execute a Standalone Nexus Operation directly from a Nexus Client +created using `ITemporalClient.CreateNexusClient()`. + +Standalone Nexus Operations use the same Nexus Service contract, Operation handlers, and Worker setup as +Workflow-driven Operations — only the execution path differs. See the [Nexus feature guide](/develop/dotnet/nexus/feature-guide) +for details on +[defining a Service contract](/develop/dotnet/nexus/feature-guide#define-nexus-service-contract), +[developing Operation handlers](/develop/dotnet/nexus/feature-guide#develop-nexus-service-operation-handlers), and +[registering a Service in a Worker](/develop/dotnet/nexus/feature-guide#register-a-nexus-service-in-a-worker). + +This page focuses on the client-side APIs that are unique to Standalone Nexus Operations: + +- [Execute a Standalone Nexus Operation](#execute-operation) +- [Get the result of a Standalone Nexus Operation](#get-operation-result) +- [List Standalone Nexus Operations](#list-operations) +- [Count Standalone Nexus Operations](#count-operations) +- [Run Standalone Nexus Operations with Temporal Cloud](#run-standalone-nexus-operations-temporal-cloud) + +:::note +This documentation uses source code from the +[.NET Nexus Standalone sample](https://github.com/temporalio/samples-dotnet/tree/main/src/NexusStandaloneOperations). + +::: + +## Prerequisites {/* #prerequisites */} + +Standalone Nexus Operations are at Pre-release and require a special Temporal CLI build. + +### 1. Install and verify the Pre-release Temporal CLI {/* #install-cli */} + +The `temporal nexus operation` commands require a Pre-release build of the Temporal CLI. See +[Temporal CLI support](/standalone-nexus-operation#temporal-cli-support) for the platform downloads, +then verify: + +```bash +./temporal --version +# temporal version 1.7.4-standalone-nexus-operations +``` + +Run it as `./temporal` from the directory where you extracted it. The standard `brew install temporal` +build does not include Standalone Nexus Operation support during Pre-release. + +### 2. Start a local dev server {/* #enable-dev-server */} + +The Pre-release dev server enables Standalone Nexus Operations by default — no dynamic config is +required. Start it with the caller and handler Namespaces pre-created: + +```bash +./temporal server start-dev \ + --namespace my-caller-namespace \ + --namespace my-handler-namespace +``` + +The starter and Worker connect to two different Namespaces (a caller Namespace and a handler +Namespace), mirroring how Nexus crosses Namespace boundaries. + +To run the examples on this page against the [.NET sample](https://github.com/temporalio/samples-dotnet/tree/main/src/NexusStandaloneOperations), +create a Nexus Endpoint that routes to the handler Namespace and the Worker's Task Queue: + +```bash +./temporal operator nexus endpoint create \ + --name my-nexus-endpoint \ + --target-namespace my-handler-namespace \ + --target-task-queue nexus-handler-queue +``` + +Start the sample Worker in the handler Namespace: + +```bash +TEMPORAL_NAMESPACE=my-handler-namespace dotnet run worker +``` + +Run the starter in the caller Namespace (from a separate terminal): + +```bash +TEMPORAL_NAMESPACE=my-caller-namespace dotnet run starter +``` + +## Execute a Standalone Nexus Operation {/* #execute-operation */} + +To execute a Standalone Nexus Operation, first create a +[`NexusClient`](https://dotnet.temporal.io/api/Temporalio.Client.NexusClient.html) using +`ITemporalClient.CreateNexusClient()`, bound to a specific Nexus Endpoint and Service. The endpoint must be +pre-created on the server. Then call `ExecuteNexusOperationAsync()` from application code (for example, a starter +program), not from inside a Workflow Definition. + +`ExecuteNexusOperationAsync` is a shortcut that starts the Operation and waits for the result. If you need a handle to +the Operation while it runs, call `StartNexusOperationAsync` instead — it returns a +[`NexusOperationHandle`](https://dotnet.temporal.io/api/Temporalio.Client.NexusOperationHandle.html) that you can use to +get the result, describe, cancel, or terminate the Operation. `Id` is required on +[`NexusOperationOptions`](https://dotnet.temporal.io/api/Temporalio.Client.NexusOperationOptions.html); +`ScheduleToCloseTimeout` is optional and defaults to the maximum allowed by the Temporal server. + +```csharp +var nexusClient = client.CreateNexusClient("my-nexus-endpoint"); + +var result = await nexusClient.ExecuteNexusOperationAsync( + svc => svc.Echo(new("Nexus Echo 👋")), + new("unique-operation-id") + { + ScheduleToCloseTimeout = TimeSpan.FromSeconds(10), + }); +``` + +You can also use the untyped overload that takes the Operation name as a string: + +```csharp +var nexusClient = client.CreateNexusClient("my-nexus-endpoint", "HelloService"); + +var handle = await nexusClient.StartNexusOperationAsync( + "Echo", + new IHelloService.EchoInput("Nexus Echo 👋"), + new("unique-operation-id") + { + ScheduleToCloseTimeout = TimeSpan.FromSeconds(10), + }); +``` + +Or use the Temporal CLI to execute a Standalone Nexus Operation: + +```bash +./temporal nexus operation execute \ + --namespace my-caller-namespace \ + --endpoint my-nexus-endpoint \ + --service HelloService \ + --operation Echo \ + --operation-id my-echo-op \ + --input '{"Message":"hello"}' +``` + +## Get the result of a Standalone Nexus Operation {/* #get-operation-result */} + +Use `NexusOperationHandle.GetResultAsync()` to await the Operation's completion and retrieve its result. This +works for both synchronous and asynchronous (Workflow-backed) Operations. + +```csharp +var output = await handle.GetResultAsync(); +logger.LogInformation("Operation result: {Message}", output.Message); +``` + +If the Operation completed successfully, the result is deserialized into the handle's result type. If the Operation +failed, a `NexusOperationFailedException` is thrown. + +You can also recover a handle for an already-started Operation using `GetNexusOperationHandle()` on the +Temporal Client: + +```csharp +var handle = client.GetNexusOperationHandle("unique-operation-id"); +var output = await handle.GetResultAsync(); +``` + +Or use the Temporal CLI to wait for a result by Operation ID: + +```bash +./temporal nexus operation result --namespace my-caller-namespace --operation-id my-echo-op +``` + +## List Standalone Nexus Operations {/* #list-operations */} + +Use [`ITemporalClient.ListNexusOperationsAsync()`](https://dotnet.temporal.io/api/Temporalio.Client.ITemporalClient.html#Temporalio_Client_ITemporalClient_ListNexusOperationsAsync_System_String_Temporalio_Client_NexusOperationListOptions_) +to list Standalone Nexus Operation Executions that match a [List Filter](/list-filter) query. The call returns an +`IAsyncEnumerable` that you can iterate with `await foreach`. + +Note that `ListNexusOperationsAsync` is called on the base `ITemporalClient`, not on the `NexusClient`. + +```csharp +await foreach (var execution in client.ListNexusOperationsAsync( + "Endpoint = 'my-nexus-endpoint'")) +{ + logger.LogInformation( + "OperationID: {Id}, Operation: {Operation}, Status: {Status}", + execution.OperationId, execution.Operation, execution.Status); +} +``` + +The query string accepts [List Filter](/list-filter) syntax. For example, +`"Endpoint = 'my-endpoint' AND Status = 'Running'"`. + +Or use the Temporal CLI: + +```bash +./temporal nexus operation list --namespace my-caller-namespace --query 'Endpoint = "my-nexus-endpoint"' +``` + +## Count Standalone Nexus Operations {/* #count-operations */} + +Use [`ITemporalClient.CountNexusOperationsAsync()`](https://dotnet.temporal.io/api/Temporalio.Client.ITemporalClient.html#Temporalio_Client_ITemporalClient_CountNexusOperationsAsync_System_String_Temporalio_Client_NexusOperationCountOptions_) +to count Standalone Nexus Operation Executions that match a [List Filter](/list-filter) query. + +Note that `CountNexusOperationsAsync` is called on the base `ITemporalClient`, not on the `NexusClient`. + +```csharp +var count = await client.CountNexusOperationsAsync( + "Endpoint = 'my-nexus-endpoint'"); +logger.LogInformation("Total Nexus operations: {Count}", count.Count); +``` + +Or use the Temporal CLI: + +```bash +./temporal nexus operation count --namespace my-caller-namespace --query 'Endpoint = "my-nexus-endpoint"' +``` + +## Run Standalone Nexus Operations with Temporal Cloud {/* #run-standalone-nexus-operations-temporal-cloud */} + +Standalone Nexus Operations work against Temporal Cloud with the same code — only the client connection options change. +For full details on connecting to Temporal Cloud, including Namespace creation, Nexus Endpoint setup, certificate +generation, and authentication options, see +[Make Nexus calls across Namespaces in Temporal Cloud](/develop/dotnet/nexus/feature-guide#nexus-calls-across-namespaces-temporal-cloud) +and [Connect to Temporal Cloud](/develop/dotnet/client/temporal-client#connect-to-temporal-cloud). diff --git a/docs/develop/dotnet/platform/index.mdx b/docs/develop/dotnet/platform/index.mdx index dd4cbd38ec..7ff6f3e3c5 100644 --- a/docs/develop/dotnet/platform/index.mdx +++ b/docs/develop/dotnet/platform/index.mdx @@ -13,8 +13,12 @@ tags: import * as Components from '@site/src/components'; + + ![.NET SDK Banner](/img/assets/banner-dotnet-temporal.png) + + ## Platform - [Observability](/develop/dotnet/platform/observability) diff --git a/docs/develop/dotnet/platform/observability.mdx b/docs/develop/dotnet/platform/observability.mdx index 68a0e3484d..1f9b25581f 100644 --- a/docs/develop/dotnet/platform/observability.mdx +++ b/docs/develop/dotnet/platform/observability.mdx @@ -106,19 +106,9 @@ An appropriate logging level depends on your specific needs. During development or troubleshooting, you might use debug or even trace. In production, you might use info or warn to avoid excessive log volume. -The logger supports the following logging levels: +Logging uses the .NET standard logging APIs. You can find the log levels supported in [their official documentation](https://learn.microsoft.com/en-us/dotnet/api/microsoft.extensions.logging.loglevel?view=net-11.0-pp). The Temporal SDK core normally uses `WARN` as its default logging level. -| Level | Use | -| ------- | --------------------------------------------------------------------------------------------------------- | -| `TRACE` | The most detailed level of logging, used for very fine-grained information. | -| `DEBUG` | Detailed information, typically useful for debugging purposes. | -| `INFO` | General information about the application's operation. | -| `WARN` | Indicates potentially harmful situations or minor issues that don't prevent the application from working. | -| `ERROR` | Indicates error conditions that might still allow the application to continue running. | -The Temporal SDK core normally uses `WARN` as its default logging level. - -Logging uses the .NET standard logging APIs. The `LoggerFactory` can be set in the client. The following example shows logging on the console and sets the level to `Information`. diff --git a/docs/develop/dotnet/set-up.mdx b/docs/develop/dotnet/set-up.mdx index 8d04074f20..ddf31f98da 100644 --- a/docs/develop/dotnet/set-up.mdx +++ b/docs/develop/dotnet/set-up.mdx @@ -15,9 +15,7 @@ tags: hide_table_of_contents: true --- -import { SetupSteps, SetupStep, CodeSnippet } from "@site/src/components/elements/SetupSteps"; -import { InfoBox } from "@site/src/components/elements/InfoBox"; -import { CallToAction } from "@site/src/components/elements/CallToAction"; +import { CallToAction, SetupSteps, SetupStep, CodeSnippet } from "@site/src/components"; import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; @@ -101,7 +99,7 @@ Build the solution: }> ## Install Temporal CLI and start the development server - The fastest way to get a development version of the Temporal Service running on your local machine is to use [Temporal CLI](https://docs.temporal.io/cli). + The fastest way to get a development version of the Temporal Service running on your local machine is to use [Temporal CLI](/cli). Choose your operating system to install Temporal CLI: diff --git a/docs/develop/dotnet/workers/index.mdx b/docs/develop/dotnet/workers/index.mdx index b6fee1716f..b0cdd18fa4 100644 --- a/docs/develop/dotnet/workers/index.mdx +++ b/docs/develop/dotnet/workers/index.mdx @@ -13,8 +13,12 @@ tags: import * as Components from '@site/src/components'; + + ![.NET SDK Banner](/img/assets/banner-dotnet-temporal.png) + + ## Workers - [Worker processes](/develop/dotnet/workers/run-worker-process) diff --git a/docs/develop/dotnet/workflows/continue-as-new.mdx b/docs/develop/dotnet/workflows/continue-as-new.mdx index c1842aa68d..30f48e6886 100644 --- a/docs/develop/dotnet/workflows/continue-as-new.mdx +++ b/docs/develop/dotnet/workflows/continue-as-new.mdx @@ -17,7 +17,7 @@ tags: - Temporal SDKs --- -import { ViewSourceCodeNotice } from '@site/src/components/elements/ViewSourceCodeNotice'; +import { ViewSourceCodeNotice } from '@site/src/components'; This page answers the following questions for .NET developers: diff --git a/docs/develop/dotnet/workflows/index.mdx b/docs/develop/dotnet/workflows/index.mdx index 023e96b5f3..b4581e3011 100644 --- a/docs/develop/dotnet/workflows/index.mdx +++ b/docs/develop/dotnet/workflows/index.mdx @@ -13,8 +13,12 @@ tags: import * as Components from '@site/src/components'; + + ![.NET SDK Banner](/img/assets/banner-dotnet-temporal.png) + + ## Workflows - [Workflow basics](/develop/dotnet/workflows/basics) diff --git a/docs/develop/environment-configuration.mdx b/docs/develop/environment-configuration.mdx index 189ee1a83e..67e1642320 100644 --- a/docs/develop/environment-configuration.mdx +++ b/docs/develop/environment-configuration.mdx @@ -11,7 +11,7 @@ tags: --- import { LANGUAGE_TAB_GROUP, getLanguageLabel } from '@site/src/constants/languageTabs'; -import SdkTabs from '@site/src/components'; +import { SdkTabs } from '@site/src/components'; import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; diff --git a/docs/develop/go/activities/basics.mdx b/docs/develop/go/activities/basics.mdx index 4fa00dd742..bbb4472c0d 100644 --- a/docs/develop/go/activities/basics.mdx +++ b/docs/develop/go/activities/basics.mdx @@ -11,7 +11,7 @@ tags: - Temporal SDKs --- -import { ViewSourceCodeNotice } from '@site/src/components/elements/ViewSourceCodeNotice'; +import { ViewSourceCodeNotice } from '@site/src/components'; ## How to develop an Activity Definition in Go {/* #activity-definition */} @@ -113,7 +113,7 @@ func (a *YourActivityObject) YourActivityDefinition(ctx context.Context, param Y All data returned from an Activity must be serializable. -Activity return values are subject to payload size limits in Temporal. The default payload size limit is 2MB, and there is a hard limit of 4MB for any gRPC message size in the Event History transaction ([see Cloud limits here](https://docs.temporal.io/cloud/limits#per-message-grpc-limit)). Keep in mind that all return values are recorded in a [Workflow Execution Event History](/workflow-execution/event#event-history). +Activity return values are subject to payload size limits in Temporal. The default payload size limit is 2MB, and there is a hard limit of 4MB for any gRPC message size in the Event History transaction ([see Cloud limits here](/cloud/limits#per-message-grpc-limit)). Keep in mind that all return values are recorded in a [Workflow Execution Event History](/workflow-execution/event#event-history). A Go-based Activity Definition can return either just an `error` or a `customValue, error` combination (same as a Workflow Definition). You may wish to use a `struct` type to hold all custom values, just keep in mind they must all be serializable. diff --git a/docs/develop/go/activities/execution.mdx b/docs/develop/go/activities/execution.mdx index fa34bfada2..5080e6ad84 100644 --- a/docs/develop/go/activities/execution.mdx +++ b/docs/develop/go/activities/execution.mdx @@ -11,7 +11,7 @@ tags: - Activity --- -import { ViewSourceCodeNotice } from '@site/src/components/elements/ViewSourceCodeNotice'; +import { ViewSourceCodeNotice } from '@site/src/components'; ## How to start an Activity Execution {/* #activity-execution */} diff --git a/docs/develop/go/activities/index.mdx b/docs/develop/go/activities/index.mdx index 9cabb4c32a..d7486fb2d9 100644 --- a/docs/develop/go/activities/index.mdx +++ b/docs/develop/go/activities/index.mdx @@ -13,8 +13,12 @@ tags: import * as Components from '@site/src/components'; + + ![Go SDK Banner](/img/assets/banner-go-temporal.png) + + ## Activities - [Activity basics](/develop/go/activities/basics) diff --git a/docs/develop/go/activities/standalone-activities.mdx b/docs/develop/go/activities/standalone-activities.mdx index 41efebb61e..2825caeab2 100644 --- a/docs/develop/go/activities/standalone-activities.mdx +++ b/docs/develop/go/activities/standalone-activities.mdx @@ -57,7 +57,7 @@ Prerequisites: - **[Go](https://go.dev/dl/)** 1.22+ -- **[Temporal Go SDK](https://docs.temporal.io/develop/go/core-application#install-a-temporal-sdk)** (v1.41.0 or higher) +- **[Temporal Go SDK](/develop/go/set-up-your-local-go#install-the-temporal-go-sdk)** (v1.41.0 or higher) - **Temporal CLI** v1.7.0 or higher diff --git a/docs/develop/go/activities/timeouts.mdx b/docs/develop/go/activities/timeouts.mdx index cb3f492dd0..ce17236f16 100644 --- a/docs/develop/go/activities/timeouts.mdx +++ b/docs/develop/go/activities/timeouts.mdx @@ -26,7 +26,7 @@ The following timeouts are available in the Activity Options. - **[Schedule-To-Close Timeout](/encyclopedia/detecting-activity-failures#schedule-to-close-timeout):** is the maximum amount of time allowed for the overall [Activity Execution](/activity-execution). - **[Start-To-Close Timeout](/encyclopedia/detecting-activity-failures#start-to-close-timeout):** is the maximum time allowed for a single [Activity Task Execution](/tasks#activity-task-execution). -- **[Schedule-To-Start Timeout](/encyclopedia/detecting-activity-failures#schedule-to-start-timeout):** is the maximum amount of time that is allowed from when an [Activity Task](/tasks#activity-task) is scheduled to when a [Worker](/workers#worker) starts that Activity Task. +- **[Schedule-To-Start Timeout](/encyclopedia/detecting-activity-failures#schedule-to-start-timeout):** is the maximum amount of time that is allowed from when an [Activity Task](/tasks#activity-task) is scheduled to when a [Worker](/workers#worker) starts that Activity Task. This timeout is non-retryable by design. An Activity Execution must have either the Start-To-Close or the Schedule-To-Close Timeout set. diff --git a/docs/develop/go/best-practices/index.mdx b/docs/develop/go/best-practices/index.mdx index 1c364958a7..9fc6d490f6 100644 --- a/docs/develop/go/best-practices/index.mdx +++ b/docs/develop/go/best-practices/index.mdx @@ -13,8 +13,12 @@ tags: import * as Components from '@site/src/components'; + + ![Go SDK Banner](/img/assets/banner-go-temporal.png) + + ## Best practices - [Multithreading](/develop/go/best-practices/multithreading) diff --git a/docs/develop/go/client/index.mdx b/docs/develop/go/client/index.mdx index 062f0a1efd..8a9627a15b 100644 --- a/docs/develop/go/client/index.mdx +++ b/docs/develop/go/client/index.mdx @@ -13,8 +13,12 @@ tags: import * as Components from '@site/src/components'; + + ![Go SDK Banner](/img/assets/banner-go-temporal.png) + + ## Temporal Client - [Temporal Client](/develop/go/client/temporal-client) diff --git a/docs/develop/go/client/namespaces.mdx b/docs/develop/go/client/namespaces.mdx index 3b168a7f66..af82a9dbce 100644 --- a/docs/develop/go/client/namespaces.mdx +++ b/docs/develop/go/client/namespaces.mdx @@ -23,7 +23,7 @@ Use Namespaces to isolate your Workflow Executions according to your needs. For example, you can use Namespaces to match the development lifecycle by having separate `dev` and `prod` Namespaces. You could also use them to ensure Workflow Executions between different teams never communicate - such as ensuring that the `teamA` Namespace never impacts the `teamB` Namespace. -On Temporal Cloud, use the [Temporal Cloud UI](/cloud/namespaces#create-a-namespace) to create and manage a Namespace from the UI, or [tcld commands](https://docs.temporal.io/cloud/tcld/namespace/) to manage Namespaces from the command-line interface. +On Temporal Cloud, use the [Temporal Cloud UI](/cloud/namespaces#create-a-namespace) to create and manage a Namespace from the UI, or [tcld commands](/cloud/tcld/namespace/) to manage Namespaces from the command-line interface. On self-hosted Temporal Service, you can register and manage your Namespaces using the Temporal CLI (recommended) or programmatically using APIs. Note that these APIs and Temporal CLI commands will not work with Temporal Cloud. @@ -36,7 +36,7 @@ You must register a Namespace with the Temporal Service before setting it in the Registering a Namespace creates a Namespace on the Temporal Service or Temporal Cloud. -On Temporal Cloud, use the [Temporal Cloud UI](/cloud/namespaces#create-a-namespace) or [tcld commands](https://docs.temporal.io/cloud/tcld/namespace/) to create Namespaces. +On Temporal Cloud, use the [Temporal Cloud UI](/cloud/namespaces#create-a-namespace) or [tcld commands](/cloud/tcld/namespace/) to create Namespaces. On self-hosted Temporal Service, you can register your Namespaces using the Temporal CLI (recommended) or programmatically using APIs. Note that these APIs and Temporal CLI commands will not work with Temporal Cloud. @@ -73,7 +73,7 @@ To update your Namespace using the Temporal CLI, use the [temporal operator name You can get details for your Namespaces, update Namespace configuration, and deprecate or delete your Namespaces. -On Temporal Cloud, use the [Temporal Cloud UI](/cloud/namespaces#create-a-namespace) or [tcld commands](https://docs.temporal.io/cloud/tcld/namespace/) to manage Namespaces. +On Temporal Cloud, use the [Temporal Cloud UI](/cloud/namespaces#create-a-namespace) or [tcld commands](/cloud/tcld/namespace/) to manage Namespaces. On self-hosted Temporal Service, you can manage your registered Namespaces using the Temporal CLI (recommended) or programmatically using APIs. Note that these APIs and Temporal CLI commands will not work with Temporal Cloud. diff --git a/docs/develop/go/client/temporal-client.mdx b/docs/develop/go/client/temporal-client.mdx index 099c545029..c73b8b140a 100644 --- a/docs/develop/go/client/temporal-client.mdx +++ b/docs/develop/go/client/temporal-client.mdx @@ -69,7 +69,7 @@ Environment variable and configuration file support were added in Go SDK v1.28.0 ::: When you are running a Temporal Service locally, such as the -[Temporal CLI](https://docs.temporal.io/cli/command-reference/server#start-dev), the connection options you must provide are minimal. +[Temporal CLI](/cli/command-reference/server#start-dev), the connection options you must provide are minimal. If you don't provide [`HostPort`](https://pkg.go.dev/go.temporal.io/sdk/internal#ClientOptions), the Client defaults the address and port number to `127.0.0.1:7233`, which is the port of the development Temporal Service. If you don't set a @@ -651,7 +651,7 @@ if err != nil { You can configure Task Queues that are host-specific, Worker-specific or Workflow-specific to distribute your application load. For more information, refer to [Task Queues Processing Tuning](/develop/worker-performance#task-queues-processing-tuning) and -[Worker Versioning](https://docs.temporal.io/worker-versioning). +[Worker Versioning](/worker-versioning). ### Set custom Workflow Id {/* #workflow-id */} diff --git a/docs/develop/go/index.mdx b/docs/develop/go/index.mdx index 8765fad0e0..62c05b03d0 100644 --- a/docs/develop/go/index.mdx +++ b/docs/develop/go/index.mdx @@ -15,8 +15,12 @@ tags: import * as Components from '@site/src/components'; + + ![Go SDK Banner](/img/assets/banner-go-temporal.png) + + ## Install and get started You can find detailed installation instructions for the Go SDK in the [Quickstart](/develop/go/set-up-your-local-go). @@ -73,6 +77,7 @@ From there, you can dive deeper into any of the Temporal primitives to start bui - [Quickstart](/develop/go/nexus/quickstart) - [Feature guide](/develop/go/nexus/feature-guide) +- [Standalone Operations](/develop/go/nexus/standalone-operations) ## [Platform](/develop/go/platform) diff --git a/docs/develop/go/nexus/feature-guide.mdx b/docs/develop/go/nexus/feature-guide.mdx index 7e9dca82bf..8162ea37ae 100644 --- a/docs/develop/go/nexus/feature-guide.mdx +++ b/docs/develop/go/nexus/feature-guide.mdx @@ -86,7 +86,7 @@ Defining a clear contract for the Nexus Service is crucial for smooth communicat In this example, there is a service package that describes the Service and Operation names along with input/output types for caller Workflows to use the Nexus Endpoint. -Each [Temporal SDK includes and uses a default Data Converter](https://docs.temporal.io/dataconversion). +Each [Temporal SDK includes and uses a default Data Converter](/dataconversion). The default data converter encodes payloads in the following order: Null, Byte array, Protobuf JSON, and JSON. In a polyglot environment, that is where more than one language and SDK is being used to develop a Temporal solution, Protobuf and JSON are common choices. This example uses native Go types. diff --git a/docs/develop/go/nexus/index.mdx b/docs/develop/go/nexus/index.mdx index 4dffdd4389..8ffc702bcf 100644 --- a/docs/develop/go/nexus/index.mdx +++ b/docs/develop/go/nexus/index.mdx @@ -11,9 +11,14 @@ tags: - Temporal SDKs --- + + ![Go SDK Banner](/img/assets/banner-go-temporal.png) + + ## Temporal Nexus - [Quickstart](/develop/go/nexus/quickstart) - [Feature guide](/develop/go/nexus/feature-guide) +- [Standalone Operations](/develop/go/nexus/standalone-operations) diff --git a/docs/develop/go/nexus/quickstart.mdx b/docs/develop/go/nexus/quickstart.mdx index 684f2a9f82..7e19c72761 100644 --- a/docs/develop/go/nexus/quickstart.mdx +++ b/docs/develop/go/nexus/quickstart.mdx @@ -16,7 +16,7 @@ tags: hide_table_of_contents: true --- -import { SetupSteps, SetupStep, CodeSnippet } from '@site/src/components/elements/SetupSteps'; +import { SetupSteps, SetupStep, CodeSnippet } from '@site/src/components'; # Nexus Go Quickstart diff --git a/docs/develop/go/nexus/standalone-operations.mdx b/docs/develop/go/nexus/standalone-operations.mdx new file mode 100644 index 0000000000..e708f2bece --- /dev/null +++ b/docs/develop/go/nexus/standalone-operations.mdx @@ -0,0 +1,238 @@ +--- +id: standalone-operations +title: Standalone Nexus Operations - Go SDK +sidebar_label: Standalone Operations +toc_max_heading_level: 4 +keywords: + - standalone nexus operation + - nexus operation execution + - execute nexus operation + - nexus operation handle + - list nexus operations + - count nexus operations + - go sdk +tags: + - Nexus + - Temporal Client + - Go SDK + - Temporal SDKs +description: Execute Nexus Operations independently without a Workflow using the Temporal Go SDK. +--- + +import { ReleaseNoteHeader } from '@site/src/components'; + + + Requires Go SDK `v1.46.0` or above. All APIs are experimental and may be subject to backwards-incompatible changes. + + +[Standalone Nexus Operations](/standalone-nexus-operation) let you run Nexus Operation Executions independently, without +being orchestrated by a Workflow. Instead of calling a Nexus Operation from within a Workflow Definition using +`workflow.NewNexusClient()`, you execute a Standalone Nexus Operation directly from a Nexus Client created using +`client.NewNexusClient()`. + +Standalone Nexus Operations use the same Nexus Service contract, Operation handlers, and Worker setup as +Workflow-driven Operations — only the execution path differs. See the [Nexus feature guide](/develop/go/nexus/feature-guide) for details on +[defining a Service contract](/develop/go/nexus/feature-guide#define-nexus-service-contract), +[developing Operation handlers](/develop/go/nexus/feature-guide#develop-nexus-service-operation-handlers), and +[registering a Service in a Worker](/develop/go/nexus/feature-guide#register-a-nexus-service-in-a-worker). + +This page focuses on the client-side APIs that are unique to Standalone Nexus Operations: + +- [Execute a Standalone Nexus Operation](#execute-operation) +- [Get the result of a Standalone Nexus Operation](#get-operation-result) +- [List Standalone Nexus Operations](#list-operations) +- [Count Standalone Nexus Operations](#count-operations) +- [Run Standalone Nexus Operations with Temporal Cloud](#run-standalone-nexus-operations-temporal-cloud) + +:::note +This documentation uses source code from the +[Go Nexus Standalone sample](https://github.com/temporalio/samples-go/tree/main/nexus-standalone-operations). + +::: + +## Prerequisites {/* #prerequisites */} + +Standalone Nexus Operations are at Pre-release and require a special Temporal CLI build. + +### 1. Install and verify the Pre-release Temporal CLI {/* #install-cli */} + +The `temporal nexus operation` commands require a Pre-release build of the Temporal CLI. See +[Temporal CLI support](/standalone-nexus-operation#temporal-cli-support) for the platform downloads, +then verify: + +```bash +./temporal --version +# temporal version 1.7.4-standalone-nexus-operations +``` + +Run it as `./temporal` from the directory where you extracted it. The standard `brew install temporal` +build does not include Standalone Nexus Operation support during Pre-release. + +### 2. Start a local dev server {/* #enable-dev-server */} + +The Pre-release dev server enables Standalone Nexus Operations by default — no dynamic config is +required. Start it with the caller and handler Namespaces pre-created: + +```bash +./temporal server start-dev \ + --namespace my-caller-namespace \ + --namespace my-handler-namespace +``` + +The starter and Worker connect to two different Namespaces (a caller Namespace and a handler +Namespace), mirroring how Nexus crosses Namespace boundaries. + +To run the examples on this page against the [Go sample](https://github.com/temporalio/samples-go/tree/main/nexus-standalone-operations), +create a Nexus Endpoint that routes to the handler Namespace and the Worker's Task Queue: + +```bash +./temporal operator nexus endpoint create \ + --name my-nexus-endpoint \ + --target-namespace my-handler-namespace \ + --target-task-queue nexus-handler-queue +``` + +Then start the sample Worker in the handler Namespace: + +```bash +TEMPORAL_NAMESPACE=my-handler-namespace go run nexus-standalone-operations/worker/main.go +``` + +## Execute a Standalone Nexus Operation {/* #execute-operation */} + +To execute a Standalone Nexus Operation, first create a +[`NexusClient`](https://pkg.go.dev/go.temporal.io/sdk/client#NexusClient) using `client.NewNexusClient()`, bound to a +specific Nexus Endpoint and Service. The endpoint must be pre-created on the server. Then call `ExecuteOperation()` from +application code (for example, a starter program), not from inside a Workflow Definition. + +`ExecuteOperation` returns a [`NexusOperationHandle`](https://pkg.go.dev/go.temporal.io/sdk/client#NexusOperationHandle) +that you can use to get the result of the Operation. +[`StartNexusOperationOptions`](https://pkg.go.dev/go.temporal.io/sdk/client#StartNexusOperationOptions) requires `ID`. +`ScheduleToCloseTimeout` is optional and defaults to the maximum allowed by the Temporal server. + +```go +nexusClient, err := c.NewNexusClient(client.NexusClientOptions{ + Endpoint: "my-nexus-endpoint", + Service: "my-hello-service", +}) + +handle, err := nexusClient.ExecuteOperation(ctx, operationName, input, client.StartNexusOperationOptions{ + ID: "unique-operation-id", + ScheduleToCloseTimeout: 10 * time.Second, +}) +``` + +See the full +[starter sample](https://github.com/temporalio/samples-go/blob/main/nexus-standalone-operations/starter/main.go) +for a complete example that executes both synchronous and asynchronous Operations, gets their results, and lists and +counts Operations. + +To run the starter in the caller Namespace (in a separate terminal from the Worker): + +``` +TEMPORAL_NAMESPACE=my-caller-namespace go run nexus-standalone-operations/starter/main.go +``` + +Or use the Temporal CLI to execute a Standalone Nexus Operation: + +```bash +./temporal nexus operation execute \ + --namespace my-caller-namespace \ + --endpoint my-nexus-endpoint \ + --service my-hello-service \ + --operation echo \ + --operation-id my-echo-op \ + --input '{"Message":"hello"}' +``` + +## Get the result of a Standalone Nexus Operation {/* #get-operation-result */} + +Use `NexusOperationHandle.Get()` to block until the Operation completes and retrieve its result. This works for both +synchronous and asynchronous (Workflow-backed) Operations. + +```go +var result service.EchoOutput +err = handle.Get(context.Background(), &result) +if err != nil { + log.Fatalln("Operation failed", err) +} +log.Println("Operation result:", result.Message) +``` + +If the Operation completed successfully, the result is deserialized into the provided pointer. If the Operation failed, +the failure is returned as an error. + +Or use the Temporal CLI to wait for a result by Operation ID: + +```bash +./temporal nexus operation result --namespace my-caller-namespace --operation-id my-echo-op +``` + +## List Standalone Nexus Operations {/* #list-operations */} + +Use [`client.ListNexusOperations()`](https://pkg.go.dev/go.temporal.io/sdk/client#Client) to list Standalone Nexus +Operation Executions that match a [List Filter](/list-filter) query. The result contains an iterator that yields +operation metadata entries. + +Note that `ListNexusOperations` is called on the base `client.Client`, not on the `NexusClient`. + +```go +resp, err := c.ListNexusOperations(context.Background(), client.ListNexusOperationsOptions{ + Query: "Endpoint = 'my-nexus-endpoint'", +}) +if err != nil { + log.Fatalln("Unable to list Nexus operations", err) +} + +for metadata, err := range resp.Results { + if err != nil { + log.Fatalln("Error iterating operations", err) + } + log.Printf("OperationID: %s, Operation: %s, Status: %v\n", + metadata.OperationID, metadata.Operation, metadata.Status) +} +``` + +The `Query` field accepts [List Filter](/list-filter) syntax. For example, +`"Endpoint = 'my-endpoint' AND Status = 'Running'"`. + +Or use the Temporal CLI: + +```bash +./temporal nexus operation list --namespace my-caller-namespace --query 'Endpoint = "my-nexus-endpoint"' +``` + +## Count Standalone Nexus Operations {/* #count-operations */} + +Use [`client.CountNexusOperations()`](https://pkg.go.dev/go.temporal.io/sdk/client#Client) to count Standalone Nexus +Operation Executions that match a [List Filter](/list-filter) query. + +Note that `CountNexusOperations` is called on the base `client.Client`, not on the `NexusClient`. + +```go +resp, err := c.CountNexusOperations(context.Background(), client.CountNexusOperationsOptions{ + Query: "Endpoint = 'my-nexus-endpoint'", +}) +if err != nil { + log.Fatalln("Unable to count Nexus operations", err) +} + +log.Println("Total Nexus operations:", resp.Count) +``` + +Or use the Temporal CLI: + +```bash +./temporal nexus operation count --namespace my-caller-namespace --query 'Endpoint = "my-nexus-endpoint"' +``` + +## Run Standalone Nexus Operations with Temporal Cloud {/* #run-standalone-nexus-operations-temporal-cloud */} + +The code samples on this page use `envconfig.MustLoadDefaultClientOptions()`, so the same code +works against Temporal Cloud — just configure the connection via environment variables or a TOML +profile. No code changes are needed. + +For full details on connecting to Temporal Cloud, including Namespace creation, Nexus Endpoint +setup, certificate generation, and authentication options, see +[Make Nexus calls across Namespaces in Temporal Cloud](/develop/go/nexus/feature-guide#nexus-calls-across-namespaces-temporal-cloud) +and [Connect to Temporal Cloud](/develop/go/client/temporal-client#connect-to-temporal-cloud). diff --git a/docs/develop/go/platform/index.mdx b/docs/develop/go/platform/index.mdx index a035fab4df..5bf6d49680 100644 --- a/docs/develop/go/platform/index.mdx +++ b/docs/develop/go/platform/index.mdx @@ -13,8 +13,12 @@ tags: import * as Components from '@site/src/components'; + + ![Go SDK Banner](/img/assets/banner-go-temporal.png) + + ## Platform - [Observability](/develop/go/platform/observability) diff --git a/docs/develop/go/platform/observability.mdx b/docs/develop/go/platform/observability.mdx index 5c56e81f57..463ccc7700 100644 --- a/docs/develop/go/platform/observability.mdx +++ b/docs/develop/go/platform/observability.mdx @@ -38,8 +38,6 @@ This section covers features related to viewing the state of the application, in ## How to emit metrics {/* #metrics */} -**How to emit application metrics using the Temporal Go SDK.** - Each Temporal SDK is capable of emitting an optional set of metrics from either the Client or the Worker process. For a complete list of metrics capable of being emitted, see the [SDK metrics reference](/references/sdk-metrics). @@ -51,10 +49,11 @@ To emit metrics from the Temporal Client in Go, create a [metrics handler](https ```go client.Options{ - MetricsHandler: sdktally.NewMetricsHandler(newPrometheusScope(prometheus.Configuration{ - ListenAddress: "0.0.0.0:9090", - TimerType: "histogram", - } + MetricsHandler: sdktally.NewMetricsHandler(newPrometheusScope(prometheus.Configuration{ + ListenAddress: "0.0.0.0:9090", + TimerType: "histogram", + } +} ``` The Go SDK currently supports the [Tally](https://pkg.go.dev/go.temporal.io/sdk/contrib/tally) library; however, Tally offers [extensible custom metrics reporting](https://github.com/uber-go/tally#report-your-metrics), which is exposed through the [`WithCustomMetricsReporter`](/references/server-options#withcustommetricsreporter) API. @@ -97,8 +96,6 @@ To build custom context propagation (e.g., tenant IDs, auth tokens), see [Contex ## Log from a Workflow {/* #logging */} -**How to log from a Workflow using the Go SDK.** - Send logs and errors to a logging service, so that when things go wrong, you can see what happened. Loggers create an audit trail and capture information about your Workflow's operation. @@ -106,27 +103,17 @@ An appropriate logging level depends on your specific needs. During development or troubleshooting, you might use debug or even trace. In production, you might use info or warn to avoid excessive log volume. -The logger supports the following logging levels: - -| Level | Use | -| ------- | --------------------------------------------------------------------------------------------------------- | -| `TRACE` | The most detailed level of logging, used for very fine-grained information. | -| `DEBUG` | Detailed information, typically useful for debugging purposes. | -| `INFO` | General information about the application's operation. | -| `WARN` | Indicates potentially harmful situations or minor issues that don't prevent the application from working. | -| `ERROR` | Indicates error conditions that might still allow the application to continue running. | - -The Temporal SDK core normally uses `WARN` as its default logging level. +You can find the log levels supported by `slog` in [their official documentation](https://pkg.go.dev/log/slog#Level). The Temporal SDK core normally uses `WARN` as its default logging level. In Workflow Definitions you can use [`workflow.GetLogger(ctx)`](https://pkg.go.dev/go.temporal.io/sdk/workflow#GetLogger) to write logs. ```go import ( - "context" - "time" + "context" + "time" - "go.temporal.io/sdk/activity" - "go.temporal.io/sdk/workflow" + "go.temporal.io/sdk/activity" + "go.temporal.io/sdk/workflow" ) // Workflow is a standard workflow definition. @@ -138,24 +125,22 @@ func Workflow(ctx workflow.Context, name string) (string, error) { workflow.WithActivityOptions(ctx, ao) // Getting the logger from the context. - logger := workflow.GetLogger(ctx) + logger := workflow.GetLogger(ctx) // Logging a message with the key value pair `name` and `name` - logger.Info("Compressed Payloads workflow started", "name", name) + logger.Info("Compressed Payloads workflow started", "name", name) - info := map[string]string{ - "name": name, - } + info := map[string]string{ + "name": name, + } - logger.Info("Compressed Payloads workflow completed.", "result", result) + logger.Info("Compressed Payloads workflow completed.", "result", result) - return result, nil + return result, nil } ``` ### Provide a custom logger {/* #custom-logger */} -**How to provide a custom logger to the Temporal Client using the Go SDK.** - This field sets a custom Logger that is used for all logging actions of the instance of the Temporal Client. The Go SDK supports custom loggers via `log.NewStructuredLogger()`, which wraps Go's standard [`slog.Logger`](https://pkg.go.dev/log/slog) (Go 1.21+). @@ -165,22 +150,22 @@ Because most modern logging libraries (zap, zerolog, logrus, etc.) can back a `s ```go import ( - "log/slog" - "os" + "log/slog" + "os" - "go.temporal.io/sdk/client" - "go.temporal.io/sdk/log" + "go.temporal.io/sdk/client" + "go.temporal.io/sdk/log" ) func main() { - // ... - slogHandler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}) - logger := log.NewStructuredLogger(slog.New(slogHandler)) - clientOptions := client.Options{ - Logger: logger, - } - temporalClient, err := client.Dial(clientOptions) - // ... + // ... + slogHandler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}) + logger := log.NewStructuredLogger(slog.New(slogHandler)) + clientOptions := client.Options{ + Logger: logger, + } + temporalClient, err := client.Dial(clientOptions) + // ... } ``` @@ -188,24 +173,24 @@ func main() { ```go import ( - "log/slog" + "log/slog" - "go.uber.org/zap" - "go.uber.org/zap/exp/zapslog" - "go.temporal.io/sdk/client" - "go.temporal.io/sdk/log" + "go.uber.org/zap" + "go.uber.org/zap/exp/zapslog" + "go.temporal.io/sdk/client" + "go.temporal.io/sdk/log" ) func main() { - // ... - zapLogger, _ := zap.NewProduction() - handler := zapslog.NewHandler(zapLogger.Core()) - logger := log.NewStructuredLogger(slog.New(handler)) - clientOptions := client.Options{ - Logger: logger, - } - temporalClient, err := client.Dial(clientOptions) - // ... + // ... + zapLogger, _ := zap.NewProduction() + handler := zapslog.NewHandler(zapLogger.Core()) + logger := log.NewStructuredLogger(slog.New(handler)) + clientOptions := client.Options{ + Logger: logger, + } + temporalClient, err := client.Dial(clientOptions) + // ... } ``` @@ -218,8 +203,6 @@ The term Visibility, within the Temporal Platform, refers to the subsystems and ### Search Attributes {/* #search-attributes */} -**How to use Search Attributes using the Go SDK.** - The typical method of retrieving a Workflow Execution is by its Workflow Id. However, sometimes you'll want to retrieve one or more Workflow Executions based on another property. For example, imagine you want to get all Workflow Executions of a certain type that have failed within a time range, so that you can start new ones with the same arguments. @@ -270,8 +253,6 @@ for _, exec := range resp.Executions { ### Set custom Search Attributes {/* #custom-search-attributes */} -**How to set custom Search Attributes using the Go SDK.** - After you've created custom Search Attributes in your Temporal Service (using the `temporal operator search-attribute create` command or the Cloud UI), you can set the values of the custom Search Attributes when starting a Workflow. Provide key-value pairs in [`StartWorkflowOptions.SearchAttributes`](https://pkg.go.dev/go.temporal.io/sdk/internal#StartWorkflowOptions). @@ -290,24 +271,22 @@ If you had custom Search Attributes `CustomerId` of type Keyword and `MiscData` ```go func (c *Client) CallYourWorkflow(ctx context.Context, workflowID string, payload map[string]interface{}) error { + // ... + searchAttributes := map[string]interface{}{ + "CustomerId": payload["customer"], + "MiscData": payload["miscData"] + } + options := client.StartWorkflowOptions{ + SearchAttributes: searchAttributes // ... - searchAttributes := map[string]interface{}{ - "CustomerId": payload["customer"], - "MiscData": payload["miscData"] - } - options := client.StartWorkflowOptions{ - SearchAttributes: searchAttributes - // ... - } - we, err := c.Client.ExecuteWorkflow(ctx, options, app.YourWorkflow, payload) - // ... + } + we, err := c.Client.ExecuteWorkflow(ctx, options, app.YourWorkflow, payload) + // ... } ``` ### Upsert Search Attributes {/* #upsert-search-attributes */} -**How to upsert Search Attributes using the Go SDK.** - You can upsert Search Attributes to add or update Search Attributes from within Workflow code. In advanced cases, you may want to dynamically update these attributes as the Workflow progresses. @@ -319,17 +298,17 @@ Consider this example Workflow code: ```go func YourWorkflow(ctx workflow.Context, input string) error { - attr1 := map[string]interface{}{ - "CustomIntField": 1, - "CustomBoolField": true, - } - workflow.UpsertSearchAttributes(ctx, attr1) + attr1 := map[string]interface{}{ + "CustomIntField": 1, + "CustomBoolField": true, + } + workflow.UpsertSearchAttributes(ctx, attr1) - attr2 := map[string]interface{}{ - "CustomIntField": 2, - "CustomKeywordField": "seattle", - } - workflow.UpsertSearchAttributes(ctx, attr2) + attr2 := map[string]interface{}{ + "CustomIntField": 2, + "CustomKeywordField": "seattle", + } + workflow.UpsertSearchAttributes(ctx, attr2) } ``` @@ -337,16 +316,14 @@ After the second call to `UpsertSearchAttributes`, the map will contain: ```go map[string]interface{}{ - "CustomIntField": 2, // last update wins - "CustomBoolField": true, - "CustomKeywordField": "seattle", + "CustomIntField": 2, // last update wins + "CustomBoolField": true, + "CustomKeywordField": "seattle", } ``` ### Remove a Search Attribute from a Workflow {/* #remove-search-attribute */} -**How to remove a Search Attribute from a Workflow using the Go SDK.** - To remove a Search Attribute that was previously set, set it to an empty array: `[]`. **There is no support for removing a field.** diff --git a/docs/develop/go/set-up.mdx b/docs/develop/go/set-up.mdx index 47a5c054ed..e67bbbbfbf 100644 --- a/docs/develop/go/set-up.mdx +++ b/docs/develop/go/set-up.mdx @@ -14,8 +14,7 @@ tags: hide_table_of_contents: true --- -import { SetupSteps, SetupStep, CodeSnippet } from '@site/src/components/elements/SetupSteps'; -import { CallToAction } from '@site/src/components/elements/CallToAction'; +import { CallToAction, SetupSteps, SetupStep, CodeSnippet } from '@site/src/components'; # Quickstart @@ -115,7 +114,7 @@ Finally, install the Temporal SDK with `go get`. ## Install Temporal CLI and start the development server The fastest way to get a development version of the Temporal Service running on your local machine is to use -[Temporal CLI](https://docs.temporal.io/cli). +[Temporal CLI](/cli). Choose your operating system to install Temporal CLI: diff --git a/docs/develop/go/workers/index.mdx b/docs/develop/go/workers/index.mdx index 06555948ac..62121ded3f 100644 --- a/docs/develop/go/workers/index.mdx +++ b/docs/develop/go/workers/index.mdx @@ -13,8 +13,12 @@ tags: import * as Components from '@site/src/components'; + + ![Go SDK Banner](/img/assets/banner-go-temporal.png) + + ## Workers - [Run a Worker](/develop/go/workers/run-worker-process) diff --git a/docs/develop/go/workers/sessions.mdx b/docs/develop/go/workers/sessions.mdx index e477a5be07..f7ac8ffe8d 100644 --- a/docs/develop/go/workers/sessions.mdx +++ b/docs/develop/go/workers/sessions.mdx @@ -12,7 +12,7 @@ tags: description: Enable Worker Sessions, change concurrent Sessions, and create Session Workers using the Go SDK for precise Task Routing, ensuring efficient Activity Tasks execution. --- -import { ViewSourceCodeNotice } from '@site/src/components/elements/ViewSourceCodeNotice'; +import { ViewSourceCodeNotice } from '@site/src/components'; This page shows how to do the following: diff --git a/docs/develop/go/workflows/basics.mdx b/docs/develop/go/workflows/basics.mdx index 997beafaeb..7ed1cc355e 100644 --- a/docs/develop/go/workflows/basics.mdx +++ b/docs/develop/go/workflows/basics.mdx @@ -11,7 +11,7 @@ tags: - Temporal SDKs --- -import { ViewSourceCodeNotice } from '@site/src/components/elements/ViewSourceCodeNotice'; +import { ViewSourceCodeNotice } from '@site/src/components'; ## How to develop a basic Workflow {/* #develop-workflows */} diff --git a/docs/develop/go/workflows/cancellation.mdx b/docs/develop/go/workflows/cancellation.mdx index fb532bd2f7..cd0612a2b2 100644 --- a/docs/develop/go/workflows/cancellation.mdx +++ b/docs/develop/go/workflows/cancellation.mdx @@ -16,7 +16,7 @@ tags: - Temporal SDKs --- -import { ViewSourceCodeNotice } from '@site/src/components/elements/ViewSourceCodeNotice'; +import { ViewSourceCodeNotice } from '@site/src/components'; This page shows the following: diff --git a/docs/develop/go/workflows/continue-as-new.mdx b/docs/develop/go/workflows/continue-as-new.mdx index 91149e19a5..481942ae07 100644 --- a/docs/develop/go/workflows/continue-as-new.mdx +++ b/docs/develop/go/workflows/continue-as-new.mdx @@ -17,7 +17,7 @@ tags: - Temporal SDKs --- -import { ViewSourceCodeNotice } from '@site/src/components/elements/ViewSourceCodeNotice'; +import { ViewSourceCodeNotice } from '@site/src/components'; This page answers the following questions for Go developers: diff --git a/docs/develop/go/workflows/index.mdx b/docs/develop/go/workflows/index.mdx index e9b6eb27e7..7837ea8a93 100644 --- a/docs/develop/go/workflows/index.mdx +++ b/docs/develop/go/workflows/index.mdx @@ -13,8 +13,12 @@ tags: import * as Components from '@site/src/components'; + + ![Go SDK Banner](/img/assets/banner-go-temporal.png) + + ## Workflows - [Workflow basics](/develop/go/workflows/basics) diff --git a/docs/develop/go/workflows/schedules.mdx b/docs/develop/go/workflows/schedules.mdx index dba621fee0..1a21c94f01 100644 --- a/docs/develop/go/workflows/schedules.mdx +++ b/docs/develop/go/workflows/schedules.mdx @@ -13,7 +13,7 @@ tags: description: Schedule Workflows, start them with delays or as Temporal Cron Jobs using the Go SDK. Master scheduling, backfilling, pausing, deleting, and updating Workflows. --- -import { ViewSourceCodeNotice } from '@site/src/components/elements/ViewSourceCodeNotice'; +import { ViewSourceCodeNotice } from '@site/src/components'; This page shows how to do the following: @@ -262,7 +262,7 @@ if err != nil { :::caution Cron support is not recommended -We recommend using [Schedules](https://docs.temporal.io/schedule) instead of Cron Jobs. +We recommend using [Schedules](/schedule) instead of Cron Jobs. Schedules were built to provide a better developer experience, including more configuration options and the ability to update or pause running Schedules. ::: diff --git a/docs/develop/index.mdx b/docs/develop/index.mdx index 4196222a1c..6b31d1a51d 100644 --- a/docs/develop/index.mdx +++ b/docs/develop/index.mdx @@ -5,17 +5,30 @@ description: Discover comprehensive Temporal SDK feature guides and API referenc sidebar_label: Development --- -import { SdkLogos } from '@site/src/components'; - The Temporal SDK developer guides provide a comprehensive overview of the structures, primitives, and features used in [Temporal Application](/temporal#temporal-application) development. - -- Go SDK [developer guide](/develop/go) and [API reference](http://t.mp/go-api) -- Java SDK [developer guide](/develop/java) and [API reference](http://t.mp/java-api) -- PHP SDK [developer guide](/develop/php) and [API reference](https://php.temporal.io/namespaces/temporal.html) -- Python SDK [developer guide](/develop/python) and [API reference](https://python.temporal.io) -- TypeScript SDK [developer guide](/develop/typescript) and [API reference](https://typescript.temporal.io) -- .NET SDK [developer guide](/develop/dotnet) and [API reference](https://dotnet.temporal.io/) -- Ruby SDK [developer guide](/develop/ruby) and [API reference](https://ruby.temporal.io/) -- Rust SDK [developer guide](/develop/rust) and [API reference](https://docs.rs/temporalio-sdk/latest/temporalio_sdk/) +- .NET SDK: + - [Developer guide](/develop/dotnet) + - [API reference](https://dotnet.temporal.io/) +- Go SDK: + - [Developer guide](/develop/go) + - [API reference](http://t.mp/go-api) +- Java SDK: + - [Developer guide](/develop/java) + - [API reference](http://t.mp/java-api) +- PHP SDK: + - [Developer guide](/develop/php) + - [API reference](https://php.temporal.io/namespaces/temporal.html) +- Python SDK: + - [Developer guide](/develop/python) + - [API reference](https://python.temporal.io) +- Ruby SDK: + - [Developer guide](/develop/ruby) + - [API reference](https://ruby.temporal.io/) +- Rust SDK: + - [Developer guide](/develop/rust) + - [API reference](https://docs.rs/temporalio-sdk/latest/temporalio_sdk/) +- TypeScript SDK: + - [Developer guide](/develop/typescript) + - [API reference](https://typescript.temporal.io) diff --git a/docs/develop/java/activities/basics.mdx b/docs/develop/java/activities/basics.mdx index 2e8f1e7ca9..d68e27b37a 100644 --- a/docs/develop/java/activities/basics.mdx +++ b/docs/develop/java/activities/basics.mdx @@ -116,7 +116,7 @@ For more details, see [Dynamic Activity Reference](https://www.javadoc.io/doc/io All data returned from an Activity must be serializable. -Activity return values are subject to payload size limits in Temporal. The default payload size limit is 2MB, and there is a hard limit of 4MB for any gRPC message size in the Event History transaction ([see Cloud limits here](https://docs.temporal.io/cloud/limits#per-message-grpc-limit)). Keep in mind that all return values are recorded in a [Workflow Execution Event History](/workflow-execution/event#event-history). +Activity return values are subject to payload size limits in Temporal. The default payload size limit is 2MB, and there is a hard limit of 4MB for any gRPC message size in the Event History transaction ([see Cloud limits here](/cloud/limits#per-message-grpc-limit)). Keep in mind that all return values are recorded in a [Workflow Execution Event History](/workflow-execution/event#event-history). Activity return values must be serializable and deserializable by the provided [`DataConverter`](https://www.javadoc.io/static/io.temporal/temporal-sdk/1.17.0/io/temporal/common/converter/DataConverter.html). diff --git a/docs/develop/java/activities/index.mdx b/docs/develop/java/activities/index.mdx index fb971e14d4..edfb99a6e4 100644 --- a/docs/develop/java/activities/index.mdx +++ b/docs/develop/java/activities/index.mdx @@ -13,8 +13,12 @@ tags: import * as Components from '@site/src/components'; + + ![Java SDK Banner](/img/assets/banner-java-temporal.png) + + ## Activities - [Activity basics](/develop/java/activities/basics) diff --git a/docs/develop/java/activities/timeouts.mdx b/docs/develop/java/activities/timeouts.mdx index fa3b035160..5dd11d8328 100644 --- a/docs/develop/java/activities/timeouts.mdx +++ b/docs/develop/java/activities/timeouts.mdx @@ -20,7 +20,7 @@ The following timeouts are available in the Activity Options. - **[Schedule-To-Close Timeout](/encyclopedia/detecting-activity-failures#schedule-to-close-timeout):** is the maximum amount of time allowed for the overall [Activity Execution](/activity-execution). - **[Start-To-Close Timeout](/encyclopedia/detecting-activity-failures#start-to-close-timeout):** is the maximum time allowed for a single [Activity Task Execution](/tasks#activity-task-execution). -- **[Schedule-To-Start Timeout](/encyclopedia/detecting-activity-failures#schedule-to-start-timeout):** is the maximum amount of time that is allowed from when an [Activity Task](/tasks#activity-task) is scheduled to when a [Worker](/workers#worker) starts that Activity Task. +- **[Schedule-To-Start Timeout](/encyclopedia/detecting-activity-failures#schedule-to-start-timeout):** is the maximum amount of time that is allowed from when an [Activity Task](/tasks#activity-task) is scheduled to when a [Worker](/workers#worker) starts that Activity Task. This timeout is non-retryable by design. An Activity Execution must have either the Start-To-Close or the Schedule-To-Close Timeout set. @@ -28,9 +28,9 @@ Set your Activity Timeout from the [`ActivityOptions.Builder`](https://www.javad Available timeouts are: -- ScheduleToCloseTimeout() -- ScheduleToStartTimeout() -- StartToCloseTimeout() +- ScheduleToCloseTimeout +- StartToCloseTimeout +- ScheduleToStartTimeout You can set Activity Options using an `ActivityStub` within a Workflow implementation, or per-Activity using `WorkflowImplementationOptions` within a Worker. diff --git a/docs/develop/java/best-practices/index.mdx b/docs/develop/java/best-practices/index.mdx index 78e26ce0b3..b605e16474 100644 --- a/docs/develop/java/best-practices/index.mdx +++ b/docs/develop/java/best-practices/index.mdx @@ -13,8 +13,12 @@ tags: import * as Components from '@site/src/components'; + + ![Java SDK Banner](/img/assets/banner-java-temporal.png) + + ## Best practices - [Testing](/develop/java/best-practices/testing-suite) diff --git a/docs/develop/java/best-practices/testing-suite.mdx b/docs/develop/java/best-practices/testing-suite.mdx index 78a3cfde03..3e2ca4da54 100644 --- a/docs/develop/java/best-practices/testing-suite.mdx +++ b/docs/develop/java/best-practices/testing-suite.mdx @@ -58,7 +58,7 @@ as a dependency to your project: io.temporal temporal-testing - 1.17.0 + 1.36.0 test ``` @@ -66,129 +66,209 @@ as a dependency to your project: **[Gradle Groovy DSL](https://gradle.org/):** ```groovy -testImplementation ("io.temporal:temporal-testing:1.17.0") +testImplementation ("io.temporal:temporal-testing:1.36.0") +``` + +If you need JUnit4 or JUnit5 extensions: + +``` +testImplementation("io.temporal:temporal-testing:1.36.0") { + capabilities { + requireCapability("io.temporal:temporal-testing-junit4") + //requireCapability("io.temporal:temporal-testing-junit5") + } +} ``` Make sure to set the version that matches your dependency version of the [Temporal Java SDK](https://github.com/temporalio/sdk-java). -### Sample unit tests +## Test Activities {/* #test-activities */} + +An Activity can be tested with a mock Activity environment, which provides a way to mock the Activity context, listen to Heartbeats, and cancel the Activity. This behavior allows you to test the Activity in isolation by calling it directly, without needing to create a Worker to run the Activity. -The following code implements unit tests for the `HelloActivity` sample: +Temporal provides the `TestActivityEnvironment` and `TestActivityExtension` classes for testing Activities outside the scope of a Workflow. Testing Activities is similar to testing non-Temporal Java code. + +For example, you can test an Activity for: + +- Exceptions thrown when invoking the Activity Execution. +- Exceptions thrown when checking for the result of the Activity Execution. +- Activity's return values. Check that the return value matches the expected value. + +Here's an example of an Activity that will be referenced in the sections below: ```java -public class HelloActivityTest { +package helloworkflow; - private TestWorkflowEnvironment testEnv; - private Worker worker; - private WorkflowClient client; +import java.util.concurrent.TimeUnit; - // Set up the test workflow environment - @Before - public void setUp() { - testEnv = TestWorkflowEnvironment.newInstance(); - worker = testEnv.newWorker(TASK_QUEUE); - // Register your workflow implementations - worker.registerWorkflowImplementationTypes(GreetingWorkflowImpl.class); +import io.temporal.activity.Activity; +import io.temporal.activity.ActivityExecutionContext; +import io.temporal.client.ActivityCompletionException; - client = testEnv.getWorkflowClient(); +public class GreetActivitiesImpl implements GreetActivities { + + @Override + public String greet(String name) { + ActivityExecutionContext context = Activity.getExecutionContext(); + + for (int i = 0; i < 5; i++) { + try { + context.heartbeat(null); + } catch (ActivityCompletionException e) { + throw e; + } + + sleep(1); } - // Clean up test environment after tests are completed - @After - public void tearDown() { - testEnv.close(); + return "Hello " + name + "!"; + } + + private void sleep(int seconds) { + try { + Thread.sleep(TimeUnit.SECONDS.toMillis(seconds)); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException(e); } + } +} +``` - @Test - public void testActivityImpl() { - // This uses the actual activity impl - worker.registerActivitiesImplementations(new GreetingActivitiesImpl()); +### Run an Activity {/* #run-an-activity */} - // Start test environment - testEnv.start(); +The following code implements unit tests for the `greet` Activity above: - // Create the workflow stub - GreetingWorkflow workflow = - client.newWorkflowStub( - GreetingWorkflow.class, WorkflowOptions.newBuilder().setTaskQueue(TASK_QUEUE).build() - ); +```java +package helloworkflow; - // Execute our workflow waiting for it to complete - String greeting = workflow.getGreeting("World"); - assertEquals("Hello World!", greeting); - } +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +import io.temporal.testing.TestActivityEnvironment; + +public class GreetActivitiesTest { + + @Test + public void testActivityImpl() { + TestActivityEnvironment testEnv = TestActivityEnvironment.newInstance(); + + testEnv.registerActivitiesImplementations(new GreetActivitiesImpl()); + + GreetActivities activities = testEnv.newActivityStub(GreetActivities.class); + + String result = activities.greet("Temporal"); + + assertEquals("Hello Temporal!", result); + } } ``` -In cases where you do not wish to execute your actual Activity or Nexus Operation implementations during -unit testing, you can use a framework such as [Mockito](https://site.mockito.org/) to mock them. +### Listen to Heartbeats {/* #listen-to-heartbeats */} + +Activities usually issue periodic Heartbeats, a ping that shows that an Activity is making progress and the Worker hasn't crashed. Heartbeats may include details that report task progress in the event an Activity Worker crashes. -The following code implements a unit test for the `HelloActivity` sample which shows -how activities can be mocked: +When testing Activities that support Heartbeats, make sure you can see those Heartbeats in your test code. ```java -public class HelloActivityTest { +@Test +void testActivityHeartbeat() { + TestActivityEnvironment env = TestActivityEnvironment.newInstance(); - private TestWorkflowEnvironment testEnv; - private Worker worker; - private WorkflowClient client; + AtomicInteger heartbeatCount = new AtomicInteger(0); - // Set up the test workflow environment - @Before - public void setUp() { - testEnv = TestWorkflowEnvironment.newInstance(); - worker = testEnv.newWorker(TASK_QUEUE); - // Register your workflow implementations - worker.registerWorkflowImplementationTypes(GreetingWorkflowImpl.class); + env.setActivityHeartbeatListener( + Void.class, + heartbeat -> heartbeatCount.incrementAndGet()); - client = testEnv.getWorkflowClient(); - } + env.registerActivitiesImplementations(new GreetActivitiesImpl()); - // Clean up test environment after tests are completed - @After - public void tearDown() { - testEnv.close(); - } + GreetActivities activities = env.newActivityStub(GreetActivities.class); - @Test - public void testMockedActivity() { - // Mock our workflow activity - GreetingActivities activities = mock(GreetingActivities.class); - when(activities.composeGreeting("Hello", "World")).thenReturn("Hello Mocked World!"); - worker.registerActivitiesImplementations(activities); + String result = activities.greet("Temporal"); - // Start test environment - testEnv.start(); + assertEquals("Hello Temporal!", result); + assertEquals(5, heartbeatCount.get()); +} +``` - // Create the workflow stub - GreetingWorkflow workflow = - client.newWorkflowStub( - GreetingWorkflow.class, WorkflowOptions.newBuilder().setTaskQueue(TASK_QUEUE).build() - ); +### Cancel an Activity {/* #cancel-an-activity */} - // Execute our workflow waiting for it to complete - String greeting = workflow.getGreeting("World"); - assertEquals("Hello Mocked World!", greeting); +Activity cancellation lets Activities know they don't need to continue work and gives time for the Activity to clean up any resources it's created. You can cancel Java-based Activities if they emit Heartbeats. To test an Activity that reacts to Cancellations, make sure that the Activity reacts correctly and cancels. + +```java +@Test +void testCancelActivity() { + try (TestWorkflowEnvironment env = TestWorkflowEnvironment.newInstance()) { + Worker worker = env.newWorker(TASK_QUEUE); + + worker.registerWorkflowImplementationTypes(SayHelloWorkflowImpl.class); + worker.registerActivitiesImplementations(new GreetActivitiesImpl()); + + env.start(); + + SayHelloWorkflow workflow = + env.getWorkflowClient() + .newWorkflowStub( + SayHelloWorkflow.class, + WorkflowOptions.newBuilder() + .setTaskQueue(TASK_QUEUE) + .build()); + + WorkflowClient.start(workflow::sayHello, "Temporal"); + + env.registerDelayedCallback( + Duration.ofSeconds(1), + () -> WorkflowStub.fromTyped(workflow).signal("cancelActivity")); + + try { + WorkflowStub.fromTyped(workflow).getResult(String.class); + fail("Workflow should have failed because the Activity was canceled"); + } catch (WorkflowFailedException e) { + assertEquals(ActivityFailure.class, e.getCause().getClass()); + + ActivityFailure activityFailure = (ActivityFailure) e.getCause(); + assertEquals(CanceledFailure.class, activityFailure.getCause().getClass()); } + } } ``` -### Testing with JUnit4 +## Testing Workflows {/* #test-workflows */} -For Junit4 tests, Temporal provides the TestWorkflowRule class which simplifies the Temporal test environment setup, as well as the -creation and shutdown of Workflow Workers in your tests. +### How to mock Activities {/* #mock-activities */} -Make sure to set the version that matches your dependency version of the [Temporal Java SDK](https://github.com/temporalio/sdk-java). +When integration testing Workflows with a Worker, you can mock Activities by providing mock Activity implementations to the Worker. + +In cases where you do not wish to execute your actual Activity or Nexus Operation implementations during +unit testing, you can use a framework such as [Mockito](https://site.mockito.org/) to mock them. + +The following code implements a unit test that shows how Activities can be mocked: + +```java +@Test +public void testMockedActivity() { + GreetActivities activities = + mock(GreetActivities.class, withSettings().withoutAnnotations()); + + when(activities.greet("Temporal")).thenReturn("Hello Temporal!"); + + assertEquals("Hello Temporal!", activities.greet("Temporal")); +} +``` + +#### Testing with JUnit4 -We can now rewrite our above mentioned "HelloActivityTest" test class as follows: +For JUnit4 tests, Temporal provides the `TestWorkflowRule` class which simplifies the Temporal test environment setup, as well as the creation and shutdown of Workflow Workers in your tests. You can now rewrite the above Activity test class as follows: ```java -public class HelloActivityJUnit4Test { +public class GreetActivitiesJUnit4Test { @Rule public TestWorkflowRule testWorkflowRule = TestWorkflowRule.newBuilder() - .setWorkflowTypes(GreetingWorkflowImpl.class) - .setActivityImplementations(new GreetingActivitiesImpl()) + .setWorkflowTypes(SayHelloWorkflowImpl.class) + .setActivityImplementations(new GreetActivitiesImpl()) .build(); @Test @@ -202,104 +282,60 @@ public class HelloActivityJUnit4Test { WorkflowOptions.newBuilder().setTaskQueue(testWorkflowRule.getTaskQueue()).build() ); // Execute a workflow waiting for it to complete. - String greeting = workflow.getGreeting("World"); - assertEquals("Hello World!", greeting); + String greeting = workflow.sayHello("Temporal"); + assertEquals("Hello Temporal!", greeting); testWorkflowRule.getTestEnvironment().shutdown(); } } ``` -### Testing with JUnit5 +#### Testing with JUnit5 -For Junit5 tests, Temporal also provides the TestWorkflowExtension helper class. +For JUnit5 tests, Temporal also provides the `TestWorkflowExtension` helper class. This class can be used to simplify the Temporal test environment setup as well as Workflow Worker startup and shutdowns. -To start using JUnit5 TestWorkflowExtension in your tests with [Gradle](https://gradle.org/), you need to enable capability [`io.temporal:temporal-testing-junit5`]: +To start using JUnit5 `TestWorkflowExtension` in your tests with [Gradle](https://gradle.org/), you need to enable capability [`io.temporal:temporal-testing-junit5`]: -Make sure to set the version that matches your dependency version of the [Temporal Java SDK](https://github.com/temporalio/sdk-java). -We can now use JUnit5 and rewrite our above mentioned "HelloActivityTest" test class as follows: +Now you can use JUnit5 and rewrite the above test class as follows: ```java -public class HelloActivityJUnit5Test { - @RegisterExtension - public static final TestWorkflowExtension testWorkflowExtension = - TestWorkflowExtension.newBuilder() - .setWorkflowTypes(GreetingWorkflowImpl.class) - .setActivityImplementations(new GreetingActivitiesImpl()) - .build(); +public class GreetActivitiesJUnit5Test { + @RegisterExtension + public static final TestWorkflowExtension testWorkflowExtension = + TestWorkflowExtension.newBuilder() + .setWorkflowTypes(SayHelloWorkflowImpl.class) + .setActivityImplementations(new GreetActivitiesImpl()) + .build(); - @Test - public void testActivityImpl( - TestWorkflowEnvironment testEnv, Worker worker, GreetingWorkflow workflow) { - // Execute a workflow waiting for it to complete. - String greeting = workflow.getGreeting("World"); - assertEquals("Hello World!", greeting); - } + @Test + public void testActivityImpl( + TestWorkflowEnvironment testEnv, Worker worker, SayHelloWorkflow workflow) { + // Execute a workflow waiting for it to complete. + String greeting = workflow.sayHello("Temporal"); + assertEquals("Hello Temporal!", greeting); + } } ``` -You can find all unit tests for the [Temporal Java samples](https://github.com/temporalio/samples-java) repository in [its test package](https://github.com/temporalio/samples-java/tree/main/core/src/test/java/io/temporal/samples). - -## Test Activities {/* #test-activities */} - -Mocking isolates code undergoing testing so the focus remains on the code, and not on external dependencies or other state. You can test Activities using a mocked Activity environment. - -This approach offers a way to mock the Activity context, listen to Heartbeats, and cancel the Activity. You test the Activity in isolation, calling it directly without needing to create a Worker to run it. +You can find more unit test examples in the [Temporal Java samples](https://github.com/temporalio/samples-java) repository in [its test package](https://github.com/temporalio/samples-java/tree/main/core/src/test/java/io/temporal/samples). -Temporal provides the `TestActivityEnvironment` and `TestActivityExtension` classes for testing Activities outside the scope of a Workflow. Testing -Activities are similar to testing non-Temporal Java code. - -For example, you can test an Activity for: - -- Exceptions thrown when invoking the Activity Execution. -- Exceptions thrown when checking for the result of the Activity Execution. -- Activity's return values. Check that the return value matches the expected value. - -### Run an Activity {/* #run-an-activity */} - -During isolation testing, if an Activity references its context, you'll need to mock that context. -Mocked information stands in for the context, allowing you to focus your testing on the Activity's code. - -### Listen to Heartbeats {/* #listen-to-heartbeats */} - -Activities usually issue periodic Heartbeats, a feature that broadcasts recurring proof-of-life updates. -Each ping shows that an Activity is making progress and the Worker hasn't crashed. -Heartbeats may include details that report task progress in the event an Activity Worker crashes. - -When testing Activities that support Heartbeats, make sure you can see those Heartbeats in your test code. -Provide appropriate test coverage. -This enables you to verify both the Heartbeat's content and behavior. - -### Cancel an Activity {/* #cancel-an-activity */} - -Activity cancellation lets Activities know they don't need to continue work and gives time for the Activity to clean up any resources it's created. You can cancel Java-based activities if they emit Heartbeats. To test an Activity that reacts to Cancellations, make sure that the Activity reacts correctly and cancels. - -## Testing Workflows {/* #test-workflows */} - -### How to mock Activities {/* #mock-activities */} - -Mock the Activity invocation when unit testing your Workflows. - -When integration testing Workflows with a Worker, you can mock Activities by providing mock Activity implementations to the Worker. -For more details on mocking activities, see [sample unit tests](#sample-unit-tests). ### How to mock Nexus Operations {/* #mock-nexus-operations */} When integration testing Workflows with a Worker, you can mock Nexus operations by providing mock Nexus Service handlers to the Worker. Alternatively, you could just mock the Nexus service itself. -You can find example unit tests for Nexus in the [Temporal Java samples](https://github.com/temporalio/samples-java) repository in [this test package](https://github.com/temporalio/samples-java/tree/main/core/src/test/java/io/temporal/samples/nexus/caller). +You can find more example unit tests for Nexus in the [Temporal Java samples](https://github.com/temporalio/samples-java) repository in [this test package](https://github.com/temporalio/samples-java/tree/main/core/src/test/java/io/temporal/samples/nexus/caller). These samples show how to call Nexus services in tests using the Temporal testing package and also how to mock them, for both JUnit 4 and 5. Detailed explanatory comments are included in the code in the repository. -To mock Nexus handlers, create a Rule (for JUnit4) or Extension (for JUnit5) from the Temporal testing package, just as in the [sample unit tests](#sample-unit-tests) and add a call to `setNexusServiceImplementation` to the builder. +To mock Nexus handlers, create a `Rule` (for JUnit4) or `Extension` (for JUnit5) from the Temporal testing package and add a call to `setNexusServiceImplementation` to the builder. That sets up the Nexus endpoints needed for testing as well as the Nexus handler workflows defined by the Nexus Service implementation. -Everything is created and set up by the Temporal Testing package, so no more work is needed than that! -You will need to create workers for each handler just as normal, using either `setWorkflowTypes` (for JUnit4) or `registerWorkflowImplementationTypes` (for JUnit5). -With that in place, you can then mock a Nexus endpoint exactly like any other workflow - again, just as in [the sample unit tests](#sample-unit-tests) above. +You will need to create Workers for each handler, using either `setWorkflowTypes` (for JUnit4) or `registerWorkflowImplementationTypes` (for JUnit5). +With that in place, you can then mock a Nexus endpoint exactly like any other Workflow like you do with Activity tests. The following are samples derived from [the test package](https://github.com/temporalio/samples-java/tree/main/core/src/test/java/io/temporal/samples/nexus/caller) to demonstrate this. @@ -378,11 +414,11 @@ public class CallerWorkflowJunit5MockTest { ``` -An alternative approach is to simply mock the Nexus service itself, instead of mocking the handlers. -This is useful if you just want to test the calling logic but can't easily mock the Nexus handlers. +An alternative approach is to mock the Nexus service itself, instead of mocking the handlers. +This is useful if you want to test the calling logic, but can't easily mock the Nexus handlers. -The code will just mock the implementation of the SampleNexusService class with the handler methods, but will need those methods stubbed in for the testing framework. -Those methods can be directly mocked with static return values, or else they can return an instance variable which each unit test can modify to return a desired value. +The code will mock the implementation of the `SampleNexusService` class with the handler methods, but will need those methods stubbed in for the testing framework. +Those methods can be directly mocked with static return values or they can return an instance variable which each unit test can modify to return a desired value. #### Mocking the Nexus Service with JUnit4 {/* SNIPSTART java-nexus-service-sample-junit4-mock */} @@ -529,18 +565,143 @@ Time is a global property of an instance of `TestWorkflowEnvironment`: skipping If you need different time behaviors for different tests, run your tests in a series or with separate instances of the test server. For example, you could run all tests with automatic time skipping in parallel, and then all tests with manual time skipping in series, and then all tests without time skipping in parallel. -#### Set up time skipping {/* #setting-up */} +#### Skip time automatically {/* #automatic-method */} -Set up the time-skipping test framework in the SDK of your choice. +When you execute a Workflow and wait for the result, the test environment automatically skips Workflow timers such as `Workflow.sleep`. This means: -#### Skip time automatically {/* #automatic-method */} +- Workflow timers (like `Workflow.sleep`) are fast-forwarded. +- Time doesn't skip while Activities and Nexus operations are executing. Nexus operation handlers timeout after 10 seconds and time skipping is allowed while waiting for retries. -Start a test server process that skips time as needed. -For example, in the time-skipping mode, Timers, which include sleeps and conditional timeouts, are fast-forwarded except when Activities or Nexus Operation handlers are running. Nexus Operation handlers timeout after 10 seconds and time skipping is allowed while waiting for retries. +Here's an example of a Workflow that implements `Workflow.sleep` to wait for a day before calling the Activity: + +```java +public class SayHelloWorkflowImpl implements SayHelloWorkflow { + private final GreetActivities activities = + Workflow.newActivityStub( + GreetActivities.class, + ActivityOptions.newBuilder() + .setStartToCloseTimeout(Duration.ofSeconds(30)) + .build()); + + @Override + public String sayHello(String name) { + // This will complete immediately in TestWorkflowEnvironment because + // Workflow time is automatically advanced. + Workflow.sleep(Duration.ofDays(1)); + + return activities.greet(name); + } +} +``` + +Here's what the test for that Workflow could look like: + +```java +@Test +void testSleepCompletesWithoutWaitingOneDay() { + try (TestWorkflowEnvironment testEnv = TestWorkflowEnvironment.newInstance()) { + Worker worker = testEnv.newWorker(TASK_QUEUE); + + worker.registerWorkflowImplementationTypes(SayHelloWorkflowImpl.class); + worker.registerActivitiesImplementations(new GreetActivitiesImpl()); + + testEnv.start(); + + SayHelloWorkflow workflow = + testEnv + .getWorkflowClient() + .newWorkflowStub( + SayHelloWorkflow.class, + WorkflowOptions.newBuilder().setTaskQueue(TASK_QUEUE).build()); + + String result = workflow.sayHello("Temporal"); + + assertEquals("Hello Temporal!", result); + } +} +``` + +Use `Workflow.sleep()` in Workflow code, not `Thread.sleep()`. `Workflow.sleep()` creates a Temporal timer that the test environment can skip. `Thread.sleep()` blocks a Java thread in real time and is not controlled by Temporal’s time-skipping test server. #### Skip time manually {/* #manual-method */} -Skip time manually in the SDK of your choice. +Use `TestWorkflowEnvironment.sleep(Duration)` when you want to advance virtual time yourself and inspect intermediate Workflow state. Start the Workflow asynchronously with `WorkflowClient.start()`, then call `testEnv.sleep()` from the test. + +Here's an example of a Workflow test that lets you manually advance time with `testEnv.sleep()`: + +```java +package helloworkflow; + +import java.time.Duration; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import org.junit.jupiter.api.Test; + +import io.temporal.client.WorkflowClient; +import io.temporal.client.WorkflowOptions; +import io.temporal.testing.TestWorkflowEnvironment; +import io.temporal.worker.Worker; +import io.temporal.workflow.QueryMethod; +import io.temporal.workflow.Workflow; +import io.temporal.workflow.WorkflowInterface; +import io.temporal.workflow.WorkflowMethod; + +public class ManualTimeSkippingTest { + private static final String TASK_QUEUE = "manual-time-skipping-test"; + + @WorkflowInterface + public interface ProgressWorkflow { + @WorkflowMethod + void run(); + + @QueryMethod + int daysElapsed(); + } + + public static class ProgressWorkflowImpl implements ProgressWorkflow { + private int daysElapsed = 0; + + @Override + public void run() { + for (int i = 0; i < 100; i++) { + Workflow.sleep(Duration.ofDays(1)); + daysElapsed++; + } + } + + @Override + public int daysElapsed() { + return daysElapsed; + } + } + + @Test + void manuallyAdvanceWorkflowTime() { + try (TestWorkflowEnvironment testEnv = TestWorkflowEnvironment.newInstance()) { + Worker worker = testEnv.newWorker(TASK_QUEUE); + worker.registerWorkflowImplementationTypes(ProgressWorkflowImpl.class); + testEnv.start(); + + ProgressWorkflow workflow = + testEnv + .getWorkflowClient() + .newWorkflowStub( + ProgressWorkflow.class, + WorkflowOptions.newBuilder().setTaskQueue(TASK_QUEUE).build()); + + WorkflowClient.start(workflow::run); + + assertEquals(0, workflow.daysElapsed()); + + testEnv.sleep(Duration.ofHours(25)); + assertEquals(1, workflow.daysElapsed()); + + testEnv.sleep(Duration.ofHours(25)); + assertEquals(2, workflow.daysElapsed()); + } + } +} +``` ## How to Replay a Workflow Execution {/* #replay */} diff --git a/docs/develop/java/client/index.mdx b/docs/develop/java/client/index.mdx index e87f1bc9e0..d19717b9d8 100644 --- a/docs/develop/java/client/index.mdx +++ b/docs/develop/java/client/index.mdx @@ -13,8 +13,12 @@ tags: import * as Components from '@site/src/components'; + + ![Java SDK Banner](/img/assets/banner-java-temporal.png) + + ## Temporal Client - [Temporal Client](/develop/java/client/temporal-client) diff --git a/docs/develop/java/client/namespaces.mdx b/docs/develop/java/client/namespaces.mdx index e5a05e3638..9a841f9956 100644 --- a/docs/develop/java/client/namespaces.mdx +++ b/docs/develop/java/client/namespaces.mdx @@ -23,7 +23,7 @@ Use Namespaces to isolate your Workflow Executions according to your needs. For example, you can use Namespaces to match the development lifecycle by having separate `dev` and `prod` Namespaces. You could also use them to ensure Workflow Executions between different teams never communicate - such as ensuring that the `teamA` Namespace never impacts the `teamB` Namespace. -On Temporal Cloud, use the [Temporal Cloud UI](/cloud/namespaces#create-a-namespace) to create and manage a Namespace from the UI, or [tcld commands](https://docs.temporal.io/cloud/tcld/namespace/) to manage Namespaces from the command-line interface. +On Temporal Cloud, use the [Temporal Cloud UI](/cloud/namespaces#create-a-namespace) to create and manage a Namespace from the UI, or [tcld commands](/cloud/tcld/namespace/) to manage Namespaces from the command-line interface. On self-hosted Temporal Service, you can register and manage your Namespaces using the Temporal CLI (recommended) or programmatically using APIs. Note that these APIs and Temporal CLI commands will not work with Temporal Cloud. @@ -36,7 +36,7 @@ You must register a Namespace with the Temporal Service before setting it in the Registering a Namespace creates a Namespace on the Temporal Service or Temporal Cloud. -On Temporal Cloud, use the [Temporal Cloud UI](/cloud/namespaces#create-a-namespace) or [tcld commands](https://docs.temporal.io/cloud/tcld/namespace/) to create Namespaces. +On Temporal Cloud, use the [Temporal Cloud UI](/cloud/namespaces#create-a-namespace) or [tcld commands](/cloud/tcld/namespace/) to create Namespaces. On self-hosted Temporal Service, you can register your Namespaces using the Temporal CLI (recommended) or programmatically using APIs. Note that these APIs and Temporal CLI commands will not work with Temporal Cloud. @@ -76,7 +76,7 @@ To update your Namespace use the [UpdateNamespace API](#manage-namespaces) with You can get details for your Namespaces, update Namespace configuration, and deprecate or delete your Namespaces. -On Temporal Cloud, use the [Temporal Cloud UI](/cloud/namespaces#create-a-namespace) or [tcld commands](https://docs.temporal.io/cloud/tcld/namespace/) to manage Namespaces. +On Temporal Cloud, use the [Temporal Cloud UI](/cloud/namespaces#create-a-namespace) or [tcld commands](/cloud/tcld/namespace/) to manage Namespaces. On self-hosted Temporal Service, you can manage your registered Namespaces using the Temporal CLI (recommended) or programmatically using APIs. Note that these APIs and Temporal CLI commands will not work with Temporal Cloud. @@ -85,7 +85,7 @@ Use a custom [Authorizer](/self-hosted-guide/security#authorizer-plugin) on your You must register a Namespace with the Temporal Service before setting it in the Temporal Client. -On Temporal Cloud, use the [Temporal Cloud UI](/cloud/namespaces) or [tcld commands](https://docs.temporal.io/cloud/tcld/namespace/) to manage Namespaces. +On Temporal Cloud, use the [Temporal Cloud UI](/cloud/namespaces) or [tcld commands](/cloud/tcld/namespace/) to manage Namespaces. On self-hosted Temporal Service, you can manage your registered Namespaces using the Temporal CLI (recommended) or programmatically using APIs. Note that these APIs and Temporal CLI commands will not work with Temporal Cloud. diff --git a/docs/develop/java/index.mdx b/docs/develop/java/index.mdx index 46f73981be..27897090d0 100644 --- a/docs/develop/java/index.mdx +++ b/docs/develop/java/index.mdx @@ -13,8 +13,12 @@ tags: import * as Components from '@site/src/components'; + + ![Java SDK Banner](/img/assets/banner-java-temporal.png) + + ## Install and get started You can find detailed installation instructions for the Java SDK in the [Quickstart](/develop/java/set-up-your-local-java). @@ -66,6 +70,7 @@ From there, you can dive deeper into any of the Temporal primitives to start bui - [Quickstart](/develop/java/nexus/quickstart) - [Feature guide](/develop/java/nexus/feature-guide) +- [Standalone Operations](/develop/java/nexus/standalone-operations) ## [Platform](/develop/java/platform) @@ -78,13 +83,15 @@ From there, you can dive deeper into any of the Temporal primitives to start bui - [Debugging](/develop/java/best-practices/debugging) - [Converters and encryption](/develop/java/best-practices/converters-and-encryption) -## Spring Boot Integration +## [Integrations](/develop/java/integrations) -- [Spring Boot Integration](/develop/java/integrations/spring-boot-integration) +- [Parseable integration](https://github.com/parseablehq/temporal-plugin-java/blob/main/INTEGRATION.md) +- [Spring AI integration](/develop/java/integrations/spring-ai) +- [Spring Boot integration](/develop/java/integrations/spring-boot-integration) -## Temporal Java Technical Resources +## Temporal Java technical resources -- [Java SDK Quickstart - Setup Guide](https://docs.temporal.io/develop/java/set-up-your-local-java) +- [Java SDK Quickstart - Setup Guide](/develop/java/set-up-your-local-java) - [Java API Documentation](https://javadoc.io/doc/io.temporal/temporal-sdk) - [Java SDK Code Samples](https://github.com/temporalio/samples-java) - [Java SDK GitHub](https://github.com/temporalio/sdk-java) diff --git a/docs/develop/java/nexus/feature-guide.mdx b/docs/develop/java/nexus/feature-guide.mdx index d94e7fdbb6..e1d0f353e5 100644 --- a/docs/develop/java/nexus/feature-guide.mdx +++ b/docs/develop/java/nexus/feature-guide.mdx @@ -91,7 +91,7 @@ Defining a clear contract for the Nexus Service is crucial for smooth communicat In this example, there is a service package that describes the Service and Operation names along with input/output types for caller Workflows to use the Nexus Endpoint. -Each [Temporal SDK includes and uses a default Data Converter](https://docs.temporal.io/dataconversion). The default +Each [Temporal SDK includes and uses a default Data Converter](/dataconversion). The default data converter encodes payloads in the following order: Null, Byte array, Protobuf JSON, and JSON. In a polyglot environment, that is where more than one language and SDK is being used to develop a Temporal solution, Protobuf and JSON are common choices. This example uses Java classes serialized into JSON. diff --git a/docs/develop/java/nexus/index.mdx b/docs/develop/java/nexus/index.mdx index b67cb7d048..6549ce1dd0 100644 --- a/docs/develop/java/nexus/index.mdx +++ b/docs/develop/java/nexus/index.mdx @@ -11,10 +11,15 @@ tags: - Temporal SDKs --- + + ![Java SDK Banner](/img/assets/banner-java-temporal.png) + + ## Temporal Nexus - [Quickstart](/develop/java/nexus/quickstart) - [Feature guide](/develop/java/nexus/feature-guide) +- [Standalone Operations](/develop/java/nexus/standalone-operations) - [Nexus sync tutorial](https://learn.temporal.io/tutorials/nexus/nexus-sync-tutorial/) diff --git a/docs/develop/java/nexus/quickstart.mdx b/docs/develop/java/nexus/quickstart.mdx index 8ea5008f49..0009f598d9 100644 --- a/docs/develop/java/nexus/quickstart.mdx +++ b/docs/develop/java/nexus/quickstart.mdx @@ -16,7 +16,7 @@ tags: hide_table_of_contents: true --- -import { SetupSteps, SetupStep, CodeSnippet } from '@site/src/components/elements/SetupSteps'; +import { SetupSteps, SetupStep, CodeSnippet } from '@site/src/components'; [Temporal Nexus](/evaluate/nexus) connects Temporal Applications within and across Namespaces using a Nexus Endpoint, a Nexus Service contract, and Nexus Operations. Build a Nexus Service that wraps an existing Temporal Workflow, then invoke it from a caller Workflow. diff --git a/docs/develop/java/nexus/standalone-operations.mdx b/docs/develop/java/nexus/standalone-operations.mdx new file mode 100644 index 0000000000..894b6ddfbe --- /dev/null +++ b/docs/develop/java/nexus/standalone-operations.mdx @@ -0,0 +1,252 @@ +--- +id: standalone-operations +title: Standalone Nexus Operations - Java SDK +sidebar_label: Standalone Operations +toc_max_heading_level: 4 +keywords: + - standalone nexus operation + - nexus operation execution + - execute nexus operation + - nexus operation handle + - list nexus operations + - count nexus operations + - java sdk +tags: + - Nexus + - Temporal Client + - Java SDK + - Temporal SDKs +description: Execute Nexus Operations independently without a Workflow using the Temporal Java SDK. +--- + +import { ReleaseNoteHeader } from '@site/src/components'; + + + Requires Java SDK `v1.36.1` or above. All APIs are experimental and may be subject to backwards-incompatible changes. + + +[Standalone Nexus Operations](/standalone-nexus-operation) let you run Nexus Operation Executions independently, without +being orchestrated by a Workflow. Instead of calling a Nexus Operation from within a Workflow Definition using +`Workflow.newNexusServiceStub()`, you execute a Standalone Nexus Operation directly from a Nexus service client created +from a `NexusClient` using `NexusClient.newNexusServiceClient()`. + +Standalone Nexus Operations use the same Nexus Service contract, Operation handlers, and Worker setup as +Workflow-driven Operations — only the execution path differs. See the [Nexus feature guide](/develop/java/nexus/feature-guide) for details on +[defining a Service contract](/develop/java/nexus/feature-guide#define-nexus-service-contract), +[developing Operation handlers](/develop/java/nexus/feature-guide#develop-nexus-service-operation-handlers), and +[registering a Service in a Worker](/develop/java/nexus/feature-guide#register-a-nexus-service-in-a-worker). + +This page focuses on the client-side APIs that are unique to Standalone Nexus Operations: + +- [Execute a Standalone Nexus Operation](#execute-operation) +- [Start a Standalone Nexus Operation and Wait for the Result](#get-operation-result) +- [List Standalone Nexus Operations](#list-operations) +- [Count Standalone Nexus Operations](#count-operations) +- [Run Standalone Nexus Operations with Temporal Cloud](#run-standalone-nexus-operations-temporal-cloud) + +:::note +This documentation uses source code from the +[Java Nexus Standalone sample](https://github.com/temporalio/samples-java/tree/main/core/src/main/java/io/temporal/samples/nexusstandalone). + +::: + +## Prerequisites {/* #prerequisites */} + +Standalone Nexus Operations are at Pre-release and require a special Temporal CLI build. + +### 1. Install and verify the Pre-release Temporal CLI {/* #install-cli */} + +The `temporal nexus operation` commands require a Pre-release build of the Temporal CLI. See +[Temporal CLI support](/standalone-nexus-operation#temporal-cli-support) for the platform downloads, +then verify: + +```bash +./temporal --version +# temporal version 1.7.4-standalone-nexus-operations +``` + +Run it as `./temporal` from the directory where you extracted it. The standard `brew install temporal` +build does not include Standalone Nexus Operation support during Pre-release. + +### 2. Start a local dev server {/* #enable-dev-server */} + +The Pre-release dev server enables Standalone Nexus Operations by default — no dynamic config is +required. Start it with the caller and handler Namespaces pre-created: + +```bash +./temporal server start-dev \ + --namespace my-caller-namespace \ + --namespace my-handler-namespace +``` + +The starter and Worker connect to two different Namespaces (a caller Namespace and a handler +Namespace), mirroring how Nexus crosses Namespace boundaries. + +To run the examples on this page against the [Java sample](https://github.com/temporalio/samples-java/tree/main/core/src/main/java/io/temporal/samples/nexusstandalone), +create a Nexus Endpoint that routes to the handler Namespace and the Worker's Task Queue: + +```bash +./temporal operator nexus endpoint create \ + --name my-nexus-endpoint \ + --target-namespace my-handler-namespace \ + --target-task-queue nexus-handler-queue +``` + +Start the sample handler Worker in the handler Namespace: + +```bash +TEMPORAL_NAMESPACE=my-handler-namespace \ + ./gradlew -q :core:execute -PmainClass=io.temporal.samples.nexusstandalone.handler.HandlerWorker +``` + +Run the starter in the caller Namespace (from a separate terminal): + +```bash +TEMPORAL_NAMESPACE=my-caller-namespace \ + ./gradlew -q :core:execute -PmainClass=io.temporal.samples.nexusstandalone.StandaloneClientStarter +``` + +## Execute a Standalone Nexus Operation {/* #execute-operation */} + +To execute a Standalone Nexus Operation, first create a +[`NexusClient`](https://www.javadoc.io/doc/io.temporal/temporal-sdk/latest/io/temporal/client/NexusClient.html), then +derive a typed +[`NexusServiceClient`](https://www.javadoc.io/doc/io.temporal/temporal-sdk/latest/io/temporal/client/NexusServiceClient.html) +from it with `newNexusServiceClient()`, bound to a specific Nexus Endpoint and Service. The endpoint must be +pre-created on the server. Then call `start()` or `execute()` from application code (for example, a starter program), +not from inside a Workflow Definition. + +`execute()` waits for the Operation to complete and returns the result. +Both methods take a [`StartNexusOperationOptions`](https://www.javadoc.io/doc/io.temporal/temporal-sdk/latest/io/temporal/client/StartNexusOperationOptions.html) +whose `id` is required — the SDK never generates one for you. `scheduleToCloseTimeout` is optional and defaults to the +maximum allowed by the Temporal server. + +```java +NexusClient nexusClient = NexusClient.newInstance(stubs, options); +NexusServiceClient greetingClient = + nexusClient.newNexusServiceClient(GreetingNexusService.class, ENDPOINT_NAME); + +// Block until the operation completes and return its result. +GreetingOutput greeting = + greetingClient.execute( + GreetingNexusService::greet, + StartNexusOperationOptions.newBuilder() + .setId("greet-" + UUID.randomUUID()) + .setScheduleToCloseTimeout(Duration.ofSeconds(10)) + .build(), + new GreetingInput("World")); +``` + +`executeAsync()` is the same but returns a `CompletableFuture` instead of blocking. + +```java +CompletableFuture future = + greetingClient.executeAsync( + GreetingNexusService::greet, options, new GreetingInput("World")); +GreetingOutput greeting = future.get(); +``` + +See the full +[starter sample](https://github.com/temporalio/samples-java/blob/main/core/src/main/java/io/temporal/samples/nexusstandalone/StandaloneClientStarter.java) +for a complete example that executes both synchronous and asynchronous Operations, gets their results, and lists and +counts Operations. + +Or use the Temporal CLI to execute a Standalone Nexus Operation: + +```bash +./temporal nexus operation execute \ + --namespace my-caller-namespace \ + --endpoint my-nexus-endpoint \ + --service GreetingNexusService \ + --operation greet \ + --operation-id my-greet-op \ + --input '{"name":"World"}' +``` + +## Start a Standalone Nexus Operation and Wait for the Result {/* #get-operation-result */} + +`start()` returns a +[`NexusOperationHandle`](https://www.javadoc.io/doc/io.temporal/temporal-sdk/latest/io/temporal/client/NexusOperationHandle.html). +Use `NexusOperationHandle.getResult()` to wait until the Operation completes and retrieve its result. This works for +both synchronous and asynchronous Operations. + +```java +// Start an operation and get a NexusOperationHandle. +NexusOperationHandle handle = + greetingClient.start( + GreetingNexusService::startGreeting, options, new GreetingInput("World")); + +// Block until the operation completes and retrieve its result. +GreetingOutput greeting = handle.getResult(); +``` + +If the Operation completed successfully, the result is returned. If the Operation failed, the failure is thrown as a +`NexusOperationException`. Use `getResultAsync()` for a non-blocking `CompletableFuture`, or +`getResult(long timeout, TimeUnit unit)` to bound the wait. + +Or use the Temporal CLI to wait for a result by Operation ID: + +```bash +./temporal nexus operation result --namespace my-caller-namespace --operation-id my-greet-op +``` + +## List Standalone Nexus Operations {/* #list-operations */} + +Use [`NexusClient.listNexusOperationExecutions()`](https://www.javadoc.io/doc/io.temporal/temporal-sdk/latest/io/temporal/client/NexusClient.html) +to list Standalone Nexus Operation Executions that match a [List Filter](/list-filter) query. The result is a `Stream` +of operation metadata entries. + +Note that `listNexusOperationExecutions()` is called on a `NexusClient`, not on the typed `NexusServiceClient`. + +```java +String query = "Endpoint = \"" + ENDPOINT_NAME + "\""; +nexusClient + .listNexusOperationExecutions(query) + .forEach( + op -> + System.out.printf( + "OperationId: %s, Operation: %s, Status: %s%n", + op.getOperationId(), op.getOperation(), op.getStatus())); +``` + +The `query` parameter accepts [List Filter](/list-filter) syntax. For example, +`"Endpoint = 'my-endpoint' AND ExecutionStatus = 'Running'"`. + +Or use the Temporal CLI: + +```bash +./temporal nexus operation list --namespace my-caller-namespace --query 'Endpoint = "my-nexus-endpoint"' +``` + +## Count Standalone Nexus Operations {/* #count-operations */} + +Use [`NexusClient.countNexusOperationExecutions()`](https://www.javadoc.io/doc/io.temporal/temporal-sdk/latest/io/temporal/client/NexusClient.html) +to count Standalone Nexus Operation Executions that match a [List Filter](/list-filter) query. + +Note that `countNexusOperationExecutions()` is called on a `NexusClient`, not on the typed `NexusServiceClient`. + +```java +String query = "Endpoint = \"" + ENDPOINT_NAME + "\""; +NexusOperationExecutionCount count = nexusClient.countNexusOperationExecutions(query); +System.out.println("Total Nexus operations: " + count.getCount()); +``` + +Passing a `GROUP BY` query (for example, `"GROUP BY ExecutionStatus"`) returns a count per group, available through +`NexusOperationExecutionCount.getGroups()`. + +Or use the Temporal CLI: + +```bash +./temporal nexus operation count --namespace my-caller-namespace --query 'Endpoint = "my-nexus-endpoint"' +``` + +## Run Standalone Nexus Operations with Temporal Cloud {/* #run-standalone-nexus-operations-temporal-cloud */} + +The code samples referenced on this page build their client from a `ClientConfigProfile` loaded from a TOML profile, so +the same code works against Temporal Cloud — just point the profile at your Cloud Namespace (or override the connection +via `TEMPORAL_*` environment variables). No code changes are needed. + +For full details on connecting to Temporal Cloud, including Namespace creation, Nexus Endpoint setup, certificate +generation, and authentication options, see +[Make Nexus calls across Namespaces in Temporal Cloud](/develop/java/nexus/feature-guide#nexus-calls-across-namespaces-temporal-cloud) +and [Connect to Temporal Cloud](/develop/java/client/temporal-client#connect-to-temporal-cloud). diff --git a/docs/develop/java/platform/index.mdx b/docs/develop/java/platform/index.mdx index 4379f189ba..b92a13802f 100644 --- a/docs/develop/java/platform/index.mdx +++ b/docs/develop/java/platform/index.mdx @@ -13,8 +13,12 @@ tags: import * as Components from '@site/src/components'; + + ![Java SDK Banner](/img/assets/banner-java-temporal.png) + + ## Platform - [Observability](/develop/java/platform/observability) diff --git a/docs/develop/java/platform/observability.mdx b/docs/develop/java/platform/observability.mdx index fec0953205..0e447b9d66 100644 --- a/docs/develop/java/platform/observability.mdx +++ b/docs/develop/java/platform/observability.mdx @@ -136,17 +136,7 @@ An appropriate logging level depends on your specific needs. During development or troubleshooting, you might use debug or even trace. In production, you might use info or warn to avoid excessive log volume. -The logger supports the following logging levels: - -| Level | Use | -| ------- | --------------------------------------------------------------------------------------------------------- | -| `TRACE` | The most detailed level of logging, used for very fine-grained information. | -| `DEBUG` | Detailed information, typically useful for debugging purposes. | -| `INFO` | General information about the application's operation. | -| `WARN` | Indicates potentially harmful situations or minor issues that don't prevent the application from working. | -| `ERROR` | Indicates error conditions that might still allow the application to continue running. | - -The Temporal SDK core normally uses `WARN` as its default logging level. +You can find the log levels supported by `slf4j` in [their official documentation](https://www.slf4j.org/apidocs/org/slf4j/ext/XLogger.Level.html). The Temporal SDK core normally uses `WARN` as its default logging level. To get a standard `slf4j` logger in your Workflow code, use the [`Workflow.getLogger`](https://www.javadoc.io/doc/io.temporal/temporal-sdk/latest/io/temporal/workflow/Workflow.html) method. diff --git a/docs/develop/java/set-up.mdx b/docs/develop/java/set-up.mdx index 05c1c86550..2e5d012c24 100644 --- a/docs/develop/java/set-up.mdx +++ b/docs/develop/java/set-up.mdx @@ -14,8 +14,7 @@ tags: hide_table_of_contents: true --- -import { SetupSteps, SetupStep, CodeSnippet } from '@site/src/components/elements/SetupSteps'; -import { CallToAction } from '@site/src/components/elements/CallToAction'; +import { CallToAction, SetupSteps, SetupStep, CodeSnippet } from '@site/src/components'; import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; @@ -211,7 +210,7 @@ Next, you'll configure a local Temporal Service for development. ## Install Temporal CLI and start the development server The fastest way to get a development version of the Temporal Service running on your local machine is to use -[Temporal CLI](https://docs.temporal.io/cli). +[Temporal CLI](/cli). Choose your operating system to install Temporal CLI: diff --git a/docs/develop/java/workers/index.mdx b/docs/develop/java/workers/index.mdx index b6038b34f4..57b54e17b4 100644 --- a/docs/develop/java/workers/index.mdx +++ b/docs/develop/java/workers/index.mdx @@ -13,8 +13,12 @@ tags: import * as Components from '@site/src/components'; + + ![Java SDK Banner](/img/assets/banner-java-temporal.png) + + ## Workers - [Run Worker processes](/develop/java/workers/run-worker-process) diff --git a/docs/develop/java/workflows/cancellation.mdx b/docs/develop/java/workflows/cancellation.mdx index 9d524e336a..ae1678f3b3 100644 --- a/docs/develop/java/workflows/cancellation.mdx +++ b/docs/develop/java/workflows/cancellation.mdx @@ -54,7 +54,7 @@ WorkflowStub workflowStub = WorkflowStub.fromTyped(workflow); workflowStub.cancel(); ``` -## Cancellation scopes in Java {/* #cancellation-scopes */} +## Cancellation scopes {/* #cancellation-scopes */} In the Java SDK, Workflows are represented internally by a tree of cancellation scopes, each with cancellation behaviors you can specify. By default, everything runs in the "root" scope. @@ -84,6 +84,10 @@ created within it, such as the following: - Child Workflows - Nexus Operations +### Cancel with Workflow error + +When you want to interrupt a Workflow Execution using an error, throw the [`DestroyWorkflowThreadError`](https://www.javadoc.io/doc/io.temporal/temporal-sdk/latest/io/temporal/internal/sync/DestroyWorkflowThreadError.html). Make sure there isn't any code on that thread to catch this error or it could cause issues with the Temporal service. + ### Cancel an Activity from a Workflow {/* #cancel-activity */} Canceling an Activity from within a Workflow requires that the Activity Execution sends Heartbeats and sets a Heartbeat diff --git a/docs/develop/java/workflows/continue-as-new.mdx b/docs/develop/java/workflows/continue-as-new.mdx index 4e18957cd2..f19ebab767 100644 --- a/docs/develop/java/workflows/continue-as-new.mdx +++ b/docs/develop/java/workflows/continue-as-new.mdx @@ -17,7 +17,7 @@ tags: - Temporal SDKs --- -import { ViewSourceCodeNotice } from '@site/src/components/elements/ViewSourceCodeNotice'; +import { ViewSourceCodeNotice } from '@site/src/components'; This page answers the following questions for Java developers: diff --git a/docs/develop/java/workflows/index.mdx b/docs/develop/java/workflows/index.mdx index e570110457..f73ac38f48 100644 --- a/docs/develop/java/workflows/index.mdx +++ b/docs/develop/java/workflows/index.mdx @@ -13,8 +13,12 @@ tags: import * as Components from '@site/src/components'; + + ![Java SDK Banner](/img/assets/banner-java-temporal.png) + + ## Workflows - [Workflow basics](/develop/java/workflows/basics) diff --git a/docs/develop/java/workflows/message-passing.mdx b/docs/develop/java/workflows/message-passing.mdx index 70e95bcc01..840988dea6 100644 --- a/docs/develop/java/workflows/message-passing.mdx +++ b/docs/develop/java/workflows/message-passing.mdx @@ -209,7 +209,7 @@ public class MessagePassingIntro { - When a Validator throws an error, the Update is rejected, the Update is not run, and `WorkflowExecutionUpdateAccepted` _won't_ be added to the Event History. The caller receives an "Update failed" error. -- Use [`getCurrentUpdateInfo`](https://www.javadoc.io/doc/io.temporal/temporal-sdk/latest/io/temporal/internal/sync/WorkflowInternal.html#getCurrentUpdateInfo()) to obtain information about the current Update. This includes the Update ID, which can be useful for deduplication when using Continue-As-New: see [Ensuring your messages are processed exactly once](https://docs.temporal.io/handling-messages#exactly-once-message-processing). +- Use [`getCurrentUpdateInfo`](https://www.javadoc.io/doc/io.temporal/temporal-sdk/latest/io/temporal/internal/sync/WorkflowInternal.html#getCurrentUpdateInfo()) to obtain information about the current Update. This includes the Update ID, which can be useful for deduplication when using Continue-As-New: see [Ensuring your messages are processed exactly once](/handling-messages#exactly-once-message-processing). - Signal (and Update) handlers can be blocking, letting them use Activities, Child Workflows, durable [`Workflow.sleep`](https://www.javadoc.io/doc/io.temporal/temporal-sdk/latest/io/temporal/workflow/Workflow.html#sleep(java.time.Duration)) Timers, [`Workflow.await`](https://www.javadoc.io/doc/io.temporal/temporal-sdk/latest/io/temporal/workflow/Workflow.html#await(java.time.Duration,java.util.function.Supplier)) conditions, and more. See [Blocking handlers](#blocking-handlers) and [Workflow message passing](/encyclopedia/workflow-message-passing) for safe usage guidelines. diff --git a/docs/develop/java/workflows/schedules.mdx b/docs/develop/java/workflows/schedules.mdx index 6e0c282411..c0cb21e31b 100644 --- a/docs/develop/java/workflows/schedules.mdx +++ b/docs/develop/java/workflows/schedules.mdx @@ -180,7 +180,7 @@ handle.update( :::caution Cron support is not recommended -We recommend using [Schedules](https://docs.temporal.io/schedule) instead of Cron Jobs. +We recommend using [Schedules](/schedule) instead of Cron Jobs. Schedules were built to provide a better developer experience, including more configuration options and the ability to update or pause running Schedules. ::: diff --git a/docs/develop/php/activities/basics.mdx b/docs/develop/php/activities/basics.mdx index c14f3c3a80..22dc88413b 100644 --- a/docs/develop/php/activities/basics.mdx +++ b/docs/develop/php/activities/basics.mdx @@ -65,7 +65,7 @@ The default implementation uses a JSON serializer, but an alternative implementa All data returned from an Activity must be serializable. -Activity return values are subject to payload size limits in Temporal. The default payload size limit is 2MB, and there is a hard limit of 4MB for any gRPC message size in the Event History transaction ([see Cloud limits here](https://docs.temporal.io/cloud/limits#per-message-grpc-limit)). Keep in mind that all return values are recorded in a [Workflow Execution Event History](/workflow-execution/event#event-history). +Activity return values are subject to payload size limits in Temporal. The default payload size limit is 2MB, and there is a hard limit of 4MB for any gRPC message size in the Event History transaction ([see Cloud limits here](/cloud/limits#per-message-grpc-limit)). Keep in mind that all return values are recorded in a [Workflow Execution Event History](/workflow-execution/event#event-history). Return values must be serializable to a byte array using the provided [DataConverter](https://github.com/temporalio/sdk-php/blob/master/src/DataConverter/DataConverterInterface.php) interface. The default implementation uses a JSON serializer, but an alternative implementation can be easily configured. diff --git a/docs/develop/php/activities/index.mdx b/docs/develop/php/activities/index.mdx index 167e18ee3b..24ac29acfa 100644 --- a/docs/develop/php/activities/index.mdx +++ b/docs/develop/php/activities/index.mdx @@ -13,8 +13,12 @@ tags: import * as Components from '@site/src/components'; + + ![PHP SDK Banner](/img/assets/banner-php-temporal.png) + + ## Activities - [Activity Basics](/develop/php/activities/basics) diff --git a/docs/develop/php/activities/timeouts.mdx b/docs/develop/php/activities/timeouts.mdx index 6d6a32c847..bfffafdd56 100644 --- a/docs/develop/php/activities/timeouts.mdx +++ b/docs/develop/php/activities/timeouts.mdx @@ -25,7 +25,7 @@ The following timeouts are available in the Activity Options. - **[Schedule-To-Close Timeout](/encyclopedia/detecting-activity-failures#schedule-to-close-timeout):** is the maximum amount of time allowed for the overall [Activity Execution](/activity-execution). - **[Start-To-Close Timeout](/encyclopedia/detecting-activity-failures#start-to-close-timeout):** is the maximum time allowed for a single [Activity Task Execution](/tasks#activity-task-execution). -- **[Schedule-To-Start Timeout](/encyclopedia/detecting-activity-failures#schedule-to-start-timeout):** is the maximum amount of time that is allowed from when an [Activity Task](/tasks#activity-task) is scheduled to when a [Worker](/workers#worker) starts that Activity Task. +- **[Schedule-To-Start Timeout](/encyclopedia/detecting-activity-failures#schedule-to-start-timeout):** is the maximum amount of time that is allowed from when an [Activity Task](/tasks#activity-task) is scheduled to when a [Worker](/workers#worker) starts that Activity Task. This timeout is non-retryable by design. An Activity Execution must have either the Start-To-Close or the Schedule-To-Close Timeout set. diff --git a/docs/develop/php/best-practices/index.mdx b/docs/develop/php/best-practices/index.mdx index 3a1abeaff6..b4b36e1dc0 100644 --- a/docs/develop/php/best-practices/index.mdx +++ b/docs/develop/php/best-practices/index.mdx @@ -13,8 +13,12 @@ tags: import * as Components from '@site/src/components'; + + ![PHP SDK Banner](/img/assets/banner-php-temporal.png) + + ## Best practices - [Testing](/develop/php/best-practices/testing-suite) diff --git a/docs/develop/php/client/index.mdx b/docs/develop/php/client/index.mdx index 8e7ed20922..4389887d58 100644 --- a/docs/develop/php/client/index.mdx +++ b/docs/develop/php/client/index.mdx @@ -13,8 +13,12 @@ tags: import * as Components from '@site/src/components'; + + ![PHP SDK Banner](/img/assets/banner-php-temporal.png) + + ## Temporal Client - [Temporal Client](/develop/php/client/temporal-client) diff --git a/docs/develop/php/client/temporal-client.mdx b/docs/develop/php/client/temporal-client.mdx index 211f9a3a81..e43d7c1dea 100644 --- a/docs/develop/php/client/temporal-client.mdx +++ b/docs/develop/php/client/temporal-client.mdx @@ -44,7 +44,7 @@ However, it is acceptable and common to use a Temporal Client inside an Activity ::: -When you are running a Temporal Service locally (such as the [Temporal CLI](https://docs.temporal.io/cli/command-reference/server#start-dev)), the number of connection options you must provide is minimal. +When you are running a Temporal Service locally (such as the [Temporal CLI](/cli/command-reference/server#start-dev)), the number of connection options you must provide is minimal. Many SDKs default to `127.0.0.1:7233`. In the PHP SDK, different client classes are responsible for different functional areas. diff --git a/docs/develop/php/index.mdx b/docs/develop/php/index.mdx index 5981ef3138..f3281f59a2 100644 --- a/docs/develop/php/index.mdx +++ b/docs/develop/php/index.mdx @@ -13,8 +13,12 @@ tags: import * as Components from '@site/src/components'; + + ![PHP SDK Banner](/img/assets/banner-php-temporal.png) + + ## Install and get started You can find detailed installation instructions for the PHP SDK in the [Quickstart](/develop/php/set-up-your-local-php). @@ -68,7 +72,7 @@ From there, you can dive deeper into any of the Temporal primitives to start bui - [Testing](/develop/php/best-practices/testing-suite) - [Debugging](/develop/php/best-practices/debugging) -## Temporal PHP Technical Resources +## Temporal PHP technical resources - [PHP SDK Quickstart - Setup Guide](/develop/php/set-up-your-local-php) - [PHP API Documentation](https://php.temporal.io) diff --git a/docs/develop/php/platform/index.mdx b/docs/develop/php/platform/index.mdx index a9b22312fa..c405c1fa7d 100644 --- a/docs/develop/php/platform/index.mdx +++ b/docs/develop/php/platform/index.mdx @@ -13,8 +13,12 @@ tags: import * as Components from '@site/src/components'; + + ![PHP SDK Banner](/img/assets/banner-php-temporal.png) + + ## Platform - [Observability](/develop/php/platform/observability) diff --git a/docs/develop/php/platform/observability.mdx b/docs/develop/php/platform/observability.mdx index 0b66cefde7..3b2fc31c5f 100644 --- a/docs/develop/php/platform/observability.mdx +++ b/docs/develop/php/platform/observability.mdx @@ -35,17 +35,7 @@ An appropriate logging level depends on your specific needs. During development or troubleshooting, you might use debug or even trace. In production, you might use info or warn to avoid excessive log volume. -The logger supports the following logging levels: - -| Level | Use | -| ------- | --------------------------------------------------------------------------------------------------------- | -| `TRACE` | The most detailed level of logging, used for very fine-grained information. | -| `DEBUG` | Detailed information, typically useful for debugging purposes. | -| `INFO` | General information about the application's operation. | -| `WARN` | Indicates potentially harmful situations or minor issues that don't prevent the application from working. | -| `ERROR` | Indicates error conditions that might still allow the application to continue running. | - -The Temporal SDK core normally uses `WARN` as its default logging level. +You can find the log levels supported by `PSR-3` in [their official documentation](https://www.php-fig.org/psr/psr-3/#5-psrlogloglevel). The Temporal SDK core normally uses `WARN` as its default logging level. To get a PSR-3 compatible logger in your Workflow code, use the [`Workflow::getLogger()`](https://php.temporal.io/classes/Temporal-Workflow.html#method_getLogger) method. diff --git a/docs/develop/php/set-up.mdx b/docs/develop/php/set-up.mdx index 92f31ee174..873dcea48c 100644 --- a/docs/develop/php/set-up.mdx +++ b/docs/develop/php/set-up.mdx @@ -15,8 +15,7 @@ hide_table_of_contents: true description: Configure your local development environment to get started developing with Temporal --- -import { SetupSteps, SetupStep, CodeSnippet } from '@site/src/components/elements/SetupSteps'; -import { CallToAction } from '@site/src/components/elements/CallToAction'; +import { CallToAction, SetupSteps, SetupStep, CodeSnippet } from '@site/src/components'; import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; @@ -222,7 +221,7 @@ Create a simple configuration file named `.rr.yaml` with the following content: ## Install Temporal CLI and start the development server The fastest way to get a development version of the Temporal Service running on your local machine is to use -[Temporal CLI](https://docs.temporal.io/cli). +[Temporal CLI](/cli). Choose your operating system to install Temporal CLI: diff --git a/docs/develop/php/workers/index.mdx b/docs/develop/php/workers/index.mdx index 0d246ae5ed..bddf6b3428 100644 --- a/docs/develop/php/workers/index.mdx +++ b/docs/develop/php/workers/index.mdx @@ -13,8 +13,12 @@ tags: import * as Components from '@site/src/components'; + + ![PHP SDK Banner](/img/assets/banner-php-temporal.png) + + ## Workers - [Run Worker processes](/develop/php/workers/run-worker-process) diff --git a/docs/develop/php/workflows/continue-as-new.mdx b/docs/develop/php/workflows/continue-as-new.mdx index 9548550600..147747ebea 100644 --- a/docs/develop/php/workflows/continue-as-new.mdx +++ b/docs/develop/php/workflows/continue-as-new.mdx @@ -17,7 +17,7 @@ tags: - Temporal SDKs --- -import { ViewSourceCodeNotice } from '@site/src/components/elements/ViewSourceCodeNotice'; +import { ViewSourceCodeNotice } from '@site/src/components'; This page answers the following questions for PHP developers: diff --git a/docs/develop/php/workflows/index.mdx b/docs/develop/php/workflows/index.mdx index 3dc3096100..5a512948c2 100644 --- a/docs/develop/php/workflows/index.mdx +++ b/docs/develop/php/workflows/index.mdx @@ -13,8 +13,12 @@ tags: import * as Components from '@site/src/components'; + + ![PHP SDK Banner](/img/assets/banner-php-temporal.png) + + ## Workflows - [Workflow Basics](/develop/php/workflows/basics) diff --git a/docs/develop/php/workflows/schedules.mdx b/docs/develop/php/workflows/schedules.mdx index 3672930d4f..d54c4d1f9e 100644 --- a/docs/develop/php/workflows/schedules.mdx +++ b/docs/develop/php/workflows/schedules.mdx @@ -40,7 +40,7 @@ $workflowClient->start($workflow, 'Hello world!'); :::caution Cron support is not recommended -We recommend using [Schedules](https://docs.temporal.io/schedule) instead of Cron Jobs. +We recommend using [Schedules](/schedule) instead of Cron Jobs. Schedules were built to provide a better developer experience, including more configuration options and the ability to update or pause running Schedules. ::: diff --git a/docs/develop/plugins-guide.mdx b/docs/develop/plugins-guide.mdx index c9e3158fcc..1c2e26900f 100644 --- a/docs/develop/plugins-guide.mdx +++ b/docs/develop/plugins-guide.mdx @@ -13,7 +13,7 @@ tags: - AI Agents --- -import SdkTabs from '@site/src/components'; +import { SdkTabs } from '@site/src/components'; # Plugins diff --git a/docs/develop/python/activities/basics.mdx b/docs/develop/python/activities/basics.mdx index fa23d69819..3c40d7a609 100644 --- a/docs/develop/python/activities/basics.mdx +++ b/docs/develop/python/activities/basics.mdx @@ -11,7 +11,7 @@ tags: - Temporal SDKs --- -import { ViewSourceCodeNotice } from '@site/src/components/elements/ViewSourceCodeNotice'; +import { ViewSourceCodeNotice } from '@site/src/components'; ## Develop a basic Activity {/* #develop-activities */} @@ -110,7 +110,7 @@ All data returned from an Activity must be serializable. Activity return values are subject to payload size limits in Temporal. The default payload size limit is 2MB, and there is a hard limit of 4MB for any gRPC message size in the Event History transaction -([see Cloud limits here](https://docs.temporal.io/cloud/limits#per-message-grpc-limit)). Keep in mind that all return +([see Cloud limits here](/cloud/limits#per-message-grpc-limit)). Keep in mind that all return values are recorded in a [Workflow Execution Event History](/workflow-execution/event#event-history). An Activity Execution can return inputs and other Activity values. diff --git a/docs/develop/python/activities/execution.mdx b/docs/develop/python/activities/execution.mdx index f6ae6d90ad..7ae6b0a56b 100644 --- a/docs/develop/python/activities/execution.mdx +++ b/docs/develop/python/activities/execution.mdx @@ -11,7 +11,7 @@ tags: - Activity --- -import { ViewSourceCodeNotice } from '@site/src/components/elements/ViewSourceCodeNotice'; +import { ViewSourceCodeNotice } from '@site/src/components'; ## Start an Activity Execution {/* #activity-execution */} diff --git a/docs/develop/python/activities/index.mdx b/docs/develop/python/activities/index.mdx index 417544cfe6..f2b3dfb394 100644 --- a/docs/develop/python/activities/index.mdx +++ b/docs/develop/python/activities/index.mdx @@ -13,8 +13,12 @@ tags: import * as Components from '@site/src/components'; + + ![Python SDK Banner](/img/assets/banner-python-temporal.png) + + ## Activities - [Activity basics](/develop/python/activities/basics) diff --git a/docs/develop/python/activities/timeouts.mdx b/docs/develop/python/activities/timeouts.mdx index 15fe03cb35..ad764e28c2 100644 --- a/docs/develop/python/activities/timeouts.mdx +++ b/docs/develop/python/activities/timeouts.mdx @@ -17,7 +17,7 @@ tags: - Temporal SDKs --- -import { ViewSourceCodeNotice } from '@site/src/components/elements/ViewSourceCodeNotice'; +import { ViewSourceCodeNotice } from '@site/src/components'; ## Set Activity timeouts {/* #activity-timeouts */} @@ -27,7 +27,7 @@ The following timeouts are available in the Activity Options. - **[Schedule-To-Close Timeout](/encyclopedia/detecting-activity-failures#schedule-to-close-timeout):** is the maximum amount of time allowed for the overall [Activity Execution](/activity-execution). - **[Start-To-Close Timeout](/encyclopedia/detecting-activity-failures#start-to-close-timeout):** is the maximum time allowed for a single [Activity Task Execution](/tasks#activity-task-execution). -- **[Schedule-To-Start Timeout](/encyclopedia/detecting-activity-failures#schedule-to-start-timeout):** is the maximum amount of time that is allowed from when an [Activity Task](/tasks#activity-task) is scheduled to when a [Worker](/workers#worker) starts that Activity Task. +- **[Schedule-To-Start Timeout](/encyclopedia/detecting-activity-failures#schedule-to-start-timeout):** is the maximum amount of time that is allowed from when an [Activity Task](/tasks#activity-task) is scheduled to when a [Worker](/workers#worker) starts that Activity Task. This timeout is non-retryable by design. An Activity Execution must have either the Start-To-Close or the Schedule-To-Close Timeout set. diff --git a/docs/develop/python/best-practices/index.mdx b/docs/develop/python/best-practices/index.mdx index 2b5f2c8cc1..f12e14e607 100644 --- a/docs/develop/python/best-practices/index.mdx +++ b/docs/develop/python/best-practices/index.mdx @@ -13,8 +13,12 @@ tags: import * as Components from '@site/src/components'; + + ![Python SDK Banner](/img/assets/banner-python-temporal.png) + + ## Best practices - [Error handling](/develop/python/best-practices/error-handling) diff --git a/docs/develop/python/best-practices/testing-suite.mdx b/docs/develop/python/best-practices/testing-suite.mdx index 706befa67f..7719dd01cb 100644 --- a/docs/develop/python/best-practices/testing-suite.mdx +++ b/docs/develop/python/best-practices/testing-suite.mdx @@ -36,6 +36,8 @@ Some SDKs have support or examples for popular test frameworks, runners, or libr One recommended framework for testing in Python for the Temporal SDK is [pytest](https://docs.pytest.org/), which can help with fixtures to stand up and tear down test environments, provide useful test discovery, and make it easy to write parameterized tests. +If you do use `pytest`, consider using `-s` (`--show-capture=no`) so you can see the logs live. + ## Testing Activities {/* #test-activities */} An Activity can be tested with a mock Activity environment, which provides a way to mock the Activity context, listen to Heartbeats, and cancel the Activity. @@ -75,13 +77,45 @@ assert heartbeats == ["param: test", "second heartbeat"] ## Testing Workflows {/* #test-workflows */} -### How to mock Activities {/* #mock-activities */} +The simplest test case we can write is to have the test environment execute the Workflow and then evaluate the results. `WorkflowEnvironment.start_local` configures a local environment for running and testing Workflows: + +```python +import uuid + +import pytest +from temporalio import activity +from temporalio.testing import WorkflowEnvironment +from temporalio.worker import Worker + +from activities import greet +from workflows import SayHelloWorkflow + -Mock the Activity invocation when unit testing your Workflows. +@pytest.mark.asyncio +async def test_say_hello_workflow(): + """Execute the workflow end-to-end with its real activity.""" + task_queue_name = str(uuid.uuid4()) + async with await WorkflowEnvironment.start_local(ui=True, ui_port=8233) as env: + async with Worker( + env.client, + task_queue=task_queue_name, + workflows=[SayHelloWorkflow], + activities=[greet], + ): + result = await env.client.execute_workflow( + SayHelloWorkflow.run, + "Temporal", + id=str(uuid.uuid4()), + task_queue=task_queue_name, + ) + assert result == "Hello Temporal" +``` -When integration testing Workflows with a Worker, you can mock Activities by providing mock Activity implementations to the Worker. +You can also pass `ui=True` to `start_local` to see the UI. + +### How to mock Activities {/* #mock-activities */} -Provide mock Activity implementations to the Worker. +When running unit tests on Workflows, many times you want to test the Workflow logic in isolation. When integration testing Workflows with a Worker, you can mock Activities by providing mock Activity implementations to the Worker. ```python import uuid @@ -130,24 +164,21 @@ For example, if you have a Workflow sleep for a day, or have an Activity failure Instead, test the logic that happens after the sleep by skipping forward in time and complete your tests in a timely manner. The test framework included in most SDKs is an in-memory implementation of Temporal Server that supports skipping time. -Time is a global property of an instance of `TestWorkflowEnvironment`: skipping time (either automatically or manually) applies to all currently running tests. +Time is a global property of an instance of `WorkflowEnvironment`: skipping time (either automatically or manually) applies to all currently running tests. If you need different time behaviors for different tests, run your tests in a series or with separate instances of the test server. For example, you could run all tests with automatic time skipping in parallel, and then all tests with manual time skipping in series, and then all tests without time skipping in parallel. #### Skip time automatically {/* #automatic-method */} -Start a test server process that skips time as needed. -For example, in the time-skipping mode, Timers, which include sleeps and conditional timeouts, are fast-forwarded except when Activities are running. +Use the [`start_time_skipping()`](https://python.temporal.io/temporalio.testing.WorkflowEnvironment.html#start_time_skipping) method to start a test server process and skip time automatically. In time-skipping mode, Timers, which include sleeps and conditional timeouts, are fast-forwarded except when Activities are running. -Use the [`start_time_skipping()`](https://python.temporal.io/temporalio.testing.WorkflowEnvironment.html#start_time_skipping) method to start a test server process and skip time automatically. - -Use the [`start_local()`](https://python.temporal.io/temporalio.testing.WorkflowEnvironment.html#start_local) method for a full local Temporal Server. +Use the [`start_local()`](https://python.temporal.io/temporalio.testing.WorkflowEnvironment.html#start_local) method for testing a full local Temporal Server. There is no time skipping in this environment, so any Timers will wait the actual amount of time. You can find an example of this being used in [the code above](/develop/python/best-practices/testing-suite#test-workflows). Use the [`from_client()`](https://python.temporal.io/temporalio.testing.WorkflowEnvironment.html#from_client) method for an existing Temporal Server. #### Skip time manually {/* #manual-method */} -To implement time skipping, use the [`start_time_skipping()`](https://python.temporal.io/temporalio.testing.WorkflowEnvironment.html#start_time_skipping) static method. +To implement time skipping manually, use the [`sleep`](https://python.temporal.io/temporalio.testing.WorkflowEnvironment.html#sleep) method inside the `WorkflowEnvironment`. This will manually advance time by the duration you specify. ```python from temporalio.testing import WorkflowEnvironment @@ -160,14 +191,6 @@ async def test_manual_time_skipping(): # Your code here ``` -### Assert in Workflow {/* #assert-in-workflow */} - -The `assert` statement is a convenient way to insert debugging assertions into the Workflow context. - -The `assert` method is available in Python and TypeScript. - -For information about assert statements in Python, see [`assert`](https://docs.python.org/3/reference/simple_stmts.html#the-assert-statement) in the Python Language Reference. - ## How to Replay a Workflow Execution {/* #replay */} Replay recreates the exact state of a Workflow Execution. diff --git a/docs/develop/python/client/index.mdx b/docs/develop/python/client/index.mdx index e0cee44999..285ba1c8f6 100644 --- a/docs/develop/python/client/index.mdx +++ b/docs/develop/python/client/index.mdx @@ -13,8 +13,12 @@ tags: import * as Components from '@site/src/components'; + + ![Python SDK Banner](/img/assets/banner-python-temporal.png) + + ## Temporal Client - [Temporal Client](/develop/python/client/temporal-client) diff --git a/docs/develop/python/client/temporal-client.mdx b/docs/develop/python/client/temporal-client.mdx index 7290a1905a..8370a66aca 100644 --- a/docs/develop/python/client/temporal-client.mdx +++ b/docs/develop/python/client/temporal-client.mdx @@ -28,7 +28,7 @@ tags: - Certificates --- -import { ViewSourceCodeNotice } from '@site/src/components/elements/ViewSourceCodeNotice'; +import { ViewSourceCodeNotice } from '@site/src/components'; A [Temporal Client](/encyclopedia/temporal-sdks#temporal-client) enables you to communicate with the Temporal Service. Communication with a Temporal Service lets you perform actions such as starting Workflow Executions, sending Signals and @@ -55,7 +55,7 @@ options directly in code, load them from **environment variables**, or a **TOML configuration file for secure, repeatable configuration. When you’re running a Temporal Service locally (such as with the -[Temporal CLI dev server](https://docs.temporal.io/cli/command-reference/server#start-dev)), the required options are minimal. If you +[Temporal CLI dev server](/cli/command-reference/server#start-dev)), the required options are minimal. If you don't specify a host/port, most connections default to `127.0.0.1:7233` and the `default` Namespace. diff --git a/docs/develop/python/index.mdx b/docs/develop/python/index.mdx index f20bada1e4..59446aa08b 100644 --- a/docs/develop/python/index.mdx +++ b/docs/develop/python/index.mdx @@ -13,8 +13,12 @@ tags: import * as Components from '@site/src/components'; + + ![Python SDK Banner](/img/assets/banner-python-temporal.png) + + ## Install and get started You can find detailed installation instructions for the Python SDK in the [Quickstart](/develop/python/set-up-your-local-python). @@ -64,6 +68,7 @@ From there, you can dive deeper into any of the Temporal primitives to start bui - [Quickstart](/develop/python/nexus/quickstart) - [Feature guide](/develop/python/nexus/feature-guide) +- [Standalone Operations](/develop/python/nexus/standalone-operations) ## [Platform](/develop/python/platform) @@ -91,7 +96,7 @@ From there, you can dive deeper into any of the Temporal primitives to start bui - [Strands Agents integration](/develop/python/integrations/strands-agents) - [Tenuo integration](https://tenuo.ai/temporal) -## Temporal Python Technical Resources +## Temporal Python technical resources - [Python SDK Quickstart - Setup Guide](/develop/python/set-up-your-local-python) - [Python API Documentation](https://python.temporal.io) diff --git a/docs/develop/python/integrations/langgraph.mdx b/docs/develop/python/integrations/langgraph.mdx index 17ca899bd7..c17f084d9a 100644 --- a/docs/develop/python/integrations/langgraph.mdx +++ b/docs/develop/python/integrations/langgraph.mdx @@ -28,7 +28,7 @@ The plugin supports both the LangGraph **Graph API** (`StateGraph` with nodes an directly inside the Workflow — Activity nodes get configurable timeouts and retry policies, while Workflow nodes run inline and must be deterministic. - + Code snippets in this guide are taken from the [LangGraph plugin samples](https://github.com/temporalio/samples-python/tree/main/langgraph_plugin). Refer to the diff --git a/docs/develop/python/integrations/langsmith.mdx b/docs/develop/python/integrations/langsmith.mdx index d005279c5f..fd3f4207d2 100644 --- a/docs/develop/python/integrations/langsmith.mdx +++ b/docs/develop/python/integrations/langsmith.mdx @@ -19,14 +19,10 @@ description: import { ReleaseNoteHeader } from '@site/src/components'; - - All APIs are experimental and may be subject to backwards-incompatible changes. - - Temporal's LangSmith integration lets you trace AI agent Workflows in [LangSmith](https://smith.langchain.com/) alongside every LLM call, tool execution, and Temporal operation. -Temporal gives your agent code [durable execution](https://docs.temporal.io/temporal#durable-execution). +Temporal gives your agent code [durable execution](/temporal#durable-execution). LangSmith adds the observability side, so you can inspect LLM inputs and outputs, follow a request from the Client through to the model, and compare runs over time. @@ -34,6 +30,8 @@ The `LangSmithPlugin` is what connects the two. It propagates trace context acro started on the Client nest correctly under Workflow and Activity runs on the Worker. It can also create LangSmith runs for Temporal operations themselves: Workflow executions, Activity executions, Signals, Updates, and Queries. + + All code snippets in this guide are taken from the [LangSmith tracing sample](https://github.com/temporalio/samples-python/tree/main/langsmith_tracing). Refer to the sample for complete code. diff --git a/docs/develop/python/nexus/feature-guide.mdx b/docs/develop/python/nexus/feature-guide.mdx index e037be3e4e..2430baeb84 100644 --- a/docs/develop/python/nexus/feature-guide.mdx +++ b/docs/develop/python/nexus/feature-guide.mdx @@ -87,7 +87,7 @@ Defining a clear contract for the Nexus Service is crucial for smooth communicat In this example, there is a service package that describes the Service and Operation names along with input/output types for caller Workflows to use the Nexus Endpoint. -Each [Temporal SDK includes and uses a default Data Converter](https://docs.temporal.io/dataconversion). +Each [Temporal SDK includes and uses a default Data Converter](/dataconversion). The default data converter encodes payloads in the following order: Null, Byte array, Protobuf JSON, and JSON. In a polyglot environment, that is where more than one language and SDK is being used to develop a Temporal solution, Protobuf and JSON are common choices. This example uses Python dataclasses serialized into JSON. @@ -256,7 +256,7 @@ class MyNexusServiceHandler: -### Register your Nexus Service handler in a Worker +### Register your Nexus Service handler in a Worker {/* #register-a-nexus-service-in-a-worker */} After developing an asynchronous Nexus Operation handler to start a Workflow, the next step is to register your Nexus Service handler in a Worker. At this stage you can pass any arguments you need to your service handler's `__init__` method. @@ -322,7 +322,7 @@ See [hello_nexus/caller/app.py](https://github.com/temporalio/samples-python/blo ## Exceptions in Nexus operations {/* #exceptions-in-nexus-operations */} -Temporal provides general guidance on [Errors in Nexus operations](https://docs.temporal.io/references/failures#errors-in-nexus-operations). +Temporal provides general guidance on [Errors in Nexus operations](/references/failures#errors-in-nexus-operations). In Python, there are three Nexus-specific exception classes: - [`nexusrpc.OperationError`](https://nexus-rpc.github.io/sdk-python/nexusrpc.OperationError.html): this is the exception type you should raise in a Nexus operation to indicate that it has failed according to its own application logic and should not be retried. diff --git a/docs/develop/python/nexus/index.mdx b/docs/develop/python/nexus/index.mdx index c5783cb3dd..87a3ed0b82 100644 --- a/docs/develop/python/nexus/index.mdx +++ b/docs/develop/python/nexus/index.mdx @@ -13,9 +13,14 @@ tags: import * as Components from '@site/src/components'; + + ![Python SDK Banner](/img/assets/banner-python-temporal.png) + + ## Temporal Nexus - [Quickstart](/develop/python/nexus/quickstart) - [Feature guide](/develop/python/nexus/feature-guide) +- [Standalone Operations](/develop/python/nexus/standalone-operations) diff --git a/docs/develop/python/nexus/quickstart.mdx b/docs/develop/python/nexus/quickstart.mdx index 0c731c0037..e7fa2e31c8 100644 --- a/docs/develop/python/nexus/quickstart.mdx +++ b/docs/develop/python/nexus/quickstart.mdx @@ -16,7 +16,7 @@ tags: hide_table_of_contents: true --- -import { SetupSteps, SetupStep, CodeSnippet } from '@site/src/components/elements/SetupSteps'; +import { SetupSteps, SetupStep, CodeSnippet } from '@site/src/components'; # Nexus Python Quickstart diff --git a/docs/develop/python/nexus/standalone-operations.mdx b/docs/develop/python/nexus/standalone-operations.mdx new file mode 100644 index 0000000000..d74beaebcb --- /dev/null +++ b/docs/develop/python/nexus/standalone-operations.mdx @@ -0,0 +1,233 @@ +--- +id: standalone-operations +title: Standalone Nexus Operations - Python SDK +sidebar_label: Standalone Operations +toc_max_heading_level: 4 +keywords: + - standalone nexus operation + - nexus operation execution + - execute nexus operation + - nexus operation handle + - list nexus operations + - count nexus operations + - python sdk +tags: + - Nexus + - Temporal Client + - Python SDK + - Temporal SDKs +description: Execute Nexus Operations independently without a Workflow using the Temporal Python SDK. +--- + +import { ReleaseNoteHeader } from '@site/src/components'; + + + Requires Python SDK `1.30.0` or above. All APIs are experimental and may be subject to backwards-incompatible changes. + + +[Standalone Nexus Operations](/standalone-nexus-operation) let you run Nexus Operation Executions independently, without +being orchestrated by a Workflow. Instead of calling a Nexus Operation from within a Workflow Definition using +`workflow.create_nexus_client()`, you execute a Standalone Nexus Operation directly from a Nexus Client created using +`client.create_nexus_client()`. + +Standalone Nexus Operations use the same Nexus Service contract, Operation handlers, and Worker setup as +Workflow-driven Operations — only the execution path differs. See the [Nexus feature guide](/develop/python/nexus/feature-guide) for details on +[defining a Service contract](/develop/python/nexus/feature-guide#define-nexus-service-contract), +[developing Operation handlers](/develop/python/nexus/feature-guide#develop-nexus-service-operation-handlers), and +[registering a Service in a Worker](/develop/python/nexus/feature-guide#register-a-nexus-service-in-a-worker). + +This page focuses on the client-side APIs that are unique to Standalone Nexus Operations: + +- [Execute a Standalone Nexus Operation](#execute-operation) +- [Start a Standalone Nexus Operation and Wait for the Result](#get-operation-result) +- [List Standalone Nexus Operations](#list-operations) +- [Count Standalone Nexus Operations](#count-operations) +- [Run Standalone Nexus Operations with Temporal Cloud](#run-standalone-nexus-operations-temporal-cloud) + +:::note +This documentation uses source code from the +[Python Nexus Standalone sample](https://github.com/temporalio/samples-python/tree/main/nexus_standalone_operations). + +::: + +## Prerequisites {/* #prerequisites */} + +Standalone Nexus Operations are at Pre-release and require a special Temporal CLI build. + +### 1. Install and verify the Pre-release Temporal CLI {/* #install-cli */} + +The `temporal nexus operation` commands require a Pre-release build of the Temporal CLI. See +[Temporal CLI support](/standalone-nexus-operation#temporal-cli-support) for the platform downloads, +then verify: + +```bash +./temporal --version +# temporal version 1.7.4-standalone-nexus-operations +``` + +Run it as `./temporal` from the directory where you extracted it. The standard `brew install temporal` +build does not include Standalone Nexus Operation support during Pre-release. + +### 2. Start a local dev server {/* #enable-dev-server */} + +The Pre-release dev server enables Standalone Nexus Operations by default — no dynamic config is +required. Start it with the caller and handler Namespaces pre-created: + +```bash +./temporal server start-dev \ + --namespace my-caller-namespace \ + --namespace my-handler-namespace +``` + +The starter and Worker connect to two different Namespaces (a caller Namespace and a handler +Namespace), mirroring how Nexus crosses Namespace boundaries. + +To run the examples on this page against the [Python sample](https://github.com/temporalio/samples-python/tree/main/nexus_standalone_operations), +create a Nexus Endpoint that routes to the handler Namespace and the Worker's Task Queue: + +```bash +./temporal operator nexus endpoint create \ + --name my-nexus-endpoint \ + --target-namespace my-handler-namespace \ + --target-task-queue nexus-handler-queue +``` + +Start the sample Worker in the handler Namespace: + +```bash +TEMPORAL_NAMESPACE=my-handler-namespace uv run nexus_standalone_operations/worker.py +``` + +Run the starter in the caller Namespace (from a separate terminal): + +```bash +TEMPORAL_NAMESPACE=my-caller-namespace uv run nexus_standalone_operations/starter.py +``` + +## Execute a Standalone Nexus Operation {/* #execute-operation */} + +To execute a Standalone Nexus Operation, first create a +[`NexusClient`](https://python.temporal.io/temporalio.client.NexusClient.html) using `client.create_nexus_client()`, bound to a +specific Nexus Endpoint and Service. The endpoint must be pre-created on the server. Then call `start_operation()` or `execute_operation()` from application code (for example, a starter program), not from inside a Workflow Definition. + +`execute_operation` waits for the Operation to complete and returns the result. +Both methods require `id`. `schedule_to_close_timeout` is optional and defaults to the maximum allowed by the Temporal server. + +```python +nexus_client = client.create_nexus_client( + service=MyNexusService, endpoint=ENDPOINT_NAME +) + +# Await the result of the operation immediately. +echo_result = await nexus_client.execute_operation( + MyNexusService.echo, + EchoInput(message="hello"), + id=f"echo-{uuid.uuid4()}", + schedule_to_close_timeout=timedelta(seconds=10), +) +``` + +See the full +[starter sample](https://github.com/temporalio/samples-python/blob/main/nexus_standalone_operations/starter.py) +for a complete example that executes both synchronous and asynchronous Operations, gets their results, and lists and +counts Operations. + +Or use the Temporal CLI to execute a Standalone Nexus Operation: + +```bash +./temporal nexus operation execute \ + --namespace my-caller-namespace \ + --endpoint my-nexus-endpoint \ + --service MyNexusService \ + --operation echo \ + --operation-id my-echo-op \ + --input '{"message":"hello"}' +``` + +## Start a Standalone Nexus Operation and Wait for the Result {/* #get-operation-result */} + +`start_operation` returns a [`NexusOperationHandle`](https://python.temporal.io/temporalio.client.NexusOperationHandle.html). +Use `NexusOperationHandle.result()` to wait until the Operation completes and retrieve its result. This works for both +synchronous and asynchronous Operations. + +```python +# Start an operation and get a NexusOperationHandle +handle = await nexus_client.start_operation( + MyNexusService.hello, + HelloInput(name="World"), + id=f"hello-{uuid.uuid4()}", + schedule_to_close_timeout=timedelta(seconds=10), +) +# Await the result +try: + hello_result = await handle.result() + print(hello_result) +except err: + print(err) + raise +``` + +If the Operation completed successfully, the result is returned. If the Operation failed, the failure is raised as an error. + +Or use the Temporal CLI to wait for a result by Operation ID: + +```bash +./temporal nexus operation result --namespace my-caller-namespace --operation-id my-echo-op +``` + +## List Standalone Nexus Operations {/* #list-operations */} + +Use [`client.list_nexus_operations()`](https://python.temporal.io/temporalio.client.Client.html#list_nexus_operations) to list Standalone Nexus +Operation Executions that match a [List Filter](/list-filter) query. The result contains an iterator that yields +operation metadata entries. + +Note that `list_nexus_operations` is called on the base `client.Client`, not on the `NexusClient`. + +```python +query = f'Endpoint = "{ENDPOINT_NAME}"' +async for op in client.list_nexus_operations(query): + print( + f" OperationId: {op.operation_id},", + f" Operation: {op.operation},", + f" Status: {op.status.name}", + ) +``` + +The `query` parameter accepts [List Filter](/list-filter) syntax. For example, +`"Endpoint = 'my-endpoint' AND Status = 'Running'"`. + +Or use the Temporal CLI: + +```bash +./temporal nexus operation list --namespace my-caller-namespace --query 'Endpoint = "my-nexus-endpoint"' +``` + +## Count Standalone Nexus Operations {/* #count-operations */} + +Use [`client.count_nexus_operations()`](https://python.temporal.io/temporalio.client.Client.html#count_nexus_operations) to count Standalone Nexus +Operation Executions that match a [List Filter](/list-filter) query. + +Note that `count_nexus_operations` is called on the base `client.Client`, not on the `NexusClient`. + +```python +query = f'Endpoint = "{ENDPOINT_NAME}"' +count = await client.count_nexus_operations(query) +print(f"Total Nexus operations: {count.count}") +``` + +Or use the Temporal CLI: + +```bash +./temporal nexus operation count --namespace my-caller-namespace --query 'Endpoint = "my-nexus-endpoint"' +``` + +## Run Standalone Nexus Operations with Temporal Cloud {/* #run-standalone-nexus-operations-temporal-cloud */} + +The code samples referenced on this page use [`ClientConfig.load_client_connect_config()`](https://python.temporal.io/temporalio.envconfig.ClientConfig.html#load_client_connect_config), so the same code +works against Temporal Cloud — just configure the connection via environment variables or a TOML +profile. No code changes are needed. + +For full details on connecting to Temporal Cloud, including Namespace creation, Nexus Endpoint +setup, certificate generation, and authentication options, see +[Make Nexus calls across Namespaces in Temporal Cloud](/develop/python/nexus/feature-guide#nexus-calls-across-namespaces-temporal-cloud) +and [Connect to Temporal Cloud](/develop/python/client/temporal-client#connect-to-temporal-cloud). diff --git a/docs/develop/python/platform/index.mdx b/docs/develop/python/platform/index.mdx index 2d5c114cf2..80b03ba25c 100644 --- a/docs/develop/python/platform/index.mdx +++ b/docs/develop/python/platform/index.mdx @@ -13,8 +13,12 @@ tags: import * as Components from '@site/src/components'; + + ![Python SDK Banner](/img/assets/banner-python-temporal.png) + + ## Platform - [Observability](/develop/python/platform/observability) diff --git a/docs/develop/python/platform/observability.mdx b/docs/develop/python/platform/observability.mdx index de866471e1..ab4478c1bd 100644 --- a/docs/develop/python/platform/observability.mdx +++ b/docs/develop/python/platform/observability.mdx @@ -24,7 +24,7 @@ tags: - Temporal SDKs --- -import { ViewSourceCodeNotice } from '@site/src/components/elements/ViewSourceCodeNotice'; +import { ViewSourceCodeNotice } from '@site/src/components'; The observability section of the Temporal Developer's guide covers the many ways to view the current state of your [Temporal Application](/temporal#temporal-application)—that is, ways to view which [Workflow Executions](/workflow-execution) are tracked by the [Temporal Platform](/temporal#temporal-platform) and the state of any specified Workflow Execution, either currently or at points of an execution. @@ -37,8 +37,6 @@ This section covers features related to viewing the state of the application, in ## Emit metrics {/* #metrics */} -**How to emit metrics** - Each Temporal SDK is capable of emitting an optional set of metrics from either the Client or the Worker process. For a complete list of metrics capable of being emitted, see the [SDK metrics reference](/references/sdk-metrics). @@ -61,8 +59,6 @@ my_client = await Client.connect("my.temporal.host:7233", runtime=new_runtime) ## Set up tracing {/* #tracing */} -**How to set up tracing** - Tracing allows you to view the call graph of a Workflow along with its Activities and any Child Workflows. Temporal Web's tracing capabilities mainly track Activity Execution within a Temporal context. If you need custom tracing specific for your use case, you should make use of context propagation to add tracing logic accordingly. @@ -87,25 +83,7 @@ An appropriate logging level depends on your specific needs. During development or troubleshooting, you might use debug or even trace. In production, you might use info or warn to avoid excessive log volume. -The logger supports the following logging levels: - -| Level | Use | -| ------- | --------------------------------------------------------------------------------------------------------- | -| `TRACE` | The most detailed level of logging, used for very fine-grained information. | -| `DEBUG` | Detailed information, typically useful for debugging purposes. | -| `INFO` | General information about the application's operation. | -| `WARN` | Indicates potentially harmful situations or minor issues that don't prevent the application from working. | -| `ERROR` | Indicates error conditions that might still allow the application to continue running. | - -The Temporal SDK core normally uses `WARN` as its default logging level. - -**How to log from a Workflow** - -Send logs and errors to a logging service, so that when things go wrong, you can see what happened. - -The SDK core uses `WARN` for its default logging level. - -You can log from a Workflow using Python's standard library, by importing the logging module `logging`. +You can log from a Workflow using Python's standard library, by importing the logging module `logging`. You can find the log levels supported by the `logging` module in [their official documentation](https://docs.python.org/3/library/logging.html#logging-levels). The Temporal SDK core normally uses `WARN` as its default logging level. Set your logging configuration to a level you want to expose logs to. The following example sets the logging information level to `INFO`. @@ -120,7 +98,7 @@ Then in your Workflow, set your [`logger`](https://python.temporal.io/temporalio ```python # ... - workflow.logger.info("Workflow input parameter: %s" % name) +workflow.logger.info("Workflow input parameter: %s" % name) ``` ### Custom logger {/* #custom-logger */} diff --git a/docs/develop/python/set-up.mdx b/docs/develop/python/set-up.mdx index d18841b86b..a0186fa16b 100644 --- a/docs/develop/python/set-up.mdx +++ b/docs/develop/python/set-up.mdx @@ -14,8 +14,7 @@ tags: hide_table_of_contents: true --- -import { SetupSteps, SetupStep, CodeSnippet } from '@site/src/components/elements/SetupSteps'; -import { CallToAction } from '@site/src/components/elements/CallToAction'; +import { CallToAction, SetupSteps, SetupStep, CodeSnippet } from '@site/src/components'; # Quickstart @@ -107,7 +106,7 @@ Next, you'll configure a local Temporal Service for development. ## Install Temporal CLI The fastest way to get a development version of the Temporal Service running on your local machine is to use -[Temporal CLI](https://docs.temporal.io/cli). +[Temporal CLI](/cli). Choose your operating system to install Temporal CLI. diff --git a/docs/develop/python/workers/index.mdx b/docs/develop/python/workers/index.mdx index 00615cf466..ce85273389 100644 --- a/docs/develop/python/workers/index.mdx +++ b/docs/develop/python/workers/index.mdx @@ -13,8 +13,12 @@ tags: import * as Components from '@site/src/components'; + + ![Python SDK Banner](/img/assets/banner-python-temporal.png) + + ## Workers - [Worker processes](/develop/python/workers/run-worker-process) diff --git a/docs/develop/python/workers/run-process.mdx b/docs/develop/python/workers/run-process.mdx index c7ed853be4..c34e4eb895 100644 --- a/docs/develop/python/workers/run-process.mdx +++ b/docs/develop/python/workers/run-process.mdx @@ -11,7 +11,7 @@ tags: - Worker --- -import { ViewSourceCodeNotice } from '@site/src/components/elements/ViewSourceCodeNotice'; +import { ViewSourceCodeNotice } from '@site/src/components'; ## Run a Worker Process {/* #run-a-dev-worker */} diff --git a/docs/develop/python/workflows/basics.mdx b/docs/develop/python/workflows/basics.mdx index e322089ca3..551857653d 100644 --- a/docs/develop/python/workflows/basics.mdx +++ b/docs/develop/python/workflows/basics.mdx @@ -11,7 +11,7 @@ tags: - Temporal SDKs --- -import { ViewSourceCodeNotice } from '@site/src/components/elements/ViewSourceCodeNotice'; +import { ViewSourceCodeNotice } from '@site/src/components'; ## Develop a basic Workflow {/* #develop-workflows */} diff --git a/docs/develop/python/workflows/child-workflows.mdx b/docs/develop/python/workflows/child-workflows.mdx index fc4b4bdd9a..3491ae0c75 100644 --- a/docs/develop/python/workflows/child-workflows.mdx +++ b/docs/develop/python/workflows/child-workflows.mdx @@ -19,7 +19,7 @@ tags: - Temporal SDKs --- -import { ViewSourceCodeNotice } from '@site/src/components/elements/ViewSourceCodeNotice'; +import { ViewSourceCodeNotice } from '@site/src/components'; This page shows how to do the following: diff --git a/docs/develop/python/workflows/continue-as-new.mdx b/docs/develop/python/workflows/continue-as-new.mdx index 938947cdbc..d1354de608 100644 --- a/docs/develop/python/workflows/continue-as-new.mdx +++ b/docs/develop/python/workflows/continue-as-new.mdx @@ -17,7 +17,7 @@ tags: - Temporal SDKs --- -import { ViewSourceCodeNotice } from '@site/src/components/elements/ViewSourceCodeNotice'; +import { ViewSourceCodeNotice } from '@site/src/components'; This page answers the following questions for Python developers: diff --git a/docs/develop/python/workflows/index.mdx b/docs/develop/python/workflows/index.mdx index 85733b3bb7..2c7a2a7bcf 100644 --- a/docs/develop/python/workflows/index.mdx +++ b/docs/develop/python/workflows/index.mdx @@ -13,8 +13,12 @@ tags: import * as Components from '@site/src/components'; + + ![Python SDK Banner](/img/assets/banner-python-temporal.png) + + ## Workflows - [Workflow basics](/develop/python/workflows/basics) diff --git a/docs/develop/python/workflows/schedules.mdx b/docs/develop/python/workflows/schedules.mdx index 89345f551d..c1c1822d2d 100644 --- a/docs/develop/python/workflows/schedules.mdx +++ b/docs/develop/python/workflows/schedules.mdx @@ -24,7 +24,7 @@ tags: - Temporal SDKs --- -import { ViewSourceCodeNotice } from '@site/src/components/elements/ViewSourceCodeNotice'; +import { ViewSourceCodeNotice } from '@site/src/components'; This page shows how to do the following: @@ -265,7 +265,7 @@ The following example updates the Schedule to use a new argument. :::caution Cron support is not recommended -We recommend using [Schedules](https://docs.temporal.io/schedule) instead of Cron Jobs. +We recommend using [Schedules](/schedule) instead of Cron Jobs. Schedules were built to provide a better developer experience, including more configuration options and the ability to update or pause running Schedules. ::: diff --git a/docs/develop/python/workflows/timeouts.mdx b/docs/develop/python/workflows/timeouts.mdx index fe7d3b243a..bf13fe5e7a 100644 --- a/docs/develop/python/workflows/timeouts.mdx +++ b/docs/develop/python/workflows/timeouts.mdx @@ -17,7 +17,7 @@ tags: - Temporal SDKs --- -import { ViewSourceCodeNotice } from '@site/src/components/elements/ViewSourceCodeNotice'; +import { ViewSourceCodeNotice } from '@site/src/components'; ## Workflow timeouts {/* #workflow-timeouts */} diff --git a/docs/develop/python/workflows/timers.mdx b/docs/develop/python/workflows/timers.mdx index 2b91a49193..80593914c0 100644 --- a/docs/develop/python/workflows/timers.mdx +++ b/docs/develop/python/workflows/timers.mdx @@ -18,7 +18,7 @@ tags: - Temporal SDKs --- -import { ViewSourceCodeNotice } from '@site/src/components/elements/ViewSourceCodeNotice'; +import { ViewSourceCodeNotice } from '@site/src/components'; A Workflow can set a durable Timer for a fixed time period. In some SDKs, the function is called `sleep()`, and in others, it's called `timer()`. diff --git a/docs/develop/python/workflows/versioning.mdx b/docs/develop/python/workflows/versioning.mdx index 0b2c6ef198..de60155f28 100644 --- a/docs/develop/python/workflows/versioning.mdx +++ b/docs/develop/python/workflows/versioning.mdx @@ -28,7 +28,7 @@ tags: - Temporal SDKs --- -import { ViewSourceCodeNotice } from '@site/src/components/elements/ViewSourceCodeNotice'; +import { ViewSourceCodeNotice } from '@site/src/components'; import { CaptionedImage } from '@site/src/components'; diff --git a/docs/develop/ruby/activities/index.mdx b/docs/develop/ruby/activities/index.mdx index 661ad8e357..a77f2716f0 100644 --- a/docs/develop/ruby/activities/index.mdx +++ b/docs/develop/ruby/activities/index.mdx @@ -13,8 +13,12 @@ tags: import * as Components from '@site/src/components'; + + ![Ruby SDK Banner](/img/assets/banner-ruby-temporal.png) + + ## Activities - [Activity basics](/develop/ruby/activities/basics) diff --git a/docs/develop/ruby/best-practices/error-handling.mdx b/docs/develop/ruby/best-practices/error-handling.mdx index d4ef624f8c..4e0434cd32 100644 --- a/docs/develop/ruby/best-practices/error-handling.mdx +++ b/docs/develop/ruby/best-practices/error-handling.mdx @@ -95,6 +95,6 @@ class SagaWorkflow < Temporalio::Workflow::Definition This works differently in a Workflow than raising exceptions from Activities. In an Activity, any Ruby exceptions or custom exceptions are converted to a Temporal `ApplicationError`. -In a Workflow, any exceptions that are raised other than an explicit Temporal `ApplicationError` will only fail that particular [Workflow Task](https://docs.temporal.io/tasks#workflow-task-execution) and be retried. +In a Workflow, any exceptions that are raised other than an explicit Temporal `ApplicationError` will only fail that particular [Workflow Task](/tasks#workflow-task-execution) and be retried. This includes any typical Ruby `RuntimeError`s that are raised automatically. These errors are treated as bugs that can be corrected with a fixed deployment, rather than a reason for a Temporal Workflow Execution to return unexpectedly. diff --git a/docs/develop/ruby/best-practices/index.mdx b/docs/develop/ruby/best-practices/index.mdx index 974994478b..1dfa5124c2 100644 --- a/docs/develop/ruby/best-practices/index.mdx +++ b/docs/develop/ruby/best-practices/index.mdx @@ -13,8 +13,12 @@ tags: import * as Components from '@site/src/components'; + + ![Ruby SDK Banner](/img/assets/banner-ruby-temporal.png) + + ## Best practices - [Error handling](/develop/ruby/best-practices/error-handling) diff --git a/docs/develop/ruby/client/index.mdx b/docs/develop/ruby/client/index.mdx index 4c68d4c864..fb46d9bf3f 100644 --- a/docs/develop/ruby/client/index.mdx +++ b/docs/develop/ruby/client/index.mdx @@ -13,8 +13,12 @@ tags: import * as Components from '@site/src/components'; + + ![.NET SDK Banner](/img/assets/banner-ruby-temporal.png) + + ## Temporal Client - [Temporal Client](/develop/ruby/client/temporal-client) diff --git a/docs/develop/ruby/client/temporal-client.mdx b/docs/develop/ruby/client/temporal-client.mdx index c4e20480c1..4f3ab696d8 100644 --- a/docs/develop/ruby/client/temporal-client.mdx +++ b/docs/develop/ruby/client/temporal-client.mdx @@ -41,7 +41,7 @@ these options directly in code, load them from **environment variables**, or a * configuration file for secure, repeatable configuration. When you’re running a Temporal Service locally (such as with the -[Temporal CLI dev server](https://docs.temporal.io/cli/command-reference/server#start-dev)), the required options are minimal. If you +[Temporal CLI dev server](/cli/command-reference/server#start-dev)), the required options are minimal. If you don't specify a host/port, most connections default to `127.0.0.1:7233` and the `default` Namespace. diff --git a/docs/develop/ruby/index.mdx b/docs/develop/ruby/index.mdx index ee1e6cc7eb..8588a66832 100644 --- a/docs/develop/ruby/index.mdx +++ b/docs/develop/ruby/index.mdx @@ -18,8 +18,12 @@ import * as Components from '@site/src/components'; + + ![Ruby SDK Banner](/img/assets/banner-ruby-temporal.png) + + ## Install and get started You can find detailed installation instructions for the Ruby SDK in the [Quickstart](/develop/ruby/set-up-local-ruby). @@ -84,9 +88,9 @@ From there, you can dive deeper into any of the Temporal primitives to start bui - [Debugging](/develop/ruby/best-practices/debugging) - [Converters and encryption](/develop/ruby/best-practices/converters-and-encryption) -## Temporal Ruby Technical Resources +## Temporal Ruby technical resources -- [Ruby SDK Quickstart - Setup Guide](https://docs.temporal.io/develop/ruby/set-up-local-ruby) +- [Ruby SDK Quickstart - Setup Guide](/develop/ruby/set-up-local-ruby) - [Ruby SDK Code Samples](https://github.com/temporalio/samples-ruby) - [Ruby API Documentation](https://ruby.temporal.io/) - [Ruby SDK GitHub](https://github.com/temporalio/sdk-ruby) diff --git a/docs/develop/ruby/platform/index.mdx b/docs/develop/ruby/platform/index.mdx index cfbcc69de8..8fed7ccccf 100644 --- a/docs/develop/ruby/platform/index.mdx +++ b/docs/develop/ruby/platform/index.mdx @@ -13,8 +13,12 @@ tags: import * as Components from '@site/src/components'; + + ![Ruby SDK Banner](/img/assets/banner-ruby-temporal.png) + + ## Platform - [Observability](/develop/ruby/platform/observability) diff --git a/docs/develop/ruby/platform/observability.mdx b/docs/develop/ruby/platform/observability.mdx index 5a58d3514f..fcefd3e6b5 100644 --- a/docs/develop/ruby/platform/observability.mdx +++ b/docs/develop/ruby/platform/observability.mdx @@ -88,16 +88,8 @@ Spans are created and serialized through the server to give one trace for a Work Logging enables you to capture and persist important execution details from your Workflow and Activity code. -Logging levels typically include: +Logging uses the Ruby standard logging APIs. You can find the log levels supported in [their official documentation](https://ruby-doc.org/stdlib-2.4.0/libdoc/logger/rdoc/Logger.html). The Temporal SDK core normally uses `WARN` as its default logging level. -| Level | Use | -| ------- | --------------------------------------------------------------------------------------------------------- | -| `DEBUG` | Detailed information, typically useful for debugging purposes. | -| `INFO` | General information about the application's operation. | -| `WARN` | Indicates potentially harmful situations or minor issues that don't prevent the application from working. | -| `ERROR` | Indicates error conditions that might still allow the application to continue running. | - -Logging uses the Ruby standard logging APIs. The `logger` can be set when connecting a client. The following example shows logging on the console and sets the level to `INFO`. diff --git a/docs/develop/ruby/set-up.mdx b/docs/develop/ruby/set-up.mdx index c1a64df3d8..5ffc3e45e6 100644 --- a/docs/develop/ruby/set-up.mdx +++ b/docs/develop/ruby/set-up.mdx @@ -15,8 +15,7 @@ tags: hide_table_of_contents: true --- -import { SetupSteps, SetupStep, CodeSnippet } from '@site/src/components/elements/SetupSteps'; -import { CallToAction } from '@site/src/components/elements/CallToAction'; +import { CallToAction, SetupSteps, SetupStep, CodeSnippet } from '@site/src/components'; # Quickstart @@ -123,7 +122,7 @@ Follow the steps to create a directory, initialize the project with a `Gemfile`, ## Install Temporal CLI The fastest way to get a development version of the Temporal Service running on your local machine is to use -[Temporal CLI](https://docs.temporal.io/cli). +[Temporal CLI](/cli). Choose your operating system to install Temporal CLI. diff --git a/docs/develop/ruby/workers/index.mdx b/docs/develop/ruby/workers/index.mdx index e47a4fd2c4..3efb038267 100644 --- a/docs/develop/ruby/workers/index.mdx +++ b/docs/develop/ruby/workers/index.mdx @@ -13,8 +13,12 @@ tags: import * as Components from '@site/src/components'; + + ![Ruby SDK Banner](/img/assets/banner-ruby-temporal.png) + + ## Workers - [Worker processes](/develop/ruby/workers/run-worker-process) diff --git a/docs/develop/ruby/workflows/basics.mdx b/docs/develop/ruby/workflows/basics.mdx index 6a022a139f..afbfe9750d 100644 --- a/docs/develop/ruby/workflows/basics.mdx +++ b/docs/develop/ruby/workflows/basics.mdx @@ -85,7 +85,7 @@ end ## Workflow logic requirements {/* #workflow-logic-requirements */} -Temporal Workflows [must be deterministic](https://docs.temporal.io/workflows#deterministic-constraints), which includes +Temporal Workflows [must be deterministic](/workflow-definition#deterministic-constraints), which includes Ruby Workflows. This means there are several things Workflows cannot do such as: - Perform IO (network, disk, stdio, etc) @@ -97,4 +97,4 @@ Ruby Workflows. This means there are several things Workflows cannot do such as: To prevent illegal Workflow calls, a call tracer is put on the Workflow thread that raises an exception if any illegal calls are made. -Which calls are illegal is configurable in the Worker options. \ No newline at end of file +Which calls are illegal is configurable in the Worker options. diff --git a/docs/develop/ruby/workflows/index.mdx b/docs/develop/ruby/workflows/index.mdx index 2d0ba98833..14bbb367f3 100644 --- a/docs/develop/ruby/workflows/index.mdx +++ b/docs/develop/ruby/workflows/index.mdx @@ -13,8 +13,12 @@ tags: import * as Components from '@site/src/components'; + + ![Ruby SDK Banner](/img/assets/banner-ruby-temporal.png) + + ## Workflows - [Workflow basics](/develop/ruby/workflows/basics) diff --git a/docs/develop/rust/activities/index.mdx b/docs/develop/rust/activities/index.mdx index ea12cf6bbf..7eb3379018 100644 --- a/docs/develop/rust/activities/index.mdx +++ b/docs/develop/rust/activities/index.mdx @@ -14,8 +14,12 @@ tags: import * as Components from '@site/src/components'; + + ![Rust SDK Banner](/img/assets/banner-rust-temporal.png) + + ## Activities - [Activity basics](/develop/rust/activities/basics) diff --git a/docs/develop/rust/activities/timeouts.mdx b/docs/develop/rust/activities/timeouts.mdx index 6d497951a4..3a9e38e51c 100644 --- a/docs/develop/rust/activities/timeouts.mdx +++ b/docs/develop/rust/activities/timeouts.mdx @@ -25,7 +25,7 @@ The following timeouts are available in Activity options: - [Schedule-To-Close Timeout](/encyclopedia/detecting-activity-failures#schedule-to-close-timeout): the maximum amount of time allowed for the overall [Activity Execution](/activity-execution). - [Start-To-Close Timeout](/encyclopedia/detecting-activity-failures#start-to-close-timeout): the maximum time allowed for a single [Activity Task Execution](/tasks#activity-task-execution). -- [Schedule-To-Start Timeout](/encyclopedia/detecting-activity-failures#schedule-to-start-timeout): the maximum amount of time allowed from when an [Activity Task](/tasks#activity-task) is scheduled to when a [Worker](/workers#worker) starts that Activity Task. +- [Schedule-To-Start Timeout](/encyclopedia/detecting-activity-failures#schedule-to-start-timeout): the maximum amount of time allowed from when an [Activity Task](/tasks#activity-task) is scheduled to when a [Worker](/workers#worker) starts that Activity Task. This timeout is non-retryable by design. An Activity Execution must have either the Start-To-Close Timeout or the Schedule-To-Close Timeout set. Temporal strongly recommends setting a Start-To-Close Timeout because the service relies on it to detect lost Activity Tasks and trigger retries when appropriate. diff --git a/docs/develop/rust/client/index.mdx b/docs/develop/rust/client/index.mdx index 08e2f73ec6..028b034f14 100644 --- a/docs/develop/rust/client/index.mdx +++ b/docs/develop/rust/client/index.mdx @@ -14,8 +14,12 @@ tags: import * as Components from '@site/src/components'; + + ![Rust SDK Banner](/img/assets/banner-rust-temporal.png) + + ## Temporal Client - [Temporal Client](/develop/rust/client/temporal-client) diff --git a/docs/develop/rust/index.mdx b/docs/develop/rust/index.mdx index 7433b0d5e0..a67e3cfa48 100644 --- a/docs/develop/rust/index.mdx +++ b/docs/develop/rust/index.mdx @@ -14,8 +14,12 @@ tags: import * as Components from '@site/src/components'; + + ![Rust SDK Banner](/img/assets/banner-rust-temporal.png) + + ## Install and get started You can find detailed installation instructions for the Rust SDK in the [Quickstart](/develop/rust/quickstart). @@ -55,7 +59,7 @@ Once your local Temporal Service is set up, continue building with the following - [Feature guide](/develop/rust/nexus/feature-guide) -## Temporal Rust Technical Resources +## Temporal Rust technical resources - [Rust SDK Quickstart - Setup Guide](/develop/rust/quickstart) - [Rust API Documentation](https://docs.rs/temporalio-sdk/latest/temporalio_sdk/) diff --git a/docs/develop/rust/nexus/index.mdx b/docs/develop/rust/nexus/index.mdx index 7ef29f45ec..a1d937f50e 100644 --- a/docs/develop/rust/nexus/index.mdx +++ b/docs/develop/rust/nexus/index.mdx @@ -14,8 +14,12 @@ tags: import * as Components from '@site/src/components'; + + ![Rust SDK Banner](/img/assets/banner-rust-temporal.png) + + ## Temporal Nexus - [Feature guide](/develop/rust/nexus/feature-guide) diff --git a/docs/develop/rust/quickstart.mdx b/docs/develop/rust/quickstart.mdx index e67ddfe8cb..eb50df12b4 100644 --- a/docs/develop/rust/quickstart.mdx +++ b/docs/develop/rust/quickstart.mdx @@ -14,8 +14,7 @@ tags: hide_table_of_contents: true --- -import { SetupSteps, SetupStep, CodeSnippet } from '@site/src/components/elements/SetupSteps'; -import { CallToAction } from '@site/src/components/elements/CallToAction'; +import { CallToAction, SetupSteps, SetupStep, CodeSnippet } from '@site/src/components'; import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; @@ -135,7 +134,7 @@ sudo mv temporal /usr/local/bin ## Install Temporal CLI and start the development server The fastest way to get a development version of the Temporal Service running on your local machine is to use -[Temporal CLI](https://docs.temporal.io/cli). +[Temporal CLI](/cli). Choose your operating system to install Temporal CLI: diff --git a/docs/develop/rust/workers/index.mdx b/docs/develop/rust/workers/index.mdx index 6be9323510..3f164b4e9e 100644 --- a/docs/develop/rust/workers/index.mdx +++ b/docs/develop/rust/workers/index.mdx @@ -14,8 +14,12 @@ tags: import * as Components from '@site/src/components'; + + ![Rust SDK Banner](/img/assets/banner-rust-temporal.png) + + ## Workers - [Worker processes](/develop/rust/workers/worker-process) diff --git a/docs/develop/rust/workflows/basics.mdx b/docs/develop/rust/workflows/basics.mdx index 5f9f0bb650..fceb138a60 100644 --- a/docs/develop/rust/workflows/basics.mdx +++ b/docs/develop/rust/workflows/basics.mdx @@ -26,7 +26,7 @@ A Workflow is defined by: ```rust use temporalio_macros::{workflow, workflow_methods}; -use temporalio_sdk::{WorkflowResult, WorkflowContextView}; +use temporalio_sdk::{WorkflowResult, WorkflowContextView, WorkflowContext}; #[workflow] pub struct GreetingWorkflow { @@ -41,7 +41,7 @@ impl GreetingWorkflow { } #[run] - async fn run(ctx: &mut WorkflowContext) -> WorkflowResult { + pub async fn run(ctx: &mut WorkflowContext) -> WorkflowResult { let name = ctx.state(|s| s.name.clone()); Ok(format!("Hello, {}!", name)) } @@ -91,7 +91,7 @@ impl WorkflowRunSeesWorkflowInitWorkflow { } #[run] - async fn get_greeting( + pub async fn get_greeting( ctx: &mut WorkflowContext, _workflow_input: MyWorkflowInput, ) -> WorkflowResult { @@ -108,13 +108,14 @@ impl WorkflowRunSeesWorkflowInitWorkflow { The `#[run]` method is required and contains the main Workflow logic. It: - Must be `async` +- Must be `public` - Receives a mutable `WorkflowContext` - Returns `WorkflowResult` where T is the Workflow return type - Executes exactly once per Workflow execution ```rust #[run] -async fn run(ctx: &mut WorkflowContext) -> WorkflowResult { +pub async fn run(ctx: &mut WorkflowContext) -> WorkflowResult { // Execute activities, timers, child workflows, etc. let result = ctx.start_activity( MyActivities::greet, @@ -158,7 +159,7 @@ impl ProcessingWorkflow { } #[run] - async fn run(_ctx: &mut WorkflowContext) -> WorkflowResult { + pub async fn run(_ctx: &mut WorkflowContext) -> WorkflowResult { // Use the initialized state Ok("Processing complete".to_string()) } @@ -241,7 +242,7 @@ The Rust SDK provides: use std::time::Duration; #[run] -async fn run(ctx: &mut WorkflowContext) -> WorkflowResult { +pub async fn run(ctx: &mut WorkflowContext) -> WorkflowResult { // Good - deterministic timer ctx.timer(TimerOptions { duration: Duration::from_secs(60), @@ -267,7 +268,7 @@ Use `ctx.state()` for read-only access and `ctx.state_mut()` for mutable access ```rust #[run] -async fn run(ctx: &mut WorkflowContext) -> WorkflowResult { +pub async fn run(ctx: &mut WorkflowContext) -> WorkflowResult { // Read-only access let name = ctx.state(|s| s.name.clone()); @@ -290,7 +291,7 @@ For application failures, construct an `ApplicationFailure` and convert it into use temporalio_sdk::ApplicationFailure; #[run] -async fn run(ctx: &mut WorkflowContext) -> WorkflowResult { +pub async fn run(ctx: &mut WorkflowContext) -> WorkflowResult { if some_validation_fails { return Err(ApplicationFailure::new("validation_failed: Input is invalid").into()); } diff --git a/docs/develop/rust/workflows/index.mdx b/docs/develop/rust/workflows/index.mdx index d350610b25..b6e49c6bd5 100644 --- a/docs/develop/rust/workflows/index.mdx +++ b/docs/develop/rust/workflows/index.mdx @@ -14,8 +14,12 @@ tags: import * as Components from '@site/src/components'; + + ![Rust SDK Banner](/img/assets/banner-rust-temporal.png) + + ## Workflows - [Workflow basics](/develop/rust/workflows/basics) diff --git a/docs/develop/standalone-activities-interactive-demo.mdx b/docs/develop/standalone-activities-interactive-demo.mdx index 26aaedd7bf..0386f03138 100644 --- a/docs/develop/standalone-activities-interactive-demo.mdx +++ b/docs/develop/standalone-activities-interactive-demo.mdx @@ -17,6 +17,7 @@ import { StandaloneActivityDemo, ReleaseNoteHeader, SdkGuideLinks } from '@site/ Available in [Temporal Cloud](/standalone-activity#temporal-cloud-support) and in the [Temporal CLI](/standalone-activity#temporal-cli-support) v1.7.0 or higher with Temporal Server v1.31.0 or higher. Java SDK support is in [Pre-release](/evaluate/development-production-features/release-stages#pre-release). diff --git a/docs/develop/task-queue-priority-fairness.mdx b/docs/develop/task-queue-priority-fairness.mdx index a5f030d095..32ea26f4ce 100644 --- a/docs/develop/task-queue-priority-fairness.mdx +++ b/docs/develop/task-queue-priority-fairness.mdx @@ -14,8 +14,7 @@ tags: - Priority and Fairness --- -import { EnlargeImage } from '@site/src/components'; -import SdkTabs from '@site/src/components'; +import { EnlargeImage, SdkTabs } from '@site/src/components'; [Task Queue Priority](#task-queue-priority) and [Task Queue Fairness](#task-queue-fairness) are two ways to manage the distribution of work within a Task Queue. Priority allows [Tasks](/tasks) to be executed in Priority order. @@ -64,7 +63,7 @@ temporal workflow start \ You can set priority keys for a Workflow within the SDK like so: - + ```go workflowOptions := client.StartWorkflowOptions{ diff --git a/docs/develop/typescript/activities/basics.mdx b/docs/develop/typescript/activities/basics.mdx index 0a5ac7ebfe..272e912b29 100644 --- a/docs/develop/typescript/activities/basics.mdx +++ b/docs/develop/typescript/activities/basics.mdx @@ -75,7 +75,7 @@ All data returned from an Activity must be serializable. Activity return values are subject to payload size limits in Temporal. The default payload size limit is 2MB, and there is a hard limit of 4MB for any gRPC message size in the Event History transaction -([see Cloud limits here](https://docs.temporal.io/cloud/limits#per-message-grpc-limit)). Keep in mind that all return +([see Cloud limits here](/cloud/limits#per-message-grpc-limit)). Keep in mind that all return values are recorded in a [Workflow Execution Event History](/workflow-execution/event#event-history). In TypeScript, the return value is always a Promise. diff --git a/docs/develop/typescript/activities/index.mdx b/docs/develop/typescript/activities/index.mdx index 208335f316..29a586e6e2 100644 --- a/docs/develop/typescript/activities/index.mdx +++ b/docs/develop/typescript/activities/index.mdx @@ -14,8 +14,12 @@ tags: import * as Components from '@site/src/components'; + + ![TypeScript SDK Banner](/img/assets/banner-typescript-temporal.png) + + ## Activities - [Activity basics](/develop/typescript/activities/basics) diff --git a/docs/develop/typescript/activities/timeouts.mdx b/docs/develop/typescript/activities/timeouts.mdx index 99f17a4d95..02c3782f1c 100644 --- a/docs/develop/typescript/activities/timeouts.mdx +++ b/docs/develop/typescript/activities/timeouts.mdx @@ -33,9 +33,9 @@ Each Activity Timeout controls the maximum duration of a different aspect of an The following Timeouts are available in the Activity Options: -- **[Schedule-To-Close Timeout](/encyclopedia/detecting-activity-failures#schedule-to-close-timeout):** is the maximum amount of time allowed for the entire [Activity Execution](/activity-execution), from when the [Activity Task](/tasks#activity-task) is initially scheduled by the Workflow to when the server receives a successful completion for that Activity Task -- **[Start-To-Close Timeout](/encyclopedia/detecting-activity-failures#start-to-close-timeout):** is the maximum time allowed for a single [Activity Task Execution](/tasks#activity-task-execution), from when the Activity Task Execution gets polled by a [Worker](/workers#worker) to when the server receives a successful completion for that Activity Task -- **[Schedule-To-Start Timeout](/encyclopedia/detecting-activity-failures#schedule-to-start-timeout):** is the maximum amount of time that is allowed from when an [Activity Task](/tasks#activity-task) is initially scheduled by the Workflow to when a [Worker](/workers#worker) polls the Activity Task Execution +- **[Schedule-To-Close Timeout](/encyclopedia/detecting-activity-failures#schedule-to-close-timeout):** is the maximum amount of time allowed for the entire [Activity Execution](/activity-execution), from when the [Activity Task](/tasks#activity-task) is initially scheduled by the Workflow to when the server receives a successful completion for that Activity Task. +- **[Start-To-Close Timeout](/encyclopedia/detecting-activity-failures#start-to-close-timeout):** is the maximum time allowed for a single [Activity Task Execution](/tasks#activity-task-execution), from when the Activity Task Execution gets polled by a [Worker](/workers#worker) to when the server receives a successful completion for that Activity Task. +- **[Schedule-To-Start Timeout](/encyclopedia/detecting-activity-failures#schedule-to-start-timeout):** is the maximum amount of time that is allowed from when an [Activity Task](/tasks#activity-task) is initially scheduled by the Workflow to when a [Worker](/workers#worker) polls the Activity Task Execution. This timeout is non-retryable by design. An Activity Execution must have either the Start-To-Close or the Schedule-To-Close Timeout set. diff --git a/docs/develop/typescript/best-practices/index.mdx b/docs/develop/typescript/best-practices/index.mdx index 34d1798813..e2b83d741a 100644 --- a/docs/develop/typescript/best-practices/index.mdx +++ b/docs/develop/typescript/best-practices/index.mdx @@ -14,8 +14,12 @@ tags: import * as Components from '@site/src/components'; + + ![TypeScript SDK Banner](/img/assets/banner-typescript-temporal.png) + + ## Best practices - [Testing](/develop/typescript/best-practices/testing-suite) diff --git a/docs/develop/typescript/client/index.mdx b/docs/develop/typescript/client/index.mdx index 0e4ab6b907..343390ebd7 100644 --- a/docs/develop/typescript/client/index.mdx +++ b/docs/develop/typescript/client/index.mdx @@ -14,8 +14,12 @@ tags: import * as Components from '@site/src/components'; + + ![TypeScript SDK Banner](/img/assets/banner-typescript-temporal.png) + + ## Temporal Client - [Temporal Client](/develop/typescript/client/temporal-client) diff --git a/docs/develop/typescript/client/namespaces.mdx b/docs/develop/typescript/client/namespaces.mdx index 0053703728..452321cba9 100644 --- a/docs/develop/typescript/client/namespaces.mdx +++ b/docs/develop/typescript/client/namespaces.mdx @@ -21,7 +21,7 @@ Use Namespaces to isolate your Workflow Executions according to your needs. For example, you can use Namespaces to match the development lifecycle by having separate `dev` and `prod` Namespaces. You could also use them to ensure Workflow Executions between different teams never communicate - such as ensuring that the `teamA` Namespace never impacts the `teamB` Namespace. -On Temporal Cloud, use the [Temporal Cloud UI](/cloud/namespaces#create-a-namespace) to create and manage a Namespace from the UI, or [tcld commands](https://docs.temporal.io/cloud/tcld/namespace/) to manage Namespaces from the command-line interface. +On Temporal Cloud, use the [Temporal Cloud UI](/cloud/namespaces#create-a-namespace) to create and manage a Namespace from the UI, or [tcld commands](/cloud/tcld/namespace/) to manage Namespaces from the command-line interface. On self-hosted Temporal Service, you can register and manage your Namespaces using the Temporal CLI (recommended) or programmatically using APIs. Note that these APIs and Temporal CLI commands will not work with Temporal Cloud. @@ -34,7 +34,7 @@ You must register a Namespace with the Temporal Service before setting it in the Registering a Namespace creates a Namespace on the Temporal Service or Temporal Cloud. -On Temporal Cloud, use the [Temporal Cloud UI](/cloud/namespaces#create-a-namespace) or [tcld commands](https://docs.temporal.io/cloud/tcld/namespace/) to create Namespaces. +On Temporal Cloud, use the [Temporal Cloud UI](/cloud/namespaces#create-a-namespace) or [tcld commands](/cloud/tcld/namespace/) to create Namespaces. On self-hosted Temporal Service, you can register your Namespaces using the Temporal CLI (recommended) or programmatically using APIs. Note that these APIs and Temporal CLI commands will not work with Temporal Cloud. @@ -45,7 +45,7 @@ Use a custom [Authorizer](/self-hosted-guide/security#authorizer-plugin) on your You can get details for your Namespaces, update Namespace configuration, and deprecate or delete your Namespaces. -On Temporal Cloud, use the [Temporal Cloud UI](/cloud/namespaces#create-a-namespace) or [tcld commands](https://docs.temporal.io/cloud/tcld/namespace/) to manage Namespaces. +On Temporal Cloud, use the [Temporal Cloud UI](/cloud/namespaces#create-a-namespace) or [tcld commands](/cloud/tcld/namespace/) to manage Namespaces. On self-hosted Temporal Service, you can manage your registered Namespaces using the Temporal CLI (recommended) or programmatically using APIs. Note that these APIs and Temporal CLI commands will not work with Temporal Cloud. diff --git a/docs/develop/typescript/index.mdx b/docs/develop/typescript/index.mdx index 155e343caa..f14efd7b02 100644 --- a/docs/develop/typescript/index.mdx +++ b/docs/develop/typescript/index.mdx @@ -15,8 +15,12 @@ tags: import * as Components from '@site/src/components'; + + ![TypeScript SDK Banner](/img/assets/banner-typescript-temporal.png) + + ## Install and get started You can find detailed installation instructions for the TypeScript SDK in the [Quickstart](/develop/typescript/set-up-your-local-typescript). @@ -66,6 +70,7 @@ Once your local Temporal Service is set up, continue building with the following - [Quickstart](/develop/typescript/nexus/quickstart) - [Feature guide](/develop/typescript/nexus/feature-guide) +- [Standalone Operations](/develop/typescript/nexus/standalone-operations) ## [Platform](/develop/typescript/platform) @@ -81,10 +86,13 @@ Once your local Temporal Service is set up, continue building with the following ## [Integrations](/develop/typescript/integrations) -- [Vercel AI SDK Integration](/develop/typescript/integrations/ai-sdk) -- [OpenAI Agents SDK Integration](/develop/typescript/integrations/openai-agents) +- [Braintrust integration](https://www.braintrust.dev/docs/integrations/sdk-integrations/temporal#typescript) +- [Mastra integration](https://mastra.ai/guides/deployment/temporal) +- [OpenAI Agents SDK integration](/develop/typescript/integrations/openai-agents) +- [Parseable integration](https://github.com/parseablehq/temporal-plugin/blob/main/INTEGRATION.md) +- [Vercel AI SDK integration](/develop/typescript/integrations/ai-sdk) -## Temporal TypeScript Technical Resources +## Temporal TypeScript technical resources - [TypeScript SDK Quickstart - Setup Guide](/develop/typescript/set-up-your-local-typescript) - [TypeScript API Documentation](https://typescript.temporal.io) diff --git a/docs/develop/typescript/integrations/langsmith.mdx b/docs/develop/typescript/integrations/langsmith.mdx new file mode 100644 index 0000000000..201cf0fedc --- /dev/null +++ b/docs/develop/typescript/integrations/langsmith.mdx @@ -0,0 +1,343 @@ +--- +id: langsmith +title: LangSmith integration +sidebar_label: LangSmith +toc_max_heading_level: 2 +keywords: + - ai + - agents + - langsmith + - observability + - tracing +tags: + - LangSmith + - TypeScript SDK + - Temporal SDKs +description: Add LangSmith tracing to TypeScript Workflows using the Temporal TypeScript SDK. +--- + +import { ReleaseNoteHeader } from '@site/src/components'; + + + +Temporal's LangSmith integration lets you trace AI agent Workflows in [LangSmith](https://smith.langchain.com/) +alongside every LLM call, tool execution, and Temporal operation. + +Temporal gives your agent code [durable execution](/temporal#durable-execution). LangSmith adds +the observability side, so you can inspect LLM inputs and outputs, follow a request from the Client through to the +model, and compare runs over time. + +The `LangSmithPlugin` is what connects the two. It propagates trace context across Temporal boundaries +(Workflow → Activity → Child Workflow) so that runs started on the Client nest correctly under Workflow and Activity +runs on the Worker. It can also emit LangSmith runs for the Temporal operations themselves: Workflow executions, Activity +executions, Signals, and Updates. + +All code snippets in this guide are taken from the +[LangSmith samples](https://github.com/temporalio/samples-typescript/tree/main/langsmith). Refer to the samples for +complete code. + +## Prerequisites + +- This guide assumes you are already familiar with LangSmith. If you aren't, refer to the + [LangSmith documentation](https://docs.smith.langchain.com/) for more details. +- If you are new to Temporal, we recommend reading [Understanding Temporal](/evaluate/understanding-temporal) or taking + the [Temporal 101](https://learn.temporal.io/courses/temporal_101/) course. +- Ensure you have set up your local development environment by following the + [Set up your local with the TypeScript SDK](/develop/typescript/set-up-your-local-typescript) guide. When you are + done, leave the Temporal Development Server running if you want to test your code locally. + +## Configure Workers to use LangSmith + +Workers execute the code that defines your Workflows and Activities. To trace Workflow and Activity execution in +LangSmith, add the `LangSmithPlugin` to your Worker. + +Follow the steps below to configure your Worker. + +1. Install the `@temporalio/langsmith` package along with `langsmith`, which is a peer dependency. + + ```bash + npm install @temporalio/langsmith langsmith + ``` + +2. Construct a LangSmith Client and a single shared `LangSmithPlugin`, then pass the plugin to your Worker. + + ```ts {16} + import { NativeConnection, Worker } from '@temporalio/worker'; + import { Client as LangSmithClient } from 'langsmith'; + import { LangSmithPlugin } from '@temporalio/langsmith'; + import * as activities from './activities'; + + const connection = await NativeConnection.connect({ address: 'localhost:7233' }); + + const langsmith = new LangSmithClient(); // reads LANGSMITH_API_KEY from the env + const plugin = new LangSmithPlugin({ client: langsmith, addTemporalRuns: true }); + + const worker = await Worker.create({ + connection, + taskQueue: 'langsmith', + workflowsPath: require.resolve('./workflows'), + activities, + plugins: [plugin], + }); + + await worker.run(); + ``` + + Reuse this one instance on both the Worker and Client — see [Compose with other plugins](#compose-with-other-plugins). + +3. Run the Worker with LangSmith tracing enabled. Ensure the Worker process has access to your LangSmith API key. + + ```bash + export LANGSMITH_TRACING=true + export LANGSMITH_API_KEY="your-api-key" + ``` + + Tracing is **off by default**, matching the `langsmith` library. With no tracing flag set to `true`, the plugin + emits nothing. + + The plugin reads the same flags `langsmith` itself uses — `LANGSMITH_TRACING` (or `LANGSMITH_TRACING_V2`) and their + `LANGCHAIN_` aliases. + +## Configure Clients to use LangSmith + +In TypeScript the Client and the Worker are configured independently, so add a `LangSmithPlugin` to your +`Client` too. This links client-side operations, like starting a Workflow, to the Workflows they trigger. + +```ts {4} +import { Connection, Client } from '@temporalio/client'; + +const connection = await Connection.connect(); +const client = new Client({ connection, plugins: [plugin] }); +``` + +Wrap the call that starts your Workflow in a `traceable` so the rest of the trace nests under your own run. + +```ts {3-11} +import { traceable } from 'langsmith/traceable'; + +const pipeline = traceable( + async () => { + return client.workflow.execute(GreetingWorkflow, { + taskQueue: 'langsmith', + workflowId: 'greeting-1', + args: ['hello'], + }); + }, + { name: 'user_pipeline' } +); + +await pipeline(); +``` + +## Trace Activities + +Any non-deterministic work in a Temporal Workflow (LLM calls, tool executions, database queries, external API calls, +and so on) must run inside an Activity. That makes Activities an important place to add LangSmith runs. A `traceable` +from `langsmith/traceable` works unchanged inside an Activity body: the run shows up in LangSmith nested under +`RunActivity:` for the Activity that scheduled it. + + +[langsmith/activity-tracing/src/activities.ts](https://github.com/temporalio/samples-typescript/blob/main/langsmith/activity-tracing/src/activities.ts) +```ts +import { traceable } from 'langsmith/traceable'; + +const callModel = traceable( + async (prompt: string): Promise => { + return `answer to: ${prompt}`; + }, + { name: 'inner_llm_call' }, +); + +export async function answer(prompt: string): Promise { + return callModel(prompt); +} +``` + + +## Trace Workflows + +A `traceable` also works inside a Workflow body, and the plugin keeps it replay-safe: each run is emitted exactly +once and is never duplicated when the Workflow replays its history. + + +[langsmith/workflow-tracing/src/workflows.ts](https://github.com/temporalio/samples-typescript/blob/main/langsmith/workflow-tracing/src/workflows.ts) +```ts +import { traceable } from 'langsmith/traceable'; + +const extractKeyPoints = traceable(async (text: string): Promise => `points:${text}`, { + name: 'extract_key_points', +}); + +const summarize = traceable(async (points: string): Promise => `summary:${points}`, { + name: 'summarize', +}); + +export async function SummarizeWorkflow(text: string): Promise { + const points = await extractKeyPoints(text); + return summarize(points); +} +``` + + +:::note + +Inside a Workflow body, sequential `await inner(...)` nesting is exact. Under `Promise.all(...)` fan-out, or for +`traceable` calls made after an `await` in the same scope, parenting falls back to the Workflow run. This affects only +the visual shape of the trace, never Workflow history or control flow. Activity-side and client-side `traceable` are +unaffected. + +::: + +## Trace Signal and Update handlers + +A `traceable` inside a Signal or Update handler nests under that handler's run, following the same Workflow-body +semantics. Temporal-internal Queries (`__temporal*`, `__stack_trace`) are never traced. + + +[langsmith/message-handlers/src/workflows.ts](https://github.com/temporalio/samples-typescript/blob/main/langsmith/message-handlers/src/workflows.ts) +```ts +import { traceable } from 'langsmith/traceable'; +import { allHandlersFinished, condition, defineSignal, defineUpdate, setHandler } from '@temporalio/workflow'; + +const classifyMessage = traceable(async (text: string): Promise => `intent:${text}`, { + name: 'classify_intent', +}); + +const draftReply = traceable(async (text: string): Promise => `reply:${text}`, { + name: 'draft_reply', +}); + +export const handleMessage = defineSignal<[string]>('handle_message'); +export const composeReply = defineUpdate('compose_reply'); +export const complete = defineSignal('complete'); + +export async function ConversationWorkflow(): Promise { + const log: string[] = []; + let done = false; + + setHandler(handleMessage, async (text: string) => { + log.push(await classifyMessage(text)); + }); + + setHandler(composeReply, async (text: string) => { + const reply = await draftReply(text); + log.push(reply); + return reply; + }); + + setHandler(complete, () => { + done = true; + }); + + await condition(() => done && allHandlersFinished()); + return log; +} +``` + + +## Trace multi-step agents across Activities and Child Workflows + +A single trace threads through a multi-step agent that calls Activities and a Child Workflow. The parent Workflow +gathers facts and writes a report in Activities, then delegates review to a Child Workflow, and every run nests under +the same trace. + + +[langsmith/agent-pipeline/src/workflows.ts](https://github.com/temporalio/samples-typescript/blob/main/langsmith/agent-pipeline/src/workflows.ts) +```ts +import { executeChild, proxyActivities, workflowInfo } from '@temporalio/workflow'; +import type * as activities from './activities'; + +const { gatherFacts, writeReport, reviewReport } = proxyActivities({ + startToCloseTimeout: '1 minute', +}); + +export async function ReviewWorkflow(report: string): Promise { + return reviewReport(report); +} + +export async function ResearchWorkflow(topic: string): Promise { + const facts = await gatherFacts(topic); + const report = await writeReport(facts); + return executeChild(ReviewWorkflow, { + args: [report], + workflowId: `${workflowInfo().workflowId}-review`, + }); +} +``` + + +## Include Temporal operations as runs + +By default (`addTemporalRuns: false`), the plugin only propagates LangSmith context so that your `traceable` runs nest +correctly. It does not create its own runs. + +Set `addTemporalRuns: true` if you want first-class runs for the Temporal operations themselves: Workflow executions, +Activity executions, Signals, Updates, and so on (`StartWorkflow:`, `RunWorkflow:`, `StartActivity:`, `RunActivity:`, +`HandleSignal:`, `HandleUpdate:`). `Start*` and `Run*` pairs appear as siblings: the `Start*` run is emitted by the +side scheduling the operation (for example, the Client), and the `Run*` run is emitted by the side executing it (for +example, the Worker). + +With the plugin configured on both the Client and the Worker, and `addTemporalRuns: true`, a trace for a simple LLM +call looks like this: + +``` +user_pipeline + StartWorkflow:GreetingWorkflow + RunWorkflow:GreetingWorkflow + StartActivity:answer + RunActivity:answer + inner_llm_call +``` + +Without `addTemporalRuns` (the default), only your `traceable` runs appear. Context still propagates, so they nest +correctly under the client-side run: + +``` +user_pipeline + inner_llm_call +``` + +## Plugin options + +| Option | Default | Meaning | +| ----------------- | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| `client` | `new Client()` | The LangSmith `Client` that runs are emitted to. | +| `addTemporalRuns` | `false` | Emit first-class runs for Temporal operations (`StartWorkflow:`, `RunActivity:`, `HandleSignal:`, …) in addition to your `traceable` runs. | +| `projectName` | LangSmith default | Target LangSmith project for emitted runs. | +| `tags` | — | Tags attached to every run the plugin emits. | +| `metadata` | — | Metadata merged into every run the plugin emits. Credential-looking keys are scrubbed before emission. | + +The LangSmith API key is never accepted as a plugin option and never crosses a Temporal boundary. Supply a +pre-constructed `Client` (which reads `LANGSMITH_API_KEY` from the process environment) or let the plugin build a +default client from the environment. + +## Compose with other plugins + +Register observability **first** (outermost) so it observes everything beneath it, then governance, then +agent-framework plugins. + +```ts +const worker = await Worker.create({ + connection, + taskQueue: 'langsmith', + workflowsPath: require.resolve('./workflows'), + activities, + plugins: [ + new LangSmithPlugin({ client: langsmith }), // observability — first + // new GovernancePlugin(...), + // new AgentFrameworkPlugin(...), + ], +}); +``` + +The plugin de-duplicates its own instrumentation, so a Worker built from a plugin-configured Client will not +double-instrument. + +## Samples + +| Sample | Demonstrates | +| --------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | +| [activity-tracing](https://github.com/temporalio/samples-typescript/tree/main/langsmith/activity-tracing) | A `traceable` model call inside an Activity, nested under the Workflow and Activity runs. | +| [workflow-tracing](https://github.com/temporalio/samples-typescript/tree/main/langsmith/workflow-tracing) | Replay-safe `traceable` calls in a Workflow body — emitted once, never duplicated on replay. | +| [agent-pipeline](https://github.com/temporalio/samples-typescript/tree/main/langsmith/agent-pipeline) | A multi-step agent whose trace threads through Activities and a Child Workflow. | +| [message-handlers](https://github.com/temporalio/samples-typescript/tree/main/langsmith/message-handlers) | `traceable` calls inside Signal and Update handlers, nested under each handler's run. | diff --git a/docs/develop/typescript/integrations/openai-agents.mdx b/docs/develop/typescript/integrations/openai-agents.mdx index 00dc09d879..b1e597711b 100644 --- a/docs/develop/typescript/integrations/openai-agents.mdx +++ b/docs/develop/typescript/integrations/openai-agents.mdx @@ -92,7 +92,6 @@ and `session`. A few differences apply for Workflow-safe execution: - `runConfig.model` must be a model name string. The Worker's `modelProvider` resolves it inside the model Activity. - `signal` is not supported. Use Temporal cancellation APIs, such as `CancellationScope`, to cancel Workflow work. -- `runStreamed()` is not currently supported. ### Configure the Worker @@ -239,7 +238,7 @@ const agent = new Agent({ ### Nexus operation tools -Use `nexusOperationAsTool` to expose a [Nexus](https://docs.temporal.io/nexus) Operation as an agent tool. The Workflow +Use `nexusOperationAsTool` to expose a [Nexus](/nexus) Operation as an agent tool. The Workflow starts the Operation through a Nexus client and feeds the stringified result back to the agent. ```typescript @@ -510,6 +509,30 @@ export async function approvalWorkflow(input: ApprovalInput = {}): Promise +[openai-agents/src/streaming/workflows.ts](https://github.com/temporalio/samples-typescript/blob/main/openai-agents/src/streaming/workflows.ts) +```ts +export async function streamingChat(prompt: string): Promise { + new WorkflowStream(); + const agent = new Agent({ name: 'StreamingAgent', instructions: 'You are a helpful assistant.' }); + const result = await new TemporalOpenAIRunner().run(agent, prompt, { stream: true }); + // The external client is the event consumer; the Workflow only drives the run to completion. + for await (const _event of result); + await result.completed; + return result.finalOutput ?? ''; +} +``` + + +Streaming from within the Workflow like this is replay-safe. Subscribe from an external client with `WorkflowStreamClient` from `@temporalio/workflow-streams/client`; see the [`@temporalio/workflow-streams`](https://github.com/temporalio/sdk-typescript/tree/main/contrib/workflow-streams) docs for the subscriber API. + ## Tracing OpenAI Agents SDK tracing works across Client, Workflow, Activity, Nexus, and MCP boundaries. diff --git a/docs/develop/typescript/nexus/feature-guide.mdx b/docs/develop/typescript/nexus/feature-guide.mdx index 256914ec2f..6286183c30 100644 --- a/docs/develop/typescript/nexus/feature-guide.mdx +++ b/docs/develop/typescript/nexus/feature-guide.mdx @@ -90,7 +90,7 @@ Defining a clear contract for the Nexus Service is crucial for smooth communicat In this example, there is a service package that describes the Service and Operation names along with input/output types for caller Workflows to use the Nexus Endpoint. -Each [Temporal SDK includes and uses a default Data Converter](https://docs.temporal.io/dataconversion). +Each [Temporal SDK includes and uses a default Data Converter](/dataconversion). The default data converter encodes payloads in the following order: Null, Byte array, and JSON. In a polyglot environment, that is where more than one language and SDK is being used to develop a Temporal solution, JSON is a common choice. This example uses plain TypeScript objects, serialized into JSON. @@ -336,7 +336,7 @@ Refer to the [complete TypeScript sample](https://github.com/temporalio/samples- ## Exceptions in Nexus operations {/* #exceptions-in-nexus-operations */} -Temporal provides general guidance on [Errors in Nexus operations](https://docs.temporal.io/references/failures#errors-in-nexus-operations). +Temporal provides general guidance on [Errors in Nexus operations](/references/failures#errors-in-nexus-operations). In TypeScript, there are three Nexus-specific exception classes: - `nexus-rpc`'s [`OperationError`](https://nexus-rpc.github.io/sdk-typescript/classes/OperationError.html): this is the exception type you should throw in a Nexus operation to indicate that it has failed according to its own application logic and should not be retried. diff --git a/docs/develop/typescript/nexus/index.mdx b/docs/develop/typescript/nexus/index.mdx index 85352c2695..8db46f2453 100644 --- a/docs/develop/typescript/nexus/index.mdx +++ b/docs/develop/typescript/nexus/index.mdx @@ -18,9 +18,14 @@ import { ReleaseNoteHeader } from '@site/src/components'; featureName="nexus" /> + + ![TypeScript SDK Banner](/img/assets/banner-typescript-temporal.png) + + ## Temporal Nexus - [Quickstart](/develop/typescript/nexus/quickstart) - [Feature guide](/develop/typescript/nexus/feature-guide) +- [Standalone Operations](/develop/typescript/nexus/standalone-operations) diff --git a/docs/develop/typescript/nexus/quickstart.mdx b/docs/develop/typescript/nexus/quickstart.mdx index aa565ed383..ef02921432 100644 --- a/docs/develop/typescript/nexus/quickstart.mdx +++ b/docs/develop/typescript/nexus/quickstart.mdx @@ -16,8 +16,7 @@ tags: hide_table_of_contents: true --- -import { SetupSteps, SetupStep, CodeSnippet } from '@site/src/components/elements/SetupSteps'; -import { ReleaseNoteHeader } from '@site/src/components'; +import { SetupSteps, SetupStep, CodeSnippet, ReleaseNoteHeader } from '@site/src/components'; + Requires TypeScript SDK `v1.20.2` or above. All APIs are experimental and may be subject to backwards-incompatible changes. + + +[Standalone Nexus Operations](/standalone-nexus-operation) let you run Nexus Operation Executions independently, without +being orchestrated by a Workflow. Instead of calling a Nexus Operation from within a Workflow Definition using +`@temporalio/workflow`'s `createNexusServiceClient()`, you execute a Standalone Nexus Operation directly from a Nexus +service client created on the Temporal Client using `client.nexus.createServiceClient()`. + +Standalone Nexus Operations use the same Nexus Service contract, Operation handlers, and Worker setup as +Workflow-driven Operations — only the execution path differs. See the [Nexus feature guide](/develop/typescript/nexus/feature-guide) for details on +[defining a Service contract](/develop/typescript/nexus/feature-guide#define-nexus-service-contract) and +[developing Operation handlers and registering a Service in a Worker](/develop/typescript/nexus/feature-guide#develop-nexus-service-operation-handlers). + +This page focuses on the client-side APIs that are unique to Standalone Nexus Operations: + +- [Execute a Standalone Nexus Operation](#execute-operation) +- [Start a Standalone Nexus Operation and Wait for the Result](#get-operation-result) +- [List Standalone Nexus Operations](#list-operations) +- [Count Standalone Nexus Operations](#count-operations) +- [Run Standalone Nexus Operations with Temporal Cloud](#run-standalone-nexus-operations-temporal-cloud) + +:::note +This documentation uses source code from the +[TypeScript Nexus Standalone sample](https://github.com/temporalio/samples-typescript/tree/main/nexus-standalone-operations). + +::: + +## Prerequisites {/* #prerequisites */} + +Standalone Nexus Operations are at Pre-release and require a special Temporal CLI build. + +### 1. Install and verify the Pre-release Temporal CLI {/* #install-cli */} + +The `temporal nexus operation` commands require a Pre-release build of the Temporal CLI. See +[Temporal CLI support](/standalone-nexus-operation#temporal-cli-support) for the platform downloads, +then verify: + +```bash +./temporal --version +# temporal version 1.7.4-standalone-nexus-operations +``` + +Run it as `./temporal` from the directory where you extracted it. The standard `brew install temporal` +build does not include Standalone Nexus Operation support during Pre-release. + +### 2. Start a local dev server {/* #enable-dev-server */} + +The Pre-release dev server enables Standalone Nexus Operations by default — no dynamic config is +required. Start it with the caller and handler Namespaces pre-created: + +```bash +./temporal server start-dev \ + --namespace my-caller-namespace \ + --namespace my-handler-namespace +``` + +The starter and Worker connect to two different Namespaces (a caller Namespace and a handler +Namespace), mirroring how Nexus crosses Namespace boundaries. + +To run the examples on this page against the [TypeScript sample](https://github.com/temporalio/samples-typescript/tree/main/nexus-standalone-operations), +create a Nexus Endpoint that routes to the handler Namespace and the Worker's Task Queue: + +```bash +./temporal operator nexus endpoint create \ + --name my-nexus-endpoint \ + --target-namespace my-handler-namespace \ + --target-task-queue nexus-handler-queue +``` + +Start the sample Worker in the handler Namespace: + +```bash +TEMPORAL_NAMESPACE=my-handler-namespace npm run worker +``` + +Run the starter in the caller Namespace (from a separate terminal): + +```bash +TEMPORAL_NAMESPACE=my-caller-namespace npm run starter +``` + +## Execute a Standalone Nexus Operation {/* #execute-operation */} + +To execute a Standalone Nexus Operation, first create a [`NexusServiceClient`](https://typescript.temporal.io/api/interfaces/client.NexusServiceClient) using [`client.nexus.createServiceClient()`](https://typescript.temporal.io/api/classes/client.NexusClient#createserviceclient), +bound to a specific Nexus Endpoint and Service. The endpoint must be pre-created on the server. Then call +`startOperation()` or `executeOperation()` from application code (for example, a starter program), not from inside a +Workflow Definition. + +`executeOperation` waits for the Operation to complete and returns the result. +Both methods require `id`. `scheduleToCloseTimeout` is optional and defaults to the maximum allowed by the Temporal server. + +```ts +const nexusClient = client.nexus.createServiceClient({ + endpoint: ENDPOINT_NAME, + service: myNexusService, +}); + +// Await the result of the operation immediately. +const echoResult = await nexusClient.executeOperation( + myNexusService.operations.echo, + { message: 'hello' }, + { + id: `echo-${nanoid()}`, + scheduleToCloseTimeout: '10s', + }, +); +``` + +See the full +[starter sample](https://github.com/temporalio/samples-typescript/blob/main/nexus-standalone-operations/src/starter.ts) +for a complete example that executes both synchronous and asynchronous Operations, gets their results, and lists and +counts Operations. + +Or use the Temporal CLI to execute a Standalone Nexus Operation: + +```bash +./temporal nexus operation execute \ + --namespace my-caller-namespace \ + --endpoint my-nexus-endpoint \ + --service myNexusService \ + --operation echo \ + --operation-id my-echo-op \ + --input '{"message":"hello"}' +``` + +## Start a Standalone Nexus Operation and Wait for the Result {/* #get-operation-result */} + +`startOperation` returns a [`NexusOperationHandle`](https://typescript.temporal.io/api/interfaces/client.NexusOperationHandle). +Use `NexusOperationHandle.result()` to wait until the Operation completes and retrieve its result. This works for both +synchronous and asynchronous Operations. + +```ts +// Start an operation and get a NexusOperationHandle +const handle = await nexusClient.startOperation( + myNexusService.operations.hello, + { name: 'World' }, + { + id: `hello-${nanoid()}`, + scheduleToCloseTimeout: '10s', + }, +); + +// Await the result +const helloResult = await handle.result(); +console.log(helloResult.greeting); +``` + +If the Operation completed successfully, the result is returned. If the Operation failed, the failure is thrown as an error. + +Or use the Temporal CLI to wait for a result by Operation ID: + +```bash +./temporal nexus operation result --namespace my-caller-namespace --operation-id my-echo-op +``` + +## List Standalone Nexus Operations {/* #list-operations */} + +Use [`client.nexus.list()`](https://typescript.temporal.io/api/classes/client.NexusClient#list) to list Standalone Nexus Operation Executions that match a [List Filter](/list-filter) query. +The result is an async iterator that yields operation metadata entries. + +Note that `client.nexus.list()` is called on the base `Client`, not on the `NexusServiceClient`. + +```ts +const query = `Endpoint = "${ENDPOINT_NAME}"`; +for await (const op of client.nexus.list({ query })) { + console.log( + `OperationId: ${op.operationId},`, + `Operation: ${op.operation},`, + `Status: ${op.status}`, + ); +} +``` + +The `query` parameter accepts [List Filter](/list-filter) syntax. For example, +`"Endpoint = 'my-endpoint' AND Status = 'Running'"`. + +Or use the Temporal CLI: + +```bash +./temporal nexus operation list --namespace my-caller-namespace --query 'Endpoint = "my-nexus-endpoint"' +``` + +## Count Standalone Nexus Operations {/* #count-operations */} + +Use [`client.nexus.count()`](https://typescript.temporal.io/api/classes/client.NexusClient#count) to count Standalone Nexus Operation Executions that match a [List Filter](/list-filter) query. + +Note that `client.nexus.count()` is called on the base `Client`, not on the Nexus service client. + +```ts +const query = `Endpoint = "${ENDPOINT_NAME}"`; +const count = await client.nexus.count(query); +console.log(`Total Nexus operations: ${count.count}`); +``` + +Or use the Temporal CLI: + +```bash +./temporal nexus operation count --namespace my-caller-namespace --query 'Endpoint = "my-nexus-endpoint"' +``` + +## Run Standalone Nexus Operations with Temporal Cloud {/* #run-standalone-nexus-operations-temporal-cloud */} + +The code samples referenced on this page use [`loadClientConnectConfig()`](https://typescript.temporal.io/api/namespaces/envconfig#loadclientconfig) from `@temporalio/envconfig`, so the same code +works against Temporal Cloud — just configure the connection via environment variables or a TOML +profile. No code changes are needed. + +For full details on connecting to Temporal Cloud, including Namespace creation, Nexus Endpoint +setup, certificate generation, and authentication options, see +[Make Nexus calls across Namespaces in Temporal Cloud](/develop/typescript/nexus/feature-guide#nexus-calls-across-namespaces-temporal-cloud) +and [Connect to Temporal Cloud](/develop/typescript/client/temporal-client#connect-to-temporal-cloud). diff --git a/docs/develop/typescript/platform/index.mdx b/docs/develop/typescript/platform/index.mdx index d4e24fc3d3..9a32c58aeb 100644 --- a/docs/develop/typescript/platform/index.mdx +++ b/docs/develop/typescript/platform/index.mdx @@ -13,8 +13,12 @@ tags: import * as Components from '@site/src/components'; + + ![TypeScript SDK Banner](/img/assets/banner-typescript-temporal.png) + + ## Platform - [Observability](/develop/typescript/platform/observability) diff --git a/docs/develop/typescript/platform/observability.mdx b/docs/develop/typescript/platform/observability.mdx index 3e33a3eb01..0395df0939 100644 --- a/docs/develop/typescript/platform/observability.mdx +++ b/docs/develop/typescript/platform/observability.mdx @@ -44,11 +44,11 @@ To set up tracing of Workflows and Activities, use our `opentelemetry-intercepto ```typescript telemetryOptions: { - metrics: { - prometheus: { bindAddress: '0.0.0.0:9464' }, - }, - logging: { forward: { level: 'DEBUG' } }, + metrics: { + prometheus: { bindAddress: '0.0.0.0:9464' }, }, + logging: { forward: { level: 'DEBUG' } }, +}, ``` ## Set up tracing {/* #tracing */} @@ -96,7 +96,7 @@ To extend the default ([Trace Context](https://github.com/open-telemetry/opentel Similarly, you can customize the OpenTelemetry `NodeSDK` propagators by following the instructions in the [Initialize the SDK](https://github.com/open-telemetry/opentelemetry-js/tree/main/experimental/packages/opentelemetry-sdk-node#initialize-the-sdk) section of the `README.md` file. -## Log from a Workflow {/* #logging */} +## Set up logging {/* #logging */} Logging enables you to record critical information during code execution. Loggers create an audit trail and capture information about your Workflow's operation. @@ -104,17 +104,7 @@ An appropriate logging level depends on your specific needs. During development or troubleshooting, you might use debug or even trace. In production, you might use info or warn to avoid excessive log volume. -The logger supports the following logging levels: - -| Level | Use | -| ------- | --------------------------------------------------------------------------------------------------------- | -| `TRACE` | The most detailed level of logging, used for very fine-grained information. | -| `DEBUG` | Detailed information, typically useful for debugging purposes. | -| `INFO` | General information about the application's operation. | -| `WARN` | Indicates potentially harmful situations or minor issues that don't prevent the application from working. | -| `ERROR` | Indicates error conditions that might still allow the application to continue running. | - -The Temporal SDK core normally uses `WARN` as its default logging level. +You can find the log levels supported by the `winston` package used in the examples below in [their official documentation](https://github.com/winstonjs/winston#logging-levels). The Temporal SDK core normally uses `WARN` as its default logging level. ### Logging from Activities @@ -241,7 +231,7 @@ import { makeTelemetryFilterString, Runtime, } from '@temporalio/worker'; -import winston from 'winston'; +import winston, { transports } from 'winston'; const logger = winston.createLogger({ level: 'info', @@ -250,7 +240,15 @@ const logger = winston.createLogger({ }); Runtime.install({ - logger, + logger: new DefaultLogger('INFO', (entry) => { + logger.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, + }); + }), // The following block is optional, but generally desired. // It allows capturing log messages emitted by the underlying Temporal Core SDK (native code). // The Telemetry Filter String determine the desired verboseness of messages emitted by the @@ -264,120 +262,8 @@ Runtime.install({ }); ``` -{/* FIXME(JWH): Everything below this point must be revisited and moved to a distinct section (Sinks). */} - -### Implementing custom Logging-like features based on Workflow Sinks - -Sinks enable one-way export of logs, metrics, and traces from the Workflow isolate to the Node.js environment. - -{/* -Workflows in Temporal may be replayed from the beginning of their history when resumed. In order for Temporal to recreate the exact state Workflow code was in, the code is required to be fully deterministic. To prevent breaking determinism, in the TypeScript SDK, Workflow code runs in an isolated execution environment and may not use any of the Node.js APIs or communicate directly with the outside world. */} - -Sinks are written as objects with methods. -Similar to Activities, they are declared in the Worker and then proxied in Workflow code, and it helps to share types between both. - -#### Comparing Sinks and Activities - -Sinks are similar to Activities in that they are both registered on the Worker and proxied into the Workflow. -However, they differ from Activities in important ways: - -- A sink function doesn't return any value back to the Workflow and cannot be awaited. -- A sink call isn't recorded in the Event History of a Workflow Execution (no timeouts or retries). -- A sink function _always_ runs on the same Worker that runs the Workflow Execution it's called from. - -#### Declare the sink interface - -Explicitly declaring a sink's interface is optional but is useful for ensuring type safety in subsequent steps: - - -[packages/test/src/workflows/log-sink-tester.ts](https://github.com/temporalio/sdk-typescript/blob/main/packages/test/src/workflows/log-sink-tester.ts) -```ts -import type { Sinks } from '@temporalio/workflow'; - -export interface CustomLoggerSinks extends Sinks { - customLogger: { - info(message: string): void; - }; -} -``` - - -#### Implement sinks - -Implementing sinks is a two-step process. - -Implement and inject the Sink function into a Worker - - -[sinks/src/worker.ts](https://github.com/temporalio/samples-typescript/blob/main/sinks/src/worker.ts) -```ts -import { InjectedSinks, Worker } from '@temporalio/worker'; -import { MySinks } from './workflows'; - -async function main() { - const sinks: InjectedSinks = { - alerter: { - alert: { - fn(workflowInfo, message) { - console.log('sending SMS alert!', { - workflowId: workflowInfo.workflowId, - workflowRunId: workflowInfo.runId, - message, - }); - }, - callDuringReplay: false, // The default - }, - }, - }; - const worker = await Worker.create({ - workflowsPath: require.resolve('./workflows'), - taskQueue: 'sinks', - sinks, - }); - await worker.run(); - console.log('Worker gracefully shutdown'); -} - -main().catch((err) => { - console.error(err); - process.exit(1); -}); -``` - - -- Sink function implementations are passed as an object into [WorkerOptions](https://typescript.temporal.io/api/interfaces/worker.WorkerOptions/#sinks). -- You can specify whether you want the injected function to be called during Workflow replay by setting the `callDuringReplay` option. - -#### Proxy and call a sink function from a Workflow - - -[packages/test/src/workflows/log-sample.ts](https://github.com/temporalio/sdk-typescript/blob/main/packages/test/src/workflows/log-sample.ts) -```ts -import * as wf from '@temporalio/workflow'; - -export async function logSampleWorkflow(): Promise { - wf.log.info('Workflow execution started'); -} -``` - - -Some important features of the [InjectedSinkFunction](https://typescript.temporal.io/api/interfaces/worker.InjectedSinkFunction) interface: - -- **Injected WorkflowInfo argument:** The first argument of a Sink function implementation is a [`workflowInfo` object](https://typescript.temporal.io/api/interfaces/workflow.WorkflowInfo/) that contains useful metadata. -- **Limited arguments types:** The remaining Sink function arguments are copied between the sandbox and the Node.js environment using the [structured clone algorithm](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm). -- **No return value:** To prevent breaking determinism, Sink functions cannot return values to the Workflow. - -**Advanced: Performance considerations and non-blocking Sinks** - -The injected sink function contributes to the overall Workflow Task processing duration. - -- If you have a long-running sink function, such as one that tries to communicate with external services, you might start seeing Workflow Task timeouts. -- The effect is multiplied when using `callDuringReplay: true` and replaying long Workflow histories because the Workflow Task timer starts when the first history page is delivered to the Worker. - ### How to provide a custom logger {/* #custom-logger */} -Use a custom logger for logging. - #### Logging in Workers and Clients The Worker comes with a default logger, which defaults to log any messages with level `INFO` and higher to `STDERR` using `console.error`. @@ -415,14 +301,26 @@ logger.error('go'); A common logging use case is logging to a file to be picked up by a collector like the [Datadog Agent](https://docs.datadoghq.com/logs/log_collection/nodejs/?tab=winston30). ```ts -import { Runtime } from '@temporalio/worker'; +import { + DefaultLogger, + Runtime +} from '@temporalio/worker'; import winston from 'winston'; -const logger = winston.createLogger({ - level: 'info', - format: winston.format.json(), - transports: [new transports.File({ filename: '/path/to/worker.log' })], +const winstonLogger = winston.createLogger({ + level: 'debug', +}); + +const logger = new DefaultLogger('DEBUG', (entry) => { + winstonLogger.log({ + label: 'worker', + level: entry.level.toLowerCase(), + message: entry.message, + timestamp: Number(entry.timestampNanos / 1_000_000n), + ...entry.meta, + }); }); + Runtime.install({ logger }); ``` @@ -538,3 +436,111 @@ async function yourWorkflow() { upsertSearchAttributes({ CustomIntField: null }); } ``` + +## Workflow Sinks + +Sinks enable one-way export of logs, metrics, and traces from the Workflow isolate to the Node.js environment. + +{/* +Workflows in Temporal may be replayed from the beginning of their history when resumed. In order for Temporal to recreate the exact state Workflow code was in, the code is required to be fully deterministic. To prevent breaking determinism, in the TypeScript SDK, Workflow code runs in an isolated execution environment and may not use any of the Node.js APIs or communicate directly with the outside world. */} + +Sinks are written as objects with methods. +Similar to Activities, they are declared in the Worker and then proxied in Workflow code, and it helps to share types between both. + +### Comparing Sinks and Activities + +Sinks are similar to Activities in that they are both registered on the Worker and proxied into the Workflow. +However, they differ from Activities in important ways: + +- A sink function doesn't return any value back to the Workflow and cannot be awaited. +- A sink call isn't recorded in the Event History of a Workflow Execution (no timeouts or retries). +- A sink function _always_ runs on the same Worker that runs the Workflow Execution it's called from. + +### Declare the sink interface + +Explicitly declaring a sink's interface is optional but is useful for ensuring type safety in subsequent steps: + + +[packages/test/src/workflows/log-sink-tester.ts](https://github.com/temporalio/sdk-typescript/blob/main/packages/test/src/workflows/log-sink-tester.ts) +```ts +import type { Sinks } from '@temporalio/workflow'; + +export interface CustomLoggerSinks extends Sinks { + customLogger: { + info(message: string): void; + }; +} +``` + + +### Implement sinks + +Implementing sinks is a two-step process. + +Implement and inject the Sink function into a Worker + + +[sinks/src/worker.ts](https://github.com/temporalio/samples-typescript/blob/main/sinks/src/worker.ts) +```ts +import { InjectedSinks, Worker } from '@temporalio/worker'; +import { MySinks } from './workflows'; + +async function main() { + const sinks: InjectedSinks = { + alerter: { + alert: { + fn(workflowInfo, message) { + console.log('sending SMS alert!', { + workflowId: workflowInfo.workflowId, + workflowRunId: workflowInfo.runId, + message, + }); + }, + callDuringReplay: false, // The default + }, + }, + }; + const worker = await Worker.create({ + workflowsPath: require.resolve('./workflows'), + taskQueue: 'sinks', + sinks, + }); + await worker.run(); + console.log('Worker gracefully shutdown'); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); +``` + + +- Sink function implementations are passed as an object into [WorkerOptions](https://typescript.temporal.io/api/interfaces/worker.WorkerOptions/#sinks). +- You can specify whether you want the injected function to be called during Workflow replay by setting the `callDuringReplay` option. + +### Proxy and call a sink function from a Workflow + + +[packages/test/src/workflows/log-sample.ts](https://github.com/temporalio/sdk-typescript/blob/main/packages/test/src/workflows/log-sample.ts) +```ts +import * as wf from '@temporalio/workflow'; + +export async function logSampleWorkflow(): Promise { + wf.log.info('Workflow execution started'); +} +``` + + +Some important features of the [InjectedSinkFunction](https://typescript.temporal.io/api/interfaces/worker.InjectedSinkFunction) interface: + +- **Injected WorkflowInfo argument:** The first argument of a Sink function implementation is a [`workflowInfo` object](https://typescript.temporal.io/api/interfaces/workflow.WorkflowInfo/) that contains useful metadata. +- **Limited arguments types:** The remaining Sink function arguments are copied between the sandbox and the Node.js environment using the [structured clone algorithm](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm). +- **No return value:** To prevent breaking determinism, Sink functions cannot return values to the Workflow. + +**Advanced: Performance considerations and non-blocking Sinks** + +The injected sink function contributes to the overall Workflow Task processing duration. + +- If you have a long-running sink function, such as one that tries to communicate with external services, you might start seeing Workflow Task timeouts. +- The effect is multiplied when using `callDuringReplay: true` and replaying long Workflow histories because the Workflow Task timer starts when the first history page is delivered to the Worker. diff --git a/docs/develop/typescript/set-up.mdx b/docs/develop/typescript/set-up.mdx index bedd66dbb5..269a5d792d 100644 --- a/docs/develop/typescript/set-up.mdx +++ b/docs/develop/typescript/set-up.mdx @@ -15,8 +15,7 @@ tags: hide_table_of_contents: true --- -import { SetupSteps, SetupStep, CodeSnippet } from '@site/src/components/elements/SetupSteps'; -import { CallToAction } from '@site/src/components/elements/CallToAction'; +import { CallToAction, SetupSteps, SetupStep, CodeSnippet } from '@site/src/components'; # Quickstart @@ -93,7 +92,7 @@ Next, you'll configure a local Temporal Service for development. ## Install Temporal CLI The fastest way to get a development version of the Temporal Service running on your local machine is to use -[Temporal CLI](https://docs.temporal.io/cli). +[Temporal CLI](/cli). Choose your operating system to install Temporal CLI. diff --git a/docs/develop/typescript/workers/index.mdx b/docs/develop/typescript/workers/index.mdx index f73db8ce5d..fed01edd6c 100644 --- a/docs/develop/typescript/workers/index.mdx +++ b/docs/develop/typescript/workers/index.mdx @@ -14,8 +14,12 @@ tags: import * as Components from '@site/src/components'; + + ![TypeScript SDK Banner](/img/assets/banner-typescript-temporal.png) + + ## Workers - [Worker processes](/develop/typescript/workers/run-worker-process) diff --git a/docs/develop/typescript/workflows/continue-as-new.mdx b/docs/develop/typescript/workflows/continue-as-new.mdx index 45cc29017d..f9665b38f8 100644 --- a/docs/develop/typescript/workflows/continue-as-new.mdx +++ b/docs/develop/typescript/workflows/continue-as-new.mdx @@ -17,7 +17,7 @@ tags: - Temporal SDKs --- -import { ViewSourceCodeNotice } from '@site/src/components/elements/ViewSourceCodeNotice'; +import { ViewSourceCodeNotice } from '@site/src/components'; This page answers the following questions for Typescript developers: diff --git a/docs/develop/typescript/workflows/index.mdx b/docs/develop/typescript/workflows/index.mdx index 9b79035270..ea13f214b8 100644 --- a/docs/develop/typescript/workflows/index.mdx +++ b/docs/develop/typescript/workflows/index.mdx @@ -14,8 +14,12 @@ tags: import * as Components from '@site/src/components'; + + ![TypeScript SDK Banner](/img/assets/banner-typescript-temporal.png) + + ## Workflows - [Workflow basics](/develop/typescript/workflows/basics) diff --git a/docs/develop/typescript/workflows/schedules.mdx b/docs/develop/typescript/workflows/schedules.mdx index ab1240c2d8..0bd167e659 100644 --- a/docs/develop/typescript/workflows/schedules.mdx +++ b/docs/develop/typescript/workflows/schedules.mdx @@ -268,7 +268,7 @@ async function run() { :::caution Cron support is not recommended -We recommend using [Schedules](https://docs.temporal.io/schedule) instead of Cron Jobs. +We recommend using [Schedules](/schedule) instead of Cron Jobs. Schedules were built to provide a better developer experience, including more configuration options and the ability to update or pause running Schedules. ::: diff --git a/docs/develop/typescript/workflows/timeouts.mdx b/docs/develop/typescript/workflows/timeouts.mdx index 8a56154fbc..8da3c144f9 100644 --- a/docs/develop/typescript/workflows/timeouts.mdx +++ b/docs/develop/typescript/workflows/timeouts.mdx @@ -99,7 +99,7 @@ try { This works differently in a Workflow than raising exceptions from Activities. In an Activity, any Typescript exceptions or custom exceptions are converted to a Temporal `ApplicationFailure`. -In a Workflow, any exceptions that are raised other than an explicit Temporal `ApplicationFailure` will only fail that particular [Workflow Task](https://docs.temporal.io/tasks#workflow-task-execution) and be retried. +In a Workflow, any exceptions that are raised other than an explicit Temporal `ApplicationFailure` will only fail that particular [Workflow Task](/tasks#workflow-task-execution) and be retried. This includes any typical Typescript runtime errors like an `undefined` error that are raised automatically. These errors are treated as bugs that can be corrected with a fixed deployment, rather than a reason for a Temporal Workflow Execution to return unexpectedly. diff --git a/docs/develop/worker-performance.mdx b/docs/develop/worker-performance.mdx index 432449e174..22a0c5d7b2 100644 --- a/docs/develop/worker-performance.mdx +++ b/docs/develop/worker-performance.mdx @@ -9,9 +9,8 @@ tags: - Performance --- -import { CaptionedImage, ReleaseNoteHeader } from '@site/src/components'; +import { CaptionedImage, ReleaseNoteHeader, SdkTabs } from '@site/src/components'; import { LANGUAGE_TAB_GROUP, getLanguageLabel } from '@site/src/constants/languageTabs'; -import SdkTabs from '@site/src/components'; This page documents metrics and configurations that drive the efficiency of your Worker fleet. It provides coverage of performance metric families, Worker configuration options, Task Queue information, backlog counts, Task rates, and how to evaluate Worker availability. @@ -248,7 +247,7 @@ These options define the maximum count of pollers performing poll requests on Wo #### SDK Examples - + [Go SDK docs](https://pkg.go.dev/go.temporal.io/sdk/worker#PollerBehaviorAutoscalingOptions) ```go @@ -345,7 +344,7 @@ worker = Temporalio::Worker.new( ### Cache options (Java SDK) {/* #cache-options */} -A Workflow Cache is created and shared between all Workers on a single host. +A [Workflow Cache](/workflow-execution#workflow-cache) is created and shared between all Workers on a single host. It's designed to limit the resources used by the cache for each host/process. These options are defined on `WorkerFactoryOptions`: @@ -372,7 +371,7 @@ These properties should always be true for a Worker's configuration. Perform this sanity check after the adjustments to Worker settings. 1. `workflowCacheSize` should be ≤ `maxWorkflowThreadCount`. Each Workflow has at least one Workflow thread. -2. `maxConcurrentWorkflowTaskExecutionSize` should be ≤ `maxWorkflowThreadCount`. Having more Worker slots than the Workflow cache size will lead to resource contention/stealing between executors and unpredictable delays. It's recommended that `maxWorkflowThreadCount` be at least 2x of `maxConcurrentWorkflowTaskExecutionSize`. +2. `maxConcurrentWorkflowTaskExecutionSize` should be ≤ `maxWorkflowThreadCount`. It's recommended that `maxWorkflowThreadCount` be at least 2x of `maxConcurrentWorkflowTaskExecutionSize`. This is because having more Worker slots than the Workflow cache size will lead to resource allocation issues between executors and cause unpredictable delays. 3. `maxConcurrentWorkflowTaskPollers` should be significantly ≤ `maxConcurrentWorkflowTaskExecutionSize`. And `maxConcurrentActivityTaskPollers` should be significantly ≤ `maxConcurrentActivityExecutionSize`. The number of pollers should always be lower than the number of executors. ## Worker runtime performance tuning {/* #worker-performance-tuning */} @@ -756,8 +755,8 @@ While individual `add` and `dispatch` rates may be inaccurate due to Eager and S ## Evaluate Task Queue performance {/* #evaluate-worker-loads */} -A [Task Queue](https://docs.temporal.io/task-queue) is a lightweight, dynamically allocated queue. -[Worker Entities](/workers#worker-entity) poll the queue for [Tasks](https://docs.temporal.io/tasks#task) and retrieve Tasks to work on. +A [Task Queue](/task-queue) is a lightweight, dynamically allocated queue. +[Worker Entities](/workers#worker-entity) poll the queue for [Tasks](/tasks#task) and retrieve Tasks to work on. Tasks are contexts that a Worker progresses using a specific Workflow Execution, Activity Execution, or a Nexus Task Execution. Each Task Queue type offers its Tasks to compatible Workers for Task completion. The Temporal Service dynamically creates different [Task Queue types](/task-queue) including Activity Task Queues, Workflow Task Queues, and Nexus Task Queues. @@ -819,7 +818,7 @@ for _, taskQueueName := range taskQueueNames { ### Evaluate Worker availability and capacity issues {/* #worker-capacity-issues */} -Each Temporal [Server](https://docs.temporal.io/temporal-service/temporal-server) records the last time of each poll request. +Each Temporal [Server](/temporal-service/temporal-server) records the last time of each poll request. This time is displayed in the `temporal task-queue describe` output. - A `LastAccessTime` value exceeding one minute may indicate that the Worker fleet is at capacity or that Workers have shut down or been removed. diff --git a/docs/encyclopedia/activities/activity-operations.mdx b/docs/encyclopedia/activities/activity-operations.mdx index 5a3a05c008..a122170b34 100644 --- a/docs/encyclopedia/activities/activity-operations.mdx +++ b/docs/encyclopedia/activities/activity-operations.mdx @@ -22,16 +22,17 @@ This page discusses the following: - [Unpause](#unpause) - [Reset](#reset) - [Update Options](#update-options) +- [Standalone Activity operator commands](#standalone-activity-operator-commands) - [Observability](#observability) Activity Operations are deliberate actions you perform on a specific [Activity Execution](/activity-execution), as opposed to lifecycle behaviors like [retries](/encyclopedia/retry-policies) and [timeouts](/encyclopedia/detecting-activity-failures) which happen automatically. -You can perform Activity Operations through the [CLI](/cli/command-reference/activity), the UI, or directly via the gRPC -API. Not all operations are available in all interfaces yet - see the [summary table](#operations-summary) for current -support. Activity Operations don't apply to [Local Activities](/local-activity) or -[Standalone Activities](/standalone-activity). +You can perform Activity Operations through the [CLI](/cli/command-reference/activity), the UI, or directly through the +gRPC API. Interface support differs between Workflow Activities and Standalone Activities. Activity Operations don't +apply to [Local Activities](/local-activity). For Standalone Activities, see +[Standalone Activity operator commands](#standalone-activity-operator-commands). :::note Public Preview @@ -45,12 +46,12 @@ gRPC API - not for programmatic use in Workflow or Activity code. ## Operations summary -| Operation | What it does | CLI | -| --------------------------------- | -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | -| [Pause](#pause) | Stops retries. In-flight execution continues unless the Activity uses Heartbeat. | [`temporal activity pause`](/cli/command-reference/activity#pause) | -| [Unpause](#unpause) | Resumes a Paused Activity. The next execution starts immediately. | [`temporal activity unpause`](/cli/command-reference/activity#unpause) | -| [Reset](#reset) | Clears retry state (attempts, backoff) and schedules a new execution. | [`temporal activity reset`](/cli/command-reference/activity#reset) | -| [Update Options](#update-options) | Changes timeouts, Retry Policy, or Task Queue without restarting the Activity. | [`temporal activity update-options`](/cli/command-reference/activity#update-options) | +| Operation | What it does | CLI | +| --------------------------------- | ------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------ | +| [Pause](#pause) | Stops retries. In-flight execution continues unless the Activity uses Heartbeat. | [`temporal activity pause`](/cli/command-reference/activity#pause) | +| [Unpause](#unpause) | Resumes a Paused Activity. The next execution starts after any remaining retry backoff or start delay. | [`temporal activity unpause`](/cli/command-reference/activity#unpause) | +| [Reset](#reset) | Clears retry state (attempts, backoff) and schedules the Activity for execution again. | [`temporal activity reset`](/cli/command-reference/activity#reset) | +| [Update Options](#update-options) | Changes Activity options (for example, timeouts, Retry Policy, or Task Queue) without restarting it. | [`temporal activity update-options`](/cli/command-reference/activity#update-options) | ## Pause {/* #pause */} @@ -65,20 +66,19 @@ Pause stops the Temporal Service from scheduling new retries of an [Activity Exe ### What happens when you Pause an Activity +- **No further retries are scheduled.** The Temporal Service stops scheduling retries. +- **[Heartbeating](/encyclopedia/detecting-activity-failures#activity-heartbeat) determines whether an interruption is + sent to the in-flight Activity Task Execution:** + - For **Activities with Heartbeat**, the interruption is sent with the next Heartbeat response. The SDK raises a + Pause-specific error; the Activity can catch this error to clean up resources before exiting. + - **Activities without Heartbeat** continue running. If the Activity Task Execution succeeds, its result is delivered + to the Workflow. If it fails, no retry is scheduled. Pause takes effect after the in-flight execution ends. +- **Pause is idempotent.** Pausing an already-Paused Activity has no effect. Pausing a completed Activity returns an + error. - **Pausing an Activity doesn't affect the parent Workflow.** The Workflow continues Running, and [Signals, Queries, and Updates](/encyclopedia/workflow-message-passing) on the parent Workflow are unaffected. -- **No further retries are scheduled.** The Temporal Service stops scheduling retries. This is enforced server-side, not - by the SDK. - **Workflow code has no visibility into Activity Operations.** Pause doesn't produce an Event History event, so the Workflow can't detect or react to it. See [Observability](#observability). -- **[Heartbeating](/encyclopedia/detecting-activity-failures#activity-heartbeat) determines whether the in-flight - execution is interrupted:** - - **Activities with Heartbeat** are interrupted on their next Heartbeat. The SDK raises a Pause-specific error, and - the Activity can catch this to clean up resources before exiting. - - **Activities without Heartbeat** continue running to completion. If the execution succeeds, the result is delivered - to the Workflow normally. If it fails, no retry is scheduled. Pause takes effect after the in-flight execution ends. -- **Pause is idempotent.** Pausing an already-Paused Activity has no effect. Pausing a completed Activity returns an - error. ### CLI usage @@ -93,7 +93,7 @@ See the [CLI reference for `temporal activity pause`](/cli/command-reference/act ### Detect Pause in Activity code -Activities with Heartbeat can detect that an interruption was caused by Pause rather than a timeout or Workflow +Activities with a Heartbeat can detect that an interruption was caused by Pause rather than by a timeout or Workflow Cancellation. A Paused Activity resumes later. A Cancelled Activity doesn't. Your Activity code may need to handle these cases differently, for example releasing held resources on Pause while preserving them on Cancellation, or vice versa. @@ -107,10 +107,10 @@ cases differently, for example releasing held resources on Pause while preservin ### Interaction with Workflow Pause -[Workflow Pause](/cli/command-reference/workflow#pause) and Activity Pause are independent. Both stop Activity retries, +[Workflow Pause](/encyclopedia/workflow/workflow-pause) and Activity Pause are independent. Both stop Activity retries, but they must be Unpaused separately. -- Workflow Pause blocks retries but doesn't interrupt in-flight executions via Heartbeat. Activity Pause does. +- Workflow Pause blocks retries but doesn't interrupt in-flight Activity Task Executions via Heartbeat. Activity Pause does. - If both are active, both must be Unpaused before the Activity resumes. ### Important considerations @@ -125,7 +125,7 @@ but they must be Unpaused separately. - **Pause operates on individual Activities by ID within a single Workflow.** Unlike Unpause, Reset, and Update Options, there's no `--query` flag. To pause multiple Activities, issue separate commands for each Activity ID. -- **No Namespace-wide query for Paused Activities.** You must know the Workflow Id. See [Observability](#observability). +- **No Namespace-wide query for Paused Activities.** You must know the Workflow ID. See [Observability](#observability). ## Unpause {/* #unpause */} @@ -139,11 +139,10 @@ Unpause resumes a Paused Activity Execution. ### What happens when you Unpause an Activity -- **The Activity is rescheduled immediately.** Any remaining retry backoff is discarded. The next execution starts right - away. -- **Attempt count and Heartbeat data are preserved by default.** The Activity resumes from where it left off. Use - `--reset-attempts` or `--reset-heartbeats` on the CLI to clear these, or use [Reset](#reset) to restart from - attempt 1. +- **The Activity is rescheduled for its next attempt.** The next attempt starts after any remaining retry backoff or + Start Delay. +- **Attempt and Heartbeat state are preserved by default.** Use `--reset-attempts` to restart from attempt 1 and clear + the current retry interval when Unpausing. Unpause is idempotent. Unpausing an Activity that isn't Paused has no effect. Unpausing an Activity that has already completed returns an error. @@ -156,8 +155,7 @@ temporal activity unpause \ --activity-id my-activity ``` -See the [CLI reference for `temporal activity unpause`](/cli/command-reference/activity#unpause) for all options, -including `--reset-attempts` and `--reset-heartbeats` to clear state on resume. +See the [CLI reference for `temporal activity unpause`](/cli/command-reference/activity#unpause) for all options. ### Important considerations @@ -167,8 +165,8 @@ including `--reset-attempts` and `--reset-heartbeats` to clear state on resume. - **Unpausing doesn't override Workflow Pause.** If the parent Workflow is also Paused, Unpausing the Activity alone isn't enough. Both must be Unpaused before the Activity resumes. See [Interaction with Workflow Pause](#interaction-with-workflow-pause). -- **Unpausing doesn't reset the attempt count.** The Activity retries from its current attempt number. Use - [Reset](#reset) to restart from attempt 1. +- **Unpausing doesn't reset the attempt count by default.** The Activity retries from its current attempt number. Use + `--reset-attempts` when Unpausing, or use [Reset](#reset), to restart from attempt 1. - **A Paused Activity can time out before you Unpause it.** The [Schedule-To-Close Timeout](/encyclopedia/detecting-activity-failures#schedule-to-close-timeout) isn't stopped or extended while Paused. Use [`update-options`](#update-options) to extend the timeout before Unpausing if needed. @@ -178,37 +176,33 @@ including `--reset-attempts` and `--reset-heartbeats` to clear state on resume. ## Reset {/* #reset */} -Reset clears an Activity's retry state and schedules a fresh execution. +Reset clears an Activity's retry state and schedules it to start again from attempt 1. ### When to Reset -- An Activity has exhausted most of its retries, and you want to give it a fresh set after fixing the underlying issue. +- An Activity has exhausted most of its retries, and you want to give it its full retries after fixing the underlying issue. - A Paused Activity needs to start clean after a configuration change or code deploy. -- You want to clear accumulated retry backoff and retry immediately instead of waiting for the next backoff interval. +- You want to retry immediately instead of waiting for the next backoff interval. - A batch of Activities failed due to a transient issue and you want to restart them all with staggered jitter. ### What happens when you Reset an Activity - **The attempt count resets to 1.** The Activity gets a full set of retry attempts regardless of how many it had used. - **Retry backoff is discarded.** If the Activity was in a backoff wait, it's rescheduled to run immediately. -- **If the Activity is Paused, Reset also Unpauses it.** Use `--keep-paused` to Reset the attempt count without resuming - execution. With `--keep-paused`, the attempt count and Heartbeat data (if `--reset-heartbeats`) are reset, but the - Activity stays Paused. No retry is scheduled until you [Unpause](#unpause) separately. +- **If the Activity is Paused, Reset also Unpauses it.** Use `--keep-paused` to Reset without Unpausing. + With `--keep-paused`, the attempt count and Heartbeat data are reset, but the Activity stays Paused. No attempt is + scheduled until you [Unpause](#unpause) separately. - **Resetting an Activity doesn't affect the parent Workflow.** The Workflow continues Running, and Signals, Queries, and Updates on the parent Workflow are unaffected. - **Workflow code has no visibility into Activity Operations.** Reset doesn't produce an Event History event, so the Workflow can't detect or react to it. See [Observability](#observability). -- **[Heartbeating](/encyclopedia/detecting-activity-failures#activity-heartbeat) determines whether an in-flight - execution is interrupted:** - - **Activities with Heartbeat** are interrupted on their next Heartbeat. The SDK may raise a Reset-specific error so - the Activity can clean up before exiting. The next execution starts at attempt 1. +- **[Heartbeating](/encyclopedia/detecting-activity-failures#activity-heartbeat) determines whether an interruption will + be sent to an in-flight execution:** + - For **Activities with Heartbeat**, an interruption is sent with the next Heartbeat response. The SDK raises a + Reset-specific error so the Activity can clean up before exiting. The next execution starts at attempt 1. - **Activities without Heartbeat** continue running to completion. Reset doesn't cancel, interrupt, or schedule a - concurrent execution. If the Activity was already retrying, the Temporal Service rejects the current execution's - result because Reset changed the expected attempt number, and a fresh execution is scheduled after the - [Start-To-Close Timeout](/encyclopedia/detecting-activity-failures#start-to-close-timeout) expires. If it was on its - first execution, a successful result is still delivered to the Workflow normally. -- **Reset is idempotent.** Resetting an Activity that's already at attempt 1 with no backoff has no effect. Resetting a - completed Activity returns an error. + concurrent execution. If the Activity was running on the Worker, attempt 1 is scheduled to start after the attempt + finishes. If the attempt succeeds, the Reset request is ignored and the Activity completes successfully. ### CLI usage @@ -225,13 +219,14 @@ temporal activity reset \ ``` See the [CLI reference for `temporal activity reset`](/cli/command-reference/activity#reset) for all options, including -`--reset-heartbeats` and bulk mode via `--query`. +bulk mode via `--query`. ### Detect Reset in Activity code -Activities with Heartbeat can detect that an interruption was caused by Reset rather than a timeout or Workflow -Cancellation. A Reset Activity is retried from attempt 1. A Cancelled Activity isn't. Your Activity code may need to -handle these cases differently, for example saving partial progress on Reset while discarding it on Cancellation. +Activities with a Heartbeat can detect that an interruption was caused by Reset rather than by a timeout or Workflow +Cancellation. A Reset Activity will be retried from attempt 1. A Cancelled Activity won't be retried. Your Activity code +may need to handle these cases differently, for example saving partial progress on Reset while discarding it on +Cancellation. | SDK | How to detect Reset | | ---------- | ---------------------------------------------------------------------------------- | @@ -247,21 +242,19 @@ handle these cases differently, for example saving partial progress on Reset whi [Schedule-To-Close Timeout](/encyclopedia/detecting-activity-failures#schedule-to-close-timeout). The deadline is calculated from when the Activity was originally scheduled. Use [`update-options`](#update-options) to extend the timeout before or after Reset. -- **Heartbeat details are preserved by default.** If your Activity uses Heartbeat details for progress tracking and you - want a clean restart, pass `--reset-heartbeats`. +- **Heartbeat details are cleared.** If your Activity uses Heartbeat details for progress tracking, the next attempt + starts without those details after Reset. - **Reset won't interrupt an Activity that doesn't Heartbeat.** The current execution runs to completion, which could take up to the full [Start-To-Close Timeout](/encyclopedia/detecting-activity-failures#start-to-close-timeout). If the - Activity had already retried (attempt > 1), the Temporal Service rejects the current execution's result because Reset - changed the expected attempt number. The Activity waits for its Start-To-Close Timeout to expire before a new - execution is scheduled. -- **`--restore-original-options` restores the Activity's original configuration.** It reverts timeouts, Retry Policy, - and Task Queue to the values from when the Activity was first scheduled. + current attempt succeeds, the Activity completes successfully and the Reset request is ignored. +- **`--restore-original-options` restores the Activity's original configuration.** It reverts any changes to timeouts, + Retry Policy, and Task Queue back to the values they had when the Activity was first scheduled. - **Bulk Reset can overwhelm downstream services.** When using `--query` to Reset Activities across many Workflows, use `--jitter` to stagger the restart times. ## Update Options {/* #update-options */} -Update Options changes an Activity's runtime configuration without restarting it. +An Activity's runtime configuration can be changed without restarting it. ### When to Update Options @@ -279,16 +272,15 @@ You can change [timeouts](/encyclopedia/detecting-activity-failures) (Schedule-T Schedule-To-Start, Heartbeat), Retry Policy (initial interval, maximum interval, backoff coefficient, maximum attempts), and Task Queue. Only the fields you specify are changed. All other options remain unchanged. -- **If the Activity is waiting for retry (scheduled),** the new options take effect immediately. Any pending retry timer - is regenerated with the updated configuration. -- **If the Activity is currently running,** the new options are stored but take effect on the next execution. The - in-flight execution isn't interrupted. -- **If the Activity is Paused,** the new options are stored immediately. They take effect when the Activity is Unpaused - and the next execution starts. -- **Workflow code has no visibility into Activity Operations.** Update Options doesn't produce an Event History event, +- **If the Activity is waiting for its Start Delay or retry backoff,** the new options take effect immediately. Any + pending timer is regenerated with the updated configuration. +- **If the Activity is currently running,** the new options take effect on the next attempt. The + in-flight execution isn't interrupted or affected. +- **If the Activity is Paused,** the new options are stored immediately and will affect the next attempt on Unpause. +- **Workflow code has no visibility into Activity Operations.** No Event History event is produced for Update Options, so the Workflow can't detect or react to it. See [Observability](#observability). -Update Options is idempotent. Updating an Activity with the same values it already has produces no change. Updating +The operation is idempotent. Updating an Activity with the same values it already has produces no change. Updating options on an Activity that has already completed returns an error. ### CLI usage @@ -305,39 +297,248 @@ options, including Retry Policy, Task Queue, and bulk mode via `--query`. ### Important considerations -- **Changes to a running Activity take effect on the next execution, not the current one.** If you need the change to - apply immediately, the Activity must finish or fail its current execution first. -- **`--restore-original-options` is batch-only.** This flag only works with `--query`. It's silently ignored in - single-workflow mode. It can't be combined with other option changes in the same command. +- **`--restore-original-options` can't be combined with other option changes.** It replaces the current options with the + values captured when the Activity was first scheduled. -### Limitations +### UI support + +Update Options is available in the UI. The UI shows Activity operator controls when connected to Temporal Server +v1.32.0 or later. + +## Standalone Activity operator commands {/* #standalone-activity-operator-commands */} + +Standalone Activity operator commands target a top-level [Standalone Activity](/standalone-activity) instead of an +Activity scheduled by a Workflow. + +To target a Standalone Activity, provide `--activity-id` and omit `--workflow-id`. If the Activity ID has multiple runs, +use `--run-id` to select a specific Activity Run. Without `--run-id`, the command targets the latest run. + +```bash +temporal activity pause \ + --activity-id my-standalone-activity \ + --run-id 42016101-b6f7-4812-991b-d58b70c74a54 +``` + +These commands have the following common behavior: + +- They operate on one Standalone Activity Execution at a time. The `--query` batch mode applies to Workflow Activities, + not Standalone Activities. +- A missing Activity Execution returns a `NotFound` error. A terminal Activity Execution can't be Paused, Unpaused, + Reset, or updated. +- Cancellation takes precedence after it has been requested. Pause and Reset are rejected while cancellation is + pending, but an Activity can still be cancelled while Pause or Reset is pending. +- Workflow timeouts don't apply. Schedule-To-Close, Schedule-To-Start, Start-To-Close, and Heartbeat timeouts still + apply to the Standalone Activity. +- Pause and Reset don't restart the Schedule-To-Close period. Its deadline is the Activity's Schedule Time plus any + Start Delay and the Schedule-To-Close Timeout. +- Schedule-To-Start begins when an attempt becomes eligible for dispatch. Start-To-Close and Heartbeat timeouts apply + while a Worker is running an attempt. +- Before the first Worker pickup, an operation can't dispatch the Activity before its remaining Start Delay. After the + first pickup, Start Delay no longer affects later attempts. + +### Pause a Standalone Activity {/* #pause-standalone-activity */} + +Pause prevents the Temporal Service from dispatching another attempt. + +```bash +temporal activity pause \ + --activity-id my-standalone-activity \ + --reason "Waiting for the downstream service to recover" +``` + +If the Activity is waiting for dispatch, Pause invalidates its pending dispatch and moves it to the Paused state. No +Worker receives the Activity until you Unpause it or Reset it without `--keep-paused`. + +If a Worker is already running an attempt, the pause remains pending: + +- The Worker keeps its task token, and the current Start-To-Close and Heartbeat timeouts remain in effect. +- The next Heartbeat response tells the Worker that the Activity was Paused. +- If the attempt succeeds, the Activity completes and the pending Pause has no effect. +- If the attempt fails or times out and another retry is permitted, the Activity becomes Paused instead of dispatching + the retry. The failed attempt still counts toward the Retry Policy. +- If the failure is non-retryable, no attempts remain, or Schedule-To-Close expires, the Activity closes instead of + becoming Paused. + +Pause doesn't clear the attempt count, retry state, or Heartbeat details. A fully Paused Activity can still reach its +Schedule-To-Close deadline. + +Pausing an already-Paused Activity returns a `FailedPrecondition` error. At the gRPC API level, retrying a Pause request +with the same request ID is an idempotent no-op, including after a later Unpause. Pause is also rejected while Reset or +Cancellation is pending. + +### Unpause a Standalone Activity {/* #unpause-standalone-activity */} + +Unpause resumes a Paused Standalone Activity. + +```bash +temporal activity unpause \ + --activity-id my-standalone-activity +``` + +For a fully Paused Activity, Unpause creates a new dispatch and Schedule-To-Start timer. By default, the Activity keeps +its current attempt count, Heartbeat details, and any remaining Retry Policy backoff or Worker-provided Next Retry Delay. +The dispatch occurs after the latest of the Unpause time, the preserved retry time, and any remaining Start Delay. + +You can change that behavior when Unpausing: + +- `--reset-attempts` resets the attempt count to 1 and discards the current retry interval. +- `--jitter` adds a random delay within the specified duration. + +If Pause is still pending on a running attempt, Unpause cancels the pending Pause. The Worker continues with the same +task token and timers, and no new attempt is dispatched. Reset and jitter options don't alter that in-flight attempt. + +If a Reset with `--keep-paused` is pending on a running attempt, Unpause removes only the `keep-paused` intent. Reset +remains pending, and attempt 1 is dispatched when the running attempt yields. + +Unpausing an Activity that isn't Paused is an idempotent no-op. Unpausing a terminal Activity returns an error. + +### Reset a Standalone Activity {/* #reset-standalone-activity */} + +Reset clears retry and Heartbeat state and schedules, or requests, attempt 1. + +```bash +temporal activity reset \ + --activity-id my-standalone-activity +``` + +Reset applies immediately when no Activity Task Execution is in flight. This includes an Activity that is waiting for +its initial Start Delay, scheduled or dispatched but not yet picked up by a Worker, waiting for retry backoff, or fully +Paused. Applying Reset immediately: + +- Sets the attempt count to 1. +- Clears retry backoff, the last Heartbeat time, and Heartbeat details. +- Creates a new dispatch and Schedule-To-Start timer unless the Activity remains Paused. +- Unpauses a Paused Activity unless you set `--keep-paused`. With `--keep-paused`, the state is reset but no dispatch is + created until a later Unpause. +- Applies `--jitter` to the new dispatch. Before the first Worker pickup, any remaining Start Delay still applies. Reset + can therefore apply immediately even though the next attempt isn't dispatched immediately. + +Reset remains pending when a Worker has picked up an Activity Task and that attempt is still in flight. It doesn't +invalidate the Worker task token or current attempt timers. This remains true if the Worker stops responding: Reset is +pending until the attempt reports an outcome or reaches Start-To-Close or Heartbeat Timeout. The next Heartbeat response +tells the Worker that the Activity was Reset. If the attempt succeeds, the Activity completes and Reset has no effect. +If it fails or times out, Reset applies and schedules attempt 1—even if the failure is non-retryable or the previous +Retry Policy had no attempts remaining—provided another attempt fits within the unchanged Schedule-To-Close deadline. -- **Update Options is CLI and gRPC only.** It's not available in the UI. +Reset while Pause is pending clears the Pause by default. Set `--keep-paused` to have the Activity become fully Paused at +attempt 1 when the running attempt yields. + +Reset always clears Heartbeat details. For a running attempt, the details are cleared when Reset is applied. Reset +doesn't create a new Schedule-To-Close period, so attempt 1 must still fit within the original Activity Execution +deadline. + +Use `--restore-original-options` to restore the Task Queue, timeouts, Retry Policy, Priority, and Start Delay captured +when the Standalone Activity was created. Start Delay is restored only before the first attempt starts. A restore is +immediate for an Activity without a running attempt and deferred for a running Activity, so it doesn't change the +in-flight attempt. If that attempt succeeds, neither Reset nor the deferred restore is applied. + +Reset is rejected if Cancellation or another Reset is already pending, or if the Activity is terminal. + +### Update Standalone Activity options {/* #update-standalone-activity-options */} + +A Standalone Activity's stored options can be changed without Resetting it. + +```bash +temporal activity update-options \ + --activity-id my-standalone-activity \ + --schedule-to-close-timeout 24h \ + --retry-maximum-attempts 20 +``` + +The server supports the following updates. CLI flags are shown where available; the other fields can be updated directly +through the gRPC API. + +| Option | CLI flag | gRPC `update_mask` path | +| ------ | -------- | ----------------------- | +| Task Queue | `--task-queue` | `task_queue.name` | +| Schedule-To-Close Timeout | `--schedule-to-close-timeout` | `schedule_to_close_timeout` | +| Schedule-To-Start Timeout | `--schedule-to-start-timeout` | `schedule_to_start_timeout` | +| Start-To-Close Timeout | `--start-to-close-timeout` | `start_to_close_timeout` | +| Heartbeat Timeout | `--heartbeat-timeout` | `heartbeat_timeout` | +| Retry Policy or a supported subfield | `--retry-*` flags | `retry_policy` or a supported subfield | +| Priority or a supported subfield | gRPC only | `priority` or a supported subfield | +| Start Delay | gRPC only | `start_delay` | + +Only fields selected by the update mask change. Through gRPC, you can instead set `restore_original` to restore the +options captured when the Activity was created. `restore_original` can't be combined with an update mask, and it restores +Start Delay only before the first attempt starts. + +Updates have the following effects: + +- Schedule-To-Close is recalculated and its timer is reissued. Extending it can allow retries that previously wouldn't + fit; setting it to zero disables that timeout when Start-To-Close remains set. +- Updating Start-To-Close or Heartbeat Timeout can affect an attempt that is already running. The new deadlines are + calculated from that attempt's start time or its last Heartbeat time. +- A Task Queue change affects future dispatches, not an Activity Task that a Worker has already accepted. +- Retry Policy subfield updates are merged with the current policy and validated. A pending policy-derived retry interval + is recalculated, but a Worker-provided Next Retry Delay is preserved. +- A waiting Activity is redispatched using the updated options. A fully Paused Activity stores the options without + dispatching and uses them after Unpause. +- Options can be updated while Pause, Reset, or Cancellation is pending. Changes to running-attempt timeouts can still + affect that attempt. + +Start Delay can be updated while the first dispatch is still in its delay window. It can also be updated while the +Activity is fully Paused before its first attempt, even if the previous delay has elapsed. After the first attempt has +been dispatched, Start Delay can't be changed. + +Updating a terminal Activity returns an error. + +#### Timeout validation requirements {/* #timeout-validation-requirements */} + +Updated timeout values are validated and normalized after the update: + +- Each updated timeout must be a valid, non-negative duration. +- An Activity must have either Schedule-To-Close or Start-To-Close Timeout set. +- If Schedule-To-Close Timeout is set, missing Schedule-To-Start and Start-To-Close values are set from it, and longer + values are capped to it. +- If only Start-To-Close Timeout is set, Schedule-To-Close and Schedule-To-Start can remain unset for a Standalone + Activity. +- Heartbeat Timeout can't exceed Start-To-Close Timeout. + +### Standalone Activity operation limitations {/* #standalone-activity-operation-limitations */} + +Pause, Unpause, Reset, and Update Options are available for Standalone Activities through the CLI, gRPC API, and the UI. +The UI shows these controls when connected to Temporal Server v1.32.0 or later. + +- Standalone Activities don't support batch Pause, Unpause, Reset, or Update Options by Visibility Query. +- The current state is available from `temporal activity describe`, but Standalone Activities don't have an Event + History containing an audit trail of operator commands. ## Observability {/* #observability */} -Activity Operations have a limited audit trail because they are not recorded in a Workflow's Event History. However, you -can use the CLI and the UI to check Activity state and find Paused Activities for running Workflows. +Activity Operations have a limited audit trail because they are not recorded in a Workflow's Event History. You can use +the CLI or the UI to inspect the current state of Workflow Activities and Standalone Activities. ### Check Activity state -`temporal workflow describe` shows the current state of each pending Activity, including whether it's Paused, its -current attempt count, and last failure. The UI shows who performed an operation, when, and why (if a `--reason` was -provided). +For a Workflow Activity, `temporal workflow describe` shows each pending Activity's state, current attempt count, and +last failure. The UI also shows who performed an operation, when, and why if a `--reason` was provided. + +For a Standalone Activity, use the UI or `temporal activity describe --activity-id `. In the CLI output, the +`RunState` field distinguishes `Paused` from `PauseRequested`, where a Worker is still running the current attempt. ### Find Paused Activities -The `TemporalPauseInfo` [Search Attribute](/search-attribute) is filterable within a Workflow. +The `TemporalPauseInfo` [Search Attribute](/search-attribute) is filterable within a Workflow. `TemporalPauseInfo` is +only added to Workflows; it doesn't exist for Standalone Activities. + +There's no Namespace-wide query to find all Paused Activities across Workflows. You must know the Workflow ID. -There's no Namespace-wide query to find all Paused Activities across Workflows. You must know the Workflow Id. +Standalone Activities have their own Visibility records. To find fully Paused Standalone Activities, use: + +```bash +temporal activity list --query "ExecutionStatus = 'Paused'" +``` ### Audit trail {/* #audit-trail */} -Activity Operations don't produce Event History events. There is no record of a Pause, Reset, or option change in the +Activity Operations don't produce Event History events. There is no record of a Pause, Reset, or option change in a Workflow's [Event History](/workflow-execution/event#event-history). Nothing that reads the Event History - Workflow -code, Replays, or external tooling - will see that an Operation occurred. +code, Replays, or external tooling - will see that an Operation occurred. Standalone Activities don't have a Workflow +Event History. Evidence of an Operation is gone when the Activity completes or the Workflow closes. There's no persistent record that an Activity was Paused, Reset, or had its options changed. -The only way to confirm the current state of an Activity is `temporal workflow describe` or the UI. +Use `temporal workflow describe`, `temporal activity describe`, or the UI for Workflow Activities to confirm current +state. diff --git a/docs/encyclopedia/activities/local-activity.mdx b/docs/encyclopedia/activities/local-activity.mdx index 6534727c75..45591110d9 100644 --- a/docs/encyclopedia/activities/local-activity.mdx +++ b/docs/encyclopedia/activities/local-activity.mdx @@ -2,47 +2,171 @@ id: local-activity title: Local Activity sidebar_label: Local Activity -description: Learn about Local Activities in Temporal, their benefits, execution model, and when to use them. +description: Learn about Local Activities in Temporal, how they work, when to use them, and how they differ from regular Activities. slug: /local-activity toc_max_heading_level: 4 keywords: - explanation - term - timeouts + - activities + - local activities + - workflow tags: - Concepts - Activities - Durable Execution --- -This page discusses [Local Activity](#local-activity). - ## What is a Local Activity? {/* #local-activity */} -A Local Activity is an [Activity Execution](/activity-execution) that executes in the same process as the [Workflow Execution](/workflow-execution) that spawns it. +A Local Activity is an [Activity Execution](/activity-execution) that executes in the same Worker process as the [Workflow Execution](/workflow-execution) that schedules it. + +Unlike a regular Activity, a Local Activity never enters an Activity Task Queue. Instead, the Workflow Worker executes it directly in an in-process queue. Because it avoids the round trip through the Temporal Service, a Local Activity has significantly lower latency and produces fewer Event History entries than a regular Activity. + +Local Activities help with performance optimization and are not a replacement for regular Activities. + +Consider using a Local Activity only when the operation: + +- is short-lived (completes in a few seconds), including retries +- can execute in the same binary as the Workflow +- does not require routing to a specific Worker or Task Queue +- does not require global rate limiting +- [is idempotent](/develop/python/best-practices/error-handling#make-activities-idempotent) + +For most production workloads, regular Activities remain the recommended default. + +:::tip Recommendation + +Use Local Activities only when your use case requires the performance optimization they provide, such as high-throughput Workflows with many very short-lived operations. For most business logic, regular Activities are the better choice. + +::: + +## How Local Activities execute + +Regular Activities are coordinated through the Temporal Service. + +The execution flow is: + +1. A Workflow schedules an Activity. +2. The Workflow completes its Workflow Task by sending a `ScheduleActivityTask` command to the Temporal Service. +3. The Temporal Service creates an Activity Task. +4. An Activity Worker polls the Activity Task Queue and executes the Activity. +5. The Activity result is recorded in Event history. +6. The Workflow resumes in a new Workflow Task. + +Local Activities follow a shorter execution path: + +1. A Workflow schedules a Local Activity. +2. The Local Activity is placed into an in-process queue within the Workflow Worker. +3. The Workflow Worker executes the Local Activity immediately. +4. The result is returned directly to the Workflow. +5. When the Workflow Task completes, the Worker records a `MarkerRecorded` event containing the Local Activity result. + +Unlike regular Activities, scheduling and execution occur entirely within the Worker process. Only the final marker is persisted to Event history. + +## Workflow Task heartbeating + +Local Activities do **not** support Activity heartbeats. Instead, the SDK supports Workflow Task heartbeating. + +Workflow Task heartbeating means that if a Local Activity approaches approximately 80% of the Workflow Task Timeout (10 seconds by default), the Worker completes the current Workflow Task and requests a new one from the Temporal Service. This renews the Worker's authorization to continue executing the Workflow and allows the Local Activity to keep running without exceeding the Workflow Task Timeout. + +This enables Local Activities to run longer than a single Workflow Task timeout, but it comes with tradeoffs: + +- Each Workflow Task heartbeat adds additional Events to the Event history. +- Signals and other external Workflow events are not processed until the Local Activities finish. +- Commands generated by the Workflow are not sent to the Temporal Service until one of the following occurs: + - the Local Activity completes + - the next Workflow Task heartbeat occurs + +If your operation regularly approaches the Workflow Task timeout, it is usually better implemented as a regular Activity. + +You can monitor your Local Activities by using the [`local_activity_total` metric](/references/sdk-metrics#local_activity_total) to determine how many Local Activity Executions have been made. You can also check your Event History for `RecordMarker` entries. + +## Failure and durability + +Regular Activities are durably tracked by the Temporal Service. Scheduling, completion, retries, and failures are all recorded in the Event history. If a Worker crashes after an Activity completes, the completed Activity is not executed again. + +Local Activities behave differently. A Local Activity result becomes durable only when the enclosing Workflow Task successfully completes and records a `MarkerRecorded` event. Until then, execution exists only in Worker memory. + +If the Worker crashes before the Workflow Task completes, the Workflow Task is retried, causing Local Activities executed during that Workflow Task to run again. + +Because of this behavior, Local Activities provide at-least-once execution semantics and should always be idempotent. You can learn more about the behavior at shutdown in the [Workers section on Local Activities](encyclopedia/workers/worker-shutdown#local-activities). + +Once a `MarkerRecorded` event has been written to the Event history, replay uses the recorded result rather than executing the Local Activity again. + +## Mixing Local Activities and regular Activities + +A Workflow can freely combine Local Activities and regular Activities. + +For example: + +```mermaid +flowchart LR + LA[Local Activity A] --> LB[Local Activity B] --> RC[Regular Activity C] +``` + +When the Workflow Task completes after scheduling Activity C, the Worker sends commands similar to: + +- `MarkerRecorded` (Local Activity A) +- `MarkerRecorded` (Local Activity B) +- `ScheduleActivityTask` (Activity C) + +If Activity C later fails or retries, the Workflow replays using the recorded markers for A and B. Those completed Local Activities are not executed again because their results have already been persisted in the Event history. Only Activity C is retried according to its Retry Policy. + +The only time completed Local Activities execute again is if the Worker fails before their completion markers are recorded. Long retry intervals are inefficient because retries eventually require Workflow Timers and additional Event history entries. + +## Choosing between regular Activities and Local Activities + +Choose a regular Activity unless you have a specific need for the performance optimization that Local Activities provide. + +Use a Local Activity when: + +- execution completes in a few seconds +- retries are expected to be short +- the operation is idempotent +- low latency is more important than full durability +- routing, rate limiting, and separate Activity Workers are unnecessary + +Use a regular Activity when: + +- interacting with external systems +- execution may take longer than a few seconds +- retries may span minutes or hours +- Activity heartbeating is required +- strong durability guarantees are important + +Regular Activities are the right choice for most applications. Local Activities are an optimization for specialized, high-throughput workloads where minimizing latency and Event history size outweighs their reduced durability guarantees. -Some Activity Executions are very short-living and do not need the queuing semantic, flow control, rate limiting, and routing capabilities. -For this case, Temporal supports the Local Activity feature. +### Use cases for Local Activities -The main benefit of Local Activities is that they use less Temporal Service resources (for example, fewer History events) and have much lower latency overhead (because no need to roundtrip to the Temporal Service) compared to normal Activity Executions. -However, Local Activities are subject to shorter durations and a lack of rate limiting. +Good use cases for Local Activities include: -Consider using Local Activities for functions that are the following: +- Lightweight data transformations +- Small computations +- Reading from an in-memory cache +- Fast local filesystem operations +- High-throughput Workflows with many very short-lived operations -- can be implemented in the same binary as the Workflow that calls them. -- do not require global rate limiting. -- do not require routing to a specific Worker or Worker pool. -- no longer than a few seconds, inclusive of retries. +Use regular Activities for: -If it takes longer than 80% of the Workflow Task Timeout (which is 10 seconds by default), the Worker will ask the Temporal Service to create a new Workflow Task to extend the "lease" for processing the Local Activity. -The Worker will continue doing so until the Local Activity has completed. -This is called Workflow Task Heartbeating. -The drawbacks of long-running Local Activities are: +- Network requests +- Database operations +- External API calls +- Long-running work +- Operations requiring durable retries +- Operations that benefit from Task Queue routing or rate limiting -- Each new Workflow Task results in 3 more Events in History. -- The Workflow won't get notified of new events like Signals and completions until the next Workflow Task Heartbeat. -- New Commands created by the Workflow concurrently with the Local Activity will not be sent to the Temporal Service until either the Local Activity completes or the next Workflow Task Heartbeat. +### Activity vs. Local Activity -Using a Local Activity without understanding its limitations can cause various production issues. -**We recommend using regular Activities unless your use case requires very high throughput and large Activity fan outs of very short-lived Activities.** -More guidance in choosing between [Local Activity vs Activity](https://community.temporal.io/t/local-activity-vs-activity/290/3) is available in our forums. +| Feature | Activity | Local Activity | +| --- | --- | --- | +| Execution | Activity Worker | Workflow Worker | +| Task Queue | Yes | No | +| Service round trip | Required | Not required | +| Latency | Higher | Lower | +| Event history | Fully recorded | `MarkerRecorded` on completion | +| Heartbeating | Activity heartbeats | Workflow Task heartbeating | +| Retry durability | Durable | At-least-once until marker is recorded | +| Signal responsiveness | Unaffected | Delayed while the Workflow Task executes | +| Best for | General-purpose work | Short, high-throughput operations | diff --git a/docs/encyclopedia/activities/standalone-activity.mdx b/docs/encyclopedia/activities/standalone-activity.mdx index e9b7aa3659..1b459e8958 100644 --- a/docs/encyclopedia/activities/standalone-activity.mdx +++ b/docs/encyclopedia/activities/standalone-activity.mdx @@ -16,12 +16,12 @@ tags: - Durable Execution --- -import ThemedImage from '@theme/ThemedImage'; -import { ReleaseNoteHeader } from '@site/src/components'; +import { CaptionedImage, ReleaseNoteHeader } from '@site/src/components'; Available in [Temporal Cloud](#temporal-cloud-support) and in Temporal Server v1.31.0 or higher (included in [Temporal CLI](#temporal-cli-support) v1.7.0 or higher). @@ -37,16 +37,11 @@ Standalone Activities are Temporal’s [job queue](/evaluate/development-product the simplest way to run durable, retryable tasks on Temporal.
- - - +
A Standalone Activity is a top-level [Activity Execution](/activity-execution) started directly by a @@ -84,7 +79,8 @@ webhook, syncing data, or executing a single function reliably with built-in ret - Arbitrary length jobs with heartbeats for liveness and checkpointing progress - At-least-once execution by default with native retry policy and timeouts - At-most-once execution if retry max attempts is 1 -- Addressable - get an Activity ID / Run ID and get the result, cancel, and terminate +- Addressable - get an Activity ID / Run ID and get the result, cancel, pause, unpause, reset, update options, and + terminate - Deduplication - with conflict policy: (USE_EXISTING, ...), reuse policy: (REJECT_DUPLICATES, ...) - Separate ID space from Workflows - Standalone Activities are a different kind of top-level execution - Priority and fairness - multi-tenant fairness, weighted priority tiers, and safeguards against starvation of lower-weighted tasks @@ -110,8 +106,10 @@ failed, etc.) - not the number of queued tasks. The Public Preview of Standalone Activities has some known limitations: -- Pause, reset, and update options are not supported in Public Preview but scheduled for GA. - `TerminateExisting` conflict policy / `TerminateIfRunning` reuse policy is not supported yet. +- Pause, Unpause, Reset, and Update Options are available through the CLI, gRPC API, and the UI. The UI shows these + controls when connected to Temporal Server v1.32.0 or later. +- Visibility Query batch operations don't target Standalone Activities. ## Temporal CLI support @@ -137,7 +135,14 @@ temporal version 1.7.0 (Server 1.31.0, UI 2.49.1) ``` The `temporal activity` subcommand supports Standalone Activities with commands including: `start`, -`execute`, `result`, `list`, `count`, `describe`, `cancel`, and `terminate`. +`execute`, `result`, `list`, `count`, `describe`, `cancel`, `pause`, `unpause`, `reset`, +`update-options`, and `terminate`. + +For Pause, Unpause, Reset, and Update Options, provide `--activity-id` and omit `--workflow-id`. Add `--run-id` to target +a specific Activity Run; otherwise, the command targets the latest run. + +For details about Pause, Unpause, Reset, and Update Options, see +[Standalone Activity operator commands](/activity-operations#standalone-activity-operator-commands). The Temporal Dev Server has Standalone Activities enabled by default for local testing. diff --git a/docs/encyclopedia/detecting-activity-failures.mdx b/docs/encyclopedia/detecting-activity-failures.mdx index b1c65e3722..891c4a28a8 100644 --- a/docs/encyclopedia/detecting-activity-failures.mdx +++ b/docs/encyclopedia/detecting-activity-failures.mdx @@ -73,8 +73,7 @@ This timeout has two primary use cases: **The default Schedule-To-Start Timeout is ∞ (infinity).** If this timeout is used, we recommend setting this timeout to the maximum time a Workflow Execution is willing to wait for an Activity Execution in the presence of all possible Worker outages, and have a concrete plan in place to reroute Activity Tasks to a different Task Queue. -This timeout **does not** trigger any retries regardless of the Retry Policy, as a retry would place the Activity Task back into the same Task Queue. -We do not recommend using this timeout unless you know what you are doing. +This timeout is non-retryable by design. It **does not** trigger any retries regardless of the Retry Policy, as a retry would place the Activity Task back into the same Task Queue. In most cases, we recommend monitoring the `temporal_activity_schedule_to_start_latency` metric to know when Workers slow down picking up Activity Tasks, instead of setting this timeout. diff --git a/docs/encyclopedia/event-history/dotnet.mdx b/docs/encyclopedia/event-history/dotnet.mdx index 6e3c86261f..2285fbf436 100644 --- a/docs/encyclopedia/event-history/dotnet.mdx +++ b/docs/encyclopedia/event-history/dotnet.mdx @@ -253,7 +253,7 @@ the Workflow Execution, ultimately resulting in a completed execution that's ide ## Example of a Non-Deterministic Workflow {/* #Example-of-Non-Deterministic-Workflow */} Now that Replay has been covered, this section will explain why Workflows need to be -[deterministic](https://docs.temporal.io/workflow-definition#deterministic-constraints) in order for Replay to work. +[deterministic](/workflow-definition#deterministic-constraints) in order for Replay to work. A Workflow is deterministic if every execution of its Workflow Definition produces the same Commands in the same sequence given the same input. @@ -301,11 +301,11 @@ number generator. /> Note that non-deterministic failures do not fail the Workflow Execution by default. A non-deterministic failure is -considered a [Workflow Task Failure](https://docs.temporal.io/references/failures#workflow-task-failures) which is +considered a [Workflow Task Failure](/references/failures#workflow-task-failures) which is considered a transient failure, meaning it retries over and over. Users can also fix the source of non-determinism, perhaps by removing the Activity, and then restart the Workers. This means that this type of failure can recover by itself. You can also use a strategy called versioning to address this non-determinism error. See -[versioning](https://docs.temporal.io/develop/dotnet/workflows/versioning) to learn more. +[versioning](/develop/dotnet/workflows/versioning) to learn more. For more information on how Temporal handles Durable Execution or to see these slides in a video format with more explanation, check out our free, self-paced courses: [Temporal 102](https://learn.temporal.io/courses/temporal_102/) and diff --git a/docs/encyclopedia/event-history/event-history.mdx b/docs/encyclopedia/event-history/event-history.mdx index 71bdf20b67..84f743748c 100644 --- a/docs/encyclopedia/event-history/event-history.mdx +++ b/docs/encyclopedia/event-history/event-history.mdx @@ -28,7 +28,7 @@ keywords: - worker process execution --- -With Temporal, your Workflows can seamlessly recover from crashes. This is made possible by the [Event History](https://docs.temporal.io/workflow-execution/event), a complete and durable log of everything that has happened in the lifecycle of a Workflow Execution, as well as the ability of the Temporal Service to durably persist the Events during Replay. +With Temporal, your Workflows can seamlessly recover from crashes. This is made possible by the [Event History](/workflow-execution/event), a complete and durable log of everything that has happened in the lifecycle of a Workflow Execution, as well as the ability of the Temporal Service to durably persist the Events during Replay. Temporal uses the Event History to record every step taken along the way. Each time your Workflow Definition makes an API call to execute an Activity or start a Timer for instance, it doesn’t perform the action directly. Instead, it sends a Command to the Temporal Service. diff --git a/docs/encyclopedia/event-history/go.mdx b/docs/encyclopedia/event-history/go.mdx index 737bcc6558..1be260abbb 100644 --- a/docs/encyclopedia/event-history/go.mdx +++ b/docs/encyclopedia/event-history/go.mdx @@ -267,7 +267,7 @@ the Workflow Execution, ultimately resulting in a completed execution that's ide ## Example of a Non-Deterministic Workflow {/* #Example-of-Non-Deterministic-Workflow */} Now that Replay has been covered, this section will explain why Workflows need to be -[deterministic](https://docs.temporal.io/workflow-definition#deterministic-constraints) in order for Replay to work. +[deterministic](/workflow-definition#deterministic-constraints) in order for Replay to work. A Workflow is deterministic if every execution of its Workflow Definition produces the same Commands in the same sequence given the same input. @@ -314,7 +314,7 @@ number generator. /> Note that non-deterministic failures do not fail the Workflow Execution by default. A non-deterministic failure is -considered a [Workflow Task Failure](https://docs.temporal.io/references/failures#workflow-task-failures) which is +considered a [Workflow Task Failure](/references/failures#workflow-task-failures) which is considered a transient failure, meaning it retries over and over. Users can also fix the source of non-determinism, perhaps by removing the Activity, and then restart the Workers. This means that this type of failure can recover by itself. You can also use a strategy called versioning to address this non-determinism error. See diff --git a/docs/encyclopedia/event-history/java.mdx b/docs/encyclopedia/event-history/java.mdx index b2f03e68cc..6d0c34fe3d 100644 --- a/docs/encyclopedia/event-history/java.mdx +++ b/docs/encyclopedia/event-history/java.mdx @@ -257,7 +257,7 @@ the Workflow Execution, ultimately resulting in a completed execution that's ide ## Example of a Non-Deterministic Workflow {/* #Example-of-Non-Deterministic-Workflow */} Now that Replay has been covered, this section will explain why Workflows need to be -[deterministic](https://docs.temporal.io/workflow-definition#deterministic-constraints) in order for Replay to work. +[deterministic](/workflow-definition#deterministic-constraints) in order for Replay to work. A Workflow is deterministic if every execution of its Workflow Definition produces the same Commands in the same sequence given the same input. @@ -306,11 +306,11 @@ number generator. /> Note that non-deterministic failures do not fail the Workflow Execution by default. A non-deterministic failure is -considered a [Workflow Task Failure](https://docs.temporal.io/references/failures#workflow-task-failures) which is +considered a [Workflow Task Failure](/references/failures#workflow-task-failures) which is considered a transient failure, meaning it retries over and over. Users can also fix the source of non-determinism, perhaps by removing the Activity, and then restart the Workers. This means that this type of failure can recover by itself. You can also use a strategy called versioning to address this non-determinism error. See -[versioning](https://docs.temporal.io/develop/java/versioning) to learn more. +[versioning](/develop/java/workflows/versioning) to learn more. For more information on how Temporal handles Durable Execution or to see these slides in a video format with more explanation, check out our free, self-paced courses: [Temporal 102](https://learn.temporal.io/courses/temporal_102/) and diff --git a/docs/encyclopedia/event-history/python.mdx b/docs/encyclopedia/event-history/python.mdx index 7cd0e0670f..fa096f48b0 100644 --- a/docs/encyclopedia/event-history/python.mdx +++ b/docs/encyclopedia/event-history/python.mdx @@ -259,7 +259,7 @@ the Workflow Execution, ultimately resulting in a completed execution that's ide ## Example of a Non-Deterministic Workflow {/* #Example-of-Non-Deterministic-Workflow */} Now that Replay has been covered, this section will explain why Workflows need to be -[deterministic](https://docs.temporal.io/workflow-definition#deterministic-constraints) in order for Replay to work. +[deterministic](/workflow-definition#deterministic-constraints) in order for Replay to work. A Workflow is deterministic if every execution of its Workflow Definition produces the same Commands in the same sequence given the same input. @@ -308,7 +308,7 @@ number generator. /> Note that non-deterministic failures do not fail the Workflow Execution by default. A non-deterministic failure is -considered a [Workflow Task Failure](https://docs.temporal.io/references/failures#workflow-task-failures) which is +considered a [Workflow Task Failure](/references/failures#workflow-task-failures) which is considered a transient failure, meaning it retries over and over. Users can also fix the source of non-determinism, perhaps by removing the Activity, and then restart the Workers. This means that this type of failure can recover by itself. You can also use a strategy called versioning to address this non-determinism error. See diff --git a/docs/encyclopedia/event-history/typescript.mdx b/docs/encyclopedia/event-history/typescript.mdx index 6c5fd773ff..80907f0351 100644 --- a/docs/encyclopedia/event-history/typescript.mdx +++ b/docs/encyclopedia/event-history/typescript.mdx @@ -261,7 +261,7 @@ the Workflow Execution, ultimately resulting in a completed execution that's ide ## Example of a Non-Deterministic Workflow {/* #Example-of-Non-Deterministic-Workflow */} Now that Replay has been covered, this section will explain why Workflows need to be -[deterministic](https://docs.temporal.io/workflow-definition#deterministic-constraints) in order for Replay to work. +[deterministic](/workflow-definition#deterministic-constraints) in order for Replay to work. A Workflow is deterministic if every execution of its Workflow Definition produces the same Commands in the same sequence given the same input. @@ -310,11 +310,11 @@ number generator. /> Note that non-deterministic failures do not fail the Workflow Execution by default. A non-deterministic failure is -considered a [Workflow Task Failure](https://docs.temporal.io/references/failures#workflow-task-failures) which is +considered a [Workflow Task Failure](/references/failures#workflow-task-failures) which is considered a transient failure, meaning it retries over and over. Users can also fix the source of non-determinism, perhaps by removing the Activity, and then restart the Workers. This means that this type of failure can recover by itself. You can also use a strategy called versioning to address this non-determinism error. See -[versioning](https://docs.temporal.io/develop/typescript/workflows/versioning) to learn more. +[versioning](/develop/typescript/workflows/versioning) to learn more. For more information on how Temporal handles Durable Execution or to see these slides in a video format with more explanation, check out our free, self-paced courses: [Temporal 102](https://learn.temporal.io/courses/temporal_102/) and diff --git a/docs/encyclopedia/nexus/nexus-operations.mdx b/docs/encyclopedia/nexus/nexus-operations.mdx index cb307dc67c..eb96a5fc7e 100644 --- a/docs/encyclopedia/nexus/nexus-operations.mdx +++ b/docs/encyclopedia/nexus/nexus-operations.mdx @@ -47,6 +47,7 @@ An Operation's lifecycle spans scheduling, reliable delivery with retries, handl ::: **Caller side:** A caller Workflow executes a Nexus Operation through a [Nexus Endpoint](/nexus/endpoints) using the Temporal SDK. +A [Standalone Nexus Operation](/standalone-nexus-operation) is a top-level Nexus Operation Execution started directly by a Client, without using a caller Workflow. **Handler side:** [Nexus Services](/nexus/services) and their Operations are registered with a Worker that polls the Endpoint's target Task Queue. Operations are defined using SDK builder functions: @@ -188,9 +189,9 @@ The Schedule-to-Close timeout limits the total duration from when the Operation This is the overall timeout for the entire Operation. The Nexus Machinery [automatically retries](#automatic-retries) failed requests internally until this timeout is exceeded, at which point the Operation fails with a [NexusOperationTimedOut](/references/events#nexusoperationtimedout) event. -This timeout covers the full [Nexus Operation lifecycle](https://docs.temporal.io/nexus/operations#operation-lifecycle). Asynchronous Operations are scheduled, started, and completed. Synchronous Operations don't have an intermediate started state because they complete as part of the start request. +This timeout covers the full [Nexus Operation lifecycle](/nexus/operations#operation-lifecycle). Asynchronous Operations are scheduled, started, and completed. Synchronous Operations don't have an intermediate started state because they complete as part of the start request. -In Temporal Cloud, the [maximum Schedule-to-Close timeout is 60 days](https://docs.temporal.io/cloud/limits#nexus-operation-duration-limits). +In Temporal Cloud, the [maximum Schedule-to-Close timeout is 60 days](/cloud/limits#nexus-operation-duration-limits). ### Schedule-to-Start timeout {/* #schedule-to-start-timeout */} diff --git a/docs/encyclopedia/nexus/nexus.mdx b/docs/encyclopedia/nexus/nexus.mdx index f60a195248..62ed4dd4c5 100644 --- a/docs/encyclopedia/nexus/nexus.mdx +++ b/docs/encyclopedia/nexus/nexus.mdx @@ -49,6 +49,7 @@ Caller and handler Workflows are siblings that communicate across Namespace boun A [Nexus Service](/nexus/services) is a named collection of [Nexus Operations](/nexus/operations) that a team exposes. Operations abstract the underlying implementation - callers don't need to know whether an Operation starts a Workflow, sends a Signal, runs a Query, or executes other reliable code. +If you need to orchestrate multiple Nexus Operations, call them from a Workflow. But if you just need to execute a single Nexus Operation across Namespace boundaries, use a [Standalone Nexus Operation](/standalone-nexus-operation). The [Operation lifecycle](/nexus/operations#operation-lifecycle) supports two modes: diff --git a/docs/encyclopedia/nexus/standalone-nexus-operation.mdx b/docs/encyclopedia/nexus/standalone-nexus-operation.mdx new file mode 100644 index 0000000000..f1be08f98f --- /dev/null +++ b/docs/encyclopedia/nexus/standalone-nexus-operation.mdx @@ -0,0 +1,175 @@ +--- +id: standalone-nexus-operation +title: Standalone Nexus Operation +sidebar_label: Standalone Nexus Operation +description: Learn about Standalone Nexus Operations in Temporal, their benefits, execution model, and when to use them. +slug: /standalone-nexus-operation +toc_max_heading_level: 4 +keywords: + - standalone nexus operation + - nexus operation execution + - execute nexus operation + - explanation + - term +tags: + - Concepts + - Nexus + - Durable Execution +--- + +import ThemedImage from '@theme/ThemedImage'; +import { ReleaseNoteHeader, SdkGuideLinks } from '@site/src/components'; + + + APIs are experimental and may be subject to backwards-incompatible changes. + + +## What is a Standalone Nexus Operation? {/* #standalone-nexus-operation */} + +A Standalone Nexus Operation is a top-level [Nexus Operation Execution](/nexus/operations) started +directly by a [Client](/encyclopedia/temporal-sdks#temporal-client), without using a caller +Workflow. Instead of calling a Nexus Operation from within a Workflow Definition, you execute it +directly from a Nexus Client created from the Temporal SDK Client and bound to a +[Nexus Endpoint](/nexus/endpoints) and Service. + + + +Standalone Nexus Operations use the same Service contract, Operation handlers, and Worker setup as +Workflow-driven Operations — only the caller side differs. The same Operation can be executed as a +Standalone Nexus Operation and as a Workflow-driven Nexus Operation with no handler code changes. + +If you need to orchestrate multiple Nexus Operations, call them from a [Workflow](/workflows). But if +you just need to execute a single Nexus Operation across Namespace boundaries, use a Standalone +Nexus Operation. + +### Get started with your SDK + +To make your first standalone Nexus call: + +1. Create a caller Namespace where durable Nexus Operations will be started. +2. Import the Temporal SDK in your language of choice. +3. Execute a Nexus Operation through your caller Namepace using the Temporal SDK Client. + +See the SDK guides below for details: + + + +## Use cases + +### Make Nexus calls from anywhere + +Standalone Nexus Operations let you make Nexus calls from anywhere using the Temporal SDK Client. +Any code that can construct a Temporal Client can durably hand off a Nexus Operation to Temporal, such as a UI backend or BFF, a non-Temporal microservice, an HTTP handler, or a script. +You still get automatic retries, built-in rate limiting and circuit breaking, and full execution visibility: the reliability of Nexus without the overhead of a caller (proxy) Workflow. + +## Key features + +- Execute any Nexus Operation as a top-level primitive without the overhead of a caller Workflow +- Same Service contract, Operation handlers, and Worker setup as Workflow-driven Operations +- Supports both synchronous and asynchronous (Workflow-backed) Nexus Operations +- At-least-once execution with automatic retries by the Nexus Machinery +- Get a handle to retrieve results, with the Operation token for asynchronous Operations +- List and count Standalone Nexus Operation Executions using [List Filter](/list-filter) queries +- Execute the same Operation from a Workflow or standalone with no handler code changes + +## Migrate a single-Operation caller Workflow to a Standalone Nexus Operation {/* #migrate-from-caller-workflow */} + +A common pattern is a caller Workflow whose only purpose is to invoke a single Nexus Operation. +When that's all the Workflow does, you can drop the caller Workflow entirely and call the +Operation as a Standalone Nexus Operation. The handler Namespace's Service contract, Operation +handlers, and Workers do not change — only the caller side does. + +Considerations when migrating: + +- If the caller Workflow does more than one Operation call, or has business logic between calls, + keep the Workflow. Standalone Nexus Operations are only a fit when a single top-level Operation + is all the Workflow did. +- Pick a stable Operation ID if you previously relied on Workflow ID reuse semantics for + deduplication. +- Update Visibility queries that filtered by caller Workflow attributes. + +For SDK-specific syntax, see the per-SDK guide — for example +[Go: Standalone Nexus Operations](/develop/go/nexus/standalone-operations). + +## Observability {/* #observability */} + +You can use [List Filters](/list-filter) to query Standalone Nexus Operation Executions by Endpoint, +Service, Operation, status, and other attributes using the SDK. + +`CountNexusOperations` returns the total number of Standalone Nexus Operation Executions matching a +filter. This is the total count of executions (running, completed, failed, etc.) — not the number of +queued tasks. + +## Pre-release limitations + +Standalone Nexus Operations are at Pre-release. APIs are experimental and may be subject to +backwards-incompatible changes. + +## Temporal CLI support + +Standalone Nexus Operations require a Pre-release build of the [Temporal CLI](/cli) that includes +the `temporal nexus operation` command family. All commands are Experimental. + +Download for your platform: + +```bash title="macOS (Apple Silicon)" +curl -L https://github.com/temporalio/cli/releases/download/v1.7.4-standalone-nexus-operations/temporal_cli_1.7.4-standalone-nexus-operations_darwin_arm64.tar.gz | tar xz +``` + +```bash title="macOS (Intel)" +curl -L https://github.com/temporalio/cli/releases/download/v1.7.4-standalone-nexus-operations/temporal_cli_1.7.4-standalone-nexus-operations_darwin_amd64.tar.gz | tar xz +``` + +```bash title="Linux (arm64)" +curl -L https://github.com/temporalio/cli/releases/download/v1.7.4-standalone-nexus-operations/temporal_cli_1.7.4-standalone-nexus-operations_linux_arm64.tar.gz | tar xz +``` + +```bash title="Linux (amd64)" +curl -L https://github.com/temporalio/cli/releases/download/v1.7.4-standalone-nexus-operations/temporal_cli_1.7.4-standalone-nexus-operations_linux_amd64.tar.gz | tar xz +``` + +Verify the installation: + +```bash +./temporal --version +# temporal version 1.7.4-standalone-nexus-operations +``` + +Move the binary to your PATH or run it from the current directory as `./temporal`. + +:::warning + +- The standard `brew install temporal` or `brew upgrade temporal` does not include Standalone Nexus Operation support during Pre-release. +- If you see `unknown command "nexus operation"` then you are using the standard Temporal CLI instead of the Pre-release version above. +- If you see `Standalone Nexus Operations are disabled` when running commands against a local dev server, then your server does not support the feature. Start it with the Pre-release dev server build above, which enables Standalone Nexus Operations by default. +- If you don't see the Standalone Nexus Operations button in the Temporal UI, make sure the UI is running the latest release and has the `EnableStandaloneNexusOperations` feature flag enabled. + +::: + +The `temporal nexus operation` subcommand supports Standalone Nexus Operations with commands +including: `start`, `execute`, `result`, `list`, `count`, `describe`, `cancel`, and `terminate`. +The Nexus Endpoint must already exist on the server — create it with +[`temporal operator nexus endpoint create`](/cli/command-reference/operator#create-1). + +## Temporal Cloud support + +Standalone Nexus Operations in Temporal Cloud is available as a Pre-release feature. diff --git a/docs/encyclopedia/temporal-client.mdx b/docs/encyclopedia/temporal-client.mdx index bcdff87294..9ac763738f 100644 --- a/docs/encyclopedia/temporal-client.mdx +++ b/docs/encyclopedia/temporal-client.mdx @@ -26,8 +26,10 @@ The most common operations that a Temporal Client allows you to perform are the - Query a Workflow Execution - Signal a Workflow Execution - Start and manage [Standalone Activities](/standalone-activity) directly, without involving a Workflow +- Start a [Standalone Nexus Operation](/standalone-nexus-operation) directly, without using a caller Workflow -A Standalone Activity is a top-level [Activity Execution](/activity-execution) started directly by a [Client](/encyclopedia/temporal-sdks#temporal-client), without using a Workflow. +A Standalone Activity is a top-level [Activity Execution](/activity-execution) started directly by a Client, without using a Workflow. +A Standalone Nexus Operation is a top-level [Nexus Operation Execution](/nexus/operations) started directly by a Client, without using a caller Workflow. ## SDK guides {/* #sdk-guides */} diff --git a/docs/encyclopedia/temporal-sdks.mdx b/docs/encyclopedia/temporal-sdks.mdx index b7fd3a233d..40bd88039f 100644 --- a/docs/encyclopedia/temporal-sdks.mdx +++ b/docs/encyclopedia/temporal-sdks.mdx @@ -271,7 +271,7 @@ A Workflow executes Activities (other functions that interact with external syst This Workflow code, while executing, can be paused, resumed, and migrated across physical machines without losing state. -When a Workflow calls the API to execute an Activity, the Worker sends a [Command](https://docs.temporal.io/references/commands) back to the Temporal Service. The Temporal Service creates Activity Tasks in response which the same or a different Worker can then pick up and begin executing. In this way, the Worker and Temporal Service work together to incrementally execute Workflow code in a reliable way. +When a Workflow calls the API to execute an Activity, the Worker sends a [Command](/references/commands) back to the Temporal Service. The Temporal Service creates Activity Tasks in response which the same or a different Worker can then pick up and begin executing. In this way, the Worker and Temporal Service work together to incrementally execute Workflow code in a reliable way. We discuss this more in detail in [The SDK and Temporal Service relationship](/encyclopedia/temporal-sdks#sdk-and-cluster-relationship) section. The SDK APIs also enable developers to write code that more genuinely maps to their process. This is because without a specialized SDK, developers might have to write a lot of boilerplate code. This can lead to code that's hard to maintain, difficult to understand, or that doesn't directly correspond to the underlying business process. diff --git a/docs/encyclopedia/temporal-service/multi-cluster-replication.mdx b/docs/encyclopedia/temporal-service/multi-cluster-replication.mdx index c4f46721ef..23d13f192a 100644 --- a/docs/encyclopedia/temporal-service/multi-cluster-replication.mdx +++ b/docs/encyclopedia/temporal-service/multi-cluster-replication.mdx @@ -39,7 +39,7 @@ This feature must be enabled through a Dynamic Config flag per [Global Namespace When the feature is enabled, Tasks are sent to the Parent Task Queue partition that matches that Namespace, if it exists. All Visibility APIs can be used against active and standby Clusters. -This enables [Temporal UI](https://docs.temporal.io/web-ui) to work seamlessly for Global Namespaces. +This enables [Temporal UI](/web-ui) to work seamlessly for Global Namespaces. Applications making API calls directly to the Temporal Visibility API continue to work even if a Global Namespace is in standby mode. However, they might see a lag due to replication delay when querying the Workflow Execution state from a standby Cluster. diff --git a/docs/encyclopedia/temporal-service/persistence.mdx b/docs/encyclopedia/temporal-service/persistence.mdx index 2a638ce0fb..bedf39e77f 100644 --- a/docs/encyclopedia/temporal-service/persistence.mdx +++ b/docs/encyclopedia/temporal-service/persistence.mdx @@ -51,7 +51,7 @@ With Temporal Server version 1.20 and later, advanced Visibility features are av Temporal tests compatibility by spanning the minimum and maximum stable major versions for each supported database. The following versions are used in our test pipelines and actively tested before we release any version of Temporal: -- **Cassandra v3.11 and v4.0** +- **Cassandra v3.11, v4.0, and 5.0.4 and later** (Cassandra 5 versions earlier than 5.0.4 are not supported) - **PostgreSQL 13.18, 14.15, 15.10 and 16.6** - **MySQL v5.7 and v8.0** (specifically 8.0.19+ due to a bug) diff --git a/docs/encyclopedia/workers/serverless-workers.mdx b/docs/encyclopedia/workers/serverless-workers.mdx index 87671dca02..4f4da17a37 100644 --- a/docs/encyclopedia/workers/serverless-workers.mdx +++ b/docs/encyclopedia/workers/serverless-workers.mdx @@ -17,8 +17,7 @@ tags: - Serverless --- -import CaptionedImage from '@site/src/components/images/CaptionedImage'; -import { ReleaseNoteHeader } from '@site/src/components'; +import { CaptionedImage, ReleaseNoteHeader } from '@site/src/components'; To request access during Pre-release, create a [support ticket](/cloud/support#support-ticket) or contact your account diff --git a/docs/encyclopedia/workers/worker-versioning.mdx b/docs/encyclopedia/workers/worker-versioning.mdx index 7c863f9afa..59951818b5 100644 --- a/docs/encyclopedia/workers/worker-versioning.mdx +++ b/docs/encyclopedia/workers/worker-versioning.mdx @@ -85,7 +85,7 @@ A Worker Deployment Version moves through the following states: 1. **Inactive**: The version exists because a Worker with that version has polled the server. If this version never becomes Active, it will never be Draining or Drained. 2. **Active**: The version is either Current or Ramping, so it is accepting new Workflows and existing auto-upgrade Workflows. 3. **Draining**: The version has open pinned Workflows running on it, but stopped being Current or Ramping, usually because a newer version has been deployed. It is possible to be Draining and have no open pinned Workflows for a short time, since the drainage status is updated only periodically. -4. **Drained**: The version was draining and now all the pinned Workflows that were running on it are closed. Closed Workflows may still re-run some code paths if they are [Queried](https://docs.temporal.io/sending-messages#sending-queries) within their [Retention Period](https://docs.temporal.io/temporal-service/temporal-server#retention-period) and Workers with that version are still polling. +4. **Drained**: The version was draining and now all the pinned Workflows that were running on it are closed. Closed Workflows may still re-run some code paths if they are [Queried](/sending-messages#sending-queries) within their [Retention Period](/temporal-service/temporal-server#retention-period) and Workers with that version are still polling. ## Continue-as-new, Child Workflow, and Retry Semantics {/* #inheritance-semantics */} @@ -95,10 +95,10 @@ When Workflows start new runs (e.g. by continuing-as-new or retrying) the new ru A Workflow can start a new run through: -- Starting a [Child Workflow](https://docs.temporal.io/child-workflows) -- Invoking [Continue-As-New](https://docs.temporal.io/workflow-execution/continue-as-new) -- Retrying per its [Retry Policy](https://docs.temporal.io/encyclopedia/retry-policies) -- Starting another iteration of a [Cron Job](https://docs.temporal.io/cron-job) (superseded by [Schedules](https://docs.temporal.io/schedule)) +- Starting a [Child Workflow](/child-workflows) +- Invoking [Continue-As-New](/workflow-execution/continue-as-new) +- Retrying per its [Retry Policy](/encyclopedia/retry-policies) +- Starting another iteration of a [Cron Job](/cron-job) (superseded by [Schedules](/schedule)) ### Inheritance Rules Overview diff --git a/docs/encyclopedia/workflow-message-passing/handling-messages.mdx b/docs/encyclopedia/workflow-message-passing/handling-messages.mdx index 4d268f80ce..cd4478402d 100644 --- a/docs/encyclopedia/workflow-message-passing/handling-messages.mdx +++ b/docs/encyclopedia/workflow-message-passing/handling-messages.mdx @@ -177,16 +177,16 @@ The following content applies in every SDK except the Go SDK. See below. #### Exceptions in Signals In Signal handlers, throw [Application Failures](/references/failures#application-failure) only for unrecoverable errors, because the entire Workflow will fail. -Similarly, allowing a failing Activity or Child Workflow to exhaust its retries, so that it throws an [Activity Failure](https://docs.temporal.io/references/failures#activity-failure) or [Child Workflow Failure](https://docs.temporal.io/references/failures#child-workflow-failure) will cause the entire Workflow to fail. -Note that for Activities, this will only happen if you change the default Activity [Retry Policy](https://docs.temporal.io/encyclopedia/retry-policies), since by default they retry forever. +Similarly, allowing a failing Activity or Child Workflow to exhaust its retries, so that it throws an [Activity Failure](/references/failures#activity-failure) or [Child Workflow Failure](/references/failures#child-workflow-failure) will cause the entire Workflow to fail. +Note that for Activities, this will only happen if you change the default Activity [Retry Policy](/encyclopedia/retry-policies), since by default they retry forever. If you throw any other exception, by default, it will cause a [Workflow Task Failure](/references/failures#workflow-task-failures). This means the Workflow will get stuck and will retry the handler periodically until the exception is fixed, for example by a code change. #### Exceptions in Updates Doing any of the following will fail the Update and cause the client to receive the error: -- Reject the Update by throwing any exception from your [Validator](https://docs.temporal.io/handling-messages#update-validators). -- Allow a failing Activity or Child Workflow to exhaust its retries, so that it throws an [Activity Failure](https://docs.temporal.io/references/failures#activity-failure) or [Child Workflow Failure](https://docs.temporal.io/references/failures#child-workflow-failure). Note that for Activities, this will only happen if you change the default Activity [Retry Policy](https://docs.temporal.io/encyclopedia/retry-policies), since by default they retry forever. +- Reject the Update by throwing any exception from your [Validator](/handling-messages#update-validators). +- Allow a failing Activity or Child Workflow to exhaust its retries, so that it throws an [Activity Failure](/references/failures#activity-failure) or [Child Workflow Failure](/references/failures#child-workflow-failure). Note that for Activities, this will only happen if you change the default Activity [Retry Policy](/encyclopedia/retry-policies), since by default they retry forever. - Throw an [Application Failure](/references/failures#application-failure) from your Update handler. Unlike with Signals, the Workflow will keep going in these cases. diff --git a/docs/encyclopedia/workflow-message-passing/sending-messages.mdx b/docs/encyclopedia/workflow-message-passing/sending-messages.mdx index a178eb9e92..3c7c10cbe4 100644 --- a/docs/encyclopedia/workflow-message-passing/sending-messages.mdx +++ b/docs/encyclopedia/workflow-message-passing/sending-messages.mdx @@ -139,12 +139,12 @@ For open source server users, Temporal Server version [Temporal Server version 1 ::: Update-with-Start sends an Update request, starting a Workflow if necessary. -A [`WorkflowIDConflictPolicy`](https://docs.temporal.io/workflow-execution/workflowid-runid#workflow-id-conflict-policy) must be specified. +A [`WorkflowIDConflictPolicy`](/workflow-execution/workflowid-runid#workflow-id-conflict-policy) must be specified. Workflow ID and Update ID can be used as idempotency keys as follows: - If the Workflow exists and you provided an Update ID, and the Update exists in the latest Workflow Run, then Update-With-Start attaches to the existing Update (regardless of `WorkflowIDConflictPolicy`) - If the Workflow is closed, it attaches only if the Update has completed. -- Otherwise it uses [`WorkflowIDConflictPolicy`](https://docs.temporal.io/workflow-execution/workflowid-runid#workflow-id-conflict-policy) and [`WorkflowIDReusePolicy`](https://docs.temporal.io/workflow-execution/workflowid-runid#workflow-id-reuse-policy) as usual to determine whether to start a Workflow, and then starts a new Update immediately. +- Otherwise it uses [`WorkflowIDConflictPolicy`](/workflow-execution/workflowid-runid#workflow-id-conflict-policy) and [`WorkflowIDReusePolicy`](/workflow-execution/workflowid-runid#workflow-id-reuse-policy) as usual to determine whether to start a Workflow, and then starts a new Update immediately. Update-With-Start is great for latency-sensitive use cases: diff --git a/docs/encyclopedia/workflow/workflow-execution/event.mdx b/docs/encyclopedia/workflow/workflow-execution/event.mdx index c8f8517d8f..cb13941802 100644 --- a/docs/encyclopedia/workflow/workflow-execution/event.mdx +++ b/docs/encyclopedia/workflow/workflow-execution/event.mdx @@ -156,7 +156,7 @@ Possible values are as follows: Anyone who has permission to read Event history in the Namespace (ReadOnly access and above) can see the Principal (and the metadata such as email address). -To enable Principal Attribution for a Namespace, contact [Temporal Cloud support](https://docs.temporal.io/cloud/support#support-ticket). +To enable Principal Attribution for a Namespace, contact [Temporal Cloud support](/cloud/support#support-ticket). ### Self-hosted Temporal diff --git a/docs/encyclopedia/workflow/workflow-execution/workflow-execution.mdx b/docs/encyclopedia/workflow/workflow-execution/workflow-execution.mdx index a0929e577d..218c0bb62c 100644 --- a/docs/encyclopedia/workflow/workflow-execution/workflow-execution.mdx +++ b/docs/encyclopedia/workflow/workflow-execution/workflow-execution.mdx @@ -10,7 +10,6 @@ keywords: tags: - Concepts - Workflows - --- import { CaptionedImage } from '@site/src/components'; @@ -25,6 +24,7 @@ This page provides an overview of Workflow Execution: - [Workflow Execution Chain](#workflow-execution-chain) - [Memo](#memo) - [State Transition](#state-transition) +- [Workflow cache](#workflow-cache) ## What is a Workflow Execution? {/* #workflow-execution */} @@ -137,8 +137,9 @@ A Workflow Execution can be either _Open_ or _Closed_. An _Open_ status means that the Workflow Execution is able to make progress. -- Running: The only Open status for a Workflow Execution. - When the Workflow Execution is Running, it is either actively progressing or is waiting on something. +- Running: The Workflow Execution is actively progressing or is waiting on something. +- Paused: The Workflow Execution has been [Paused](/encyclopedia/workflow/workflow-pause). It remains Open, but the Temporal Service doesn't + dispatch new Workflow Tasks until the Workflow Execution is Unpaused. #### Closed @@ -206,3 +207,11 @@ For more information on Workflow Execution, please refer to the following subpag - [Continue-as-New](/workflow-execution/continue-as-new) - [Timers and Start Delay](/workflow-execution/timers-delays) ::: + +## Workflow cache + +A Workflow Cache is an in-memory LRU (least recently used) cache maintained by Workers that stores the state of Workflow Executions they have processed. + +When a Worker picks up a Workflow Task, it caches the Workflow's state in memory. This allows the Worker to continue processing subsequent Tasks for that Workflow without having to fetch the full Event History from the server and replay it from scratch. + +This caching mechanism is closely tied to [Sticky Execution](/sticky-execution). The Temporal Service directs future Workflow Tasks to the same Worker that cached the Workflow, via a dedicated "Sticky Queue". If the cached Workflow is evicted, to make room for another for example, the Worker must replay the Event History to restore its state before continuing. \ No newline at end of file diff --git a/docs/encyclopedia/workflow/workflow-pause.mdx b/docs/encyclopedia/workflow/workflow-pause.mdx new file mode 100644 index 0000000000..ca84413e20 --- /dev/null +++ b/docs/encyclopedia/workflow/workflow-pause.mdx @@ -0,0 +1,200 @@ +--- +id: workflow-pause +title: Workflow Pause +sidebar_label: Workflow Pause +description: Pause and Unpause a Workflow Execution to stop new progress without terminating the Workflow or losing state. +toc_max_heading_level: 4 +keywords: + - Workflow + - Workflow Pause + - pause + - unpause + - operations +tags: + - Concepts + - Workflows +--- + +import { ReleaseNoteHeader } from "@site/src/components"; + +This page discusses the following: + +- [Pause](#pause) +- [Unpause](#unpause) +- [Observability](#observability) +- [What Workflow Pause is not](#what-workflow-pause-is-not) +- [Important considerations](#important-considerations) +- [Limitations](#limitations) + +Workflow Pause is an operational control that stops a specific [Workflow Execution](/workflow-execution) from making new +progress until it is Unpaused. + +Use Workflow Pause when you need to hold a Workflow in place without terminating the Workflow Execution or losing Workflow state. For example, during an incident, investigation, or dependency outage. + +You can Pause and Unpause Workflow Executions through the [CLI](/cli/command-reference/workflow), the UI, or directly +through the gRPC API. + + + In Temporal + Cloud, Pre-release access is invite-only; contact your Temporal account team or + [open a support ticket](/cloud/support#support-ticket). For self-hosted Temporal, Workflow Pause requires Temporal + Server v1.30.0+ with `frontend.WorkflowPauseEnabled` enabled. The Temporal CLI requires v1.6.0+. Self-hosted UI support + requires v2.47.2+. + + +## Operations summary + +| Operation | What it does | CLI | +| ----------------- | ---------------------------------------------------- | -------------------------------------------------------------------- | +| [Pause](#pause) | Stops a Workflow Execution from making new progress. | [`temporal workflow pause`](/cli/command-reference/workflow#pause) | +| [Unpause](#unpause) | Resumes a Paused Workflow Execution. | [`temporal workflow unpause`](/cli/command-reference/workflow#unpause) | + +## Pause + +Pause stops a Workflow Execution from making new progress until it is Unpaused. + +### When to pause + +- A downstream dependency is unhealthy, and you want to stop the Workflow from continuing until the dependency recovers. +- You need time to inspect or fix an issue before the Workflow schedules more work. +- You're rolling out a Worker change and want to hold specific Workflow Executions until the deploy is complete. +- You want to prevent a Workflow from continuing without terminating it or losing its current state. + +### What happens when you Pause a Workflow + +- No new Workflow Tasks are dispatched. Workflow code doesn't make progress while the Workflow is Paused. +- No new Activity Tasks are dispatched. Activity retries and newly scheduled Activity Tasks don't start while the + Workflow is Paused. +- In-flight Activity attempts aren't interrupted. Activity attempts that are already running can complete, fail, + time out, and Heartbeat normally. +- Activity completion, failure, and timeout events can be recorded. Workflow code doesn't process those events until + the Workflow is Unpaused. +- Signals are accepted and recorded. Signal handlers run after the Workflow is Unpaused. +- Timers keep advancing. Timers that fire while Paused are processed by Workflow code after the Workflow is + Unpaused. +- Updates and Queries are rejected. +- Cancel requests are recorded. Cancellation takes effect after the Workflow is Unpaused. +- Terminate requests still terminate the Workflow immediately. +- Pause is recorded in Event History. The Event includes the identity, reason, and request ID. + +### CLI usage + +```bash +temporal workflow pause \ + --workflow-id YourWorkflowId \ + --reason "Pausing while downstream service recovers" +``` + +See the [CLI reference for `temporal workflow pause`](/cli/command-reference/workflow#pause) for all options. + +## Unpause + +Unpause resumes a Paused Workflow Execution. + +### When to unpause + +- The dependency or service that caused the Pause has recovered. +- Investigation is complete and the Workflow can continue. +- A deploy or configuration change is complete. +- You're ready for queued Signals, recorded cancellation, or blocked work to proceed. + +### What happens when you Unpause a Workflow + +- Workflow Tasks and Activity Tasks can be dispatched again. +- Signals received and Timers that fired while Paused are processed by the Workflow. +- Pending Activity retries can proceed unless the Activity itself is Paused. +- The Workflow continues from its existing state. +- Unpause is recorded in Event History. The Event includes the identity, reason, and request ID. + +### CLI usage + +```bash +temporal workflow unpause \ + --workflow-id YourWorkflowId \ + --reason "Downstream service recovered" +``` + +See the [CLI reference for `temporal workflow unpause`](/cli/command-reference/workflow#unpause) for all options. + +## Observability + +Paused Workflow Executions have `ExecutionStatus="Paused"`. + +You can find Paused Workflow Executions with a [List Filter](/list-filter): + +```sql +ExecutionStatus = "Paused" +``` + +You can also inspect a Workflow Execution with: + +```bash +temporal workflow describe \ + --workflow-id YourWorkflowId +``` + +Pause and Unpause are recorded in [Event History](/workflow-execution/event#event-history) with the identity, reason, and +request ID. + +## What Workflow Pause is not + +- Workflow Pause isn't Activity Pause. To interrupt a Heartbeating Activity attempt, use + [Activity Pause](/activity-operations#pause). +- Workflow Pause isn't bulk Workflow Pause. It applies to one Workflow Execution at a time. +- Workflow Pause doesn't pause [Schedules](/schedule), Task Queues, or Namespaces. +- Workflow Pause isn't an auto-pause policy. Operators must Pause and Unpause manually through an operational interface. + +## Important considerations + +### Scope + +- Workflow Pause applies to a single Workflow Execution. +- It doesn't pause [Child Workflows](/child-workflows), Activities, Schedules, Task Queues, or Namespaces. + +### Timeouts and in-flight work + +- Workflow Pause doesn't stop time from passing. Workflow Execution timeouts, Workflow Run timeouts, Activity timeouts, + and Timer deadlines continue to advance. +- Activity attempts that are already running aren't interrupted. They can complete, fail, Heartbeat, or time out while + the Workflow is Paused. + +### Messages and lifecycle operations + +- Signals are accepted while Paused and processed after the Workflow is Unpaused. +- Updates and Queries are rejected while Paused. +- Cancel requests are recorded while Paused, but cancellation takes effect after the Workflow is Unpaused. +- [Terminate](/evaluate/development-production-features/interrupt-workflow) requests still terminate the Workflow + immediately. +- Resetting a Paused Workflow terminates the current Run and starts a new Run from the selected reset point. + +### Schedules, cron, and operation timing + +- A Paused Run is still considered active for [Schedule](/schedule) overlap policy decisions. +- For [Cron jobs](/cron-job), Pause affects the current Run. Missed cron intervals are not backfilled. +- Pause and Unpause aren't a transactional boundary. Work can make progress while the Workflow is Unpaused. + +### Billing + +In Temporal Cloud, pausing a Workflow Execution stops progress but doesn't end the Workflow Execution. +Its Event History continues to count toward [Active Storage](/cloud/pricing#storage) until the Workflow Execution +completes, fails, times out, is canceled, or is terminated. + +## Limitations + +- Workflow Pause must be enabled before it can be used. +- Workflow Pause applies to a single Workflow Execution. Bulk Workflow Pause is not supported. +- Auto-pause policies are not supported. +- Workflow Pause is an operational control. It isn't intended to be called from Workflow code. +- There is no indicator that all in-flight Activity attempts have completed after a Workflow is Paused. + +## Interaction with Activity operations + +Workflow Pause and [Activity Operations](/activity-operations) are separate controls. + +Workflow Pause stops progress for a Workflow Execution. Activity Operations act on a specific Activity Execution. + +If a Workflow is Paused, Activity retries in that Workflow are blocked. If an individual Activity is also Paused, both +the Workflow and the Activity must be Unpaused before that Activity can proceed. + +Workflow Pause doesn't interrupt Activity attempts that are already running. To interrupt a Heartbeating Activity +attempt, use [Activity Pause](/activity-operations#pause). diff --git a/docs/evaluate/development-production-features/cloud-automation.mdx b/docs/evaluate/development-production-features/cloud-automation.mdx index b97ac687eb..1269604fc3 100644 --- a/docs/evaluate/development-production-features/cloud-automation.mdx +++ b/docs/evaluate/development-production-features/cloud-automation.mdx @@ -29,13 +29,13 @@ Cloud Automation offers secure authentication across all interfaces, reducing er **Key Features:** -- [Secure API Keys](https://docs.temporal.io/cloud/api-keys): Manage resources securely with Temporal Cloud API Keys. -- [Temporal Cloud CLI (tcld)](https://docs.temporal.io/cloud/tcld): Automate operations directly from the command line. -- [Terraform Provider for Cloud](https://docs.temporal.io/cloud/terraform-provider#prerequisites): Scale effortlessly with infrastructure-as-code. +- [Secure API Keys](/cloud/api-keys): Manage resources securely with Temporal Cloud API Keys. +- [Temporal Cloud CLI (tcld)](/cloud/tcld): Automate operations directly from the command line. +- [Terraform Provider for Cloud](/cloud/terraform-provider#prerequisites): Scale effortlessly with infrastructure-as-code. - - + + diff --git a/docs/evaluate/development-production-features/low-latency.mdx b/docs/evaluate/development-production-features/low-latency.mdx index 06c513c1c8..5daa62c5b4 100644 --- a/docs/evaluate/development-production-features/low-latency.mdx +++ b/docs/evaluate/development-production-features/low-latency.mdx @@ -41,6 +41,6 @@ Temporal Cloud provides lower latency, making it suitable for latency-sensitive, - + diff --git a/docs/evaluate/development-production-features/temporal-nexus.mdx b/docs/evaluate/development-production-features/temporal-nexus.mdx index 64c6a7f652..2b706e500b 100644 --- a/docs/evaluate/development-production-features/temporal-nexus.mdx +++ b/docs/evaluate/development-production-features/temporal-nexus.mdx @@ -71,13 +71,11 @@ Connect Temporal Applications across teams, domains, regions, and clouds with: Use the following decision tree to help determine if Nexus is right for your use case:
- - - +
## Get started {/* #learn-more */} diff --git a/docs/evaluate/temporal-cloud/actions.mdx b/docs/evaluate/temporal-cloud/actions.mdx index 0c6b3a63c0..871840df3e 100644 --- a/docs/evaluate/temporal-cloud/actions.mdx +++ b/docs/evaluate/temporal-cloud/actions.mdx @@ -58,7 +58,7 @@ endpoints can differ based on what is visible to a system. Action Categories and billing API. Usage and Billing data will have the most complete Actions data while Event history and metrics will help with -detailed _estimates_. For example, [History Event Types](https://docs.temporal.io/workflow-execution/event) are provided +detailed _estimates_. For example, [History Event Types](/workflow-execution/event) are provided for transparency, but do not always have a simple 1:1 relationship with Action Types. The Categories, Action types, and available endpoints are listed in the following sections: @@ -142,9 +142,9 @@ available endpoints are listed in the following sections: ## Signal {/* #signal */} -- **Signal sent**. An Action occurs for every [Signal](https://docs.temporal.io/sending-messages#sending-signals), +- **Signal sent**. An Action occurs for every [Signal](/sending-messages#sending-signals), whether sent from a Client or from a Workflow. Also, one total action occurs for any - [Signal-With-Start](https://docs.temporal.io/sending-messages#signal-with-start), regardless of whether the Workflow + [Signal-With-Start](/sending-messages#signal-with-start), regardless of whether the Workflow starts. | Usage Name | Metric Name | History Event Type | @@ -156,7 +156,7 @@ available endpoints are listed in the following sections: ## Query {/* #query */} - **Query received by Worker**. An Action occurs for every - [Query](https://docs.temporal.io/sending-messages#sending-queries), including viewing the call stack in the Temporal + [Query](/sending-messages#sending-queries), including viewing the call stack in the Temporal Cloud UI, which results in a Query behind the scenes. `__temporal_workflow_metadata` is a built-in query used to retrieve workflow metadata at runtime and is excluded. @@ -167,9 +167,9 @@ available endpoints are listed in the following sections: ## Update {/* #update */} - **Update received by Worker**. - - Occurs for every successful [Update](https://docs.temporal.io/sending-messages#sending-updates) and every - [rejected](https://docs.temporal.io/handling-messages#update-validators) Update. - - This includes [Update-With-Start](https://docs.temporal.io/sending-messages#update-with-start), and is in addition + - Occurs for every successful [Update](/sending-messages#sending-updates) and every + [rejected](/handling-messages#update-validators) Update. + - This includes [Update-With-Start](/sending-messages#update-with-start), and is in addition to the start Action in the case when the Workflow starts as well. - De-duplicated Updates that share an Update ID do _not_ count as an Action. diff --git a/docs/evaluate/temporal-cloud/limits.mdx b/docs/evaluate/temporal-cloud/limits.mdx index 8c125802e2..236243de32 100644 --- a/docs/evaluate/temporal-cloud/limits.mdx +++ b/docs/evaluate/temporal-cloud/limits.mdx @@ -121,7 +121,7 @@ Nexus requests (such as starting a Nexus Operation or sending a Nexus completion If too many Nexus requests are sent at once, they may be throttled, along with other requests to the Namespace. Throttling limits the rate at which Nexus requests are processed, ensuring the RPS limit isn't exceeded. -You can request this limit be manually raised by [opening a support ticket](https://docs.temporal.io/cloud/support#support-ticket). +You can request this limit be manually raised by [opening a support ticket](/cloud/support#support-ticket). :::note diff --git a/docs/evaluate/temporal-cloud/pricing.mdx b/docs/evaluate/temporal-cloud/pricing.mdx index 44c6e0a107..7e82bf5e93 100644 --- a/docs/evaluate/temporal-cloud/pricing.mdx +++ b/docs/evaluate/temporal-cloud/pricing.mdx @@ -21,8 +21,6 @@ tags: - Support --- -import { DiscoverableDisclosure } from '@site/src/components'; - Temporal Cloud is a consumption-based service. You pay only for what you use. Our pricing reflects your use of [_Actions_](#action), [_Storage_](#storage), and [_Support_](/cloud/support#support). @@ -40,7 +38,7 @@ For more information, visit the [Billing](/cloud/billing) page. This section explains the basis of the Temporal Cloud pricing model and how it works. Your total invoice each calendar month is the combination of Temporal Cloud consumption ([Actions](#action) and [Storage](#storage)), and a [Temporal Cloud Plan](#base_plans) that includes [Support](/cloud/support#support). -### Temporal Cloud Plans {/* #base_plans */} +### Temporal Cloud plans {/* #base_plans */} **How plans work** @@ -57,13 +55,13 @@ Prices are outlined in the following table: | | Essentials | Business | Enterprise | Mission Critical | | ----------------- | ------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | | Support Targeting | Basic use | Production deployments
that scale | Enterprise deployments
w/ stringent uptime demands | Mission-critical applications
w/ the highest support needs | -| Support Features | Access to Support | - P0 Response Times | - P0: \<30 Min, 24/7
- Private Slack | - P0: \<15 Min, 24/7
- Private Slack
- Dedicated DSE | +| Support Features | Access to Support | - P0 Response Times | - P0: \<30 Min, 24/7
- Private Slack | - P0: \<15 Min, 24/7
- Private Slack
- Dedicated Platform Architect | | Product Features | - 1M Actions
- 1 GB Active Storage
- 40 GB Retained Storage | - Commit discounts
- SAML included
- SCIM (Add-on)
- 2.5M Actions
- 2.5 GB Active Storage
- 100 GB Retained Storage | - Commit discounts
- SAML included
- SCIM included
- 10M Actions
- 10 GB Active Storage
- 400 GB Retained Storage | - Commit discounts
- SAML included
- SCIM included
- 10M Actions
- 10 GB Active Storage
- 400 GB Retained Storage | | Plan Pricing | Greater of
- $100/mo or
- 5% of Usage Spend | Greater of
- $500/mo or
- 10% of Usage Spend | Priced annually:
[contact Sales](mailto:sales@temporal.io) for details | Priced annually:
[contact Sales](mailto:sales@temporal.io) for details | | Usage Pricing | [Pay-As-You-Go](#payg) Pricing | Choose from
[Pay-As-You-Go](#payg) or [Commitment Pricing](#commitment-pricing) | Choose from
[Pay-As-You-Go](#payg) or [Commitment Pricing](#commitment-pricing) | Choose from
[Pay-As-You-Go](#payg) or [Commitment Pricing](#commitment-pricing) | Please note, partial months are prorated to the day. -Find a complete description of Support offerings and response times in our [Support](/cloud/support) documentation. +Find a complete description of Support offerings and response times in the [Support](/cloud/support) documentation. :::note Converting GB to GBH @@ -156,8 +154,8 @@ For additional storage within a calendar month, you are billed for Active and Re :::tip Storage costs are also affected by Temporal System Workflows that back features such as: -- [Schedules](https://docs.temporal.io/schedule): Each Scheduled Workflow contributes to storage usage. Supplied inputs, outputs, and failures all account for the storage usage incurred from Scheduled Workflows. -- [Batch jobs](https://docs.temporal.io/cli/command-reference/batch): Batch Workflow executions also consume storage. +- [Schedules](/schedule): Each Scheduled Workflow contributes to storage usage. Supplied inputs, outputs, and failures all account for the storage usage incurred from Scheduled Workflows. +- [Batch jobs](/cli/command-reference/batch): Batch Workflow executions also consume storage. These Workflow executions contribute to overall active and retained storage consumption. @@ -339,7 +337,7 @@ When upgrading an existing Namespace, some points to consider: **How does pricing for Fairness work?** -When [Fairness](/develop/task-queue-priority-fairness#task-queue-fairness) is enabled on a Namespace, an additional `0.1` Action is charged per Action in that Namespace for each hour the feature is on, regardless of whether individual Workflows or Activities use fairness keys. +When [Fairness](/develop/task-queue-priority-fairness#task-queue-fairness) is enabled on a Namespace, an additional `0.1` Action is charged per Action in that Namespace during each hour the feature is on, regardless of whether individual Workflows or Activities use fairness keys. The examples below use a Namespace, `your-namespace`, that normally generates 10,000 Actions per hour. diff --git a/docs/evaluate/temporal-cloud/support.mdx b/docs/evaluate/temporal-cloud/support.mdx index 565a59246a..01cfb4feff 100644 --- a/docs/evaluate/temporal-cloud/support.mdx +++ b/docs/evaluate/temporal-cloud/support.mdx @@ -51,7 +51,7 @@ We offer two levels of support defined by their availability and SLAs in the fol | ------------------------------------------------ | -------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------ | | **Availability**
(Based on
Time-zones) | **P0–3**: 9–5 Mon–Fri | **P0–3**: 9–5 Mon–Fri | **P0**: 24×7,
(On Page Service)
**P1–3**: 9–5 Mon–Fri | **P0**: 24×7 (On Page Service)
**P1**: 9-5, 7 days/week
**P2–3**: Mon–Fri | | **Response Time** | **P0**: 1 business day
**P1**: 1 business day
**P2**: 1 business day
**P3**: 2 business days | **P0**: 2 business hours
**P1**: 2 business hours
**P2**: 1 business day
**P3**: 2 business days | **P0**: 30 minutes
**P1**: 1 business hour
**P2**: 4 business hours
**P3**: 1 business day | **P0**: 15 minutes
**P1**: 1 business hour
**P2**: 4 business hours
**P3**: 1 business day | -| **DSE** | - | - | Add-on | DSE Included (1 Unit) | +| **Dedicated Platform Architect** | - | - | Add-on | Included (1 Unit) | | **Channels** | Community
Temporal Support Portal | Community
Temporal Support Portal | Community
Temporal Support Portal
Private Slack | Community
Temporal Support Portal
Private Slack | :::info Business Hours Timezones @@ -76,10 +76,9 @@ Business Hours will be specified in your contract, including one of three locati P0: 24×7 (On Page Service) is offered for Enterprise and Mission Critical accounts. ::: - For pricing details of these support levels, please visit our [pricing page](/cloud/pricing). - -## Temporal Dedicated Support Engineer {/* #dedicated-support-engineer */} +## Temporal Dedicated Platform Architect {/* #dedicated-platform-architect */} + Customers on the Mission Critical Plan and (by opting in) Enterprise customers receive access to a Dedicated Support Engineer. We offer: diff --git a/docs/evaluate/understanding-temporal.mdx b/docs/evaluate/understanding-temporal.mdx index 25b9f65e06..4092d5a644 100644 --- a/docs/evaluate/understanding-temporal.mdx +++ b/docs/evaluate/understanding-temporal.mdx @@ -155,7 +155,7 @@ The Temporal CLI provides developers with direct access to a Temporal Service fo ### Event History -With Temporal, your Workflows can seamlessly recover from crashes. This is made possible by the [Event History](https://docs.temporal.io/workflow-execution/event), a complete and durable log of everything that has happened in the lifecycle of a Workflow Execution, as well as the ability of the Temporal Service to durably persist the Events during [Replay](/workflow-execution#replay). +With Temporal, your Workflows can seamlessly recover from crashes. This is made possible by the [Event History](/workflow-execution/event), a complete and durable log of everything that has happened in the lifecycle of a Workflow Execution, as well as the ability of the Temporal Service to durably persist the Events during [Replay](/workflow-execution#replay). Temporal uses the Event History to record every step taken along the way. Each time your Workflow Definition makes an API call to execute an Activity or start a Timer for instance, it doesn’t perform the action directly. Instead, it sends a Command to the Temporal Service. diff --git a/docs/glossary.md b/docs/glossary.md index f18575809f..144476baad 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -564,7 +564,7 @@ Activity Execution, reaches a Closed status. #### [Schedule-To-Start Timeout](/encyclopedia/detecting-activity-failures#schedule-to-start-timeout) A Schedule-To-Start Timeout is the maximum amount of time that is allowed from when an Activity Task is placed in a Task -Queue to when a Worker picks it up from the Task Queue. +Queue to when a Worker picks it up from the Task Queue. This timeout is non-retryable by design. @@ -805,6 +805,10 @@ are executed with the same Worker without requiring you to manually specify Task In day-to-day conversations, the term "Workflow" frequently denotes either a Workflow Type, a Workflow Definition, or a Workflow Execution. +#### [Workflow cache](/workflow-execution#workflow-cache) + +An in-memory cache on a Worker that holds the state of Workflow Executions it has processed so later Workflow Tasks can avoid a full Event History replay. Used with [Sticky Execution](/sticky-execution). See also [Workflow Cache Tuning](/develop/worker-performance#workflow-cache-tuning). + #### [Workflow Definition](/workflow-definition) diff --git a/docs/guides/assets/saga-pattern/keep-business-moving-architectural-comparison.png b/docs/guides/assets/saga-pattern/keep-business-moving-architectural-comparison.png new file mode 100644 index 0000000000..31dbb0f95b Binary files /dev/null and b/docs/guides/assets/saga-pattern/keep-business-moving-architectural-comparison.png differ diff --git a/docs/guides/assets/saga-pattern/keep-business-moving-architecture-diagram.svg b/docs/guides/assets/saga-pattern/keep-business-moving-architecture-diagram.svg new file mode 100644 index 0000000000..c7e9b39906 --- /dev/null +++ b/docs/guides/assets/saga-pattern/keep-business-moving-architecture-diagram.svg @@ -0,0 +1,4 @@ +Operator / AgentActivitiesWorkflowClientOperator / AgentActivitiesWorkflowClientCorrection cycleStart homeLoanWorkflowExecute ActivityApplicationFailure.nonRetryable()Set PENDING_FIX, await condition()Query blocked WorkflowsPENDING_FIX listSignal: retry({key, value})Patch data, wake conditionRetry ActivitySuccessAdvance to next Activity \ No newline at end of file diff --git a/docs/guides/assets/saga-pattern/keep-business-moving-cover.png b/docs/guides/assets/saga-pattern/keep-business-moving-cover.png new file mode 100644 index 0000000000..945e0ab001 Binary files /dev/null and b/docs/guides/assets/saga-pattern/keep-business-moving-cover.png differ diff --git a/docs/guides/assets/saga-pattern/keep-business-moving-event-history.png b/docs/guides/assets/saga-pattern/keep-business-moving-event-history.png new file mode 100644 index 0000000000..78532324d5 Binary files /dev/null and b/docs/guides/assets/saga-pattern/keep-business-moving-event-history.png differ diff --git a/docs/guides/assets/saga-pattern/keep-business-moving-fix-employer.png b/docs/guides/assets/saga-pattern/keep-business-moving-fix-employer.png new file mode 100644 index 0000000000..e529c55896 Binary files /dev/null and b/docs/guides/assets/saga-pattern/keep-business-moving-fix-employer.png differ diff --git a/docs/guides/assets/saga-pattern/keep-business-moving-list-view.png b/docs/guides/assets/saga-pattern/keep-business-moving-list-view.png new file mode 100644 index 0000000000..863eda1547 Binary files /dev/null and b/docs/guides/assets/saga-pattern/keep-business-moving-list-view.png differ diff --git a/docs/guides/assets/saga-pattern/keep-business-moving-success.png b/docs/guides/assets/saga-pattern/keep-business-moving-success.png new file mode 100644 index 0000000000..8e4bdb94ef Binary files /dev/null and b/docs/guides/assets/saga-pattern/keep-business-moving-success.png differ diff --git a/docs/guides/assets/temporary-rate-limit-increases/orchestrate-temporary-rate-limit-increases.svg b/docs/guides/assets/temporary-rate-limit-increases/orchestrate-temporary-rate-limit-increases.svg new file mode 100644 index 0000000000..9a4f59fcc9 --- /dev/null +++ b/docs/guides/assets/temporary-rate-limit-increases/orchestrate-temporary-rate-limit-increases.svg @@ -0,0 +1 @@ +removeTRUs(Activity)deprovisionTRU(Child Workflow)addTRUs(Activity)provisionTRU(Workflow)my-service (gRPC)ClientremoveTRUs(Activity)deprovisionTRU(Child Workflow)addTRUs(Activity)provisionTRU(Workflow)my-service (gRPC)Client1. Initial Request2. Start Workflow3. Provisioning Activity4. Async Child StartParent-Close-Policy: ABANDON5. Return Response6 & 7. TTL TimerTimer fires after 60m8. Deprovisioning ActivitygRPC Request(Input: apslimit, namespace)Start Workflow executionSchedule ActivityActivity CompleteStart Async Child WorkflowReturn SuccessSchedule Timer (TTL: 60 mins)Schedule ActivityActivity Complete \ No newline at end of file diff --git a/docs/guides/assets/worker-execution-affinity/worker-execution-affinity.svg b/docs/guides/assets/worker-execution-affinity/worker-execution-affinity.svg new file mode 100644 index 0000000000..38b1130641 --- /dev/null +++ b/docs/guides/assets/worker-execution-affinity/worker-execution-affinity.svg @@ -0,0 +1,4 @@ +Worker 1Local Diskfile-processing-abc123Task QueueWorker 1(UUID: abc123)file-processing-sharedTask QueueWorkflowWorker 1Local Diskfile-processing-abc123Task QueueWorker 1(UUID: abc123)file-processing-sharedTask QueueWorkflowNeed to process fileon same workerRoute all activitiesto Worker 1's unique queue10GB video fileProcess locallyNo network transfer!Upload to S3get_available_task_queue()Activity dispatched"file-processing-abc123"download_file(url)Routed to Worker 1Save file to /tmp/video.mp4"/tmp/video.mp4"process_file(path)Same workerRead /tmp/video.mp4Write /tmp/video-processed.mp4"/tmp/video-processed.mp4"upload_file(path)Same workerRead /tmp/video-processed.mp4Clean up files"https://s3.../video.mp4" \ No newline at end of file diff --git a/docs/guides/durable-gaming-sessions.mdx b/docs/guides/durable-gaming-sessions.mdx new file mode 100644 index 0000000000..53daca359c --- /dev/null +++ b/docs/guides/durable-gaming-sessions.mdx @@ -0,0 +1,1302 @@ +--- +id: durable-gaming-sessions +title: Player Sessions That Survive Anything +description: Protect player sessions from backend failures by using the Actor pattern. +sidebar_label: Durable gaming sessions +toc_max_heading_level: 3 +author: Cecil Phillip +tags: + - Workflows + - Activities + - Continue-as-new + - Child workflows + - Heartbeat + - Task queues + - Workers +--- + +**by Cecil Phillip** + +Multiplayer game backends run into the same wall at scale: player state lives in memory, pinned to a server, and when that server goes down the session goes with it. Reconnection logic is painful, distributed state is expensive, and cross-player coordination is worse. This guide shows how to model each player as their own durable, independently-running process — one that survives crashes, executes game actions, and stays addressable for as long as the session lasts. + +## Problem statement + +When a game server goes down, every player on it loses their session. Inventory, position, active buffs, current room — gone. Reconnection has to reconstruct state from whatever made it to the database before the crash, and something always gets missed. Scaling makes this worse: keeping per-player state consistent across a cluster requires distributed caches, locking, and sync logic that grows in complexity with every new game feature. Multi-step operations between players — trading items, triggering combat, changing rooms — need coordination logic that's hard to get right and even harder to debug when it goes wrong. + +## Solution + +Each player gets their own Workflow — a durable, uniquely-addressable process identified by their player ID. It holds inventory, health, position, and room state in memory. It doesn't just store state: it acts. Joining a room, executing a combat move, updating the leaderboard — each of these happens through an Activity the Workflow drives directly. When the server goes down, the Workflow resumes on another Worker from exactly the point it left off, with no state lost and no reconnection logic required. Sessions that run for weeks don't accumulate unbounded history — Continue-As-New handles that automatically. + +## Outcomes + +After working through this guide, you'll have a session system where: + +- **Server crashes don't end player sessions**: state is durable and resumes on any available Worker without custom recovery logic +- **Game actions are reliable**: room joins, combat moves, and leaderboard updates execute through Activities with automatic retries — partial failures don't corrupt session state +- **Sessions run as long as needed**: Continue-As-New manages history growth so a session that lasts weeks costs no more to run than one that lasts minutes +- **Every player is independently addressable**: any service can send a command, Query state, or trigger an Update using the player's Workflow Id +- **Active sessions are queryable by operators**: Search Attributes let you find who's in which room, how long a session has been running, or which players are idle + +## Background and best practices + +### What is an Actor Workflow? + +The Actor Workflow pattern extends the Workflow Entity pattern. An Workflow Entity is a long-running Workflow that represents a *thing*: it holds state, responds to messages, and exposes that state through Queries. An Actor Workflow is an Entity Workflow that *does things*. The distinction is behavioral: + +- **An entity is a thing. An actor is a thing that does things.** +- An Workflow Entity is analogous to a distributed data cache: it stores and retrieves state. +- An Actor Workflow is analogous to a distributed object with operations: it stores state *and* executes side effects on behalf of the entity it represents. + +An Entity Workflow that takes action is an Actor Workflow. Concrete examples of actors include a Workflow that scales up a database when load increases, starts a car engine when a driver authenticates, submits an order when a customer confirms checkout, or manages a player session in a multiplayer game by joining rooms and executing combat moves. + +AI agents are often modeled as Actor Workflows. They receive instructions (Temporal Signals or Updates), maintain conversational state, and take action by calling external services (Activities) such as language model APIs, tool integrations, and data retrieval systems. + +### The actor model and Temporal + +The actor model, originally formalized by Carl Hewitt in 1973, defines computation in terms of *actors*: autonomous units that communicate exclusively through asynchronous messages. Each actor: + +1. Has a private, encapsulated state that other actors can not directly access. +2. Processes messages one at a time from an inbox (mailbox). +3. Can send messages to other actors. +4. Can create new actors. +5. Can change its own internal state in response to a message. + +Temporal Workflows map naturally to this model: + +| Actor model concept | Temporal equivalent | +|---|---| +| Actor identity | Workflow Id | +| Actor mailbox | Workflow Event History (Signals or Updates) | +| Processing a message | Signal handler, Update handler | +| Sending a message to another actor | Signaling an external Workflow | +| Creating a new actor | Starting a new Workflow Execution | +| Actor supervision | Retry Policies, parent-child relationships | +| Actor state persistence | Durable Execution (automatic via Event History) | + +Traditional actor frameworks such as Akka, and Orleans require you to configure persistence, message delivery guarantees, and supervision hierarchies. Temporal provides these capabilities as platform primitives. Every Workflow Execution is durably persisted. Every Signal is reliably delivered. Every Activity execution is automatically retried according to its Retry Policy. You do not need to implement a persistence plugin, configure a journal, or write custom supervision strategies. + +### Why actors matter for player sessions + +A player session maps almost perfectly onto the actor model. Each player has private state that nothing else should write to directly — inventory, health, position, active buffs. They receive messages from clients, servers, and other players. They take real-world actions in response. And their lifecycle is unpredictable: a session might last twenty minutes or three months, with idle stretches in between. + +Modeling each player as an Actor Workflow gives you a single authoritative representation of that session. There is no cache to invalidate, no database row to lock, no sticky session to maintain. The Workflow *is* the session, and it's always reachable by player ID. + +### Event History and Continue-As-New + +Every Workflow Execution in Temporal produces an append-only Event History. This history has a limit of 50,000 Events or 50 MB. For actor Workflows that run indefinitely, Event History growth must be managed proactively. + +Continue-As-New atomically completes the current Workflow Execution and starts a new one with the same Workflow Id, carrying forward any state you provide as arguments. From the perspective of external callers, the Workflow Id remains the same. Signals sent during the transition are not lost; the Temporal Service buffers them for the new execution. + +Before executing Continue-As-New, you must ensure that all in-progress Signal and Update handlers have finished processing. The `workflow.all_handlers_finished` predicate provides this guarantee. + +### Signal volume limits + +Two hard limits govern Signal volume per Workflow Execution: + +- **10,000 total Signals** per Execution. Continue-As-New resets this counter, so an entity that transitions regularly is not constrained by it in practice. +- **2,000 pending Signals** (unprocessed Signals buffered by the server) at any one time. If this limit is reached, new Signals are rejected. + +Beyond these limits, the practical throughput ceiling is Worker-side: each Signal triggers a Workflow Task, and a Workflow processes one Workflow Task at a time. For typical short tasks, this yields a few Workflow Tasks per second per Workflow Execution. + +If your use case requires higher Signal ingestion rates — for example, streaming real-time game telemetry — consider one of these approaches: + +- **Batch events into a single Signal payload.** Instead of one Signal per game event, batch several events into a list and send them as a single Signal. The handler appends the entire batch to the queue in one shot. +- **Use an aggregation layer.** Route high-frequency event streams through a service (such as a message broker or aggregator) that batches events before forwarding them as Signals. This decouples the producer's throughput from the Workflow's processing rate. + +### Activity Heartbeating + +Long-running Activities, such as maintaining a WebSocket connection to a game room or processing a large batch of leaderboard updates, should emit Heartbeats. A Heartbeat serves two purposes: + +1. It tells the Temporal Service that the Activity is still making progress. If heartbeats stop arriving within the configured Heartbeat Timeout, the Temporal Service considers the Activity failed and schedules a retry. +2. It carries a custom payload that captures the Activity's progress. When the Activity is retried after a failure, the new attempt can read the last heartbeat payload and resume from where it left off. + +Configure a short Heartbeat Timeout (for example, 30 seconds) and emit heartbeats frequently (for example, every 5 to 10 seconds). The SDK throttles Heartbeat calls to avoid overwhelming the Temporal Service, so you can call `activity.heartbeat()` as often as needed without performance concern. + +### Workflow determinism + +In Temporal, your Activities can include non-deterministic code, but the Workflow itself must remain deterministic. This is because Temporal restores Workflow state through *replay*. When a Worker restarts, it re-executes the Workflow code from the beginning, matching Commands against Events stored in the history. If the code produces different Commands than what the history contains, the Worker raises a `NondeterminismError`. + +In practice, this means: + +- Use `workflow.now()` instead of `datetime.now()`. +- Use `workflow.uuid4()` instead of `uuid.uuid4()`. +- Use `workflow.random()` instead of the `random` module. +- Do not perform I/O, network calls, or file system access inside Workflow code. Delegate all side effects to Activities. +- Use `workflow.logger` instead of `print()` for replay-safe logging. + +The Python SDK's Workflow sandbox provides automatic protection against many of these violations, but understanding the underlying mechanism helps you write correct code. + +### Activity idempotency + +Activities may be retried by the Temporal Service due to timeouts, Worker crashes, or transient failures. Every Activity that interacts with an external system must be designed so that executing it twice produces the same result as executing it once. Common strategies include: + +- Passing a unique identifier (the Workflow Id, an Activity-specific identifier, or a business identifier) as an idempotency key to external APIs. +- Checking the current state of the external system before making changes. +- Using conditional writes or upserts instead of blind inserts. + +## Target audience + +This guide references the following roles: + +- **Game backend developers** who design and implement player session management, matchmaking, and real-time game logic. They will write the Workflow, Activity, and data model code. +- **Platform engineers** who deploy and operate Temporal Workers, and manage Temporal infrastructure. +- **Technical architects** who evaluate distributed system patterns for multiplayer game backends. They will use this guide to understand how the actor model maps to Temporal and when to apply it. + + +## Prerequisites + +### Required software and infrastructure + +- **Python 3.11 or later** +- **Temporal Python SDK (`temporalio`)** version 1.9.0 or later +- **Pydantic** version 2.0 or later for data validation +- **Temporal CLI** for running a local development server (`temporal server start-dev`) + +### Required concepts + +- Familiarity with Python `async`/`await` and the `asyncio` event loop +- Familiarity with Temporal Workflows, Activities, and Workers +- Familiarity with Temporal Signals, Queries, and Updates +- Basic understanding of the actor model (message-passing concurrency) +- Familiarity with `dataclasses` or Pydantic models for structured data + +--- + +## Architecture diagram + +The following sequence diagram illustrates the lifecycle of a player Actor Workflow, including interactions with game systems and Events. + +### Narrative + +1. The game client starts a player Actor Workflow using the player's unique identifier as the Workflow Id. +2. The Workflow initializes player state and enters a main event loop, waiting for messages. +3. When the client sends a `join_room` Signal, the Workflow executes an Activity that communicates with the game room service. This is the key distinction from an Entity Workflow: the actor *does something* by executing an Activity with a real-world side effect. +4. Combat moves trigger Activities that process game logic and update leaderboards. +5. Queries provide read-only access to the player's current state for dashboards and client polling. +6. When the Event History approaches the size limit, the Workflow waits for all handlers to finish and then executes Continue-As-New, carrying forward the player's state to a fresh execution. + + +```mermaid +sequenceDiagram + participant Client as Game Client + participant TS as Temporal Service + participant PW as Player Actor Workflow
(player-session-alice) + participant A as Activities + + Client->>TS: Start Workflow (player-session-alice) + TS->>PW: Execute @workflow.run + + Note over PW: Actor initializes state,
enters main event loop + + Client->>TS: Signal: join_room("dungeon-7") + TS->>PW: Deliver Signal + PW->>A: execute_activity(join_game_room) + A-->>PW: Room joined, room state returned + Note over PW: Update internal state
with room information + + Client->>TS: Signal: execute_move(attack, target) + TS->>PW: Deliver Signal + PW->>A: execute_activity(process_combat_move) + A-->>PW: Move result (damage dealt, XP earned) + PW->>A: execute_activity(update_leaderboard) + A-->>PW: Leaderboard updated + + Client->>TS: Query: get_player_state + TS->>PW: Deliver Query + PW-->>TS: Return current state + TS-->>Client: Player state + + Note over PW: Event History approaching limit + PW->>PW: await all_handlers_finished + PW->>TS: Continue-As-New(carry forward state) + TS->>PW: New execution with same Workflow Id +``` + + +## Implementation plan + +This section walks you through building a complete player session Actor Workflow system. The implementation is organized into the following phases: + +1. Define the data models +2. Define the Activities +3. Define the Player Actor Workflow +4. Configure and start the Worker +5. Start and interact with player sessions from a client + +### Phase 1: Define the data models + +Create a file named `models.py` to hold all data structures used by the Workflows and Activities. Using `dataclasses` keeps payloads lightweight and avoids additional dependencies, though Pydantic models work equally well if you need validation. + +```python +# models.py +from __future__ import annotations + +import enum +from dataclasses import dataclass, field + + +class PlayerStatus(str, enum.Enum): + """Represents the current lifecycle state of a player session.""" + + ONLINE = "online" + IN_ROOM = "in_room" + IN_COMBAT = "in_combat" + IDLE = "idle" + OFFLINE = "offline" + + +class MoveType(str, enum.Enum): + """Types of combat moves a player can execute.""" + + ATTACK = "attack" + DEFEND = "defend" + HEAL = "heal" + SPECIAL = "special" + + +@dataclass +class InventoryItem: + """A single item in a player's inventory.""" + + item_id: str + name: str + quantity: int = 1 + + +@dataclass +class CombatMoveRequest: + """A request from a player to execute a combat move.""" + + move_type: MoveType + target_player_id: str | None = None + event_id: str = "" # Idempotency key; callers should always provide one + + +@dataclass +class CombatMoveResult: + """The outcome of a combat move.""" + + damage_dealt: int = 0 + damage_received: int = 0 + xp_earned: int = 0 + move_description: str = "" + + +@dataclass +class LeaderboardEntry: + """A player's leaderboard record.""" + + player_id: str + score: int + rank: int = 0 + + +@dataclass +class RoomInfo: + """Information about a game room the player has joined.""" + + room_id: str + room_name: str + player_count: int = 0 + max_players: int = 20 + + +@dataclass +class PlayerState: + """The complete state of a player session. + + This dataclass is the state that the Actor Workflow carries through + Continue-As-New cycles. It contains everything needed to reconstruct + the player's session context. + """ + + player_id: str + display_name: str + status: PlayerStatus = PlayerStatus.ONLINE + current_room: RoomInfo | None = None + inventory: list[InventoryItem] = field(default_factory=list) + health: int = 100 + max_health: int = 100 + xp: int = 0 + level: int = 1 + score: int = 0 + session_actions_count: int = 0 + total_actions_count: int = 0 + pending_notifications: list[str] = field(default_factory=list) +``` + +The `PlayerState` dataclass is the single source of truth for a player's session. It is passed as the argument to the Workflow's `@workflow.run` method and carried forward through each Continue-As-New cycle. The `session_actions_count` field tracks the number of actions in the *current* execution (reset on Continue-As-New), while `total_actions_count` tracks the lifetime total. + +The separation of `session_actions_count` from `total_actions_count` gives you two useful signals: the session count helps you decide when to trigger Continue-As-New (since each action generates Events in the history), and the total count provides a lifetime metric for the player. + +### Phase 2: Define the Activities + +Create a file named `activities.py`. Activities contain all non-deterministic code: network calls, database writes, game server interactions, and notifications. Each Activity is designed to be idempotent so that retries do not produce duplicate effects. + +```python +# activities.py +from __future__ import annotations + +import uuid +from dataclasses import replace + +from temporalio import activity + +from models import ( + CombatMoveRequest, + CombatMoveResult, + InventoryItem, + LeaderboardEntry, + MoveType, + RoomInfo, +) + + +@activity.defn +def join_game_room(player_id: str, room_id: str) -> RoomInfo: + """Join a game room and return room information. + + In a production system, this Activity would call the game room + microservice API to register the player in the room. The player_id + serves as an idempotency key: joining the same room twice is a + no-op that returns the current room state. + """ + activity.logger.info( + f"Player {player_id} joining room {room_id}" + ) + # Production: call game room service API + # room_service.join(player_id=player_id, room_id=room_id) + return RoomInfo( + room_id=room_id, + room_name=f"Room {room_id}", + player_count=5, + max_players=20, + ) + + +@activity.defn +def leave_game_room(player_id: str, room_id: str) -> None: + """Remove a player from a game room. + + Idempotent: leaving a room you are not in is a no-op. + """ + activity.logger.info( + f"Player {player_id} leaving room {room_id}" + ) + # Production: call game room service API + # room_service.leave(player_id=player_id, room_id=room_id) + + +@activity.defn +def process_combat_move( + player_id: str, move: CombatMoveRequest +) -> CombatMoveResult: + """Process a combat move and return the result. + + This Activity calls the game logic service to resolve the combat + action. The combination of player_id and a server-assigned move + identifier ensures idempotency. + """ + activity.logger.info( + f"Player {player_id} executing {move.move_type.value}" + f" targeting {move.target_player_id}" + ) + # Production: call game logic service + # result = combat_service.resolve_move(player_id, move) + damage = 25 if move.move_type == MoveType.ATTACK else 0 + healing = 15 if move.move_type == MoveType.HEAL else 0 + xp = 10 + + return CombatMoveResult( + damage_dealt=damage, + damage_received=0, + xp_earned=xp, + move_description=( + f"{move.move_type.value} executed" + f"{' against ' + move.target_player_id if move.target_player_id else ''}" + ), + ) + + +@activity.defn +def update_leaderboard(entry: LeaderboardEntry) -> LeaderboardEntry: + """Update the leaderboard with the player's current score. + + Uses the player_id as an upsert key so that repeated calls with the + same score do not create duplicate entries. + """ + activity.logger.info( + f"Updating leaderboard for {entry.player_id} " + f"with score {entry.score}" + ) + # Production: upsert into leaderboard database + # db.leaderboard.upsert(player_id=entry.player_id, score=entry.score) + return replace(entry, rank=42) + + +@activity.defn +def send_player_notification( + target_player_id: str, message: str +) -> None: + """Send a push notification to a player. + + Notifications are delivered through an external push service. + The message includes a client-generated identifier for deduplication. + """ + activity.logger.info( + f"Sending notification to {target_player_id}: {message}" + ) + # Production: call push notification service + # push_service.send(player_id=target_player_id, message=message) + + +@activity.defn +def record_session_metrics( + player_id: str, + total_actions: int, + session_duration_minutes: int, +) -> None: + """Record session metrics to the analytics system. + + Called during Continue-As-New to capture session telemetry. + Uses an append-only metrics store, so duplicate writes are harmless. + """ + activity.logger.info( + f"Recording session metrics for {player_id}: " + f"{total_actions} actions, {session_duration_minutes} min" + ) + # Production: write to metrics/analytics pipeline + # analytics.record_session(player_id, total_actions, duration) +``` + +Each Activity follows a consistent pattern: + +1. **Logging with `activity.logger`** provides automatic correlation with the Workflow Id and Activity attempt number. +2. **Idempotency annotations** in the docstrings explain how the Activity handles retries. In production code, each Activity passes an idempotency key to the external service it calls. +3. **Stub implementations** return realistic data structures. In a production system, each stub would be replaced by a call to the corresponding game backend service. + +Activities are defined as synchronous functions. The Python SDK runs synchronous Activities in a `ThreadPoolExecutor`, which is safer and easier to debug than async Activities. Async Activities are only necessary when the Activity must use `async`-native libraries throughout its implementation. + +### Phase 3: Define the player Actor Workflow + +Create a file named `player_workflow.py`. This is the core of the system: a long-running Actor Workflow that represents a single player's game session. + +```python +# player_workflow.py +from __future__ import annotations + +import asyncio +from datetime import timedelta + +from temporalio import workflow +from temporalio.common import RetryPolicy, SearchAttributes, SearchAttributeKey + +with workflow.unsafe.imports_passed_through(): + from activities import ( + join_game_room, + leave_game_room, + process_combat_move, + record_session_metrics, + send_player_notification, + update_leaderboard, + ) + from models import ( + CombatMoveRequest, + CombatMoveResult, + InventoryItem, + LeaderboardEntry, + PlayerState, + PlayerStatus, + ) + + +# Search Attribute keys for operational visibility +PLAYER_STATUS_KEY = SearchAttributeKey.for_keyword("PlayerStatus") +CURRENT_ROOM_KEY = SearchAttributeKey.for_keyword("CurrentRoom") +PLAYER_LEVEL_KEY = SearchAttributeKey.for_int("PlayerLevel") + +# Threshold for triggering Continue-As-New. +# Each Signal/Update handler execution and each Activity execution +# generates Events in the history. A conservative threshold of +# 10,000 events provides ample headroom below the 50,000 limit. +CONTINUE_AS_NEW_THRESHOLD = 10_000 + +# Default Retry Policy for Activities in this Workflow. +DEFAULT_RETRY_POLICY = RetryPolicy( + initial_interval=timedelta(seconds=1), + backoff_coefficient=2.0, + maximum_interval=timedelta(seconds=30), + maximum_attempts=5, +) + + +@workflow.defn +class PlayerSessionWorkflow: + """Actor Workflow representing a single player's game session. + + This Workflow is the player's durable, autonomous agent in the game + backend. It maintains the player's state and actively performs + operations by executing Activities. External systems interact with + it through Signals (fire-and-forget commands), Updates (synchronous + request-response), and Queries (read-only state inspection). + + Unlike an Entity Workflow that passively holds state, this Actor + Workflow takes action: it joins game rooms, executes combat moves, + updates leaderboards, and sends notifications. + These side effects are all executed through Activities. + + Lifecycle: + 1. The Workflow starts when a player logs in. + 2. It enters a main loop that waits for Signals and Updates. + 3. It carries forward state through Continue-As-New cycles. + 4. It completes when the player explicitly logs out. + """ + + def __init__(self) -> None: + # Pending action queues. Signal handlers append to these queues, + # and the main loop processes them. This serializes action + # processing and avoids concurrent Activity executions that + # could conflict with each other. + self._pending_room_joins: list[str] = [] + self._pending_moves: list[CombatMoveRequest] = [] + self._pending_notifications: list[str] = [] + self._shutdown_requested: bool = False + self._state: PlayerState | None = None + # In-memory set for Signal deduplication within this execution. + # Temporal delivers Signals at least once, so the same Signal may + # arrive more than once. This set catches duplicates that arrive + # within the same Execution. It is not persisted across + # Continue-As-New transitions, which is acceptable: the window + # for a delayed duplicate to span a CAN boundary is very small. + self._seen_move_ids: set[str] = set() + + # ────────────────────────────────────────────── + # Signals: fire-and-forget commands from clients + # ────────────────────────────────────────────── + + @workflow.signal + async def join_room(self, room_id: str) -> None: + """Request the player to join a game room. + + The Signal handler appends the room identifier to a queue. The + main loop picks it up and executes the join_game_room Activity. + This pattern ensures that room joins are processed serially, + preventing race conditions where a player could be in two rooms. + + If the room is already queued or is the player's + current room, the Signal is silently dropped. If the caller + needs confirmation that the join succeeded, use an Update instead. + """ + if room_id in self._pending_room_joins: + return + if ( + self._state is not None + and self._state.current_room is not None + and self._state.current_room.room_id == room_id + ): + return + self._pending_room_joins.append(room_id) + + @workflow.signal + async def execute_move(self, move: CombatMoveRequest) -> None: + """Request the player to execute a combat move. + + Moves are queued and processed in order by the main loop. + Callers must supply a unique event_id for deduplication. Without + one, duplicate Signals cannot be detected and may result in + double-processing. If at-most-once execution or a result is + required, use an Update instead of a Signal. + """ + if move.event_id: + if move.event_id in self._seen_move_ids: + return + self._seen_move_ids.add(move.event_id) + self._pending_moves.append(move) + + @workflow.signal + async def receive_notification(self, message: str) -> None: + """Receive a notification from another player or the game system. + + Notifications are stored in the player's state and can be + retrieved through the get_player_state Query. + """ + if self._state is not None: + self._state.pending_notifications.append(message) + + @workflow.signal + async def logout(self) -> None: + """Request the player to log out and end the session.""" + self._shutdown_requested = True + + # ────────────────────────────────────────────── + # Queries: read-only state inspection + # ────────────────────────────────────────────── + + @workflow.query + def get_player_state(self) -> PlayerState | None: + """Return the complete player state. + + Queries are read-only and must not modify Workflow state. + They execute synchronously on the Worker and return immediately. + Game clients can poll this Query to display inventory, health, + score, and pending notifications. + """ + return self._state + + @workflow.query + def get_status(self) -> str: + """Return the player's current status as a string.""" + if self._state is None: + return PlayerStatus.OFFLINE.value + return self._state.status.value + + @workflow.query + def get_inventory(self) -> list[InventoryItem]: + """Return the player's current inventory.""" + if self._state is None: + return [] + return self._state.inventory + + # ────────────────────────────────────────────── + # Main Workflow logic + # ────────────────────────────────────────────── + + @workflow.run + async def run(self, state: PlayerState) -> str: + """Main entry point for the Player Actor Workflow. + + This method initializes state and enters a loop that processes + queued actions and checks for Continue-As-New conditions. The + loop runs indefinitely until the player logs out or the + session is terminated. + + Args: + state: The player's state. On the first execution, this is + the initial state. On Continue-As-New, this is the state + carried forward from the previous execution. + """ + self._state = state + self._state.status = PlayerStatus.ONLINE + + # Update Search Attributes so operators can find this player + self._upsert_search_attributes() + + workflow.logger.info( + f"Player session started for {state.player_id} " + f"(total actions: {state.total_actions_count})" + ) + + # Main event loop: process queued actions until shutdown + while not self._shutdown_requested: + # Wait for any pending action or a shutdown request. + # The timeout ensures we periodically check the Event + # History length even during idle periods. + try: + await workflow.wait_condition( + lambda: ( + bool(self._pending_room_joins) + or bool(self._pending_moves) + or self._shutdown_requested + ), + timeout=timedelta(minutes=5), + ) + except asyncio.TimeoutError: + # No actions for 5 minutes. Check Continue-As-New + # threshold and loop again. + pass + + # Process all pending room joins + await self._process_room_joins() + + # Process all pending combat moves + await self._process_combat_moves() + + # Check whether Event History is approaching the limit + if self._should_continue_as_new(): + await self._perform_continue_as_new() + # continue_as_new raises an exception that exits the method + return "" # unreachable, but satisfies the type checker + + # Shutdown path: player logged out + await self._handle_logout() + + return f"Player {state.player_id} session ended" + + # ────────────────────────────────────────────── + # Private helper methods + # ────────────────────────────────────────────── + + async def _process_room_joins(self) -> None: + """Process all pending room join requests.""" + while self._pending_room_joins: + room_id = self._pending_room_joins.pop(0) + + # Leave the current room first if the player is already in one + if self._state.current_room is not None: + await workflow.execute_activity( + leave_game_room, + args=[ + self._state.player_id, + self._state.current_room.room_id, + ], + start_to_close_timeout=timedelta(seconds=30), + retry_policy=DEFAULT_RETRY_POLICY, + ) + + # Join the new room. This Activity calls the game room + # service, which is a real-world side effect. This is + # what makes this an Actor Workflow, not an Entity Workflow. + room_info = await workflow.execute_activity( + join_game_room, + args=[self._state.player_id, room_id], + start_to_close_timeout=timedelta(seconds=30), + retry_policy=DEFAULT_RETRY_POLICY, + ) + + self._state.current_room = room_info + self._state.status = PlayerStatus.IN_ROOM + self._increment_action_count() + self._upsert_search_attributes() + + workflow.logger.info( + f"Player {self._state.player_id} joined room " + f"{room_info.room_name}" + ) + + async def _process_combat_moves(self) -> None: + """Process all pending combat moves in order.""" + while self._pending_moves: + move = self._pending_moves.pop(0) + self._state.status = PlayerStatus.IN_COMBAT + + # Execute the combat move. This Activity calls the game + # logic service to resolve damage, XP, and other effects. + result: CombatMoveResult = await workflow.execute_activity( + process_combat_move, + args=[self._state.player_id, move], + start_to_close_timeout=timedelta(seconds=30), + retry_policy=DEFAULT_RETRY_POLICY, + ) + + # Apply the results to the player's state + self._state.xp += result.xp_earned + self._state.health = max( + 0, + min( + self._state.max_health, + self._state.health - result.damage_received, + ), + ) + self._state.score += result.damage_dealt + result.xp_earned + + # Check for level up (every 100 XP) + new_level = (self._state.xp // 100) + 1 + if new_level > self._state.level: + self._state.level = new_level + workflow.logger.info( + f"Player {self._state.player_id} reached " + f"level {new_level}" + ) + + # Update the leaderboard. This is another real-world side + # effect: the leaderboard is an external system that other + # players and the game client query. + await workflow.execute_activity( + update_leaderboard, + LeaderboardEntry( + player_id=self._state.player_id, + score=self._state.score, + ), + start_to_close_timeout=timedelta(seconds=30), + retry_policy=DEFAULT_RETRY_POLICY, + ) + + # Notify the target player of the incoming attack. + # This demonstrates cross-actor communication: one Actor + # Workflow triggers an Activity that sends a message to + # another player's session via an external push service. + if move.target_player_id: + await workflow.execute_activity( + send_player_notification, + args=[ + move.target_player_id, + f"{self._state.display_name} attacked you " + f"for {result.damage_dealt} damage " + f"({result.move_description})", + ], + start_to_close_timeout=timedelta(seconds=10), + retry_policy=DEFAULT_RETRY_POLICY, + ) + + self._increment_action_count() + self._state.status = ( + PlayerStatus.IN_ROOM + if self._state.current_room + else PlayerStatus.ONLINE + ) + + def _should_continue_as_new(self) -> bool: + """Check whether the Event History is approaching the limit.""" + current_length = workflow.info().get_current_history_length() + return current_length > CONTINUE_AS_NEW_THRESHOLD + + async def _perform_continue_as_new(self) -> None: + """Execute Continue-As-New with the current player state. + + Before continuing, this method: + 1. Waits for all in-progress Signal and Update handlers to finish. + 2. Records session metrics to the analytics system. + 3. Resets the session-level action counter. + """ + workflow.logger.info( + f"Continue-As-New for player {self._state.player_id} " + f"at {workflow.info().get_current_history_length()} events" + ) + + # Wait for all handlers to complete before continuing. + # This prevents data loss from in-flight Signal or Update handlers. + await workflow.wait_condition(workflow.all_handlers_finished) + + # Record session metrics before resetting the counter + await workflow.execute_activity( + record_session_metrics, + args=[ + self._state.player_id, + self._state.total_actions_count, + 0, # duration computed from workflow start time in production + ], + start_to_close_timeout=timedelta(seconds=30), + retry_policy=DEFAULT_RETRY_POLICY, + ) + + # Reset session-level counter; keep total lifetime counter + self._state.session_actions_count = 0 + + # Continue-As-New carries the full player state to the new execution + workflow.continue_as_new(args=[self._state]) + + async def _handle_logout(self) -> None: + """Clean up resources when the player logs out.""" + assert self._state is not None + + # Wait for all handlers to finish processing + await workflow.wait_condition(workflow.all_handlers_finished) + + # Leave the current room + if self._state.current_room is not None: + await workflow.execute_activity( + leave_game_room, + args=[ + self._state.player_id, + self._state.current_room.room_id, + ], + start_to_close_timeout=timedelta(seconds=30), + retry_policy=DEFAULT_RETRY_POLICY, + ) + self._state.current_room = None + + self._state.status = PlayerStatus.OFFLINE + self._upsert_search_attributes() + + workflow.logger.info( + f"Player {self._state.player_id} logged out " + f"after {self._state.total_actions_count} total actions" + ) + + def _increment_action_count(self) -> None: + """Increment both session and total action counters.""" + self._state.session_actions_count += 1 + self._state.total_actions_count += 1 + + def _upsert_search_attributes(self) -> None: + """Update Search Attributes for operational visibility. + + Search Attributes allow operators to query running Workflows. + For example: find all players in a specific room, find all + players above a certain level, or find all players with a + particular status. + """ + pairs = [ + (PLAYER_STATUS_KEY, self._state.status.value), + (PLAYER_LEVEL_KEY, self._state.level), + ] + if self._state.current_room: + pairs.append( + (CURRENT_ROOM_KEY, self._state.current_room.room_id) + ) + workflow.upsert_search_attributes(pairs) +``` + +The `PlayerSessionWorkflow` is the central piece of this system. Several design decisions are worth examining in detail: + +**Message queue pattern for Signals.** Signal handlers do not execute Activities directly. Instead, they append to internal queues (`_pending_room_joins`, `_pending_moves`), and the main loop drains these queues. This serializes side effects and prevents concurrent Activity executions that could conflict with each other. For example, if two `join_room` Signals arrive simultaneously, processing them through a queue guarantees that the player leaves the first room before joining the second. + +**Signal deduplication.** Temporal delivers Signals at least once: the same Signal may arrive more than once under certain failure conditions. Without deduplication, a duplicate `execute_move` Signal could apply a combat move twice, corrupting XP and health state. The `join_room` handler drops a Signal if the target room is already queued or is the current room. The `execute_move` handler uses the `event_id` field on `CombatMoveRequest` as an idempotency key, tracking seen identifiers in `_seen_move_ids`. Callers must generate and provide a unique `event_id` for every move Signal. If a Signal has no `event_id`, duplicates cannot be detected and the move is appended unconditionally — this is the fallback behavior and should be avoided in production. For operations where the caller needs confirmation or strict at-most-once semantics, use a Workflow Update instead of a Signal: Updates support validators that run before the operation is accepted, and the caller blocks until the handler returns a result. + +**Continue-As-New with handler draining.** Before executing `workflow.continue_as_new()`, the Workflow calls `await workflow.wait_condition(workflow.all_handlers_finished)`. This ensures that any Signal or Update handler that is currently executing finishes before the current execution ends. Without this step, in-flight handlers could be interrupted, causing data loss. + +**Search Attribute updates.** The Workflow upserts Search Attributes whenever the player's status, room, or level changes. This enables operational Queries such as: + +``` +PlayerStatus = "in_room" AND CurrentRoom = "dungeon-7" +``` + +``` +PlayerLevel >= 10 AND PlayerStatus = "online" +``` + +These Queries are invaluable for live operations: finding all players in a room that needs to be shut down for maintenance, identifying high-level players for a special event, or monitoring the distribution of player statuses across the system. + +### Phase 4: Configure and start the Worker + +Create a file named `worker.py`. The Worker registers the `PlayerSessionWorkflow` and all Activities on a single Task Queue. + +```python +# worker.py +from __future__ import annotations + +import asyncio +import concurrent.futures +import logging + +from temporalio.client import Client +from temporalio.worker import Worker + +from activities import ( + join_game_room, + leave_game_room, + process_combat_move, + record_session_metrics, + send_player_notification, + update_leaderboard, +) +from player_workflow import PlayerSessionWorkflow + +# Configure logging for the Worker process +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", +) + + +async def main() -> None: + """Start a Worker that handles player session Workflows.""" + client = await Client.connect("localhost:7233", namespace="default") + + # Use a ThreadPoolExecutor for synchronous Activities. + # Size the pool based on expected concurrent Activity executions. + # Each concurrent Activity occupies one thread. + with concurrent.futures.ThreadPoolExecutor( + max_workers=100 + ) as activity_executor: + worker = Worker( + client, + task_queue="player-session-queue", + workflows=[PlayerSessionWorkflow], + activities=[ + join_game_room, + leave_game_room, + process_combat_move, + update_leaderboard, + send_player_notification, + record_session_metrics, + ], + activity_executor=activity_executor, + # Limit concurrent Workflow Tasks. Each Workflow Task is + # lightweight (in-memory replay), but limiting concurrency + # prevents memory pressure under high load. + max_concurrent_workflow_tasks=200, + # Limit concurrent Activity executions. Activities perform + # I/O and consume threads from the executor pool. + max_concurrent_activities=100, + ) + + logging.info( + "Worker started on task queue 'player-session-queue'" + ) + await worker.run() + + +if __name__ == "__main__": + asyncio.run(main()) +``` + +The Worker configuration makes several deliberate choices: + +- **ThreadPoolExecutor sizing:** The `max_workers=100` setting supports up to 100 concurrent Activity executions. Size this based on the expected ratio of concurrent players to Activity execution time. If Activities are fast (under 100 milliseconds), fewer threads are needed. If Activities involve slower external service calls, increase the pool size. +- **Concurrency limits:** `max_concurrent_workflow_tasks=200` and `max_concurrent_activities=100` prevent the Worker from being overwhelmed. These values should be tuned based on the Worker's available memory and CPU. + +### Phase 5: Start and interact with player sessions from a client + +Create a file named `starter.py`. This module demonstrates how a game backend API would start player sessions and send commands to them. + +```python +# starter.py +from __future__ import annotations + +import asyncio + +from temporalio.client import Client +from temporalio.common import SearchAttributes, SearchAttributeKey + +from models import ( + CombatMoveRequest, + InventoryItem, + MoveType, + PlayerState, +) +from player_workflow import PlayerSessionWorkflow + +# Search Attribute keys (must match the Workflow definitions) +PLAYER_STATUS_KEY = SearchAttributeKey.for_keyword("PlayerStatus") +PLAYER_LEVEL_KEY = SearchAttributeKey.for_int("PlayerLevel") + + +async def start_player_session( + client: Client, player_id: str, display_name: str +) -> None: + """Start a new player session Actor Workflow. + + The Workflow Id is derived from the player_id, ensuring that each + player has exactly one active session. If a session with this + Workflow Id already exists, this call will raise an error. Use + the Workflow Id Reuse Policy to control this behavior. + """ + initial_state = PlayerState( + player_id=player_id, + display_name=display_name, + inventory=[ + InventoryItem( + item_id="sword-001", + name="Iron Sword", + quantity=1, + ), + InventoryItem( + item_id="potion-001", + name="Health Potion", + quantity=5, + ), + ], + ) + + handle = await client.start_workflow( + PlayerSessionWorkflow.run, + initial_state, + id=f"player-session-{player_id}", + task_queue="player-session-queue", + search_attributes=SearchAttributes.from_pairs([ + (PLAYER_STATUS_KEY, "online"), + (PLAYER_LEVEL_KEY, 1), + ]), + ) + print(f"Started session for {display_name} ({player_id})") + print(f" Workflow Id: {handle.id}") + + +async def join_room( + client: Client, player_id: str, room_id: str +) -> None: + """Send a join_room Signal to a player's Actor Workflow.""" + handle = client.get_workflow_handle( + f"player-session-{player_id}" + ) + await handle.signal(PlayerSessionWorkflow.join_room, room_id) + print(f"Player {player_id} sent join_room Signal for {room_id}") + + +async def attack( + client: Client, + player_id: str, + target_player_id: str, +) -> None: + """Send a combat move Signal to a player's Actor Workflow.""" + handle = client.get_workflow_handle( + f"player-session-{player_id}" + ) + move = CombatMoveRequest( + move_type=MoveType.ATTACK, + target_player_id=target_player_id, + ) + await handle.signal(PlayerSessionWorkflow.execute_move, move) + print( + f"Player {player_id} sent attack Signal " + f"targeting {target_player_id}" + ) + + +async def check_player_state( + client: Client, player_id: str +) -> None: + """Query a player's current state.""" + handle = client.get_workflow_handle( + f"player-session-{player_id}" + ) + state = await handle.query( + PlayerSessionWorkflow.get_player_state + ) + if state is not None: + print(f"Player: {state.display_name}") + print(f" Status: {state.status.value}") + print(f" Health: {state.health}/{state.max_health}") + print(f" Level: {state.level}") + print(f" Score: {state.score}") + print(f" XP: {state.xp}") + print( + f" Room: " + f"{state.current_room.room_name if state.current_room else 'None'}" + ) + print(f" Inventory: {len(state.inventory)} item(s)") + print(f" Total actions: {state.total_actions_count}") + + +async def logout_player( + client: Client, player_id: str +) -> None: + """Send a logout Signal to gracefully end a player session.""" + handle = client.get_workflow_handle( + f"player-session-{player_id}" + ) + await handle.signal(PlayerSessionWorkflow.logout) + print(f"Player {player_id} sent logout Signal") + + +async def main() -> None: + """Demonstrate a complete player session lifecycle.""" + client = await Client.connect("localhost:7233", namespace="default") + + # Start two player sessions + await start_player_session(client, "alice", "Alice the Brave") + await start_player_session(client, "bob", "Bob the Bold") + + # Give the Workers a moment to pick up the Workflow Tasks + await asyncio.sleep(2) + + # Alice joins a game room + await join_room(client, "alice", "dungeon-7") + await asyncio.sleep(1) + + # Bob joins the same room + await join_room(client, "bob", "dungeon-7") + await asyncio.sleep(1) + + # Alice attacks Bob + await attack(client, "alice", "bob") + await asyncio.sleep(1) + + # Check Alice's state + await check_player_state(client, "alice") + + # Logout both players + await logout_player(client, "alice") + await logout_player(client, "bob") + + +if __name__ == "__main__": + asyncio.run(main()) +``` + +The client code demonstrates the two interaction patterns available to external callers: + +- **Signals** for fire-and-forget commands: `join_room`, `execute_move`, `logout`. The caller does not wait for the Workflow to process the Signal. +- **Queries** for read-only state inspection: `get_player_state`. The caller reads the Workflow's current state without affecting its execution. + +### (Optional) Phase 6: Configure Search Attributes + +Before starting the Worker, register the custom Search Attributes with the Temporal Service. When using the Temporal CLI development server, run the following commands: + +```bash +temporal operator search-attribute create --name PlayerStatus --type Keyword +temporal operator search-attribute create --name CurrentRoom --type Keyword +temporal operator search-attribute create --name PlayerLevel --type Int +``` + +Once registered, you can query running player sessions. For example, to find all players currently in room `dungeon-7`: + +```bash +temporal workflow list --query 'PlayerStatus = "in_room" AND CurrentRoom = "dungeon-7"' +``` + +To find all players at level 10 or higher: + +```bash +temporal workflow list --query 'PlayerLevel >= 10' +``` + +### (Optional) Phase 7: Add Heartbeating to long-running Activities + +If your game includes Activities that run for extended periods, such as maintaining a persistent connection to a game room server or processing a large batch of analytics events, add Heartbeating to track progress and enable resumption after failures. + +```python +# activities.py (additional long-running Activity example) + +@activity.defn +def process_batch_leaderboard_update( + entries: list[LeaderboardEntry], +) -> int: + """Process a large batch of leaderboard updates with heartbeating. + + This Activity processes entries one at a time and heartbeats after + each entry with the index of the last processed entry. If the Worker + fails mid-batch, the next attempt reads the heartbeat payload and + resumes from where processing stopped. + + Configure this Activity with a heartbeat_timeout of 30 seconds. + The SDK throttles heartbeat calls, so calling activity.heartbeat() + on every iteration is safe and does not overload the Temporal Service. + """ + heartbeat_details = activity.info().heartbeat_details + start_index = heartbeat_details[0] if heartbeat_details else 0 + + processed_count = start_index + for i in range(start_index, len(entries)): + entry = entries[i] + # Production: upsert into leaderboard database + activity.logger.info( + f"Processing leaderboard entry {i + 1}/{len(entries)} " + f"for player {entry.player_id}" + ) + processed_count += 1 + + # Heartbeat with progress. The heartbeat payload (the current + # index) is available to the next attempt if this attempt fails. + activity.heartbeat(processed_count) + + return processed_count +``` + +When calling this Activity from a Workflow, configure the `heartbeat_timeout` parameter: + +```python +# In the Workflow +processed = await workflow.execute_activity( + process_batch_leaderboard_update, + entries, + start_to_close_timeout=timedelta(minutes=30), + heartbeat_timeout=timedelta(seconds=30), + retry_policy=DEFAULT_RETRY_POLICY, +) +``` + +The `heartbeat_timeout` of 30 seconds means that if the Activity does not emit a Heartbeat for 30 seconds, the Temporal Service considers it stalled and schedules a retry. The new attempt reads the last heartbeat payload (the index of the last processed entry) and resumes from that point. + +--- + +## Outcomes + +You've built a session system where player state is durable, game actions are reliable, and server failures are non-events. The player Workflow doesn't just hold state — it drives action. That distinction matters: when a player joins a room or executes a combat move, the Workflow owns the operation end-to-end, retrying if needed and preserving consistency if something fails partway through. + +The approach isn't limited to games. Any long-lived entity that has to take action on its own behalf — an IoT device scaling infrastructure in response to sensor data, an e-commerce order submitting payment and triggering fulfillment, an AI agent calling tools and external APIs — fits the same model. The player session implementation here gives you the complete pattern. + +--- + +## Related resources + +- [Temporal Python SDK documentation](https://docs.temporal.io/develop/python) +- [Message Passing — Signals, Queries, Updates](https://docs.temporal.io/develop/python/message-passing) +- [Continue-As-New](https://docs.temporal.io/develop/python/continue-as-new) +- [Child Workflows](https://docs.temporal.io/develop/python/child-workflows) +- [Failure Detection — Timeouts, Activity Heartbeating, and Retry Policies](https://docs.temporal.io/develop/python/failure-detection) +- [Search Attributes](https://docs.temporal.io/visibility/search-attributes) +- [Temporal Python SDK API Reference](https://python.temporal.io) +- [Temporal Python SDK samples](https://github.com/temporalio/samples-python) +- Companion article: [Entity Workflow Pattern](/guides/entity-pattern-loyalty-points) \ No newline at end of file diff --git a/docs/guides/entity-pattern-loyalty-points.mdx b/docs/guides/entity-pattern-loyalty-points.mdx new file mode 100644 index 0000000000..59b3302d80 --- /dev/null +++ b/docs/guides/entity-pattern-loyalty-points.mdx @@ -0,0 +1,1130 @@ +--- +id: entity-pattern-loyalty-points +title: Track customer loyalty points with durable Workflows +description: How to run a customer loyalty program with durable Workflows provided by Temporal +sidebar_label: Customer loyalty program +toc_max_heading_level: 3 +author: Cecil Phillip +tags: + - Workflows + - Workers + - Activities + - Continue-As-New + - Task Queues + - Python SDK + - Retry Policies +--- + +**by Cecil Phillip** + +Loyalty programs look simple until you're running them at scale: points have to accrue correctly across every purchase, tiers have to recalculate at the right moment, redemptions have to be consistent, and all of it has to hold up across years of customer interactions. This guide shows how to give each customer their own dedicated, durable account — one that holds state, enforces business rules, responds to purchases and redemptions in real time, and never needs a cron job to stay current. + +## Problem statement + +Every customer in a loyalty program is their own entity. Their points accumulate across purchases, their tier changes when they hit thresholds, and their redemptions need to be validated and applied without double-spending. Doing this with a shared database means locking, retry logic across services, and cron jobs that periodically recalculate who qualifies for what. When a points accrual succeeds in one service but the tier upgrade notification fails in another, you have an inconsistency that's difficult to detect and expensive to fix. The longer the customer relationship, the more chances there are for these gaps to accumulate. + +## Solution + +Each customer gets their own Workflow — a persistent, running process identified by their customer ID. It holds the account's points balance, tier status, and activity history in memory. When a purchase happens, a Signal adds the points. When a customer redeems at checkout, an Update validates and applies the redemption synchronously. When a mobile app needs to display the current balance, a Query reads the state without touching the database. Tier upgrades happen as part of normal business logic, not from a nightly job. The Workflow runs for as long as the customer relationship does — years if needed — and resets its internal history automatically to stay within platform limits. + +## Outcomes + +After working through this guide, you'll have a loyalty system where: + +- **Each customer account is independent and durable**: state is held per-customer, survives infrastructure failures, and picks up exactly where it left off +- **Tier upgrades happen in real time**: business rules run in the Workflow itself, triggered by point accruals — no scheduler, no batch job +- **Redemptions are consistent**: synchronous Updates validate and apply changes atomically, with the response going back to the caller +- **In-memory Balance reads**: Queries read in-memory state without generating Events or hitting a database +- **The Workflow runs for the life of the customer**: Continue-As-New keeps history size in check so nothing needs to be migrated or restarted +- **Failures are isolated**: a notification failing doesn't roll back a point accrual; each concern fails independently + +## Background and best practices + +### What is an Entity Workflow? + +Although "Workflow" is a Temporal SDK primitive, when we talk about an "entity workflow," we're describing an architectural pattern. An entity workflow represents something that persists over time — a customer, a device, an order, a bank account. Contrast this with a "process workflow," which has a definite end. The key distinction is that an entity is a thing, whereas a process workflow does a thing. An entity workflow has an indefinite lifetime and reacts to events as they arrive. In the loyalty context, the customer account is the entity: it exists as long as the customer relationship does, and it responds to purchases, redemptions, and status queries throughout that lifetime. + +Entity Workflows share three defining characteristics: + +- They run for an **indefinite duration**. You do not know at start time when or if they will complete. +- They **react to external messages** at any point in their lifecycle via Signals, Updates, and Queries. +- They **maintain mutable state** that evolves over time in response to those messages. + +In the loyalty points use case, each customer has their own Entity Workflow. The Workflow holds the points balance and tier status. External services send Signals to add points after a purchase, send Updates to redeem points at checkout, and send Queries to display the current balance in a mobile app. The Workflow runs for as long as the customer account exists. + +### How Entity Workflows differ from actors + +The Entity Workflow pattern shares characteristics with the Actor Model: both receive messages, maintain state, and can create new instances. However, there is an important distinction. An entity **represents state** and responds to interactions. It is closer to a data cache than a process orchestrator. It holds and serves state, evaluates business rules against that state, and delegates side effects to Activities. An actor, by contrast, **takes actions**. It may write to databases, call external APIs, or send emails as part of its core behavior. + +In this pattern, the loyalty Entity Workflow does not directly write to a database or send an email. When it needs a side effect, such as sending a tier upgrade notification, it delegates that work to an Activity. The Workflow itself remains a pure state container with business rules. + +### Why Temporal is well suited for this pattern + +Temporal provides several capabilities that make Entity Workflows practical: + +- **Durable Timers**: A Workflow can sleep for hours, days, or months without consuming compute resources. The Timer is persisted by the Temporal Server; the Worker is freed to handle other tasks. +- **Message passing**: Signals, Queries, and Updates provide three distinct interaction patterns — fire-and-forget, read-only, and request-response — covering the full range of entity interactions. +- **Event History**: Every state transition is recorded in an append-only log, giving a complete audit trail for compliance and debugging. +- **Continue-As-New**: When the Event History approaches size limits, the Workflow resets itself with a fresh history while preserving current state, allowing Entity Workflows to run for years. +- **Deterministic replay**: If a Worker crashes, the Workflow resumes from its last known state by replaying the Event History — no state is lost and no external coordination is needed. + +### Operational considerations + +Before implementing this pattern, keep the following constraints in mind: + +**Event History limits.** Temporal enforces a hard limit of 51,200 Events and 50 MB per Workflow Execution, with warnings starting around 10,000 Events. Each Signal generates approximately 4 Events; each Activity execution approximately 11. A loyalty entity processing 100 point accruals per day (each triggering one Activity) generates roughly 1,500 Events per day — plan your Continue-As-New threshold accordingly. + +**Payload size limits.** Individual payloads for Workflow and Activity inputs and outputs are limited to 2 MB. If the serialized entity state approaches this limit, store large data externally and reference it by identifier. + +**Workflow Determinism.** Activities can include non-deterministic code, but the Workflow itself must remain deterministic. Use `workflow.now()` instead of `datetime.now()`, `workflow.random()` instead of `random`, and `workflow.uuid4()` instead of `uuid.uuid4()`. All I/O must be performed in Activities. + +**Signal delivery guarantees.** Temporal delivers Signals at least once. Include a deduplication key in Signal payloads and make your Signal processing logic idempotent. + +## Target audience + +The following roles are involved in this pattern: + +- **Temporal Workflow and Activity developers**: Responsible for implementing the loyalty Entity Workflow, defining Activities, and writing client code to interact with the entity +- **Platform operators**: Responsible for deploying and managing Temporal Workers, configuring Task Queues, and monitoring Event History growth +- **Domain architects**: Responsible for deciding which loyalty business rules belong in the Workflow versus in Activities, and defining the message contracts for Signals, Updates, and Queries + +This guide requires Python, a Temporal Worker, and either a local development server or Temporal Cloud. + +## Prerequisites + +### Required software and infrastructure + +- Python 3.9 or later +- Temporal Python SDK (`temporalio`) version 1.7.0 or later (required for `@workflow.init`, `workflow.all_handlers_finished`, and Workflow Updates) +- A running Temporal Server, either via the [Temporal CLI](https://docs.temporal.io/cli) for local development or [Temporal Cloud](https://temporal.io/cloud) for production +- `pip` or `uv` for Python dependency management + +### Resources and access privileges + +- Temporal Cloud account with Namespace Admin role, or local development server access via `temporal server start-dev` +- Write access to your Worker deployment environment + +### Required concepts + +You should be familiar with the following before proceeding: + +- [Temporal Workflows](https://docs.temporal.io/workflows): The durable, stateful functions that Temporal orchestrates +- [Temporal Activities](https://docs.temporal.io/activities): The functions that perform side effects such as API calls and database writes +- [Temporal Workers](https://docs.temporal.io/workers): The processes that host and execute Workflow and Activity code +- [Temporal Task Queues](https://docs.temporal.io/task-queues): The named queues that route work from the Temporal Server to Workers + +## Architecture diagrams + +The following diagrams illustrate how external services interact with a loyalty Entity Workflow through Signals, Updates, and Queries. + +### Signal and Continue-As-New flow + +This diagram shows how a client adds points to a customer's loyalty account via a Signal, and how the Workflow performs Continue-As-New when the Event History grows large. + +**Narrative:** + +1. An external service starts a loyalty Entity Workflow with a stable Workflow Id tied to the customer identifier, such as `loyalty-cust-123`. +2. The Temporal Server persists the start request and schedules a Workflow Task on the `loyalty-points-queue` Task Queue. +3. A Python Worker picks up the Task, initializes the loyalty state, and enters a wait loop. +4. Throughout the customer's lifetime, external services send Signals to add points, Updates to redeem points, and Queries to read the current balance and tier. +5. When the Event History approaches the suggested threshold, the Workflow serializes its current state and calls `continue_as_new`, starting a fresh Execution with the same Workflow Id. +6. When the customer account is closed, a shutdown Signal causes the Workflow to drain its queue, await all handlers, and complete. + +```mermaid +sequenceDiagram + participant Client as External Service + participant TS as Temporal Server + participant W as Worker (Python) + + Client->>TS: start_workflow(LoyaltyEntity,
id="loyalty-cust-123") + TS->>W: Schedule Workflow Task + W->>W: Initialize loyalty state (0 points, Bronze tier) + W-->>TS: Blocked on wait_condition (waiting for messages) + + Client->>TS: signal("loyalty-cust-123",
add_points, {points: 500}) + TS->>W: Deliver Signal + W->>W: Append to internal queue + W->>W: Process: update points balance + W->>TS: Schedule Activity (record_points_transaction) + TS->>W: Execute Activity + W-->>TS: Activity complete, resume Workflow + + Client->>TS: query("loyalty-cust-123", get_loyalty_summary) + TS->>W: Deliver Query + W-->>Client: Return {points: 500, tier: "Bronze"}
(no Event recorded) + + Note over W: Event History approaching threshold + W->>W: Drain pending signals + W->>W: await all_handlers_finished() + W->>TS: continue_as_new(current_state) + Note over TS: Same Workflow Id,
new Run Id, fresh Event History +``` + +### Update flow for point redemption + +This diagram shows a synchronous Update interaction where a client redeems points and receives confirmation of the new balance. + +```mermaid +sequenceDiagram + participant Client as External Service + participant TS as Temporal Server + participant W as Worker (Python) + + Client->>TS: execute_update("loyalty-cust-123",
redeem_points, {points: 200}) + TS->>W: Deliver Update + W->>W: Run validator (check points > 0) + W->>W: Check sufficient balance + W->>W: Deduct points from state + W->>TS: Schedule Activity (record_redemption) + TS->>W: Execute Activity + W-->>Client: Return {redeemed: 200, remaining: 300} +``` + +## Implementation plan + +This section walks you through building a complete customer loyalty Entity Workflow in Python. You will define the data model, implement the Workflow with Signal, Query, and Update handlers, write Activities for side effects, configure Continue-As-New, set up the Worker, and write client code to interact with the entity. + +### Phase 1: Define the loyalty state and data models + +Entity Workflows carry their current state as their primary input, which enables Continue-As-New to resume seamlessly. Use Python `dataclass` objects — they serialize cleanly with Temporal's default JSON converter and allow new fields with defaults without breaking existing Executions. + +Create a file named `models.py` with the following content: + +```python +# models.py +from dataclasses import dataclass, field +from enum import Enum + + +class LoyaltyTier(str, Enum): + """Loyalty tiers based on lifetime points earned.""" + BRONZE = "Bronze" + SILVER = "Silver" + GOLD = "Gold" + PLATINUM = "Platinum" + + +@dataclass +class PointsEvent: + """A single points transaction to be processed by the entity.""" + event_id: str # Unique identifier for idempotent processing + points: int # Positive for accrual, negative for redemption + reason: str # Human-readable description (e.g., "purchase", "signup_bonus") + source: str = "" # Originating service or channel + + +@dataclass +class LoyaltyState: + """The complete state of a customer's loyalty account. + + This is the data that survives Continue-As-New transitions. + Keep it small and serializable — well under the 2 MB payload limit. + """ + customer_id: str + points_balance: int = 0 + lifetime_points: int = 0 + tier: str = LoyaltyTier.BRONZE.value + is_active: bool = True + processed_event_ids: list[str] = field(default_factory=list) + + # Unprocessed events carried across Continue-As-New transitions + pending_events: list[dict] = field(default_factory=list) + + +@dataclass +class LoyaltySummary: + """Read-only view of the loyalty account returned by Queries.""" + customer_id: str + points_balance: int + lifetime_points: int + tier: str + is_active: bool + + +@dataclass +class RedemptionRequest: + """Input for the redeem_points Update.""" + event_id: str # Idempotency key for the redemption + points: int # Number of points to redeem (must be positive) + reward: str # What the points are being redeemed for + + +@dataclass +class RedemptionResult: + """Output from the redeem_points Update.""" + redeemed: int + remaining_balance: int + reward: str +``` + +`LoyaltyState` holds only what's needed to reconstruct the entity after a Continue-As-New transition. `processed_event_ids` enables idempotent Signal processing — in production, limit this to a recent window (e.g. last 1,000 IDs) and rely on an external store for long-term deduplication. `pending_events` carries any Signals that arrived but weren't processed before the transition. + +### Phase 2: Implement the Activities + +Activities hold all non-deterministic code: database writes, API calls, and notifications. The Workflow stays a pure state container. + +Create a file named `activities.py`: + +```python +# activities.py +import logging + +from temporalio import activity + +logger = logging.getLogger(__name__) + + +@activity.defn +async def record_points_transaction(transaction: dict) -> bool: + """Record a points transaction in the loyalty database. + + Must be idempotent: use event_id as the key to prevent duplicate records + on retry. Example: INSERT ... ON CONFLICT (event_id) DO NOTHING. + """ + activity.logger.info( + "Recording points transaction for customer %s: %s points (%s)", + transaction["customer_id"], + transaction["points"], + transaction["reason"], + ) + # await db.execute("INSERT INTO points_transactions ... ON CONFLICT (event_id) DO NOTHING", ...) + return True + + +@activity.defn +async def send_tier_change_notification(notification: dict) -> bool: + """Send a notification when a customer's loyalty tier changes. + + Non-critical: if this Activity fails after all retries, the Workflow + logs the failure and continues rather than terminating the account. + """ + activity.logger.info( + "Sending tier change notification to customer %s: %s -> %s", + notification["customer_id"], + notification["old_tier"], + notification["new_tier"], + ) + # await email_service.send(template="tier_change", to=..., context={...}) + return True + + +@activity.defn +async def record_redemption(redemption: dict) -> bool: + """Record a points redemption in the loyalty database. + + Must be idempotent: use event_id as the key to prevent duplicate records + on retry. Example: INSERT ... ON CONFLICT (event_id) DO NOTHING. + """ + activity.logger.info( + "Recording redemption for customer %s: %s points for %s", + redemption["customer_id"], + redemption["points"], + redemption["reward"], + ) + # await db.execute("INSERT INTO redemptions ... ON CONFLICT (event_id) DO NOTHING", ...) + return True +``` + +Each Activity uses `@activity.defn` and `activity.logger` for context-aware logging. All three are idempotent by design: calling them twice with the same `event_id` produces the same result, which is essential because Temporal may retry Activities on failure. + +### Phase 3: Implement the Entity Workflow + +The Entity Workflow is the core of the pattern: it initializes state, waits for messages, processes them sequentially, and performs Continue-As-New when history grows large. + +Create a file named `workflows.py`: + +```python +# workflows.py +import asyncio +from collections import deque +from dataclasses import asdict +from datetime import timedelta + +from temporalio import workflow +from temporalio.common import RetryPolicy +from temporalio.exceptions import ApplicationError + +with workflow.unsafe.imports_passed_through(): + from activities import ( + record_points_transaction, + record_redemption, + send_tier_change_notification, + ) + from models import ( + LoyaltyState, + LoyaltySummary, + LoyaltyTier, + PointsEvent, + RedemptionRequest, + RedemptionResult, + ) + + +# Retry policy for database writes: retry transient failures with backoff +DB_RETRY_POLICY = RetryPolicy( + initial_interval=timedelta(seconds=1), + backoff_coefficient=2.0, + maximum_interval=timedelta(seconds=30), + maximum_attempts=10, +) + +# Retry policy for notifications: more aggressive retry since these are non-critical +NOTIFICATION_RETRY_POLICY = RetryPolicy( + initial_interval=timedelta(seconds=2), + backoff_coefficient=2.0, + maximum_interval=timedelta(minutes=1), + maximum_attempts=5, +) + + +@workflow.defn +class LoyaltyEntityWorkflow: + """Manages a single customer's loyalty account as a long-lived Entity Workflow. + + Each customer has their own instance identified by a stable Workflow Id + (e.g. "loyalty-cust-123"). All side effects are delegated to Activities. + """ + + @workflow.init + def __init__(self, state: LoyaltyState | None = None) -> None: + """Initialize Workflow state. + + The @workflow.init decorator ensures this runs before any Signal + or Update handler, preventing race conditions with Signal-with-Start. + + Args: + state: Existing state from Continue-As-New, or None for a new account. + """ + if state is not None: + self._state = state + else: + # First start only (not Continue-As-New); customer_id set in run(). + self._state = LoyaltyState(customer_id="") + + # Signal queue: handlers append, main loop pops one at a time. + self._pending_signals: deque[dict] = deque(self._state.pending_events) + self._state.pending_events = [] # Clear after restoring + + self._shutdown_requested: bool = False + + @workflow.run + async def run(self, state: LoyaltyState | None = None) -> LoyaltyState: + """Main entity loop: wait for messages, process them, and Continue-As-New when needed.""" + if state is None: + # First start: derive customer_id from the Workflow Id convention. + self._state.customer_id = workflow.info().workflow_id.replace("loyalty-", "", 1) + + workflow.logger.info( + "Loyalty entity started for customer %s (tier: %s, balance: %d)", + self._state.customer_id, + self._state.tier, + self._state.points_balance, + ) + + while not self._shutdown_requested: + # Block until a Signal arrives, shutdown is requested, + # CAN is suggested, or 24 hours pass. + await workflow.wait_condition( + lambda: ( + bool(self._pending_signals) + or self._shutdown_requested + or workflow.info().is_continue_as_new_suggested() + ), + timeout=timedelta(hours=24), + ) + + # Process all queued Signals sequentially + while self._pending_signals: + event_data = self._pending_signals.popleft() + await self._process_points_event(event_data) + + # Check if we should Continue-As-New after each event + if workflow.info().is_continue_as_new_suggested(): + break + + # Exit if shutdown was requested + if self._shutdown_requested: + break + + # Trigger Continue-As-New when Temporal suggests it + if workflow.info().is_continue_as_new_suggested(): + await self._do_continue_as_new() + + # Wait for all in-flight handlers before completing. + await workflow.wait_condition(workflow.all_handlers_finished) + + workflow.logger.info( + "Loyalty entity shutting down for customer %s (final balance: %d)", + self._state.customer_id, + self._state.points_balance, + ) + return self._state + + @workflow.signal + def add_points(self, event: dict) -> None: + """Enqueue a points accrual event for processing in the main loop. + + Signal handlers must be synchronous — they only append to the queue. + All processing happens in the main loop to ensure sequential execution. + + Args: + event: dict with keys event_id, points, reason, and optionally source. + """ + self._pending_signals.append(event) + + @workflow.signal + def shutdown(self) -> None: + """Signal the entity to drain pending events and terminate gracefully.""" + self._shutdown_requested = True + + # ------------------------------------------------------------------ + # Query handlers + # ------------------------------------------------------------------ + + @workflow.query + def get_loyalty_summary(self) -> LoyaltySummary: + """Return a read-only summary (no Events generated, safe at high frequency).""" + return LoyaltySummary( + customer_id=self._state.customer_id, + points_balance=self._state.points_balance, + lifetime_points=self._state.lifetime_points, + tier=self._state.tier, + is_active=self._state.is_active, + ) + + @workflow.query + def get_points_balance(self) -> int: + """Return the current points balance.""" + return self._state.points_balance + + @workflow.query + def get_tier(self) -> str: + """Return the current loyalty tier.""" + return self._state.tier + + @workflow.update + async def redeem_points(self, request: RedemptionRequest) -> RedemptionResult: + """Redeem loyalty points for a reward. + + Blocks until the Update completes and the caller receives the result, + making it ideal for checkout flows that need confirmed point deduction. + + Raises: + ApplicationError: If the account is inactive or has insufficient points. + """ + if not self._state.is_active: + raise ApplicationError( + "Cannot redeem points: account is inactive", + type="InactiveAccount", + non_retryable=True, + ) + + if request.points > self._state.points_balance: + raise ApplicationError( + f"Insufficient points: requested {request.points}, " + f"available {self._state.points_balance}", + type="InsufficientPoints", + non_retryable=True, + ) + + # Idempotency: return without re-processing if already applied. + if request.event_id in self._state.processed_event_ids: + return RedemptionResult( + redeemed=request.points, + remaining_balance=self._state.points_balance, + reward=request.reward, + ) + + self._state.points_balance -= request.points + + # Record the redemption via an Activity (idempotent on event_id). + await workflow.execute_activity( + record_redemption, + { + "customer_id": self._state.customer_id, + "event_id": request.event_id, + "points": request.points, + "reward": request.reward, + }, + start_to_close_timeout=timedelta(seconds=30), + retry_policy=DB_RETRY_POLICY, + ) + + # Track this event as processed. + self._state.processed_event_ids.append(request.event_id) + self._trim_processed_ids() + + workflow.logger.info( + "Customer %s redeemed %d points for %s (remaining: %d)", + self._state.customer_id, + request.points, + request.reward, + self._state.points_balance, + ) + + return RedemptionResult( + redeemed=request.points, + remaining_balance=self._state.points_balance, + reward=request.reward, + ) + + @redeem_points.validator + def validate_redemption(self, request: RedemptionRequest) -> None: + """Run before the Update is accepted into Event History. Rejection here writes no Event.""" + if request.points <= 0: + raise ApplicationError( + "Redemption amount must be a positive number", + type="ValidationError", + ) + + async def _process_points_event(self, event_data: dict) -> None: + """Process a single points event: check for duplicates, record the + transaction via an Activity, update in-memory state, and evaluate tier. + """ + event_id = event_data.get("event_id", "") + points = event_data.get("points", 0) + reason = event_data.get("reason", "unknown") + + if event_id and event_id in self._state.processed_event_ids: + workflow.logger.info( + "Skipping duplicate event %s for customer %s", + event_id, + self._state.customer_id, + ) + return + + # Record the transaction BEFORE updating in-memory state to avoid + # double-counting if the Activity retries. + await workflow.execute_activity( + record_points_transaction, + { + "customer_id": self._state.customer_id, + "event_id": event_id, + "points": points, + "reason": reason, + }, + start_to_close_timeout=timedelta(seconds=30), + retry_policy=DB_RETRY_POLICY, + ) + + # Update state after Activity confirms success. + self._state.points_balance += points + if points > 0: + self._state.lifetime_points += points + + if event_id: + self._state.processed_event_ids.append(event_id) + self._trim_processed_ids() + + workflow.logger.info( + "Processed event %s for customer %s: %+d points (balance: %d)", + event_id, + self._state.customer_id, + points, + self._state.points_balance, + ) + + await self._evaluate_tier() + + async def _evaluate_tier(self) -> None: + """Recalculate tier; send notification if it changed (non-critical).""" + new_tier = self._calculate_tier(self._state.lifetime_points) + + if new_tier != self._state.tier: + old_tier = self._state.tier + self._state.tier = new_tier + + workflow.logger.info( + "Customer %s tier changed: %s -> %s", + self._state.customer_id, + old_tier, + new_tier, + ) + + # Notification is non-critical — log and continue on failure. + try: + await workflow.execute_activity( + send_tier_change_notification, + { + "customer_id": self._state.customer_id, + "old_tier": old_tier, + "new_tier": new_tier, + }, + start_to_close_timeout=timedelta(seconds=30), + retry_policy=NOTIFICATION_RETRY_POLICY, + ) + except Exception: + workflow.logger.warning( + "Failed to send tier change notification for customer %s; " + "continuing entity lifecycle", + self._state.customer_id, + ) + + def _calculate_tier(self, lifetime_points: int) -> str: + """Determine tier from lifetime points — pure logic, no side effects.""" + if lifetime_points >= 10_000: + return LoyaltyTier.PLATINUM.value + if lifetime_points >= 5_000: + return LoyaltyTier.GOLD.value + if lifetime_points >= 1_000: + return LoyaltyTier.SILVER.value + return LoyaltyTier.BRONZE.value + + def _trim_processed_ids(self) -> None: + """Keep only the most recent 1,000 processed event IDs to prevent unbounded growth.""" + max_ids = 1_000 + if len(self._state.processed_event_ids) > max_ids: + self._state.processed_event_ids = self._state.processed_event_ids[ + -max_ids: + ] + + async def _do_continue_as_new(self) -> None: + """Perform Continue-As-New: drain excess pending signals, serialize + remaining unprocessed signals into state, wait for all handlers to + finish, then call continue_as_new with the current state. + """ + # Drain excess pending signals before serializing into the CAN input. + # Keeps the LoyaltyState payload well under the 2 MB limit. + MAX_PENDING_CARRY = 500 + while len(self._pending_signals) > MAX_PENDING_CARRY: + event_data = self._pending_signals.popleft() + await self._process_points_event(event_data) + + # Carry remaining unprocessed Signals to the next Execution. + self._state.pending_events = list(self._pending_signals) + + # Wait for async handlers to finish before transitioning. + await workflow.wait_condition(workflow.all_handlers_finished) + + workflow.logger.info( + "Continuing as new for customer %s (balance: %d, pending: %d)", + self._state.customer_id, + self._state.points_balance, + len(self._state.pending_events), + ) + + workflow.continue_as_new(self._state) +``` + +Two design decisions in this Workflow deserve extra attention: + +**Activity call inside an Update handler.** The `redeem_points` Update calls `record_redemption` directly via `await` rather than enqueuing it for the main loop. The Python and TypeScript SDKs support `async` Update handlers, making this safe. The trade-off keeps redemption logic self-contained and returns the confirmed result to the caller in a single round trip. It is safe here because `record_redemption` is idempotent — a replay detects the duplicate `event_id` and returns without creating a second record. If you are using the Java SDK, Update handlers cannot be `async`; enqueue the work for the main loop instead. + +**Continue-As-New payload size.** Unprocessed Signals are serialized into the CAN input, which must stay under the 2 MB payload limit. The `MAX_PENDING_CARRY` guard drains the queue to a safe size before serializing. Lower the limit if your Signal payloads are large. If the guard fires frequently, Signals are arriving faster than Workers can process them — scale your Worker pool. + +### Phase 4: Configure and run the Worker + +The Worker hosts your Workflow and Activity code, polls a Task Queue, and executes work. + +Create a file named `worker.py`: + +```python +# worker.py +import asyncio +import concurrent.futures +import logging + +from temporalio.client import Client +from temporalio.worker import Worker + +from activities import ( + record_points_transaction, + record_redemption, + send_tier_change_notification, +) +from workflows import LoyaltyEntityWorkflow + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +TASK_QUEUE = "loyalty-points-queue" +TEMPORAL_ADDRESS = "localhost:7233" +TEMPORAL_NAMESPACE = "default" + + +async def main() -> None: + """Start a Worker that hosts the LoyaltyEntityWorkflow and its Activities.""" + client = await Client.connect( + TEMPORAL_ADDRESS, + namespace=TEMPORAL_NAMESPACE, + ) + + logger.info("Starting loyalty Worker on task queue: %s", TASK_QUEUE) + + with concurrent.futures.ThreadPoolExecutor(max_workers=50) as activity_executor: + worker = Worker( + client, + task_queue=TASK_QUEUE, + workflows=[LoyaltyEntityWorkflow], + activities=[ + record_points_transaction, + record_redemption, + send_tier_change_notification, + ], + activity_executor=activity_executor, + max_concurrent_workflow_tasks=200, + max_concurrent_activities=100, + ) + await worker.run() + + +if __name__ == "__main__": + asyncio.run(main()) +``` + +The Worker registers the `LoyaltyEntityWorkflow` class and all three Activity functions on the `loyalty-points-queue` Task Queue. Setting `max_concurrent_workflow_tasks` higher than the default makes sense here because Entity Workflows spend most of their time blocked on `wait_condition`, so one Worker can host many concurrent instances. + +To start the Worker, run: + +``` +python worker.py +``` + +The Worker connects to the Temporal Server and begins polling. It will continue running until you stop the process. + +### Phase 5: Write client code to interact with the entity + +The client code starts Entity Workflows, sends Signals, executes Updates, and runs Queries. In production this typically lives in an API server or background service. + +Create a file named `starter.py`: + +```python +# starter.py +import asyncio + +from temporalio.client import Client, WorkflowUpdateFailedError + +from models import RedemptionRequest +from workflows import LoyaltyEntityWorkflow + +TASK_QUEUE = "loyalty-points-queue" +TEMPORAL_ADDRESS = "localhost:7233" +TEMPORAL_NAMESPACE = "default" + + +async def main() -> None: + client = await Client.connect( + TEMPORAL_ADDRESS, + namespace=TEMPORAL_NAMESPACE, + ) + + customer_id = "cust-42" + workflow_id = f"loyalty-{customer_id}" + + # Start the entity — Workflow Id is stable per customer. + # If it's already running, start_workflow raises WorkflowAlreadyStartedError. + handle = await client.start_workflow( + LoyaltyEntityWorkflow.run, + id=workflow_id, + task_queue=TASK_QUEUE, + # No initial state — the Workflow derives customer_id from the Workflow Id. + ) + print(f"Started loyalty entity: {workflow_id}") + + # Signals are fire-and-forget: the call returns once the Server accepts them. + await handle.signal( + LoyaltyEntityWorkflow.add_points, + { + "event_id": "evt-001", + "points": 500, + "reason": "initial_purchase", + "source": "ecommerce", + }, + ) + print("Sent Signal: +500 points (initial_purchase)") + + await handle.signal( + LoyaltyEntityWorkflow.add_points, + { + "event_id": "evt-002", + "points": 750, + "reason": "referral_bonus", + "source": "referral_service", + }, + ) + print("Sent Signal: +750 points (referral_bonus)") + + # Allow time for the Worker to process the Signals + await asyncio.sleep(2) + + # Queries are synchronous and read-only — no Events generated. + summary = await handle.query(LoyaltyEntityWorkflow.get_loyalty_summary) + print( + f"Query result: {summary.points_balance} points, " + f"tier={summary.tier}, lifetime={summary.lifetime_points}" + ) + + # Updates block until the Workflow processes them and returns a result — + # ideal for checkout where the caller needs confirmation. + try: + result = await handle.execute_update( + LoyaltyEntityWorkflow.redeem_points, + RedemptionRequest( + event_id="redeem-001", + points=200, + reward="free_shipping", + ), + ) + print( + f"Redemption successful: {result.redeemed} points for " + f"{result.reward}, remaining: {result.remaining_balance}" + ) + except WorkflowUpdateFailedError as e: + print(f"Redemption failed: {e}") + + # Query again to confirm the updated balance. + balance = await handle.query(LoyaltyEntityWorkflow.get_points_balance) + tier = await handle.query(LoyaltyEntityWorkflow.get_tier) + print(f"After redemption: balance={balance}, tier={tier}") + + print("\nSending batch of points events...") + for i in range(10): + await handle.signal( + LoyaltyEntityWorkflow.add_points, + { + "event_id": f"batch-{i:04d}", + "points": 100, + "reason": f"batch_purchase_{i}", + }, + ) + print("Sent 10 batch events (+1000 points total)") + + await asyncio.sleep(3) + + summary = await handle.query(LoyaltyEntityWorkflow.get_loyalty_summary) + print( + f"Final state: {summary.points_balance} points, " + f"tier={summary.tier}, lifetime={summary.lifetime_points}" + ) + + # Uncomment to shut down the entity gracefully. + # await handle.signal(LoyaltyEntityWorkflow.shutdown) + # print("Sent shutdown Signal") + + +if __name__ == "__main__": + asyncio.run(main()) +``` + +This client code demonstrates the three interaction patterns: + +- **Signals** (`add_points`): Fire-and-forget; returns immediately after the Server accepts the Signal. +- **Queries** (`get_loyalty_summary`, `get_points_balance`, `get_tier`): Read-only, no Events generated — safe at high frequency. +- **Updates** (`redeem_points`): Synchronous request-response; the caller blocks until the Workflow returns a confirmed result. + +### Phase 6: Handle Continue-As-New for indefinite execution + +Continue-As-New allows an Entity Workflow to run for years without unbounded history. When `workflow.info().is_continue_as_new_suggested()` returns `True`, the history is approaching the suggested threshold (~4,096 Events). + +The `LoyaltyEntityWorkflow` checks this flag in two places: + +1. **In the main wait condition.** The Workflow wakes up even if no Signals are pending, handling the case where history has grown during idle periods. +2. **After processing each event.** Avoids processing an entire batch before detecting an oversized history. + +The `_do_continue_as_new` method handles the transition in three steps: + +1. **Unprocessed Signals are preserved.** Any Signals still in the queue are serialized into `pending_events` on the state. The next Execution restores them in its constructor. +2. **All handlers complete.** `workflow.all_handlers_finished` ensures any in-flight Update handlers finish before the transition — cancelling them mid-execution would lose data. +3. **The transition occurs.** `workflow.continue_as_new(self._state)` ends the current Execution and starts a new one with the same Workflow Id, a new Run Id, and a fresh Event History. + +After the transition, clients continue interacting with the same Workflow Id. Temporal automatically routes Signals, Queries, and Updates to the latest Run. Do not specify a Run Id when obtaining a Workflow handle, or your interactions will target a stale Execution. + +### Phase 7: Ensure Workflow determinism + +Temporal replays Event History to resume a Workflow after a crash. The code must produce the same sequence of Commands on every replay. + +The Python SDK sandbox blocks most non-deterministic operations, but you must still follow these rules: + +| Do not use | Use instead | Reason | +|---|---|---| +| `datetime.now()` | `workflow.now()` | Returns the same value during replay | +| `random.random()` | `workflow.random().random()` | Seeded deterministically by the SDK | +| `uuid.uuid4()` | `workflow.uuid4()` | Produces the same value during replay | +| `time.sleep()` | `asyncio.sleep()` or `workflow.sleep()` | Converts to a Durable Timer | +| `print()` | `workflow.logger.info()` | Replay-safe; only logs on initial execution | +| Direct I/O (HTTP, file reads) | Activities | All I/O must happen in Activities | + +**Testing for determinism.** Use the `Replayer` to verify code changes against a saved Event History: + +```python +# test_determinism.py +import asyncio + +from temporalio.client import WorkflowHistory +from temporalio.worker import Replayer + +from workflows import LoyaltyEntityWorkflow + + +async def test_replay_compatibility() -> None: + """Replay a saved Event History against the current code. + + Export history first: + temporal workflow show --workflow-id loyalty-cust-42 --output json > history.json + """ + replayer = Replayer(workflows=[LoyaltyEntityWorkflow]) + + with open("history.json") as f: + history = WorkflowHistory.from_json("loyalty-cust-42", f.read()) + + # This raises NondeterminismError if the code is incompatible + await replayer.replay_workflow(history) + print("Replay succeeded: code is compatible with saved history") + + +if __name__ == "__main__": + asyncio.run(test_replay_compatibility()) +``` + +Run this test as part of your continuous integration pipeline before deploying Workflow code changes. If the replay fails with a `NondeterminismError`, the code change is not backward-compatible. Use Worker Versioning (see Phase 8) to deploy the change safely. + +### (Optional) Phase 8: Manage Workflow evolution with Worker Versioning + +Entity Workflows are the hardest case for code evolution: a loyalty account can span years and dozens of deployments. Any change that alters the sequence of Commands the Workflow generates causes a `NondeterminismError` during replay for in-progress Executions. + +[Worker Versioning](https://docs.temporal.io/production-deployment/worker-deployments/worker-versioning) is the recommended solution: run multiple builds simultaneously, route each Execution to the build it started on, and drain old builds without any code-level branching. + +> Worker Versioning requires Python SDK v1.11.0 or later, Temporal Server v1.29.1 or later, and Temporal CLI v1.4.1 or later. + +#### Why PINNED is the right choice for Entity Workflows + +`PINNED` is the canonical versioning behavior for Entity Workflows: each execution runs entirely on the Worker Deployment Version where it started. At a Continue-As-New boundary the Workflow can optionally upgrade to the latest build. + +| Behavior | Guarantee | When to use | +|----------|-----------|-------------| +| `PINNED` | Each execution completes on the version where it started | Long-lived entities; no patching needed within a run | +| `AUTO_UPGRADE` | Moves to the latest version automatically | Short Workflows that complete before the next deploy | + +#### Step 1: Annotate the Workflow + +Add `versioning_behavior=VersioningBehavior.PINNED` to `@workflow.defn` in `workflows.py`: + +```python +from temporalio.common import VersioningBehavior + +@workflow.defn(versioning_behavior=VersioningBehavior.PINNED) +class LoyaltyEntityWorkflow: + ... +``` + +#### Step 2: Configure the Worker + +Update `worker.py` to declare the deployment name and build ID: + +```python +import os +from temporalio.worker import Worker, WorkerDeploymentOptions + +# BUILD_ID should be injected by CI/CD (e.g. a Git SHA). All Workers from the same build must match. +BUILD_ID = os.environ["BUILD_ID"] +DEPLOYMENT_NAME = os.getenv("TEMPORAL_DEPLOYMENT", "loyalty-points") + +async def main() -> None: + client = await Client.connect(TEMPORAL_ADDRESS, namespace=TEMPORAL_NAMESPACE) + + with concurrent.futures.ThreadPoolExecutor(max_workers=50) as activity_executor: + worker = Worker( + client, + task_queue=TASK_QUEUE, + workflows=[LoyaltyEntityWorkflow], + activities=[ + record_points_transaction, + record_redemption, + send_tier_change_notification, + ], + activity_executor=activity_executor, + worker_deployment_options=WorkerDeploymentOptions( + deployment_name=DEPLOYMENT_NAME, + build_id=BUILD_ID, + use_worker_versioning=True, + ), + max_concurrent_workflow_tasks=200, + max_concurrent_activities=100, + ) + await worker.run() +``` + +#### Step 3: Roll out changes with the CLI + +**Initial deployment** — start the first versioned build and make it Current: + +```bash +BUILD_ID=1.0.0 python worker.py & + +temporal worker deployment set-current-version \ + --deployment-name "loyalty-points" \ + --build-id "1.0.0" +``` + +**Deploying a change** — run the new build alongside the old one, ramp gradually, then promote: + +```bash +# Start Workers on the new build +BUILD_ID=1.1.0 python worker.py & + +# Canary: send 10% of new Workflows to the new build +temporal worker deployment set-ramping-version \ + --deployment-name "loyalty-points" \ + --build-id "1.1.0" \ + --percentage 10 + +# Promote to Current once satisfied +temporal worker deployment set-current-version \ + --deployment-name "loyalty-points" \ + --build-id "1.1.0" +``` + +Old `1.0.0` Workers keep running, draining their pinned Executions automatically. + +Check drain status before stopping old Workers: + +```bash +temporal worker deployment describe-version \ + --deployment-name "loyalty-points" \ + --build-id "1.0.0" +# DrainageStatus: drained ← safe to shut down +``` + +#### Upgrading at the Continue-As-New boundary + +Because `LoyaltyEntityWorkflow` already calls `workflow.continue_as_new()` to manage Event History, you can upgrade long-running Executions to a new build at that boundary — without patching. + +In `_do_continue_as_new`, check `get_target_worker_deployment_version_changed()` before transitioning: + +```python +from temporalio.workflow import ContinueAsNewVersioningBehavior + +async def _do_continue_as_new(self) -> None: + MAX_PENDING_CARRY = 500 + while len(self._pending_signals) > MAX_PENDING_CARRY: + event_data = self._pending_signals.popleft() + await self._process_points_event(event_data) + + self._state.pending_events = list(self._pending_signals) + await workflow.wait_condition(workflow.all_handlers_finished) + + can_options = {} + if workflow.info().get_target_worker_deployment_version_changed(): + # Newer build available — upgrade on this CaN boundary. + can_options["versioning_behavior"] = ContinueAsNewVersioningBehaviorAutoUpgrade + + workflow.continue_as_new(self._state, **can_options) +``` + +When `get_target_worker_deployment_version_changed()` is `False`, the CaN run stays on the same pinned build. This flag is refreshed after each Workflow Task, so upgrades are picked up on the next natural CaN boundary. + +## Outcomes + +You've built a loyalty account system where each customer is an independent, self-managing process. Points accrue consistently, tier upgrades happen in real time, redemptions can't be double-spent, and the account keeps running through deployments, restarts, and schema changes. The approach isn't specific to loyalty programs — any domain object with a long lifecycle and ongoing interactions can be modeled the same way: user profiles, IoT devices, subscription accounts, open orders. The loyalty implementation here gives you the full blueprint. + +## Related resources + +- [Temporal Python SDK documentation](https://docs.temporal.io/develop/python) +- [Temporal Python SDK API Reference](https://python.temporal.io) +- [Message Passing — Signals, Queries, Updates](https://docs.temporal.io/develop/python/message-passing) +- [Continue-As-New](https://docs.temporal.io/develop/python/continue-as-new) +- [Worker Versioning](https://docs.temporal.io/production-deployment/worker-deployments/worker-versioning) +- [Failure Detection — Timeouts, Activity Heartbeating, and Retry Policies](https://docs.temporal.io/develop/python/failure-detection) +- [Temporal Python SDK samples](https://github.com/temporalio/samples-python) + + \ No newline at end of file diff --git a/docs/guides/index.mdx b/docs/guides/index.mdx new file mode 100644 index 0000000000..028990282f --- /dev/null +++ b/docs/guides/index.mdx @@ -0,0 +1,28 @@ +--- +id: index +title: Guides +slug: /guides +toc_max_heading_level: 2 +keywords: + - guides + - how-to + - best practices + - troubleshooting + - performance +tags: + - Integrations + - AI Frameworks + - Agent Frameworks + - Observability +description: Browse all Guides available for Temporal. +llm_exclude: + This page renders an interactive grid component. For a machine-readable list of guides and their metadata, see + [guides-data.ts](https://github.com/temporalio/documentation/blob/main/src/components/GuidesGrid/guides-data.ts). +--- + +import GuidesGrid from '@site/src/components/GuidesGrid'; + +Browse Guides for Temporal SDKs and Temporal Cloud. +Filter by SDK, tag, or search to find what you need. + + diff --git a/docs/guides/lock-shared-resources.mdx b/docs/guides/lock-shared-resources.mdx new file mode 100644 index 0000000000..9db12276c7 --- /dev/null +++ b/docs/guides/lock-shared-resources.mdx @@ -0,0 +1,466 @@ +--- +id: lock-shared-resources +title: Coordinate Access to Shared Resources with a Distributed Lock +description: Use Temporal Workflow IDs to build a durable, safe locking system for your shared resources. +sidebar_label: Distributed locking +toc_max_heading_level: 3 +author: Keith Tenzer +tags: + - Workflows + - Activities + - Child workflows + - Signals +--- + +**by Keith Tenzer** + +This guide details a reusable, durable distributed lock for Temporal Workflows. The design doesn't rely on an external database, a central limiter, or any shared state outside Temporal itself. + +This pattern is best for small, fixed pools of scarce resources where each permit is held for minutes, not milliseconds: GPU devices, lab hardware, tenant migrations, or similar operational resources. The latency maybe too high to qualify as a general-purpose high-throughput mutex or rate limiter. +Common approaches such as a database, cache-layer, or a central rate-limiter services introduce extra infrastructure and new failure modes. Holders of locks die without releasing leak orphan locks. Lock state drifts out of sync with the Workflows that hold them. Capacity changes require restarting limiter services. + +This pattern represents each held permit or lock as its own short-lived child Workflow. +The child Workflow's ID encapsulates the resource and slot, for example `permit:gpu-pool:gpu-2`. +Temporal does not allow two running Workflows with the same ID, so acquiring a permit is an atomic operation using Temporal's own state. +The Workflow releases on a Signal pinned to the run. If no release Signal is received, the permit Workflow's lease timeout handles orphan recovery. + +By following this implementation plan, you will gain: + +- **Atomic Acquire**: Take a permit by starting a child Workflow whose ID is the lock. The operation succeeds or fails atomically against Temporal state. +- **Lease-Based Recovery**: A per-permit lease Timer makes the slot reusable if the holder crashes before releasing. For external resources that can be corrupted by overlap, pass a fencing token to reject stale holders. +- **Horizontal Scale**: Permit traffic spreads across the slots in a scarce resource pool, which is useful for long-held resource coordination. + +## Background and best practices + +This section explains why Temporal Workflow IDs make a good distributed lock primitive and how the design recovers from the failure modes that hurt typical lock services. + +**Distributed locking is a classical problem** +Solutions such as a database, cache-layer, and central rate-limiting services share a structural weakness: the lock state lives outside the runtime that holds it. The locking system and the work it gates can fall out of sync. A holder crash leaves an external lock entry in place until a separate cleanup job runs. A limiter outage blocks every gated Workflow, or lets every request through unchecked. Scaling the limiter is a separate operational concern from scaling the workload it gates. + +Inside Temporal, by default, every running Workflow execution already has a globally unique, atomically enforced identifier. You cannot start two running Workflows with the same Workflow ID at the same time. That guarantee is the primitive a distributed mutex requires (guarantee only exists if not changing WorkflowId Conflict Policy from its default of FAIL). Treating each permit as its own child Workflow places the lock and the work it gates on the same platform: Temporal's history, durability, and Worker fleet. + +**The Workflow ID is the lock** +Encapsulating the resource and slot into the Workflow ID (`permit:{resource}:{slot}`) makes the act of starting the child Workflow the acquire operation. The start either succeeds and the caller holds the permit, or it fails with `WorkflowAlreadyStartedError` and the caller tries the next slot. + +If the parent Workflow closes for any reason without sending the release Signal (operator termination, execution timeout, unhandled exception), the default `ParentClosePolicy.TERMINATE` cancels the permit child immediately and the slot frees right away. The permit Workflow also runs a `wait_condition` with a lease timeout for cases where the holder is alive but went silent, and `start_child_workflow` sets `execution_timeout=lease` as a server-side backstop for permits that no Worker ever picks up. Together these mechanisms handle orphan recovery internally, eliminating the need for a separate cleanup process. + +The `Semaphore` pins the release Signal to the specific run started at acquire time by holding the `ChildWorkflowHandle` from `start_child_workflow` and signalling on that handle. Without this pin, a late release Signal from a holder whose lease already expired could revoke a subsequent acquirer's permit. The `Semaphore` shuffles its slot list using `workflow.random()` so Workflows always probe in a random order. The shuffle is deterministic across replay, as Workflow determinism requires, and uniform across callers. This spreads contention across the pool rather than concentrating on slot 0, for example. + +Three operational characteristics matter before adopting the pattern: + +- Acquire is not first-in-first-out (FIFO). When a slot frees, whomever claims it first, wins. +- Capacity changes apply only to acquires after the change. Already-held permits run to release or lease expiry on their original capacity. +- For production, host `PermitSlotWorkflow` on a dedicated Task Queue so application Worker backpressure does not slow permit acquire and release. +- Lease expiry is recovery, not proof that the old holder stopped using the external resource. For resources that can be corrupted by overlap, pass the permit run ID as a fencing token to the Activity or downstream system and reject stale holders there. + +## Target audience + +This guide references the following roles: + +- **Application developers**: Build the Workflows that acquire permits. Import the `Semaphore` helper and wrap critical sections in `async with sem.acquire(): ...`. +- **Platform operators**: Run the Worker that hosts `PermitSlotWorkflow`, tune lease and backoff defaults, and decide the Task Queue topology. + +The process outlined in this document requires Python code changes and Worker configuration. +This pattern requires no additional infrastructure. + +## Prerequisites + +To complete the implementation plan, you will need: + +- **Required software and tools**: + - Python 3.12 or later ([download](https://www.python.org/downloads/)) + - Temporal Python SDK 1.7 or later ([github](https://github.com/temporalio/sdk-python)) + - `uv` 0.9.18 for Python dependency management ([install](https://docs.astral.sh/uv/)) + - Temporal CLI 1.6.2 for the local development server ([install](https://docs.temporal.io/cli)) + - Access to a Temporal Cluster 1.3.2 (local development server or Temporal Cloud). +- **Resources and access privileges**: + - A Temporal Namespace where you can register Workers and start Workflows. + - For Temporal Cloud: an API key with the Namespace Admin role for the target Namespace. +- **Required concepts**: + - Familiarity with Temporal Workflows and Activities ([docs](https://docs.temporal.io/workflows)). + - Child Workflows and parent close policies ([docs](https://docs.temporal.io/encyclopedia/child-workflows)). + - Signals and Queries ([docs](https://docs.temporal.io/encyclopedia/workflow-message-passing)). + - Workflow determinism ([docs](https://docs.temporal.io/workflows#workflow-determinism)). + +## Architecture diagram + +The following diagram shows the acquire and release flow when two Workflows compete for slots in a 2-slot pool. The first attempt to get a slot collides with an already held slot; the second succeeds. + +```mermaid +sequenceDiagram + autonumber + actor App as Application Workflow + participant Sem as Semaphore (helper) + participant Permit as PermitSlotWorkflow (child) + participant Temporal as Temporal Service + + App->>Sem: acquire(lease=10m, backoff=5s) + Sem->>Temporal: start_child_workflow(id="permit:gpu-pool:2") + Temporal-->>Sem: WorkflowAlreadyStartedError (slot held) + Sem->>Temporal: start_child_workflow(id="permit:gpu-pool:3") + Temporal-->>Sem: ChildWorkflowHandle (pinned to this run) + Temporal->>Permit: dispatch run(PermitSlotInput) + Sem-->>App: yield "3" + Note over App: execute gated work + App->>Sem: release on context exit + Sem->>Temporal: signal "release" on the child handle + Temporal->>Permit: deliver release Signal + Permit-->>Temporal: complete ("released") + Note over Permit,Temporal: slot ID becomes reusable +``` + +1. The application Workflow calls `Semaphore.acquire`. The `Semaphore` shuffles slot names using `workflow.random()` and attempts to acquire a slot. +2. The first attempt to acquire a slot starts a child Workflow with ID `permit:gpu-pool:2`. Temporal already has a running Workflow at that ID and rejects the start with `WorkflowAlreadyStartedError`. +3. The `Semaphore` falls through to the next slot and starts `permit:gpu-pool:3`. Temporal accepts the start and returns a `ChildWorkflowHandle` to the `Semaphore`. +4. Temporal dispatches the new execution to a Worker, and `PermitSlotWorkflow` enters its `wait_condition` with the lease timeout running. +5. The `Semaphore` yields the slot name (`"3"`) to the caller. +6. The application Workflow runs the gated work using the yielded slot identity. +7. On context-manager exit, the `Semaphore` sends a `release` Signal on the recorded `ChildWorkflowHandle`, which targets the specific run it started. +8. The permit Workflow's `wait_condition` returns, the Workflow completes with `released`, and the Workflow ID is reusable. + +## Implementation plan + +This section outlines the sequential phases to build the distributed lock library and integrate it into a Worker. + +### Define shared configuration + +The library needs a small config module with lease and backoff defaults, a function that encodes the resource and slot into a Workflow ID, and a function that picks auth mode from environment variables. + +Create a file named `distributed_lock/config.py`: + +```python +# config.py + +from __future__ import annotations + +import os +from datetime import timedelta + +from temporalio.client import Client +from temporalio.envconfig import ClientConfig + +TEMPORAL_TASK_QUEUE: str = os.environ.get("TEMPORAL_TASK_QUEUE", "distributed-lock-tq") + +DEFAULT_LEASE: timedelta = timedelta(minutes=10) +DEFAULT_BACKOFF: timedelta = timedelta(seconds=5) +DEFAULT_MAX_BACKOFF: timedelta = timedelta(minutes=1) +DEFAULT_BACKOFF: timedelta = timedelta(seconds=5) + +PERMIT_WORKFLOW_ID_PREFIX: str = "permit" + +def permit_workflow_id(resource: str, slot: str) -> str: + return f"{PERMIT_WORKFLOW_ID_PREFIX}:{resource}:{slot}" + +async def connect_temporal_client() -> Client: + return await Client.connect(**ClientConfig.load_client_connect_config()) +``` + +The `permit_workflow_id` function produces IDs like `permit:gpu-pool:2`. That string is the lock: Temporal will reject any second attempt to start a Workflow with the same ID while the first is running. +The default lease and backoff values are conservative starting points. Adjust these values based on the resource you're gating. +The `connect_temporal_client` function delegates to `temporalio.envconfig`, which reads connection settings from a TOML profile at `~/.config/temporalio/temporal.toml` (or the path in `TEMPORAL_CONFIG_FILE`) and applies environment variable overrides such as `TEMPORAL_ADDRESS`, `TEMPORAL_NAMESPACE`, `TEMPORAL_API_KEY`, and the `TEMPORAL_TLS_*` family. This is the same configuration source the Temporal CLI uses, so a developer's existing CLI profile drives both the CLI and this Worker. API-key authentication enables Transport Layer Security (TLS) automatically; for self-hosted TLS without an API key, set the relevant `TEMPORAL_TLS_*` variables; for `temporal server start-dev`, leave the variables unset and the connection falls back to plain Transmission Control Protocol (TCP). + +### Define the permit primitive + +The permit primitive is a child Workflow whose ID is the lock. It accepts a resource name, slot name, and lease duration, then blocks on a `release` Signal with a timeout equal to the lease. On Signal it completes; on timeout it auto-completes for orphan recovery. + +Create a file named `distributed_lock/workflow.py`: + +```python +# workflow.py + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import timedelta + +from temporalio import workflow + +@dataclass +class PermitSlotInput: + resource: str + slot: str + lease_seconds: float + +@workflow.defn(name="PermitSlotWorkflow") +class PermitSlotWorkflow: + def __init__(self) -> None: + self._released: bool = False + + @workflow.signal(name="release") + def release(self) -> None: + self._released = True + + @workflow.run + async def run(self, input: PermitSlotInput) -> str: + workflow.logger.info( + "permit acquired", + extra={"resource": input.resource, "slot": input.slot}, + ) + try: + await workflow.wait_condition( + lambda: self._released, + timeout=timedelta(seconds=input.lease_seconds), + ) + return "released" + except TimeoutError: + workflow.logger.warning( + "permit lease expired - auto-releasing slot", + extra={"resource": input.resource, "slot": input.slot}, + ) + return "lease_expired" +``` + +The `@workflow.signal(name="release")` decorator exposes the Signal the caller sends on context exit. The `workflow.wait_condition(predicate, timeout=lease)` is the durable wait: Temporal persists the wait, so a Worker restart does not lose the held permit. The Workflow does not set its own ID. The caller sets it via `start_child_workflow(id=...)`. The return value (`"released"` or `"lease_expired"`) lets operators distinguish clean releases from orphan recoveries directly in the Temporal UI. + +### Build the caller-side Semaphore helper + +The `Semaphore` is a thin async context manager around `workflow.start_child_workflow`. It is not a Workflow itself. Construct it with a fixed `capacity`; the `Semaphore` auto-generates slot names `"0"` through `"N-1"` and yields the name of the slot it acquired. Activities that need to map the slot to an external resource (CUDA device, database shard, etc.) translate the string with `int(slot)`. + +Create a file named `distributed_lock/semaphore.py`: + +```python +# semaphore.py + +from __future__ import annotations + +from contextlib import asynccontextmanager +from datetime import timedelta +from typing import AsyncIterator + +from temporalio import workflow +from temporalio.exceptions import FailureError, WorkflowAlreadyStartedError +from temporalio.workflow import ChildWorkflowHandle + +from distributed_lock.config import DEFAULT_BACKOFF, DEFAULT_LEASE, permit_workflow_id +from distributed_lock.workflow import PermitSlotInput, PermitSlotWorkflow + +class Semaphore: + def __init__( + self, + resource: str, + *, + capacity: int, + task_queue: str | None = None, + ) -> None: + if capacity < 1: + raise ValueError("capacity must be >= 1") + self._resource = resource + self._slots: list[str] = [str(i) for i in range(capacity)] + self._task_queue = task_queue + + @asynccontextmanager + async def acquire( + self, + *, + lease: timedelta = DEFAULT_LEASE, + backoff: timedelta = DEFAULT_BACKOFF, + ) -> AsyncIterator[str]: + slot, handle = await self._acquire_one(lease=lease, backoff=backoff) + try: + yield slot + finally: + await self._release(slot, handle) + + async def _acquire_one( + self, *, lease: timedelta, backoff: timedelta + ) -> tuple[str, ChildWorkflowHandle]: + rng = workflow.random() + lease_seconds = lease.total_seconds() + while True: + order = list(self._slots) + rng.shuffle(order) + for slot in order: + wf_id = permit_workflow_id(self._resource, slot) + try: + handle = await workflow.start_child_workflow( + PermitSlotWorkflow.run, + PermitSlotInput( + resource=self._resource, + slot=slot, + lease_seconds=lease_seconds, + ), + id=wf_id, + task_queue=self._task_queue, + execution_timeout=lease, + ) + return slot, handle + except WorkflowAlreadyStartedError: + continue + await workflow.sleep(backoff) + + async def _release(self, slot: str, handle: ChildWorkflowHandle) -> None: + try: + await handle.signal("release") + except FailureError as e: + workflow.logger.info( + "release signal target already finished (lease likely expired)", + extra={ + "resource": self._resource, + "slot": slot, + "error": str(e), + }, + ) +``` + +The constructor takes a fixed `capacity` and rejects values below 1. The `Semaphore` auto-generates slot names `"0"` through `"N-1"` and reshuffles them on every attempt using `workflow.random()` so long-running contention spreads evenly across slots. The `Semaphore` keeps the `ChildWorkflowHandle` returned by `start_child_workflow` and signals release on that handle, so the Signal cannot land on a subsequent acquirer's execution. +The `FailureError` caught in `_release` covers the lease-already-expired case: the slot is already free and the release is a no-op, logged at info level for observability. +Three orphan-recovery mechanisms work together to bound slot occupancy. The default `ParentClosePolicy.TERMINATE` is the fastest, when the parent Workflow closes for any reason without releasing, Temporal terminates the permit child immediately and the slot frees right away. The in-workflow timer `wait_condition(timeout=lease)` covers the case where the parent is still alive but the holder is silent, the permit Workflow exits cleanly as `"lease_expired"`, distinguishable in the Temporal UI. That Timer only starts ticking once a Worker runs the permit's first Task, however, so the workflow timeout `execution_timeout=lease` is a server-enforced backstop that ticks from Workflow creation regardless of whether a Worker ever picks it up. Together these guarantee the slot is never leaked, even if the permit Workflow never runs. + +### Register the permit Workflow on a Worker + +The Worker that runs application Workflows must also register `PermitSlotWorkflow`. +A single-queue topology like the one below is fine for development. For production deployments, use the dedicated Task Queue described in the Background and People and process sections. + +Create a file named `worker/main.py`: + +```python +# main.py + +from __future__ import annotations + +import asyncio + +from temporalio.worker import Worker + +from distributed_lock.config import TEMPORAL_TASK_QUEUE, connect_temporal_client +from distributed_lock.workflow import PermitSlotWorkflow + +async def _run_worker() -> None: + client = await connect_temporal_client() + worker = Worker( + client, + task_queue=TEMPORAL_TASK_QUEUE, + workflows=[PermitSlotWorkflow], + activities=[], + ) + await worker.run() + +if __name__ == "__main__": + asyncio.run(_run_worker()) +``` + +`PermitSlotWorkflow` is in the `workflows=[...]` list and the `activities=[]` list is empty. +The permit lifecycle is pure Workflow code, so this library needs no Activities. `worker.run()` blocks until the awaited Task is cancelled, which is sufficient for `temporal server start-dev` and CI: Ctrl-C raises `KeyboardInterrupt` and the Worker drains in-flight work as the cancellation propagates. For containerized production deployments, add a `SIGTERM` handler that cancels the run Task so pod restarts and rolling deploys also drain gracefully. The `connect_temporal_client` function picks up environment variables to choose between Temporal Cloud API key auth, self-hosted TLS, and plain TCP for `temporal server start-dev`, so the same Worker source runs against any environment. + +### Example application Workflows + +The two examples below show how application Workflows apply the pattern in different use cases. The API is the same in both: construct a `Semaphore` with a `capacity`, wrap the gated section in `async with sem.acquire(...)`, and use the yielded slot string however the workload requires. Source files live at `examples/gate_workflow.py` and `examples/gpu_workflow.py` in the reference implementation. + +#### Generic Workflow throttling + +Cap the number of concurrent holders without caring about slot identity. The yielded slot is a string `"0"` through `"N-1"`; the Activity passes it through as a label. + +```python +# gate_workflow.py + +from __future__ import annotations + +from datetime import timedelta + +from temporalio import workflow + +from distributed_lock.semaphore import Semaphore + +@workflow.defn(name="ThrottledGateWorkflow") +class ThrottledGateWorkflow: + @workflow.run + async def run(self, job_id: str) -> str: + sem = Semaphore("app-gate", capacity=4) + async with sem.acquire(lease=timedelta(minutes=10)) as slot: + return await workflow.execute_activity( + "do_gated_work", + args=[job_id, slot], + start_to_close_timeout=timedelta(minutes=5), + heartbeat_timeout=timedelta(seconds=30), + ) +``` + +#### Resource locks (GPU, database shard, tenant) + +Hand the yielded slot to an Activity that maps it to a specific resource. The slot is a string `"0"` through `"N-1"`; convert it with `int(slot)` if the resource is indexed numerically (CUDA device, shard ID). + +```python +# gpu_workflow.py + +from __future__ import annotations + +from datetime import timedelta + +from temporalio import workflow + +from distributed_lock.semaphore import Semaphore + +@workflow.defn(name="GpuTrainingWorkflow") +class GpuTrainingWorkflow: + @workflow.run + async def run(self, model_id: str) -> str: + sem = Semaphore("gpu-pool", capacity=4) + async with sem.acquire(lease=timedelta(minutes=45)) as slot: + return await workflow.execute_activity( + "run_training", + args=[model_id, slot], + start_to_close_timeout=timedelta(minutes=30), + heartbeat_timeout=timedelta(minutes=3), + ) +``` + +#### Heartbeat the long-running Activity + +Both Activities run inside the gated critical section, so a Worker crash mid-Activity must be detected promptly to free the slot. Set `heartbeat_timeout` on the Activity invocation and call `activity.heartbeat(...)` periodically inside the Activity body. Temporal fails the Activity attempt as soon as a Heartbeat is missed for longer than the timeout, the holder Workflow then exits its `async with` block, the `Semaphore` sends `release` to the slot Workflow, and the slot returns to the pool. The lease Timer remains as a coarse last-resort recovery for cases where Heartbeats cannot run. + +```python +# activities.py + +from __future__ import annotations + +import asyncio + +from temporalio import activity + +@activity.defn(name="do_gated_work") +async def do_gated_work(job_id: str, slot: str) -> str: + total_seconds = 60 + interval = 5 + elapsed = 0 + while elapsed < total_seconds: + # `asyncio.sleep` simulates the real gated work + # (API call, batch processing, etc.). Replace with the actual workload. + await asyncio.sleep(interval) + elapsed += interval + activity.heartbeat( + {"job_id": job_id, "slot": slot, "elapsed_seconds": elapsed} + ) + return f"{job_id} done on slot {slot}" + +@activity.defn(name="run_training") +async def run_training(model_id: str, slot: str) -> str: + gpu_index = int(slot) # map "0".."3" to a CUDA device index + epochs = 10 + for epoch in range(1, epochs + 1): + # `asyncio.sleep` simulates one training epoch on cuda:{gpu_index}. + # Replace with the actual training step. + await asyncio.sleep(60) + activity.heartbeat( + {"model_id": model_id, "gpu_index": gpu_index, "epoch": epoch} + ) + return f"trained {model_id} on cuda:{gpu_index}" +``` + +Set the Heartbeat interval shorter than `heartbeat_timeout` (a 2x to 3x ratio is a common starting point) so a single delayed Heartbeat does not fail a healthy Activity. Pass progress data into `activity.heartbeat(...)` so retries can resume from the last reported point and operators can observe progress in the Temporal UI. + +## Outcomes + +By following this guide, you have built a durable distributed lock for Temporal Workflows backed by Temporal's own Workflow ID uniqueness guarantee. + +You now have the capability to: + +- Cap concurrent access to any shared resource or critical section without a database, cache-layer, or a central limiter service. +- Recover orphan permits automatically when holders crash, using per-permit lease Timers. +- Scale lock acquisition horizontally. Permit traffic distributes across short-lived child Workflows rather than concentrating on one limiter. + +## Related resources + +- [Temporal Python SDK Documentation](https://docs.temporal.io/develop/python). SDK reference for Workflows, Signals, child Workflows, and Worker setup. +- [Child Workflow Executions](https://docs.temporal.io/encyclopedia/child-workflows). How parent and child Workflows interact, including parent close policies. +- [Workflow Message Passing](https://docs.temporal.io/encyclopedia/workflow-message-passing). Reference for Signals and Queries. +- [Reference implementation source repository](https://github.com/temporal-sa/temporal-workflow-throttler). The upstream repository bundles `PermitSlotWorkflow`, the `Semaphore` helper, demo Workers, a FastAPI service, and a React UI. The package directory is named `throttler/` in the source. \ No newline at end of file diff --git a/docs/guides/rate-limit-downstream-apis.mdx b/docs/guides/rate-limit-downstream-apis.mdx new file mode 100644 index 0000000000..91372b8446 --- /dev/null +++ b/docs/guides/rate-limit-downstream-apis.mdx @@ -0,0 +1,787 @@ +--- +id: rate-limit-downstream-apis +title: Rate-limit downstream APIs with separate Task Queues +description: Protect limited resources and avoid Workflow failures by setting limits on downstream APIs with separate Task Queues. +sidebar_label: Rate-limit downstream APIs +toc_max_heading_level: 3 +author: Cecil Phillip +tags: + - Workers + - Routing + - Rate limiting +--- + +**by Cecil Phillip** + +### Problem statement + +Modern applications integrate with numerous external APIs (SendGrid, Stripe, OpenAI, Twilio) that enforce rate limits to protect their infrastructure. These limits vary by provider service and plan, for example: + +- **SendGrid:** 100 emails/minute on free tier, 1000/minute on paid +- **Stripe:** 100 requests/second globally +- **OpenAI:** 60 requests/minute, 90,000 tokens/minute +- **Twilio:** 1 request/second per phone number + +Without proactive rate limiting when calling downstream services, users may experience the following issues: +- **HTTP 429 errors:** Activities overwhelm APIs causing "Too Many Requests" errors +- **Account suspension:** Repeated violations can lead to temporary or permanent bans from the downstream service +- **Failed Workflows:** Without proper retry handling, Workflows may not be able to continue to make progress +- **Wasted execution:** Activities that will fail due to rate limits consume Worker resources +- **Cascading failures:** One Workflow's excessive API calls could affect others + +### Solution + +Use separate Task Queues with rate limiting configuration to protect downstream APIs. Create one Task Queue per rate-limited API and configure: + +- `max_task_queue_activities_per_second`: Global rate limit across all Workers on the queue +- `max_activities_per_second`: Per-Worker rate limit (optional, for additional control) +- `max_concurrent_activities`: Limit concurrent executions when API has concurrency limits + +This ensures Activities calling external APIs never exceed their rate limits, preventing 429 errors and account issues. + +### Outcomes + +- **Rate limit compliance:** Activities respect API rate limits, eliminating 429 errors and preventing account suspension +- **Improved reliability:** Workflows complete successfully without failures caused by rate limits +- **Resource efficiency:** Workers don't waste resources on doomed-to-fail Activities +- **Better API relationships:** Consistent rate limit compliance maintains good standing with API providers +- **Independent scaling:** Add more Workers without exceeding API rate limits (global queue limit applies) + +## Background and best practices + +### Task Queue fundamentals + +Task Queues in Temporal are dynamically created when first referenced. Rate limiting is configured at the Worker level and enforced by the Temporal Server. + +**Recommended practice:** Create one Task Queue per rate-limited API to isolate rate limits. + +### Rate limiting configuration options + +The Python SDK provides three rate limiting controls: + +1. **max_task_queue_activities_per_second** (global): Limits Activity dispatch across ALL Workers on this queue. Enforced by Temporal Server. Best for API rate limits. Last value wins. + +2. **max_activities_per_second** (per-worker): Limits Activities per Worker. Can be combined with global limit for finer control. + +3. **max_concurrent_activities** (concurrency): Limits concurrent executions. Use when API has concurrent connection limits (e.g., database pool size). + +4. **disable_eager_activity_execution** (Client configuration): Set to `True` when starting Workflows to prevent Activities from being eagerly assigned to the Workflow Worker, ensuring they go through the rate-limited Task Queue instead. + +**Important:** Without `disable_eager_activity_execution=True`, Activities may bypass your rate-limited Task Queues entirely. Eager execution runs Activities on the same Worker as the Workflow, which circumvents the rate limiting controls configured on the Activity-specific Task Queues. Always disable eager execution when using rate-limited Task Queues for API calls. + +### Handling HTTP 429 responses + +Even with rate limiting, occasional 429 errors may occur due to: +- Other systems using the same API key +- API provider reducing limits temporarily +- Burst traffic patterns + +**Recommended practice:** Raise an exception with a specific next retry delay from the API's `Retry-After` header, and fallback to exponential backoff when the header is not available: + +1. When catching 429 errors, check for the `Retry-After` response header +2. If present, raise an `ApplicationError` with `next_retry_delay` set to the header value +3. If not present, raise the exception normally to use the Activity's Retry Policy + +```python +retry_policy=workflow.RetryPolicy( + initial_interval=timedelta(seconds=1), + maximum_interval=timedelta(minutes=10), + backoff_coefficient=2.0, + maximum_attempts=5, +) +``` + +**Understanding the retry cadence:** + +With this configuration, the retry intervals follow this pattern: +- Attempt 1: 1 second +- Attempt 2: 2 seconds (1s × 2.0) +- Attempt 3: 4 seconds (2s × 2.0) +- Attempt 4: 8 seconds (4s × 2.0) +- Attempt 5: 16 seconds (8s × 2.0) + +Total time before failure: ~31 seconds across 5 attempts. + +**Key differences from default Retry Policy:** +- **maximum_attempts**: Set to `5` instead of the default `unlimited`. This prevents infinite retries for persistent rate limiting issues and ensures timely failure detection. +- **maximum_interval**: Set to `10 minutes` instead of the default `100x initial_interval`. This caps retry delays at a reasonable duration. + +For rate-limited APIs, capping `maximum_attempts` ensures that Activities don't retry indefinitely if an API is experiencing extended downtime or if your rate limits have been permanently reduced. + +This approach respects the API's rate limit guidance while providing a sensible fallback strategy. + +### Operational considerations + +- **Monitor API usage:** Track actual API calls vs rate limits to adjust configured rate limits in your Temporal Task Queues +- **API limit changes:** API providers may change rate limits; monitor and update Worker configuration +- **Burst allowance:** Some APIs allow short bursts above stated limits; test to determine safe Temporal limits +- **Multiple environments:** Use separate API keys and Task Queues for dev/staging/prod + +#### Task Queue backlog and draining strategies + +During throttling events, Task Queues can grow significantly. Operators need to switch to **draining mitigation mode** when backlogs occur. + +**Critical considerations:** +- **Determine acceptable SLA/SLO for draining:** How long is acceptable for the queue to drain? Hours? Days? +- **Cascading failures:** Simply increasing downstream API or Temporal Cloud rate limits may move the bottleneck elsewhere, potentially overwhelming the next component in line + +**Mitigation strategies (in order of consideration):** + +1. **Request downstream API rate limit increases** - Work with API providers to increase your rate limits +2. **Request Temporal Cloud rate limit increases** - If using Temporal Cloud, request higher limits +3. **Scale Worker pools** - Add more Workers or adjust Worker configuration +4. **Increase internal resources** - Scale your infrastructure +5. **Identify the next bottleneck** - Determine what will become throttled next to prevent cascading failures + +#### CLI commands for operators + +**Adjust Task Queue rate limits dynamically:** + +```bash +# Set rate limit for a specific task queue +temporal task-queue config set --queue-rps-limit 99 --task-queue sendgrid-api +``` + +This allows operators to adjust rate limits without redeploying workers. + +**Reset stuck activities:** + +When activities are stuck at their maximum retry intervals: + +```bash +# Reset activities to retry immediately +temporal activity reset --workflow-id --activity-id +``` + +This is useful when downstream APIs recover from outages and you want to immediately retry activities that are waiting at long backoff intervals (e.g., 10 minutes). + +## Target audience + +- **Temporal Workflow & Activity developers:** Implementing API integrations with rate limiting +- **Platform operators:** Configuring and monitoring rate-limited Workers +- **API integration engineers:** Ensuring compliance with third-party API limits +- **SRE teams:** Preventing API-related outages + +This implementation requires Worker configuration, Activity error handling, and monitoring of API usage against limits. + +## Prerequisites + +### Required software, infrastructure, and tools + +- Temporal Server v1.17+ (for `max_task_queue_activities_per_second` support) +- Python 3.8 or later +- Temporal Python SDK v1.0.0 or later (`pip install temporalio`) +- API keys and documentation for external services + +### Resources and access privileges + +- Temporal Namespace with permissions to start Workflows and register Workers +- API keys with known rate limits for external services +- Access to API provider dashboards to monitor usage + +### Required concepts + +- Temporal Workflows, Activities, and Task Queues +- HTTP client libraries (httpx, requests) +- API authentication (API keys, OAuth) +- Exponential backoff and retry strategies + +## Architecture diagram(s) + +### Rate-Limited Task Queues architecture + +```mermaid +flowchart TB + subgraph Workflow["Workflow Execution"] + WF[Notification
Workflow] + end + + subgraph Routing["Activity Routing"] + Decision{Which
API?} + end + + subgraph Queues["Rate-Limited Task Queues"] + SGTQ[sendgrid-api
100/min = 1.67/sec] + TWTQ[twilio-api
60/min = 1/sec] + AITQ[openai-api
60/min = 1/sec] + end + + subgraph Workers["Worker Pools"] + subgraph SGWP["SendGrid Workers"] + SGW1[Worker 1] + SGW2[Worker 2] + SGW3[Global Limit:
1.5/sec] + end + subgraph TWWP["Twilio Workers"] + TWW1[Worker 1] + TWW2[Worker 2] + TWW3[Global Limit:
0.9/sec] + end + subgraph AIWP["OpenAI Workers"] + AIW1[Worker 1] + AIW2[Worker 2] + AIW3[Global Limit:
0.9/sec] + end + end + + subgraph APIs["External APIs"] + SGAPI[SendGrid API
Rate Limit: 100/min] + TWAPI[Twilio API
Rate Limit: 60/min] + AIAPI[OpenAI API
Rate Limit: 60/min] + end + + WF --> Decision + + Decision -->|Email| SGTQ + Decision -->|SMS| TWTQ + Decision -->|AI| AITQ + + SGTQ --> SGWP + TWTQ --> TWWP + AITQ --> AIWP + + SGWP -.->|Respects
100/min limit| SGAPI + TWWP -.->|Respects
60/min limit| TWAPI + AIWP -.->|Respects
60/min limit| AIAPI + + classDef blue stroke-width:1px + classDef amber stroke-width:1px + classDef purple stroke-width:1px + class SGTQ blue + class TWTQ amber + class AITQ purple +``` + +## Implementation plan + +### Step 1: Identify API rate limits + +Document rate limits for all external APIs your application uses. + +**Example rate limit inventory:** + +| API | Rate Limit | Concurrency Limit | Plan/Tier | Notes | +|-----|-----------|-------------------|-----------|-------| +| SendGrid | 100 req/min | None | Free | Burst: 300/min for 1 min | +| Stripe | 100 req/sec | None | Standard | Per API key globally | +| OpenAI | 60 req/min | None | Tier 1 | Also: 90K tokens/min | +| Twilio | 60 req/min | None | Free | Per phone number | +| Database | None | 100 connections | N/A | Connection pool limit | + +**Actions:** +1. Review API documentation for each integrated service +2. Check your current plan/tier limits +3. Determine if limits are per API key, per account, or per resource +4. Set Temporal rate limits to 90% of API limits (leave safety buffer) + +### Step 2: Define Task Queue constants + +**File: `task_queues.py`** + +```python +"""Task Queue constants for rate-limited APIs.""" + +# Rate-limited API task queues +SENDGRID_API_QUEUE = "sendgrid-api" +STRIPE_API_QUEUE = "stripe-api" +OPENAI_API_QUEUE = "openai-api" +TWILIO_API_QUEUE = "twilio-api" +``` + +### Step 3: Configure Workers with rate limiting + +**File: `worker_sendgrid.py`** + +```python +"""Worker for rate-limited SendGrid email activities.""" +import asyncio +import logging +from temporalio.client import Client +from temporalio.worker import Worker + +from task_queues import SENDGRID_API_QUEUE +from activities import send_email, send_batch_email + +logging.basicConfig(level=logging.INFO) + + +async def main(): + client = await Client.connect("localhost:7233") + + worker = Worker( + client, + task_queue=SENDGRID_API_QUEUE, + activities=[send_email, send_batch_email], + + # SendGrid free tier: 100 emails/minute = 1.67/sec + # Set to 1.5/sec for safety buffer + # This limit applies GLOBALLY across all workers on this queue + max_task_queue_activities_per_second=1.5, + + disable_eager_activity_execution=True, + + # Optional: Also limit per-worker to prevent single worker bursts + max_activities_per_second=0.5, + + # Limit concurrent connections (SendGrid allows many concurrent) + max_concurrent_activities=20, + ) + + logging.info( + f"Starting SendGrid worker on {SENDGRID_API_QUEUE} " + f"(max 1.5/sec globally)" + ) + await worker.run() + + +if __name__ == "__main__": + asyncio.run(main()) +``` + +**File: `worker_openai.py`** + +```python +"""Worker for rate-limited OpenAI API activities.""" +import asyncio +import logging +from temporalio.client import Client +from temporalio.worker import Worker + +from task_queues import OPENAI_API_QUEUE +from activities import generate_text, generate_embeddings + +logging.basicConfig(level=logging.INFO) + + +async def main(): + client = await Client.connect("localhost:7233") + + worker = Worker( + client, + task_queue=OPENAI_API_QUEUE, + activities=[generate_text, generate_embeddings], + + # OpenAI Tier 1: 60 requests/minute = 1/sec + # Set to 0.9/sec for safety + max_task_queue_activities_per_second=0.9, + + # Limit concurrent requests + max_concurrent_activities=5, + ) + + logging.info( + f"Starting OpenAI worker on {OPENAI_API_QUEUE} " + f"(max 0.9/sec globally)" + ) + await worker.run() + + +if __name__ == "__main__": + asyncio.run(main()) +``` + +**File: `worker_stripe.py`** + +```python +"""Worker for rate-limited Stripe API activities.""" +import asyncio +import logging +from temporalio.client import Client +from temporalio.worker import Worker + +from task_queues import STRIPE_API_QUEUE +from activities import create_payment, refund_payment + +logging.basicConfig(level=logging.INFO) + + +async def main(): + client = await Client.connect("localhost:7233") + + worker = Worker( + client, + task_queue=STRIPE_API_QUEUE, + activities=[create_payment, refund_payment], + + # Stripe: 100 requests/second globally + # Set to 90/sec for safety + max_task_queue_activities_per_second=90, + + # High concurrency allowed + max_concurrent_activities=50, + ) + + logging.info( + f"Starting Stripe worker on {STRIPE_API_QUEUE} " + f"(max 90/sec globally)" + ) + await worker.run() + + +if __name__ == "__main__": + asyncio.run(main()) +``` + +**Deployment guidance:** +- Deploy 2-5 Workers per API-specific Task Queue +- Rate limit is enforced globally by Temporal Service across all Workers +- More Workers = better fault tolerance, but rate limit still applies +- Monitor Worker CPU/memory; rate-limited Workers typically have low utilization + +### Step 4: Implement Activities with proper error handling + +**File: `activities.py`** + +```python +"""Activities that call rate-limited external APIs.""" +import httpx +from temporalio import activity +from temporalio.exceptions import ApplicationError +from datetime import timedelta + +# API keys (use environment variables in production) +SENDGRID_API_KEY = "your-sendgrid-key" +STRIPE_API_KEY = "your-stripe-key" +OPENAI_API_KEY = "your-openai-key" + + +@activity.defn +async def send_email(email_data: dict) -> dict: + """ + Send email via SendGrid. + + Rate limited by worker configuration to 1.5/sec globally. + """ + activity.logger.info(f"Sending email to {email_data['to']}") + + async with httpx.AsyncClient() as client: + try: + response = await client.post( + "https://api.sendgrid.com/v3/mail/send", + headers={ + "Authorization": f"Bearer {SENDGRID_API_KEY}", + "Content-Type": "application/json", + }, + json={ + "personalizations": [{"to": [{"email": email_data["to"]}]}], + "from": {"email": "noreply@example.com"}, + "subject": email_data["subject"], + "content": [{"type": "text/html", "value": email_data["body"]}], + }, + timeout=30.0, + ) + + response.raise_for_status() + activity.logger.info(f"Email sent to {email_data['to']}") + + return {"status": "sent", "email": email_data["to"]} + + except httpx.HTTPStatusError as e: + if e.response.status_code == 429: + # Check for Retry-After header (in seconds) + retry_after = e.response.headers.get("Retry-After") + + if retry_after: + # Use specific retry delay from API + retry_delay = timedelta(seconds=int(retry_after)) + activity.logger.warning( + f"Rate limit hit for {email_data['to']}, " + f"retry after {retry_after}s" + ) + raise ApplicationError( + "Rate limit exceeded", + non_retryable=False, + next_retry_delay=retry_delay, + ) + else: + # Fallback to exponential backoff via retry policy + activity.logger.warning( + f"Rate limit hit for {email_data['to']}, " + f"using exponential backoff" + ) + raise + + # Other HTTP errors + activity.logger.error(f"HTTP error {e.response.status_code}: {e}") + raise + + except Exception as e: + activity.logger.error(f"Failed to send email: {e}") + raise + + +@activity.defn +async def generate_text(prompt: str) -> dict: + """ + Generate text using OpenAI API. + + Rate limited to 0.9/sec globally. + """ + activity.logger.info(f"Generating text for prompt: {prompt[:50]}...") + + async with httpx.AsyncClient() as client: + try: + response = await client.post( + "https://api.openai.com/v1/chat/completions", + headers={ + "Authorization": f"Bearer {OPENAI_API_KEY}", + "Content-Type": "application/json", + }, + json={ + "model": "gpt-3.5-turbo", + "messages": [{"role": "user", "content": prompt}], + }, + timeout=60.0, + ) + + response.raise_for_status() + result = response.json() + + activity.logger.info("Text generated successfully") + return { + "text": result["choices"][0]["message"]["content"], + "usage": result["usage"], + } + + except httpx.HTTPStatusError as e: + if e.response.status_code == 429: + # Check for Retry-After header + retry_after = e.response.headers.get("Retry-After") + error_data = e.response.json() + + if retry_after: + # Use specific retry delay from API + retry_delay = timedelta(seconds=int(retry_after)) + activity.logger.warning( + f"OpenAI rate limit, retry after {retry_after}s: " + f"{error_data.get('error', {}).get('message')}" + ) + raise ApplicationError( + "OpenAI rate limit exceeded", + non_retryable=False, + next_retry_delay=retry_delay, + ) + else: + # Fallback to exponential backoff + activity.logger.warning( + f"OpenAI rate limit, using exponential backoff: " + f"{error_data.get('error', {}).get('message')}" + ) + raise + + activity.logger.error(f"OpenAI error {e.response.status_code}: {e}") + raise + + +@activity.defn +async def create_payment(payment_data: dict) -> dict: + """ + Create payment using Stripe API. + + Rate limited to 90/sec globally. + """ + activity.logger.info( + f"Creating payment for ${payment_data['amount']/100:.2f}" + ) + + async with httpx.AsyncClient() as client: + try: + response = await client.post( + "https://api.stripe.com/v1/payment_intents", + headers={ + "Authorization": f"Bearer {STRIPE_API_KEY}", + }, + data={ + "amount": payment_data["amount"], + "currency": payment_data["currency"], + "payment_method": payment_data["payment_method"], + "confirm": "true", + }, + timeout=30.0, + ) + + response.raise_for_status() + result = response.json() + + activity.logger.info(f"Payment created: {result['id']}") + return { + "payment_id": result["id"], + "status": result["status"], + } + + except httpx.HTTPStatusError as e: + if e.response.status_code == 429: + # Check for Retry-After header + retry_after = e.response.headers.get("Retry-After") + + if retry_after: + # Use specific retry delay from API + retry_delay = timedelta(seconds=int(retry_after)) + activity.logger.warning( + f"Stripe rate limit hit, retry after {retry_after}s" + ) + raise ApplicationError( + "Stripe rate limit exceeded", + non_retryable=False, + next_retry_delay=retry_delay, + ) + else: + # Fallback to exponential backoff + activity.logger.warning( + "Stripe rate limit hit, using exponential backoff" + ) + raise + + activity.logger.error(f"Stripe error {e.response.status_code}") + raise +``` + +### Step 5: Route Activities to rate-limited queues in Workflows + +**File: `notification_workflow.py`** + +```python +"""Workflow that routes to rate-limited API queues.""" +from datetime import timedelta +from dataclasses import dataclass +from temporalio import workflow + +with workflow.unsafe.imports_passed_through(): + from task_queues import SENDGRID_API_QUEUE, OPENAI_API_QUEUE, STRIPE_API_QUEUE + + +@dataclass +class NotificationRequest: + user_email: str + notification_type: str + data: dict + + +@workflow.defn +class NotificationWorkflow: + """ + Send notifications via multiple rate-limited APIs. + + Routes to appropriate queues based on notification type. + """ + + @workflow.run + async def run(self, request: NotificationRequest) -> dict: + workflow.logger.info( + f"Processing {request.notification_type} notification " + f"for {request.user_email}" + ) + + results = {} + + # Send email (rate-limited to 1.5/sec) + if "email" in request.notification_type: + email_result = await workflow.execute_activity( + "send_email", + { + "to": request.user_email, + "subject": f"{request.notification_type} Notification", + "body": str(request.data), + }, + task_queue=SENDGRID_API_QUEUE, + start_to_close_timeout=timedelta(minutes=2), + retry_policy=workflow.RetryPolicy( + initial_interval=timedelta(seconds=1), + maximum_interval=timedelta(minutes=10), + backoff_coefficient=2.0, + maximum_attempts=5, + ), + ) + results["email"] = email_result + + # Generate AI content (rate-limited to 0.9/sec) + if "ai" in request.notification_type: + ai_result = await workflow.execute_activity( + "generate_text", + f"Generate notification: {request.data}", + task_queue=OPENAI_API_QUEUE, + start_to_close_timeout=timedelta(minutes=5), + retry_policy=workflow.RetryPolicy( + initial_interval=timedelta(seconds=1), + maximum_interval=timedelta(minutes=10), + backoff_coefficient=2.0, + maximum_attempts=5, + ), + ) + results["ai"] = ai_result + + workflow.logger.info(f"Notification sent to {request.user_email}") + return results +``` + +**Starter example:** + +```python +# start_workflow.py +"""Start notification workflow.""" +import asyncio +from temporalio.client import Client +from notification_workflow import NotificationWorkflow, NotificationRequest + + +async def main(): + client = await Client.connect("localhost:7233") + + # Send 100 notifications - rate limiting prevents API overload + for i in range(100): + request = NotificationRequest( + user_email=f"user{i}@example.com", + notification_type="email", + data={"message": f"Notification {i}"}, + ) + + handle = await client.start_workflow( + NotificationWorkflow.run, + request, + id=f"notification-{i}", + task_queue="workflows", + # Disable eager execution to ensure activities go through rate-limited queues + disable_eager_activity_execution=True, + ) + + print(f"Started workflow {i}: {handle.id}") + + print("All workflows started. Rate limiting ensures API compliance.") + + +if __name__ == "__main__": + asyncio.run(main()) +``` + +## Conclusion + +By implementing rate-limited separate Task Queues for external APIs, you have achieved: + +1. **Rate limit compliance:** Activities respect API rate limits (e.g., SendGrid 1.5/sec, OpenAI 0.9/sec), eliminating 429 errors and preventing account suspension + +2. **Improved reliability:** Workflows complete successfully without rate limit failures. Global rate limiting ensures that adding more Workers doesn't violate API limits. + +3. **Better API relationships:** Consistent rate limit compliance maintains good standing with API providers, avoiding temporary bans or throttling + +4. **Independent scaling:** Add more Workers to increase throughput for other operations without exceeding API rate limits (global queue limit still applies) + +Your application now safely integrates with rate-limited external APIs, ensuring compliance and reliability. + +## Related resources + +### Official documentation +- [Temporal Documentation - Task Routing](https://docs.temporal.io/task-routing) +- [Temporal Best Practices - Separate Task Queues](https://docs.temporal.io/best-practices/worker#separate-task-queues-logically) +- [Python SDK Worker Configuration](https://python.temporal.io/temporalio.worker.Worker.html) + +### Related patterns +- [Separate Task Queues - Worker Affinity](worker-execution-affinity) - For activities on same Worker + +### Community resources +- [Forum: When to Use Multiple Task Queues](https://community.temporal.io/t/in-what-situation-should-we-use-multiple-separated-task-queues/1254) +- [Slack: Rate Limiting Activities](https://temporal.io/slack) - Search for "rate limiting" discussions + +### API documentation +- [SendGrid Rate Limits](https://docs.sendgrid.com/api-reference/rate-limits) +- [Stripe Rate Limits](https://stripe.com/docs/rate-limits) +- [OpenAI Rate Limits](https://platform.openai.com/docs/guides/rate-limits) \ No newline at end of file diff --git a/docs/guides/reliable-document-approvals.mdx b/docs/guides/reliable-document-approvals.mdx new file mode 100644 index 0000000000..350e426190 --- /dev/null +++ b/docs/guides/reliable-document-approvals.mdx @@ -0,0 +1,1356 @@ +--- +id: reliable-document-approvals +title: Reliable document approvals with human-in-the-loop workflows +description: Build durable human-in-the-loop Workflows that can recover even if the humans are delayed. +sidebar_label: Reliable document approvals +toc_max_heading_level: 3 +author: Cecil Phillip +tags: + - Workflows + - Signals + - Activities + - Timers + - Continue-as-new + - Child workflows + - Task queues + - Workers +--- + +**by Cecil Phillip** + +Document approval processes fail in predictable ways: requests go unanswered, deadlines pass silently, context gets lost across restarts, and audit trails end up incomplete. The usual fixes — polling loops, cron jobs, reminder emails kicked off by a scheduler — create coordination debt that compounds over time. This guide shows how to build an approval system where none of that is possible: decisions are waited on durably, SLAs are enforced automatically, escalations happen without a scheduler, and every action is recorded whether or not your infrastructure stays up. + +### Problem statement + +An approval request may not get a response until the following week — or ever. Between submission and decision, a lot can go wrong: the request gets buried in an inbox, the original approver is out, the system restarts and loses state, or nobody notices the deadline passed. Patching this with a database, a cron job, a notification service, and custom reconciliation logic means maintaining four systems to do one thing. + +### Solution + +The approval process runs as a single Workflow that holds its own state and waits — without consuming compute — for as long as it takes. Deadlines are tracked by durable timers that fire whether or not Workers restart. Reminders go out on schedule. If no one responds in time, escalation happens automatically. Resubmissions loop back cleanly. A complete audit record is written at every step. The infrastructure can fail and recover; the approval process continues from exactly where it left off. + +### Outcomes + +After working through this guide, you'll have a running approval system where: + +- **Requests don't disappear when infrastructure does**: Temporal's Event History replays the Workflow to its exact pre-failure state, so no approval decisions or audit entries are ever lost. +- **Deadlines enforce themselves**: Durable timers track every SLA and fire precisely when a deadline expires — no external scheduler, no polling loop, no cron job. +- **Escalations happen automatically when reviewers go quiet**: If an approver doesn't respond within the SLA window, the Workflow escalates to a backup approver without any external trigger. +- **Resubmissions work without restarting the process from scratch**: Change requests loop back cleanly, preserving full context and the existing audit trail. +- **Every action is in the audit record**: Decisions, reminders, escalations, and status changes are all recorded — whether from a Signal, an Activity, or a timer firing. + +## Background and best practices + +### Why human-in-the-loop matters + +Some decisions can't be automated — a contract requiring legal sign-off, a patient record needing clinical approval, an AI-generated document awaiting human review. A system that drops a request, misses a deadline, or produces an incomplete audit trail creates real business and legal exposure. + +### How Temporal enables the "wait" state + +When a Workflow calls `workflow.wait_condition()`, **the Worker returns the current task to the Temporal Server and becomes idle** — consuming no compute. The Workflow's state is persisted and suspended on the Server. When a Signal arrives or a timeout fires, the Server schedules a new Workflow Task, the Worker replays the Event History to reconstruct the Workflow's state, and execution resumes from the `wait_condition` call. This mechanism works identically whether the wait is five seconds or five months — durable timers are persisted to the Server's database and survive Worker restarts, deployments, and infrastructure migrations. + +### Event History management + +Temporal's Event History has a hard limit of 51,200 events or 50 MB ([Workflow Execution limits](https://docs.temporal.io/workflow-execution/limits)). The Server emits warnings at ~10,240 events or 10 MB; hitting either hard limit terminates the Workflow. For this approval pattern: + +| Workflow operation | Approximate events generated | +|--------------------|------------------------------| +| Workflow start (baseline) | 3 events | +| Single Activity execution | ~8 events | +| Timer (`asyncio.sleep` / `workflow.sleep`) | ~4 events | +| Signal received | ~4 events | +| Query received | 0 events (not recorded) | +| Continue-As-New | 1 event on the old run; fresh history on the new run | + +A single approval with reminders and escalation generates roughly 40–80 events — well within limits. If your process supports resubmission loops or runs for months, use `workflow.info().is_continue_as_new_suggested()` to detect when the Server recommends a Continue-As-New, and act on it at a safe boundary. + +### Workflow determinism + +Workflow code must be deterministic because Temporal uses *history replay* to reconstruct state after Worker restarts. If the code generates different commands on replay, the SDK raises a `NondeterminismError`. + +Key rules: + +- Use `workflow.now()` instead of `datetime.now()` for timestamps +- Use `workflow.uuid4()` instead of `uuid.uuid4()` for generating unique identifiers +- Use `workflow.random()` instead of Python's `random` module +- Never perform I/O (network calls, file reads, database queries) directly in Workflow code — delegate to Activities +- Use `workflow.logger` instead of `print()` for replay-safe logging + +### Activity idempotency + +Activities may be retried if a Worker crashes mid-execution. Each Activity must be designed so that executing it twice with the same inputs produces the same outcome: + +- **Idempotency keys**: Include the Workflow Id and a step identifier in every external API call. +- **Check-before-act**: Query the external system's state before performing a mutation. +- **Upsert semantics**: Use database upsert operations for audit log entries keyed on Workflow Id and approval stage. + +### Long running Activities and Heartbeating + +For Activities that may take minutes (PDF report generation, rate-limited API calls): + +- Set `start_to_close_timeout` to the maximum time for a single attempt. +- Set `heartbeat_timeout` to the maximum acceptable interval between progress reports. If the Worker crashes, the Server retries on another Worker within this window. +- Call `activity.heartbeat()` periodically with a progress payload. On retry, read `activity.info().heartbeat_details` to resume from where it left off. + +## Prerequisites + +### Required software and infrastructure + +- Python **3.11** or later +- Temporal Python SDK (`temporalio`) **1.7.0** or later +- A running Temporal Server — either the [Temporal CLI](https://docs.temporal.io/cli) development server (`temporal server start-dev`) or [Temporal Cloud](https://temporal.io/cloud) +- `uv` or `pip` for Python dependency management + +### Resources and access privileges + +- (Optional) SMTP server or third-party notification API credentials for the notification Activity + +### Required concepts + +Readers should be familiar with the following concepts: + +- [Signals](https://docs.temporal.io/signals) — the mechanism for sending data to a running Workflow +- [Queries](https://docs.temporal.io/queries) — the mechanism for reading Workflow state without affecting execution +- [Workers](https://docs.temporal.io/workers) — the processes that execute Workflow and Activity code +- [Task Queues](https://docs.temporal.io/task-queues) — the named queues that route work to Workers +- Python `asyncio` — coroutines, `await`, and the event loop +- Python dataclasses or [Pydantic](https://docs.pydantic.dev/) for structured data + +## Architecture diagram + +The following sequence diagram illustrates the complete lifecycle of a document approval Workflow, from submission through approval to final disposition. + +```mermaid +sequenceDiagram + participant Submitter as Document Submitter + participant API as Web API / Client + participant TS as Temporal Server + participant W as Worker (Python) + participant NS as Notification Service + participant AuditDB as Audit Log Store + + Submitter->>API: Submit document for approval + API->>TS: start_workflow(DocumentApprovalWorkflow, document) + TS->>W: Schedule Workflow Task + W->>TS: Schedule Activity: store_document + TS->>W: Execute store_document Activity + W-->>TS: Document stored + W->>TS: Schedule Activity: send_notification (to approver) + TS->>W: Execute send_notification Activity + W->>NS: Send approval request email + NS-->>W: Notification sent + W-->>TS: Workflow waiting for Signal (approval decision) + + Note over TS,W: Workflow is suspended. Zero compute consumed. + + alt Approver responds within SLA + Submitter->>API: Approve document + API->>TS: signal(workflow_id, "submit_decision", APPROVED) + TS->>W: Deliver Signal + Schedule Workflow Task + W->>TS: Schedule Activity: record_audit_entry + TS->>W: Execute record_audit_entry Activity + W->>AuditDB: Write audit entry + W->>TS: Schedule Activity: generate_approval_report + Note over W: Generate final report and complete... + else SLA deadline expires + TS->>W: Timer fires, Workflow Task scheduled + W->>W: Handle TimeoutError — escalate + W->>TS: Schedule Activity: send_notification (to escalation contact) + else Approver rejects + Submitter->>API: Reject document + API->>TS: signal(workflow_id, "submit_decision", REJECTED) + TS->>W: Deliver Signal + W->>TS: Schedule Activity: record_audit_entry + W->>TS: Schedule Activity: send_notification (rejection notice) + W-->>TS: Workflow completes with REJECTED status + end +``` + +## Implementation plan + +The following phases walk through the complete implementation. Each phase builds on the previous one. + +### Phase 1: Define the data models + +Start by defining the data structures that represent documents, approval decisions, and the state that flows through the Workflow. Using Python dataclasses keeps the models serializable by Temporal's default JSON data converter without additional configuration. + +Create a file named `models.py`: + +```python +"""Data models for the document approval system.""" + +from __future__ import annotations + +import enum +from dataclasses import dataclass, field +from datetime import datetime + + +class ApprovalStatus(str, enum.Enum): + """Possible statuses for an approval decision.""" + + PENDING = "PENDING" + APPROVED = "APPROVED" + REJECTED = "REJECTED" + ESCALATED = "ESCALATED" + TIMED_OUT = "TIMED_OUT" + CHANGES_REQUESTED = "CHANGES_REQUESTED" + + +class DocumentStatus(str, enum.Enum): + """Overall status of the document approval process.""" + + SUBMITTED = "SUBMITTED" + IN_REVIEW = "IN_REVIEW" + APPROVED = "APPROVED" + REJECTED = "REJECTED" + WITHDRAWN = "WITHDRAWN" + + +@dataclass +class ApproverConfig: + """Configuration for the approver.""" + + approver_email: str + approver_name: str + sla_seconds: int # Maximum time to wait for the approver's decision + escalation_email: str | None = None # Backup approver if SLA expires + reminder_interval_seconds: int = 86400 # Send reminders every 24 hours + max_reminders: int = 3 # Maximum number of reminders to send + resubmission_timeout_seconds: int = 604800 # Time to allow resubmission after changes requested (default 7 days) + + +@dataclass +class ApprovalDecision: + """A decision submitted by an approver.""" + + approver_email: str + status: ApprovalStatus + comment: str = "" + decided_at: str = "" # ISO 8601 timestamp, set by the Workflow + + +@dataclass +class AuditEntry: + """A single entry in the approval audit trail.""" + + timestamp: str # ISO 8601 + workflow_id: str + action: str + actor: str + details: str = "" + approval_level: int = 0 + + +@dataclass +class DocumentSubmission: + """Input to the document approval Workflow.""" + + document_id: str + title: str + submitter_email: str + submitter_name: str + content_url: str # Reference to the stored document (Claim Check pattern) + approver: ApproverConfig = field(default_factory=lambda: ApproverConfig( + approver_email="", + approver_name="", + sla_seconds=86400, + )) + metadata: dict[str, str] = field(default_factory=dict) + + +@dataclass +class ApprovalState: + """Mutable state carried through the approval process and passed to Continue-As-New.""" + + document: DocumentSubmission + status: DocumentStatus = DocumentStatus.SUBMITTED + decision: ApprovalDecision | None = None + audit_trail: list[AuditEntry] = field(default_factory=list) + resubmission_count: int = 0 + max_resubmissions: int = 3 + document_stored: bool = False +``` + +The `ApprovalState` dataclass carries everything the Workflow needs to resume after a Continue-As-New. + +### Phase 2: Define the Activities + +Create a file named `activities.py`: + +```python +"""Activities for the document approval system. + +Each Activity performs a side-effecting operation and is designed to be idempotent. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import timedelta + +from temporalio import activity +from temporalio.exceptions import ApplicationError + +from models import AuditEntry, DocumentSubmission + + +@dataclass +class NotificationRequest: + """Input for the send_notification Activity.""" + + recipient_email: str + recipient_name: str + subject: str + body: str + idempotency_key: str # Prevents duplicate sends on retry + + +@dataclass +class StoreDocumentRequest: + """Input for the store_document Activity.""" + + document_id: str + title: str + content_url: str + submitter_email: str + + +@activity.defn +def send_notification(request: NotificationRequest) -> bool: + """Send a notification; raises ApplicationError for invalid input (non-retryable).""" + # Validate input — non-retryable business logic error + if not request.recipient_email or "@" not in request.recipient_email: + raise ApplicationError( + f"Invalid recipient email: {request.recipient_email}" + ) + + if not request.subject or not request.subject.strip(): + raise ApplicationError("Notification subject cannot be empty") + + activity.logger.info( + "Sending notification", + extra={ + "recipient": request.recipient_email, + "subject": request.subject, + "idempotency_key": request.idempotency_key, + }, + ) + + # --- Add your notification provider ---- + + activity.logger.info( + "Notification sent successfully", + extra={"recipient": request.recipient_email}, + ) + return True + + +@activity.defn +def record_audit_entry(entry: AuditEntry) -> bool: + """Write an audit entry; uses upsert semantics to prevent duplicates on retry.""" + activity.logger.info( + "Recording audit entry", + extra={ + "workflow_id": entry.workflow_id, + "action": entry.action, + "actor": entry.actor, + "level": entry.approval_level, + }, + ) + + # --- Add your audit log store --- + + return True + + +@activity.defn +def store_document(request: StoreDocumentRequest) -> str: + """Persist document metadata; idempotent — storing the same document_id twice overwrites with identical data.""" + activity.logger.info( + "Storing document", + extra={ + "document_id": request.document_id, + "title": request.title, + }, + ) + + # --- Add your document store --- + + return f"doc-store://{request.document_id}" + + +@activity.defn +def generate_approval_report( + document_id: str, + decisions: list[dict], +) -> str: + """Generate an approval summary report; uses heartbeating to support long runs.""" + activity.logger.info( + "Generating approval report", + extra={"document_id": document_id, "decision_count": len(decisions)}, + ) + + total_steps = len(decisions) + for i, decision in enumerate(decisions): + # Simulate report generation work for each decision + activity.logger.info( + f"Processing decision {i + 1}/{total_steps}", + extra={"approver": decision.get("approver_email", "unknown")}, + ) + + # Heartbeat with progress so the server can detect Worker crashes. + activity.heartbeat(i + 1) + + report_url = f"reports://{document_id}/approval-summary" + activity.logger.info( + "Approval report generated", + extra={"document_id": document_id, "report_url": report_url}, + ) + return report_url +``` + +### Phase 3: Define the approval Workflow + +Create a file named `workflows.py`: + +```python +"""Document approval Workflow with SLA enforcement, escalation, resubmission handling, and audit logging.""" + +from __future__ import annotations + +import asyncio +from dataclasses import asdict +from datetime import timedelta + +from temporalio import workflow +from temporalio.common import RetryPolicy + +with workflow.unsafe.imports_passed_through(): + from activities import ( + NotificationRequest, + StoreDocumentRequest, + generate_approval_report, + record_audit_entry, + send_notification, + store_document, + ) + from models import ( + ApprovalDecision, + ApprovalState, + ApprovalStatus, + ApproverConfig, + AuditEntry, + DocumentStatus, + DocumentSubmission, + ) + + +# Retry policy for notification Activities. +NOTIFICATION_RETRY_POLICY = RetryPolicy( + initial_interval=timedelta(seconds=5), + backoff_coefficient=2.0, + maximum_interval=timedelta(minutes=2), + maximum_attempts=5, +) + +# Retry policy for audit logging. +AUDIT_RETRY_POLICY = RetryPolicy( + initial_interval=timedelta(seconds=2), + backoff_coefficient=2.0, + maximum_interval=timedelta(minutes=5), + maximum_attempts=10, +) + +# Retry policy for document storage. +STORAGE_RETRY_POLICY = RetryPolicy( + initial_interval=timedelta(seconds=1), + backoff_coefficient=2.0, + maximum_interval=timedelta(minutes=1), + maximum_attempts=5, +) + +# Retry policy for long-running report generation. +REPORT_RETRY_POLICY = RetryPolicy( + initial_interval=timedelta(seconds=10), + backoff_coefficient=2.0, + maximum_interval=timedelta(minutes=5), + maximum_attempts=3, +) + + +@workflow.defn +class DocumentApprovalWorkflow: + + @workflow.init + def __init__( + self, input_data: DocumentSubmission | ApprovalState + ) -> None: + if isinstance(input_data, ApprovalState): + self._state = input_data + else: + self._state = ApprovalState(document=input_data) + + self._pending_decision: ApprovalDecision | None = None + self._processed_update_ids: set[str] = set() + + @workflow.signal + async def submit_decision(self, decision: ApprovalDecision) -> None: + workflow.logger.info( + "Decision received via Signal", + extra={ + "approver": decision.approver_email, + "status": decision.status, + }, + ) + self._pending_decision = decision + + @workflow.signal + async def withdraw(self) -> None: + workflow.logger.info("Document withdrawal requested") + self._state.status = DocumentStatus.WITHDRAWN + + @workflow.query + def get_status(self) -> dict: + return { + "document_id": self._state.document.document_id, + "title": self._state.document.title, + "status": self._state.status.value, + "decision": asdict(self._state.decision) if self._state.decision else None, + "resubmission_count": self._state.resubmission_count, + } + + @workflow.query + def get_audit_trail(self) -> list[dict]: + """Return the in-memory audit trail for this Workflow.""" + return [asdict(e) for e in self._state.audit_trail] + + @workflow.update + async def resubmit_document(self, update_id: str, new_content_url: str) -> dict: + # Prevent duplicate processing of the same update + if update_id in self._processed_update_ids: + return { + "accepted": True, + "reason": "Resubmission already processed", + "duplicate": True, + } + + if self._state.status != DocumentStatus.REJECTED: + return { + "accepted": False, + "reason": ( + f"Document is in {self._state.status.value} state. " + "Resubmission is only allowed after rejection." + ), + } + + if self._state.resubmission_count >= self._state.max_resubmissions: + return { + "accepted": False, + "reason": ( + f"Maximum resubmissions ({self._state.max_resubmissions}) " + "reached." + ), + } + + self._state.document.content_url = new_content_url + self._state.resubmission_count += 1 + self._state.status = DocumentStatus.SUBMITTED + self._pending_decision = None + self._processed_update_ids.add(update_id) + + workflow.logger.info( + "Document resubmitted", + extra={ + "resubmission_count": self._state.resubmission_count, + "new_content_url": new_content_url, + }, + ) + + return { + "accepted": True, + "resubmission_count": self._state.resubmission_count, + } + + @resubmit_document.validator + def validate_resubmit(self, update_id: str, new_content_url: str) -> None: + """Validate the resubmission URL before accepting the Update.""" + if not update_id or not update_id.strip(): + raise ValueError("update_id must not be empty") + if not new_content_url or not new_content_url.strip(): + raise ValueError("new_content_url must not be empty") + + @workflow.run + async def run( + self, input_data: DocumentSubmission | ApprovalState + ) -> dict: + workflow_id = workflow.info().workflow_id + + await self._record_audit( + workflow_id=workflow_id, + action="WORKFLOW_STARTED", + actor=self._state.document.submitter_email, + details=( + f"Document '{self._state.document.title}' submitted " + f"for approval (resubmission #{self._state.resubmission_count})" + ), + ) + + if not self._state.document_stored: + await self._store_document(workflow_id) + self._state.document_stored = True + + if workflow.info().is_continue_as_new_suggested(): + workflow.logger.info("Continue-As-New suggested, resetting Event History") + await workflow.wait_condition(workflow.all_handlers_finished) + workflow.continue_as_new(args=[self._state]) + + self._state.status = DocumentStatus.IN_REVIEW + approver_config = self._state.document.approver + + workflow.logger.info( + "Approval process started", + extra={ + "approver": approver_config.approver_email, + "sla_seconds": approver_config.sla_seconds, + }, + ) + + await self._send_approval_request(workflow_id, approver_config) + + result: dict = {} + while True: + decision = await self._wait_for_decision(workflow_id, approver_config) + + if self._state.status == DocumentStatus.WITHDRAWN: + await self._record_audit( + workflow_id=workflow_id, + action="DOCUMENT_WITHDRAWN", + actor=self._state.document.submitter_email, + details="Document withdrawn by submitter", + ) + result = self._build_result("Document withdrawn by submitter") + break + + result = await self._handle_decision(workflow_id, approver_config, decision) + + if self._state.status != DocumentStatus.SUBMITTED: + break + + if workflow.info().is_continue_as_new_suggested(): + workflow.logger.info( + "Continue-As-New suggested before resubmission cycle" + ) + await workflow.wait_condition(workflow.all_handlers_finished) + workflow.continue_as_new(args=[self._state]) + + self._state.status = DocumentStatus.IN_REVIEW + await self._send_approval_request(workflow_id, approver_config) + + # Wait for all in-flight Signal and Update handlers to complete + # before returning + await workflow.wait_condition(workflow.all_handlers_finished) + + return result + + async def _wait_for_decision( + self, workflow_id: str, approver_config: ApproverConfig + ) -> ApprovalDecision: + sla_timeout = timedelta(seconds=approver_config.sla_seconds) + reminder_interval = timedelta( + seconds=approver_config.reminder_interval_seconds + ) + + self._pending_decision = None + + reminder_task = asyncio.create_task( + self._send_reminders(workflow_id, approver_config, reminder_interval) + ) + + try: + await workflow.wait_condition( + lambda: ( + self._pending_decision is not None + or self._state.status == DocumentStatus.WITHDRAWN + ), + timeout=sla_timeout, + ) + + reminder_task.cancel() + try: + await reminder_task + except asyncio.CancelledError: + pass + + if self._state.status == DocumentStatus.WITHDRAWN: + return ApprovalDecision( + approver_email=self._state.document.submitter_email, + status=ApprovalStatus.REJECTED, + comment="Document withdrawn", + decided_at=workflow.now().isoformat(), + ) + + decision = self._pending_decision + assert decision is not None + self._pending_decision = None + return decision + + except asyncio.TimeoutError: + reminder_task.cancel() + try: + await reminder_task + except asyncio.CancelledError: + pass + + return await self._handle_escalation(workflow_id, approver_config) + + async def _send_reminders( + self, workflow_id: str, approver_config: ApproverConfig, interval: timedelta + ) -> None: + reminder_count = 0 + while True: + await asyncio.sleep(interval.total_seconds()) + reminder_count += 1 + + if reminder_count > approver_config.max_reminders: + workflow.logger.info( + "Max reminders reached, stopping reminder loop" + ) + break + + workflow.logger.info( + "Sending reminder", + extra={ + "reminder_count": reminder_count, + "approver": approver_config.approver_email, + }, + ) + + await workflow.execute_activity( + send_notification, + NotificationRequest( + recipient_email=approver_config.approver_email, + recipient_name=approver_config.approver_name, + subject=( + f"Reminder: Approval needed for " + f"'{self._state.document.title}'" + ), + body=f"Reminder #{reminder_count}: please review '{self._state.document.title}'.", + idempotency_key=( + f"{workflow_id}-reminder-R{reminder_count}" + ), + ), + start_to_close_timeout=timedelta(seconds=30), + retry_policy=NOTIFICATION_RETRY_POLICY, + ) + + await self._record_audit( + workflow_id=workflow_id, + action="REMINDER_SENT", + actor="system", + details=( + f"Reminder #{reminder_count} sent to " + f"{approver_config.approver_email}" + ), + ) + + async def _handle_escalation( + self, workflow_id: str, approver_config: ApproverConfig + ) -> ApprovalDecision: + await self._record_audit( + workflow_id=workflow_id, + action="SLA_EXPIRED", + actor="system", + details=( + f"SLA expired for {approver_config.approver_email} " + f"after {approver_config.sla_seconds} seconds" + ), + ) + + if approver_config.escalation_email: + workflow.logger.info( + "Escalating to backup approver", + extra={ + "original_approver": approver_config.approver_email, + "escalation_contact": approver_config.escalation_email, + }, + ) + + await workflow.execute_activity( + send_notification, + NotificationRequest( + recipient_email=approver_config.escalation_email, + recipient_name="Escalation Contact", + subject=( + f"ESCALATION: Approval needed for " + f"'{self._state.document.title}'" + ), + body=f"Original approver ({approver_config.approver_email}) did not respond within the SLA. Please review and approve or reject.", + idempotency_key=f"{workflow_id}-escalation", + ), + start_to_close_timeout=timedelta(seconds=30), + retry_policy=NOTIFICATION_RETRY_POLICY, + ) + + await self._record_audit( + workflow_id=workflow_id, + action="ESCALATION_SENT", + actor="system", + details=f"Escalated to {approver_config.escalation_email}", + ) + + self._pending_decision = None + try: + await workflow.wait_condition( + lambda: ( + self._pending_decision is not None + or self._state.status == DocumentStatus.WITHDRAWN + ), + timeout=timedelta(seconds=approver_config.sla_seconds), + ) + + if self._state.status == DocumentStatus.WITHDRAWN: + return ApprovalDecision( + approver_email=self._state.document.submitter_email, + status=ApprovalStatus.REJECTED, + comment="Document withdrawn during escalation", + decided_at=workflow.now().isoformat(), + ) + + decision = self._pending_decision + assert decision is not None + self._pending_decision = None + return decision + + except asyncio.TimeoutError: + await self._record_audit( + workflow_id=workflow_id, + action="ESCALATION_TIMEOUT", + actor="system", + details=( + f"Escalation contact {approver_config.escalation_email} " + f"also timed out" + ), + ) + + return ApprovalDecision( + approver_email="system", + status=ApprovalStatus.TIMED_OUT, + comment=( + "Auto-rejected: both the original approver and " + "escalation contact failed to respond within the SLA" + ), + decided_at=workflow.now().isoformat(), + ) + else: + workflow.logger.info("No escalation contact configured, auto-rejecting") + + await self._record_audit( + workflow_id=workflow_id, + action="AUTO_REJECTED", + actor="system", + details=( + f"No escalation contact configured for {approver_config.approver_email}" + ), + ) + + return ApprovalDecision( + approver_email="system", + status=ApprovalStatus.TIMED_OUT, + comment=( + f"Auto-rejected: approver {approver_config.approver_email} " + f"did not respond within {approver_config.sla_seconds} seconds" + ), + decided_at=workflow.now().isoformat(), + ) + + async def _handle_decision( + self, + workflow_id: str, + approver_config: ApproverConfig, + decision: ApprovalDecision, + ) -> dict: + # Stamp the decision with processing time using the deterministic Workflow clock + decision.decided_at = workflow.now().isoformat() + self._state.decision = decision + + await self._record_audit( + workflow_id=workflow_id, + action=f"DECISION_{decision.status.value}", + actor=decision.approver_email, + details=decision.comment, + ) + + if decision.status == ApprovalStatus.APPROVED: + workflow.logger.info( + "Document approved", + extra={"approver": decision.approver_email}, + ) + + report_url = await self._finalize_approval(workflow_id) + + await workflow.execute_activity( + send_notification, + NotificationRequest( + recipient_email=self._state.document.submitter_email, + recipient_name=self._state.document.submitter_name, + subject=( + f"Document approved: '{self._state.document.title}'" + ), + body=f"Your document has been approved by {decision.approver_email}. Approval report: {report_url}", + idempotency_key=f"{workflow_id}-final-approval", + ), + start_to_close_timeout=timedelta(seconds=30), + retry_policy=NOTIFICATION_RETRY_POLICY, + ) + + return self._build_result( + message="Document approved", + report_url=report_url, + ) + + elif decision.status == ApprovalStatus.CHANGES_REQUESTED: + workflow.logger.info( + "Changes requested", + extra={"approver": decision.approver_email}, + ) + + self._state.status = DocumentStatus.REJECTED + + await workflow.execute_activity( + send_notification, + NotificationRequest( + recipient_email=self._state.document.submitter_email, + recipient_name=self._state.document.submitter_name, + subject=( + f"Changes requested: " + f"'{self._state.document.title}'" + ), + body=f"Approver {decision.approver_email} has requested changes: {decision.comment}. You may resubmit.", + idempotency_key=( + f"{workflow_id}-changes-requested-" + f"S{self._state.resubmission_count}" + ), + ), + start_to_close_timeout=timedelta(seconds=30), + retry_policy=NOTIFICATION_RETRY_POLICY, + ) + + resubmission_timeout = timedelta( + seconds=approver_config.resubmission_timeout_seconds + ) + try: + await workflow.wait_condition( + lambda: ( + self._state.status == DocumentStatus.SUBMITTED + or self._state.status == DocumentStatus.WITHDRAWN + ), + timeout=resubmission_timeout, + ) + + if self._state.status == DocumentStatus.WITHDRAWN: + await self._record_audit( + workflow_id=workflow_id, + action="DOCUMENT_WITHDRAWN", + actor=self._state.document.submitter_email, + details="Document withdrawn after changes requested", + ) + return self._build_result( + message="Document withdrawn by submitter" + ) + + workflow.logger.info( + "Resubmission accepted", + extra={"resubmission_count": self._state.resubmission_count}, + ) + + if self._state.resubmission_count >= self._state.max_resubmissions: + self._state.status = DocumentStatus.REJECTED + await self._record_audit( + workflow_id=workflow_id, + action="MAX_RESUBMISSIONS_EXCEEDED", + actor="system", + details=f"Maximum resubmissions ({self._state.max_resubmissions}) exceeded", + ) + return self._build_result( + message="Maximum resubmission attempts exceeded" + ) + + return self._build_result(message="Resubmission accepted") + + except asyncio.TimeoutError: + workflow.logger.info( + "Resubmission window expired" + ) + self._state.status = DocumentStatus.REJECTED + await self._record_audit( + workflow_id=workflow_id, + action="RESUBMISSION_TIMEOUT", + actor="system", + details=( + "Resubmission window expired after 7 days" + ), + ) + return self._build_result( + message="Resubmission deadline expired, document rejected" + ) + + else: + workflow.logger.info( + "Document rejected", + extra={"status": decision.status.value}, + ) + + self._state.status = DocumentStatus.REJECTED + + await workflow.execute_activity( + send_notification, + NotificationRequest( + recipient_email=self._state.document.submitter_email, + recipient_name=self._state.document.submitter_name, + subject=( + f"Document rejected: " + f"'{self._state.document.title}'" + ), + body=f"Your document was {decision.status.value.lower()}. Reason: {decision.comment}", + idempotency_key=( + f"{workflow_id}-rejected-" + f"S{self._state.resubmission_count}" + ), + ), + start_to_close_timeout=timedelta(seconds=30), + retry_policy=NOTIFICATION_RETRY_POLICY, + ) + return self._build_result( + message="Document rejected by approver" + ) + + async def _finalize_approval(self, workflow_id: str) -> str: + """Generate the approval report and return the report URL.""" + self._state.status = DocumentStatus.APPROVED + + await self._record_audit( + workflow_id=workflow_id, + action="DOCUMENT_APPROVED", + actor="system", + details="Document approved by approver", + ) + + decision_data = asdict(self._state.decision) + report_url = await workflow.execute_activity( + generate_approval_report, + self._state.document.document_id, + [decision_data], + start_to_close_timeout=timedelta(minutes=5), + heartbeat_timeout=timedelta(minutes=2), + retry_policy=REPORT_RETRY_POLICY, + ) + + return report_url + + async def _store_document(self, workflow_id: str) -> None: + """Store the document and record the audit entry.""" + storage_ref = await workflow.execute_activity( + store_document, + StoreDocumentRequest( + document_id=self._state.document.document_id, + title=self._state.document.title, + content_url=self._state.document.content_url, + submitter_email=self._state.document.submitter_email, + ), + start_to_close_timeout=timedelta(seconds=60), + retry_policy=STORAGE_RETRY_POLICY, + ) + + workflow.logger.info( + "Document stored", + extra={"storage_ref": storage_ref}, + ) + + await self._record_audit( + workflow_id=workflow_id, + action="DOCUMENT_STORED", + actor=self._state.document.submitter_email, + details=f"Document stored at {storage_ref}", + ) + + async def _send_approval_request( + self, workflow_id: str, approver_config: ApproverConfig + ) -> None: + """Send the initial approval request to the approver.""" + await workflow.execute_activity( + send_notification, + NotificationRequest( + recipient_email=approver_config.approver_email, + recipient_name=approver_config.approver_name, + subject=( + f"Approval requested: " + f"'{self._state.document.title}'" + ), + body=f"'{self._state.document.title}' by {self._state.document.submitter_name} requires your approval (SLA: {approver_config.sla_seconds}s).", + idempotency_key=( + f"{workflow_id}-approval-request-" + f"S{self._state.resubmission_count}" + ), + ), + start_to_close_timeout=timedelta(seconds=30), + retry_policy=NOTIFICATION_RETRY_POLICY, + ) + + await self._record_audit( + workflow_id=workflow_id, + action="APPROVAL_REQUESTED", + actor="system", + details=( + f"Approval request sent to " + f"{approver_config.approver_email}" + ), + ) + + async def _record_audit( + self, + workflow_id: str, + action: str, + actor: str, + details: str = "", + approval_level: int = 0, + ) -> None: + """Record an audit entry both in-memory and via an Activity.""" + entry = AuditEntry( + timestamp=workflow.now().isoformat(), + workflow_id=workflow_id, + action=action, + actor=actor, + details=details, + approval_level=approval_level, + ) + + self._state.audit_trail.append(entry) + + await workflow.execute_activity( + record_audit_entry, + entry, + start_to_close_timeout=timedelta(seconds=30), + retry_policy=AUDIT_RETRY_POLICY, + ) + + def _build_result( + self, message: str, report_url: str = "" + ) -> dict: + """Build the final result returned by the Workflow.""" + return { + "document_id": self._state.document.document_id, + "status": self._state.status.value, + "message": message, + "decision": ( + asdict(self._state.decision) + if self._state.decision + else None + ), + "resubmission_count": self._state.resubmission_count, + "report_url": report_url, + } +``` + +### Phase 4: Configure and run the Worker + +The Worker is the process that executes Workflow and Activity code. Create a file named `worker.py`: + +```python +"""Worker process for the document approval system.""" + +from __future__ import annotations + +import asyncio +import concurrent.futures +import logging +import os + +from temporalio.client import Client +from temporalio.worker import Worker + +from activities import ( + generate_approval_report, + record_audit_entry, + send_notification, + store_document, +) +from workflows import DocumentApprovalWorkflow + +TASK_QUEUE = os.getenv("TEMPORAL_TASK_QUEUE", "document-approval") +NAMESPACE = os.getenv("TEMPORAL_NAMESPACE", "document-approval") + + +async def main() -> None: + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + ) + + logging.info(f"Connecting to Temporal Server with namespace: {NAMESPACE}") + client = await Client.connect("localhost:7233", namespace=NAMESPACE) + + with concurrent.futures.ThreadPoolExecutor( + max_workers=50 + ) as activity_executor: + worker = Worker( + client, + task_queue=TASK_QUEUE, + workflows=[DocumentApprovalWorkflow], + activities=[ + send_notification, + record_audit_entry, + store_document, + generate_approval_report, + ], + activity_executor=activity_executor, + max_concurrent_workflow_tasks=100, + max_concurrent_activities=50, + ) + + logging.info("Starting Worker on Task Queue: %s", TASK_QUEUE) + await worker.run() + + +if __name__ == "__main__": + asyncio.run(main()) +``` + +To run the Worker, start the Temporal development server in one terminal and the Worker in another: + +``` +temporal server start-dev +``` + +``` +python worker.py +``` + +### Phase 5: Start a Workflow and send Signals + +Create a file named `starter.py` that demonstrates how to start the approval Workflow and interact with it: + +```python +"""Client code to start a document approval Workflow and interact with it.""" + +from __future__ import annotations + +import asyncio +import os + +from temporalio.api.common.v1 import SearchAttributes +from temporalio.client import Client + +from models import ( + ApprovalDecision, + ApprovalStatus, + ApproverConfig, + DocumentSubmission, +) +from workflows import DocumentApprovalWorkflow + +TASK_QUEUE = os.getenv("TEMPORAL_TASK_QUEUE", "document-approval") +NAMESPACE = os.getenv("TEMPORAL_NAMESPACE", "document-approval") + + +async def main() -> None: + """Start an approval Workflow and demonstrate Signal and Query interactions.""" + client = await Client.connect("localhost:7233", namespace=NAMESPACE) + + document = DocumentSubmission( + document_id="doc-2026-001", + title="Q1 Budget Proposal", + submitter_email="alice@example.com", + submitter_name="Alice Chen", + content_url="https://docs.example.com/q1-budget-v1", + approver=ApproverConfig( + approver_email="bob@example.com", + approver_name="Bob Martinez", + sla_seconds=172800, # 48 hours + escalation_email="carol@example.com", + reminder_interval_seconds=86400, # Remind every 24 hours + max_reminders=3, # Send max 3 reminders + ), + ) + + workflow_id = f"approval-{document.document_id}" + + search_attributes = SearchAttributes.from_pairs([ + ("DocumentId", document.document_id), + ("SubmitterEmail", document.submitter_email), + ("ApprovalStatus", "SUBMITTED"), + ]) + + handle = await client.start_workflow( + DocumentApprovalWorkflow.run, + document, + id=workflow_id, + task_queue=TASK_QUEUE, + search_attributes=search_attributes, + ) + + print(f"Workflow started: {workflow_id}") + print(f"Run Id: {handle.result_run_id}") + + status = await handle.query(DocumentApprovalWorkflow.get_status) + print(f"Current status: {status}") + + await handle.signal( + DocumentApprovalWorkflow.submit_decision, + ApprovalDecision( + approver_email="bob@example.com", + status=ApprovalStatus.APPROVED, + comment="Budget looks good. Approved.", + ), + ) + print("Approval Signal sent") + + result = await handle.result() + print(f"Workflow result: {result}") + + search_attributes = SearchAttributes.from_pairs([ + ("ApprovalStatus", result["status"].upper()), + ]) + await client.update_workflow_search_attributes(workflow_id, search_attributes) + + # Query the audit trail + audit_trail = await handle.query( + DocumentApprovalWorkflow.get_audit_trail + ) + print(f"Audit trail ({len(audit_trail)} entries):") + for entry in audit_trail: + print(f" [{entry['timestamp']}] {entry['action']}: {entry['details']}") + + +if __name__ == "__main__": + asyncio.run(main()) +``` + +### Phase 6: Configure timeouts and retry policies + +Choosing appropriate timeouts and retry policies is critical for the reliability of the system. The following table summarizes the timeout and retry configuration used in this pattern: + +| Activity | `start_to_close_timeout` | `heartbeat_timeout` | Retry policy | Rationale | +|----------|--------------------------|---------------------|--------------|-----------| +| `send_notification` | 30 seconds | Not set | 5 attempts, 5s initial, 2x backoff, 2m max | Notifications are important but not blocking. Five retries with exponential backoff handle transient network failures. | +| `record_audit_entry` | 30 seconds | Not set | 10 attempts, 2s initial, 2x backoff, 5m max | Audit entries are critical for compliance. More aggressive retry to ensure entries are persisted. | +| `store_document` | 60 seconds | Not set | 5 attempts, 1s initial, 2x backoff, 1m max | Document storage should complete quickly. Longer `start_to_close_timeout` accommodates large documents. | +| `generate_approval_report` | 5 minutes | 30 seconds | 3 attempts, 10s initial, 2x backoff, 5m max | Report generation may take time. Heartbeating detects Worker failures quickly. Fewer retries because the operation is expensive. | + +Do not set `schedule_to_close_timeout` unless you need to bound the total time across all retry attempts. + +## Outcomes + +You've built a document approval system that holds up under real conditions — slow reviewers, missed deadlines, infrastructure restarts, and repeated resubmissions. The techniques here aren't specific to documents. Any process that has to wait for a human — loan applications, employee onboarding, content moderation, procurement sign-off, insurance claims — can be built the same way. The Workflow waits, the timers fire, the audit trail fills in, and the process completes regardless of what happens underneath it. + +## Related resources + +- [Temporal Python SDK documentation](https://docs.temporal.io/develop/python) +- [Message Passing — Signals, Queries, Updates](https://docs.temporal.io/develop/python/message-passing) +- [Continue-As-New](https://docs.temporal.io/develop/python/continue-as-new) +- [Failure Detection — Timeouts, Activity Heartbeating, and Retry Policies](https://docs.temporal.io/develop/python/failure-detection) +- [Child Workflows](https://docs.temporal.io/develop/python/child-workflows) +- [Worker Versioning](https://docs.temporal.io/production-deployment/worker-deployments/worker-versioning) +- [Temporal Python SDK API Reference](https://python.temporal.io) +- [Temporal Python SDK GitHub repository](https://github.com/temporalio/sdk-python) +- [Temporal Python SDK samples](https://github.com/temporalio/samples-python) +- [Entity Workflow Pattern](entity-pattern-loyalty-points) — covers the related Entity Workflow pattern for modeling long-lived domain objects \ No newline at end of file diff --git a/docs/guides/route-specialized-workloads.mdx b/docs/guides/route-specialized-workloads.mdx new file mode 100644 index 0000000000..3f5c86e30f --- /dev/null +++ b/docs/guides/route-specialized-workloads.mdx @@ -0,0 +1,686 @@ +--- +id: route-specialized-workloads +title: Route specialized workloads +description: Direct resource-intensive workloads to appropriate Task Queues to optimize resources +sidebar_label: Route specialized workloads +toc_max_heading_level: 3 +author: Cecil Phillip +tags: + - Task Queues + - Routing + - Workers + - Resource requirements + - GPU computing +--- + +**by Cecil Phillip** + +Modern applications have workloads with diverse resource requirements. ML/AI workloads require expensive GPU-equipped Workers with specific CUDA libraries, video processing needs specialized encoding hardware, and data analytics may require high-memory instances. Running all Activities on the same Worker type is cost-prohibitive and inefficient. + +## Problem statement + +Without separate Task Queues for different resource types, these scenarios create problems: +- **Resource waste:** GPU Workers sit idle waiting for ML work, or expensive GPUs run standard CPU tasks that don't need them +- **Cost inefficiency:** Running all Workers on GPU instances when only 10% of workloads need GPUs increases costs by 20-30x +- **Environment conflicts:** ML libraries (TensorFlow, PyTorch) have complex dependencies that conflict with other Activity requirements +- **Scaling complexity:** Cannot independently scale GPU Workers based on ML demand vs CPU Workers based on standard workload demand + +## Solution + +Use separate Task Queues to route Activities based on their resource requirements. Create dedicated Worker pools for: +- **GPU-intensive ML workloads** (NVIDIA GPUs, CUDA libraries, limited concurrency due to GPU memory) +- **Standard CPU workloads** (high concurrency on cost-effective instances) +- **High-memory analytics** (memory-optimized instances for large dataset processing) +- **Specialized hardware** (video encoding hardware, TPUs, ARM processors) + +## Outcomes + +- **Cost optimization:** GPU instances (\$3/hr) only handle ML inference while standard Activities run on cost-effective CPU instances (\$0.10/hr), reducing infrastructure costs by 60-80% +- **Resource efficiency:** GPU Workers run at 2-4 concurrent activities (GPU memory constraints) while CPU Workers run at 100+ concurrent activities, maximizing hardware utilization +- **Independent scaling:** Scale GPU Workers based on ML workload demand, high-memory Workers based on analytics load, and CPU Workers based on standard workload, without coordination +- **Environment isolation:** ML Workers have TensorFlow/PyTorch dependencies isolated from standard Workers, preventing library conflicts and simplifying deployments + +## Background and best practices + +### Task Queue fundamentals +Task Queues in Temporal are dynamically created when first referenced. If your Workflow references Task Queue `gpu-processing` but your Worker polls `gpu-procesing` (typo), two separate queues are created and the Worker never receives Tasks. + +**Best practice:** Always define Task Queue names as constants in a shared module that both Workflows and Workers import. + +### Resource-based routing approaches + +Route Activities to appropriate Worker pools based on their resource requirements: +- **GPU-intensive Activities:** Route to GPU-equipped Workers with ML libraries (TensorFlow, PyTorch, CUDA) +- **CPU-intensive Activities:** Route to high-CPU instances for compute-heavy tasks +- **Memory-intensive Activities:** Route to memory-optimized instances for large dataset processing +- **Standard Activities:** Route to cost-effective general-purpose instances + +### Worker configuration for specialized hardware + +Key considerations for resource-specific Workers: +- **GPU concurrency:** Limit GPU Workers to 2-4 concurrent activities due to GPU memory constraints +- **CPU concurrency:** Standard Workers can handle 100+ concurrent activities +- **Memory allocation:** High-memory Workers need careful concurrency tuning to avoid OOM errors +- **Hardware dependencies:** GPU Workers require NVIDIA drivers, CUDA toolkit, and specific ML library versions +- **Container images:** Use specialized images with pre-installed dependencies (nvidia/cuda, tensorflow/tensorflow:latest-gpu) + +### Operational considerations + +- **Resource utilization:** Monitor GPU memory, compute utilization, and CPU/memory usage per Worker pool +- **Queue latency:** Track `schedule_to_start_latency` per Task Queue to detect under-provisioning of specialized hardware +- **Cost tracking:** Tag resources by hardware type (GPU, CPU, high-memory) for cost analysis and optimization +- **Hardware health:** Monitor GPU temperature, driver errors, and CUDA out-of-memory errors +- **Dependency management:** Use container images with pinned library versions to ensure reproducible environments + +## Target audience + +- **Temporal Workflow & Activity developers:** Implementing resource-aware routing logic +- **Platform operators:** Configuring Worker deployments for specialized hardware +- **ML/AI Engineers:** Deploying GPU-based inference and training Workers +- **DevOps/SRE teams:** Managing heterogeneous Worker pools and infrastructure +- **FinOps teams:** Optimizing cloud infrastructure costs through efficient resource allocation + +This implementation requires code changes, Worker configuration, and deployment of specialized infrastructure (GPU instances, high-memory instances, specialized hardware). + +## Prerequisites + +### Required software, infrastructure, and tools + +- Temporal Service (Self-hosted or Temporal Cloud) +- Python 3.8 or later +- Temporal Python SDK v1.0.0 or later (`pip install temporalio`) +- GPU infrastructure (AWS EC2 with NVIDIA GPUs, GCP with GPUs) for ML workloads +- Process manager or container orchestration for multi-Worker deployments + +### Resources & access privileges + +- Temporal Namespace with permissions to start Workflows and register Workers +- Infrastructure provisioning access for GPU instances and multiple Worker pools +- Ability to configure autoscaling policies + +### Required concepts + +- Temporal Workflows, Activities, and Task Queues +- Python async/await patterns +- GPU computing basics (for ML scenarios) +- Basic deployment and process management + +## Architecture diagram(s) + +### Resource-Based routing architecture + +```mermaid +flowchart TB + subgraph Workflow["Workflow Execution"] + WF[ML Pipeline
Workflow] + end + + subgraph Routing["Resource-Based Routing Logic"] + Decision{Activity
Resource
Requirements} + end + + subgraph Queues["Task Queues by Resource Type"] + CPUTQ[cpu-standard
Task Queue] + GPUTQ[gpu-ml-inference
Task Queue] + MEMTQ[high-memory-analytics
Task Queue] + end + + subgraph Workers["Specialized Worker Pools"] + subgraph CPUPool["Standard CPU Pool"] + CPU1[Instance: c5.xlarge] + CPU2[4 vCPU, 8GB RAM] + CPU3[Replicas: 10] + CPU4[Concurrency: 100] + CPU5[Cost: $0.17/hr] + end + subgraph GPUPool["GPU ML Pool"] + GPU1[Instance: g4dn.xlarge] + GPU2[NVIDIA T4 16GB] + GPU3[CUDA 12.0, PyTorch] + GPU4[Replicas: 2] + GPU5[Concurrency: 2] + GPU6[Cost: $0.526/hr] + end + subgraph MEMPool["High-Memory Pool"] + MEM1[Instance: r5.2xlarge] + MEM2[8 vCPU, 64GB RAM] + MEM3[Replicas: 3] + MEM4[Concurrency: 10] + MEM5[Cost: $0.504/hr] + end + end + + WF --> Decision + + Decision -->|CPU Activities
validation, API calls| CPUTQ + Decision -->|GPU Activities
inference, embeddings| GPUTQ + Decision -->|Memory Activities
large dataset processing| MEMTQ + + CPUTQ --> CPUPool + GPUTQ --> GPUPool + MEMTQ --> MEMPool + + classDef blue stroke-width:1px + classDef amber stroke-width:1px + classDef purple stroke-width:1px + class CPUTQ blue + class GPUTQ amber + class MEMTQ purple + class CPUPool blue + class GPUPool amber + class MEMPool purple +``` + +## Implementation plan + +### Step 1: Define Task Queue constants + +Create a shared Python module with Task Queue name constants based on resource requirements. + +**File: `task_queues.py`** + +```python +"""Task Queue constants for resource-based routing.""" + +# Resource-specific task queues +STANDARD_CPU_QUEUE = "cpu-standard" +GPU_ML_QUEUE = "gpu-ml-inference" +HIGH_MEMORY_QUEUE = "high-memory-analytics" +VIDEO_ENCODING_QUEUE = "video-encoding-hardware" +``` + +### Step 2: Configure Workers for standard CPU processing + +Deploy Workers on cost-effective CPU instances for standard Activities that don't require specialized hardware. + +**File: `worker_cpu.py`** + +```python +"""Worker for standard CPU-based Activities.""" +import asyncio +import logging +from temporalio.client import Client +from temporalio.worker import Worker + +from task_queues import STANDARD_CPU_QUEUE +from workflows import MLPipelineWorkflow +from activities import validate_data, preprocess_data, store_results + +logging.basicConfig(level=logging.INFO) + + +async def main(): + client = await Client.connect("localhost:7233") + + worker = Worker( + client, + task_queue=STANDARD_CPU_QUEUE, + workflows=[MLPipelineWorkflow], + activities=[validate_data, preprocess_data, store_results], + max_concurrent_activities=100, # High concurrency on CPU + ) + + logging.info(f"Starting CPU worker on {STANDARD_CPU_QUEUE}") + await worker.run() + + +if __name__ == "__main__": + asyncio.run(main()) +``` + +**Deployment guidance:** +- Deploy 10 instances of `worker_cpu.py` on standard compute instances (c5.xlarge: 4 vCPU, 8GB RAM) +- Use autoscaling based on queue depth and CPU utilization +- Consider using spot instances for cost savings if workload tolerates interruptions + +### Step 3: Configure Workers for GPU processing + +Deploy Workers on GPU-equipped instances for ML/AI workloads. GPU Workers should have limited concurrency due to GPU memory constraints. + +**File: `worker_gpu.py`** + +```python +"""Worker for GPU-intensive ML Activities.""" +import asyncio +import logging +from temporalio.client import Client +from temporalio.worker import Worker + +from task_queues import GPU_ML_QUEUE +from activities import generate_embeddings, run_inference, train_model + +logging.basicConfig(level=logging.INFO) + + +async def main(): + client = await Client.connect("localhost:7233") + + worker = Worker( + client, + task_queue=GPU_ML_QUEUE, + activities=[generate_embeddings, run_inference, train_model], + # Limited concurrency - GPU memory constraint + # T4 16GB can typically handle 2-4 concurrent inference tasks + max_concurrent_activities=2, + ) + + logging.info(f"Starting GPU worker on {GPU_ML_QUEUE}") + await worker.run() + + +if __name__ == "__main__": + asyncio.run(main()) +``` + +**Deployment guidance:** +- Deploy 2-3 instances of `worker_gpu.py` on GPU instances (g4dn.xlarge with NVIDIA T4, or p3.2xlarge with V100) +- GPU Workers require: + - NVIDIA drivers (version 525.60 or later) + - CUDA toolkit (12.0 or later) + - ML frameworks (PyTorch, TensorFlow) with GPU support +- Use container images: `nvidia/cuda:12.0-cudnn8-runtime-ubuntu22.04` or `pytorch/pytorch:2.0.0-cuda11.7-cudnn8-runtime` +- Monitor GPU memory usage - adjust `max_concurrent_activities` if OOM errors occur +- Consider using GPU-optimized instance types based on model requirements (T4 for inference, V100/A100 for training) + +### Step 4: Configure Workers for high-memory analytics + +Deploy Workers on memory-optimized instances for Activities processing large datasets. + +**File: `worker_high_memory.py`** + +```python +"""Worker for high-memory data processing Activities.""" +import asyncio +import logging +from temporalio.client import Client +from temporalio.worker import Worker + +from task_queues import HIGH_MEMORY_QUEUE +from activities import process_large_dataset, aggregate_analytics, build_large_index + +logging.basicConfig(level=logging.INFO) + + +async def main(): + client = await Client.connect("localhost:7233") + + worker = Worker( + client, + task_queue=HIGH_MEMORY_QUEUE, + activities=[process_large_dataset, aggregate_analytics, build_large_index], + # Limited concurrency due to high memory usage per activity + # r5.2xlarge (64GB RAM) can handle ~10 concurrent activities using 5-6GB each + max_concurrent_activities=10, + ) + + logging.info(f"Starting high-memory worker on {HIGH_MEMORY_QUEUE}") + await worker.run() + + +if __name__ == "__main__": + asyncio.run(main()) +``` + +**Deployment guidance:** +- Deploy 3-5 instances of `worker_high_memory.py` on memory-optimized instances (r5.2xlarge: 8 vCPU, 64GB RAM) +- Adjust `max_concurrent_activities` based on per-Activity memory usage to prevent OOM errors +- Monitor memory utilization and swap usage +- Consider using instances with local NVMe storage for faster I/O on large datasets + +### Step 5: Implement resource-aware routing in Workflows + +**File: `ml_pipeline_workflow.py`** + +```python +"""Workflow with resource-aware routing.""" +from datetime import timedelta +from dataclasses import dataclass +from temporalio import workflow + +with workflow.unsafe.imports_passed_through(): + from task_queues import STANDARD_CPU_QUEUE, GPU_ML_QUEUE, HIGH_MEMORY_QUEUE + + +@dataclass +class MLPipelineRequest: + pipeline_id: str + dataset_url: str + model_type: str + customer_id: str + + +@workflow.defn +class MLPipelineWorkflow: + """ + ML pipeline workflow with resource-aware routing. + + Routes Activities to appropriate Worker pools based on resource requirements: + - CPU queue: Data validation, preprocessing, result storage + - GPU queue: Model training, inference, embeddings generation + - High-memory queue: Large dataset processing, analytics aggregation + """ + + @workflow.run + async def run(self, request: MLPipelineRequest) -> dict: + workflow.logger.info(f"Starting ML pipeline {request.pipeline_id}") + + # Step 1: Validate data (CPU queue - lightweight validation) + validation_result = await workflow.execute_activity( + "validate_data", + {"dataset_url": request.dataset_url}, + task_queue=STANDARD_CPU_QUEUE, + start_to_close_timeout=timedelta(minutes=2), + ) + + if not validation_result["valid"]: + return {"status": "validation_failed", "error": validation_result["error"]} + + # Step 2: Process large dataset (High-memory queue - loads full dataset into memory) + processed_data = await workflow.execute_activity( + "process_large_dataset", + {"dataset_url": request.dataset_url, "pipeline_id": request.pipeline_id}, + task_queue=HIGH_MEMORY_QUEUE, + start_to_close_timeout=timedelta(hours=1), + ) + + # Step 3: Train model (GPU queue - requires GPU for training) + model_result = await workflow.execute_activity( + "train_model", + { + "model_type": request.model_type, + "data_path": processed_data["output_path"], + "pipeline_id": request.pipeline_id, + }, + task_queue=GPU_ML_QUEUE, + start_to_close_timeout=timedelta(hours=4), + ) + + # Step 4: Generate embeddings (GPU queue - GPU-accelerated inference) + embeddings = await workflow.execute_activity( + "generate_embeddings", + { + "model_path": model_result["model_path"], + "customer_id": request.customer_id, + }, + task_queue=GPU_ML_QUEUE, + start_to_close_timeout=timedelta(minutes=30), + ) + + # Step 5: Store results (CPU queue - simple I/O operation) + await workflow.execute_activity( + "store_results", + { + "pipeline_id": request.pipeline_id, + "embeddings": embeddings, + "model_metrics": model_result["metrics"], + }, + task_queue=STANDARD_CPU_QUEUE, + start_to_close_timeout=timedelta(minutes=5), + ) + + return { + "status": "completed", + "pipeline_id": request.pipeline_id, + "model_path": model_result["model_path"], + "embeddings_count": len(embeddings["vectors"]), + } +``` + +**File: `activities.py`** + +```python +"""Activity implementations for different resource requirements.""" +from temporalio import activity +import asyncio + + +# ===== CPU-based activities (STANDARD_CPU_QUEUE) ===== + +@activity.defn +async def validate_data(data: dict) -> dict: + """ + Validate input data (runs on standard CPU workers). + + Lightweight validation that doesn't require specialized hardware. + """ + activity.logger.info(f"Validating dataset at {data['dataset_url']}") + + # Validation logic + is_valid = True # Simplified validation + + return { + "valid": is_valid, + "dataset_url": data["dataset_url"], + "error": None if is_valid else "Invalid dataset format", + } + + +@activity.defn +async def preprocess_data(data: dict) -> dict: + """Basic data preprocessing (runs on CPU workers).""" + activity.logger.info("Preprocessing data") + + # Lightweight preprocessing + return {"preprocessed": True, "records": 1000} + + +@activity.defn +async def store_results(data: dict) -> dict: + """Store pipeline results (runs on CPU workers).""" + activity.logger.info(f"Storing results for pipeline {data['pipeline_id']}") + + # Store to database or object storage + return {"stored": True, "pipeline_id": data["pipeline_id"]} + + +# ===== GPU-based activities (GPU_ML_QUEUE) ===== + +@activity.defn +async def train_model(data: dict) -> dict: + """ + Train ML model (runs on GPU workers). + + Requires GPU for acceptable training performance. + """ + activity.logger.info(f"Training {data['model_type']} model on GPU") + + import torch + + device = torch.device("cuda" if torch.cuda.is_available() else "cpu") + activity.logger.info(f"Using device: {device}") + + # Simulate training + # In production: load data, initialize model, train + await asyncio.sleep(5) # Simulate training time + + model_path = f"/models/{data['pipeline_id']}/model.pth" + + activity.logger.info(f"Training complete, model saved to {model_path}") + + return { + "model_path": model_path, + "metrics": {"accuracy": 0.95, "loss": 0.05}, + "device": str(device), + } + + +@activity.defn +async def generate_embeddings(data: dict) -> dict: + """ + Generate embeddings using ML model (runs on GPU workers). + + Requires GPU hardware for acceptable performance. + """ + activity.logger.info("Generating embeddings on GPU") + + import torch + from sentence_transformers import SentenceTransformer + + # Load model (cached after first load) + model = SentenceTransformer("all-MiniLM-L6-v2") + device = torch.device("cuda" if torch.cuda.is_available() else "cpu") + model = model.to(device) + + # Generate embeddings (simplified for example) + texts = [f"Sample text {i}" for i in range(100)] + embeddings = model.encode(texts, convert_to_tensor=True) + + activity.logger.info(f"Generated {len(embeddings)} embeddings on {device}") + + return { + "vectors": embeddings.cpu().tolist(), + "customer_id": data["customer_id"], + "dimension": embeddings.shape[1], + } + + +@activity.defn +async def run_inference(data: dict) -> dict: + """Run ML inference (runs on GPU workers).""" + activity.logger.info("Running inference on GPU") + + import torch + device = torch.device("cuda" if torch.cuda.is_available() else "cpu") + + # Model inference logic + activity.logger.info(f"Inference completed on {device}") + + return {"predictions": [], "confidence": 0.95, "device": str(device)} + + +# ===== High-memory activities (HIGH_MEMORY_QUEUE) ===== + +@activity.defn +async def process_large_dataset(data: dict) -> dict: + """ + Process large dataset (runs on high-memory workers). + + Loads entire dataset into memory for processing. + Requires 32-64GB RAM for typical datasets. + """ + activity.logger.info(f"Processing large dataset from {data['dataset_url']}") + + # In production: load dataset (pandas, dask, spark) + # Process in-memory transformations + await asyncio.sleep(10) # Simulate processing time + + output_path = f"/data/processed/{data['pipeline_id']}/dataset.parquet" + + activity.logger.info(f"Dataset processed, saved to {output_path}") + + return { + "output_path": output_path, + "records_processed": 10_000_000, + "memory_used_gb": 45, + } + + +@activity.defn +async def aggregate_analytics(data: dict) -> dict: + """ + Aggregate analytics from large datasets (runs on high-memory workers). + + Performs in-memory aggregations on large datasets. + """ + activity.logger.info("Running analytics aggregation") + + # In-memory aggregation logic + await asyncio.sleep(5) + + return { + "aggregations": {"total": 1000000, "average": 42.5}, + "memory_used_gb": 38, + } + + +@activity.defn +async def build_large_index(data: dict) -> dict: + """ + Build search index from large dataset (runs on high-memory workers). + + Requires significant memory to build index structures. + """ + activity.logger.info("Building large search index") + + # Build index in memory + await asyncio.sleep(8) + + return { + "index_path": "/indexes/search_index.bin", + "index_size_gb": 25, + "documents_indexed": 50_000_000, + } +``` + + +**Starter example:** + +```python +# start_ml_pipeline.py +"""Start ML pipeline workflow with resource-aware routing.""" +import asyncio +from temporalio.client import Client +from ml_pipeline_workflow import MLPipelineWorkflow, MLPipelineRequest + + +async def main(): + client = await Client.connect("localhost:7233") + + pipeline_request = MLPipelineRequest( + pipeline_id="ml-pipeline-2024-001", + dataset_url="s3://datasets/customer-behavior-10m.parquet", + model_type="transformer", + customer_id="customer-456", + ) + + handle = await client.start_workflow( + MLPipelineWorkflow.run, + pipeline_request, + id=f"ml-pipeline-{pipeline_request.pipeline_id}", + task_queue="workflows", + ) + + print(f"Started ML pipeline workflow: {handle.id}") + + # Wait for result (or use handle.describe() to check status) + result = await handle.result() + print(f"Pipeline completed: {result}") + + +if __name__ == "__main__": + asyncio.run(main()) +``` + +## Conclusion + +By implementing separate Task Queues for resource-based routing, you have achieved: + +1. **Cost optimization:** GPU instances (g4dn.xlarge at $0.526/hr) only handle ML training and inference, while standard CPU Activities run on cost-effective instances (c5.xlarge at $0.17/hr), and high-memory analytics run on memory-optimized instances (r5.2xlarge at $0.504/hr). This targeted resource allocation reduces infrastructure costs by 60-80% compared to running everything on GPU instances. + +2. **Resource efficiency:** GPU Workers run at 2-4 concurrent activities (constrained by GPU memory), CPU Workers run at 100+ concurrent activities, and high-memory Workers run at 10 concurrent activities (constrained by RAM). Each Worker pool is optimized for its specific resource constraints. + +3. **Independent scaling:** Scale GPU Workers based on ML workload demand, high-memory Workers based on analytics load, and CPU Workers based on standard workload volume, without coordination between pools. Each pool can scale independently based on its queue depth and resource utilization. + +4. **Environment isolation:** ML Workers with TensorFlow/PyTorch/CUDA dependencies are isolated from standard Workers, preventing library conflicts and simplifying deployment. Each Worker type can use specialized container images with only the dependencies it needs. + +Your application now intelligently routes work based on resource requirements, ensuring efficient hardware utilization and cost-effective infrastructure allocation. + +## Related resources + +### Official documentation +- [Temporal Documentation - Task Routing](https://docs.temporal.io/task-routing) +- [Temporal Best Practices - Separate Task Queues](https://docs.temporal.io/best-practices/worker#separate-task-queues-logically) +- [Python SDK Worker Configuration](https://python.temporal.io/temporalio.worker.Worker.html) + +### Related patterns +- [Separate Task Queues - Worker Affinity](worker-execution-affinity) - For Activities that must run on the same Worker instance +- [Separate Task Queues - Rate Limiting](rate-limit-downstream-apis) - For protecting downstream APIs with rate limits + +### Code samples +- [GPU-Accelerated Image Processing (Python)](https://github.com/temporalio/samples-python) - Example of GPU worker configuration +- [Worker Versioning with Task Queues](https://github.com/temporalio/samples-python/tree/main/worker_versioning) - Managing different Worker versions + +### Community resources +- [Blog: AI, ML, and Data Engineering Workflows with Temporal](https://temporal.io/blog/ai-ml-and-data-engineering-workflows-with-temporal) +- [Forum: Task Queue Best Practices](https://community.temporal.io/t/in-what-situation-should-we-use-multiple-separated-task-queues/1254) +- [Temporal ML/AI Use Cases](https://temporal.io/use-cases/machine-learning) \ No newline at end of file diff --git a/docs/guides/saga-pattern.mdx b/docs/guides/saga-pattern.mdx new file mode 100644 index 0000000000..d70f752330 --- /dev/null +++ b/docs/guides/saga-pattern.mdx @@ -0,0 +1,1063 @@ +--- +id: saga-pattern +title: Recover business processes without restarting +description: Build a multi-step business procss that pauses when it hits a permanent failure, then resumes from where it stopped without restarting. +sidebar_label: Recovery without restart +toc_max_heading_level: 3 +author: Tao Guo +tags: + - Architecture + - Saga Pattern + - Retries + - Durable Execution + - TypeScript SDK +--- + +**by Tao Guo** + +This pattern shows how to build a multi-step business process that pauses when it hits a permanent failure — a malformed identifier, an exceeded policy limit, a failed compliance check — allows a human or automated agent to correct the underlying issue, and resumes from where it stopped without restarting from the beginning. +Queryable metadata routes each blocked process to the right resolution resource, and operators get full visibility into the state of every individual execution and across the entire pipeline. + +![A blocked Workflow showing the failure details and a form to send a corrective Signal](assets/saga-pattern/keep-business-moving-list-view.png) + +## Problem statement + +Business processes such as loan origination, insurance claims, and onboarding involve sequential steps where certain failures — invalid input, regulatory violations, business policy breaches — cannot be resolved by automated retries. +An invalid Social Security number will never pass a credit check regardless of how often you retry it. +A title search against a missing property identifier will return the same error no matter how many times you repeat it. +These failures require someone to inspect the error, correct the data, and instruct the process to continue. + +Without a durable orchestration layer, teams typically implement this by writing failed records to a dead-letter queue, building a separate reconciliation service, and stitching together polling loops to resume processing. +This approach is fragile: the process state lives across multiple systems, corrections are ad-hoc, and operators have no single view of which applications need attention and why. +When something goes wrong, the common workaround is to fix the data and restart the entire process from the beginning, losing all progress from steps that already completed. + +## Solution + +You will use a Temporal Workflow to orchestrate a six-step home loan processing pipeline. +When a step encounters a problem that retries alone cannot solve — a missing document, a policy limit breach, or a regulatory hold — it throws a non-retryable `ApplicationFailure` and the Workflow pauses, preserving all progress. +Custom Search Attributes advertise the blocked state so that operators or AI agents can find and prioritize the right cases. +An operator sends a Signal containing corrected data, which patches the application in-place and wakes the Workflow to retry the failed step. +The process picks up where it left off: completed steps do not re-execute, and the full correction history is preserved. + +## Architectural benefit + +While you can use traditional architectures to implement a similar solution, they often involve significantly more complexity and demand higher implementation and operational costs. A Temporal-based solution is typically much leaner and more robust. + +![Architectural comparison between a traditional implementation and the Temporal-based solution](assets/saga-pattern/keep-business-moving-architectural-comparison.png) + +## What you will achieve + +By completing this pattern, you will: + +- Pause a business process on a non-retryable failure and resume after correction without restarting from the beginning. +- Unwind side-effecting steps through a LIFO **saga** compensation stack when forward progress is not possible — for example, a compliance block or an explicit cancellation from the applicant. +- Recover from compensation failures using the same pause-and-fix loop so a stuck rollback does not leave the process in a partially unwound state. +- Route blocked processes to the right human or automated agent using queryable Search Attributes. +- Inspect the current state of any individual process or filter across all processes through a single API. +- Maintain a complete audit trail of every data correction and every compensation applied during the lifecycle of each process. + +## Background and best practices + +Temporal Workflows persist their execution state, including local variables, call stacks, and pending Timers, to the Temporal Cluster. +When a Workflow suspends on `condition()`, the Worker completes the Workflow Task and the execution can be evicted from the Worker's in-memory cache to make room for other Workflows, freeing the associated resources. +The Workflow resumes where it left off when the condition becomes true, regardless of how much wall-clock time passes. +This means thousands of loan applications can sit in a `PENDING_FIX` state simultaneously without consuming Worker capacity. + +The primary architectural challenge is distinguishing between transient failures that automated retries can resolve and permanent failures that require human intervention. +Temporal addresses this with [`ApplicationFailure.nonRetryable()`](https://docs.temporal.io/references/failures#application-failure), which instructs the SDK to skip the Retry Policy and propagate the error to the Workflow code without delay. +The Workflow then has full control over how to handle the failure: it can log the error, update Search Attributes for visibility, and suspend until a [Signal](https://docs.temporal.io/develop/typescript/message-passing#signals) arrives. + +Custom [Search Attributes](https://docs.temporal.io/visibility#search-attribute) provide the mechanism for routing blocked Workflows to the correct resolution resource. +By upserting attributes like `LoanStatus` and `FailedActivity` at each State Transition, you create a queryable index across all active Workflows. +An operations dashboard can filter for `LoanStatus = 'PENDING_FIX' AND FailedActivity = 'runCreditCheck'` to show which applications need a credit-related data correction. +This same query interface supports automated agents that poll for specific failure categories and apply corrections programmatically. + +Temporal [Queries](https://docs.temporal.io/develop/typescript/message-passing#queries) provide synchronous read access to the current state of any running Workflow. +Unlike Search Attributes, which expose denormalized metadata for cross-Workflow queries, Queries return the full internal state of a single execution. +Together, Search Attributes and Queries give you both the aggregate view across your entire pipeline and the detailed view into any individual Workflow. + +## Target audience + +Engineers familiar with Temporal foundations who need to handle long-running, complex business processes. + +## Prerequisites + +To execute the steps in this pattern, you must have: + +- **Required software, infrastructure, and tools:** Temporal TypeScript SDK v1.13.0 or later, Node.js v18 or later, Temporal CLI v1.6.1 or later. +- **Resources and access privileges:** A running Temporal Cluster (local dev server or Temporal Cloud) with permissions to create custom Search Attributes and start Workflows. +- **Required concepts:** Familiarity with Temporal Workflows, Activities, Signals, Queries, Search Attributes, and Workers. + +Install the Temporal CLI and start a local dev server: + +```bash +# macOS +brew install temporal + +# Or download directly from https://docs.temporal.io/cli#install + +# Start the local development server +temporal server start-dev +``` + +This starts a Temporal Server on `localhost:7233` with the Web UI at `http://localhost:8233`. + +**Note:** This guide uses the TypeScript SDK but the pattern apply to any Temporal SDK. + +## Architecture diagram + +The following diagram illustrates the flow of a loan application through the six-step pipeline, including the pause-and-resume cycle when an Activity encounters a permanent failure. + +```mermaid +sequenceDiagram + participant Client + participant WF as Workflow + participant Act as Activities + participant Op as Operator / Agent + + Client->>WF: Start homeLoanWorkflow + WF->>+Act: Execute Activity + Act-->>-WF: ApplicationFailure.nonRetryable() + WF->>WF: Set PENDING_FIX, await condition() + + Note over Op, Act: Correction cycle + Op->>WF: Query blocked Workflows + WF-->>Op: PENDING_FIX list + Op->>WF: Signal: retry({key, value}) + WF->>WF: Patch data, wake condition + WF->>+Act: Retry Activity + Act-->>-WF: Success + + WF->>WF: Advance to next Activity +``` + +1. A client starts `homeLoanWorkflow` with the loan application data. +2. The Workflow executes Activities sequentially. On a non-retryable failure, the Activity throws `ApplicationFailure.nonRetryable()`. +3. The Workflow catches the error, sets Search Attributes to `PENDING_FIX`, and suspends via `await condition(() => retryRequested)`. +4. An operator or agent queries for blocked Workflows, sends a corrective Signal with the field name and new value. +5. The Signal handler patches the data, records the fix, and wakes the Workflow. +6. The Workflow retries the failed Activity. On success it advances; on failure the cycle repeats from step 3. + +## Implementation plan + +Prior to executing this plan, ensure you have your Temporal Cluster running and the custom Search Attributes created. + +### Create custom Search Attributes + +Before writing any code, register the custom Search Attributes that your Workflow will use to advertise its state. +These attributes enable the visibility queries that route blocked Workflows to the appropriate resolution resource. + +```bash +temporal operator search-attribute create --name LoanStatus --type Keyword +temporal operator search-attribute create --name FailedActivity --type Keyword +``` + +`LoanStatus` tracks the current pipeline stage: `STARTED`, `INCOME_VERIFIED`, `CREDIT_CHECKED`, `APPRAISAL_ORDERED`, `TITLE_SEARCHED`, `UNDERWRITTEN`, `CLOSED`, or `PENDING_FIX`. +`FailedActivity` records the name of the Activity that caused the failure. +Together, these two attributes allow you to write queries like `LoanStatus = 'PENDING_FIX' AND FailedActivity = 'runCreditCheck'` to find every application blocked on a credit check. + +### Define data models + +Create the interfaces that define the data flowing through Workflows and Activities. +It is a best practice to use a single serializable input to Workflows and Activities. + +```typescript +// src/models.ts + +// Input data for the loan processing pipeline +export interface LoanApplication { + applicationId: string; + applicantName: string; + ssn: string; + employerName: string; + annualIncome: number; + propertyAddress: string; + propertyId: string; + loanAmount: number; + downPayment: number; +} + +// Tracks the current pipeline stage; PENDING_FIX means the Workflow is waiting for a correction +export type LoanStatus = + | 'STARTED' + | 'INCOME_VERIFIED' + | 'CREDIT_CHECKED' + | 'APPRAISAL_ORDERED' + | 'TITLE_SEARCHED' + | 'UNDERWRITTEN' + | 'CLOSED' + | 'PENDING_FIX' + | 'FAILED'; + +// Records a single data correction applied during execution +export interface FixEntry { + activity: string; // which Activity was blocked + field: string; // which field was corrected + oldValue: string; + newValue: string; + error: string; // the failure message that triggered the fix + id?: string; // client-supplied idempotency key for duplicate Signal detection +} + +// Complete queryable state of a pipeline execution +export interface LoanState { + status: LoanStatus; + failedActivity: string; + failureMessage: string; + completedActivities: string[]; // Activities that have already succeeded + fixHistory: FixEntry[]; // audit trail of all corrections + application: LoanApplication; // current data including patches +} + +// Signal payload: the field to correct and its new value +export interface RetryUpdate { + key?: keyof LoanApplication | ''; + value?: string; + id?: string; // optional idempotency key — Signals are at-least-once +} +``` + +### Define the Activities + +Create functions that validate loan application data at each pipeline stage. +Temporal Activities automatically recover from transient failures — network timeouts, temporary service unavailability, rate limits — through their built-in [Retry Policy](https://docs.temporal.io/encyclopedia/retry-policies) and [timeout management](https://docs.temporal.io/develop/typescript/failure-detection#activity-timeouts). +When an Activity encounters a permanent failure that retries cannot fix — invalid input data, a policy violation, or a missing record — it throws `ApplicationFailure.nonRetryable()` to bypass the Retry Policy and propagate the error directly to the Workflow. + +```typescript +// src/activities.ts + +import { ApplicationFailure } from '@temporalio/activity'; + +// Step 1: Validate the applicant's employment and income +export async function verifyIncome( + applicantName: string, + employerName: string, + annualIncome: number +): Promise { + // Unrecognized employer — cannot verify + if (employerName === 'UNKNOWN_EMPLOYER') { + throw ApplicationFailure.nonRetryable( + `Employer "${employerName}" not found in verification database for ${applicantName}` + ); + } + if (annualIncome <= 0) { + throw ApplicationFailure.nonRetryable( + `Invalid annual income: $${annualIncome} for ${applicantName}` + ); + } + return `Income verified: ${applicantName} earns $${annualIncome}/yr at ${employerName}`; +} + +// Step 2: Pull credit report using the applicant's SSN +export async function runCreditCheck( + applicantName: string, + ssn: string +): Promise { + // Malformed SSN — no retry will fix this + if (ssn === '000-00-0000' || ssn.length < 11) { + throw ApplicationFailure.nonRetryable( + `Invalid SSN "${ssn}" for ${applicantName} — cannot pull credit report` + ); + } + return `Credit check passed for ${applicantName}: score 750`; +} + +// Step 3: Order a property appraisal +export async function orderAppraisal( + propertyAddress: string, + loanAmount: number +): Promise { + // Invalid property address + if (propertyAddress === '' || propertyAddress === 'INVALID_ADDRESS') { + throw ApplicationFailure.nonRetryable( + `Cannot order appraisal — invalid property address: "${propertyAddress}"` + ); + } + return `Appraisal completed for ${propertyAddress}: valued at $${loanAmount * 1.1}`; +} + +// Step 4: Verify the property title is clear +export async function performTitleSearch( + propertyId: string, + propertyAddress: string +): Promise { + // Missing or invalid property ID + if (propertyId === '' || propertyId === 'MISSING') { + throw ApplicationFailure.nonRetryable( + `Title search failed — missing or invalid property ID: "${propertyId}" for ${propertyAddress}` + ); + } + return `Title is clear for property ${propertyId} at ${propertyAddress}`; +} + +// Step 5: Check debt-to-income ratio against lending policy +export async function underwrite( + applicantName: string, + annualIncome: number, + loanAmount: number, + downPayment: number +): Promise { + const dti = ((loanAmount - downPayment) / annualIncome) * 100; + // DTI above 400% exceeds policy limit — needs a larger down payment or lower loan amount + if (dti > 400) { + throw ApplicationFailure.nonRetryable( + `Underwriting denied for ${applicantName} — debt-to-income ratio ${dti.toFixed(0)}% exceeds 400% limit` + ); + } + return `Underwriting approved for ${applicantName}: DTI ${dti.toFixed(0)}%`; +} + +// Step 6: Finalize and fund the loan +export async function closeLoan( + applicationId: string, + applicantName: string, + loanAmount: number +): Promise { + return `Loan ${applicationId} closed for ${applicantName}: $${loanAmount} funded`; +} +``` + +Each Activity validates its inputs against business rules and throws `ApplicationFailure.nonRetryable()` when the data is fundamentally invalid. +The `nonRetryable` designation is critical: it instructs the Temporal SDK to skip the Retry Policy entirely and propagate the error directly to the Workflow. +This distinguishes failures that require human judgment — corrupt input, policy breaches, compliance blocks — from transient infrastructure failures that retries can resolve. + +### Implement the Workflow with the recoverableStep pattern + +You will now create the Workflow that orchestrates the six-step pipeline. +The central mechanism is the `recoverableStep` helper function that wraps each Activity call in a pause-and-resume loop. + +```typescript +// src/workflows.ts + +import { + proxyActivities, + defineSignal, + defineQuery, + setHandler, + condition, + upsertSearchAttributes, + log, + ActivityFailure, + isCancellation, +} from '@temporalio/workflow'; +import { defineSearchAttributeKey } from '@temporalio/common'; +import type * as activities from './activities'; +import type { FixEntry, LoanApplication, LoanState, LoanStatus, RetryUpdate } from './models'; + +// Typed Search Attribute keys — used in both upsertSearchAttributes and client start options +const LoanStatusKey = defineSearchAttributeKey('LoanStatus', 'KEYWORD'); +const FailedActivityKey = defineSearchAttributeKey('FailedActivity', 'KEYWORD'); + +const { + verifyIncome, + runCreditCheck, + orderAppraisal, + performTitleSearch, + underwrite, + closeLoan, +} = proxyActivities({ + // In production, split into multiple proxies and tune per Activity; long-running steps + // should also call heartbeat() with a heartbeatTimeout. + startToCloseTimeout: '10 seconds', + // Use the default retry policy for transient failures. + // ApplicationFailure.nonRetryable() bypasses retries for permanent failures. +}); + +// Signal to deliver corrected data; Query to read current pipeline state +export const retrySignal = defineSignal<[RetryUpdate]>('retry'); +export const getStateQuery = defineQuery('getState'); + +export async function homeLoanWorkflow(application: LoanApplication): Promise { + // Mutable copy — patched in-place by the Signal handler when corrections arrive + const app = { ...application }; + let status: LoanStatus = 'STARTED'; + let failedActivity = ''; + let failureMessage = ''; + let retryRequested = false; + const completedActivities: string[] = []; + const fixHistory: FixEntry[] = []; + + // Publish pipeline state as Search Attributes so operators can query across all Workflows + const updateStatus = (newStatus: LoanStatus, activity = '', message = '') => { + status = newStatus; + failedActivity = activity; + failureMessage = message; + upsertSearchAttributes([ + { key: LoanStatusKey, value: newStatus }, + { key: FailedActivityKey, value: activity }, + ]); + }; + + // Query handler — returns a snapshot of the full pipeline state without side effects + setHandler(getStateQuery, () => ({ + status, + failedActivity, + failureMessage, + completedActivities: [...completedActivities], + fixHistory: [...fixHistory], + application: { ...app }, + })); + + // Signal handler — patches the application data and wakes the suspended Workflow. + // Signals are at-least-once: clients can retry on transport hiccups, so dedupe + // via the caller-supplied id stored on FixEntry. + setHandler(retrySignal, (update: RetryUpdate) => { + if (update.id && fixHistory.some((f) => f.id === update.id)) { + log.warn(`Duplicate retry signal ignored: ${update.id}`); + return; + } + if (update.key) { + const key = update.key as keyof LoanApplication; + const oldValue = String((app as any)[key]); + if (key === 'annualIncome' || key === 'loanAmount' || key === 'downPayment') { + (app as any)[key] = parseFloat(update.value ?? '0'); + } else { + (app as any)[key] = update.value ?? ''; + } + fixHistory.push({ + activity: failedActivity, + field: key, + oldValue, + newValue: update.value ?? '', + error: failureMessage, + id: update.id, + }); + log.info(`Fix received ${key}: ${oldValue} -> ${update.value}`); + } else { + log.info('Retry requested without patch'); + } + retryRequested = true; // unblocks the condition() below + }); + + // Core pattern: wrap each Activity in a pause-and-resume loop. + // On failure, advertise PENDING_FIX via Search Attributes and suspend + // until a Signal delivers corrected data, then retry the same Activity. + const recoverableStep = async ( + activityName: string, + fn: () => Promise + ): Promise => { + while (true) { + try { + const result = await fn(); + return result; + } catch (e) { + // Anything that isn't an ActivityFailure (workflow-side bug, non-determinism) + // is treated as a workflow task failure by Temporal and retried — let it surface. + if (!(e instanceof ActivityFailure)) throw e; + // Cancellation arrives wrapped as ActivityFailure; propagate so the workflow unwinds. + if (isCancellation(e)) throw e; + // Activity errors are wrapped in ActivityFailure; the original message is in .cause + const message = e.cause?.message || e.message || String(e); + log.warn(`Activity ${activityName} failed: ${message}`); + updateStatus('PENDING_FIX', activityName, message); + retryRequested = false; + // Suspend the Workflow — no resources consumed while waiting + await condition(() => retryRequested); + updateStatus('STARTED', '', ''); + log.info(`Retrying activity ${activityName} after fix`); + } + } + }; + + await recoverableStep('verifyIncome', () => + verifyIncome(app.applicantName, app.employerName, app.annualIncome) + ); + completedActivities.push('verifyIncome'); + updateStatus('INCOME_VERIFIED'); + + await recoverableStep('runCreditCheck', () => + runCreditCheck(app.applicantName, app.ssn) + ); + completedActivities.push('runCreditCheck'); + updateStatus('CREDIT_CHECKED'); + + await recoverableStep('orderAppraisal', () => + orderAppraisal(app.propertyAddress, app.loanAmount) + ); + completedActivities.push('orderAppraisal'); + updateStatus('APPRAISAL_ORDERED'); + + await recoverableStep('performTitleSearch', () => + performTitleSearch(app.propertyId, app.propertyAddress) + ); + completedActivities.push('performTitleSearch'); + updateStatus('TITLE_SEARCHED'); + + await recoverableStep('underwrite', () => + underwrite(app.applicantName, app.annualIncome, app.loanAmount, app.downPayment) + ); + completedActivities.push('underwrite'); + updateStatus('UNDERWRITTEN'); + + await recoverableStep('closeLoan', () => + closeLoan(app.applicationId, app.applicantName, app.loanAmount) + ); + completedActivities.push('closeLoan'); + updateStatus('CLOSED'); + + return { + status, + failedActivity, + failureMessage, + completedActivities: [...completedActivities], + fixHistory: [...fixHistory], + application: { ...app }, + }; +} +``` + +The `homeLoanWorkflow` orchestrates the entire loan processing pipeline. +The `recoverableStep` helper function wraps each Activity call in a `while (true)` loop. +When an Activity throws, the helper updates the `LoanStatus` Search Attribute to `PENDING_FIX` and the `FailedActivity` Search Attribute to the name of the failed Activity. +It then calls `await condition(() => retryRequested)`, which suspends the Workflow until a Signal delivers corrected data. + +The catch block narrows the error before suspending. It first re-throws anything that is not an `ActivityFailure` — a Workflow-side bug or a non-determinism error should fail the Workflow Task and let Temporal retry it, not be silently parked in `PENDING_FIX`. It then re-throws on `isCancellation(e)`, since cancellation arrives wrapped as an `ActivityFailure` and must propagate so the framework can unwind the Workflow cleanly. + +The Signal handler receives a `RetryUpdate` containing the field name and corrected value. +It patches the `app` object in-place, records the correction in `fixHistory`, and sets `retryRequested = true` to wake the suspended `condition()`. +The loop then retries the Activity with the patched data. +If the Activity succeeds, the loop exits and the pipeline advances. +If it fails again, the cycle repeats. + +Temporal delivers Signals with at-least-once semantics: a client retry, a flaky network, or an over-eager UI can submit the same correction twice and the Workflow will see both copies. +Without protection, the second delivery would overwrite the field again — usually a no-op, but harmful when the operator has since sent a different value, and always noisy in `fixHistory`. +The handler defends against this by checking the caller-supplied `id` against the audit trail and dropping anything it has already applied. +The client (the dashboard, CLI script, or upstream agent) is responsible for generating a stable `id` per logical correction — typically a UUID minted when the operator clicks **Patch and Retry**, reused across any retries of that same submission. +[Workflow Updates](https://docs.temporal.io/encyclopedia/workflow-message-passing#updates) would give you this deduplication for free via the SDK's built-in `updateId` handling, along with a synchronous result to the caller; this pattern sticks with Signals because the dashboard is fire-and-forget and the audit trail already provides the dedup index. + +The Query handler returns the complete `LoanState` at any point during execution. +This includes the current status, the list of completed Activities, the full fix history, and the current application data. +External systems can poll this Query to display real-time pipeline progress. + +By calling `upsertSearchAttributes` at every State Transition, the Workflow maintains a denormalized index that the Temporal Visibility API can query across all active Workflows. +This enables an operations dashboard to display aggregate statistics and filter by any combination of status and failed Activity. + +### Add saga compensation for side-effecting steps + +Not every failure can be resolved by correcting input data. +An OFAC match, a withdrawn offer, or a regulatory hold demands that the pipeline roll back rather than pause for a fix. +This is where the [saga pattern](https://temporal-design-patterns.fly.dev/saga-pattern.html) comes in: each forward Activity that produces an external side effect — a credit bureau inquiry, an appraiser booking, a title company fee, a reserved lending slot, a funded loan — registers a compensating Activity **before** it executes. +If the forward pipeline aborts, the Workflow unwinds the registered compensations in reverse order. + +The pattern has three key discipline points that the implementation must honor: + +1. **Register before execution.** Compensations are registered before the forward call to handle partial failures. Consider `orderAppraisal`: the Worker POSTs a booking to the appraisal vendor, the vendor records the booking and reserves the fee, then the response is lost to a network blip on the way back. The Activity throws because it never received a response — yet the booking exists on the vendor side. If the Workflow only registered the compensation after a successful forward call, that booking would be orphaned by a saga rollback. Pre-registering guarantees `cancelAppraisal` runs during rollback either way; idempotency makes it a safe no-op when the side effect never actually landed. +2. **LIFO unwinding.** The last step to touch external state is the first to undo. In TypeScript this is expressed by pushing onto the front of the array with `unshift()` and iterating forward, or by iterating a `push()`-built array in reverse. +3. **Recoverable compensation.** Compensations can fail — vendor APIs go down, external systems reject requests. Running each compensation through the same recoverable wrapper as forward steps means a stuck rollback pauses with `ROLLBACK_PENDING_FIX` and can be patched or retried by an operator. + +Extend the Workflow to register compensations alongside forward Activities: + +```typescript +interface Compensation { + forwardActivity: string; + compensationActivity: string; + run: () => Promise; +} +const compensations: Compensation[] = []; + +const runForward = async ( + activityName: string, + forward: () => Promise, + compensation?: { name: string; fn: () => Promise } +): Promise => { + if (cancelRequested) { + throw new Error(`Cancelled before ${activityName}: ${cancelReason}`); + } + // Register BEFORE execution — handles partial side effects if the Activity aborts mid-flight + if (compensation) { + compensations.unshift({ + forwardActivity: activityName, + compensationActivity: compensation.name, + run: compensation.fn, + }); + } + return recoverableStep(activityName, forward, 'forward'); +}; +``` + +Two paths now trigger a rollback: + +**Explicit cancellation.** A `cancelApplication` Signal sets `cancelRequested = true` and wakes any paused `condition()`. The `recoverableStep` loop checks this flag on wake-up and throws out of the pause to the outer `try/catch`. + +**RollbackRequired failure type.** An Activity can throw `ApplicationFailure.nonRetryable(message, 'RollbackRequired')` to signal that no data correction will help — the application must be withdrawn. The Workflow catches this specific failure type and routes it to the compensation phase instead of the pause loop: + +```typescript +export async function underwrite( + applicantName: string, ssn: string, ... +): Promise { + if (ssn.startsWith('999')) { + throw ApplicationFailure.nonRetryable( + `Compliance block: OFAC/sanctions match — application must be withdrawn`, + 'RollbackRequired' + ); + } + // ... normal DTI check +} +``` + +The compensation loop runs in LIFO order through the same recoverable wrapper: + +```typescript +try { + // ... forward pipeline: verifyIncome, runCreditCheck, orderAppraisal, ... +} catch (err: any) { + const trigger = cancelReason || err.message || String(err); + updateStatus('COMPENSATING', '', trigger); + + for (const comp of compensations) { + // Safe even without this check thanks to idempotency, but skipping keeps the audit clean + if (!completedActivities.includes(comp.forwardActivity)) continue; + + const result = await recoverableStep(comp.forwardActivity, comp.run, 'compensation'); + compensationHistory.push({ + forwardActivity: comp.forwardActivity, + compensationActivity: comp.compensationActivity, + result, + }); + compensatedActivities.push(comp.forwardActivity); + updateStatus('COMPENSATING'); + } + + updateStatus('ROLLED_BACK', '', trigger); +} +``` + +Not every step needs a compensation. +`verifyIncome` is a read-only lookup against the employer verification database — there is no external state to undo. +`runCreditCheck` records a hard inquiry that lowers the applicant's score, so its compensation submits a withdrawal request. +`orderAppraisal` books an appraiser and charges a fee — the compensation cancels the booking and issues a partial refund. +`performTitleSearch` pays the title company and places a placeholder hold — the compensation releases the hold. +`underwrite` reserves lending capacity against portfolio limits — the compensation returns the capacity to the pool. +`closeLoan` is the most consequential: funds are disbursed and a lien is recorded at the county — the compensation initiates a clawback and files the lien release. + +Each compensation Activity is intentionally written to be idempotent. +`withdrawCreditInquiry` simply resubmits the withdrawal request; the bureau accepts duplicates. +`cancelAppraisal` checks for an existing booking before cancelling. +`releaseTitleHold` is safe to call multiple times on an already-released hold. +This means the "register before execution" discipline cannot corrupt state even if the forward Activity never produced the side effect. + +After the compensations finish, a `notifyApplicantCancelled` Activity runs to inform the applicant that the application was withdrawn and to surface the trigger reason. +This step runs through the same recoverable wrapper as compensations: if the email provider is down, the Workflow pauses with `ROLLBACK_PENDING_FIX` so an operator can retry once the provider recovers rather than leaving the applicant uninformed. + +### Implement the web service + +Connect the Workflow to your application's API layer. +You will write the code that receives incoming requests, starts Workflows, queries state, and sends corrective Signals. + +```typescript +// src/web-service.ts + +import express from 'express'; +import { Connection, Client } from '@temporalio/client'; +import { defineSearchAttributeKey } from '@temporalio/common'; +import { homeLoanWorkflow, retrySignal, getStateQuery } from './workflows'; +import type { LoanApplication, RetryUpdate, LoanState } from './models'; + +const LoanStatusKey = defineSearchAttributeKey('LoanStatus', 'KEYWORD'); +const FailedActivityKey = defineSearchAttributeKey('FailedActivity', 'KEYWORD'); + +async function run() { + const connection = await Connection.connect({ address: 'localhost:7233' }); + const client = new Client({ connection }); + + const app = express(); + app.use(express.json()); + + app.get('/api/workflows', async (_req, res) => { + try { + const workflows: any[] = []; + const iterator = client.workflow.list({ + query: `TaskQueue = 'recoverable-activity' AND ExecutionStatus != 'Terminated'`, + }); + for await (const wf of iterator) { + const entry: any = { + workflowId: wf.workflowId, + wfStatus: wf.status.name, + // Read Search Attributes from the Visibility store (eventually consistent) + loanStatus: wf.searchAttributes?.LoanStatus?.[0] ?? '', + failedActivity: wf.searchAttributes?.FailedActivity?.[0] ?? '', + }; + if (wf.status.name === 'RUNNING') { + const handle = client.workflow.getHandle(wf.workflowId); + // Query returns the strongly consistent internal state from the Workflow + entry.state = await handle.query(getStateQuery); + } + workflows.push(entry); + } + res.json({ workflows }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + app.get('/api/workflows/search', async (req, res) => { + try { + const { failedActivity, status } = req.query; + const clauses = [`TaskQueue = 'recoverable-activity'`]; + if (failedActivity) { + clauses.push(`FailedActivity = '${failedActivity}'`); + } + if (status) { + clauses.push(`LoanStatus = '${status}'`); + } + const workflows: any[] = []; + const iterator = client.workflow.list({ query: clauses.join(' AND ') }); + for await (const wf of iterator) { + workflows.push({ + workflowId: wf.workflowId, + loanStatus: wf.searchAttributes?.LoanStatus?.[0] ?? '', + failedActivity: wf.searchAttributes?.FailedActivity?.[0] ?? '', + }); + } + res.json({ workflows }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + app.post('/api/workflows', async (req, res) => { + try { + const application = req.body as LoanApplication; + const handle = await client.workflow.start(homeLoanWorkflow, { + taskQueue: 'recoverable-activity', + workflowId: application.applicationId, + args: [application], + // Set initial Search Attributes so the Workflow is queryable from the start + typedSearchAttributes: [ + { key: LoanStatusKey, value: 'STARTED' }, + { key: FailedActivityKey, value: '' }, + ], + }); + res.json({ success: true, workflowId: handle.workflowId }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + app.post('/api/workflows/:workflowId/fix', async (req, res) => { + try { + const { key, value } = req.body as RetryUpdate; + // getHandle creates a reference to a running Workflow — no server call yet + const handle = client.workflow.getHandle(req.params.workflowId); + // signal() delivers the correction asynchronously; the Workflow wakes and retries + await handle.signal(retrySignal, { key, value }); + res.json({ success: true, message: `Fix sent: ${key} = ${value}` }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + app.listen(3000, () => { + console.log('Recoverable Activity UI running on http://localhost:3000'); + }); +} + +run().catch((err) => { + console.error(err); + process.exit(1); +}); +``` + +The search endpoint demonstrates how custom Search Attributes enable routing to human operators or automated agents for specific failure categories. + +### Create the Temporal Worker + +Now that you have built the Activities and Workflow, here is the complete Worker setup required to register and run the system. + +```typescript +// src/worker.ts + +import { NativeConnection, Worker } from '@temporalio/worker'; +import * as activities from './activities'; + +async function run() { + const connection = await NativeConnection.connect({ address: 'localhost:7233' }); + + const worker = await Worker.create({ + connection, + namespace: 'default', + taskQueue: 'recoverable-activity', + workflowsPath: require.resolve('./workflows'), + activities, + }); + + console.log('Worker started, ctrl+c to exit'); + await worker.run(); +} + +run().catch((err) => { + console.error(err); + process.exit(1); +}); +``` + +The `taskQueue` value must match the Task Queue used when starting Workflows from the client. + +Run the following commands sequentially, each in their own terminal: + +```bash +temporal server start-dev +``` + +```bash +npm start +``` + +### Launch the demo dashboard + +The project includes a single-page operations dashboard at [`public/index.html`](https://github.com/temporal-sa/validated-pattern-blob/main/public/index.html) that exercises the web service endpoints you built above. Start it alongside the Worker: + +```bash +npm run web +``` + +Open `http://localhost:3000` in your browser. The dashboard provides: + +- **Stats bar** — live counts of Total, Pending Fix, Running, and Completed Workflows. +- **Filters** — narrow by failed Activity or loan status, powered by the Search Attribute Query endpoint. +- **Pipeline visualization** — per-Workflow step indicators showing which Activities have completed, which failed, and which are pending. +- **Detail panel** — click any Workflow to inspect its full state (via the Query API), view the error message, and send a corrective Signal through a form that pre-suggests which field to fix. +- **Fix history** — an audit trail of every correction applied during the lifecycle of the Workflow. + +The dashboard polls the web service every 3 seconds and refreshes the detail panel in real time after you send a fix, so you can watch the pipeline advance step by step. + +### Test the Workflow execution + +To verify the pipeline and the pause-and-resume cycle, you must submit loan applications with known data errors and business rule violations. +You will write a client script that starts Workflows covering single-issue and multi-issue failure scenarios. + +```typescript +// src/client.ts + +import { Connection, Client } from '@temporalio/client'; +import { defineSearchAttributeKey } from '@temporalio/common'; +import { homeLoanWorkflow } from './workflows'; +import type { LoanApplication } from './models'; + +const LoanStatusKey = defineSearchAttributeKey('LoanStatus', 'KEYWORD'); +const FailedActivityKey = defineSearchAttributeKey('FailedActivity', 'KEYWORD'); + +const scenarios: { name: string; application: LoanApplication }[] = [ + { + name: 'Clean — all activities pass', + application: { + applicationId: 'LOAN-001', + applicantName: 'Alice Johnson', + ssn: '123-45-6789', + employerName: 'Acme Corp', + annualIncome: 120000, + propertyAddress: '123 Oak St, Springfield', + propertyId: 'PROP-001', + loanAmount: 350000, + downPayment: 70000, + }, + }, + { + name: 'Bad SSN — credit check fails', + application: { + applicationId: 'LOAN-002', + applicantName: 'Bob Smith', + ssn: '000-00-0000', + employerName: 'TechCo', + annualIncome: 95000, + propertyAddress: '456 Elm Ave, Shelbyville', + propertyId: 'PROP-002', + loanAmount: 280000, + downPayment: 56000, + }, + }, + { + name: 'Invalid address — appraisal fails', + application: { + applicationId: 'LOAN-003', + applicantName: 'Carol Davis', + ssn: '987-65-4321', + employerName: 'HealthPlus', + annualIncome: 105000, + propertyAddress: 'INVALID_ADDRESS', + propertyId: 'PROP-003', + loanAmount: 320000, + downPayment: 64000, + }, + }, + { + name: 'Multi-issue — bad employer + bad SSN + invalid address + high DTI', + application: { + applicationId: 'LOAN-009', + applicantName: 'Irene Tanaka', + ssn: '000-00-0000', + employerName: 'UNKNOWN_EMPLOYER', + annualIncome: 40000, + propertyAddress: 'INVALID_ADDRESS', + propertyId: 'PROP-009', + loanAmount: 600000, + downPayment: 5000, + }, + }, +]; + +async function run() { + const connection = await Connection.connect({ address: 'localhost:7233' }); + const client = new Client({ connection }); + + for (const scenario of scenarios) { + const handle = await client.workflow.start(homeLoanWorkflow, { + taskQueue: 'recoverable-activity', + workflowId: scenario.application.applicationId, + args: [scenario.application], + typedSearchAttributes: [ + { key: LoanStatusKey, value: 'STARTED' }, + { key: FailedActivityKey, value: '' }, + ], + }); + console.log(`Started: ${handle.workflowId} — ${scenario.name}`); + } +} + +run().catch((err) => { + console.error(err); + process.exit(1); +}); +``` + +Execute the client from your command line: + +```bash +npm run workflow +``` + +The script will complete and print the confirmation for each Workflow: + +``` +Started: LOAN-001 — Clean — all activities pass +Started: LOAN-002 — Bad SSN — credit check fails +Started: LOAN-003 — Invalid address — appraisal fails +Started: LOAN-009 — Multi-issue — bad employer + bad SSN + invalid address + high DTI +``` + +After this output appears, `LOAN-001` completes without pausing because all data is valid. +`LOAN-002` pauses at `runCreditCheck` because of the invalid Social Security number (SSN). +`LOAN-003` pauses at `orderAppraisal` because of the invalid address. +`LOAN-009` pauses at `verifyIncome` because of the unknown employer, and will require four separate corrections across four Activities to complete. + +### Send a corrective Signal + +To resume a paused Workflow, send a Signal containing the field name and corrected value. +You can do this through the demo UI, the web service API, or the Temporal CLI. + +#### Using the demo UI + +The dashboard shows all Workflows and their pipeline status. Click a blocked Workflow to inspect the failure details and send a correction. + +![Dashboard showing 3 blocked Workflows and 1 completed](assets/saga-pattern/keep-business-moving-list-view.png) + +Click a `PENDING_FIX` Workflow to open its detail panel. The UI shows the failure message and suggests a fix. Select the field to patch, enter the corrected value, and click **Patch and Retry** to send the Signal. + +![Sending a corrective Signal to fix an unknown employer](assets/saga-pattern/keep-business-moving-fix-employer.png) + +After the Signal is delivered, the Workflow wakes up, retries the failed Activity with the corrected data, and advances through the remaining pipeline steps. The detail panel shows the updated state including the fix history. + +![Workflow completed after fixing the employer name](assets/saga-pattern/keep-business-moving-success.png) + +You can also verify the full Event History — including the Signal and subsequent Activity completions — in the Temporal Web UI. + +![Event History in the Temporal Web UI showing the Signal and Activity completions](assets/saga-pattern/keep-business-moving-event-history.png) + +#### Using the Temporal CLI + +```bash +temporal workflow signal \ + --workflow-id LOAN-002 \ + --name retry \ + --input '{"key":"ssn","value":"222-33-4444"}' +``` + +#### Using the web service API + +```bash +curl -X POST http://localhost:3000/api/workflows/LOAN-002/fix \ + -H 'Content-Type: application/json' \ + -d '{"key":"ssn","value":"222-33-4444"}' +``` + +### Query pipeline status using Search Attributes + +Use the Temporal CLI or the web service to query blocked Workflows across the entire pipeline. + +Find all Workflows waiting for a fix: + +```bash +temporal workflow list --query "LoanStatus = 'PENDING_FIX'" +``` + +Find all Workflows blocked on credit checks: + +```bash +temporal workflow list \ + --query "LoanStatus = 'PENDING_FIX' AND FailedActivity = 'runCreditCheck'" +``` + +Using the web service search endpoint: + +```bash +curl "http://localhost:3000/api/workflows/search?failedActivity=runCreditCheck&status=PENDING_FIX" +``` + +These Queries return results in real time as Workflows update their Search Attributes. +An operations dashboard can poll these endpoints to display aggregate statistics: the number of applications awaiting correction, which Activities cause the most failures, and how long applications sit in `PENDING_FIX` status. + +This query capability supports automated resolution agents. +An AI agent can periodically query for Workflows blocked on a specific Activity, fetch the error message and application data through the Query API, apply a correction, and send the Signal. +The same Search Attribute interface that routes work to human operators also routes work to automated agents. + +### Query individual Workflow state + +Use the Query API to inspect the full internal state of any running Workflow without affecting its execution. + +```bash +temporal workflow query \ + --workflow-id LOAN-009 \ + --name getState +``` + +The Query returns the complete `LoanState` object: + +```json +{ + "status": "PENDING_FIX", + "failedActivity": "verifyIncome", + "failureMessage": "Employer \"UNKNOWN_EMPLOYER\" not found in verification database for Irene Tanaka", + "completedActivities": [], + "fixHistory": [], + "application": { + "applicationId": "LOAN-009", + "applicantName": "Irene Tanaka", + "ssn": "000-00-0000", + "employerName": "UNKNOWN_EMPLOYER", + "annualIncome": 40000, + "propertyAddress": "INVALID_ADDRESS", + "propertyId": "PROP-009", + "loanAmount": 600000, + "downPayment": 5000 + } +} +``` + +## Outcomes + +By following this guide, you have implemented a recoverable and compensatable pipeline including: + +- **Durable pause on permanent failure.** Activities throw `ApplicationFailure.nonRetryable()` to signal non-retryable failures — whether invalid data, a breached lending limit, or a blocked compliance check. The Workflow catches these errors and suspends using `await condition()`, consuming no Worker resources while waiting for correction. The Workflow resumes where it left off regardless of how much time passes. +- **Signal-driven recovery.** External operators or automated agents send corrective Signals containing the field name and new value. The Signal handler patches the application data in-place, records the correction in an auditable fix history, and wakes the Workflow to retry the failed Activity. +- **Saga compensation on abort.** When a `RollbackRequired` failure or a `cancelApplication` Signal aborts the forward pipeline, the Workflow unwinds registered compensations in LIFO order. Compensations are registered before execution and written to be idempotent so partial side effects are always cleaned up. Each compensation runs through the same recoverable wrapper, so a stuck rollback pauses with `ROLLBACK_PENDING_FIX` for operator intervention rather than leaving the process half-unwound. +- **Search Attribute routing.** Custom Search Attributes `LoanStatus` and `FailedActivity` are updated at every State Transition, creating a real-time queryable index across all active Workflows. Operations teams use visibility Queries to filter blocked Workflows by failure type and route them to the appropriate resolution resource, be it human or agents. +- **Full pipeline visibility.** Queries return the complete internal state of any running Workflow, including completed Activities, compensated Activities, fix history, compensation history, and the triggering cancellation reason. Search Attributes provide the denormalized aggregate view across all Workflows. Together, these mechanisms give you both the individual and cross-pipeline views needed to operate the system at scale. + +## Related resources +- [Source code](https://github.com/temporal-sa/validated-pattern-keep-business-moving) +- [Temporal TypeScript SDK Documentation](https://docs.temporal.io/develop/typescript) — Complete reference for building Workflows, Activities, and Workers with the TypeScript SDK. +- [Temporal Signals](https://docs.temporal.io/develop/typescript/message-passing#signals) — Guide to defining and sending Signals to running Workflow Executions. +- [Temporal Queries](https://docs.temporal.io/develop/typescript/message-passing#queries) — Guide to defining and handling synchronous read-only Queries on Workflows. +- [Temporal Search Attributes](https://docs.temporal.io/visibility#search-attribute) — Reference for creating and using custom Search Attributes for Workflow visibility. +- [Temporal Visibility](https://docs.temporal.io/visibility) — Overview of the Visibility subsystem and List Filter Query syntax. +- [ApplicationFailure Reference](https://docs.temporal.io/references/failures#application-failure) — API reference for `ApplicationFailure` including retryable and non-retryable error handling. \ No newline at end of file diff --git a/docs/guides/temporary-rate-limit-increases.mdx b/docs/guides/temporary-rate-limit-increases.mdx new file mode 100644 index 0000000000..5e1d347d5e --- /dev/null +++ b/docs/guides/temporary-rate-limit-increases.mdx @@ -0,0 +1,729 @@ +--- +id: temporary-rate-limit-increases +title: Orchestrate temporary rate limit increases on your Namespaces +description: Handle temporary spikes in usage by dynamically provisioning extra capacity when you need it, and deprovision it when you don't. +sidebar_label: Temporary rate limit increases +toc_max_heading_level: 3 +author: Taylor Khan +tags: + - Workflows + - Activities + - Timers +--- + +**by Taylor Khan** + +This pattern provides the steps to dynamically provision and automatically deprovision capacity on Temporal Cloud. + +Applications often experience predictable or temporary spikes in throughput that require additional Temporal Cloud capacity. This pattern demonstrates how to grant time-limited increases to your capacity limits and guarantee release of those resources after a specific duration. + +Permanently allocating peak capacity requirements for a namespace results in unnecessary costs. However, manually adjusting capacity quotas before and after load spikes is prone to human error, risking either workflow throttling if limits are not raised in time, or runaway costs if operators forget to reduce limits after the workload subsides. + +You will use Temporal Workflows to orchestrate the provisioning process. A parent Workflow executes an Activity to raise the capacity limit, then starts an asynchronous Child Workflow configured with an abandon policy. The parent Workflow completes to unblock the client, while the Child Workflow waits for a designated duration before executing an Activity to revert the capacity to its original limit. + +By completing this pattern, you will: + +- Automate capacity management to reduce Temporal Cloud costs. +- Guarantee the execution of cleanup operations using durable Timers. +- Unblock client requests while long-running deprovisioning tasks continue in the background. + +## Background and best practices + +Temporal Cloud offers different capacity modes to accommodate varying workloads. When using provisioned capacity, you define the maximum throughput your Namespace can consume. Modifying this limit via an API call allows you to scale resources up for intensive tasks. + +The primary architectural challenge is ensuring that temporary capacity increases revert reliably. Temporal solves this by persisting the state of your Workflow, including durably storing and starting Timers. + +By separating the provisioning and deprovisioning steps into distinct Workflows, you adhere to the best practice of returning control to the caller as soon as the provisioning command succeeds. The `ParentClosePolicyAbandon` setting instructs the Temporal Service to allow the Child Workflow to continue running even after the parent completes. This decouples the client's synchronous request from the long-running *Time-To-Live (TTL)* wait state. When the Child Workflow enters a sleep state, it consumes no Worker memory, allowing you to scale this pattern across thousands of concurrent requests efficiently. + +**Note:** Latency to the caller can be further reduced via [the Early Return pattern paired with an SDK call to update-with-start](https://docs.temporal.io/sending-messages#update-with-start). + +## Target audience + +This guide references the following roles: + +- **Temporal Workflow and Activity developers**: Implement the Go code for the Workflows and Activities and deploy the Worker processes. +- **Platform operators**: Maintain the Temporal Namespace configuration and monitor usage against provisioned capacity limits. + +## Prerequisites + +To execute the steps in this pattern, you must have: + +- **Required software, infrastructure, and tools:** Temporal Go SDK v1.40.0 or later, Go v1.23 or later, Temporal CLI v.1.6.1 or later. +- **Resources & Access Privileges:** Temporal Cloud Account with Admin role for the target Namespace to access the Cloud Operations API. + - You will need to create an API key using your Temporal Cloud account to access the Cloud Operations API +- **Required Concepts:** + - Familiarity with + - Temporal Workflows + - Temporal Activities + - Temporal Child Workflows + - Temporal Timers + - Temporal Workers + - Temporal Cloud Namespaces + +**Note:** This pattern uses the Temporal Go SDK and it is necessary for direct replication. However, any Temporal SDK supported by Temporal Cloud is sufficient to achieve the same outcomes outlined in this document. + +## People & process considerations + +### Platform operators + +The **Platform operators** own the Temporal Cloud Namespace and API keys. This team is responsible for the following: + +1. Generate and manage Temporal Cloud API keys required for the provisioning Activities. +2. Monitor overall throughput and ensure the base provisioned capacity meets everyday operational needs. + +### Application developers + +The **Application developers** are the primary authors of the Temporal Workflows and Activities. This team will have the following responsibilities: + +1. Implement and test the Temporal code outlined in this pattern. +2. Configure Worker scaling policies to handle the expected frequency of capacity adjustment requests. + +## Architecture diagram + +![Architecture diagram for temporary rate limit increases](assets/temporary-rate-limit-increases/orchestrate-temporary-rate-limit-increases.svg) + +The following diagram illustrates the flow of the requests to provision capacity and handle automatic cleanup after a 60 minute time-to-live. + +1. A client application sends a gRPC request to the `my-service` application with the requested limit and namespace. +2. The `my-service` application uses the Temporal SDK client to start the `provisionTRU` Workflow. +3. The `provisionTRU` Workflow schedules the `addTRUs` Activity and waits for it to complete. +4. The `provisionTRU` Workflow starts the `deprovisionTRU` asynchronous Child Workflow using a parent close policy of abandon. +5. The `provisionTRU` Workflow completes, and `my-service` returns a success response to the client. +6. The `deprovisionTRU` Child Workflow sets a Timer for 60 minutes. +7. The Timer fires after the TTL expires. +8. The `deprovisionTRU` Child Workflow schedules the `removeTRUs` Activity to revert the capacity limits. + +## Implementation plan + +In this implementation plan, you will build and verify the capacity management solution. You will begin by defining the required Activities to interact with the Temporal Cloud. Next, you will create the asynchronous Child Workflow responsible for the delayed cleanup. You will then orchestrate the process with the parent Workflow and expose it through a service handler. Finally, you will manually trigger the Workflow to simulate a client request and write tests using the Temporal Go SDK test suite to validate your orchestration logic locally. + +Prior to executing this plan, ensure you have your Temporal Cloud API key and your target Namespace name documented + +### Define messages for Activities and Workflows + +Create the messages for passing to Temporal Workflows and Activities. It is a best practice to use a single serializable input to Workflows and Activities. You will create one for each Activity and Workflow, and helper functions for generating unique identifiers for each capacity request. + +```go +// capacity/messages.go +package capacity + +import "fmt" + +type ProvisionTRUInput struct { + Namespace string + APSLimit int32 + MinutesToProvision int32 +} + +type DeprovisionTRUInput struct { + Namespace string + MinutesToProvision int32 +} + +type AddTRUInput struct { + Namespace string + APSLimit int32 +} + +type RemoveTRUInput struct { + Namespace string +} + +func generateProvisioningId(namespace string) string { + return fmt.Sprintf("provision-%s", namespace) +} + +func generateDeprovisioningId(namespace string) string { + return fmt.Sprintf("deprovision-%s", namespace) +} + +``` + +### Define the Activities + +You must create functions that interact with the Temporal Cloud Operations API to adjust capacity. Temporal Activities encapsulates these functions to guarantee retries in case of intermittent failures. + +```go +// capacity/activities.go +package capacity + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "go.temporal.io/sdk/temporal" + "io" + "net/http" + "strconv" +) + +const ( + defaultBaseURL = "https://saas-api.tmprl.cloud" +) + +// Activities holds the dependencies required by the provisioning activities. +type Activities struct { + HTTPClient *http.Client + BaseURL string + APIKey string +} + +type getNamespaceResponse struct { + Namespace struct { + Spec json.RawMessage `json:"spec"` + ResourceVersion string `json:"resourceVersion"` + } `json:"namespace"` +} + +type updateNamespaceRequest struct { + Spec json.RawMessage `json:"spec"` + ResourceVersion string `json:"resourceVersion"` +} + +func (a *Activities) baseURL() string { + if a.BaseURL != "" { + return a.BaseURL + } + return defaultBaseURL +} + +func (a *Activities) httpClient() *http.Client { + if a.HTTPClient != nil { + return a.HTTPClient + } + return http.DefaultClient +} + +func (a *Activities) getNamespace(ctx context.Context, namespace string) (json.RawMessage, string, error) { + url := fmt.Sprintf("%s/cloud/namespaces/%s", a.baseURL(), namespace) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, "", err + } + req.Header.Set("Authorization", "Bearer "+a.APIKey) + + resp, err := a.httpClient().Do(req) + if err != nil { + return nil, "", err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, "", err + } + if resp.StatusCode != http.StatusOK { + msg := fmt.Sprintf("%s", body) + return nil, "", temporal.NewApplicationError(msg, strconv.Itoa(resp.StatusCode)) + } + var result getNamespaceResponse + if err := json.Unmarshal(body, &result); err != nil { + return nil, "", err + } + return result.Namespace.Spec, result.Namespace.ResourceVersion, nil +} + +func (a *Activities) updateNamespace(ctx context.Context, namespace string, spec json.RawMessage, resourceVersion string) error { + url := fmt.Sprintf("%s/cloud/namespaces/%s", a.baseURL(), namespace) + + payload, err := json.Marshal(updateNamespaceRequest{ + Spec: spec, + ResourceVersion: resourceVersion, + }) + if err != nil { + return err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload)) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+a.APIKey) + req.Header.Set("Content-Type", "application/json") + + resp, err := a.httpClient().Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + msg := fmt.Sprintf("%s", body) + return temporal.NewApplicationError(msg, strconv.Itoa(resp.StatusCode)) + } + return nil +} + +// AddTRUs increases the provisioned capacity of the target namespace to the requested APS limit. +func (a *Activities) AddTRUs(ctx context.Context, input AddTRUInput) error { + spec, resourceVersion, err := a.getNamespace(ctx, input.Namespace) + if err != nil { + return fmt.Errorf("get namespace %q: %w", input.Namespace, err) + } + + var specMap map[string]interface{} + if err := json.Unmarshal(spec, &specMap); err != nil { + return fmt.Errorf("unmarshal namespace spec: %w", err) + } + specMap["capacitySpec"] = map[string]interface{}{ + "provisioned": map[string]interface{}{ + // 1 TRU = 500 APS; convert the requested APS limit to TRUs. + "value": float64(input.APSLimit) / 500.0, + }, + } + + updatedSpec, err := json.Marshal(specMap) + if err != nil { + return fmt.Errorf("marshal updated spec: %w", err) + } + + if err := a.updateNamespace(ctx, input.Namespace, updatedSpec, resourceVersion); err != nil { + return err + } + return nil +} + +// RemoveTRUs reverts the target namespace to on-demand capacity mode. +func (a *Activities) RemoveTRUs(ctx context.Context, input RemoveTRUInput) error { + spec, resourceVersion, err := a.getNamespace(ctx, input.Namespace) + if err != nil { + return err + } + + var specMap map[string]interface{} + if err := json.Unmarshal(spec, &specMap); err != nil { + return fmt.Errorf("unmarshal namespace spec: %w", err) + } + specMap["capacitySpec"] = map[string]interface{}{ + "onDemand": map[string]interface{}{}, + } + + updatedSpec, err := json.Marshal(specMap) + if err != nil { + return fmt.Errorf("marshal updated spec: %w", err) + } + + if err := a.updateNamespace(ctx, input.Namespace, updatedSpec, resourceVersion); err != nil { + return err + } + return nil +} +``` + +The `AddTRUs` and `RemoveTRUs` methods define the actions taken to alter the capacity. By grouping these as methods on a `Activities` struct, you can inject dependencies like API clients or logger instances. Both methods accept a struct, which contains the target namespace and the desired limit. + +## Implement the deprovisioning Child Workflow + +To ensure capacity reverts after the given duration without consuming active computing resources, you will define a Workflow that sleeps for the specified duration and then executes the Activity that deprovisions the capacity. + +```go +// capacity/deprovision_workflow.go +package capacity + +import ( + "go.temporal.io/sdk/temporal" + "time" + + "go.temporal.io/sdk/workflow" +) + +// DeprovisionTRUWorkflow sleeps for the TTL duration then reverts the namespace +// to on-demand capacity. It is started as an asynchronous Child Workflow by +// ProvisionTRUWorkflow and runs independently after the parent completes. +func DeprovisionTRUWorkflow(ctx workflow.Context, input DeprovisionTRUInput) error { + err := workflow.Sleep(ctx, time.Duration(input.MinutesToProvision)*time.Minute) + if err != nil { + return err + } + + ao := workflow.ActivityOptions{ + StartToCloseTimeout: 2 * time.Minute, + RetryPolicy: &temporal.RetryPolicy{ + NonRetryableErrorTypes: []string{unauthorized, forbidden, badRequest}, + }, + } + ctx = workflow.WithActivityOptions(ctx, ao) + + var a *Activities + return workflow.ExecuteActivity(ctx, a.RemoveTRUs, input).Get(ctx, nil) +} +``` + +## Implement the provisioning Workflow + +You will now create the main orchestration Workflow. This Workflow applies the capacity increase, and sets the deprovisioning process to run with an "abandon policy," so it will run regardless of what happens to the parent Workflow. + +```go +// capacity/provision_workflow.go +package capacity + +import ( + "go.temporal.io/sdk/temporal" + "net/http" + "strconv" + "time" + + "go.temporal.io/api/enums/v1" + "go.temporal.io/sdk/workflow" +) + +var ( + unauthorized = strconv.Itoa(http.StatusUnauthorized) + forbidden = strconv.Itoa(http.StatusForbidden) + badRequest = strconv.Itoa(http.StatusBadRequest) +) + +// ProvisionTRUWorkflow raises the namespace capacity limit, then starts the +// DeprovisionTRUWorkflow as an asynchronous Child Workflow with an abandon +// policy so the parent can return immediately while cleanup runs independently. +func ProvisionTRUWorkflow(ctx workflow.Context, input ProvisionTRUInput) error { + ao := workflow.ActivityOptions{ + StartToCloseTimeout: 2 * time.Minute, + RetryPolicy: &temporal.RetryPolicy{ + NonRetryableErrorTypes: []string{unauthorized, forbidden, badRequest}, + }, + } + actCtx := workflow.WithActivityOptions(ctx, ao) + + var a *Activities + err := workflow.ExecuteActivity(actCtx, a.AddTRUs, AddTRUInput{ + Namespace: input.Namespace, + APSLimit: input.APSLimit, + }).Get(ctx, nil) + if err != nil { + return err + } + + cwo := workflow.ChildWorkflowOptions{ + WorkflowID: generateDeprovisioningId(input.Namespace), + ParentClosePolicy: enums.PARENT_CLOSE_POLICY_ABANDON, + } + childCtx := workflow.WithChildOptions(ctx, cwo) + + childFuture := workflow.ExecuteChildWorkflow(childCtx, DeprovisionTRUWorkflow, DeprovisionTRUInput{ + Namespace: input.Namespace, + MinutesToProvision: input.MinutesToProvision, + }) + + var childExec workflow.Execution + err = childFuture.GetChildWorkflowExecution().Get(ctx, &childExec) + if err != nil { + return err + } + + return nil +} +``` + +The `ProvisionTRUWorkflow` orchestrates the entire request. First, it synchronously waits for `AddTRUs` to complete to ensure the capacity is available before returning. Second, it configures `workflow.ChildWorkflowOptions` with `enums.PARENT_CLOSE_POLICY_ABANDON`. This prevents the Temporal Service from cancelling the Child Workflow when this parent completes. Finally, the code uses `GetChildWorkflowExecution().Get()` to block execution just until the Temporal Service confirms the Child Workflow has started. Once started, the parent completes, leaving the child to run independently. + +## Implement the service handler + +Connect the Workflow to your application's API layer. You will write the code that receives the incoming request and triggers the Workflow. + +```go +// capacity/handler.go +package capacity + +import ( + "context" + "errors" + "fmt" + "go.temporal.io/sdk/client" +) + +const TaskQueue = "capacity-management" + +// HandleProvisionRequest starts a ProvisionTRUWorkflow execution and returns +// after the Temporal Cluster accepts the request. It does not wait +// for the Workflow to complete. +func HandleProvisionRequest(c client.Client, namespace string, apsLimit int32, minutesToProvision int32) error { + id := generateProvisioningId(namespace) + preExistingRun := c.GetWorkflow(context.Background(), id, "").GetRunID() + if preExistingRun != "" { + return errors.New("provisioning request already in-progress") + } + deprovisionId := fmt.Sprintf("deprovision-%s", namespace) + preExistingDeprovisionRun := c.GetWorkflow(context.Background(), deprovisionId, "").GetRunID() + if preExistingDeprovisionRun != "" { + err := c.CancelWorkflow(context.Background(), deprovisionId, preExistingDeprovisionRun) + if err != nil { + return errors.Join(errors.New("unable to cancel deprovisioning workflow"), err) + } + } + options := client.StartWorkflowOptions{ + ID: id, + TaskQueue: TaskQueue, + } + + input := ProvisionTRUInput{ + Namespace: namespace, + APSLimit: apsLimit, + MinutesToProvision: minutesToProvision, + } + + we, err := c.ExecuteWorkflow(context.Background(), options, ProvisionTRUWorkflow, input) + if err != nil { + return fmt.Errorf("failed to start workflow: %w", err) + } + + fmt.Printf("Successfully started provision workflow. ID: %s, RunID: %s\n", + we.GetID(), we.GetRunID()) + return nil +} +``` + +The `HandleProvisionRequest` function represents your gRPC or HTTP endpoint. It prepares the `client.StartWorkflowOptions` and calls `ExecuteWorkflow`. Because it uses `ExecuteWorkflow` rather than waiting on the result with `.Get()`, the function returns an acknowledgment to the client after the Temporal Service accepts the Workflow initiation. `MinutesToProvision` determines how long the `DeprovisionTRUWorkflow` waits before provisioned capacity reverts. + +## Create the Worker Program + +Now that you have built the Activities and Workflows here is the complete set of imports and definitions required to register and run the system on a Worker. + +```go +// cmd/worker.go +package main + +import ( + "github.com/temporal-sa/temporary-rate-limit-increases/capacity" + "go.temporal.io/sdk/client" + "go.temporal.io/sdk/worker" + "log" + "os" +) + +func main() { + apiKey := os.Getenv("TEMPORAL_CLOUD_API_KEY") + if apiKey == "" { + log.Fatalln("TEMPORAL_CLOUD_API_KEY missing and required") + } + + c, err := client.Dial(client.Options{}) + if err != nil { + log.Fatalln("Unable to create Temporal client", err) + } + defer c.Close() + + w := worker.New(c, capacity.TaskQueue, worker.Options{}) + + w.RegisterWorkflow(capacity.ProvisionTRUWorkflow) + w.RegisterWorkflow(capacity.DeprovisionTRUWorkflow) + + activities := &capacity.Activities{ + APIKey: apiKey, + } + w.RegisterActivity(activities) + + err = w.Run(worker.InterruptCh()) + if err != nil { + log.Fatalln("Unable to start worker", err) + } +} +``` + +Run the following commands sequentially, each in their own terminal: + +```bash +temporal server start-dev +``` + +```bash +export TEMPORAL_CLOUD_API_KEY={YOUR_TEMPORAL_CLOUD_API_KEY} +go run cmd/worker.go +``` + +## Test the Workflow execution + +To verify the capacity provisioning process, you must simulate the client request. You will write a short script that initializes a Temporal client, defines the requested limits, and calls your service handler to trigger the parent Workflow. Replace `{TEST_NAMESPACE}` with the target namespace you identified in the prerequisites section. + +```go +// cmd/trigger.go +package main + +import ( + "github.com/temporal-sa/temporary-rate-limit-increases/capacity" + "go.temporal.io/sdk/client" + "log" + "os" + "strconv" +) + +func main() { + c, err := client.Dial(client.Options{}) + if err != nil { + log.Fatalln("Unable to create Temporal client", err) + } + defer c.Close() + + targetNamespace := os.Getenv("TEMPORAL_CLOUD_NAMESPACE") + if targetNamespace == "" { + log.Fatalln("TEMPORAL_CLOUD_NAMESPACE missing and required") + } + minutesToProvisionRaw := os.Getenv("MINUTES_TO_PROVISION") + if minutesToProvisionRaw == "" { + log.Fatalln("MINUTES_TO_PROVISION missing and required") + } + minutesToProvision, err := strconv.Atoi(minutesToProvisionRaw) + if err != nil { + log.Fatalln("Unable to parse MINUTES_TO_PROVISION: " + err.Error()) + } + var newLimit int32 = 1000 + + err = capacity.HandleProvisionRequest(c, targetNamespace, newLimit, int32(minutesToProvision)) + if err != nil { + log.Fatalln("Unable to execute provision request", err) + } +} +``` + +The `main` function connects to the Temporal Service using `client.Dial`. It defines the `targetNamespace` and `newLimit` variables to represent the data payload that a gRPC request would contain. It then passes the client and these variables to the `HandleProvisionRequest` function. By executing this file while your Worker is running, the Temporal Service will start the `ProvisionTRUWorkflow**.**` + +Execute the script from your command line: + +```bash +export MINUTES_TO_PROVISION=5 +export TEMPORAL_CLOUD_NAMESPACE={YOUR_TEMPORAL_NAMESPACE} +go run trigger.go +``` + +The script will complete and print the confirmation from the handler: + +```bash +Started provision workflow. ID: provision-production-workload, RunID: {RUN_ID} +``` + +After this output appears, the Worker continues to process the provisioning Activity and the 5-minute sleep Timer in the background. + +**Note:** a 5-minute sleep time is used for testing the Workflow end-to-end. Set `MINUTES_TO_PROVISION` to your needs in production environments. We recommend at least 60 minutes. + +## Unit test the Workflow + +Verify that the Workflow orchestrates the Activities and Child Workflow without interacting with the live Temporal Service. The Temporal Go SDK provides a test environment that simulates time and cluster behavior, allowing you to validate your code logic locally. + +```go +// capacity/provision_workflow_test.go +package capacity + +import ( + "testing" + + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "go.temporal.io/sdk/testsuite" +) + +func Test_ProvisionTRUWorkflow(t *testing.T) { + testSuite := &testsuite.WorkflowTestSuite{} + env := testSuite.NewTestWorkflowEnvironment() + + env.RegisterWorkflow(DeprovisionTRUWorkflow) + + var a *Activities + env.OnActivity(a.AddTRUs, mock.Anything, mock.Anything).Return(nil) + + input := ProvisionTRUInput{ + Namespace: "test-namespace", + APSLimit: 2000, + MinutesToProvision: 5, + } + + env.ExecuteWorkflow(ProvisionTRUWorkflow, input) + + require.True(t, env.IsWorkflowCompleted()) + require.NoError(t, env.GetWorkflowError()) + env.AssertExpectations(t) +} +``` + +The `Test_ProvisionTRUWorkflow` function initializes a `testsuite.WorkflowTestSuite` and creates a `TestWorkflowEnvironment`. This environment simulates the Temporal Service in memory. Because the parent Workflow starts a Child Workflow asynchronously, you must register `DeprovisionTRUWorkflow` with the test environment so it does not fail when invoked. + +You then use `env.OnActivity` to mock the `AddTRUs` Activity, instructing it to return `nil` without making real network calls. After calling `env.ExecuteWorkflow`, the test uses `require.True` and `require.NoError` to guarantee the parent Workflow completes. Finally, `env.AssertExpectations` confirms that the mocked Activity executed as defined. + +Execute the test from your command line: + +```bash +go test -v provision_workflow_test.go provision_workflow.go deprovision_workflow.go activities.go +``` + +The terminal will output the test results: + +```bash +=== RUN Test_ProvisionTRUWorkflow +--- PASS: Test_ProvisionTRUWorkflow (0.01s) +PASS +``` + +Additional log lines from the test results are a non-issue. + +## Unit test the deprovisioning Workflow + +When testing long-running Workflows, waiting for real time to pass is inefficient. You must verify that the `DeprovisionTRUWorkflow` schedules the `RemoveTRUs` Activity after the 60-minute Timer without actually waiting an hour during your test execution. + +```go +// capacity/deprovision_workflow_test.go +package capacity + +import ( + "testing" + + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "go.temporal.io/sdk/testsuite" +) + +func Test_DeprovisionTRUWorkflow(t *testing.T) { + testSuite := &testsuite.WorkflowTestSuite{} + env := testSuite.NewTestWorkflowEnvironment() + + var a *Activities + env.OnActivity(a.RemoveTRUs, mock.Anything, mock.Anything).Return(nil) + + input := DeprovisionTRUInput{ + Namespace: "test-namespace", + MinutesToProvision: 5, + } + + env.ExecuteWorkflow(DeprovisionTRUWorkflow, input) + + require.True(t, env.IsWorkflowCompleted()) + require.NoError(t, env.GetWorkflowError()) + env.AssertExpectations(t) +} +``` + +The `Test_DeprovisionTRUWorkflow` function uses the `testsuite.WorkflowTestSuite` to run the Child Workflow. When a Timer blocks a Workflow execution the Temporal test environment automatically skips time forward, such as the `workflow.Sleep` command used in your code. This mechanism causes the 60-minute sleep to complete instantly within the test framework. + +You configure `env.OnActivity` to mock the `RemoveTRUs` Activity, ensuring the test does not make live API calls to Temporal Cloud. After execution, `require.True` confirms the Workflow finished, and `env.AssertExpectations` guarantees that the deprovisioning Activity executed as expected after the simulated time elapsed. + +Execute the test from your command line: + +```bash +go test -v deprovision_workflow_test.go deprovision_workflow.go activities.go +``` + +The terminal will output the test results: + +```bash +=== RUN Test_DeprovisionTRUWorkflow +--- PASS: Test_DeprovisionTRUWorkflow (0.01s) +PASS +ok command-line-arguments 0.015s +``` + +Additional log lines from the test results are a non-issue. + +## Outcomes + +By following this guide, you have implemented a time-bound capacity provisioning system including: + +- Requesting increased limits through a retryable Temporal Activity. +- Using an asynchronous Child Workflow to decouple application responses from long-term cleanup tasks. +- Using durable Timers to guarantee that capacity limits are strictly reverted after 60 minutes, ensuring you do not overpay for unused compute. + +## Related Resources + +- [Temporal Capacity Modes](https://docs.temporal.io/cloud/capacity-modes) +- [Temporal Best Practices](https://docs.temporal.io/best-practices) +- [Python Capacity Mode Sample](https://github.com/lainecsmith/temporal-cloud-ops-capacity-modes) +- [Go Capacity Mode Sample](https://github.com/temporal-sa/temporary-rate-limit-increases) \ No newline at end of file diff --git a/docs/guides/worker-execution-affinity.mdx b/docs/guides/worker-execution-affinity.mdx new file mode 100644 index 0000000000..76b89af052 --- /dev/null +++ b/docs/guides/worker-execution-affinity.mdx @@ -0,0 +1,502 @@ +--- +id: worker-execution-affinity +title: Ensure Activity execution on the same Worker +description: Execute multiple Activities on thee same Worker to guarantee data locality. +sidebar_label: Worker execution affinity +toc_max_heading_level: 3 +author: Cecil Phillip +tags: + - Task Queues + - Routing + - Workers + - Resource requirements + - GPU computing +--- + +**by Cecil Phillip** + +### Problem statement + +Many Workflows require multiple Activities to execute on the same Worker to maintain data locality. Common scenarios include: + +- **File processing:** Download a file (Activity 1), process it (Activity 2), and upload it (Activity 3). The file exists on the Worker's local disk, and requiring another Worker to re-download multi-GB files from the original storage (or via an object store) is slow and expensive. +- **ML model caching:** Load a large ML model into memory (Activity 1), then run multiple inference calls (Activities 2-N) using the cached model. Loading the model on each Worker wastes time and memory. +- **Database connection pooling:** Establish expensive database connections that should be reused across multiple Activities in the same Workflow Execution. + +Without Worker affinity, Temporal distributes Activities across available Workers. This means: +- Files downloaded in Activity 1 aren't available for Activity 2 (different Worker) +- Network transfer overhead: 10GB video file must be re-fetched from the original storage or an object store (incurs large network transfer and egress costs) +- Duplicate resource initialization: ML models loaded multiple times across Workers +- Increased latency: Each Activity pays setup costs instead of reusing resources + +### Solution + +Use Worker-specific Task Queues to ensure all Activities in a Workflow execute on the same Worker. To achieve this: + +1. Each Worker polls two Activity Task Queues: a shared queue and a unique queue (generated per Worker instance) +2. The Workflow calls an Activity on the shared queue to discover an available Worker's unique queue name +3. The Workflow routes all subsequent Activities to that Worker's unique queue +4. All Activities execute on the same Worker, maintaining data locality + +### Outcomes + +- **Data locality:** Files downloaded in one Activity are immediately available to subsequent Activities on the same Worker +- **Performance:** Eliminate repeated re-downloads from remote storage (multi-GB transfers) +- **Resource efficiency:** Load expensive resources (ML models, DB connections) once per Workflow instead of per Activity +- **Cost reduction:** Reduce network egress costs from repeated downloads from remote/object storage + +## Background and best practices + +### Task Queue fundamentals + +Task Queues in Temporal are dynamically created when first referenced. With this pattern a unique Task Queue is created per Worker instance (e.g., `file-processing-abc123`) that only that Worker polls. + +**Recommended practice:** Generate unique queue names using UUIDs (or the hostname if running in a containerized environment) to avoid collisions across Worker instances. + +### Worker-specific vs Worker sessions + +- **Go SDK** has a built-in [Worker Sessions API](https://docs.temporal.io/develop/go/sessions) that handles Worker-specific routing automatically +- **Other SDKs** (Python, TypeScript, etc.) must implement the pattern manually using unique Task Queue names + +This pattern provides the same guarantees as Go's Sessions API for non-Go SDKs. + +### Worker failure handling + +If a Worker crashes while processing Activities on its unique queue: +- Running activities will timeout after `heartbeat_timeout` (if configured) or `start_to_close_timeout` +- Retries and pending activities will wait in the unique queue until `schedule_to_start_timeout` expires +- To recover, the Workflow catches the timeout error and can route to a different Worker (on a new unique task queue) + +**Recommended practice:** Set a short `heartbeat_timeout` (e.g., 30s) to detect crashes quickly, and a short `schedule_to_start_timeout` (e.g., 1m) to stop waiting on dead queues. + +**Recommended practice:** Set appropriate `schedule_to_start_timeout` values to detect Worker failures quickly (e.g., 5 minutes for file processing). + +> Activity Executions in most Workflows are constrained by the Start-to-Close Timeout, which limits the maximum duration of a single attempt. Its value is set to slightly longer than the Activity should take to complete. The pattern described here also relies on the Schedule-to-Start Timeout, which limits the maximum amount of time that a Task may remain enqueued. Although otherwise seldom used, this Timeout is valuable here because it enables the system to detect a Worker crash. That is, when a Worker crashes, it will no longer dequeue Tasks and the Schedule-to-Start Timeout will be reached. + +### Operational considerations + +- **Queue proliferation:** Each Worker creates a unique queue. Monitor total queue count. +- **Queue lifecycle:** Consider setting a reasonable `schedule_to_close_timeout` on Activities to bound how long work tied to a unique queue can remain active, or implement explicit cleanup job to remove stale unique queues when Workers terminate. +- **Worker scaling:** When scaling down, ensure Workers complete in-progress Workflows before [termination](https://docs.temporal.io/encyclopedia/workers/worker-shutdown) +- **Monitoring:** Track Activities stuck in unique queues (indicates Worker crash/unavailability) + +## Target audience + +- **Temporal Workflow & Activity developers:** Implementing file processing and data-local Workflows +- **Platform operators:** Deploying and monitoring Worker-specific queue patterns +- **Data engineers:** Building ETL pipelines with large file processing +- **ML Engineers:** Deploying inference Workflows with model caching + +This implementation requires code changes to Workers and Workflows, and consideration for Worker lifecycle management. + +## Prerequisites + +### Required software, infrastructure, and tools + +- Temporal Service (Self-hosted or Temporal Cloud) +- Python 3.8 or later +- Temporal Python SDK v1.0.0 or later (`pip install temporalio`) +- File storage accessible to Workers (local disk, shared filesystem, or cloud storage) + +### Resources & access privileges + +- Temporal Namespace with permissions to start Workflows and register Workers +- File storage with appropriate read/write permissions for Workers +- Sufficient disk space on Worker hosts for file processing + +### Required concepts + +- Temporal Workflows, Activities, and Task Queues +- Python async/await patterns +- File I/O operations +- Basic understanding of Worker lifecycle + +## Architecture diagram(s) + +### Worker-specific Task Queue pattern + +```mermaid +sequenceDiagram + participant WF as Workflow + participant Shared as file-processing-shared
Task Queue + participant W1 as Worker 1
(UUID: abc123) + participant Unique as file-processing-abc123
Task Queue + participant Disk as Worker 1
Local Disk + + Note over WF: Need to process file
on same worker + + WF->>Shared: get_available_task_queue() + Shared->>W1: Activity dispatched + W1-->>WF: "file-processing-abc123" + + Note over WF: Route all activities
to Worker 1's unique queue + + WF->>Unique: download_file(url) + Unique->>W1: Routed to Worker 1 + W1->>Disk: Save file to /tmp/video.mp4 + Note over Disk: 10GB video file + W1-->>WF: "/tmp/video.mp4" + + WF->>Unique: process_file(path) + Unique->>W1: Same worker + W1->>Disk: Read /tmp/video.mp4 + Note over W1: Process locally
No network transfer! + W1->>Disk: Write /tmp/video-processed.mp4 + W1-->>WF: "/tmp/video-processed.mp4" + + WF->>Unique: upload_file(path) + Unique->>W1: Same worker + W1->>Disk: Read /tmp/video-processed.mp4 + Note over W1: Upload to S3 + W1->>Disk: Clean up files + W1-->>WF: "https://s3.../video.mp4" +``` + +## Implementation + +### Step 1: Define Task Queue constants + +**File: `task_queues.py`** + +```python +"""Task Queue constants for worker-specific routing.""" + +# Shared queue for discovering available workers +FILE_PROCESSING_SHARED_QUEUE = "file-processing-shared" + +# Note: Unique per-worker queues are generated at runtime +# Pattern: f"file-processing-{uuid.uuid4()}" +``` + +### Step 2: Configure Worker with shared and unique queues + +Each Worker polls two queues: +1. Shared queue: Returns this Worker's unique queue name +2. Unique queue: Handles file processing Activities + +**File: `worker_file_processing.py`** + +```python +"""Worker with unique task queue for file processing affinity.""" +import asyncio +import hashlib +import httpx +import uuid +import logging +import os +from pathlib import Path +from temporalio.client import Client +from temporalio.exceptions import ApplicationError +from temporalio.worker import Worker +from temporalio import activity + +from task_queues import FILE_PROCESSING_SHARED_QUEUE + +logging.basicConfig(level=logging.INFO) + +# Generate a unique Task Queue name for this worker instance +UNIQUE_WORKER_TASK_QUEUE = f"file-processing-{uuid.uuid4()}" + + +@activity.defn +async def get_available_task_queue() -> str: + """ + Return this worker's unique task queue name. + + The Workflow calls this on the shared queue to discover a Worker's + unique queue, then routes all subsequent file operations to that queue. + """ + activity.logger.info(f"Returning unique queue: {UNIQUE_WORKER_TASK_QUEUE}") + return UNIQUE_WORKER_TASK_QUEUE + + +@activity.defn +async def download_file(url: str) -> str: + """ + Download file from URL and save to local disk. + + Returns the local file path. Subsequent activities on the same Worker + can access this file without network transfer. + """ + + local_path = f"/tmp/temporal_file_{uuid.uuid4()}" + + activity.logger.info(f"Downloading {url} to {local_path}") + + async with httpx.AsyncClient() as client: + response = await client.get(url, timeout=300.0) + activity.heartbeat(f"Downloading {url}") + + # Raise exception for 4xx/5xx errors + # By default, Temporal retries all application failures. + # Treat 4xx errors as non retryable + try: + response.raise_for_status() + except httpx.HTTPStatusError as e: + if 400 <= e.response.status_code < 500: + raise ApplicationError(f"Client error downloading file: {e}", non_retryable=True) from e + raise e + + # Use asyncio.to_thread to avoid blocking the event loop + await asyncio.to_thread(Path(local_path).write_bytes, response.content) + + activity.logger.info( + f"Downloaded {len(response.content)} bytes to {local_path}" + ) + + return local_path + + +@activity.defn +async def process_file(local_path: str) -> str: + """ + Process the file that was downloaded by previous activity. + + Because this runs on the same worker, the file is already present + on the local disk - no network transfer needed. + """ + activity.logger.info(f"Processing file at {local_path}") + + # Read the local file (async to avoid blocking the event loop) + content = await asyncio.to_thread(Path(local_path).read_bytes) + + # Simulate processing (e.g., video transcoding, image transformation) + # Send periodic heartbeats to detect worker crashes quickly + processing_duration = 3 # seconds (simulated; real work could be minutes/hours) + heartbeat_interval = 1 # seconds + + elapsed = 0 + while elapsed < processing_duration: + await asyncio.sleep(heartbeat_interval) + elapsed += heartbeat_interval + # Send heartbeat periodically to prove the worker is alive + activity.heartbeat(f"Processing file at {local_path} - {elapsed}s elapsed") + + # Compute checksum as proof of processing + checksum = hashlib.sha256(content).hexdigest() + + result_path = f"{local_path}.processed" + activity.heartbeat(f"Writing results to {result_path}") + await asyncio.to_thread(Path(result_path).write_text, f"Processed. Checksum: {checksum}") + + activity.logger.info(f"Processed file, checksum: {checksum[:16]}...") + + return result_path + + +@activity.defn +async def upload_file(local_path: str) -> str: + """ + Upload the processed file to destination. + + Reads from local disk (no network transfer from previous Activities). + """ + activity.logger.info(f"Uploading file from {local_path}") + + # Use asyncio.to_thread for blocking file read + content = await asyncio.to_thread(Path(local_path).read_text) + activity.heartbeat(f"File read complete: {len(content)} bytes") + + # Simulate upload to S3, GCS, etc. with periodic heartbeats + # In real scenarios: stream file, track upload progress, handle retries, etc. + upload_duration = 3 # seconds (simulated; real uploads could take minutes) + heartbeat_interval = 1 # seconds + + elapsed = 0 + while elapsed < upload_duration: + await asyncio.sleep(heartbeat_interval) + elapsed += heartbeat_interval + progress = int((elapsed / upload_duration) * 100) + activity.heartbeat(f"Uploading file - {progress}% complete") + + upload_url = f"https://storage.example.com/results/{uuid.uuid4()}" + activity.logger.info(f"Uploaded to {upload_url}") + + # Clean up local files with heartbeats + activity.heartbeat(f"Starting cleanup of {local_path}") + original_path = local_path.replace(".processed", "") + for path in [local_path, original_path]: + try: + os.remove(path) + activity.heartbeat(f"Cleaned up {path}") + activity.logger.info(f"Cleaned up {path}") + except FileNotFoundError: + pass + + activity.heartbeat("Upload and cleanup complete") + return upload_url + + +async def main(): + config = ClientConfig.load_client_connect_config() + client = await Client.connect(**config) + + # Worker for the shared queue + # Handles "get_available_task_queue" requests from workflows + shared_worker = Worker( + client, + task_queue=FILE_PROCESSING_SHARED_QUEUE, + activities=[get_available_task_queue], + ) + + # Worker for this process's unique queue + # Handles the actual file operations + unique_worker = Worker( + client, + task_queue=UNIQUE_WORKER_TASK_QUEUE, + activities=[download_file, process_file, upload_file], + max_concurrent_activities=5, # Limit based on disk I/O + ) + + logging.info( + f"Starting file processing Worker\n" + f" Shared queue: {FILE_PROCESSING_SHARED_QUEUE}\n" + f" Unique queue: {UNIQUE_WORKER_TASK_QUEUE}" + ) + + # Run both Workers concurrently + await asyncio.gather( + shared_worker.run(), + unique_worker.run(), + ) + + +if __name__ == "__main__": + asyncio.run(main()) +``` + +**Deployment guidance:** +- Deploy multiple instances of this Worker (e.g., 5-10 instances) +- Each instance generates its own unique queue UUID +- Workers should have adequate disk space for file processing +- Consider using local SSD storage for better I/O performance + +### Step 3: Implement Workflow with Worker affinity and retries + +When a Worker crashes, Activities scheduled to its unique queue will wait. Implement failure detection and recovery using a multi-layered resilience strategy: + +**Failure Detection Mechanisms:** + +1. **Heartbeat Timeout** (fastest): Activities periodically send heartbeats to signal they're alive. If a Worker crashes mid-execution, no Heartbeats are sent, and the Activity fails within seconds (e.g., 30s) rather than waiting for the full Task duration. + +2. **Schedule-to-Start Timeout** (medium): If a Worker crashes before picking up a Task from its queue, this timeout detects it within minutes (e.g., 5 min) instead of waiting for the full Start-To-Close Timeout. This is critical for identifying dead Workers early. + +3. **Workflow-Level Retries** (recovery): The `for` loop catches all exceptions and retries the entire Workflow sequence on a different Worker. This provides recovery after detecting a Worker failure—the Workflow doesn't retry on the failed Worker's queue, but instead requests a new unique queue from a healthy Worker. + +**Failure Scenarios:** + +- **Worker crashes mid-Activity** → Detected by `heartbeat_timeout` (30s) → Exception caught by try/except → Workflow retries on new Worker +- **Worker crashes before picking up Task** → Detected by `schedule_to_start_timeout` (5 min) → Exception caught → Workflow retries on new Worker +- **All Workers fail** → Loop exhausts max attempts → Workflow fails after 3 attempts (configurable) + +**Enhanced Workflow with fallback:** + +```python +@workflow.defn +class FileProcessingWorkflowWithFallback: + """File processing with Worker failure handling. + + Uses a combination of: + - Heartbeats for fast crash detection (30s) + - Schedule-to-start timeout for dead Worker detection (5 min) + - Workflow-level retries to attempt on a different Worker + """ + + @workflow.run + async def run(self, file_url: str) -> str: + max_worker_attempts = 3 + + for attempt in range(max_worker_attempts): + try: + workflow.logger.info( + f"Attempt {attempt + 1}/{max_worker_attempts}" + ) + + # Get unique queue + unique_queue = await workflow.execute_activity( + get_available_task_queue, + task_queue=FILE_PROCESSING_SHARED_QUEUE, + start_to_close_timeout=timedelta(minutes=1), + ) + + # Process file on that Worker + local_path = await workflow.execute_activity( + download_file, + file_url, + task_queue=unique_queue, + start_to_close_timeout=timedelta(minutes=10), + # Key: schedule_to_start timeout detects dead workers before they pick up + schedule_to_start_timeout=timedelta(minutes=5), + # Key: heartbeat timeout detects worker crashes during execution (faster!) + heartbeat_timeout=timedelta(seconds=30), + ) + + processed_path = await workflow.execute_activity( + process_file, + local_path, + task_queue=unique_queue, + start_to_close_timeout=timedelta(minutes=30), + schedule_to_start_timeout=timedelta(minutes=5), + # Heartbeat detection for mid-execution worker crashes + heartbeat_timeout=timedelta(seconds=30), + ) + + upload_url = await workflow.execute_activity( + upload_file, + processed_path, + task_queue=unique_queue, + start_to_close_timeout=timedelta(minutes=10), + schedule_to_start_timeout=timedelta(minutes=5), + # Heartbeat detection for mid-execution worker crashes + heartbeat_timeout=timedelta(seconds=30), + ) + + return upload_url + + except Exception as e: + workflow.logger.warning( + f"Worker attempt {attempt + 1} failed: {e}" + ) + if attempt == max_worker_attempts - 1: + raise + # Try a different Worker + await workflow.sleep(timedelta(seconds=10)) + + raise RuntimeError("Failed to process file after all attempts") +``` + + +## Conclusion + +By implementing Worker-specific Task Queues for file processing, you have achieved: + +1. **Data locality:** Files downloaded in one Activity are immediately available to subsequent Activities on the same Worker, eliminating multi-GB network transfers + +2. **Performance improvement:** Reduced execution time by 80%+ for Workflows processing large files (e.g., 10GB video file no longer transferred between Workers) + +3. **Resource efficiency:** Load expensive resources (ML models, database connections) once per Workflow instead of per Activity, reducing memory usage and initialization overhead + +4. **Cost reduction:** Eliminated network egress costs from transferring large files between Workers, potentially saving thousands of dollars per month + +Your file processing Workflows now guarantee that all Activities execute on the same Worker, maintaining data locality and maximizing performance. + +## Related resources + +### Official documentation +- [Temporal Documentation - Task Routing](https://docs.temporal.io/task-routing) +- [Temporal Documentation - Worker Sessions (Go SDK)](https://docs.temporal.io/develop/go/sessions) + +### Related patterns +- [Separate Task Queues - Priorities](/guides/route-specialized-workloads) - For priority-based routing +- [Separate Task Queues - Rate Limiting](/guides/rate-limit-downstream-apis) - For protecting downstream APIs + +### Code samples +- [Worker-Specific Task Queues Sample (Python)](https://github.com/temporalio/samples-python/tree/main/worker_specific_task_queues) +- [Worker-Specific Task Queues Sample (TypeScript)](https://github.com/temporalio/samples-typescript/tree/main/worker-specific-task-queues) +- [Worker-Specific Task Queues Sample (Go)](https://github.com/temporalio/samples-go/tree/main/worker-specific-task-queues) +- [Worker-Specific Task Queues Sample (.NET)](https://github.com/temporalio/samples-dotnet/tree/main/src/WorkerSpecificTaskQueues) +- [Worker-Specific Task Queues Sample (Ruby)](https://github.com/temporalio/samples-ruby/tree/main/worker_specific_task_queues) + +### Community resources +- [Forum: When to Use Multiple Task Queues](https://community.temporal.io/t/in-what-situation-should-we-use-multiple-separated-task-queues/1254) +- [Slack: Using Dynamic Task Queues for Traffic Routing](https://community.temporal.io/t/using-dynamic-task-queues-for-traffic-routing/3045) \ No newline at end of file diff --git a/docs/production-deployment/self-hosted-guide/multi-cluster-replication.mdx b/docs/production-deployment/self-hosted-guide/multi-cluster-replication.mdx index 047283d55f..04deeba163 100644 --- a/docs/production-deployment/self-hosted-guide/multi-cluster-replication.mdx +++ b/docs/production-deployment/self-hosted-guide/multi-cluster-replication.mdx @@ -26,7 +26,7 @@ This feature must be enabled through a Dynamic Config flag per [Global Namespace When the feature is enabled, Tasks are sent to the Parent Task Queue partition that matches that Namespace, if it exists. All Visibility APIs can be used against active and standby Clusters. -This enables [Temporal UI](https://docs.temporal.io/web-ui) to work seamlessly for Global Namespaces. +This enables [Temporal UI](/web-ui) to work seamlessly for Global Namespaces. Applications making API calls directly to the Temporal Visibility API continue to work even if a Global Namespace is in standby mode. However, they might see a lag due to replication delay when querying the Workflow Execution state from a standby Cluster. diff --git a/docs/production-deployment/self-hosted-guide/server-frontend-api-reference.mdx b/docs/production-deployment/self-hosted-guide/server-frontend-api-reference.mdx index 6a8a5f6752..ccd04c3ba5 100644 --- a/docs/production-deployment/self-hosted-guide/server-frontend-api-reference.mdx +++ b/docs/production-deployment/self-hosted-guide/server-frontend-api-reference.mdx @@ -18,7 +18,7 @@ tags: import { CaptionedImage } from '@site/src/components'; While it's usually easiest to interact with [Temporal Server](/temporal-service/temporal-server) via a -[Client SDK](/encyclopedia/temporal-sdks#temporal-client) or the [Temporal CLI](https://docs.temporal.io/cli), you can +[Client SDK](/encyclopedia/temporal-sdks#temporal-client) or the [Temporal CLI](/cli), you can also use its gRPC API. Our Client and Worker SDKs use the gRPC API. The API reference is located here: diff --git a/docs/production-deployment/self-hosted-guide/visibility.mdx b/docs/production-deployment/self-hosted-guide/visibility.mdx index e52c901735..b18b6d0ab9 100644 --- a/docs/production-deployment/self-hosted-guide/visibility.mdx +++ b/docs/production-deployment/self-hosted-guide/visibility.mdx @@ -261,6 +261,33 @@ Note that the script uses [temporal-sql-tool](https://github.com/temporalio/temporal/blob/3b982585bf0124839e697952df4bba01fe4d9543/tools/sql/main.go) to run the setup. +### Maintaining Visibility index health {/* #postgresql-index-maintenance */} + +On high-volume deployments, the total size of the indexes on `executions_visibility` can grow much larger than the table itself and keep growing even with a Retention Period set. This is expected PostgreSQL b-tree behavior rather than a data leak: + +- Each pre-allocated custom and system Search Attribute has its own nullable column and its own index. PostgreSQL b-tree indexes store entries for `NULL` values, so a Search Attribute that a deployment never sets still has one index entry for every Workflow Execution row. +- Closing or updating a Workflow Execution upserts its Visibility row, and because these indexes order on `close_time`, the updates leave dead index entries over time. +- `VACUUM` reclaims table (heap) space but does not compact b-tree index bloat. `REINDEX` rebuilds an index and reclaims that space; removing rows through the Retention Period does not. + +To reclaim index space online, run `REINDEX` with `CONCURRENTLY`, which does not block reads or writes. It needs roughly the size of the index in extra disk space and I/O, so prefer a lower-traffic window: + +```sql +REINDEX TABLE CONCURRENTLY executions_visibility; +``` + +You can also target a single index with `REINDEX INDEX CONCURRENTLY ;`. + +Because `executions_visibility` is update-heavy, more aggressive autovacuum settings for the table help keep dead tuples, and the resulting index bloat, under control: + +```sql +ALTER TABLE executions_visibility SET ( + autovacuum_vacuum_scale_factor = 0.05, + autovacuum_analyze_scale_factor = 0.05 +); +``` + +Track sizes with `pg_relation_size('executions_visibility')` and `pg_indexes_size('executions_visibility')`, and use the [`pgstattuple`](https://www.postgresql.org/docs/current/pgstattuple.html) extension for precise bloat estimates. For sustained high-volume Visibility workloads, consider [Elasticsearch or OpenSearch](#elasticsearch), which Temporal recommends for production at scale. + ## How to set up SQLite Visibility store {/* #sqlite */} :::tip Support, stability, and dependency info diff --git a/docs/production-deployment/worker-deployments/kubernetes-controller.mdx b/docs/production-deployment/worker-deployments/kubernetes-controller.mdx index b06ea40557..afad15ffa1 100644 --- a/docs/production-deployment/worker-deployments/kubernetes-controller.mdx +++ b/docs/production-deployment/worker-deployments/kubernetes-controller.mdx @@ -47,7 +47,7 @@ Note that in Temporal, **Worker Deployment** is sometimes referred to as **Deplo - Deletion of resources associated with drained Worker Deployment Versions - `Manual`, `AllAtOnce`, and `Progressive` rollouts of new versions - Ability to specify a "gate" Workflow that must succeed on the new version before routing real traffic to that version -- Autoscaling of versioned Deployments using Kubernetes Horizontal Pod Autoscaler (HPA) +- Autoscaling of versioned Deployments using Kubernetes HPA or KEDA Refer to the [Temporal Worker Controller repo](https://github.com/temporalio/temporal-worker-controller/) for usage details. @@ -62,36 +62,43 @@ Use the Worker Controller when you need all of the following: - Kubernetes-native rollout automation - autoscaling that follows each active Worker Deployment Version separately -Because the Worker Controller uses Kubernetes HPA, you can scale on any metric available to your HPA pipeline, -including: +The Worker Controller provides Kubernetes-native autoscaling through either HPA or KEDA, so you can scale on any +metric available to your scaling pipeline, including: - CPU and memory utilization - Task Queue backlog metrics exposed through your metrics pipeline - slot utilization and other Worker-specific metrics - custom metrics surfaced through Prometheus or another Kubernetes metrics adapter -### TemporalWorkerOwnedResource +### WorkerResourceTemplate To attach autoscaling or other Kubernetes resources to each Worker Deployment Version, use a -`TemporalWorkerOwnedResource` (TWOR). +`WorkerResourceTemplate` (WRT). -A TWOR lets you define a resource template once and have the Worker Controller create a version-specific copy for each +A WRT lets you define a resource template once and have the Worker Controller create a version-specific copy for each active Worker Deployment Version. This is useful for resources such as: -- `HorizontalPodAutoscaler` +- `HorizontalPodAutoscaler` (HPA) +- `ScaledObject` (KEDA) - `PodDisruptionBudget` - other Kubernetes resources that should track the lifecycle of a versioned Deployment The Worker Controller manages these resources alongside the versioned Deployments it creates, so they are updated and cleaned up as versions roll forward and drain. -### Why use this instead of KEDA? +### Choosing a scaling strategy -If you are already using the Worker Controller for Worker Versioning, use the Worker Controller for autoscaling as -well. This keeps rollout management and scaling attached to the same versioned Kubernetes Deployments. +The Worker Controller supports two autoscaling strategies, each attached per Worker Deployment Version through a +`WorkerResourceTemplate`: Kubernetes HPA (with the Prometheus Adapter) and KEDA. -KEDA can still be a valid option for non-versioned or legacy worker deployments. However, for versioned Workers, the -Worker Controller is the preferred path because it keeps autoscaling aligned with Worker Deployment Versions. +HPA with the Prometheus Adapter is the recommended default for most deployments. It scales independently of the number +of namespaces or Task Queues and handles thousands of Task Queues efficiently. KEDA is a better fit when you need to +scale from zero, have long idle periods, or require sub-minute reactivity, though it is subject to per-namespace +Temporal API rate limits. + +For a full comparison and a decision matrix, see +[Scaling recommendations](https://github.com/temporalio/temporal-worker-controller/blob/main/docs/scaling-recommendations.md) +in the Worker Controller repository. ## Configuring Worker Lifecycles diff --git a/docs/production-deployment/worker-deployments/serverless-workers/aws-lambda.mdx b/docs/production-deployment/worker-deployments/serverless-workers/aws-lambda.mdx index 1ef0b0e12b..5d06611269 100644 --- a/docs/production-deployment/worker-deployments/serverless-workers/aws-lambda.mdx +++ b/docs/production-deployment/worker-deployments/serverless-workers/aws-lambda.mdx @@ -18,8 +18,7 @@ tags: - AWS Lambda --- -import SdkTabs from '@site/src/components/elements/SdkTabs'; -import { ReleaseNoteHeader } from '@site/src/components'; +import { ReleaseNoteHeader, SdkTabs } from '@site/src/components'; import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; @@ -382,18 +381,20 @@ This section applies to Temporal Cloud. For self-hosted Temporal Service deploym [Self-hosted setup](/production-deployment/worker-deployments/serverless-workers/self-hosted-setup#create-invocation-role) for IAM configuration with a different CloudFormation template. -Temporal needs permission to invoke your Lambda function. The Temporal server assumes an IAM role in your AWS account to -call `lambda:InvokeFunction`. The trust policy on the role includes an External ID condition to prevent +Temporal needs permission to invoke your Lambda function and check its status. The Temporal server assumes an IAM role in +your AWS account with a handful of Lambda permissions scoped to your Worker functions. The trust policy on the role +includes an External ID condition to prevent [confused deputy](https://docs.aws.amazon.com/IAM/latest/UserGuide/confused-deputy.html) attacks. -Deploy the following CloudFormation template to create the invocation role and its permissions. -[Download the template](/files/temporal-cloud-serverless-worker-role.yaml). +Deploy the following CloudFormation template to create the invocation role with these permissions. +[Download the template](/files/temporal-cloud-serverless-worker-role.yaml). See the template details below for the exact +permissions granted. | Parameter | Description | | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `AssumeRoleExternalId` | A string you choose to prevent [confused deputy](https://docs.aws.amazon.com/IAM/latest/UserGuide/confused-deputy.html) attacks. Can be any value. Use the same value when creating the Worker Deployment Version. | | `LambdaFunctionARNs` | Comma-separated list of Lambda function ARNs that Temporal may invoke. To allow invocation of any published version of a function, use a wildcard suffix (for example, `arn:aws:lambda:...:function:my-temporal-worker:*`). One role can authorize multiple Worker Lambdas. | -| `RoleName` | Base name for the created IAM role. Defaults to `Temporal-Cloud-Serverless-Worker`. | +| `RoleName` | Base name for the created IAM role. Defaults to `Temporal-Cloud-Serverless-Worker`. Provide a new role name if creating more than one stack. |
CloudFormation template @@ -446,7 +447,7 @@ Resources: TemporalCloudServerlessWorker: Type: AWS::IAM::Role Properties: - RoleName: !Sub '${RoleName}-${AWS::StackName}' + RoleName: !Ref RoleName AssumeRolePolicyDocument: Version: '2012-10-17' Statement: @@ -481,7 +482,7 @@ Resources: - lambda:GetFunction Resource: !Ref LambdaFunctionARNs Roles: - - !Sub '${RoleName}-${AWS::StackName}' + - !Ref RoleName Outputs: RoleARN: @@ -622,7 +623,10 @@ You can verify the invocation by checking: - **Temporal UI:** The Workflow execution should show task completions in the event history. - **AWS CloudWatch Logs:** The Lambda function's log group (`/aws/lambda/my-temporal-worker`) should show invocation - logs with the Worker startup, task processing, and graceful shutdown. + logs with the Worker startup, task processing, and graceful shutdown. This requires the Lambda execution role from + [Step 2](#deploy-lambda-function) to have CloudWatch Logs permissions, included in the + [`AWSLambdaBasicExecutionRole`](https://docs.aws.amazon.com/aws-managed-policy/latest/reference/AWSLambdaBasicExecutionRole.html) + managed policy. If the Workflow does not progress or the Lambda is not invoked, see [Troubleshoot Serverless Workers](/troubleshooting/serverless-workers). diff --git a/docs/production-deployment/worker-deployments/serverless-workers/self-hosted-setup.mdx b/docs/production-deployment/worker-deployments/serverless-workers/self-hosted-setup.mdx index ab000ebafb..aa8303afb2 100644 --- a/docs/production-deployment/worker-deployments/serverless-workers/self-hosted-setup.mdx +++ b/docs/production-deployment/worker-deployments/serverless-workers/self-hosted-setup.mdx @@ -127,7 +127,7 @@ aws cloudformation create-stack \ | `TemporalIamRoleArn` | The ARN of the IAM role or user that the Temporal Service runs as. This is the identity the server uses to call `sts:AssumeRole`. To find the ARN, run `aws sts get-caller-identity` in the server's environment. | | `AssumeRoleExternalId` | A unique string to prevent [confused deputy](https://docs.aws.amazon.com/IAM/latest/UserGuide/confused-deputy.html) attacks. Choose any value and pass the same value when creating the Worker Deployment Version. | | `LambdaFunctionARNs` | Comma-separated list of Lambda function ARNs that Temporal may invoke. To allow invocation of any published version of a function, use a wildcard suffix (for example, `arn:aws:lambda:...:function:my-temporal-worker:*`). | -| `RoleName` | Base name for the created IAM role. Defaults to `Temporal-Serverless-Worker`. | +| `RoleName` | Base name for the created IAM role. Defaults to `Temporal-Serverless-Worker`. Provide a new role name if creating more than one stack. |
CloudFormation template @@ -163,7 +163,7 @@ Resources: TemporalServerlessWorker: Type: AWS::IAM::Role Properties: - RoleName: !Sub '${RoleName}-${AWS::StackName}' + RoleName: !Ref RoleName AssumeRolePolicyDocument: Version: '2012-10-17' Statement: @@ -191,7 +191,7 @@ Resources: - lambda:GetFunction Resource: !Ref LambdaFunctionARNs Roles: - - !Sub '${RoleName}-${AWS::StackName}' + - !Ref RoleName Outputs: RoleARN: diff --git a/docs/production-deployment/worker-deployments/worker-versioning.mdx b/docs/production-deployment/worker-deployments/worker-versioning.mdx index 72e9f4e893..16b4ed5012 100644 --- a/docs/production-deployment/worker-deployments/worker-versioning.mdx +++ b/docs/production-deployment/worker-deployments/worker-versioning.mdx @@ -18,7 +18,7 @@ tags: --- import { LANGUAGE_TAB_GROUP, getLanguageLabel } from '@site/src/constants/languageTabs'; -import SdkTabs from '@site/src/components'; +import { SdkTabs } from '@site/src/components'; Worker Versioning is a Temporal feature that allows you to confidently deploy new changes to the Workflows running on your Workers without breaking them. Temporal enables this by helping you manage different builds or versions, formally @@ -149,7 +149,7 @@ three new parameters, with different names depending on the language: Follow the example for your SDK below: - + ```go buildID:= mustGetEnv("MY_BUILD_ID") @@ -158,7 +158,7 @@ w := worker.New(c, myTaskQueue, worker.Options{ UseVersioning: true, Version: worker.WorkerDeploymentVersion{ DeploymentName: "llm_srv", - BuildId: buildID, + BuildID: buildID, }, DefaultVersioningBehavior: workflow.VersioningBehaviorUnspecified, }, @@ -846,7 +846,7 @@ workflowOptions := client.StartWorkflowOptions{ VersioningOverride: &client.PinnedVersioningOverride{ Version: worker.WorkerDeploymentVersion{ DeploymentName: "DeployName", - BuildId: "1.0", + BuildID: "1.0", }, }, } diff --git a/docs/quickstarts.mdx b/docs/quickstarts.mdx index bf8992347b..f18f8068dd 100644 --- a/docs/quickstarts.mdx +++ b/docs/quickstarts.mdx @@ -6,8 +6,7 @@ description: Start building with Temporal using language-specific quickstarts. hide_table_of_contents: true --- -import QuickstartCards from "@site/src/components/QuickstartCards"; -import { CallToAction } from "@site/src/components/elements/CallToAction"; +import { QuickstartCards } from '@site/src/components'; # Quickstarts diff --git a/docs/references/configuration.mdx b/docs/references/configuration.mdx index db8ad2228a..f231c3d76d 100644 --- a/docs/references/configuration.mdx +++ b/docs/references/configuration.mdx @@ -298,7 +298,9 @@ A `sql` data store definition can contain the following values: - `connectAddr` - _required_ - the remote address of the database, e.g. "192.168.1.2". - `connectProtocol` - _required_ - the protocol that goes with the `connectAddr` - _Valid values_: `tcp` or `unix` -- `connectAttributes` - a map of key-value attributes to be sent as part of connect `data_source_name` url. +- `connectAttributes` - an optional map of key-value attributes to be sent as part of connect `data_source_name` url. + - `cache`: This is a standard parameter to set the SQLite shared-cache mode. `private` means each database connection uses its own private cache (as opposed to "shared", where connections share a cache). This is the recommended setting for Temporal's SQLite usage. + - `setup`: Instructs the Temporal server to automatically initialize a SQLite database schema on startup. - `maxConns` - the max number of connections to this data store. - `maxIdleConns` - the max number of idle connections to this data store - `maxConnLifetime` - is the maximum time a connection can be alive. diff --git a/docs/references/errors.mdx b/docs/references/errors.mdx index 8702ecd7f9..f81d94b2cd 100644 --- a/docs/references/errors.mdx +++ b/docs/references/errors.mdx @@ -14,7 +14,7 @@ tags: This reference lists possible [Workflow Task](/tasks#workflow-task) errors and how to resolve them. -> For other types of errors, see [Temporal Failures](https://docs.temporal.io/kb/failures). +> For other types of errors, see [Temporal Failures](/references/failures). Each of the below errors corresponds with a [WorkflowTaskFailedCause](https://api-docs.temporal.io/#temporal.api.enums.v1.WorkflowTaskFailedCause), which appears in [Events](/workflow-execution/event#event) under `workflow_task_failed_event_attributes`. diff --git a/docs/references/failures.mdx b/docs/references/failures.mdx index 1cd2c51ee1..4ad33cf124 100644 --- a/docs/references/failures.mdx +++ b/docs/references/failures.mdx @@ -67,7 +67,7 @@ Workflow Execution Timeout, which is unlimited by default. An `ApplicationError`, an extension of `FailureError`, can be raised in a Workflow to fail the Workflow Execution. Workflow Execution Failures put the Workflow Execution into the "Failed" state and no more attempts will be made in progressing this execution. -If you are creating custom exceptions you would need to extend the [`ApplicationError`](https://docs.temporal.io/references/failures#application-failure) class—a child class of [`FailureError`](https://docs.temporal.io/references/failures#temporal-failure). +If you are creating custom exceptions you would need to extend the [`ApplicationError`](/references/failures#application-failure) class—a child class of [`FailureError`](/references/failures#temporal-failure). ### Errors in Activities diff --git a/docs/references/operation-list.mdx b/docs/references/operation-list.mdx index c211bc1f5f..47081fbd02 100644 --- a/docs/references/operation-list.mdx +++ b/docs/references/operation-list.mdx @@ -13,7 +13,7 @@ tags: - rate limiting --- -import OperationsTable from '@site/src/components/formatting/OperationsTable'; +import { OperationsTable } from '@site/src/components'; # Operations diff --git a/docs/troubleshooting/performance-bottlenecks.mdx b/docs/troubleshooting/performance-bottlenecks.mdx index 756add34aa..302e3fa14f 100644 --- a/docs/troubleshooting/performance-bottlenecks.mdx +++ b/docs/troubleshooting/performance-bottlenecks.mdx @@ -33,8 +33,8 @@ The following sections detail common metrics, their potential causes for high la High [`temporal_workflow_task_schedule_to_start_latency`](/references/sdk-metrics#workflow_task_schedule_to_start_latency) (P95 higher than one second) can be caused by several factors. This metric represents the time between when a [Workflow Task](/tasks#workflow-task) is scheduled (enqueued) and when it is picked up by a Worker for processing. Here are some potential causes: -- Insufficient Worker capacity: If there aren't enough Workers or if the Workers are overloaded, they may not be able to pick up Tasks quickly enough. This can lead to Tasks waiting longer in the queue ([Detect Task Backlog](https://docs.temporal.io/cloud/worker-health#detect-task-backlog)). -- Worker configuration issues: Improperly configured Workers, such as having too few pollers or Task slots, can lead to increased latency ([Detect Task Backlog](https://docs.temporal.io/cloud/worker-health#detect-task-backlog)). +- Insufficient Worker capacity: If there aren't enough Workers or if the Workers are overloaded, they may not be able to pick up Tasks quickly enough. This can lead to Tasks waiting longer in the queue ([Detect Task Backlog](/cloud/worker-health#detect-task-backlog)). +- Worker configuration issues: Improperly configured Workers, such as having too few pollers or Task slots, can lead to increased latency ([Detect Task Backlog](/cloud/worker-health#detect-task-backlog)). - High Workflow lock latency: If many updates are made to a single execution, this can cause Workflow lock latency, which in turn affects the Schedule-to-start latency. Reduce the rate of Signals. - Network latency: Workers in a different region from the Temporal cluster, or large payload size, can introduce additional latency. @@ -51,8 +51,8 @@ High [`temporal_activity_schedule_to_start_latency`](/references/sdk-metrics#act This metric represents the time between when an [Activity Task](/tasks#activity-task) is scheduled (enqueued) and when it is picked up by a Worker for processing. Here are some potential causes: -- Insufficient Worker capacity: If there aren't enough Workers or if the Workers are overloaded, they may not be able to pick up Tasks quickly enough. This can lead to Tasks waiting longer in the queue ([Detect Task Backlog](https://docs.temporal.io/cloud/worker-health#detect-task-backlog)). -- Worker configuration issues: Improperly configured Workers, such as having too few pollers or Task slots, can lead to increased latency ([Detect Task Backlog](https://docs.temporal.io/cloud/worker-health#detect-task-backlog)). +- Insufficient Worker capacity: If there aren't enough Workers or if the Workers are overloaded, they may not be able to pick up Tasks quickly enough. This can lead to Tasks waiting longer in the queue ([Detect Task Backlog](/cloud/worker-health#detect-task-backlog)). +- Worker configuration issues: Improperly configured Workers, such as having too few pollers or Task slots, can lead to increased latency ([Detect Task Backlog](/cloud/worker-health#detect-task-backlog)). - Task Queue configuration: Setting `TaskQueueActivitiesPerSecond` too low can limit the rate at which Activities are started, leading to increased Schedule-to-start latency. - Network latency: Workers in a different region from the Temporal cluster, or large payload size can introduce additional latency. @@ -70,7 +70,7 @@ Normal ranges for this metric depend on the use case, but here are some potentia - Complex Workflows: If the Workflows have many Activities or if the Activities take a long time to execute. - Workflow and Activity retries: If Workflows or Activities are configured to retry upon failure and they fail often, this can increase the end-to-end latency as the system will wait for the retry delay before reattempting the failed operation. -- Worker capacity and configuration: If there aren't enough Workers or if the Workers are overloaded, they may not be able to pick up and process Tasks quickly enough. This can lead to Tasks waiting longer in the queue, thereby increasing the end-to-end latency ([Detect Task Backlog](https://docs.temporal.io/cloud/worker-health#detect-task-backlog)). +- Worker capacity and configuration: If there aren't enough Workers or if the Workers are overloaded, they may not be able to pick up and process Tasks quickly enough. This can lead to Tasks waiting longer in the queue, thereby increasing the end-to-end latency ([Detect Task Backlog](/cloud/worker-health#detect-task-backlog)). - External dependencies: If your Workflows or Activities depend on external systems or services (such as databases or APIs) and these systems are slow or unreliable, they can increase the end-to-end latency. - Network latency: Workers in a different region from the Temporal cluster can introduce additional latency. @@ -228,7 +228,7 @@ To diagnose and address high `temporal_request_latency`: 2. Check the network connection between the Temporal Client and the Temporal Server. 3. Monitor the resource usage on both the Temporal Client and the Temporal Server. 4. Review your Temporal Client configuration to ensure it is optimized for your workload. -5. If you're using Temporal Cloud, check if the Cloud’s [service-latency](https://docs.temporal.io/cloud/metrics/reference#service-latency) metric spikes up and reach out to Temporal Support for help. +5. If you're using Temporal Cloud, check if the Cloud’s [service-latency](/cloud/metrics/reference#service-latency) metric spikes up and reach out to Temporal Support for help. ### `rate(temporal_long_request_total{operation="PollActivityTaskQueue"})` @@ -279,8 +279,8 @@ Conversely, if you have available memory and want to improve performance, you mi ### `temporal_sticky_cache_hit_total` and `temporal_sticky_cache_miss_total` -The [`temporal_sticky_cache_hit_total`](https://docs.temporal.io/references/sdk-metrics#sticky_cache_hit) metric is a counter that measures the total number of times a Workflow Task found a cached Workflow Execution to run against, and -the opposite is [`temporal_sticky_cache_miss_total`](https://docs.temporal.io/references/sdk-metrics#sticky_cache_miss), which is a counter that measures the total number of times a Workflow Task did not find a cached Workflow Execution to run against. +The [`temporal_sticky_cache_hit_total`](/references/sdk-metrics#sticky_cache_hit) metric is a counter that measures the total number of times a Workflow Task found a cached Workflow Execution to run against, and +the opposite is [`temporal_sticky_cache_miss_total`](/references/sdk-metrics#sticky_cache_miss), which is a counter that measures the total number of times a Workflow Task did not find a cached Workflow Execution to run against. Sticky Execution is a feature where a Worker caches a Workflow Execution and creates a dedicated Task Queue to listen on. This improves performance because the Temporal Service only sends new events to the Worker instead of entire Event Histories, and the Workflow doesn't have to Replay. @@ -293,7 +293,7 @@ A high rate of cache hits with a low rate of cache misses indicates that your Wo ### `temporal_sticky_cache_total_forced_eviction_total` -The [`temporal_sticky_cache_total_forced_eviction_total`](https://docs.temporal.io/references/sdk-metrics#sticky_cache_hit) metric is a counter that measures the total number of Workflow Executions that have been forcibly evicted from the sticky cache. +The [`temporal_sticky_cache_total_forced_eviction_total`](/references/sdk-metrics#sticky_cache_hit) metric is a counter that measures the total number of Workflow Executions that have been forcibly evicted from the sticky cache. Sticky Execution is a feature where a Worker caches a Workflow Execution and creates a dedicated Task Queue to listen on. This improves performance because the Temporal Service only sends new events to the Worker instead of entire Event Histories, and the Workflow doesn't have to Replay. diff --git a/docusaurus.config.js b/docusaurus.config.js index c7deb419ff..8b0976eed1 100644 --- a/docusaurus.config.js +++ b/docusaurus.config.js @@ -1,6 +1,8 @@ //@ts-check const FontPreloadPlugin = require('webpack-font-preload-plugin'); const { prismDarkTheme, prismLightTheme } = require('./src/prismThemes'); +const { ALGOLIA_APP_ID, ALGOLIA_SEARCH_API_KEY, ALGOLIA_INDEX_NAME } = require('./src/constants/algolia'); +const mermaidTheme = require('./src/constants/mermaidTheme'); /** @type {import('@docusaurus/types').DocusaurusConfig} */ @@ -15,65 +17,17 @@ module.exports = async function createConfigAsync() { favicon: 'favicon.ico', organizationName: 'temporalio', // Usually your GitHub org/user name. projectName: 'temporal-documentation', // Usually your repo name. - headTags: [ - // JSON-LD structured data so AI agents and search engines can identify - // the product (SoftwareApplication) and disambiguate the brand (sameAs). - { - tagName: 'script', - attributes: { - type: 'application/ld+json', - }, - innerHTML: JSON.stringify({ - '@context': 'https://schema.org', - '@graph': [ - { - '@type': 'Organization', - '@id': 'https://temporal.io/#organization', - name: 'Temporal Technologies', - url: 'https://temporal.io', - logo: 'https://docs.temporal.io/img/favicon.png', - sameAs: [ - 'https://github.com/temporalio', - 'https://x.com/temporalio', - 'https://www.youtube.com/c/Temporalio', - ], - }, - { - '@type': 'SoftwareApplication', - '@id': 'https://temporal.io/#software', - name: 'Temporal', - applicationCategory: 'DeveloperApplication', - operatingSystem: 'Cross-platform', - url: 'https://temporal.io', - downloadUrl: 'https://github.com/temporalio/temporal', - description: - 'Temporal is a durable execution platform for building reliable, scalable applications using workflows and activities.', - publisher: { '@id': 'https://temporal.io/#organization' }, - offers: { '@type': 'Offer', price: '0', priceCurrency: 'USD' }, - }, - ], - }), - }, - ], + // JSON-LD structured data (Organization/SoftwareApplication/WebPage) is + // rendered per-page instead of injected globally here — see + // src/theme/DocItem/StructuredData and src/constants/organizationSchema. + // A single global block would put the full Organization property set on + // every page, which is exactly the drift risk the JSON-LD audit flagged. clientModules: ['./src/client/remote-amplitude-analytics.js', './src/client/scrollSidebarToActivePage.ts'], themeConfig: { colorMode: { defaultMode: 'dark', disableSwitch: false, respectPrefersColorScheme: true, - // switchConfig: { - // darkIcon: "🌙", - // darkIconStyle: { - // content: `url(/img/assets/moon.svg)`, - // transform: "scale(2)", - // margin: "0 0.2rem", - // }, - // lightIcon: "\u{1F602}", - // lightIconStyle: { - // content: `url(/img/assets/sun.svg)`, - // transform: "scale(2)", - // }, - // }, }, metadata: [ { name: 'robots', content: 'follow, index' }, @@ -136,6 +90,13 @@ module.exports = async function createConfigAsync() { activeBasePath: 'ai-cookbook', position: 'left', }, + // hide this for now, making this a soft-launch + // { + // label: 'Design Patterns', + // to: '/design-patterns', + // activeBasePath: 'design-patterns', + // position: 'left', + // }, { label: 'Code Exchange', href: 'https://temporal.io/code-exchange', @@ -245,11 +206,11 @@ module.exports = async function createConfigAsync() { ], }, algolia: { - apiKey: '4a2fa646f476d7756a7cdc599b625bec', - indexName: 'temporal', + apiKey: ALGOLIA_SEARCH_API_KEY, + indexName: ALGOLIA_INDEX_NAME, externalUrlRegex: 'temporal\\.io', // contextualSearch: true, // Optional; if you have different version of docs etc (v1 and v2), doesn't display dup results - appId: 'T5D6KNJCQS', // Optional, if you run the DocSearch crawler on your own + appId: ALGOLIA_APP_ID, // Optional, if you run the DocSearch crawler on your own // algoliaOptions: {}, // Optional, if provided by Algolia searchPagePath: false, // Disable default search page - using custom implementation at src/pages/search.tsx insights: true, @@ -265,6 +226,15 @@ module.exports = async function createConfigAsync() { ], }, }, + mermaid: { + theme: mermaidTheme.theme, + options: { + themeVariables: { fontFamily: mermaidTheme.fontFamily }, + flowchart: mermaidTheme.flowchart, + sequence: mermaidTheme.sequence, + state: mermaidTheme.state, + }, + }, }, presets: [ [ @@ -280,7 +250,7 @@ module.exports = async function createConfigAsync() { '**/clusters/**', '**/ai-cookbook/**', ], // partials (underscore-prefixed) + context content we don't render - editUrl: 'https://github.com/temporalio/documentation/edit/main/docs/', + editUrl: 'https://github.com/temporalio/documentation/blob/main/', /** * Whether to display the author who last updated the doc. */ @@ -411,8 +381,8 @@ module.exports = async function createConfigAsync() { showLastUpdateAuthor: true, showLastUpdateTime: true, // use a custom item to center the content: - docItemComponent: '@site/src/components/CookbookDocItem', - docCategoryGeneratedIndexComponent: '@site/src/components/CookbookCategoryIndex', // ⬅️ isolated override + docItemComponent: '@site/src/components/Cookbook/DocItem/CookbookDocItem', + docCategoryGeneratedIndexComponent: '@site/src/components/Cookbook/DocItem/CookbookCategoryIndex', // ⬅️ isolated override }, ], [ @@ -428,6 +398,12 @@ module.exports = async function createConfigAsync() { docsDir: 'docs', }, ], + [ + require.resolve('./plugins/og-image'), + { + docsDir: 'docs', + }, + ], [ 'docusaurus-plugin-llms', { diff --git a/fixtures/docs/develop/go/workflows/basics.mdx b/fixtures/docs/develop/go/workflows/basics.mdx index 997beafaeb..7ed1cc355e 100644 --- a/fixtures/docs/develop/go/workflows/basics.mdx +++ b/fixtures/docs/develop/go/workflows/basics.mdx @@ -11,7 +11,7 @@ tags: - Temporal SDKs --- -import { ViewSourceCodeNotice } from '@site/src/components/elements/ViewSourceCodeNotice'; +import { ViewSourceCodeNotice } from '@site/src/components'; ## How to develop a basic Workflow {/* #develop-workflows */} diff --git a/package.json b/package.json index 872054011f..c9608b4cd0 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,9 @@ "deploy": "docusaurus deploy", "docusaurus": "docusaurus", "check-links": "hyperlink build/index.html --recursive --check-anchors", + "validate:og-images": "node ./bin/validate-og-images.js", + "check:og-budget": "node ./bin/check-og-build-budget.js", + "test": "node --test", "lint": "vale --output=JSON docs/**/**/*.mdx > vale-output.json; vale --output=JSON docs/**/**/*.md > vale-md-output.json", "lint:go": "vale docs/develop/go/*.mdx", "lint:java": "vale docs/develop/java/*.mdx", @@ -20,6 +23,7 @@ "lint:py": "vale docs/develop/python/*.mdx", "lint:ruby": "vale docs/develop/ruby/*.mdx", "lint:ts": "vale docs/develop/typescript/*.mdx", + "lint:mermaid": "node ./scripts/lint-mermaid.mjs", "fix-vale": "node vale/auto-fix-vale.js vale-output.json", "serve": "docusaurus serve", "snipsync": "snipsync", @@ -62,38 +66,43 @@ "@docusaurus/theme-mermaid": "^3.10.1", "@docusaurus/types": "^3.10.1", "@mdx-js/react": "^3.0.0", + "@resvg/resvg-js": "^2.6.2", "@types/react": "^19.2.17", "@types/react-dom": "^19.1.0", - "@vercel/functions": "^3.7.1", - "algoliasearch": "^5.54.1", + "@vercel/functions": "^3.7.5", + "algoliasearch": "^5.55.1", "chart.js": "^4.4.1", "clsx": "^2.1.1", "comlink": "^4.4.2", "concurrently": "^10.0.3", "docusaurus-plugin-llms": "^0.2.0", "docusaurus-pushfeedback": "^1.0.3", - "js-yaml": "^4.2.0", + "gray-matter": "^4.0.3", + "js-yaml": "^5.2.1", "path": "^0.12.7", "react": "^19.2.7", "react-dom": "^19.2.7", "react-icons": "^5.6.0", - "react-instantsearch": "^7.35.1", + "react-instantsearch": "^7.38.0", "react-markdown": "^10.1.0", "react-player": "^3.4.0", "rehype-katex": "7", "remark-math": "6", + "satori": "^0.26.0", + "search-insights": "^2", + "sharp": "^0.35.3", "snipsync": "^1.12.0", "tar-fs": "^3.1.2", - "uuid": "^14.0.0", + "uuid": "^14.0.1", "wait-on": "^9.0.10", "webpack-font-preload-plugin": "^1.5.0", "yaml": "^2.9.0" }, "devDependencies": { "@playwright/test": "^1.61.0", - "@types/node": "^25.9.3", + "@types/node": "^26.0.0", "@types/yaml": "^1.9.7", - "dprint": "^0.54.0", + "dprint": "^0.55.1", "eslint": "^9.8.0", "eslint-plugin-react": "^7.23.2", "husky": "^9.1.7", diff --git a/plugins/markdown-pages/index.js b/plugins/markdown-pages/index.js index 77788c8204..d822e97b07 100644 --- a/plugins/markdown-pages/index.js +++ b/plugins/markdown-pages/index.js @@ -1,38 +1,13 @@ const fs = require('fs'); const path = require('path'); const matter = require('gray-matter'); +const { walkDir, resolveUrlPath: resolveUrlPathShared } = require('../shared/docsRouting'); module.exports = function markdownPagesPlugin(context, options = {}) { const docsDir = path.resolve(context.siteDir, options.docsDir || 'docs'); const routeBasePath = options.routeBasePath || '/'; - function walkDir(dir) { - if (!fs.existsSync(dir)) return []; - return fs.readdirSync(dir).flatMap((name) => { - const full = path.join(dir, name); - if (fs.statSync(full).isDirectory()) return walkDir(full); - if (/\.(md|mdx)$/i.test(name)) return [full]; - return []; - }); - } - - function resolveUrlPath(filePath, frontmatter) { - if (frontmatter.slug) { - const slug = frontmatter.slug.replace(/^\/+/, '').replace(/\/+$/, ''); - return slug || 'index'; - } - const rel = path.relative(docsDir, filePath).replace(/\\/g, '/'); - const withoutExt = rel.replace(/\.(md|mdx)$/i, ''); - const id = frontmatter.id || path.basename(withoutExt); - const dir = path.dirname(withoutExt); - if (dir === '.') return id === 'index' ? 'index' : id; - // Docusaurus treats a doc named the same as its parent folder (e.g. - // `event-history/event-history.mdx`) as that folder's index page, the - // same way it treats `index.mdx`/`README.mdx`. Mirror that convention - // here so the generated .md file lands at the same route as the real page. - if (id === 'index' || id === path.basename(dir)) return dir; - return `${dir}/${id}`; - } + const resolveUrlPath = (filePath, frontmatter) => resolveUrlPathShared(docsDir, filePath, frontmatter); return { name: 'markdown-pages', diff --git a/plugins/og-image/assets/og-background.png b/plugins/og-image/assets/og-background.png new file mode 100644 index 0000000000..8034f2f221 Binary files /dev/null and b/plugins/og-image/assets/og-background.png differ diff --git a/plugins/og-image/index.js b/plugins/og-image/index.js new file mode 100644 index 0000000000..d15940966a --- /dev/null +++ b/plugins/og-image/index.js @@ -0,0 +1,194 @@ +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); +const matter = require('gray-matter'); +const { renderCard, TEMPLATE_VERSION, IMAGE_EXTENSION } = require('./render'); +const { walkDir, resolveUrlPath } = require('../shared/docsRouting'); + +const CACHE_DIR = path.join(__dirname, '../../node_modules/.cache/og-images'); + +function htmlPathForUrlPath(outDir, urlPath) { + return urlPath === 'index' + ? path.join(outDir, 'index.html') + : path.join(outDir, urlPath, 'index.html'); +} + +function humanize(id) { + return id + .replace(/[-_]+/g, ' ') + .replace(/\b\w/g, (c) => c.toUpperCase()); +} + +function extractTitle(content, frontmatter, id) { + if (frontmatter.title) return frontmatter.title; + const heading = content.match(/^#\s+(.+)$/m); + if (heading) return heading[1].trim(); + return humanize(id); +} + +// A page opts out of the generated card either via front matter `image:` +// (the mechanism Docusaurus already supports natively for docs) or by +// embedding its own og:image override directly in the MDX content +// (the mechanism used for pages without front matter support). Either way, +// Docusaurus/react-helmet has already rendered that override into the built +// HTML by the time postBuild runs, so all we have to do is leave it alone. +function hasManualOverride(frontmatter, content) { + return Boolean(frontmatter.image) || /]*>[\s\S]*?og:image[\s\S]*?<\/Head>/i.test(content); +} + +// For a page with a manual override, this is the image it's actually using +// — front-matter `image:` if set, otherwise whatever content= value the +// page's own og:image tag has. Used by bin/validate-og-images.js and +// bin/generate-og-gallery.js so neither has to re-parse the block. +function overrideImageFor(frontmatter, content, siteUrl) { + if (frontmatter.image) return new URL(frontmatter.image, siteUrl).toString(); + const headMatch = content.match(/]*>([\s\S]*?)<\/Head>/i); + if (!headMatch) return null; + const tagMatch = headMatch[1].match(/]*property=["']?og:image["']?[^>]*>/i); + if (!tagMatch) return null; + const contentMatch = tagMatch[0].match(/content=["']?([^"'\s>]+)["']?/i); + return contentMatch ? contentMatch[1] : null; +} + +// Deliberately excludes section: render.js doesn't render it (dropped along +// with the section pill in the Figma redesign), so including it here would +// just fragment the cache between pages that render pixel-identically. +function hashFor(title, description) { + return crypto + .createHash('sha256') + .update(`v${TEMPLATE_VERSION}:${title}:${description || ''}`) + .digest('hex') + .slice(0, 16); +} + +async function getCardBuffer(title, description) { + const hash = hashFor(title, description); + const cachePath = path.join(CACHE_DIR, `${hash}.${IMAGE_EXTENSION}`); + if (fs.existsSync(cachePath)) { + return { hash, buffer: fs.readFileSync(cachePath), cached: true }; + } + const buffer = await renderCard({ title, description }); + fs.mkdirSync(CACHE_DIR, { recursive: true }); + fs.writeFileSync(cachePath, buffer); + return { hash, buffer, cached: false }; +} + +// On a Vercel preview (or local dev) deployment, generated cards only exist +// on that deployment's own URL — siteConfig.url is always the production +// domain, which never got this build's images. Pointing og:image there +// means Slack/X/Facebook/etc. 404 when fetching a preview link, since they +// resolve the literal URL in the meta tag, not wherever the page happens to +// be served from. VERCEL_URL is Vercel's own reachable hostname for the +// current deployment (preview or production); only substitute it for +// non-production deployments, since production should keep the stable +// custom domain. Local builds (no VERCEL_ENV) are unaffected. +function resolveSiteUrl(siteConfig) { + if (process.env.VERCEL_ENV && process.env.VERCEL_ENV !== 'production' && process.env.VERCEL_URL) { + return `https://${process.env.VERCEL_URL}`; + } + return siteConfig.url; +} + +function replaceImageMeta(html, absoluteUrl) { + return html + .replace( + /(]*property=["']?og:image["']?[^>]*content=)["']?[^"'\s>]+["']?/i, + `$1${absoluteUrl}`, + ) + .replace( + /(]*name=["']?twitter:image["']?[^>]*content=)["']?[^"'\s>]+["']?/i, + `$1${absoluteUrl}`, + ); +} + +function ogImagePlugin(context, options = {}) { + const docsDir = path.resolve(context.siteDir, options.docsDir || 'docs'); + + return { + name: 'og-image', + + async postBuild({ outDir, siteConfig }) { + const files = walkDir(docsDir); + let generated = 0; + let cached = 0; + let skipped = 0; + let overridden = 0; + let renderMs = 0; + let outputBytes = 0; + + for (const filePath of files) { + const raw = fs.readFileSync(filePath, 'utf8'); + const { data: frontmatter, content } = matter(raw); + const urlPath = resolveUrlPath(docsDir, filePath, frontmatter); + const htmlPath = htmlPathForUrlPath(outDir, urlPath); + + if (!fs.existsSync(htmlPath)) { + // Not a routed page (e.g. an underscore-prefixed partial or an + // excluded directory) — nothing to inject an og:image into. + skipped++; + continue; + } + + if (hasManualOverride(frontmatter, content)) { + // Docusaurus already rendered the author's own image into this + // page's meta tags — don't touch it. + overridden++; + continue; + } + + const id = frontmatter.id || path.basename(filePath).replace(/\.(md|mdx)$/i, ''); + const title = extractTitle(content, frontmatter, id); + const description = frontmatter.description; + const renderStart = Date.now(); + const { hash, buffer, cached: wasCached } = await getCardBuffer(title, description); + if (wasCached) { + cached++; + } else { + generated++; + renderMs += Date.now() - renderStart; + } + outputBytes += buffer.length; + + const cardOutPath = path.join(outDir, 'img', 'og', `${hash}.${IMAGE_EXTENSION}`); + if (!fs.existsSync(cardOutPath)) { + fs.mkdirSync(path.dirname(cardOutPath), { recursive: true }); + fs.copyFileSync(path.join(CACHE_DIR, `${hash}.${IMAGE_EXTENSION}`), cardOutPath); + } + + const absoluteUrl = new URL( + path.posix.join(siteConfig.baseUrl, 'img/og', `${hash}.${IMAGE_EXTENSION}`), + resolveSiteUrl(siteConfig), + ).toString(); + + const html = fs.readFileSync(htmlPath, 'utf8'); + fs.writeFileSync(htmlPath, replaceImageMeta(html, absoluteUrl)); + } + + console.log( + `[og-image] ${generated} card(s) rendered, ${cached} served from cache, ${overridden} page(s) kept a manual override, ${skipped} page(s) skipped (not routed)`, + ); + + // Consumed by bin/check-og-build-budget.js so build-time/size + // regressions can be caught in CI instead of just eyeballed. + fs.writeFileSync( + path.join(outDir, '.og-image-stats.json'), + JSON.stringify({ generated, cached, overridden, skipped, renderMs, outputBytes }, null, 2), + ); + }, + }; +} + +// Exposed so bin/validate-og-images.js can compute the exact same expected +// route/title/hash a doc page got, instead of re-deriving that logic and +// risking drift between the plugin and its own validation script. +ogImagePlugin.walkDir = walkDir; +ogImagePlugin.resolveUrlPath = resolveUrlPath; +ogImagePlugin.htmlPathForUrlPath = htmlPathForUrlPath; +ogImagePlugin.extractTitle = extractTitle; +ogImagePlugin.hashFor = hashFor; +ogImagePlugin.hasManualOverride = hasManualOverride; +ogImagePlugin.overrideImageFor = overrideImageFor; +ogImagePlugin.resolveSiteUrl = resolveSiteUrl; +ogImagePlugin.IMAGE_EXTENSION = IMAGE_EXTENSION; + +module.exports = ogImagePlugin; diff --git a/plugins/og-image/index.test.js b/plugins/og-image/index.test.js new file mode 100644 index 0000000000..da14627050 --- /dev/null +++ b/plugins/og-image/index.test.js @@ -0,0 +1,79 @@ +const { describe, it } = require('node:test'); +const assert = require('node:assert'); +const ogImagePlugin = require('./index'); + +const SITE_URL = 'https://docs.temporal.io/'; + +describe('hasManualOverride', () => { + it('is true when front matter has an image field', () => { + assert.strictEqual(ogImagePlugin.hasManualOverride({ image: '/img/custom.png' }, 'Body text'), true); + }); + + it('is true when content has a og:image override', () => { + const content = ` +import Head from '@docusaurus/Head'; + + + + + +Body text`; + assert.strictEqual(ogImagePlugin.hasManualOverride({}, content), true); + }); + + it('is false for a normal page with neither', () => { + assert.strictEqual(ogImagePlugin.hasManualOverride({}, 'Just a normal doc page.'), false); + }); + + it('is false when is used for something other than og:image', () => { + const content = ` + + + + +Body text`; + assert.strictEqual(ogImagePlugin.hasManualOverride({}, content), false); + }); +}); + +describe('overrideImageFor', () => { + it('resolves a relative front-matter image against the site URL', () => { + assert.strictEqual( + ogImagePlugin.overrideImageFor({ image: '/img/custom.png' }, '', SITE_URL), + 'https://docs.temporal.io/img/custom.png', + ); + }); + + it('leaves an already-absolute front-matter image as-is', () => { + assert.strictEqual( + ogImagePlugin.overrideImageFor({ image: 'https://example.com/img/custom.png' }, '', SITE_URL), + 'https://example.com/img/custom.png', + ); + }); + + it('extracts the content= value from a og:image override', () => { + const content = ` + + + + +`; + assert.strictEqual(ogImagePlugin.overrideImageFor({}, content, SITE_URL), 'https://docs.temporal.io/img/custom.png'); + }); + + it('prefers front-matter image over a override if somehow both are present', () => { + const content = ` + + + +`; + assert.strictEqual( + ogImagePlugin.overrideImageFor({ image: '/img/frontmatter-version.png' }, content, SITE_URL), + 'https://docs.temporal.io/img/frontmatter-version.png', + ); + }); + + it('returns null when there is no override to resolve', () => { + assert.strictEqual(ogImagePlugin.overrideImageFor({}, 'Just a normal doc page.', SITE_URL), null); + }); +}); diff --git a/plugins/og-image/render.js b/plugins/og-image/render.js new file mode 100644 index 0000000000..2040ebdf59 --- /dev/null +++ b/plugins/og-image/render.js @@ -0,0 +1,227 @@ +const fs = require('fs'); +const path = require('path'); +const satori = require('satori').default; +const { Resvg } = require('@resvg/resvg-js'); +const sharp = require('sharp'); +const { TITLE_COLOR, SUBTITLE_COLOR, FOOTER_COLOR } = require('../../src/constants/ogImageColors'); + +const CARD_WIDTH = 1200; +const CARD_HEIGHT = 630; + +// Bump this whenever the card layout/design changes (including the encoding +// step below) so cached images invalidate even though the underlying page +// content didn't change. +const TEMPLATE_VERSION = 8; + +// The icon spans the full height of the lockup's viewBox (0-395 of 0-395), +// so rendering the whole icon+wordmark asset at LOGO_HEIGHT makes the icon +// itself exactly LOGO_HEIGHT square (matches Figma's ~60x60 icon spec) +// while keeping the already-correct icon-to-wordmark proportions intact. +const LOGO_HEIGHT = 60; +const LOGO_ASPECT_RATIO = 1571 / 395; + +const TITLE_MAX_LINES = 3; +const DESCRIPTION_MAX_LINES = 3; + +function dataUri(assetPath, mimeType) { + const data = fs.readFileSync(assetPath); + return `data:${mimeType};base64,${data.toString('base64')}`; +} + +// satori has no equivalent of CSS `overflow-wrap: break-word` — its only +// word-breaking knobs are `word-break: break-all` (breaks every word, even +// short ones, which looks broken for normal titles) or the default (never +// breaks within a word, so one long unbroken token — a hash, identifier, or +// concatenated string with no spaces/hyphens — just runs off the edge of the +// card). Inserting a zero-width space every `maxSegmentLength` characters +// gives satori's line breaker a place to wrap inside such a token, while +// leaving every word under that length (i.e. all real-world titles) untouched. +function breakLongWords(text, maxSegmentLength) { + return text + .split(' ') + .map((word) => (word.length <= maxSegmentLength ? word : word.replace(new RegExp(`(.{${maxSegmentLength}})`, 'g'), '$1\u200B'))) + .join(' '); +} + +let assetsPromise; +function loadAssets() { + if (!assetsPromise) { + const fontsDir = path.resolve(__dirname, '../../src/fonts'); + assetsPromise = Promise.resolve({ + fonts: [ + { + name: 'Aeonik', + data: fs.readFileSync(path.join(fontsDir, 'Aeonik/Aeonik-Light.woff')), + weight: 300, + style: 'normal', + }, + { + // Static instance derived from the variable font at wght=400, + // opsz=14 (via `fonttools varLib.instancer`) — same reason as + // Noto Sans Mono below, satori's font parser can't load a + // variable font directly, and Google Fonts doesn't publish a + // static build of this family either. + name: 'Inter', + data: fs.readFileSync(path.join(fontsDir, 'Inter/Inter-Regular.ttf')), + weight: 400, + style: 'normal', + }, + { + // Static instance derived from the variable font at wght=400, + // wdth=100 (via `fonttools varLib.instancer`) — satori's font + // parser (an older opentype.js fork) crashes on the variable font + // itself (no fvar table support), so we can't load it directly. + name: 'Noto Sans Mono', + data: fs.readFileSync(path.join(fontsDir, 'Noto Sans Mono/NotoSansMono-Regular.ttf')), + weight: 400, + style: 'normal', + }, + ], + logo: dataUri(path.resolve(__dirname, '../../static/img/assets/temporal-logo.svg'), 'image/svg+xml'), + // Lives next to render.js (not under static/) because nothing on the + // live site ever links to it — static/ gets copied verbatim into the + // public build output, which would ship this ~780KB source texture to + // every deploy for zero benefit. Unlike temporal-logo.svg above, which + // is also used elsewhere on the site and genuinely needs to be public. + background: dataUri(path.resolve(__dirname, 'assets/og-background.png'), 'image/png'), + }); + } + return assetsPromise; +} + +function buildTree({ title, description }, { logo, background }) { + return { + type: 'div', + props: { + style: { + width: '100%', + height: '100%', + display: 'flex', + position: 'relative', + }, + children: [ + { + type: 'img', + props: { + src: background, + width: CARD_WIDTH, + height: CARD_HEIGHT, + style: { position: 'absolute', top: 0, left: 0 }, + }, + }, + { + type: 'div', + props: { + style: { + position: 'relative', + width: '100%', + height: '100%', + display: 'flex', + flexDirection: 'column', + justifyContent: 'space-between', + padding: '64px', + fontFamily: 'Inter', + }, + children: [ + { + type: 'div', + props: { + style: { display: 'flex', flexDirection: 'column', gap: 24 }, + children: [ + { + type: 'div', + props: { + style: { + // display: 'block' (not 'flex') is required for + // lineClamp to actually truncate — satori silently + // ignores lineClamp on flex containers, same as the + // description div below. + display: 'block', + fontFamily: 'Aeonik', + fontSize: 75, + fontWeight: 300, + color: TITLE_COLOR, + lineHeight: 1.15, + maxWidth: '92%', + lineClamp: TITLE_MAX_LINES, + }, + children: breakLongWords(title, 25), + }, + }, + description + ? { + type: 'div', + props: { + style: { + display: 'block', + fontFamily: 'Inter', + fontSize: 30, + fontWeight: 400, + color: SUBTITLE_COLOR, + lineHeight: 1.35, + maxWidth: '80%', + lineClamp: DESCRIPTION_MAX_LINES, + }, + children: breakLongWords(description, 40), + }, + } + : null, + ].filter(Boolean), + }, + }, + { + type: 'div', + props: { + style: { display: 'flex', flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }, + children: [ + { + type: 'img', + props: { src: logo, width: Math.round(LOGO_HEIGHT * LOGO_ASPECT_RATIO), height: LOGO_HEIGHT }, + }, + { + type: 'div', + props: { + style: { + display: 'flex', + fontFamily: 'Noto Sans Mono', + fontSize: 25, + fontWeight: 400, + color: FOOTER_COLOR, + }, + children: 'DOCS.TEMPORAL.IO', + }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + }; +} + +async function renderCard({ title, description }) { + const { fonts, logo, background } = await loadAssets(); + const svg = await satori(buildTree({ title, description }, { logo, background }), { + width: CARD_WIDTH, + height: CARD_HEIGHT, + fonts, + }); + const resvg = new Resvg(svg, { + fitTo: { mode: 'width', value: CARD_WIDTH }, + }); + const png = resvg.render().asPng(); + + // resvg's PNG encoder has no compression/quality knobs. A textured photo- + // like background (grid lines + gradient + grain) compresses terribly as + // lossless PNG (~320KB/card even palette-quantized) — palette quantization + // works well for flat/gradient-only art but not this kind of detail. + // JPEG is built for exactly this kind of content: quality 80 lands at + // ~70KB/card with no visible artifacting around the text edges (checked + // at 2x zoom), an ~80% reduction over the palette-PNG approach. + return sharp(png).jpeg({ quality: 80 }).toBuffer(); +} + +module.exports = { renderCard, TEMPLATE_VERSION, CARD_WIDTH, CARD_HEIGHT, IMAGE_EXTENSION: 'jpg' }; diff --git a/plugins/shared/docsRouting.js b/plugins/shared/docsRouting.js new file mode 100644 index 0000000000..318b1f0905 --- /dev/null +++ b/plugins/shared/docsRouting.js @@ -0,0 +1,40 @@ +const fs = require('fs'); +const path = require('path'); + +// Shared by plugins/markdown-pages and plugins/og-image, which both need to +// walk the same docs tree and independently re-derive the route Docusaurus +// will build each file to. Keeping this in one place means the two plugins +// can't silently drift apart on what counts as a routed doc page. + +function walkDir(dir) { + if (!fs.existsSync(dir)) return []; + return fs.readdirSync(dir).flatMap((name) => { + const full = path.join(dir, name); + if (fs.statSync(full).isDirectory()) return walkDir(full); + if (/\.(md|mdx)$/i.test(name)) return [full]; + return []; + }); +} + +// Mirrors Docusaurus's own route resolution (front-matter `slug` takes +// precedence, then `id`, then the file path) so callers land on the same +// route Docusaurus actually builds the page at. +function resolveUrlPath(docsDir, filePath, frontmatter) { + if (frontmatter.slug) { + const slug = frontmatter.slug.replace(/^\/+/, '').replace(/\/+$/, ''); + return slug || 'index'; + } + const rel = path.relative(docsDir, filePath).replace(/\\/g, '/'); + const withoutExt = rel.replace(/\.(md|mdx)$/i, ''); + const id = frontmatter.id || path.basename(withoutExt); + const dir = path.dirname(withoutExt); + if (dir === '.') return id === 'index' ? 'index' : id; + // Docusaurus treats a doc named the same as its parent folder (e.g. + // `event-history/event-history.mdx`) as that folder's index page, the + // same way it treats `index.mdx`/`README.mdx`. Mirror that convention + // here so callers land on the same route as the real page. + if (id === 'index' || id === path.basename(dir)) return dir; + return `${dir}/${id}`; +} + +module.exports = { walkDir, resolveUrlPath }; diff --git a/readme/MERMAID.md b/readme/MERMAID.md new file mode 100644 index 0000000000..ecf997d76e --- /dev/null +++ b/readme/MERMAID.md @@ -0,0 +1,96 @@ +# Mermaid diagrams + +Guidance for authoring [Mermaid](https://mermaid.js.org) diagrams in this repository. Read this before adding or editing a diagram — the two most common ways to break dark mode (hardcoding colors, and one specific `%%{init}%%` option) are both covered below. + +- [How diagrams are styled](#how-diagrams-are-styled) +- [Quick start](#quick-start) +- [Adding color: use classDef, never hardcode hex](#adding-color-use-classdef-never-hardcode-hex) +- [Layout and spacing](#layout-and-spacing) +- [Common pitfalls](#common-pitfalls) +- [Where this lives](#where-this-lives) + +## How diagrams are styled + +Every diagram on the site gets brand colors, a consistent font, and theme-aware light/dark adaptation automatically, with no per-diagram configuration. This works by overriding Mermaid's generated SVG classes globally via CSS custom properties, rather than through Mermaid's own `theme`/`themeVariables` API — Docusaurus's `themeConfig.mermaid` can't express different colors for light vs. dark mode (its `options` are shared across both), and Mermaid's own themes reject `var()` inside `themeVariables` outright. So don't reach for `%%{init}%%` to set colors — it won't survive dark mode, and it isn't necessary. + +Write a plain ` ```mermaid ` block with no styling directives and it will already match the rest of the site in both themes. + +## Quick start + +A minimal flowchart and sequence diagram, copyable as a starting point: + +````markdown +```mermaid +flowchart TD + Start([Start]) --> Decision{Condition?} + Decision -->|Yes| StepA[Do something] + Decision -->|No| StepB[Do something else] + StepA --> End([Done]) + StepB --> End +``` +```` + +````markdown +```mermaid +sequenceDiagram + participant Client + participant Workflow + + Client->>+Workflow: Start + Workflow->>Workflow: Process + Workflow-->>-Client: Result +``` +```` + +Neither needs a `classDef`, a color, or a spacing option. If a diagram feels cramped, that's worth reporting as a global spacing issue (see [Where this lives](#where-this-lives)) rather than fixing with a per-diagram `%%{init}%%` override. + +## Adding color: use classDef, never hardcode hex + +If a diagram needs to call out specific nodes or subgraphs — a success path, a failure branch, a small set of parallel categories — use Mermaid's `classDef` + `class` mechanism, **not** `style NodeId fill:#hex` and not a `classDef` with a literal color in it. A hardcoded hex value is baked in as an inline `style="fill:#hex !important"` attribute, which no external stylesheet can override — the box is stuck with whatever color you picked, in both themes, forever. This is a real bug that shipped and had to be migrated back out (see `docs/guides/rate-limit-downstream-apis.mdx` and `docs/guides/route-specialized-workloads.mdx` in git history for the before/after). + +Instead, register a semantic class with no color in it, and let the site's CSS supply the actual (theme-aware) colors: + +``` +classDef success stroke-width:1px +class Step1,Step2 success +``` + +Both nodes and subgraphs support this (`class SomeSubgraph success` works the same as `class SomeNode success`). + +Available classes today: + +| Class | Meaning | Use for | +|---|---|---| +| `success` | Good / completed | A step that succeeded | +| `compensation` | Rollback / cleanup | A saga compensation step | +| `wait` | Pending / waiting | A step blocked on a signal, timer, or external event | +| `complete` | Final success | The terminal success state of a flow | +| `fail` | Error / failure | A terminal failure state | +| `highlight` | Emphasis | Something worth calling out that isn't a state | +| `blue`, `amber`, `purple` | No inherent meaning | Distinguishing N parallel categories/branches with nothing to do with success or failure (e.g. resource types, downstream services) | + +`blue`/`amber`/`purple` are named for their color, not a state, on purpose — don't repurpose `success`/`fail`/etc. for a categorical distinction just because the color happens to look right; it reads as a state to the next person editing the file. + +Need a category these don't cover? Add a new class following the exact pattern in `src/css/custom.css` (search for `--dp-mermaid-`) rather than reaching for `style fill:`. + +## Layout and spacing + +Font, node/rank spacing, and curve style are all set once, globally, in `themeConfig.mermaid.options` in `docusaurus.config.js` (sourced from `src/constants/mermaidTheme.js`). Don't set `flowchart`/`sequence` spacing per-diagram via `%%{init}%%` — it'll be inconsistent with every other diagram on the site, and if you're trying to fix cramped text, the global settings are almost certainly the better place to do it. + +One exception exists in the wild (`docs/cloud/connectivity/aws-connectivity.mdx`, predating this convention) — don't copy it for new diagrams. + +## Common pitfalls + +- **Don't hardcode hex colors** (`style X fill:#hex`) — see [above](#adding-color-use-classdef-never-hardcode-hex). +- **Don't set `sequence: { wrap: true }`** in a per-diagram `%%{init}%%`. It's deliberately left out of the global config: Mermaid mis-sizes a note or message box whenever the text already has an explicit `
` line break (a common pattern here) while wrap is on — the box comes out narrower than the text actually needs, and the overflow isn't clipped, so it renders as a broken, near-collapsed sliver. Confirmed directly; this cost real debugging time in production once already. +- **Wrap long lines manually with `
`** instead of relying on auto-wrap — sequence diagrams don't auto-wrap by design (see above), so a long single-line message or note will render as one long line unless you break it yourself at a sensible point. +- **A subgraph title needs to fit on one line at the width Mermaid gives it.** Don't add padding/margin to try to fix a cramped-looking title — Mermaid measures the *unpadded* text to size its container with no slack, so added padding can tip an otherwise-fine title into wrapping, and the overflow won't be clipped (this happened, and was fixed by removing padding, not adding it). +- **Don't write a literal `rect rgb(...) ... end` color** for a sequence-diagram highlight block. It renders as a plain `` with no `style` attribute, so `src/css/custom.css` already overrides it globally (search `.rect`) to a theme-aware color — no per-diagram color needed, and message text drawn over it no longer needs a contrast compromise. + +## Where this lives + +- `src/constants/mermaidTheme.js` — font, spacing, curve style, and the light/dark base theme names. Single source of truth. +- `src/css/custom.css` (search `--mermaid-` and `--dp-mermaid-`) — every color, in both themes, plus the fixes for Mermaid rendering quirks referenced above. Each rule has a comment explaining why it exists; read one before changing it; there's usually a reason a fix looks the way it does. +- `docusaurus.config.js` — wires `mermaidTheme.js` into `themeConfig.mermaid`. + +If you want to preview a CSS or config change against every diagram in the repo before/after, in both themes, without rendering the whole site — it's straightforward to script directly against the `mermaid` package (already a transitive dependency via `@docusaurus/theme-mermaid`) and a headless render of each ` ```mermaid ` block found under `docs/`. Nothing checked in currently does this; build one locally if you need it. diff --git a/readme/ZOOMABLE_IMAGES.md b/readme/ZOOMABLE_IMAGES.md new file mode 100644 index 0000000000..586ad5b28e --- /dev/null +++ b/readme/ZOOMABLE_IMAGES.md @@ -0,0 +1,193 @@ +# Zoomable images (Stripe-style click-to-expand) + +_Added June 2026._ + +## Summary + +Large images in the docs are now **click-to-expand**, matching the behavior on +[Stripe's docs](https://docs.stripe.com/payment-links/buy-button#customize-the-button): + +- An image sits in the normal content column at its fitted size. +- **If the image is wider than the space available**, hovering it shows a `zoom-in` cursor and clicking opens a full-width modal of the image at full resolution. +- Inside the modal the cursor is `zoom-out`; clicking the image, clicking the backdrop, pressing `Escape`, or the `×` button closes it and restores the page. +- **If the image already fits, nothing changes** — no cursor change, no modal, no behavioral difference. + +The effect is automatic for every image in the docs. Authors do not need to do anything; whether an image is zoomable is decided at runtime by comparing the image's natural pixel width to the width it is actually displayed at. + +## Opting out: `` + +Some images are decorative (e.g. the SDK banners on the `/develop` landing pages) and should never expand, even though they are downscaled. Wrap them in `` to disable the click-to-expand affordance: + +```mdx + + +![.NET](/img/assets/banner-dotnet-temporal.png) + + +``` + +- `` is registered globally (`src/theme/MDXComponents.tsx`), so **no import is needed** in `.mdx` files. It is also exported from `src/components` for explicit use. +- Leave blank lines around the Markdown image inside the wrapper so MDX parses it as Markdown. +- The wrapper can contain multiple images, links, or captioned images — everything inside it renders as a plain, non-zoomable image. +- It works by providing a React context flag that `ZoomableImage` reads (`useNoZoom()`); when set, the overflow check never marks the image zoomable, so there is no zoom cursor and no modal. + +All SDK banners under `docs/develop/**` are wrapped in ``. + +## What changed + +| File | Change | +|---|---| +| `src/components/images/ZoomableImage.tsx` *(new)* | Core component. Detects overflow and renders the modal. | +| `src/components/images/ZoomableImage.module.css` *(new)* | Cursor, modal/backdrop, and `height: auto` styles. | +| `src/theme/MDXComponents.tsx` | Overrides the global `img` renderer, so **every Markdown image** (`![alt](src)`) flows through `ZoomableImage`; also registers `` globally for opt-out. | +| `src/components/images/CaptionedImage.js` | Refactored to delegate to `ZoomableImage` while keeping captions and dark/light support. | +| `src/components/images/ZoomingImage.js`, `EnlargeImage.js` | Legacy components; kept working with their existing props but now open the shared modal instead of an inline toggle / new tab. | +| `src/components/images/NoZoom.tsx` *(new)* | Context wrapper that opts a subtree of images out of zoom. | +| `src/components/index.js` | Exports `ZoomableImage` and `NoZoom`. | + +### How overflow is detected + +On image load and whenever the column is resized (via a `ResizeObserver`), the component compares `img.naturalWidth` to the rendered `img.clientWidth`. The image is treated as zoomable only when it is being downscaled (`naturalWidth > clientWidth`). This is why the same image can be zoomable on a narrow laptop column (~630px) but not on a wide monitor (~1300px), and why high-resolution ("2x") screenshots — the bulk of our images — are zoomable. + +### Aspect-ratio fix (important) + +Docusaurus adds intrinsic `width` and `height` attributes to Markdown images (to prevent layout shift). The stock theme image applies a class that forces `height: auto`, so the height scales with the width under `max-width: 100%`. `ZoomableImage` now applies the same `height: auto` rule (`.image` in its CSS module). Without it, images were rendered at their fitted width but full intrinsic height — visibly squished. If you ever see distorted images, this class is the first thing to check. + +## Benefits + +- **Readable detail on dense screenshots.** Many UI screenshots are 1600–4096px wide and get downscaled into a ~700px column; users can now open them at full resolution. +- **Zero author overhead.** Works on existing and future Markdown images automatically — no new component to remember. +- **No false affordance.** Images that already fit are completely untouched, so we don't invite clicks that do nothing. +- **One consistent interaction.** The older `EnlargeImage` (opened a raw image in a new tab), `ZoomingImage`, and `CaptionedImage zoom="true"` (inline toggles) now share one modal. +- **Accessible modal.** `role="dialog"`, `aria-modal`, `Escape` to close, focusable close button, and body-scroll lock while open. + +## Note: dark/light images now load on demand (performance trade-off) + +`CaptionedImage` supports a `srcDark` variant. The behavior changed: + +- **Before:** both the light and dark `` were rendered into the DOM and swapped with CSS (`visibility`). Both files were downloaded up front, so toggling the site theme was instant. +- **Now:** the active variant is chosen in JavaScript with `useColorMode`, so **only the current theme's image is downloaded.** + +**Trade-off:** initial page load fetches one image instead of two (faster, less bandwidth). But if a reader **switches the site theme while the page is open**, the other variant has not been downloaded yet, so it is fetched on the spot — the swapped image may flash or appear blank for a moment on slow connections or for large files. The fetch is cached after the first switch. This only affects images that actually declare a `srcDark`; plain Markdown images have a single source and are unaffected. + +> (in my opinion, this is a fine tradeoff... less bandwidth for the vast majority of cases, I don't believe theme switching is a common activity) + +## How to test + +1. `yarn start` and open a page with large images, e.g. [`/cloud/billing`](https://docs.temporal.io/cloud/billing) or [`/develop/go/client/temporal-client`](https://docs.temporal.io/develop/go/client/temporal-client). +2. Hover an oversized image → cursor becomes `zoom-in`. Click → full-width modal opens. Click the image / backdrop / `×`, or press `Escape` → it closes and the page scroll is restored. +3. Open a page whose images already fit → confirm there is **no** zoom cursor and clicking does nothing. +4. **Aspect ratio:** confirm images are not squished; compare against production ([docs.temporal.io](https://docs.temporal.io)). +5. **Responsive:** narrow the browser window and reload — images that fit at full width become zoomable as the column shrinks. +6. **Dark/light + captions:** open [`/codec-server`](https://docs.temporal.io/codec-server), toggle the navbar theme switch, and confirm the diagrams swap to their dark/light variants and the captions still render. Note the on-demand fetch described above on first toggle. + +## Pages affected + +### Pages that previously had explicit zoom — now use the shared modal + +These already offered some form of "enlarge". Their interaction is now the unified click-to-expand modal. + +| Page | Previous mechanism | +|---|---| +| [/develop/task-queue-priority-fairness](https://docs.temporal.io/develop/task-queue-priority-fairness) | EnlargeImage — click opened the raw image in a new browser tab | +| [/cloud/namespaces](https://docs.temporal.io/cloud/namespaces) | ZoomingImage (thumbnail→inline) + CaptionedImage | +| [/cloud/metrics/prometheus-grafana](https://docs.temporal.io/cloud/metrics/prometheus-grafana) | ZoomingImage (thumbnail→inline) | +| [/cloud/high-availability/ha-connectivity](https://docs.temporal.io/cloud/high-availability/ha-connectivity) | CaptionedImage zoom="true" (inline toggle) | +| [/cloud/migrate/migrate-within-cloud](https://docs.temporal.io/cloud/migrate/migrate-within-cloud) | CaptionedImage zoom="true" (inline toggle) | +| [/nexus](https://docs.temporal.io/nexus) | CaptionedImage zoom="true" (inline toggle) | +| [/nexus/execution-debugging](https://docs.temporal.io/nexus/execution-debugging) | CaptionedImage zoom="true" (inline toggle) | + +> **Heads-up / follow-ups:** the new logic only zooms images that genuinely overflow the column, so a few *small* images that were manually marked zoomable no longer zoom: +> - `migrate-within-cloud.mdx` — its four `zoom="true"` images are only 310–751px wide (they fit the column), so they no longer zoom. They'd need higher-resolution sources to be worth expanding. +> - `namespaces.mdx` — the `ZoomingImage` there (`cloud-account-id.png`, 218px) was a tiny thumbnail; expanding it is no longer meaningful. The large `CaptionedImage`s on the same page do zoom. +> - `sdk-metrics-setup.mdx` — imports `ZoomingImage` but never renders it; the unused import can be removed. + +### Newly zoomable pages (44) + +Pages that did **not** previously have any zoom but contain raster images ≥ 800px wide, which now expand when downscaled. Grouped by area; the width shown is the page's largest image. + +> The 60 SDK landing pages under `/develop/**` whose only large image was the shared _1800px_ banner are **excluded**: those banners are now wrapped in `` (see "Opting out" above), so they no longer zoom. + +#### Best practices + +- [/best-practices/worker](https://docs.temporal.io/best-practices/worker) — _max 1200px_ + +#### Temporal Cloud + +- [/cloud/billing](https://docs.temporal.io/cloud/billing) — _max 3842px_ +- [/cloud/actions-usage](https://docs.temporal.io/cloud/actions-usage) — _max 2796px_ +- [/cloud/capacity-modes](https://docs.temporal.io/cloud/capacity-modes) — _max 2686px_ +- [/cloud/audit-logs-gcp](https://docs.temporal.io/cloud/audit-logs-gcp) — _max 2548px_ +- [/cloud/high-availability/failovers](https://docs.temporal.io/cloud/high-availability/failovers) — _max 2407px_ +- [/cloud/export/gcp-export-gcs](https://docs.temporal.io/cloud/export/gcp-export-gcs) — _max 2351px_ +- [/cloud/metrics/openmetrics/migration-guide](https://docs.temporal.io/cloud/metrics/openmetrics/migration-guide) — _max 2200px_ +- [/cloud/connectivity/gcp-connectivity](https://docs.temporal.io/cloud/connectivity/gcp-connectivity) — _max 2198px_ +- [/cloud/connectivity/aws-connectivity](https://docs.temporal.io/cloud/connectivity/aws-connectivity) — _max 1999px_ +- [/cloud/export/aws-export-s3](https://docs.temporal.io/cloud/export/aws-export-s3) — _max 1981px_ +- [/cloud/nexus](https://docs.temporal.io/cloud/nexus) — _max 1714px_ +- [/cloud/terraform-provider](https://docs.temporal.io/cloud/terraform-provider) — _max 1478px_ +- [/cloud/migrate/automated](https://docs.temporal.io/cloud/migrate/automated) — _max 1202px_ +- [/cloud/api-keys](https://docs.temporal.io/cloud/api-keys) — _max 822px_ + +#### Develop + +- [/develop/worker-performance](https://docs.temporal.io/develop/worker-performance) — _max 2248px_ + +#### Develop — dotnet + +- [/develop/dotnet/client/temporal-client](https://docs.temporal.io/develop/dotnet/client/temporal-client) — _max 4096px_ +- [/develop/dotnet/nexus/feature-guide](https://docs.temporal.io/develop/dotnet/nexus/feature-guide) — _max 3542px_ +- [/develop/dotnet/best-practices/converters-and-encryption](https://docs.temporal.io/develop/dotnet/best-practices/converters-and-encryption) — _max 1320px_ + +#### Develop — go + +- [/develop/go/client/temporal-client](https://docs.temporal.io/develop/go/client/temporal-client) — _max 4096px_ +- [/develop/go/nexus/feature-guide](https://docs.temporal.io/develop/go/nexus/feature-guide) — _max 3542px_ +- [/develop/go/data-handling/data-conversion](https://docs.temporal.io/develop/go/data-handling/data-conversion) — _max 1320px_ + +#### Develop — java + +- [/develop/java/client/temporal-client](https://docs.temporal.io/develop/java/client/temporal-client) — _max 4096px_ +- [/develop/java/nexus/feature-guide](https://docs.temporal.io/develop/java/nexus/feature-guide) — _max 3542px_ +- [/develop/java/best-practices/converters-and-encryption](https://docs.temporal.io/develop/java/best-practices/converters-and-encryption) — _max 1320px_ + +#### Develop — python + +- [/develop/python/client/temporal-client](https://docs.temporal.io/develop/python/client/temporal-client) — _max 4096px_ +- [/develop/python/nexus/feature-guide](https://docs.temporal.io/develop/python/nexus/feature-guide) — _max 3542px_ + +#### Develop — ruby + +- [/develop/ruby/client/temporal-client](https://docs.temporal.io/develop/ruby/client/temporal-client) — _max 4096px_ +- [/develop/ruby/best-practices/converters-and-encryption](https://docs.temporal.io/develop/ruby/best-practices/converters-and-encryption) — _max 1320px_ + +#### Develop — typescript + +- [/develop/typescript/client/temporal-client](https://docs.temporal.io/develop/typescript/client/temporal-client) — _max 4096px_ +- [/develop/typescript/nexus/feature-guide](https://docs.temporal.io/develop/typescript/nexus/feature-guide) — _max 3542px_ +- [/develop/typescript/best-practices/debugging](https://docs.temporal.io/develop/typescript/best-practices/debugging) — _max 2702px_ + +#### Encyclopedia + +- [/nexus/operations](https://docs.temporal.io/nexus/operations) — _max 3304px_ +- [/handling-messages](https://docs.temporal.io/handling-messages) — _max 2006px_ +- [/nexus/registry](https://docs.temporal.io/nexus/registry) — _max 1858px_ +- [/nexus/security](https://docs.temporal.io/nexus/security) — _max 1342px_ +- [/activity-definition](https://docs.temporal.io/activity-definition) — _max 1154px_ +- [/task-queue](https://docs.temporal.io/task-queue) — _max 942px_ +- [/encyclopedia/retry-policies](https://docs.temporal.io/encyclopedia/retry-policies) — _max 800px_ + +#### Evaluate + +- [/cloud/limits](https://docs.temporal.io/cloud/limits) — _max 2752px_ + +#### Production deployment + +- [/self-hosted-guide/monitoring](https://docs.temporal.io/self-hosted-guide/monitoring) — _max 2750px_ +- [/self-hosted-guide/server-frontend-api-reference](https://docs.temporal.io/self-hosted-guide/server-frontend-api-reference) — _max 2148px_ +- [/production-deployment/data-encryption](https://docs.temporal.io/production-deployment/data-encryption) — _max 2004px_ +- [/self-hosted-guide/security](https://docs.temporal.io/self-hosted-guide/security) — _max 1407px_ + +--- + +_The page lists above cover raster images (PNG/JPG/etc.), which are the large screenshots that motivated this change. SVG diagrams are vector and stay crisp at any size; they only trigger the zoom affordance if they declare an intrinsic width larger than the column, and expanding them adds little._ diff --git a/sample-apps/go/cloud/go.mod b/sample-apps/go/cloud/go.mod index 212cce7fbb..847fdd9a64 100644 --- a/sample-apps/go/cloud/go.mod +++ b/sample-apps/go/cloud/go.mod @@ -1,6 +1,6 @@ module documentation-samples-go/cloud -go 1.24.0 +go 1.25.0 require go.temporal.io/sdk v1.21.1 @@ -21,9 +21,9 @@ require ( github.com/stretchr/testify v1.8.1 // indirect go.temporal.io/api v1.16.0 // indirect go.uber.org/atomic v1.9.0 // indirect - golang.org/x/net v0.48.0 // indirect - golang.org/x/sys v0.39.0 // indirect - golang.org/x/text v0.32.0 // indirect + golang.org/x/net v0.55.0 // indirect + golang.org/x/sys v0.45.0 // indirect + golang.org/x/text v0.37.0 // indirect golang.org/x/time v0.1.0 // indirect google.golang.org/genproto v0.0.0-20230127162408-596548ed4efa // indirect google.golang.org/grpc v1.79.3 // indirect diff --git a/sample-apps/go/cloud/go.sum b/sample-apps/go/cloud/go.sum index 9a9e1ebdf9..b52c950106 100644 --- a/sample-apps/go/cloud/go.sum +++ b/sample-apps/go/cloud/go.sum @@ -701,8 +701,8 @@ golang.org/x/net v0.0.0-20221012135044-0b7e1fb9d458/go.mod h1:YDH+HFinaLZZlnHAfS golang.org/x/net v0.0.0-20221014081412-f15817d10f9b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= golang.org/x/net v0.4.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE= golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws= -golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= -golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -808,8 +808,8 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= -golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA= @@ -827,8 +827,8 @@ golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= -golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= diff --git a/sample-apps/go/features/cancellation/go.mod b/sample-apps/go/features/cancellation/go.mod index fa6388c12f..ebef45e79b 100644 --- a/sample-apps/go/features/cancellation/go.mod +++ b/sample-apps/go/features/cancellation/go.mod @@ -1,6 +1,6 @@ module documentation-samples-go/features/cancellation -go 1.24.0 +go 1.25.0 require go.temporal.io/sdk v1.21.1 @@ -21,9 +21,9 @@ require ( github.com/stretchr/testify v1.8.1 // indirect go.temporal.io/api v1.16.0 // indirect go.uber.org/atomic v1.9.0 // indirect - golang.org/x/net v0.48.0 // indirect - golang.org/x/sys v0.39.0 // indirect - golang.org/x/text v0.32.0 // indirect + golang.org/x/net v0.55.0 // indirect + golang.org/x/sys v0.45.0 // indirect + golang.org/x/text v0.37.0 // indirect golang.org/x/time v0.1.0 // indirect google.golang.org/genproto v0.0.0-20230127162408-596548ed4efa // indirect google.golang.org/grpc v1.79.3 // indirect diff --git a/sample-apps/go/features/cancellation/go.sum b/sample-apps/go/features/cancellation/go.sum index 9a9e1ebdf9..b52c950106 100644 --- a/sample-apps/go/features/cancellation/go.sum +++ b/sample-apps/go/features/cancellation/go.sum @@ -701,8 +701,8 @@ golang.org/x/net v0.0.0-20221012135044-0b7e1fb9d458/go.mod h1:YDH+HFinaLZZlnHAfS golang.org/x/net v0.0.0-20221014081412-f15817d10f9b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= golang.org/x/net v0.4.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE= golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws= -golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= -golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -808,8 +808,8 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= -golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA= @@ -827,8 +827,8 @@ golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= -golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= diff --git a/sample-apps/go/features/sessions/go.mod b/sample-apps/go/features/sessions/go.mod index 9b49d263ed..d22f6bee12 100644 --- a/sample-apps/go/features/sessions/go.mod +++ b/sample-apps/go/features/sessions/go.mod @@ -1,6 +1,6 @@ module documentation-samples-go/features/sessions -go 1.24.0 +go 1.25.0 require ( github.com/stretchr/testify v1.8.1 @@ -23,9 +23,9 @@ require ( github.com/stretchr/objx v0.5.0 // indirect go.temporal.io/api v1.16.0 // indirect go.uber.org/atomic v1.9.0 // indirect - golang.org/x/net v0.48.0 // indirect - golang.org/x/sys v0.39.0 // indirect - golang.org/x/text v0.32.0 // indirect + golang.org/x/net v0.55.0 // indirect + golang.org/x/sys v0.45.0 // indirect + golang.org/x/text v0.37.0 // indirect golang.org/x/time v0.1.0 // indirect google.golang.org/genproto v0.0.0-20230127162408-596548ed4efa // indirect google.golang.org/grpc v1.79.3 // indirect diff --git a/sample-apps/go/features/sessions/go.sum b/sample-apps/go/features/sessions/go.sum index 9a9e1ebdf9..b52c950106 100644 --- a/sample-apps/go/features/sessions/go.sum +++ b/sample-apps/go/features/sessions/go.sum @@ -701,8 +701,8 @@ golang.org/x/net v0.0.0-20221012135044-0b7e1fb9d458/go.mod h1:YDH+HFinaLZZlnHAfS golang.org/x/net v0.0.0-20221014081412-f15817d10f9b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= golang.org/x/net v0.4.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE= golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws= -golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= -golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -808,8 +808,8 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= -golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA= @@ -827,8 +827,8 @@ golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= -golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= diff --git a/scripts/component-handlers/data-tables.mjs b/scripts/component-handlers/data-tables.mjs index ab09d4e8eb..2af37e4452 100644 --- a/scripts/component-handlers/data-tables.mjs +++ b/scripts/component-handlers/data-tables.mjs @@ -2,7 +2,7 @@ * component-handlers/data-tables.mjs * * Handler for . - * Component: src/components/formatting/JsonTable.js (carries a matching comment). + * Component: src/components/Tables/JsonTable.js (carries a matching comment). * * JsonTable renders, at runtime in the browser, a table fetched from a JSON * file under static/. The JSON shape is { columns: string[], rows: string[][] }. diff --git a/scripts/lint-mermaid.mjs b/scripts/lint-mermaid.mjs new file mode 100644 index 0000000000..0e68d39b9d --- /dev/null +++ b/scripts/lint-mermaid.mjs @@ -0,0 +1,148 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import DOMPurify from 'dompurify'; +import mermaidModule from 'mermaid'; + +const mermaid = mermaidModule.default ?? mermaidModule; +const DOCS_ROOT = path.resolve('docs'); +const MARKDOWN_EXTENSIONS = new Set(['.md', '.mdx']); + +function isMarkdownFile(filePath) { + return MARKDOWN_EXTENSIONS.has(path.extname(filePath)); +} + +async function walk(dir) { + const entries = await fs.readdir(dir, { withFileTypes: true }); + const results = []; + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + results.push(...(await walk(fullPath))); + } else if (entry.isFile() && isMarkdownFile(fullPath)) { + results.push(fullPath); + } + } + return results; +} + +function extractMermaidBlocks(content) { + const lines = content.split(/\r?\n/); + const blocks = []; + let inBlock = false; + let blockStartLine = 0; + let blockLines = []; + + for (let i = 0; i < lines.length; i += 1) { + const line = lines[i]; + if (!inBlock) { + if (/^\s*```mermaid\s*$/.test(line)) { + inBlock = true; + blockStartLine = i + 2; // first content line, 1-indexed + blockLines = []; + } + continue; + } + + if (/^\s*```\s*$/.test(line)) { + blocks.push({ + startLine: blockStartLine, + content: blockLines.join('\n'), + }); + inBlock = false; + blockLines = []; + continue; + } + + blockLines.push(line); + } + + if (inBlock) { + blocks.push({ + startLine: blockStartLine, + content: blockLines.join('\n'), + unterminated: true, + }); + } + + return blocks; +} + +function extractRelativeLineFromError(errorMessage) { + const match = errorMessage.match(/line\s+(\d+)/i); + return match ? Number(match[1]) : null; +} + +async function lintFile(filePath) { + const content = await fs.readFile(filePath, 'utf8'); + const blocks = extractMermaidBlocks(content); + const errors = []; + + for (const block of blocks) { + if (block.unterminated) { + errors.push({ + filePath, + line: block.startLine, + message: 'Unterminated mermaid code fence.', + }); + continue; + } + + if (!block.content.trim()) { + continue; + } + + try { + await mermaid.parse(block.content); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const relativeLine = extractRelativeLineFromError(message); + const absoluteLine = relativeLine ? block.startLine + relativeLine - 1 : block.startLine; + errors.push({ + filePath, + line: absoluteLine, + message, + }); + } + } + + return errors; +} + +async function main() { + // Mermaid's parser may call DOMPurify hooks for some diagram syntaxes. + // In Node (without a browser window), dompurify can resolve to a minimal + // implementation that lacks hook APIs; provide no-op hooks so syntax-only + // parsing still works in CI. + if (typeof DOMPurify.addHook !== 'function') { + DOMPurify.addHook = () => {}; + } + if (typeof DOMPurify.removeHook !== 'function') { + DOMPurify.removeHook = () => {}; + } + if (typeof DOMPurify.sanitize !== 'function') { + DOMPurify.sanitize = (value) => value; + } + + mermaid.initialize({ startOnLoad: false }); + + const markdownFiles = await walk(DOCS_ROOT); + const allErrors = []; + + for (const filePath of markdownFiles) { + const fileErrors = await lintFile(filePath); + allErrors.push(...fileErrors); + } + + if (allErrors.length > 0) { + console.error(`Mermaid lint failed with ${allErrors.length} error(s):`); + for (const err of allErrors) { + const relPath = path.relative(process.cwd(), err.filePath); + console.error(`- ${relPath}:${err.line} ${err.message}`); + } + process.exit(1); + } + + console.log(`Mermaid lint passed. Checked ${markdownFiles.length} markdown files.`); +} + +await main(); diff --git a/scripts/mdx-to-md.mjs b/scripts/mdx-to-md.mjs index f08b2b3cee..0109e9478e 100644 --- a/scripts/mdx-to-md.mjs +++ b/scripts/mdx-to-md.mjs @@ -53,14 +53,12 @@ export const COMPONENT_REGISTRY = { // Temporal custom components — content-bearing ZoomPanPinch: "transparent", - DiscoverableDisclosure: "transparent", RelatedReadList: "related-read", RelatedReadContainer: "related-read-container", RelatedReadItem: "related-read-item", SdkGuideLinks: "sdk-guide-links", CaptionedImage: "captioned-image", EnlargeImage: "captioned-image", - ZoomingImage: "captioned-image", PhotoCarousel: "photo-carousel", CodeSnippet: "code-snippet", SdkTabs: "sdk-tabs", @@ -85,7 +83,6 @@ export const COMPONENT_REGISTRY = { ThemedImage: "strip-block", PatternCards: "cards", QuickstartCards: "cards", - SdkLogos: "strip-block", SdkSvg: "strip-block", CloudRegionCount: "strip-block", RetrySimulator: "strip-block", @@ -1087,11 +1084,11 @@ export function transformMdx(mdxContent, options = {}) { } // --- Image components (self-closing, may span lines) --- - // → ![text](src) if ( state === State.NORMAL && - /^\s*<(CaptionedImage|EnlargeImage|ZoomingImage|Components\.CaptionedImage)\b/.test(line) + /^\s*<(CaptionedImage|EnlargeImage|Components\.CaptionedImage)\b/.test(line) ) { let tag = line; while (!/\/>/.test(tag) && i + 1 < lines.length) { diff --git a/sidebars.js b/sidebars.js index 53cfedb57a..4dab1212bf 100644 --- a/sidebars.js +++ b/sidebars.js @@ -186,7 +186,11 @@ module.exports = { type: 'doc', id: 'develop/go/nexus/index', }, - items: ['develop/go/nexus/quickstart', 'develop/go/nexus/feature-guide'], + items: [ + 'develop/go/nexus/quickstart', + 'develop/go/nexus/feature-guide', + 'develop/go/nexus/standalone-operations', + ], }, { type: 'category', @@ -306,7 +310,11 @@ module.exports = { type: 'doc', id: 'develop/java/nexus/index', }, - items: ['develop/java/nexus/quickstart', 'develop/java/nexus/feature-guide'], + items: [ + 'develop/java/nexus/quickstart', + 'develop/java/nexus/feature-guide', + 'develop/java/nexus/standalone-operations', + ], }, { type: 'category', @@ -522,7 +530,11 @@ module.exports = { type: 'doc', id: 'develop/python/nexus/index', }, - items: ['develop/python/nexus/quickstart', 'develop/python/nexus/feature-guide'], + items: [ + 'develop/python/nexus/quickstart', + 'develop/python/nexus/feature-guide', + 'develop/python/nexus/standalone-operations', + ], }, { type: 'category', @@ -680,7 +692,8 @@ module.exports = { }, items: [ 'develop/typescript/nexus/quickstart', - 'develop/typescript/nexus/feature-guide' + 'develop/typescript/nexus/feature-guide', + 'develop/typescript/nexus/standalone-operations', ], }, { @@ -722,6 +735,7 @@ module.exports = { }, items: [ 'develop/typescript/integrations/ai-sdk', + 'develop/typescript/integrations/langsmith', 'develop/typescript/integrations/openai-agents', ], }, @@ -811,7 +825,8 @@ module.exports = { }, items: [ 'develop/dotnet/nexus/quickstart', - 'develop/dotnet/nexus/feature-guide' + 'develop/dotnet/nexus/feature-guide', + 'develop/dotnet/nexus/standalone-operations', ], }, { @@ -1171,6 +1186,7 @@ module.exports = { }, items: [ 'cloud/high-availability/enable', + 'cloud/high-availability/architecture-patterns', 'cloud/high-availability/monitoring', { type: 'category', @@ -1498,6 +1514,7 @@ module.exports = { 'encyclopedia/workflow/workflow-execution/timers-delays', ], }, + 'encyclopedia/workflow/workflow-pause', 'encyclopedia/workflow/dynamic-handler', 'encyclopedia/workflow/workflow-schedule', 'encyclopedia/workflow/cron-job', @@ -1646,6 +1663,7 @@ module.exports = { items: [ 'encyclopedia/nexus/nexus-services', 'encyclopedia/nexus/nexus-operations', + 'encyclopedia/nexus/standalone-nexus-operation', 'encyclopedia/nexus/nexus-endpoints', 'encyclopedia/nexus/nexus-registry', 'encyclopedia/nexus/nexus-patterns', @@ -1698,6 +1716,25 @@ module.exports = { items: [ 'develop/standalone-activities-interactive-demo', ], + }, { + type: 'category', + label: 'Guides', + collapsed: true, + link: { + type: 'doc', + id: 'guides/index', + }, + items: [ + 'guides/entity-pattern-loyalty-points', + 'guides/saga-pattern', + 'guides/route-specialized-workloads', + 'guides/worker-execution-affinity', + 'guides/temporary-rate-limit-increases', + 'guides/reliable-document-approvals', + 'guides/rate-limit-downstream-apis', + 'guides/durable-gaming-sessions', + 'guides/lock-shared-resources', + ], }, 'integrations', 'glossary', @@ -1707,6 +1744,124 @@ module.exports = { // dirName: "./dev-guide", // '.' means the current docs folder // }, ], + designPatterns: [ + 'design-patterns/index', + { + type: 'category', + label: "Task Orchestration Patterns", + collapsed: true, + link: { type: 'doc', id: 'design-patterns/task-orchestration-patterns' }, + items: [ + 'design-patterns/child-workflows', + 'design-patterns/parallel-execution', + 'design-patterns/pick-first', + ], + }, + { + type: 'category', + label: "Workflow Messaging Patterns", + collapsed: true, + link: { type: 'doc', id: 'design-patterns/workflow-messaging-patterns' }, + items: [ + 'design-patterns/signal-with-start', + 'design-patterns/request-response-via-updates', + 'design-patterns/event-accumulator', + ], + }, + { + type: 'category', + label: "Entity & Lifecycle Patterns", + collapsed: true, + link: { type: 'doc', id: 'design-patterns/entity-lifecycle-patterns' }, + items: [ + 'design-patterns/entity-workflow', + 'design-patterns/continue-as-new', + 'design-patterns/updatable-timer', + ], + }, + { + type: 'category', + label: "External Interaction Patterns", + collapsed: true, + link: { type: 'doc', id: 'design-patterns/external-interaction-patterns' }, + items: [ + 'design-patterns/polling', + 'design-patterns/long-running-activity', + 'design-patterns/delayed-start', + 'design-patterns/delayed-callback', + 'design-patterns/approval', + ], + }, + { + type: 'category', + label: "Distributed Transaction Patterns", + collapsed: true, + link: { type: 'doc', id: 'design-patterns/distributed-transaction-patterns' }, + items: [ + 'design-patterns/saga-pattern', + 'design-patterns/early-return', + ], + }, + { + type: 'category', + label: "Error Handling & Retry Patterns", + collapsed: true, + link: { type: 'doc', id: 'design-patterns/error-handling-patterns' }, + items: [ + 'design-patterns/fixed-count-retries', + 'design-patterns/fixed-wall-time-retries', + 'design-patterns/non-retryable-errors', + 'design-patterns/delayed-retry', + 'design-patterns/fast-slow-retries', + 'design-patterns/retry-metrics', + 'design-patterns/resumable-activity', + ], + }, + { + type: 'category', + label: "Batch Processing Patterns", + collapsed: true, + link: { type: 'doc', id: 'design-patterns/batch-processing-patterns' }, + items: [ + 'design-patterns/fanout-child-workflows', + 'design-patterns/batch-iterator', + 'design-patterns/sliding-window', + 'design-patterns/mapreduce-tree', + ], + }, + { + type: 'category', + label: "QoS & Throughput Patterns", + collapsed: true, + link: { type: 'doc', id: 'design-patterns/qos-throughput-patterns' }, + items: [ + 'design-patterns/downstream-rate-limiting', + 'design-patterns/priority-task-queues', + 'design-patterns/fairness', + ], + }, + { + type: 'category', + label: "Performance & Latency Patterns", + collapsed: true, + link: { type: 'doc', id: 'design-patterns/performance-latency-patterns' }, + items: [ + 'design-patterns/local-activities', + 'design-patterns/early-return-local-activities', + 'design-patterns/eager-workflow-start', + ], + }, + { + type: 'category', + label: "Worker Configuration Patterns", + collapsed: true, + link: { type: 'doc', id: 'design-patterns/worker-configuration-patterns' }, + items: [ + 'design-patterns/worker-specific-taskqueue', + 'design-patterns/activity-dependency-injection', + ], + }, + ], tctl: [ 'tctl-v1/index', 'tctl-v1/activity', diff --git a/src/components/CookbookCategoryIndex.tsx b/src/components/Cookbook/DocItem/CookbookCategoryIndex.tsx similarity index 88% rename from src/components/CookbookCategoryIndex.tsx rename to src/components/Cookbook/DocItem/CookbookCategoryIndex.tsx index 245aee5bda..908ad72964 100644 --- a/src/components/CookbookCategoryIndex.tsx +++ b/src/components/Cookbook/DocItem/CookbookCategoryIndex.tsx @@ -1,6 +1,6 @@ import React from 'react'; import type {Props} from '@theme/DocCategoryGeneratedIndexPage'; -import CookbookHome from '@site/src/components/elements/CookbookHome'; +import { CookbookHome } from '@site/src/components'; import Original from '@theme-original/DocCategoryGeneratedIndexPage'; export default function CookbookCategoryIndex(props: Props) { diff --git a/src/components/CookbookDocItem.module.css b/src/components/Cookbook/DocItem/CookbookDocItem.module.css similarity index 100% rename from src/components/CookbookDocItem.module.css rename to src/components/Cookbook/DocItem/CookbookDocItem.module.css diff --git a/src/components/CookbookDocItem.tsx b/src/components/Cookbook/DocItem/CookbookDocItem.tsx similarity index 98% rename from src/components/CookbookDocItem.tsx rename to src/components/Cookbook/DocItem/CookbookDocItem.tsx index fa48065fc2..6cb1e73d41 100644 --- a/src/components/CookbookDocItem.tsx +++ b/src/components/Cookbook/DocItem/CookbookDocItem.tsx @@ -11,6 +11,7 @@ import { MDXProvider } from '@mdx-js/react'; import MDXComponents from '@theme/MDXComponents'; import clsx from 'clsx'; import { usePluginData } from '@docusaurus/useGlobalData'; +import DocItemStructuredData from '@site/src/theme/DocItem/StructuredData'; import styles from './CookbookDocItem.module.css'; @@ -250,6 +251,7 @@ function InnerCookbookDocItem({ content, tags }: CookbookDocItemProps) { return ( + {title} diff --git a/src/components/elements/CookbookHome.module.css b/src/components/Cookbook/Home/CookbookHome.module.css similarity index 100% rename from src/components/elements/CookbookHome.module.css rename to src/components/Cookbook/Home/CookbookHome.module.css diff --git a/src/components/elements/CookbookHome.tsx b/src/components/Cookbook/Home/CookbookHome.tsx similarity index 99% rename from src/components/elements/CookbookHome.tsx rename to src/components/Cookbook/Home/CookbookHome.tsx index 9f65bfe530..c02254cd25 100644 --- a/src/components/elements/CookbookHome.tsx +++ b/src/components/Cookbook/Home/CookbookHome.tsx @@ -1,9 +1,9 @@ import React from 'react'; -import Tile from '@site/src/components/elements/Tile'; import { useAllDocsData } from '@docusaurus/plugin-content-docs/client'; import styles from './CookbookHome.module.css'; import useGlobalData, { usePluginData } from '@docusaurus/useGlobalData'; import clsx from 'clsx'; +import Tile from '../../elements/Tile/Tile'; type CookbookItem = { id: string; diff --git a/src/components/Cookbook/index.js b/src/components/Cookbook/index.js new file mode 100644 index 0000000000..84784abd61 --- /dev/null +++ b/src/components/Cookbook/index.js @@ -0,0 +1,3 @@ +export { default as CookbookHome } from './Home/CookbookHome' +export * from './DocItem/CookbookCategoryIndex' +export * from './DocItem/CookbookDocItem' \ No newline at end of file diff --git a/src/components/elements/RetrySimulator.js b/src/components/Demos/RetrySimulator/RetrySimulator.js similarity index 100% rename from src/components/elements/RetrySimulator.js rename to src/components/Demos/RetrySimulator/RetrySimulator.js diff --git a/src/components/elements/retry-simulator.module.css b/src/components/Demos/RetrySimulator/retry-simulator.module.css similarity index 100% rename from src/components/elements/retry-simulator.module.css rename to src/components/Demos/RetrySimulator/retry-simulator.module.css diff --git a/src/components/elements/ServerlessWorkerDemo.js b/src/components/Demos/ServerlessWorker/ServerlessWorkerDemo.js similarity index 100% rename from src/components/elements/ServerlessWorkerDemo.js rename to src/components/Demos/ServerlessWorker/ServerlessWorkerDemo.js diff --git a/src/components/elements/serverless-worker-demo.module.css b/src/components/Demos/ServerlessWorker/serverless-worker-demo.module.css similarity index 100% rename from src/components/elements/serverless-worker-demo.module.css rename to src/components/Demos/ServerlessWorker/serverless-worker-demo.module.css diff --git a/src/components/elements/StandaloneActivityDemo.js b/src/components/Demos/StandaloneActivity/StandaloneActivityDemo.js similarity index 100% rename from src/components/elements/StandaloneActivityDemo.js rename to src/components/Demos/StandaloneActivity/StandaloneActivityDemo.js diff --git a/src/components/elements/standalone-activity-demo.module.css b/src/components/Demos/StandaloneActivity/standalone-activity-demo.module.css similarity index 100% rename from src/components/elements/standalone-activity-demo.module.css rename to src/components/Demos/StandaloneActivity/standalone-activity-demo.module.css diff --git a/src/components/Demos/index.js b/src/components/Demos/index.js new file mode 100644 index 0000000000..ee16ecda50 --- /dev/null +++ b/src/components/Demos/index.js @@ -0,0 +1,3 @@ +export { default as RetrySimulator } from './RetrySimulator/RetrySimulator' +export { default as ServerlessWorkerDemo } from './ServerlessWorker/ServerlessWorkerDemo' +export { default as StandaloneActivityDemo } from './StandaloneActivity/StandaloneActivityDemo' \ No newline at end of file diff --git a/src/components/GuidesGrid/GuidesGrid.module.css b/src/components/GuidesGrid/GuidesGrid.module.css new file mode 100644 index 0000000000..2ef6497988 --- /dev/null +++ b/src/components/GuidesGrid/GuidesGrid.module.css @@ -0,0 +1,234 @@ +.container { + --ig-pill-gradient: linear-gradient(255deg, #444ce7 0%, #8b46d4 100%); + --ig-pill-text-active: #fff; + + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +:global([data-theme="light"]) .container { + --ig-search-bg: #fff; + --ig-search-border: rgba(15, 23, 42, 0.25); + --ig-search-text: #1e293b; + --ig-search-placeholder: #94a3b8; + --ig-focus-color: #444ce7; + --ig-focus-shadow: 0 0 0 3px rgba(68, 76, 231, 0.15); + --ig-pill-text: #475569; + --ig-pill-border: rgba(15, 23, 42, 0.25); + --ig-card-bg: #fff; + --ig-card-border: rgba(15, 23, 42, 0.15); + --ig-card-hover: 0 4px 24px rgba(68, 76, 231, 0.18), 0 0 0 1px rgba(68, 76, 231, 0.12); + --ig-badge-bg: #f1f5f9; + --ig-badge-text: #475569; + --ig-muted: #64748b; +} + +:global([data-theme="dark"]) .container { + --ig-search-bg: #1a1a1a; + --ig-search-border: rgba(148, 163, 184, 0.35); + --ig-search-text: #f1f5f9; + --ig-search-placeholder: #64748b; + --ig-focus-color: #7c6aef; + --ig-focus-shadow: 0 0 0 3px rgba(124, 106, 239, 0.25); + --ig-pill-text: #cbd5e1; + --ig-pill-border: rgba(148, 163, 184, 0.35); + --ig-card-bg: #1a1a1a; + --ig-card-border: rgba(148, 163, 184, 0.4); + --ig-card-hover: 0 0 24px rgba(130, 90, 255, 0.25), 0 0 0 1px rgba(130, 90, 255, 0.2); + --ig-badge-bg: rgba(148, 163, 184, 0.15); + --ig-badge-text: #cbd5e1; + --ig-muted: #64748b; +} + +/* Search */ + +.searchWrapper { + position: relative; +} + +.searchIcon { + position: absolute; + left: 1rem; + top: 50%; + transform: translateY(-50%); + color: var(--ig-search-placeholder); + pointer-events: none; + display: flex; + align-items: center; +} + +.searchInput { + width: 100%; + padding: 0.75rem 1rem 0.75rem 2.75rem; + font-size: 1rem; + border: none; + border-radius: 0; + box-shadow: inset 0 0 0 1px var(--ig-search-border); + background: var(--ig-search-bg); + color: var(--ig-search-text); + outline: none; + transition: box-shadow 0.2s ease; +} + +.searchInput::placeholder { + color: var(--ig-search-placeholder); +} + +.searchInput:focus { + box-shadow: inset 0 0 0 1px var(--ig-focus-color), var(--ig-focus-shadow); +} + +/* Filters */ + +.filters { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.filterGroup { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; +} + +.filterLabel { + font-size: 0.8rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--ig-badge-text); + margin-right: 0.25rem; +} + +.pill { + display: inline-flex; + align-items: center; + padding: 0.375rem 0.875rem; + font-size: 0.85rem; + font-weight: 500; + border: none; + border-radius: 0; + box-shadow: inset 0 0 0 1px var(--ig-pill-border); + background: transparent; + color: var(--ig-pill-text); + cursor: pointer; + transition: background 0.15s ease, color 0.15s ease, box-shadow 0.15s ease; + user-select: none; +} + +.pill:hover { + box-shadow: inset 0 0 0 1px var(--ig-focus-color); +} + +.pillActive { + background: var(--ig-pill-gradient); + color: var(--ig-pill-text-active); + box-shadow: none; +} + +/* Grid */ + +.grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + gap: 1rem; +} + +/* Card */ + +.card { + display: flex; + flex-direction: column; + padding: 1.25rem; + background: var(--ig-card-bg); + border: 1px solid var(--ig-card-border); + border-radius: 0; + text-decoration: none; + color: inherit; + transition: box-shadow 0.2s ease; +} + +.card:hover { + border-image: linear-gradient(255deg, #444ce7 0%, #b664ff 100%) 1; + box-shadow: var(--ig-card-hover); + text-decoration: none; +} + +.card:focus-visible { + outline: 2px solid var(--ig-focus-color); + outline-offset: 2px; +} + +.cardHeader { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.5rem; +} + +.cardName { + margin: 0; + font-size: 1rem; + font-weight: 600; + color: var(--ifm-color-emphasis-900); + display: inline-flex; + align-items: center; + gap: 0.375rem; +} + +.sdkIcons { + display: flex; + align-items: center; + gap: 0.375rem; + margin-left: auto; +} + +.sdkIcons svg { + width: 25px; + height: 25px; + pointer-events: none; +} + +.externalIcon { + opacity: 0.6; + flex-shrink: 0; +} + +.cardDescription { + font-size: 0.875rem; + line-height: 1.5; + color: var(--ifm-color-emphasis-700); + margin: 0 0 0.75rem 0; + flex: 1; +} + +.cardMeta { + display: flex; + flex-wrap: wrap; + gap: 0.375rem; + margin-top: auto; +} + +.badge { + display: inline-block; + padding: 0.2rem 0.5rem; + font-size: 0.7rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.03em; + border-radius: 0; + background: var(--ig-badge-bg); + color: var(--ig-badge-text); +} + +/* Empty state */ + +.empty { + text-align: center; + padding: 3rem 1rem; + color: var(--ig-muted); + font-size: 1rem; +} diff --git a/src/components/GuidesGrid/guides-data.json b/src/components/GuidesGrid/guides-data.json new file mode 100644 index 0000000000..5cec1fea1f --- /dev/null +++ b/src/components/GuidesGrid/guides-data.json @@ -0,0 +1,84 @@ +[ + { + "name": "Customer loyalty program", + "description": + "How to run a customer loyalty program with the entity pattern and durable workflows.", + "tags": ["Entity Pattern"], + "sdk": "Python", + "href": "/guides/entity-pattern-loyalty-points" + }, + + { + "name": "Recover without restart", + "description": + "Build business processes that pause on errors and recover without restarting.", + "tags": ["Saga Pattern"], + "sdk": "TypeScript", + "href": "/guides/saga-pattern" + }, + + { + "name": "Route specialized workloads", + "description": + "Direct resource-intensive workloads to appropriate Task Queues to optimize resources.", + "tags": ["Task Queues"], + "sdk": "Python", + "href": "/guides/route-specialized-workloads" + }, + + { + "name": "Worker execution affinity", + "description": + "Direct multiple Activities to execute on the same Worker to maintain data locality.", + "tags": ["Worker management"], + "sdk": "Python", + "href": "/guides/worker-execution-affinity" + }, + + { + "name": "Temporary rate limit increases", + "description": + "Handle temporary spikes in usage by dynamically provisioning extra capacity.", + "tags": ["Namespace management"], + "sdk": "Go", + "href": "/guides/temporary-rate-limit-increases" + }, + + { + "name": "Reliable document approvals", + "description": + "Build durable human-in-the-loop Workflows.", + "tags": ["Human-in-the-loop"], + "sdk": "Python", + "href": "/guides/reliable-document-approvals" + }, + + { + "name": "Rate-limit downstream APIs", + "description": + "Protect limited resources and avoid Workflow failures with separate Task Queues.", + "tags": ["Workflow Management"], + "sdk": "Python", + "href": "/guides/rate-limit-downstream-apis" + }, + + { + "name": "Durable gaming sessions", + "description": + "Protect player sessions from backend failures by using the Actor pattern.", + "tags": ["Actor Pattern"], + "sdk": "Python", + "href": "/guides/durable-gaming-sessions" + }, + + { + "name": "Distributed locking", + "description": + "Coordinate access to shared resources with a distributed lock.", + "tags": ["Workflow Management"], + "sdk": "Python", + "href": "/guides/lock-shared-resources" + } + + +] diff --git a/src/components/GuidesGrid/guides-data.ts b/src/components/GuidesGrid/guides-data.ts new file mode 100644 index 0000000000..c074c5b4b8 --- /dev/null +++ b/src/components/GuidesGrid/guides-data.ts @@ -0,0 +1,16 @@ +import guidesData from "./guides-data.json"; + +export type SDK = "Python" | "TypeScript" | "Go"; + +export type Guide = { + name: string; + description: string; + tags: string[]; + sdk?: SDK; + href: string; +}; + +const guides: Guide[] = guidesData as Guide[]; + +export default guides; + diff --git a/src/components/GuidesGrid/index.tsx b/src/components/GuidesGrid/index.tsx new file mode 100644 index 0000000000..5abe51710e --- /dev/null +++ b/src/components/GuidesGrid/index.tsx @@ -0,0 +1,193 @@ +import { useState, useMemo } from "react"; +import Link from "@docusaurus/Link"; +import clsx from "clsx"; +import guides, { type SDK, type Guide } from "./guides-data"; +import SdkSvg from "../elements/SdkSvgs/SdkSvg"; +import styles from "./GuidesGrid.module.css"; + +const ALL_SDKS: SDK[] = ["Python", "TypeScript", "Go"]; +const LANGUAGE_AGNOSTIC = "Language-agnostic"; +type SdkFilter = SDK | typeof LANGUAGE_AGNOSTIC; +const ALL_SDK_FILTERS: SdkFilter[] = [...ALL_SDKS, LANGUAGE_AGNOSTIC]; + +const SDK_BLOCK_NAMES: Record = { + Python: "pythonBlock", + TypeScript: "typeScriptBlock", + Go: "goLangBlock", +}; + +const ALL_TAGS = Array.from( + new Set(guides.flatMap((i) => i.tags)), +).sort(); + +const FILTER_GROUPS = [ + { label: "SDK", key: "sdks" as const, options: ALL_SDK_FILTERS as string[] }, + { label: "Tag", key: "tags" as const, options: ALL_TAGS }, +]; + +type FilterState = { + sdks: SdkFilter[]; + tags: string[]; +}; + +function isExternal(href: string): boolean { + return href.startsWith("http://") || href.startsWith("https://"); +} + +function SearchIcon() { + return ( + + + + + ); +} + +function ExternalLinkIcon() { + return ( + + + + ); +} + +function GuideCard({ item }: { item: Guide }) { + const external = isExternal(item.href); + return ( + +
+

+ {item.name} + {external && } +

+ {item.sdk && ( +
+ +
+ )} +
+

{item.description}

+
+ {item.tags.map((tag) => ( + {tag} + ))} +
+ + ); +} + +function toggleIn(arr: T[], value: T): T[] { + return arr.includes(value) ? arr.filter((v) => v !== value) : [...arr, value]; +} + +type GuidesGridProps = { + defaultSdks?: SDK[]; +}; + +export default function GuidesGrid({ + defaultSdks = [], +}: GuidesGridProps) { + const [query, setQuery] = useState(""); + const [filters, setFilters] = useState({ + sdks: defaultSdks, + tags: [], + }); + + const filtered = useMemo(() => { + const q = query.toLowerCase().trim(); + return guides + .filter((item) => { + if (q) { + const searchable = + `${item.name} ${item.description} ${item.tags.join(" ")}`.toLowerCase(); + if (!searchable.includes(q)) return false; + } + if (filters.sdks.length > 0) { + const wantsAgnostic = filters.sdks.includes(LANGUAGE_AGNOSTIC); + const sdkFilters = filters.sdks.filter((s): s is SDK => s !== LANGUAGE_AGNOSTIC); + const matchesSdk = item.sdk && sdkFilters.includes(item.sdk); + const matchesAgnostic = wantsAgnostic && !item.sdk; + if (!matchesSdk && !matchesAgnostic) return false; + } + if (filters.tags.length > 0) { + if (!filters.tags.some((t) => item.tags.includes(t))) return false; + } + return true; + }) + .sort((a, b) => a.name.localeCompare(b.name)); + }, [query, filters]); + + return ( +
+
+ + + + setQuery(e.target.value)} + aria-label="Search guides" + /> +
+ +
+ {FILTER_GROUPS.map(({ label, key, options }) => ( +
+ {label} + {options.map((value) => ( + + ))} +
+ ))} +
+ + {filtered.length > 0 ? ( +
+ {filtered.map((item) => ( + + ))} +
+ ) : ( +
No guides match your filters.
+ )} +
+ ); +} diff --git a/src/components/IntegrationsGrid/integrations-data.json b/src/components/IntegrationsGrid/integrations-data.json index 9b15cdedde..6abe200f5e 100644 --- a/src/components/IntegrationsGrid/integrations-data.json +++ b/src/components/IntegrationsGrid/integrations-data.json @@ -89,6 +89,15 @@ "sdk": "Python", "href": "/develop/python/integrations/langsmith" }, + { + "name": "LangSmith", + "description": "Trace and debug LLM calls in Temporal Workflows with LangSmith.", + "tags": [ + "Agent observability" + ], + "sdk": "TypeScript", + "href": "/develop/typescript/integrations/langsmith" + }, { "name": "Mastra", "description": "Build durable AI agents and workflows with the Mastra TypeScript framework.", @@ -135,6 +144,15 @@ "sdk": "Python", "href": "https://docs.openbox.ai/getting-started/temporal" }, + { + "name": "Parseable", + "description": "Stream Temporal Workflow and Activity execution events to Parseable for observability and analysis.", + "tags": [ + "Agent observability" + ], + "sdk": "Java", + "href": "https://github.com/parseablehq/temporal-plugin-java/blob/main/INTEGRATION.md" + }, { "name": "Parseable", "description": "Stream Temporal Workflow and Activity execution events to Parseable for observability and analysis.", diff --git a/src/components/PatternCards.tsx b/src/components/PatternCards.tsx index 8ed2298001..23512aa0c6 100644 --- a/src/components/PatternCards.tsx +++ b/src/components/PatternCards.tsx @@ -1,16 +1,25 @@ import React from 'react'; +import useBaseUrl from '@docusaurus/useBaseUrl'; type PatternCardItem = { href: string; title: string; description: string; external?: boolean; + icon?: string; }; type PatternCardsProps = { items: PatternCardItem[]; }; +const ICON_BASE = '/img/design-patterns/icons/'; + +function CardIcon({ icon, title }: { icon: string; title: string }) { + const src = useBaseUrl(icon.startsWith('/') ? icon : `${ICON_BASE}${icon}`); + return {title}; +} + export default function PatternCards({ items }: PatternCardsProps) { return (
@@ -22,7 +31,14 @@ export default function PatternCards({ items }: PatternCardsProps) { {...(item.external ? { target: '_blank', rel: 'noopener noreferrer' } : {})} >
-

{item.title}

+ {item.icon ? ( +
+ +

{item.title}

+
+ ) : ( +

{item.title}

+ )}

{item.description}

diff --git a/src/components/elements/CallToAction.js b/src/components/Quickstart/CallToAction/CallToAction.js similarity index 100% rename from src/components/elements/CallToAction.js rename to src/components/Quickstart/CallToAction/CallToAction.js diff --git a/src/components/elements/call-to-action.module.css b/src/components/Quickstart/CallToAction/call-to-action.module.css similarity index 100% rename from src/components/elements/call-to-action.module.css rename to src/components/Quickstart/CallToAction/call-to-action.module.css diff --git a/src/components/QuickstartCards.module.css b/src/components/Quickstart/Cards/QuickstartCards.module.css similarity index 100% rename from src/components/QuickstartCards.module.css rename to src/components/Quickstart/Cards/QuickstartCards.module.css diff --git a/src/components/QuickstartCards.tsx b/src/components/Quickstart/Cards/QuickstartCards.tsx similarity index 100% rename from src/components/QuickstartCards.tsx rename to src/components/Quickstart/Cards/QuickstartCards.tsx diff --git a/src/components/Quickstart/SetupSteps/SetupSteps.js b/src/components/Quickstart/SetupSteps/SetupSteps.js new file mode 100644 index 0000000000..1fb4fc0da9 --- /dev/null +++ b/src/components/Quickstart/SetupSteps/SetupSteps.js @@ -0,0 +1,34 @@ +import React from 'react'; +import styles from './setup-steps.module.css'; +import CodeBlock from '@theme/CodeBlock'; + +export const SetupStep = ({ children, code }) => { + return ( +
+
+ {children} +
+ {code && ( +
+ {code} +
+ )} +
+ ); +}; + +export const CodeSnippet = ({ language, children }) => { + return ( + + {children} + + ); +}; + +export const SetupSteps = ({ children }) => { + return ( +
+ {children} +
+ ); +}; \ No newline at end of file diff --git a/src/components/elements/setup-steps.module.css b/src/components/Quickstart/SetupSteps/setup-steps.module.css similarity index 100% rename from src/components/elements/setup-steps.module.css rename to src/components/Quickstart/SetupSteps/setup-steps.module.css diff --git a/src/components/Quickstart/index.js b/src/components/Quickstart/index.js new file mode 100644 index 0000000000..9521fdb73b --- /dev/null +++ b/src/components/Quickstart/index.js @@ -0,0 +1,3 @@ +export { default as QuickstartCards } from './Cards/QuickstartCards'; +export * from './CallToAction/CallToAction'; +export * from './SetupSteps/SetupSteps'; \ No newline at end of file diff --git a/src/components/info/ToolTipTerm.js b/src/components/ToolTipTerm/ToolTipTerm.js similarity index 100% rename from src/components/info/ToolTipTerm.js rename to src/components/ToolTipTerm/ToolTipTerm.js diff --git a/src/components/ToolTipTerm/index.js b/src/components/ToolTipTerm/index.js new file mode 100644 index 0000000000..ff6e8fdd30 --- /dev/null +++ b/src/components/ToolTipTerm/index.js @@ -0,0 +1 @@ +export { default as ToolTipTerm } from './ToolTipTerm' \ No newline at end of file diff --git a/src/components/elements/Button.js b/src/components/elements/Button.js deleted file mode 100644 index 2686dbf23e..0000000000 --- a/src/components/elements/Button.js +++ /dev/null @@ -1,14 +0,0 @@ -import React from "react"; - -export const Button = ({children, type, name, className}) => { - return ( - - ); -}; diff --git a/src/components/elements/HomePageHero.js b/src/components/elements/HomePageHero.js index 3809004d8a..ed51eaefd0 100644 --- a/src/components/elements/HomePageHero.js +++ b/src/components/elements/HomePageHero.js @@ -4,7 +4,7 @@ // change that copy here, update the handler too. See MARKDOWN_PIPELINE.md. import React from 'react'; import useBaseUrl from '@docusaurus/useBaseUrl'; -import { SdkLogosAsBlocks } from './SdkLogosAsBlocks'; +import { SdkLogosAsBlocks } from './Sdk'; import '../../css/homepage-hero.css'; const Icon = ({ src, alt, className, width, height }) => { diff --git a/src/components/elements/Images/CaptionedImage.js b/src/components/elements/Images/CaptionedImage.js new file mode 100644 index 0000000000..912b7909f1 --- /dev/null +++ b/src/components/elements/Images/CaptionedImage.js @@ -0,0 +1,29 @@ +// src/components/captioned-image/CaptionedImage.js +import React from "react"; +import ZoomableImage from "./ZoomableImage"; +import styles from "./CaptionedImage.module.css"; + +const CaptionedImage = ({ src, srcDark, alt, title, width, inset = "0%" }) => { + return ( +
+ + {title &&

{title}

} +
+ ); +}; + +export default CaptionedImage; diff --git a/src/components/elements/Images/CaptionedImage.module.css b/src/components/elements/Images/CaptionedImage.module.css new file mode 100644 index 0000000000..bd9b0e571b --- /dev/null +++ b/src/components/elements/Images/CaptionedImage.module.css @@ -0,0 +1,18 @@ +/* src/components/captioned-image/CaptionedImage.module.css */ +.imageContainer { + margin: 4px 4px; +} + +.image { + max-width: 100%; + height: auto; +} + +.title { + margin-top: 0px; + font-size: 0.8em; + text-align: start; + font-weight: bold; + padding-left: 8px; + padding-right: 8px; +} diff --git a/src/components/elements/Images/EnlargeImage.js b/src/components/elements/Images/EnlargeImage.js new file mode 100644 index 0000000000..a56871a239 --- /dev/null +++ b/src/components/elements/Images/EnlargeImage.js @@ -0,0 +1,14 @@ +import React from 'react'; +import ZoomableImage from './ZoomableImage'; + +// Legacy component, retained for backwards compatibility. Previously opened the +// image in a new tab; now defers to ZoomableImage's click-to-expand modal. +const EnlargeImage = ({ src, alt, ariaLabel }) => { + return ( +
+ +
+ ); +}; + +export default EnlargeImage; diff --git a/src/components/elements/Images/MermaidZoomWrapper.tsx b/src/components/elements/Images/MermaidZoomWrapper.tsx new file mode 100644 index 0000000000..511aab711b --- /dev/null +++ b/src/components/elements/Images/MermaidZoomWrapper.tsx @@ -0,0 +1,248 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { useNoZoom } from './NoZoom'; +import ZoomModal from './ZoomModal'; +import { wouldEnlargeInModal } from './zoomSizing'; +import styles from './ZoomableImage.module.css'; + +// Stable class Docusaurus's Mermaid theme puts on the div it injects the +// rendered SVG into (see `@docusaurus/theme-mermaid/client`'s +// `MermaidContainerClassName`). +const MERMAID_CONTAINER_SELECTOR = '.docusaurus-mermaid-container'; + +/** Mermaid always sets a `viewBox` on the root svg reflecting the diagram's + * natural pixel size — this is the SVG equivalent of `naturalWidth`/ + * `naturalHeight` for a raster image. `getBBox()` is a last-resort fallback + * for hand-authored SVGs that might lack a viewBox. */ +function getIntrinsicSize(svg: SVGSVGElement): { width: number; height: number } { + const viewBox = svg.viewBox?.baseVal; + if (viewBox && viewBox.width > 0 && viewBox.height > 0) { + return { width: viewBox.width, height: viewBox.height }; + } + const attr = svg.getAttribute('viewBox'); + if (attr) { + const parts = attr.trim().split(/\s+/).map(Number); + if (parts.length === 4 && Number.isFinite(parts[2]) && parts[2] > 0 && Number.isFinite(parts[3]) && parts[3] > 0) { + return { width: parts[2], height: parts[3] }; + } + } + try { + const bbox = svg.getBBox(); + if (bbox.width > 0 && bbox.height > 0) { + return { width: bbox.width, height: bbox.height }; + } + } catch { + // getBBox() can throw for detached/hidden elements; ignore and fall through. + } + return { width: 0, height: 0 }; +} + +let cloneIdSeq = 0; + +/** + * Serializes a clone of `svg` to an HTML string, rewriting every `id` (and + * any same-document reference to it, e.g. the marker `` mermaid uses + * for flowchart arrowheads) to a unique value. Without this, showing the + * clone in the modal while the original diagram stays mounted in the page + * would leave two elements sharing the same id, which makes `url(#id)` + * references resolve ambiguously across browsers. + */ +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function cloneSvgMarkupWithUniqueIds(svg: SVGSVGElement): string { + const clone = svg.cloneNode(true) as SVGSVGElement; + // Mermaid sets an inline `max-width: px` on the svg it + // renders (so it never grows past its "natural" size in-page). That's a + // hard ceiling baked into the markup itself, so it would otherwise still + // apply inside the modal even though `ZoomModal`/`.modalSvgWrapper` no + // longer cap the wrapper — strip it so the vector diagram can actually + // fill the available modal space. + clone.style.removeProperty('max-width'); + const prefix = `zoom-modal-${cloneIdSeq++}-`; + const idMap = new Map(); + // Include the root itself — querySelectorAll only matches descendants, + // and Mermaid's entire theme stylesheet is scoped to the root svg's id + // (e.g. `#mermaid-svg-123 .node rect { fill: ... }`). + const elementsWithId = [clone, ...Array.from(clone.querySelectorAll('[id]'))]; + elementsWithId.forEach((el) => { + const oldId = el.getAttribute('id'); + if (oldId && !idMap.has(oldId)) { + const newId = `${prefix}${oldId}`; + idMap.set(oldId, newId); + el.setAttribute('id', newId); + } + }); + if (idMap.size > 0) { + const referencingAttrs = [ + 'fill', + 'stroke', + 'filter', + 'clip-path', + 'marker-start', + 'marker-mid', + 'marker-end', + 'href', + 'xlink:href', + ]; + clone.querySelectorAll('*').forEach((el) => { + referencingAttrs.forEach((attrName) => { + const value = el.getAttribute(attrName); + if (!value) { + return; + } + idMap.forEach((newId, oldId) => { + if (value === `#${oldId}` || value === `url(#${oldId})`) { + el.setAttribute(attrName, value.replace(oldId, newId)); + } + }); + }); + }); + // Mermaid inlines its theme as a
{children}
+ setModalState(null)} + label="Expanded diagram" + html={modalState?.html ?? ''} + naturalWidth={modalState?.width} + /> + + ); +} diff --git a/src/components/elements/Images/NoZoom.tsx b/src/components/elements/Images/NoZoom.tsx new file mode 100644 index 0000000000..66df0e1b10 --- /dev/null +++ b/src/components/elements/Images/NoZoom.tsx @@ -0,0 +1,28 @@ +import React, { createContext, useContext } from 'react'; + +/** + * Context flag that tells {@link ZoomableImage} to render as a plain image, + * i.e. without the click-to-expand affordance. Defaults to `false` (zoom on), + * so images are only opted out when they sit inside a `` wrapper. + */ +export const NoZoomContext = createContext(false); + +export const useNoZoom = (): boolean => useContext(NoZoomContext); + +/** + * Opt a subtree of images out of the automatic click-to-expand behavior. + * + * Wrap one or more Markdown images to keep them from becoming zoomable even + * when they are downscaled (e.g. decorative banners): + * + * ```mdx + * + * + * ![.NET](/img/assets/banner-dotnet-temporal.png) + * + * + * ``` + */ +export default function NoZoom({ children }: { children: React.ReactNode }): JSX.Element { + return {children}; +} diff --git a/src/components/elements/Images/ZoomModal.tsx b/src/components/elements/Images/ZoomModal.tsx new file mode 100644 index 0000000000..f5c9b8b72d --- /dev/null +++ b/src/components/elements/Images/ZoomModal.tsx @@ -0,0 +1,94 @@ +import React, { useEffect } from 'react'; +import { createPortal } from 'react-dom'; +import styles from './ZoomableImage.module.css'; + +type ZoomModalProps = { + isOpen: boolean; + onClose: () => void; + label?: string; +} & ( + | { src: string; alt?: string; naturalWidth?: number; html?: undefined } + | { html: string; naturalWidth?: number; src?: undefined; alt?: undefined } +); + +/** + * Shared full-width "expanded view" modal used by both raster/vector images + * (`ZoomableImage`) and Mermaid diagrams (`MermaidZoomWrapper`). Renders + * either an `` or a raw HTML string (e.g. a serialized, id-rewritten + * clone of a live Mermaid ``) inside the same overlay/backdrop/close + * button chrome. + * + * Clicking the backdrop, the content itself, the close button, or pressing + * Escape all close the modal (no `stopPropagation`, matching the previous + * inline-modal behavior in `ZoomableImage`). + */ +export default function ZoomModal(props: ZoomModalProps): JSX.Element | null { + const { isOpen, onClose, label } = props; + + useEffect(() => { + if (!isOpen) { + return; + } + const handleKey = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + onClose(); + } + }; + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + document.addEventListener('keydown', handleKey); + return () => { + document.body.style.overflow = previousOverflow; + document.removeEventListener('keydown', handleKey); + }; + }, [isOpen, onClose]); + + if (!isOpen || typeof document === 'undefined') { + return null; + } + + // Raster/vector images are capped at their natural pixel width — scaling a + // screenshot beyond its native resolution would just blur it. Mermaid's + // cloned (the `html` case) has no such ceiling: a viewBox is a + // coordinate system, not a pixel raster, so it stays crisp at any size and + // should fill the available space instead of being capped to its + // "natural" (i.e. as-authored) viewBox size. + const maxWidthStyle = { + maxWidth: + props.html !== undefined ? '95vw' : props.naturalWidth ? `min(95vw, ${props.naturalWidth}px)` : '95vw', + }; + + return createPortal( +
+ + {props.html !== undefined ? ( + // Keep `docusaurus-mermaid-container` so page-level Mermaid theme + // overrides in custom.css (e.g. Design Patterns semantic node colors + // scoped to that class) still apply after the SVG is portaled out of + // the original Mermaid theme component. +
+ ) : ( + {props.alt} + )} +
, + document.body + ); +} diff --git a/src/components/elements/Images/ZoomableImage.module.css b/src/components/elements/Images/ZoomableImage.module.css new file mode 100644 index 0000000000..496fbcbb6e --- /dev/null +++ b/src/components/elements/Images/ZoomableImage.module.css @@ -0,0 +1,89 @@ +/* Match the default Docusaurus img: Docusaurus adds intrinsic width/height + attributes to markdown images, so height must be auto for them to scale + proportionally when constrained by max-width. */ +.image { + height: auto; +} + +.zoomable { + cursor: zoom-in; +} + +.overlay { + position: fixed; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + padding: 4vh 4vw; + background: rgba(0, 0, 0, 0.82); + backdrop-filter: blur(4px); + z-index: calc(var(--ifm-z-index-fixed) + 10); + cursor: zoom-out; + animation: zoomableFadeIn 0.15s ease-out; +} + +.modalImage { + width: 100%; + height: auto; + max-height: 90vh; + object-fit: contain; + border-radius: 6px; + box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5); + cursor: zoom-out; +} + +/* Wraps a cloned Mermaid (injected via dangerouslySetInnerHTML by + MermaidZoomWrapper). The svg keeps its own viewBox-driven aspect ratio, so + sizing it here mirrors .modalImage's width/height/max-height treatment. + `width: 100%` (not just `max-width`) is required: this wrapper is a flex + item inside .overlay, so without an explicit width it would shrink-to-fit + its child — but the child svg's own `width: 100%` resolves against this + wrapper's width, which is a circular reference browsers resolve by + falling back to the default 300x150 replaced-element size, ignoring our + intended sizing entirely. */ +.modalSvgWrapper { + display: flex; + width: 100%; + max-height: 90vh; + cursor: zoom-out; +} + +.modalSvgWrapper svg { + width: 100%; + height: auto; + max-height: 90vh; + object-fit: contain; +} + +.close { + position: fixed; + top: 16px; + right: 24px; + width: 40px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; + font-size: 28px; + line-height: 1; + color: #fff; + background: rgba(0, 0, 0, 0.4); + border: none; + border-radius: 50%; + cursor: pointer; + transition: background 0.15s ease; +} + +.close:hover { + background: rgba(0, 0, 0, 0.7); +} + +@keyframes zoomableFadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} diff --git a/src/components/elements/Images/ZoomableImage.tsx b/src/components/elements/Images/ZoomableImage.tsx new file mode 100644 index 0000000000..e2d0e2d1be --- /dev/null +++ b/src/components/elements/Images/ZoomableImage.tsx @@ -0,0 +1,113 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { useColorMode } from '@docusaurus/theme-common'; +import clsx from 'clsx'; +import { useNoZoom } from './NoZoom'; +import ZoomModal from './ZoomModal'; +import { wouldEnlargeInModal } from './zoomSizing'; +import styles from './ZoomableImage.module.css'; + +type ZoomableImageProps = React.ComponentProps<'img'> & { + /** Optional dark-mode source; used in place of `src` when the dark theme is active. */ + srcDark?: string; +}; + +/** + * Renders an image that behaves like Stripe's docs images: it sits inside the + * normal content column, but when the image's natural width is larger than the + * space available to it, the cursor becomes a zoom-in cursor and clicking opens + * a full-width modal view. Images that already fit are left completely alone. + */ +export default function ZoomableImage({ + src, + srcDark, + alt, + className, + style, + onClick, + onLoad, + ...rest +}: ZoomableImageProps): JSX.Element { + const { colorMode } = useColorMode(); + const noZoom = useNoZoom(); + const imgRef = useRef(null); + const [isZoomable, setIsZoomable] = useState(false); + const [naturalWidth, setNaturalWidth] = useState(0); + const [isOpen, setIsOpen] = useState(false); + + const resolvedSrc = colorMode === 'dark' && srcDark ? srcDark : src; + + // An image is zoomable only when opening the modal would actually show it + // larger than it already appears on the page. That's usually just "is it + // being displayed smaller than its native resolution", but a tall image + // can overflow its column width-wise while still rendering *smaller* in + // the modal once the height cap clamps it (see zoomSizing.ts). + const measure = useCallback(() => { + const img = imgRef.current; + if (!img || !img.naturalWidth) { + return; + } + setNaturalWidth(img.naturalWidth); + setIsZoomable( + !noZoom && + wouldEnlargeInModal({ + naturalWidth: img.naturalWidth, + naturalHeight: img.naturalHeight, + pageWidth: img.clientWidth, + capToNaturalWidth: true, + }) + ); + }, [noZoom]); + + useEffect(() => { + const img = imgRef.current; + if (!img) { + return; + } + if (img.complete) { + measure(); + } + // Recompute when the column is resized (responsive layout, sidebar + // toggle) or the viewport height changes (affects the modal's 90vh cap + // without necessarily changing the image's own rendered width). + const observer = new ResizeObserver(measure); + observer.observe(img); + window.addEventListener('resize', measure); + return () => { + observer.disconnect(); + window.removeEventListener('resize', measure); + }; + }, [measure, resolvedSrc]); + + return ( + <> + {alt} { + measure(); + onLoad?.(event); + }} + onClick={(event) => { + if (isZoomable) { + setIsOpen(true); + } + onClick?.(event); + }} + {...rest} + /> + setIsOpen(false)} + label={alt || 'Expanded image'} + src={resolvedSrc} + alt={alt} + naturalWidth={naturalWidth} + /> + + ); +} diff --git a/src/components/elements/Images/index.js b/src/components/elements/Images/index.js new file mode 100644 index 0000000000..c0d6d8d884 --- /dev/null +++ b/src/components/elements/Images/index.js @@ -0,0 +1,4 @@ +export { default as CaptionedImage } from './CaptionedImage' +export { default as EnlargeImage } from './EnlargeImage' +export { default as ZoomableImage } from './ZoomableImage' +export { default as NoZoom } from './NoZoom' \ No newline at end of file diff --git a/src/components/elements/Images/zoomSizing.ts b/src/components/elements/Images/zoomSizing.ts new file mode 100644 index 0000000000..d42c2dff69 --- /dev/null +++ b/src/components/elements/Images/zoomSizing.ts @@ -0,0 +1,64 @@ +// Mirrors the modal's CSS constraints (.overlay's `padding: 4vh 4vw` plus +// `.modalImage` / `.modalSvgWrapper`'s `max-height: 90vh`): +// - available width = viewport − 8vw of horizontal padding = 92vw +// (the content's own `max-width: 95vw` is looser than this, so 92vw wins) +// - available height = 90vh (tighter than the 92vh leftover after vertical +// padding, so 90vh wins) +const MODAL_WIDTH_VIEWPORT_RATIO = 0.92; +const MODAL_HEIGHT_VIEWPORT_RATIO = 0.9; + +/** + * Computes how wide an item would actually render inside the zoom modal for + * the current viewport, given its intrinsic size. The modal uniformly scales + * content to fit within whichever of its width/height caps is more + * restrictive (`object-fit: contain` semantics), so a tall item can end up + * width-constrained by the *height* cap rather than the width cap. + * + * `capToNaturalWidth` matches raster images, which should never be scaled + * past their native pixel resolution (that would just blur them). Vector + * Mermaid diagrams have no such ceiling — a viewBox is a coordinate system, + * not a pixel raster — so they should be allowed to fill the available + * space. + */ +export function computeModalRenderWidth({ + naturalWidth, + naturalHeight, + capToNaturalWidth, +}: { + naturalWidth: number; + naturalHeight: number; + capToNaturalWidth: boolean; +}): number { + if (typeof window === 'undefined' || !naturalWidth || !naturalHeight) { + return 0; + } + const maxWidth = capToNaturalWidth + ? Math.min(window.innerWidth * MODAL_WIDTH_VIEWPORT_RATIO, naturalWidth) + : window.innerWidth * MODAL_WIDTH_VIEWPORT_RATIO; + const maxHeight = window.innerHeight * MODAL_HEIGHT_VIEWPORT_RATIO; + const scale = Math.min(maxWidth / naturalWidth, maxHeight / naturalHeight); + return naturalWidth * scale; +} + +/** + * Whether opening the zoom modal would actually display this item larger + * than it already appears on the page. An item can overflow its column + * (the naive "it's being downscaled" check) yet still render *smaller* in + * the modal once the ~90vh height cap clamps it — tall diagrams/screenshots + * in a short browser window are the common case. When that happens the zoom + * affordance would be actively misleading, so callers should suppress it. + */ +export function wouldEnlargeInModal({ + naturalWidth, + naturalHeight, + pageWidth, + capToNaturalWidth, +}: { + naturalWidth: number; + naturalHeight: number; + pageWidth: number; + capToNaturalWidth: boolean; +}): boolean { + const modalWidth = computeModalRenderWidth({ naturalWidth, naturalHeight, capToNaturalWidth }); + return modalWidth > pageWidth + 1; +} diff --git a/src/components/elements/InfoBox.js b/src/components/elements/InfoBox.js deleted file mode 100644 index d53c30a91d..0000000000 --- a/src/components/elements/InfoBox.js +++ /dev/null @@ -1,14 +0,0 @@ -import React from 'react'; -import styles from './info-box.module.css'; - -export const InfoBox = ({ title, children }) => { - return ( -
-
-
-

{title}

- {children} -
-
- ); -}; \ No newline at end of file diff --git a/src/components/info/RelatedRead/RelatedRead.js b/src/components/elements/RelatedRead/RelatedRead.js similarity index 94% rename from src/components/info/RelatedRead/RelatedRead.js rename to src/components/elements/RelatedRead/RelatedRead.js index 9737432cb7..b1a83f7c3f 100644 --- a/src/components/info/RelatedRead/RelatedRead.js +++ b/src/components/elements/RelatedRead/RelatedRead.js @@ -3,7 +3,7 @@ import clsx from "clsx"; import Link from "@docusaurus/Link"; import { v4 as uuidv4 } from "uuid"; -import { LANGUAGE_ICONS } from "../constants"; +import { LANGUAGE_SVGS } from "../../../constants/sdkLanguages"; import styles from "./RelatedRead.module.css"; const archetypeClasses = { @@ -19,9 +19,9 @@ function getTagClass(tag) { } function getLanguageIcon(text) { - for (const lang in LANGUAGE_ICONS) { + for (const lang in LANGUAGE_SVGS) { if (text.includes(lang)) { - return LANGUAGE_ICONS[lang]; + return LANGUAGE_SVGS[lang]; } } return null; diff --git a/src/components/info/RelatedRead/RelatedRead.module.css b/src/components/elements/RelatedRead/RelatedRead.module.css similarity index 100% rename from src/components/info/RelatedRead/RelatedRead.module.css rename to src/components/elements/RelatedRead/RelatedRead.module.css diff --git a/src/components/elements/RelatedRead/index.js b/src/components/elements/RelatedRead/index.js new file mode 100644 index 0000000000..802439eb85 --- /dev/null +++ b/src/components/elements/RelatedRead/index.js @@ -0,0 +1 @@ +export { RelatedReadContainer, RelatedReadItem } from './RelatedRead' diff --git a/src/components/info/ReleaseNoteHeader/ReleaseNoteHeader.js b/src/components/elements/ReleaseNoteHeader/ReleaseNoteHeader.js similarity index 74% rename from src/components/info/ReleaseNoteHeader/ReleaseNoteHeader.js rename to src/components/elements/ReleaseNoteHeader/ReleaseNoteHeader.js index ff428cd267..d857de4ed5 100644 --- a/src/components/info/ReleaseNoteHeader/ReleaseNoteHeader.js +++ b/src/components/elements/ReleaseNoteHeader/ReleaseNoteHeader.js @@ -1,9 +1,8 @@ // src/components/info/ReleaseNoteHeader/index.js import React from "react"; -import clsx from "clsx"; -import { LANGUAGE_ICONS } from "../constants"; -import SdkSvg from '../../elements/SdkSvgs/SdkSvg'; +import Link from "@docusaurus/Link"; +import SdkSvg from '../SdkSvgs/SdkSvg'; import styles from "./ReleaseNoteHeader.module.css"; import { FEATURE_RELEASE_TYPES } from "../../../constants/featureReleaseTypes"; @@ -35,6 +34,17 @@ const LANGUAGE_TO_SDK_SVG = { "TypeScript": "typeScriptBlock", } +const LANGUAGE_TO_SDK_SLUG = { + ".NET": "dotnet", + "Go": "go", + "Java": "java", + "PHP": "php", + "Python": "python", + "Ruby": "ruby", + "Rust": "rust", + "TypeScript": "typescript", +} + function getResolvedType({ featureName, type }) { return FEATURE_RELEASE_TYPES[featureName] || type || "publicPreview"; } @@ -59,6 +69,8 @@ export default function ReleaseNoteHeader({ href, // These are the supported languages for the release. If provided, icons for these languages will be shown. languages = [], + // Path segment after /develop// for linked language icons. + guidePath, // If you want to override the default label for the release type, you can pass it here. This is useful for cases like "generalAvailability" where you might want to just say "Stable". label, }) { @@ -94,16 +106,31 @@ export default function ReleaseNoteHeader({

Supported languages:

    - {languages.map((language) => ( + {languages.map((language) => { + const icon = ( + + ); + const sdkSlug = LANGUAGE_TO_SDK_SLUG[language]; + return (
  • - + {guidePath && sdkSlug ? ( + + {icon} + + ) : ( + {icon} + )}
  • - ) - )} + ); + })}
)} -

+

); -} \ No newline at end of file +} diff --git a/src/components/info/ReleaseNoteHeader/ReleaseNoteHeader.module.css b/src/components/elements/ReleaseNoteHeader/ReleaseNoteHeader.module.css similarity index 82% rename from src/components/info/ReleaseNoteHeader/ReleaseNoteHeader.module.css rename to src/components/elements/ReleaseNoteHeader/ReleaseNoteHeader.module.css index 0ace8e0466..ccbe5ac9bd 100644 --- a/src/components/info/ReleaseNoteHeader/ReleaseNoteHeader.module.css +++ b/src/components/elements/ReleaseNoteHeader/ReleaseNoteHeader.module.css @@ -89,6 +89,29 @@ margin-top: 10px; } +.languageLink { + display: flex; + line-height: 0; + border-radius: 4px; + transition: opacity 0.15s ease; +} + +.languageLink:hover { + opacity: 0.85; +} + +.languageLink:focus-visible { + outline: 2px solid var(--release-note-border); + outline-offset: 2px; +} + +.languageStatic { + display: flex; + line-height: 0; + pointer-events: none; + cursor: default; +} + .supportedLanguages { display: flex; align-items: center; diff --git a/src/components/elements/ReleaseNoteHeader/index.js b/src/components/elements/ReleaseNoteHeader/index.js new file mode 100644 index 0000000000..000a026151 --- /dev/null +++ b/src/components/elements/ReleaseNoteHeader/index.js @@ -0,0 +1 @@ +export { default as ReleaseNoteHeader} from './ReleaseNoteHeader' \ No newline at end of file diff --git a/src/components/elements/SdkGuideLinks.js b/src/components/elements/Sdk/SdkGuideLinks/SdkGuideLinks.js similarity index 98% rename from src/components/elements/SdkGuideLinks.js rename to src/components/elements/Sdk/SdkGuideLinks/SdkGuideLinks.js index 6477c48f17..79485e4a86 100644 --- a/src/components/elements/SdkGuideLinks.js +++ b/src/components/elements/Sdk/SdkGuideLinks/SdkGuideLinks.js @@ -1,6 +1,6 @@ import React from 'react'; import Link from '@docusaurus/Link'; -import SdkSvg from './SdkSvgs/SdkSvg'; +import SdkSvg from '../../SdkSvgs/SdkSvg'; import styles from './sdk-guide-links.module.css'; const DEFAULT_SDKS = [ diff --git a/src/components/elements/Sdk/SdkGuideLinks/index.js b/src/components/elements/Sdk/SdkGuideLinks/index.js new file mode 100644 index 0000000000..4494dd9699 --- /dev/null +++ b/src/components/elements/Sdk/SdkGuideLinks/index.js @@ -0,0 +1 @@ +export * from './SdkGuideLinks' \ No newline at end of file diff --git a/src/components/elements/sdk-guide-links.module.css b/src/components/elements/Sdk/SdkGuideLinks/sdk-guide-links.module.css similarity index 96% rename from src/components/elements/sdk-guide-links.module.css rename to src/components/elements/Sdk/SdkGuideLinks/sdk-guide-links.module.css index 1eb21eaaee..99a5499323 100644 --- a/src/components/elements/sdk-guide-links.module.css +++ b/src/components/elements/Sdk/SdkGuideLinks/sdk-guide-links.module.css @@ -9,7 +9,6 @@ display: flex; align-items: center; gap: 10px; - text-decoration: none; } .iconWrapper { diff --git a/src/components/elements/SdkLogosAsBlocks.js b/src/components/elements/Sdk/SdkLogosAsBlocks.js similarity index 97% rename from src/components/elements/SdkLogosAsBlocks.js rename to src/components/elements/Sdk/SdkLogosAsBlocks.js index d3efc3c2ea..4b05f96d34 100644 --- a/src/components/elements/SdkLogosAsBlocks.js +++ b/src/components/elements/Sdk/SdkLogosAsBlocks.js @@ -1,5 +1,5 @@ import React from 'react'; -import SdkSvg from './SdkSvgs/SdkSvg'; +import SdkSvg from '../SdkSvgs/SdkSvg'; const supportedTech = [ { diff --git a/src/components/elements/SdkTabs/index.js b/src/components/elements/Sdk/SdkTabs.js similarity index 100% rename from src/components/elements/SdkTabs/index.js rename to src/components/elements/Sdk/SdkTabs.js diff --git a/src/components/elements/Sdk/index.js b/src/components/elements/Sdk/index.js new file mode 100644 index 0000000000..db385aeedf --- /dev/null +++ b/src/components/elements/Sdk/index.js @@ -0,0 +1,3 @@ +export * from './SdkGuideLinks' +export * from './SdkLogosAsBlocks' +export { default as SdkTabs } from './SdkTabs' \ No newline at end of file diff --git a/src/components/elements/SdkLogos.js b/src/components/elements/SdkLogos.js deleted file mode 100644 index 791a6a66e6..0000000000 --- a/src/components/elements/SdkLogos.js +++ /dev/null @@ -1,72 +0,0 @@ -import React from 'react'; - -const supportedTech = [ - { - link: '/develop/go', - image: '/img/sdks/svgs/golang.svg', - alt: 'Go lang', - class: 'w-10 h-8', - }, - { - link: '/develop/java', - image: '/img/sdks/svgs/java.svg', - alt: 'Java', - class: 'w-7 h-7', - }, - { - link: '/develop/python', - image: '/img/sdks/svgs/python.svg', - alt: 'Python', - class: 'w-7 h-7', - }, - { - link: '/develop/typescript', - image: '/img/sdks/svgs/typescript.svg', - alt: 'TypeScript', - class: 'w-7 h-7', - }, - { - link: '/develop/php', - image: '/img/sdks/svgs/php.svg', - alt: 'PHP', - class: 'w-10', - }, - { - link: '/develop/dotnet', - image: '/img/sdks/svgs/dotnet.svg', - alt: '.NET', - class: 'w-10', - }, - { - link: '/develop/ruby', - image: '/img/sdks/svgs/ruby.svg', - alt: 'Ruby', - class: 'w-10', - }, - { - link: '/develop/rust', - image: '/img/sdks/svgs/rust.svg', - alt: 'Rust', - class: 'w-10', - }, -]; - -const displayTechListItems = () => { - return supportedTech.map((tech) => { - return ( -
  • - - {tech.alt} - -
  • - ); - }); -}; - -export const SdkLogos = () => { - return ( -
    -
      {displayTechListItems()}
    -
    - ); -}; diff --git a/src/components/elements/SdkTabs/Languages.ts b/src/components/elements/SdkTabs/Languages.ts deleted file mode 100644 index 0b7cadb86a..0000000000 --- a/src/components/elements/SdkTabs/Languages.ts +++ /dev/null @@ -1,23 +0,0 @@ -export const Go = ({ children }) => children; -Go.displayName = 'go'; - -export const Java = ({ children }) => children; -Java.displayName = 'java'; - -export const Python = ({ children }) => children; -Python.displayName = 'py'; - -export const TypeScript = ({ children }) => children; -TypeScript.displayName = 'ts'; - -export const PHP = ({ children }) => children; -PHP.displayName = 'php'; - -export const DotNet = ({ children }) => children; -DotNet.displayName = 'dotnet'; - -export const Ruby = ({ children }) => children; -Ruby.displayName = 'rb'; - -export const Rust = ({ children }) => children; -Rust.displayName = 'rs'; diff --git a/src/components/elements/SetupSteps.js b/src/components/elements/SetupSteps.js deleted file mode 100644 index 2f453a2864..0000000000 --- a/src/components/elements/SetupSteps.js +++ /dev/null @@ -1,34 +0,0 @@ -import React from 'react'; - import styles from './setup-steps.module.css'; - import CodeBlock from '@theme/CodeBlock'; - - export const SetupStep = ({ children, code }) => { - return ( -
    -
    - {children} -
    - {code && ( -
    - {code} -
    - )} -
    - ); - }; - - export const CodeSnippet = ({ language, children }) => { - return ( - - {children} - - ); - }; - - export const SetupSteps = ({ children }) => { - return ( -
    - {children} -
    - ); - }; \ No newline at end of file diff --git a/src/components/formatting/DocsTable.js b/src/components/elements/Tables/DocsTable.js similarity index 98% rename from src/components/formatting/DocsTable.js rename to src/components/elements/Tables/DocsTable.js index 8a0966109b..e42833d43f 100644 --- a/src/components/formatting/DocsTable.js +++ b/src/components/elements/Tables/DocsTable.js @@ -1,6 +1,6 @@ import React, { useState, useMemo } from "react"; -export default function DocsTable({ Columns, children, sortable = false, defaultSort = null }) { +export function DocsTable({ Columns, children, sortable = false, defaultSort = null }) { const [sortConfig, setSortConfig] = useState(() => { if (defaultSort && sortable) { return { key: defaultSort.column, direction: defaultSort.direction || 'asc' }; diff --git a/src/components/formatting/JsonTable.js b/src/components/elements/Tables/JsonTable.js similarity index 100% rename from src/components/formatting/JsonTable.js rename to src/components/elements/Tables/JsonTable.js diff --git a/src/components/formatting/OperationsTable.js b/src/components/elements/Tables/OperationsTable.js similarity index 100% rename from src/components/formatting/OperationsTable.js rename to src/components/elements/Tables/OperationsTable.js diff --git a/src/components/elements/Tables/index.js b/src/components/elements/Tables/index.js new file mode 100644 index 0000000000..66c8add24b --- /dev/null +++ b/src/components/elements/Tables/index.js @@ -0,0 +1,3 @@ +export * from './DocsTable' +export { default as JsonTable } from './JsonTable' +export { default as OperationsTable } from './OperationsTable' \ No newline at end of file diff --git a/src/components/elements/Tile.module.css b/src/components/elements/Tile/Tile.module.css similarity index 100% rename from src/components/elements/Tile.module.css rename to src/components/elements/Tile/Tile.module.css diff --git a/src/components/elements/Tile.tsx b/src/components/elements/Tile/Tile.tsx similarity index 100% rename from src/components/elements/Tile.tsx rename to src/components/elements/Tile/Tile.tsx diff --git a/src/components/elements/Tile/index.js b/src/components/elements/Tile/index.js new file mode 100644 index 0000000000..bcdfc04923 --- /dev/null +++ b/src/components/elements/Tile/index.js @@ -0,0 +1 @@ +export { default as Tile } from './Tile' diff --git a/src/components/elements/TileGrid.tsx b/src/components/elements/TileGrid.tsx deleted file mode 100644 index f55c148208..0000000000 --- a/src/components/elements/TileGrid.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import clsx from 'clsx'; -import Tile, { TileProps } from './Tile'; - -export type TileGridProps = { - items: (TileProps & { key?: string })[]; - className?: string; -}; - -export default function TileGrid({ items, className }: TileGridProps) { - return ( -
    - {items.map((it) => ( -
    - -
    - ))} -
    - ); -} diff --git a/src/components/elements/ViewSourceCodeNotice.js b/src/components/elements/ViewSourceCodeNotice/ViewSourceCodeNotice.js similarity index 100% rename from src/components/elements/ViewSourceCodeNotice.js rename to src/components/elements/ViewSourceCodeNotice/ViewSourceCodeNotice.js diff --git a/src/components/elements/view-source-code-notice.module.css b/src/components/elements/ViewSourceCodeNotice/view-source-code-notice.module.css similarity index 100% rename from src/components/elements/view-source-code-notice.module.css rename to src/components/elements/ViewSourceCodeNotice/view-source-code-notice.module.css diff --git a/src/components/elements/index.js b/src/components/elements/index.js new file mode 100644 index 0000000000..921cf64885 --- /dev/null +++ b/src/components/elements/index.js @@ -0,0 +1,7 @@ +export * from './ViewSourceCodeNotice/ViewSourceCodeNotice' +export * from './Tables' +export * from './Tile' +export * from './Images' +export * from './RelatedRead' +export * from './ReleaseNoteHeader' +export * from './Sdk' \ No newline at end of file diff --git a/src/components/elements/info-box.module.css b/src/components/elements/info-box.module.css deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/src/components/experimental/CenteredImage.js b/src/components/experimental/CenteredImage.js deleted file mode 100644 index 17be23286c..0000000000 --- a/src/components/experimental/CenteredImage.js +++ /dev/null @@ -1,51 +0,0 @@ -import React from "react"; -import {v4 as uuidv4} from "uuid"; - -export default function CenteredImage({imagePath, imageSize, title, legend}) { - const legendList = []; - if (legend != undefined) { - for (const item of legend) { - legendList.push({ - id: uuidv4(), - symbol: item[0], - description: item[1], - }); - } - } - let imageClass = ""; - switch (imageSize) { - case "25": - imageClass = "docs-centered-image-size-25"; - break; - case "50": - imageClass = "docs-centered-image-size-50"; - break; - case "75": - imageClass = "docs-centered-image-size-75"; - break; - default: - imageClass = "docs-centered-image-size-100"; - } - return ( -
    -
    -

    {title}

    -
    -
    - {title} -
    - {legendList.length > 0 && ( -
    -
      - {legendList.map(({id, symbol, description}) => ( -
    • - {symbol} ={" "} - {description} -
    • - ))} -
    -
    - )} -
    - ); -} diff --git a/src/components/experimental/CustomWarning.js b/src/components/experimental/CustomWarning.js deleted file mode 100644 index 246c31e8c6..0000000000 --- a/src/components/experimental/CustomWarning.js +++ /dev/null @@ -1,98 +0,0 @@ -import React from "react"; - -export default function CustomWarning({ - children, - title = "experimental", - color = "var(--ifm-color-warning)", // you can customize with any color you wish -}) { - const [isOpen, setOpen] = React.useState(false); - if (!isOpen) { - return ( - - ); - } - - return ( -
    -
    - - - - - - {title} -
    -
    - {children} -
    -
    - ); -} diff --git a/src/components/experimental/DetermineHeader.js b/src/components/experimental/DetermineHeader.js deleted file mode 100644 index 23a128ebf2..0000000000 --- a/src/components/experimental/DetermineHeader.js +++ /dev/null @@ -1,22 +0,0 @@ -import React from "react"; -import clsx from "clsx"; -import Link from "@docusaurus/Link"; - -export default function DetermineHeader({hLevel, hText}) { - switch (hLevel) { - case "##": - return

    {hText}

    ; - break; - case "###": - return

    {hText}

    ; - break; - case "####": - return

    {hText}

    ; - break; - case "#####": - return

    {hText}

    ; - break; - default: - return null; - } -} diff --git a/src/components/experimental/InfoButton.js b/src/components/experimental/InfoButton.js deleted file mode 100644 index e2f3253a6d..0000000000 --- a/src/components/experimental/InfoButton.js +++ /dev/null @@ -1,29 +0,0 @@ -import React, { useState } from 'react'; - -const InfoButton = ({ message }) => { - const [isActive, setIsActive] = useState(false); - - const toggleTooltip = () => setIsActive(!isActive); - - return ( -
    - - - {isActive && ( -
    -
    {message}
    -
    - )} -
    - ); -}; - -export default InfoButton; diff --git a/src/components/experimental/NoBreak.js b/src/components/experimental/NoBreak.js deleted file mode 100644 index ac674960e9..0000000000 --- a/src/components/experimental/NoBreak.js +++ /dev/null @@ -1,21 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; - -// NoBreak component that ensures text inside it doesn't break across lines -const NoBreak = ({ children }) => { - return ( - - {children} - - ); -}; - -// Prop validation -NoBreak.propTypes = { - children: PropTypes.node.isRequired, -}; - -export default NoBreak; - -// import NoBreak from '@site/src/components/NoBreak'; -// Some text that should stay together and not break across lines. diff --git a/src/components/experimental/ResponsivePlayer.js b/src/components/experimental/ResponsivePlayer.js deleted file mode 100644 index 0ab083717d..0000000000 --- a/src/components/experimental/ResponsivePlayer.js +++ /dev/null @@ -1,24 +0,0 @@ -import React from "react"; -import ReactPlayer from "react-player"; - -function ResponsivePlayer({url, loop, playing}) { - return ( -
    - -
    - ); -} - -export default ResponsivePlayer; diff --git a/src/components/experimental/RowOfImages.js b/src/components/experimental/RowOfImages.js deleted file mode 100644 index b1c194f3d2..0000000000 --- a/src/components/experimental/RowOfImages.js +++ /dev/null @@ -1,16 +0,0 @@ -import React from "react"; - -export default function RowOfImages({imagePath1, imagePath2}) { - return ( -
    -
    -
    - -
    -
    - -
    -
    -
    - ); -} diff --git a/src/components/experimental/Spacer.js b/src/components/experimental/Spacer.js deleted file mode 100644 index b76fb46d6d..0000000000 --- a/src/components/experimental/Spacer.js +++ /dev/null @@ -1,22 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; - -// Spacer component that creates a series of   based on the width prop -const Spacer = ({ width }) => { - // Create an array with "width" number of non-breaking spaces - const spaces = Array.from({ length: width }).map((_, index) => ( -   - )); - - return <>{spaces}; -}; - -// Prop validation -Spacer.propTypes = { - width: PropTypes.number.isRequired, -}; - -export default Spacer; - -// import Spacer from '@site/src/components/Spacer'; -// diff --git a/src/components/experimental/ToolTipText.js b/src/components/experimental/ToolTipText.js deleted file mode 100644 index b8a73b0996..0000000000 --- a/src/components/experimental/ToolTipText.js +++ /dev/null @@ -1,133 +0,0 @@ -import React, { useState, useRef, useEffect } from 'react'; - -const ToolTipText = ({ term, tooltip }) => { - const [isOpen, setIsOpen] = useState(false); // Tooltip is closed by default - const [isClosingByButton, setIsClosingByButton] = useState(false); // Track if it's closing by button - const tooltipRef = useRef(null); - const termRef = useRef(null); - - // Open the tooltip with animation after 0.1s pause - const openTooltip = () => { - setTimeout(() => { - setIsOpen(true); // Open the tooltip after the delay - }, 100); // 0.1s pause before opening - }; - - // Close the tooltip instantly (no animation) when clicked outside or on term - const closeTooltipInstantly = () => { - setIsOpen(false); // Instantly close the tooltip - setIsClosingByButton(false); // Reset the closing button flag - }; - - // Close the tooltip with animation when "Done" is clicked - const closeTooltipWithAnimation = () => { - setIsClosingByButton(true); // Mark as closing by button - setIsOpen(false); // Trigger animation by changing state - }; - - // Add event listener to close the tooltip when clicking outside - useEffect(() => { - const handleClickOutside = (event) => { - if ( - tooltipRef.current && - !tooltipRef.current.contains(event.target) && - termRef.current && - !termRef.current.contains(event.target) - ) { - closeTooltipInstantly(); // Close instantly when clicking outside - } - }; - - document.addEventListener('click', handleClickOutside); - - return () => { - document.removeEventListener('click', handleClickOutside); // Clean up the listener on component unmount - }; - }, []); - - return ( - - {/* Term with blue color and underlined */} - - {term} - - - {/* Tooltip Message */} - {isOpen && ( - - )} - - ); -}; - -export default ToolTipText; diff --git a/src/components/images/CaptionedImage.js b/src/components/images/CaptionedImage.js deleted file mode 100644 index 51cdaca019..0000000000 --- a/src/components/images/CaptionedImage.js +++ /dev/null @@ -1,64 +0,0 @@ -// src/components/captioned-image/CaptionedImage.js -import React, { useState } from "react"; -import styles from "./CaptionedImage.module.css"; - -const CaptionedImage = ({ src, srcDark, alt, title, width, inset = "0%", zoom = false }) => { - const [isZoomed, setIsZoomed] = useState(false); - - const toggleZoom = () => { - if (zoom) { - setIsZoomed(!isZoomed); - } - }; - - const imgStyle = { - width: isZoomed && zoom ? "auto" : "100%", - height: "auto", - maxWidth: isZoomed && zoom ? "none" : "100%", - maxHeight: isZoomed && zoom ? "none" : "auto", - objectFit: "contain", - transition: "transform 0.3s ease-in-out", - }; - - return ( -
    - {srcDark ? ( - <> - {alt - {alt - - ) : ( - {alt - )} - {title &&

    {title}

    } -
    - ); -}; - -export default CaptionedImage; diff --git a/src/components/images/CaptionedImage.module.css b/src/components/images/CaptionedImage.module.css deleted file mode 100644 index e65a6d3a91..0000000000 --- a/src/components/images/CaptionedImage.module.css +++ /dev/null @@ -1,42 +0,0 @@ -/* src/components/captioned-image/CaptionedImage.module.css */ -.imageContainer { - margin: 4px 4px; -} - -.image { - max-width: 100%; - height: auto; -} - -.title { - margin-top: 0px; - font-size: 0.8em; - text-align: start; - font-weight: bold; - padding-left: 8px; - padding-right: 8px; -} - -.lightOnly { - visibility: visible; - position: relative; -} - -.darkOnly { - visibility: hidden; - position: absolute; - top: 0; - left: 0; -} - -:global([data-theme='dark']) .lightOnly { - visibility: hidden; - position: absolute; - top: 0; - left: 0; -} - -:global([data-theme='dark']) .darkOnly { - visibility: visible; - position: relative; -} diff --git a/src/components/images/EnlargeImage.js b/src/components/images/EnlargeImage.js deleted file mode 100644 index 94afefdf2e..0000000000 --- a/src/components/images/EnlargeImage.js +++ /dev/null @@ -1,13 +0,0 @@ -const EnlargeImage = ({ src, alt, ariaLabel }) => { - const label = ariaLabel || alt; - - return ( -
    - - {label} - -
    - ); -}; - -export default EnlargeImage; diff --git a/src/components/images/ZoomingImage.js b/src/components/images/ZoomingImage.js deleted file mode 100644 index 6893e6ff80..0000000000 --- a/src/components/images/ZoomingImage.js +++ /dev/null @@ -1,49 +0,0 @@ -import React, { useState } from 'react'; -import { useColorMode } from '@docusaurus/theme-common'; - -const ZoomingImage = ({ src, alt, ariaLabel, initialWidth = '200px' }) => { - const [isZoomed, setIsZoomed] = useState(false); - const { colorMode } = useColorMode(); - - const label = ariaLabel || alt; - - const handleClick = () => { - setIsZoomed(!isZoomed); - }; - - return ( -
    -
    - - 🔍 - - - Click image to expand - -
    - {alt} -
    - ); -}; - -export default ZoomingImage; diff --git a/src/components/index.js b/src/components/index.js index 7fd2275ca3..f6662e2e3d 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -1,29 +1,9 @@ // Website components -export { default as RetrySimulator } from './elements/RetrySimulator'; -export { default as StandaloneActivityDemo } from './elements/StandaloneActivityDemo'; +export * from './elements' +export * from './Cookbook' +export * from './Demos' +export * from './Quickstart' +export * from './ToolTipTerm'; + export { default as HomePageHero } from './elements/HomePageHero'; -export { SdkLogos } from './elements/SdkLogos'; -export { SdkLogosAsBlocks } from './elements/SdkLogosAsBlocks'; -export { SdkGuideLinks } from './elements/SdkGuideLinks'; export { default as PhotoCarousel } from './elements/PhotoCarousel'; -export { default as SdkTabs } from './elements/SdkTabs'; -export { default as ServerlessWorkerDemo } from './elements/ServerlessWorkerDemo'; - -// Formatting components -export { default as DocsTable, NewDocsCell, DocsTableRow } from './formatting/DocsTable'; -export { default as JsonTable } from './formatting/JsonTable'; - -// Image components -export { default as CaptionedImage } from './images/CaptionedImage'; -export { default as EnlargeImage } from './images/EnlargeImage'; -export { default as ZoomingImage } from './images/ZoomingImage'; - -// Information components -export { default as DiscoverableDisclosure } from './info/DiscoverableDisclosure'; -export { default as ToolTipTerm } from './info/ToolTipTerm'; -export { RelatedReadContainer, RelatedReadItem } from './info/RelatedRead/RelatedRead'; -export { default as RelatedReadList } from './info/RelatedRead/RelatedReadList'; -export { default as ReleaseNoteHeader } from './info/ReleaseNoteHeader/ReleaseNoteHeader'; - -// Extra export -export { default } from './elements/SdkTabs'; diff --git a/src/components/info/DiscoverableDisclosure.js b/src/components/info/DiscoverableDisclosure.js deleted file mode 100644 index 20ca635669..0000000000 --- a/src/components/info/DiscoverableDisclosure.js +++ /dev/null @@ -1,112 +0,0 @@ -import React, { useState, useRef, useEffect } from 'react'; - -const DiscoverableDisclosure = ({ label = "Summary", children, prompt = "Dive deeper — " }) => { - const [isOpen, setIsOpen] = useState(false); - const contentRef = useRef(null); - const summaryRef = useRef(null); - const isInitialRender = useRef(true); // Track the initial render - - const [maxHeight, setMaxHeight] = useState('0px'); - - const toggleOpen = () => { - setIsOpen((prev) => { - const newState = !prev; - - if (newState) { - setMaxHeight('500px'); - } else { - setMaxHeight('0px'); - } - - return newState; - }); - }; - - useEffect(() => { - if (isInitialRender.current) { - // Skip the first render, no scroll on initial load - isInitialRender.current = false; - return; - } - - // Only scroll into view when the disclosure is closing - if (!isOpen && summaryRef.current) { - summaryRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' }); - } - - // Dynamically adjust max-height when content is open - if (isOpen && contentRef.current) { - setMaxHeight(`${contentRef.current.scrollHeight}px`); - } - }, [isOpen]); - - return ( -
    - (e.target.style.color = '#007BFF')} - onMouseLeave={(e) => (e.target.style.color = '')} - > - {prompt} - {label} - - {isOpen ? '[-]' : '[+]'} - - - -
    - {children} -
    -
    - - {isOpen && ( -
    - ↩️ - Close back up -
    - )} -
    - ); -}; - -export default DiscoverableDisclosure; diff --git a/src/components/info/RelatedRead/RelatedReadList.js b/src/components/info/RelatedRead/RelatedReadList.js deleted file mode 100644 index d34382034e..0000000000 --- a/src/components/info/RelatedRead/RelatedReadList.js +++ /dev/null @@ -1,229 +0,0 @@ -import React from "react"; -import clsx from "clsx"; -import Link from "@docusaurus/Link"; -import {v4 as uuidv4} from "uuid"; - -export function RelatedReadContainer({children}) { - let rl = []; - if (React.Children.count(children) > 1) { - React.Children.forEach(children, function (child) { - let id = uuidv4(); - rl.push({id: id, child: child}); - }); - } - return ( -
    - Related 📚 - {rl.length > 1 ? ( -
      - {rl.map(({id, child}) => ( -
    • {child}
    • - ))} -
    - ) : ( - children - )} -
    - ); -} - -export function RelatedReadItem({page, children}) { - const { - frontMatter, - metadata, - // contentTitle // doesnt seem to work yet - } = page; - // console.log({ frontMatter, metadata }) - // identify tags - let tagClass, tag; - for (const t of frontMatter.tags) { - if ( - [ - "developer-guide", - "operation-guide", - "tutorial", - "explanation", - "reference", - ].includes(t) - ) { - tag = t; - tagClass = "archetype-tag-" + t; - } - } - return ( - <> - - {children || frontMatter.title} - - - {tag} - - - ); -} - -export function Preview({ - className, - page: { - frontMatter, - metadata, - // contentTitle // doesnt seem to work yet - }, - children, -}) { - const [show, setShow] = React.useState(false); - return ( - setShow(true)} - onMouseLeave={() => setShow(false)} - style={{position: "relative", display: "inline-block"}} - > - - {children} - - - {show && ( -
    -
    - {frontMatter.title} -
    -
    - {metadata.description} - - - see full article >> - - -
    -
    - )} -
    - ); -} - -function InfoIcon() { - return ( - - - - - - ); -} - -// TODO - delete everything below this line once we deprecate - -export default function RelatedReadList({readlist}) { - let readingList = []; - for (const item of readlist) { - const tagStuff = tagInfo(item[2]); - if (tagStuff instanceof Error) throw tagStuff; - // form data structure - readingList.push({ - id: uuidv4(), - text: item[0], - goTo: item[1], - tag: tagStuff.tag, - tagClass: tagStuff.tagClass, - }); - } - if (readingList.length == 1) { - return ( -
    - Related 📚 - {readingList.map(({id, text, goTo, tag, tagClass}) => ( - - - {text} - - - {tag} - - - ))} -
    - ); - } else { - return ( -
    - Related 📚 -
      - {readingList.map(({id, text, goTo, tag, tagClass}) => ( -
    • - - {text} - - - {tag} - -
    • - ))} -
    -
    - ); - } -} - -function tagInfo(tag) { - var tagClass; - switch (tag) { - case "developer guide": - tagClass = "archetype-tag-developer-guide"; - break; - case "operation guide": - tagClass = "archetype-tag-operation-guide"; - break; - case "tutorial": - tagClass = "archetype-tag-tutorial"; - break; - case "explanation": - tagClass = "archetype-tag-explanation"; - break; - case "reference": - tagClass = "archetype-tag-reference"; - break; - default: - return new Error("unrecognized tag: " + tag); - } - return {tag: tag, tagClass: tagClass}; -} diff --git a/src/components/info/constants.js b/src/components/info/constants.js deleted file mode 100644 index 1b32faa8ba..0000000000 --- a/src/components/info/constants.js +++ /dev/null @@ -1,11 +0,0 @@ -export const LANGUAGE_ICONS = { - Go: "/img/sdks/svgs/golang.svg", - Java: "/img/sdks/svgs/java.svg", - PHP: "/img/sdks/svgs/php.svg", - Python: "/img/sdks/svgs/python.svg", - TypeScript: "/img/sdks/svgs/typescript.svg", - ".NET": "/img/sdks/svgs/dotnet.svg", - Ruby: "/img/sdks/svgs/ruby.svg", - Rust: "/img/sdks/svgs/rust.svg", - "Temporal CLI": "/img/assets/terminal.svg", -}; \ No newline at end of file diff --git a/src/constants/algolia.js b/src/constants/algolia.js new file mode 100644 index 0000000000..2433d5e809 --- /dev/null +++ b/src/constants/algolia.js @@ -0,0 +1,8 @@ +// Single source of truth for the Algolia app/index used by both the search +// UI (docusaurus.config.js theme config) and the client-side search-insights +// setup (src/theme/SearchBar/algoliaInsights.ts, src/pages/search.tsx). +module.exports = { + ALGOLIA_APP_ID: 'T5D6KNJCQS', + ALGOLIA_SEARCH_API_KEY: '4a2fa646f476d7756a7cdc599b625bec', + ALGOLIA_INDEX_NAME: 'temporal', +}; diff --git a/src/constants/featureReleaseTypes.js b/src/constants/featureReleaseTypes.js index 4d110e9a38..e6460ff47d 100644 --- a/src/constants/featureReleaseTypes.js +++ b/src/constants/featureReleaseTypes.js @@ -3,6 +3,7 @@ export const FEATURE_RELEASE_TYPES = { cloudCli: "prerelease", standaloneActivity: "publicPreview", + standaloneNexusOperation: "prerelease", nexus: "publicPreview", workflowStreams: "publicPreview", serverlessWorkers: "prerelease", diff --git a/src/constants/mermaidTheme.js b/src/constants/mermaidTheme.js new file mode 100644 index 0000000000..c86928ce95 --- /dev/null +++ b/src/constants/mermaidTheme.js @@ -0,0 +1,48 @@ +// Single source of truth for Mermaid diagram layout/typography, used by +// docusaurus.config.js (themeConfig.mermaid.options) and by the dev-only +// diagram review tool (scripts/mermaid-compare/, regenerate its +// mermaid-theme.json from this file after editing it). +// +// These are mode-independent (no colors) because Docusaurus applies +// `themeConfig.mermaid.options` identically in light and dark mode; only +// `themeConfig.mermaid.theme.{light,dark}` (a built-in theme name) varies by +// color mode. Brand colors are handled separately via CSS overrides in +// src/css/custom.css targeting Mermaid's generated SVG classes, which is not +// subject to that light/dark limitation — see the comment there for why. +module.exports = { + // Kept mode-aware (matching Docusaurus's own default) rather than pinned + // to a single base theme, as a safety net for anything custom.css's + // Mermaid overrides don't explicitly recolor (e.g. rarely-used elements + // like the sequenceNumber marker) — verified those still need Mermaid's + // own light/dark split to stay correct, not just the CSS layer. + theme: { light: 'default', dark: 'dark' }, + fontFamily: '"Aeonik", "Poppins", -apple-system, BlinkMacSystemFont, sans-serif', + flowchart: { + padding: 14, + nodeSpacing: 45, + rankSpacing: 60, + curve: 'basis', + htmlLabels: true, + wrap: true, + }, + sequence: { + actorMargin: 60, + messageMargin: 40, + boxMargin: 12, + noteMargin: 12, + // NOT wrap: true — Mermaid mis-sizes the note/message box width whenever + // the text already has explicit
    breaks (a common pattern in these + // diagrams) while wrap is on, at any font size: the box comes out + // narrower than the actual rendered text, so text overflows its own + // box. Confirmed by direct comparison; flowchart's `wrap` does not have + // this problem (only sequence). Long single-line messages without
    + // just render unwrapped (no auto line-break) instead — worse for that + // one case, but the halo on .messageText already keeps unwrapped text + // readable where it crosses a line, whereas a mis-sized note box is a + // correctness bug with no equivalent mitigation. + mirrorActors: false, + }, + state: { + padding: 14, + }, +}; diff --git a/src/constants/ogImageColors.js b/src/constants/ogImageColors.js new file mode 100644 index 0000000000..44d6b842d2 --- /dev/null +++ b/src/constants/ogImageColors.js @@ -0,0 +1,14 @@ +// Colors used by the generated og:image cards (plugins/og-image/render.js). +// Where a value matches an existing brand color, it's the same hex as the +// CSS custom property in src/css/custom.css — CSS custom properties aren't +// consumable from a Node build script, so the value is duplicated here +// rather than imported. Keep these in sync if the source token changes. +module.exports = { + // Matches --indigo-100 in src/css/custom.css. + SUBTITLE_COLOR: '#cacbf9', + // Matches --off-white in src/css/custom.css. + FOOTER_COLOR: '#f8fafc', + // Pure white per the design team's Figma spec — deliberately not + // --off-white, no matching existing token. + TITLE_COLOR: '#ffffff', +}; diff --git a/src/constants/organizationSchema.ts b/src/constants/organizationSchema.ts new file mode 100644 index 0000000000..1191589f86 --- /dev/null +++ b/src/constants/organizationSchema.ts @@ -0,0 +1,56 @@ +// Single source of truth for the Organization/SoftwareApplication JSON-LD. +// Per the JSON-LD audit, the same `@id` was showing conflicting `name`, +// `logo`, and `sameAs` values across temporal.io and docs.temporal.io — +// centralizing the canonical object here (instead of hand-authoring it in +// multiple places) is how we keep that from happening again on this +// property. temporal.io and learn.temporal.io maintain their own copies in +// their own codebases and need to be kept byte-identical to this by hand. + +export const ORGANIZATION_ID = 'https://temporal.io/#organization'; +export const SOFTWARE_APPLICATION_ID = 'https://temporal.io/#software'; + +// Rendered in full only on the docs landing page (see +// src/theme/DocItem/StructuredData). Every other page should reference it +// via `organizationReference` instead of repeating the full property set. +export const organizationSchema = { + '@type': 'Organization', + '@id': ORGANIZATION_ID, + name: 'Temporal', + alternateName: 'Temporal Technologies', + url: 'https://temporal.io/', + description: + 'Temporal is a durable execution platform for building reliable, scalable applications using workflows and activities.', + logo: { + '@type': 'ImageObject', + url: 'https://temporal.io/images/logos/logo-temporal-dark-on-white.png', + width: 512, + height: 512, + }, + sameAs: [ + 'https://www.youtube.com/temporalio', + 'https://www.linkedin.com/company/temporal-technologies', + 'https://github.com/temporalio', + 'https://x.com/temporalio', + ], +}; + +export const softwareApplicationSchema = { + '@type': 'SoftwareApplication', + '@id': SOFTWARE_APPLICATION_ID, + name: 'Temporal', + applicationCategory: 'DeveloperApplication', + operatingSystem: 'Cross-platform', + url: 'https://temporal.io', + downloadUrl: 'https://github.com/temporalio/temporal', + description: + 'Temporal is a durable execution platform for building reliable, scalable applications using workflows and activities. This entry describes the open-source server distribution.', + publisher: { '@id': ORGANIZATION_ID }, + offers: { '@type': 'Offer', price: '0', priceCurrency: 'USD' }, +}; + +// Bare reference for every non-canonical page — attach as `publisher` on +// that page's own schema rather than repeating the full Organization block. +export const organizationReference = { + '@type': 'Organization', + '@id': ORGANIZATION_ID, +}; diff --git a/src/constants/sdkLanguages.js b/src/constants/sdkLanguages.js index 088220c2d5..29619c2d50 100644 --- a/src/constants/sdkLanguages.js +++ b/src/constants/sdkLanguages.js @@ -12,4 +12,16 @@ export const SDK_LANGUAGES = [ { key: 'dotnet', label: '.NET', icon: SiDotnet }, { key: 'rb', label: 'Ruby', icon: SiRuby }, { key: 'rs', label: 'Rust', icon: SiRust }, -]; \ No newline at end of file +]; + +export const LANGUAGE_SVGS = { + Go: "/img/sdks/svgs/golang.svg", + Java: "/img/sdks/svgs/java.svg", + PHP: "/img/sdks/svgs/php.svg", + Python: "/img/sdks/svgs/python.svg", + TypeScript: "/img/sdks/svgs/typescript.svg", + ".NET": "/img/sdks/svgs/dotnet.svg", + Ruby: "/img/sdks/svgs/ruby.svg", + Rust: "/img/sdks/svgs/rust.svg", + "Temporal CLI": "/img/assets/terminal.svg", +}; \ No newline at end of file diff --git a/src/css/custom.css b/src/css/custom.css index 0034f194eb..cf44f9dd33 100644 --- a/src/css/custom.css +++ b/src/css/custom.css @@ -1231,6 +1231,24 @@ code { color: var(--ifm-color-primary); } +.pattern-card-header { + display: flex; + align-items: center; + gap: 0.75rem; + margin-bottom: 0.75rem; +} + +.pattern-card-header img { + width: 48px; + height: 36px; + object-fit: contain; + flex-shrink: 0; +} + +.pattern-card-header h3 { + margin: 0; +} + .pattern-content h3 { font-size: 1.25rem; font-weight: 500; @@ -1260,6 +1278,741 @@ code { } } +/* + * Theme-adaptive Mermaid diagram colors, applied globally to every diagram + * with no per-file changes needed. Font/spacing options live in + * themeConfig.mermaid.options in docusaurus.config.js, sourced from + * src/constants/mermaidTheme.js — a single source of truth for both. + * + * Mermaid's theme/themeVariables API can't express different colors for + * light vs. dark mode through Docusaurus's themeConfig.mermaid (options are + * shared across both color modes), and Mermaid's own "base" theme rejects + * CSS var() inside themeVariables ("Unsupported color format"). So instead + * of fighting Mermaid's theme engine, this overrides Mermaid's generated SVG + * classes directly with real CSS custom properties — the same technique + * used for the semantic success/fail/etc. classes below, just applied to + * Mermaid's default (unclassed) elements. + * + * !important is required to beat Mermaid's own inline +
    + + + + + + UI, CLI, SDK + + + + + + top-level primitives + + + + + + + + + + + Standalone Nexus Operation + Execution + + + + + + Execute a single Nexus Operation + across Namespaces reliably + + + + + + + + + + + Workflow + Execution + + + + + + + + Nexus Operation (Step 1) + + + + + + Nexus Operation (Step 2) + + + + + + Nexus Operation (Step 3) + + + + + Workflows: Multi-step + orchestration with automatic + durability via event history + & replay + + + + + + + + + + + Temporal Nexus Service, Operation Handlers + & Worker Deployment + + + + + + + + Same Nexus programming model: + write once & use anywhere. + + + + + Nexus Operations can be called standalone or from a + Workflow, with the same Service contract and handlers. + + + + + + + Standalone Nexus Operation + + Workflow & Nexus Operation Steps + + Shared infrastructure + + Description + + diff --git a/static/diagrams/standalone-nexus-operations-vs-workflowlight.svg b/static/diagrams/standalone-nexus-operations-vs-workflowlight.svg new file mode 100644 index 0000000000..625c45a513 --- /dev/null +++ b/static/diagrams/standalone-nexus-operations-vs-workflowlight.svg @@ -0,0 +1,123 @@ + + + + + + + + + + + + + UI, CLI, SDK + + + + + + top-level primitives + + + + + + + + + + + Standalone Nexus Operation + Execution + + + + + + Execute a single Nexus Operation + across Namespaces reliably + + + + + + + + + + + Workflow + Execution + + + + + + + + Nexus Operation (Step 1) + + + + + + Nexus Operation (Step 2) + + + + + + Nexus Operation (Step 3) + + + + + Workflows: Multi-step + orchestration with automatic + durability via event history + & replay + + + + + + + + + + + Temporal Nexus Service, Operation Handlers + & Worker Deployment + + + + + + + + Same Nexus programming model: + write once & use anywhere. + + + + + Nexus Operations can be called standalone or from a + Workflow, with the same Service contract and handlers. + + + + + + + Standalone Nexus Operation + + Workflow & Nexus Operation Steps + + Shared infrastructure + + Description + + diff --git a/static/img/assets/info.svg b/static/img/assets/info.svg deleted file mode 100644 index 9319725df6..0000000000 --- a/static/img/assets/info.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/static/img/assets/left-chevron.png b/static/img/assets/left-chevron.png deleted file mode 100644 index f51274ddfb..0000000000 Binary files a/static/img/assets/left-chevron.png and /dev/null differ diff --git a/static/img/assets/right-chevron.png b/static/img/assets/right-chevron.png deleted file mode 100644 index d26d57606d..0000000000 Binary files a/static/img/assets/right-chevron.png and /dev/null differ diff --git a/static/img/assets/server.svg b/static/img/assets/server.svg deleted file mode 100644 index dcb54580d6..0000000000 --- a/static/img/assets/server.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/static/img/cloud/cloud-overview.png b/static/img/cloud/cloud-overview.png deleted file mode 100644 index d050ac212a..0000000000 Binary files a/static/img/cloud/cloud-overview.png and /dev/null differ diff --git a/static/img/cloud/high-availability/active-active.png b/static/img/cloud/high-availability/active-active.png deleted file mode 100644 index 01c3186ce1..0000000000 Binary files a/static/img/cloud/high-availability/active-active.png and /dev/null differ diff --git a/static/img/cloud/high-availability/active-passive.png b/static/img/cloud/high-availability/active-passive.png deleted file mode 100644 index 52b8ece6ee..0000000000 Binary files a/static/img/cloud/high-availability/active-passive.png and /dev/null differ diff --git a/static/img/cloud/high-availability/after-failover.png b/static/img/cloud/high-availability/after-failover.png deleted file mode 100644 index 4105dcd663..0000000000 Binary files a/static/img/cloud/high-availability/after-failover.png and /dev/null differ diff --git a/static/img/cloud/high-availability/before-failover.png b/static/img/cloud/high-availability/before-failover.png deleted file mode 100644 index 9f9e6aaaa4..0000000000 Binary files a/static/img/cloud/high-availability/before-failover.png and /dev/null differ diff --git a/static/img/cloud/high-availability/cell-with-multiple-namespaces.png b/static/img/cloud/high-availability/cell-with-multiple-namespaces.png deleted file mode 100644 index c949eef6f7..0000000000 Binary files a/static/img/cloud/high-availability/cell-with-multiple-namespaces.png and /dev/null differ diff --git a/static/img/cloud/high-availability/customer-environment.png b/static/img/cloud/high-availability/customer-environment.png deleted file mode 100644 index d6e8f5147c..0000000000 Binary files a/static/img/cloud/high-availability/customer-environment.png and /dev/null differ diff --git a/static/img/cloud/high-availability/customer-side-example.png b/static/img/cloud/high-availability/customer-side-example.png deleted file mode 100644 index 45bc6870d2..0000000000 Binary files a/static/img/cloud/high-availability/customer-side-example.png and /dev/null differ diff --git a/static/img/cloud/high-availability/failover-to-secondary-region.png b/static/img/cloud/high-availability/failover-to-secondary-region.png deleted file mode 100644 index 84ee68f70b..0000000000 Binary files a/static/img/cloud/high-availability/failover-to-secondary-region.png and /dev/null differ diff --git a/static/img/cloud/high-availability/private-link-old.png b/static/img/cloud/high-availability/private-link-old.png deleted file mode 100644 index 45bc6870d2..0000000000 Binary files a/static/img/cloud/high-availability/private-link-old.png and /dev/null differ diff --git a/static/img/cloud/high-availability/single-region.png b/static/img/cloud/high-availability/single-region.png deleted file mode 100644 index 9d3da9baba..0000000000 Binary files a/static/img/cloud/high-availability/single-region.png and /dev/null differ diff --git a/static/img/cloud/multi-region/active-active.png b/static/img/cloud/multi-region/active-active.png deleted file mode 100644 index 01c3186ce1..0000000000 Binary files a/static/img/cloud/multi-region/active-active.png and /dev/null differ diff --git a/static/img/cloud/multi-region/active-passive.png b/static/img/cloud/multi-region/active-passive.png deleted file mode 100644 index 52b8ece6ee..0000000000 Binary files a/static/img/cloud/multi-region/active-passive.png and /dev/null differ diff --git a/static/img/cloud/multi-region/after-failover.png b/static/img/cloud/multi-region/after-failover.png deleted file mode 100644 index 4105dcd663..0000000000 Binary files a/static/img/cloud/multi-region/after-failover.png and /dev/null differ diff --git a/static/img/cloud/multi-region/before-failover.png b/static/img/cloud/multi-region/before-failover.png deleted file mode 100644 index 9f9e6aaaa4..0000000000 Binary files a/static/img/cloud/multi-region/before-failover.png and /dev/null differ diff --git a/static/img/cloud/multi-region/cell-with-multiple-namespaces.png b/static/img/cloud/multi-region/cell-with-multiple-namespaces.png deleted file mode 100644 index c949eef6f7..0000000000 Binary files a/static/img/cloud/multi-region/cell-with-multiple-namespaces.png and /dev/null differ diff --git a/static/img/cloud/multi-region/customer-environment.png b/static/img/cloud/multi-region/customer-environment.png deleted file mode 100644 index d6e8f5147c..0000000000 Binary files a/static/img/cloud/multi-region/customer-environment.png and /dev/null differ diff --git a/static/img/cloud/multi-region/customer-side-example.png b/static/img/cloud/multi-region/customer-side-example.png deleted file mode 100644 index 45bc6870d2..0000000000 Binary files a/static/img/cloud/multi-region/customer-side-example.png and /dev/null differ diff --git a/static/img/cloud/multi-region/failover-to-secondary-region.png b/static/img/cloud/multi-region/failover-to-secondary-region.png deleted file mode 100644 index 84ee68f70b..0000000000 Binary files a/static/img/cloud/multi-region/failover-to-secondary-region.png and /dev/null differ diff --git a/static/img/cloud/multi-region/private-link.png b/static/img/cloud/multi-region/private-link.png deleted file mode 100644 index 45bc6870d2..0000000000 Binary files a/static/img/cloud/multi-region/private-link.png and /dev/null differ diff --git a/static/img/cloud/multi-region/single-region.png b/static/img/cloud/multi-region/single-region.png deleted file mode 100644 index 9d3da9baba..0000000000 Binary files a/static/img/cloud/multi-region/single-region.png and /dev/null differ diff --git a/static/img/cloud/privatelink/vpc-endpoints.png b/static/img/cloud/privatelink/vpc-endpoints.png deleted file mode 100644 index 8c13d81f7d..0000000000 Binary files a/static/img/cloud/privatelink/vpc-endpoints.png and /dev/null differ diff --git a/static/img/design-patterns/human-in-the-loop-poster.jpg b/static/img/design-patterns/human-in-the-loop-poster.jpg new file mode 100644 index 0000000000..160b8f7b00 Binary files /dev/null and b/static/img/design-patterns/human-in-the-loop-poster.jpg differ diff --git a/static/img/design-patterns/icons/activity-dependency-injection-icon.svg b/static/img/design-patterns/icons/activity-dependency-injection-icon.svg new file mode 100644 index 0000000000..b175a52ddc --- /dev/null +++ b/static/img/design-patterns/icons/activity-dependency-injection-icon.svg @@ -0,0 +1,15 @@ + + + + DB + + + + + + + + fn() + + + diff --git a/static/img/design-patterns/icons/approval-icon.svg b/static/img/design-patterns/icons/approval-icon.svg new file mode 100644 index 0000000000..a75cfbf7df --- /dev/null +++ b/static/img/design-patterns/icons/approval-icon.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/static/img/design-patterns/icons/batch-iterator-icon.svg b/static/img/design-patterns/icons/batch-iterator-icon.svg new file mode 100644 index 0000000000..5ca3c82c08 --- /dev/null +++ b/static/img/design-patterns/icons/batch-iterator-icon.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/static/img/design-patterns/icons/child-workflows-icon.svg b/static/img/design-patterns/icons/child-workflows-icon.svg new file mode 100644 index 0000000000..f20e9cddef --- /dev/null +++ b/static/img/design-patterns/icons/child-workflows-icon.svg @@ -0,0 +1,18 @@ + + + + P + + + + + + + + + C + + C + + C + diff --git a/static/img/design-patterns/icons/continue-as-new-icon.svg b/static/img/design-patterns/icons/continue-as-new-icon.svg new file mode 100644 index 0000000000..0e0d3f4943 --- /dev/null +++ b/static/img/design-patterns/icons/continue-as-new-icon.svg @@ -0,0 +1,8 @@ + + + + + + + 1→2 + diff --git a/static/img/design-patterns/icons/delayed-retry-icon.svg b/static/img/design-patterns/icons/delayed-retry-icon.svg new file mode 100644 index 0000000000..d8b2f54a10 --- /dev/null +++ b/static/img/design-patterns/icons/delayed-retry-icon.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/static/img/design-patterns/icons/delayed-start-icon.svg b/static/img/design-patterns/icons/delayed-start-icon.svg new file mode 100644 index 0000000000..33e070903b --- /dev/null +++ b/static/img/design-patterns/icons/delayed-start-icon.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/static/img/design-patterns/icons/downstream-rate-limiting-icon.svg b/static/img/design-patterns/icons/downstream-rate-limiting-icon.svg new file mode 100644 index 0000000000..30160c70c3 --- /dev/null +++ b/static/img/design-patterns/icons/downstream-rate-limiting-icon.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/static/img/design-patterns/icons/eager-workflow-start-icon.svg b/static/img/design-patterns/icons/eager-workflow-start-icon.svg new file mode 100644 index 0000000000..4eccb290ed --- /dev/null +++ b/static/img/design-patterns/icons/eager-workflow-start-icon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/static/img/design-patterns/icons/early-return-icon.svg b/static/img/design-patterns/icons/early-return-icon.svg new file mode 100644 index 0000000000..dca32f977c --- /dev/null +++ b/static/img/design-patterns/icons/early-return-icon.svg @@ -0,0 +1,16 @@ + + + + + + + + + fast + + + + + + slow + diff --git a/static/img/design-patterns/icons/early-return-local-activities-icon.svg b/static/img/design-patterns/icons/early-return-local-activities-icon.svg new file mode 100644 index 0000000000..623f9cd32c --- /dev/null +++ b/static/img/design-patterns/icons/early-return-local-activities-icon.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + slow + diff --git a/static/img/design-patterns/icons/entity-workflow-icon.svg b/static/img/design-patterns/icons/entity-workflow-icon.svg new file mode 100644 index 0000000000..74af2c2787 --- /dev/null +++ b/static/img/design-patterns/icons/entity-workflow-icon.svg @@ -0,0 +1,12 @@ + + + + + + + + state + + + + diff --git a/static/img/design-patterns/icons/event-accumulator-icon.svg b/static/img/design-patterns/icons/event-accumulator-icon.svg new file mode 100644 index 0000000000..40900f906f --- /dev/null +++ b/static/img/design-patterns/icons/event-accumulator-icon.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/static/img/design-patterns/icons/fairness-icon.svg b/static/img/design-patterns/icons/fairness-icon.svg new file mode 100644 index 0000000000..7815564200 --- /dev/null +++ b/static/img/design-patterns/icons/fairness-icon.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/img/design-patterns/icons/fanout-child-workflows-icon.svg b/static/img/design-patterns/icons/fanout-child-workflows-icon.svg new file mode 100644 index 0000000000..1ae9f85c9a --- /dev/null +++ b/static/img/design-patterns/icons/fanout-child-workflows-icon.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/static/img/design-patterns/icons/fast-slow-retries-icon.svg b/static/img/design-patterns/icons/fast-slow-retries-icon.svg new file mode 100644 index 0000000000..c60900ee39 --- /dev/null +++ b/static/img/design-patterns/icons/fast-slow-retries-icon.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/static/img/design-patterns/icons/fixed-count-retries-icon.svg b/static/img/design-patterns/icons/fixed-count-retries-icon.svg new file mode 100644 index 0000000000..7b5490a207 --- /dev/null +++ b/static/img/design-patterns/icons/fixed-count-retries-icon.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/static/img/design-patterns/icons/fixed-wall-time-retries-icon.svg b/static/img/design-patterns/icons/fixed-wall-time-retries-icon.svg new file mode 100644 index 0000000000..cc8d7c2577 --- /dev/null +++ b/static/img/design-patterns/icons/fixed-wall-time-retries-icon.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/static/img/design-patterns/icons/local-activities-icon.svg b/static/img/design-patterns/icons/local-activities-icon.svg new file mode 100644 index 0000000000..0c1e83ee14 --- /dev/null +++ b/static/img/design-patterns/icons/local-activities-icon.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/img/design-patterns/icons/long-running-activity-icon.svg b/static/img/design-patterns/icons/long-running-activity-icon.svg new file mode 100644 index 0000000000..2f190f1674 --- /dev/null +++ b/static/img/design-patterns/icons/long-running-activity-icon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/static/img/design-patterns/icons/mapreduce-tree-icon.svg b/static/img/design-patterns/icons/mapreduce-tree-icon.svg new file mode 100644 index 0000000000..506a84a2a2 --- /dev/null +++ b/static/img/design-patterns/icons/mapreduce-tree-icon.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/img/design-patterns/icons/non-retryable-errors-icon.svg b/static/img/design-patterns/icons/non-retryable-errors-icon.svg new file mode 100644 index 0000000000..176f49f9e6 --- /dev/null +++ b/static/img/design-patterns/icons/non-retryable-errors-icon.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/static/img/design-patterns/icons/parallel-execution-icon.svg b/static/img/design-patterns/icons/parallel-execution-icon.svg new file mode 100644 index 0000000000..e669648b56 --- /dev/null +++ b/static/img/design-patterns/icons/parallel-execution-icon.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/static/img/design-patterns/icons/pick-first-icon.svg b/static/img/design-patterns/icons/pick-first-icon.svg new file mode 100644 index 0000000000..c6cd605a84 --- /dev/null +++ b/static/img/design-patterns/icons/pick-first-icon.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + 1 + diff --git a/static/img/design-patterns/icons/polling-icon.svg b/static/img/design-patterns/icons/polling-icon.svg new file mode 100644 index 0000000000..00731c4d50 --- /dev/null +++ b/static/img/design-patterns/icons/polling-icon.svg @@ -0,0 +1,11 @@ + + + + + ? + + + + + + diff --git a/static/img/design-patterns/icons/priority-task-queues-icon.svg b/static/img/design-patterns/icons/priority-task-queues-icon.svg new file mode 100644 index 0000000000..3d62decb95 --- /dev/null +++ b/static/img/design-patterns/icons/priority-task-queues-icon.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/static/img/design-patterns/icons/request-response-icon.svg b/static/img/design-patterns/icons/request-response-icon.svg new file mode 100644 index 0000000000..04f9a60252 --- /dev/null +++ b/static/img/design-patterns/icons/request-response-icon.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/static/img/design-patterns/icons/resumable-activity-icon.svg b/static/img/design-patterns/icons/resumable-activity-icon.svg new file mode 100644 index 0000000000..5ce1c93c5a --- /dev/null +++ b/static/img/design-patterns/icons/resumable-activity-icon.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/static/img/design-patterns/icons/retry-metrics-icon.svg b/static/img/design-patterns/icons/retry-metrics-icon.svg new file mode 100644 index 0000000000..cd48fa4a05 --- /dev/null +++ b/static/img/design-patterns/icons/retry-metrics-icon.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/static/img/design-patterns/icons/saga-icon.svg b/static/img/design-patterns/icons/saga-icon.svg new file mode 100644 index 0000000000..1f6ba88827 --- /dev/null +++ b/static/img/design-patterns/icons/saga-icon.svg @@ -0,0 +1,17 @@ + + + + + + + + + + 1 + 2 + 3 + + + + + diff --git a/static/img/design-patterns/icons/signal-with-start-icon.svg b/static/img/design-patterns/icons/signal-with-start-icon.svg new file mode 100644 index 0000000000..b5de14a515 --- /dev/null +++ b/static/img/design-patterns/icons/signal-with-start-icon.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/static/img/design-patterns/icons/sliding-window-icon.svg b/static/img/design-patterns/icons/sliding-window-icon.svg new file mode 100644 index 0000000000..224687a84a --- /dev/null +++ b/static/img/design-patterns/icons/sliding-window-icon.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/static/img/design-patterns/icons/updatable-timer-icon.svg b/static/img/design-patterns/icons/updatable-timer-icon.svg new file mode 100644 index 0000000000..fd8e239520 --- /dev/null +++ b/static/img/design-patterns/icons/updatable-timer-icon.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + +5m + diff --git a/static/img/design-patterns/icons/webhooks-icon.svg b/static/img/design-patterns/icons/webhooks-icon.svg new file mode 100644 index 0000000000..1ba28094a5 --- /dev/null +++ b/static/img/design-patterns/icons/webhooks-icon.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + POST + diff --git a/static/img/design-patterns/icons/worker-specific-taskqueue-icon.svg b/static/img/design-patterns/icons/worker-specific-taskqueue-icon.svg new file mode 100644 index 0000000000..b9b0104021 --- /dev/null +++ b/static/img/design-patterns/icons/worker-specific-taskqueue-icon.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + W1 + + W2 + diff --git a/static/img/favicon.svg b/static/img/favicon.svg deleted file mode 100644 index 30eac4f6e3..0000000000 --- a/static/img/favicon.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/static/img/info/grpc-endpoint.png b/static/img/info/grpc-endpoint.png deleted file mode 100644 index 3733a3ce40..0000000000 Binary files a/static/img/info/grpc-endpoint.png and /dev/null differ diff --git a/static/img/info/python-sandbox.svg b/static/img/info/python-sandbox.svg deleted file mode 100644 index f9c39341ac..0000000000 --- a/static/img/info/python-sandbox.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/static/img/sdks/png/php.png b/static/img/sdks/png/php.png deleted file mode 100644 index b7ad6de013..0000000000 Binary files a/static/img/sdks/png/php.png and /dev/null differ diff --git a/static/img/sdks/png/ruby.png b/static/img/sdks/png/ruby.png deleted file mode 100644 index d98b697481..0000000000 Binary files a/static/img/sdks/png/ruby.png and /dev/null differ diff --git a/static/img/sdks/svgs/go-lang.svg b/static/img/sdks/svgs/go-lang.svg deleted file mode 100644 index 3333226759..0000000000 --- a/static/img/sdks/svgs/go-lang.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/static/img/sdks/svgs/javascript.svg b/static/img/sdks/svgs/javascript.svg deleted file mode 100644 index 97cf150db8..0000000000 --- a/static/img/sdks/svgs/javascript.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/static/json/privatelink_services_aws.json b/static/json/privatelink_services_aws.json new file mode 100644 index 0000000000..212ea5e570 --- /dev/null +++ b/static/json/privatelink_services_aws.json @@ -0,0 +1,19 @@ +{ + "columns":["Region","PrivateLink Service Name","DNS Record Override"], + "rows":[ + ["ap-northeast-1","com.amazonaws.vpce.ap-northeast-1.vpce-svc-08f34c33f9fb8a48a"], + ["ap-northeast-2","com.amazonaws.vpce.ap-northeast-2.vpce-svc-08c4d5445a5aad308"], + ["ap-south-1","com.amazonaws.vpce.ap-south-1.vpce-svc-0ad4f8ed56db15662"], + ["ap-south-2","com.amazonaws.vpce.ap-south-2.vpce-svc-08bcf602b646c69c1"], + ["ap-southeast-1","com.amazonaws.vpce.ap-southeast-1.vpce-svc-05c24096fa89b0ccd"], + ["ap-southeast-2","com.amazonaws.vpce.ap-southeast-2.vpce-svc-0634f9628e3c15b08"], + ["ca-central-1","com.amazonaws.vpce.ca-central-1.vpce-svc-080a781925d0b1d9d"], + ["eu-central-1","com.amazonaws.vpce.eu-central-1.vpce-svc-073a419b36663a0f3"], + ["eu-west-1","com.amazonaws.vpce.eu-west-1.vpce-svc-04388e89f3479b739"], + ["eu-west-2","com.amazonaws.vpce.eu-west-2.vpce-svc-0ac7f9f07e7fb5695"], + ["sa-east-1","com.amazonaws.vpce.sa-east-1.vpce-svc-0ca67a102f3ce525a"], + ["us-east-1","com.amazonaws.vpce.us-east-1.vpce-svc-0822256b6575ea37f"], + ["us-east-2","com.amazonaws.vpce.us-east-2.vpce-svc-01b8dccfc6660d9d4"], + ["us-west-2","com.amazonaws.vpce.us-west-2.vpce-svc-0f44b3d7302816b94"] + ] +} \ No newline at end of file diff --git a/static/json/stable-ip-ranges-prod.json b/static/json/stable-ip-ranges-prod.json index 63467035f0..8db0824e32 100644 --- a/static/json/stable-ip-ranges-prod.json +++ b/static/json/stable-ip-ranges-prod.json @@ -1 +1 @@ -{"all_stable_ip_ranges":["3.11.57.39/32","3.20.81.213/32","3.34.120.102/32","3.35.213.140/32","3.37.167.175/32","3.66.152.155/32","3.98.244.3/32","3.123.161.195/32","3.123.192.153/32","3.125.88.105/32","3.128.82.233/32","3.150.10.54/32","3.216.21.43/32","8.229.188.167/32","13.58.180.63/32","13.114.69.37/32","13.134.161.151/32","13.192.13.4/32","13.192.122.117/32","13.193.169.37/32","13.203.166.198/32","13.204.104.167/32","13.205.11.104/32","13.205.230.215/32","13.206.103.247/32","13.209.240.110/32","13.215.233.197/32","13.230.7.109/32","15.135.102.252/32","15.135.181.158/32","15.157.30.163/32","15.157.229.35/32","15.164.92.169/32","15.165.72.130/32","15.165.179.158/32","15.207.64.5/32","15.229.75.67/32","16.112.169.193/32","16.112.184.159/32","16.112.198.98/32","16.112.215.141/32","18.133.92.89/32","18.168.60.8/32","18.170.80.131/32","18.219.86.1/32","18.221.38.237/32","32.194.207.160/32","34.58.167.213/32","34.180.53.76/32","34.185.162.79/32","34.198.4.178/32","34.199.198.243/32","34.202.71.47/32","34.206.215.208/32","34.212.194.183/32","35.75.225.197/32","35.156.125.11/32","35.163.216.43/32","35.163.228.72/32","35.171.215.254/32","35.183.53.239/32","40.192.48.28/32","40.192.113.144/32","43.202.249.213/32","44.193.134.243/32","44.207.253.172/32","44.229.182.70/32","44.231.94.203/32","47.130.130.11/32","47.131.209.120/32","52.30.17.45/32","52.36.148.35/32","52.49.66.61/32","52.56.178.151/32","52.60.217.5/32","52.62.160.246/32","52.74.184.100/32","52.88.74.85/32","52.213.95.28/32","52.220.146.121/32","52.221.47.166/32","54.66.61.8/32","54.87.170.60/32","54.155.17.143/32","54.155.184.148/32","54.207.149.54/32","54.217.87.237/32","54.232.113.230/32","54.253.46.221/32","54.253.159.232/32","56.125.154.35/32","56.126.131.167/32","56.126.134.210/32","63.183.222.190/32","98.86.42.241/32","98.90.162.174/32","99.79.0.57/32","100.23.223.2/32","136.107.181.45/32"],"clouds":{"aws":{"regions":{"ap-northeast-1":{"stable_ip_ranges":["13.114.69.37/32","13.192.13.4/32","13.192.122.117/32","13.193.169.37/32","13.230.7.109/32","35.75.225.197/32"]},"ap-northeast-2":{"stable_ip_ranges":["3.34.120.102/32","3.35.213.140/32","3.37.167.175/32","13.209.240.110/32","15.164.92.169/32","15.165.72.130/32","15.165.179.158/32","43.202.249.213/32"]},"ap-south-1":{"stable_ip_ranges":["13.203.166.198/32","13.204.104.167/32","13.205.11.104/32","13.205.230.215/32","13.206.103.247/32","15.207.64.5/32"]},"ap-south-2":{"stable_ip_ranges":["16.112.169.193/32","16.112.184.159/32","16.112.198.98/32","16.112.215.141/32","40.192.48.28/32","40.192.113.144/32"]},"ap-southeast-1":{"stable_ip_ranges":["13.215.233.197/32","47.130.130.11/32","47.131.209.120/32","52.74.184.100/32","52.220.146.121/32","52.221.47.166/32"]},"ap-southeast-2":{"stable_ip_ranges":["15.135.102.252/32","15.135.181.158/32","52.62.160.246/32","54.66.61.8/32","54.253.46.221/32","54.253.159.232/32"]},"ca-central-1":{"stable_ip_ranges":["3.98.244.3/32","15.157.30.163/32","15.157.229.35/32","35.183.53.239/32","52.60.217.5/32","99.79.0.57/32"]},"eu-central-1":{"stable_ip_ranges":["3.66.152.155/32","3.123.161.195/32","3.123.192.153/32","3.125.88.105/32","35.156.125.11/32","63.183.222.190/32"]},"eu-west-1":{"stable_ip_ranges":["52.30.17.45/32","52.49.66.61/32","52.213.95.28/32","54.155.17.143/32","54.155.184.148/32","54.217.87.237/32"]},"eu-west-2":{"stable_ip_ranges":["3.11.57.39/32","13.134.161.151/32","18.133.92.89/32","18.168.60.8/32","18.170.80.131/32","52.56.178.151/32"]},"sa-east-1":{"stable_ip_ranges":["15.229.75.67/32","54.207.149.54/32","54.232.113.230/32","56.125.154.35/32","56.126.131.167/32","56.126.134.210/32"]},"us-east-1":{"stable_ip_ranges":["3.216.21.43/32","32.194.207.160/32","34.198.4.178/32","34.199.198.243/32","34.202.71.47/32","34.206.215.208/32","35.171.215.254/32","44.193.134.243/32","44.207.253.172/32","54.87.170.60/32","98.86.42.241/32","98.90.162.174/32"]},"us-east-2":{"stable_ip_ranges":["3.20.81.213/32","3.128.82.233/32","3.150.10.54/32","13.58.180.63/32","18.219.86.1/32","18.221.38.237/32"]},"us-west-2":{"stable_ip_ranges":["34.212.194.183/32","35.163.216.43/32","35.163.228.72/32","44.229.182.70/32","44.231.94.203/32","52.36.148.35/32","52.88.74.85/32","100.23.223.2/32"]}}},"gcp":{"regions":{"asia-south1":{"stable_ip_ranges":["34.180.53.76/32"]},"europe-west3":{"stable_ip_ranges":["34.185.162.79/32"]},"us-central1":{"stable_ip_ranges":["34.58.167.213/32"]},"us-east4":{"stable_ip_ranges":["136.107.181.45/32"]},"us-west1":{"stable_ip_ranges":["8.229.188.167/32"]}}}}} +{"all_stable_ip_ranges":["3.11.57.39/32","3.20.81.213/32","3.34.120.102/32","3.35.213.140/32","3.37.167.175/32","3.66.152.155/32","3.98.244.3/32","3.123.161.195/32","3.123.192.153/32","3.125.88.105/32","3.128.82.233/32","3.150.10.54/32","3.216.21.43/32","8.229.188.167/32","13.58.180.63/32","13.114.69.37/32","13.134.161.151/32","13.192.13.4/32","13.192.122.117/32","13.193.169.37/32","13.203.166.198/32","13.204.104.167/32","13.205.11.104/32","13.205.230.215/32","13.206.103.247/32","13.209.240.110/32","13.215.233.197/32","13.230.7.109/32","15.135.102.252/32","15.135.181.158/32","15.157.30.163/32","15.157.229.35/32","15.164.92.169/32","15.165.72.130/32","15.165.179.158/32","15.207.64.5/32","15.229.75.67/32","16.112.169.193/32","16.112.184.159/32","16.112.198.98/32","16.112.215.141/32","18.133.92.89/32","18.168.60.8/32","18.170.80.131/32","18.219.86.1/32","18.221.38.237/32","32.194.207.160/32","34.58.167.213/32","34.101.183.89/32","34.180.53.76/32","34.185.162.79/32","34.198.4.178/32","34.199.198.243/32","34.202.71.47/32","34.206.215.208/32","34.212.194.183/32","35.75.225.197/32","35.156.125.11/32","35.163.216.43/32","35.163.228.72/32","35.171.215.254/32","35.183.53.239/32","40.192.48.28/32","40.192.113.144/32","43.202.249.213/32","44.193.134.243/32","44.207.253.172/32","44.229.182.70/32","44.231.94.203/32","47.130.130.11/32","47.131.209.120/32","52.30.17.45/32","52.36.148.35/32","52.49.66.61/32","52.56.178.151/32","52.60.217.5/32","52.62.160.246/32","52.74.184.100/32","52.88.74.85/32","52.213.95.28/32","52.220.146.121/32","52.221.47.166/32","54.66.61.8/32","54.87.170.60/32","54.155.17.143/32","54.155.184.148/32","54.207.149.54/32","54.217.87.237/32","54.232.113.230/32","54.253.46.221/32","54.253.159.232/32","56.125.154.35/32","56.126.131.167/32","56.126.134.210/32","63.183.222.190/32","98.86.42.241/32","98.90.162.174/32","99.79.0.57/32","100.23.223.2/32","136.107.181.45/32"],"clouds":{"aws":{"regions":{"ap-northeast-1":{"stable_ip_ranges":["13.114.69.37/32","13.192.13.4/32","13.192.122.117/32","13.193.169.37/32","13.230.7.109/32","35.75.225.197/32"]},"ap-northeast-2":{"stable_ip_ranges":["3.34.120.102/32","3.35.213.140/32","3.37.167.175/32","13.209.240.110/32","15.164.92.169/32","15.165.72.130/32","15.165.179.158/32","43.202.249.213/32"]},"ap-south-1":{"stable_ip_ranges":["13.203.166.198/32","13.204.104.167/32","13.205.11.104/32","13.205.230.215/32","13.206.103.247/32","15.207.64.5/32"]},"ap-south-2":{"stable_ip_ranges":["16.112.169.193/32","16.112.184.159/32","16.112.198.98/32","16.112.215.141/32","40.192.48.28/32","40.192.113.144/32"]},"ap-southeast-1":{"stable_ip_ranges":["13.215.233.197/32","47.130.130.11/32","47.131.209.120/32","52.74.184.100/32","52.220.146.121/32","52.221.47.166/32"]},"ap-southeast-2":{"stable_ip_ranges":["15.135.102.252/32","15.135.181.158/32","52.62.160.246/32","54.66.61.8/32","54.253.46.221/32","54.253.159.232/32"]},"ca-central-1":{"stable_ip_ranges":["3.98.244.3/32","15.157.30.163/32","15.157.229.35/32","35.183.53.239/32","52.60.217.5/32","99.79.0.57/32"]},"eu-central-1":{"stable_ip_ranges":["3.66.152.155/32","3.123.161.195/32","3.123.192.153/32","3.125.88.105/32","35.156.125.11/32","63.183.222.190/32"]},"eu-west-1":{"stable_ip_ranges":["52.30.17.45/32","52.49.66.61/32","52.213.95.28/32","54.155.17.143/32","54.155.184.148/32","54.217.87.237/32"]},"eu-west-2":{"stable_ip_ranges":["3.11.57.39/32","13.134.161.151/32","18.133.92.89/32","18.168.60.8/32","18.170.80.131/32","52.56.178.151/32"]},"sa-east-1":{"stable_ip_ranges":["15.229.75.67/32","54.207.149.54/32","54.232.113.230/32","56.125.154.35/32","56.126.131.167/32","56.126.134.210/32"]},"us-east-1":{"stable_ip_ranges":["3.216.21.43/32","32.194.207.160/32","34.198.4.178/32","34.199.198.243/32","34.202.71.47/32","34.206.215.208/32","35.171.215.254/32","44.193.134.243/32","44.207.253.172/32","54.87.170.60/32","98.86.42.241/32","98.90.162.174/32"]},"us-east-2":{"stable_ip_ranges":["3.20.81.213/32","3.128.82.233/32","3.150.10.54/32","13.58.180.63/32","18.219.86.1/32","18.221.38.237/32"]},"us-west-2":{"stable_ip_ranges":["34.212.194.183/32","35.163.216.43/32","35.163.228.72/32","44.229.182.70/32","44.231.94.203/32","52.36.148.35/32","52.88.74.85/32","100.23.223.2/32"]}}},"gcp":{"regions":{"asia-south1":{"stable_ip_ranges":["34.180.53.76/32"]},"asia-southeast2":{"stable_ip_ranges":["34.101.183.89/32"]},"europe-west3":{"stable_ip_ranges":["34.185.162.79/32"]},"us-central1":{"stable_ip_ranges":["34.58.167.213/32"]},"us-east4":{"stable_ip_ranges":["136.107.181.45/32"]},"us-west1":{"stable_ip_ranges":["8.229.188.167/32"]}}}}} diff --git a/static/not-used/icons/chevron.png b/static/not-used/icons/chevron.png deleted file mode 100644 index 828a515cc5..0000000000 Binary files a/static/not-used/icons/chevron.png and /dev/null differ diff --git a/static/not-used/icons/chevron.svg b/static/not-used/icons/chevron.svg deleted file mode 100644 index 005a17ae47..0000000000 --- a/static/not-used/icons/chevron.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/static/not-used/icons/copycode.png b/static/not-used/icons/copycode.png deleted file mode 100644 index 13e982eda3..0000000000 Binary files a/static/not-used/icons/copycode.png and /dev/null differ diff --git a/static/not-used/icons/copycode.svg b/static/not-used/icons/copycode.svg deleted file mode 100644 index dad4f57605..0000000000 --- a/static/not-used/icons/copycode.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/static/not-used/img/Temporal_Logo_Animation.gif b/static/not-used/img/Temporal_Logo_Animation.gif deleted file mode 100644 index 9be7262a12..0000000000 Binary files a/static/not-used/img/Temporal_Logo_Animation.gif and /dev/null differ diff --git a/static/not-used/img/chevron-up-solid.svg b/static/not-used/img/chevron-up-solid.svg deleted file mode 100644 index 08bd96fc3e..0000000000 --- a/static/not-used/img/chevron-up-solid.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/static/not-used/img/click-task-queue-name.png b/static/not-used/img/click-task-queue-name.png deleted file mode 100644 index 0c44622bc5..0000000000 Binary files a/static/not-used/img/click-task-queue-name.png and /dev/null differ diff --git a/static/not-used/img/cloud-view-workflows.png b/static/not-used/img/cloud-view-workflows.png deleted file mode 100644 index 680c5dea68..0000000000 Binary files a/static/not-used/img/cloud-view-workflows.png and /dev/null differ diff --git a/static/not-used/img/confirm-workers-polling-task-queue.png b/static/not-used/img/confirm-workers-polling-task-queue.png deleted file mode 100644 index 4e839633bc..0000000000 Binary files a/static/not-used/img/confirm-workers-polling-task-queue.png and /dev/null differ diff --git a/static/not-used/img/copy-events-from-workflow-details-page.png b/static/not-used/img/copy-events-from-workflow-details-page.png deleted file mode 100644 index 55ba25c2f3..0000000000 Binary files a/static/not-used/img/copy-events-from-workflow-details-page.png and /dev/null differ diff --git a/static/not-used/img/copy-grpc-endpoint.png b/static/not-used/img/copy-grpc-endpoint.png deleted file mode 100644 index 99f2513521..0000000000 Binary files a/static/not-used/img/copy-grpc-endpoint.png and /dev/null differ diff --git a/static/not-used/img/deterministic-failure.png b/static/not-used/img/deterministic-failure.png deleted file mode 100644 index a20e9df6b4..0000000000 Binary files a/static/not-used/img/deterministic-failure.png and /dev/null differ diff --git a/static/not-used/img/java/copy-events-from-workflow-details-page.png b/static/not-used/img/java/copy-events-from-workflow-details-page.png deleted file mode 100644 index e3a9edb0ea..0000000000 Binary files a/static/not-used/img/java/copy-events-from-workflow-details-page.png and /dev/null differ diff --git a/static/not-used/img/java/event-terminated-and-reset.png b/static/not-used/img/java/event-terminated-and-reset.png deleted file mode 100644 index ab17510eaa..0000000000 Binary files a/static/not-used/img/java/event-terminated-and-reset.png and /dev/null differ diff --git a/static/not-used/img/java/new-event-history-success-with-reset.png b/static/not-used/img/java/new-event-history-success-with-reset.png deleted file mode 100644 index 72b2a86402..0000000000 Binary files a/static/not-used/img/java/new-event-history-success-with-reset.png and /dev/null differ diff --git a/static/not-used/img/java/non-deterministic-workflow-task-failure-java.png b/static/not-used/img/java/non-deterministic-workflow-task-failure-java.png deleted file mode 100644 index c3e347c515..0000000000 Binary files a/static/not-used/img/java/non-deterministic-workflow-task-failure-java.png and /dev/null differ diff --git a/static/not-used/img/java/select-event-reset-web-ui.png b/static/not-used/img/java/select-event-reset-web-ui.png deleted file mode 100644 index f18ff8497e..0000000000 Binary files a/static/not-used/img/java/select-event-reset-web-ui.png and /dev/null differ diff --git a/static/not-used/img/java/select-reset-web-ui.png b/static/not-used/img/java/select-reset-web-ui.png deleted file mode 100644 index 9efc6f4634..0000000000 Binary files a/static/not-used/img/java/select-reset-web-ui.png and /dev/null differ diff --git a/static/not-used/img/java/select-workflow-execution-in-ui.png b/static/not-used/img/java/select-workflow-execution-in-ui.png deleted file mode 100644 index 16b98581e3..0000000000 Binary files a/static/not-used/img/java/select-workflow-execution-in-ui.png and /dev/null differ diff --git a/static/not-used/img/logo/21972-312_SOC_NonCPA_Blk.png b/static/not-used/img/logo/21972-312_SOC_NonCPA_Blk.png deleted file mode 100644 index 65a0904a18..0000000000 Binary files a/static/not-used/img/logo/21972-312_SOC_NonCPA_Blk.png and /dev/null differ diff --git a/static/not-used/img/moon.svg b/static/not-used/img/moon.svg deleted file mode 100644 index 5a497a87b7..0000000000 --- a/static/not-used/img/moon.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/static/not-used/img/non-deterministic-workflow-task-failure.png b/static/not-used/img/non-deterministic-workflow-task-failure.png deleted file mode 100644 index f3f3b00672..0000000000 Binary files a/static/not-used/img/non-deterministic-workflow-task-failure.png and /dev/null differ diff --git a/static/not-used/img/recent-workflows.avif b/static/not-used/img/recent-workflows.avif deleted file mode 100644 index 79a8466eec..0000000000 Binary files a/static/not-used/img/recent-workflows.avif and /dev/null differ diff --git a/static/not-used/img/search.svg b/static/not-used/img/search.svg deleted file mode 100644 index 540fbecd4d..0000000000 --- a/static/not-used/img/search.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/static/not-used/img/select-workflow-execution-in-ui.png b/static/not-used/img/select-workflow-execution-in-ui.png deleted file mode 100644 index 7bf27ba253..0000000000 Binary files a/static/not-used/img/select-workflow-execution-in-ui.png and /dev/null differ diff --git a/static/not-used/img/sun.svg b/static/not-used/img/sun.svg deleted file mode 100644 index 0df13b3d01..0000000000 --- a/static/not-used/img/sun.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/static/not-used/img/web-ui-namespace-selection.png b/static/not-used/img/web-ui-namespace-selection.png deleted file mode 100644 index c5a02577cb..0000000000 Binary files a/static/not-used/img/web-ui-namespace-selection.png and /dev/null differ diff --git a/static/not-used/img/webui-view-workflows.png b/static/not-used/img/webui-view-workflows.png deleted file mode 100644 index 5c66d0ae53..0000000000 Binary files a/static/not-used/img/webui-view-workflows.png and /dev/null differ diff --git a/static/pdf/Temporal-Terms-of-Service-6-7-22.pdf b/static/pdf/Temporal-Terms-of-Service-6-7-22.pdf deleted file mode 100644 index b867ad114b..0000000000 Binary files a/static/pdf/Temporal-Terms-of-Service-6-7-22.pdf and /dev/null differ diff --git a/static/pdf/Temporal-Terms-of-Service-9-1-22.pdf b/static/pdf/Temporal-Terms-of-Service-9-1-22.pdf deleted file mode 100644 index 56e464a8b5..0000000000 Binary files a/static/pdf/Temporal-Terms-of-Service-9-1-22.pdf and /dev/null differ diff --git a/static/pdf/Temporal-Terms-of-Service-9-9-22.pdf b/static/pdf/Temporal-Terms-of-Service-9-9-22.pdf deleted file mode 100644 index 488708f971..0000000000 Binary files a/static/pdf/Temporal-Terms-of-Service-9-9-22.pdf and /dev/null differ diff --git a/static/pdf/Temporal_Terms_of_Service_8-30-22.pdf b/static/pdf/Temporal_Terms_of_Service_8-30-22.pdf deleted file mode 100644 index 8e3bce6324..0000000000 Binary files a/static/pdf/Temporal_Terms_of_Service_8-30-22.pdf and /dev/null differ diff --git a/static/pdf/datadog-database-reliability.pdf b/static/pdf/datadog-database-reliability.pdf deleted file mode 100644 index ae8d8ee9f6..0000000000 Binary files a/static/pdf/datadog-database-reliability.pdf and /dev/null differ diff --git a/static/pdf/temporal-tos-2021-01-19.pdf b/static/pdf/temporal-tos-2021-01-19.pdf deleted file mode 100644 index 6260ff8956..0000000000 Binary files a/static/pdf/temporal-tos-2021-01-19.pdf and /dev/null differ diff --git a/static/pdf/temporal-tos-2021-07-24.pdf b/static/pdf/temporal-tos-2021-07-24.pdf deleted file mode 100644 index 71eb42ccd8..0000000000 Binary files a/static/pdf/temporal-tos-2021-07-24.pdf and /dev/null differ diff --git a/static/pdf/zebra-medical-case-study.pdf b/static/pdf/zebra-medical-case-study.pdf deleted file mode 100644 index 00ce88c125..0000000000 Binary files a/static/pdf/zebra-medical-case-study.pdf and /dev/null differ diff --git a/static/video/human-in-the-loop.mp4 b/static/video/human-in-the-loop.mp4 new file mode 100644 index 0000000000..12da19e75d Binary files /dev/null and b/static/video/human-in-the-loop.mp4 differ diff --git a/vale/styles/Temporal/Headings.yml b/vale/styles/Temporal/Headings.yml index 031b07d1a9..bda54d74a7 100644 --- a/vale/styles/Temporal/Headings.yml +++ b/vale/styles/Temporal/Headings.yml @@ -32,6 +32,7 @@ exceptions: - Activity - Activities - Archival + - Cloud - Codec - Converter - Cron @@ -41,6 +42,7 @@ exceptions: - Execution - Executions - Failure + - Fairness - Filter - Filters - Heartbeat @@ -52,7 +54,10 @@ exceptions: - Namespace - Namespaces - Nexus + - Operation + - Operations - Payload + - Priority - Query - Queries - Queue @@ -64,6 +69,7 @@ exceptions: - Search - Signal - Signals + - Standalone - Task - Temporal - Timer diff --git a/vale/styles/Temporal/RelativeLinks.yml b/vale/styles/Temporal/RelativeLinks.yml new file mode 100644 index 0000000000..125fe9c1c3 --- /dev/null +++ b/vale/styles/Temporal/RelativeLinks.yml @@ -0,0 +1,22 @@ +# scope: raw is required to see link destinations at all (the default text +# scope strips markdown link URLs entirely), but as a side effect it also +# sees inside fenced code blocks/inline code, which the default scope would +# normally skip. That's an accepted tradeoff: a handful of legitimate +# absolute URLs shown in code samples (e.g. curl/CLI output) may get flagged +# too, alongside the real links this rule targets. +# +# Only match a docs.temporal.io URL when it's the destination of an actual +# link — i.e. immediately preceded by `](` (Markdown link syntax) or by a +# quote after `=` (an href/path/src/to prop on a JSX component). Bare URLs +# (plain text with no link syntax, e.g. generated CLI reference docs) rely on +# Docusaurus/remark autolinking, which only works for absolute URLs — a bare +# relative path just renders as unlinked text, so those must stay absolute +# and should NOT be flagged here. +extends: existence +message: "Use a relative path instead of the absolute docs.temporal.io URL '%s'." +level: warning +scope: raw +ignorecase: true +tokens: + - '(?<=\]\()https?://docs\.temporal\.io[^\s()"''<>`\[\]]*(?=\))' + - '(?<==["''])https?://docs\.temporal\.io[^\s()"''<>`]*(?=["''])' diff --git a/vale/test/headings-good.md b/vale/test/headings-good.md index bfe491b49a..59543cd477 100644 --- a/vale/test/headings-good.md +++ b/vale/test/headings-good.md @@ -41,3 +41,5 @@ ## Update a running Workflow ## Durable External Storage for large payloads + +## Highly available Worker patterns {/* #ha-worker-patterns */} diff --git a/vale/test/relative-links-bad.md b/vale/test/relative-links-bad.md new file mode 100644 index 0000000000..43b84b6741 --- /dev/null +++ b/vale/test/relative-links-bad.md @@ -0,0 +1,5 @@ +## Absolute links + +See the [Task Queue](https://docs.temporal.io/task-queue) documentation for more information. + + diff --git a/vale/test/relative-links-good.md b/vale/test/relative-links-good.md new file mode 100644 index 0000000000..6ed413baec --- /dev/null +++ b/vale/test/relative-links-good.md @@ -0,0 +1,9 @@ +## Relative links + +See the [Task Queue](/task-queue) documentation for more information. + +Read about [Workflow Executions](/workflow-execution) and [Signals](/encyclopedia/workflow-message-passing#signals). + +External sites keep their full URL, for example [the Go SDK on GitHub](https://github.com/temporalio/sdk-go) or the [API reference](https://api-docs.temporal.io/). + +Bare URLs (no link syntax) must stay absolute, since Docusaurus only autolinks absolute URLs: Visit https://docs.temporal.io/visibility to read more about Search Attributes. diff --git a/vale/test/terms-bad.md b/vale/test/terms-bad.md deleted file mode 100644 index 23450c994a..0000000000 --- a/vale/test/terms-bad.md +++ /dev/null @@ -1,29 +0,0 @@ -This page describes how to start a workflow execution. - -The activity definition specifies the function signature. - -Configure the retry policy for your activity. - -Use the temporal CLI to interact with namespaces. - -The event history records each state change. - -Set up a task queue for your worker. - -A signal lets you send data to a running workflow. - -Use a query to retrieve workflow state. - -The data converter handles serialization. - -grpc is the protocol used by temporal server. - -Configure search attributes for advanced visibility. - -A schedule triggers workflow executions on a recurring basis. - -Use heartbeating to report activity progress. - -The codec server provides remote payload encoding. - -A child workflow execution inherits the parent close policy. diff --git a/vale/test/terms-good.md b/vale/test/terms-good.md deleted file mode 100644 index c0d6e988d6..0000000000 --- a/vale/test/terms-good.md +++ /dev/null @@ -1,29 +0,0 @@ -This page describes how to start a Workflow Execution. - -The Activity Definition specifies the function signature. - -Configure the Retry Policy for your Activity. - -Use the Temporal CLI to interact with Namespaces. - -The Event History records each state change. - -Set up a Task Queue for your Worker. - -A Signal lets you send data to a running Workflow. - -Use a Query to retrieve Workflow state. - -The Data Converter handles serialization. - -gRPC is the protocol used by Temporal Server. - -Configure Search Attributes for Advanced Visibility. - -A Schedule triggers Workflow Executions on a recurring basis. - -Use Heartbeating to report Activity progress. - -The Codec Server provides remote Payload encoding. - -A Child Workflow Execution inherits the Parent Close Policy. diff --git a/vercel.json b/vercel.json index f5dfc51cf3..c0be775e8e 100644 --- a/vercel.json +++ b/vercel.json @@ -24,6 +24,21 @@ } ], "redirects": [ + { + "source": "/cloud/disaster-recovery", + "destination": "/cloud/high-availability", + "permanent": true + }, + { + "source": "/cloud/multi-region-failover", + "destination": "/cloud/high-availability/architecture-patterns", + "permanent": true + }, + { + "source": "/cloud/high-availability/deployment-patterns", + "destination": "/cloud/high-availability/architecture-patterns", + "permanent": true + }, { "source": "/encyclopedia/detecting-application-failures", "destination": "/encyclopedia/failures-and-error-handling", diff --git a/versions.json b/versions.json deleted file mode 100644 index 0637a088a0..0000000000 --- a/versions.json +++ /dev/null @@ -1 +0,0 @@ -[] \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 7a7f4cb513..af2964a7d5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2,15 +2,15 @@ # yarn lockfile v1 -"@algolia/abtesting@1.20.1": - version "1.20.1" - resolved "https://registry.yarnpkg.com/@algolia/abtesting/-/abtesting-1.20.1.tgz#f7d0aa5ff79e86516822a7dd1e03d41b7e135baa" - integrity sha512-ZXOLrNfmAAhBrIPp+9LH9CDRHUqIx2Uf17YRN6GJ2D0wVPHhCwvMgegCUKQz3W78xVdmzEzjawqf93pPBZVMOg== +"@algolia/abtesting@1.21.1": + version "1.21.1" + resolved "https://registry.yarnpkg.com/@algolia/abtesting/-/abtesting-1.21.1.tgz#bf5c7881302a5843b78c6f44431d27ba5eb8f179" + integrity sha512-Wia5/mNTfiU0PIUN25UMfAGGdASkkwuCS9nBAdmhqrNPY/ff7U/6MgBVdwFDPsa3sA1msutPtO50gvOzx6MOXA== dependencies: - "@algolia/client-common" "5.54.1" - "@algolia/requester-browser-xhr" "5.54.1" - "@algolia/requester-fetch" "5.54.1" - "@algolia/requester-node-http" "5.54.1" + "@algolia/client-common" "5.55.1" + "@algolia/requester-browser-xhr" "5.55.1" + "@algolia/requester-fetch" "5.55.1" + "@algolia/requester-node-http" "5.55.1" "@algolia/autocomplete-core@1.19.2": version "1.19.2" @@ -52,126 +52,126 @@ resolved "https://registry.yarnpkg.com/@algolia/autocomplete-shared/-/autocomplete-shared-1.19.8.tgz#5d723d8bdb448efbb1b0e1c7ff94cc18e5b1dc0e" integrity sha512-h5hf2t8ejF6vlOgvLaZzQbWs5SyH2z4PAWygNAvvD/2RI29hdQ54ldUGwqVuj9Srs+n8XUKTPUqb7fvhBhQrnQ== -"@algolia/client-abtesting@5.54.1": - version "5.54.1" - resolved "https://registry.yarnpkg.com/@algolia/client-abtesting/-/client-abtesting-5.54.1.tgz#18ddcddc6f66e4e796c73185fbb69c943f62cb3b" - integrity sha512-xE6nz1DnpBlkp8Uq+PZdnuU29yhlfrgOIdb2M4+AxDOyDKpK88THFj80x9ZlPLLrFD1iQAt2HAwB5ZeTd4Ea+w== - dependencies: - "@algolia/client-common" "5.54.1" - "@algolia/requester-browser-xhr" "5.54.1" - "@algolia/requester-fetch" "5.54.1" - "@algolia/requester-node-http" "5.54.1" - -"@algolia/client-analytics@5.54.1": - version "5.54.1" - resolved "https://registry.yarnpkg.com/@algolia/client-analytics/-/client-analytics-5.54.1.tgz#89664d984ff23fb04b037860c852ff290480b97d" - integrity sha512-fcbniRV8wWJPX3IxGsbVs8JLO+Z5fXqbJOcWBd3duXYm8w0G/LiuFh9PX6ke0weNZNqLZMdQf+we/cR0ANHoOA== - dependencies: - "@algolia/client-common" "5.54.1" - "@algolia/requester-browser-xhr" "5.54.1" - "@algolia/requester-fetch" "5.54.1" - "@algolia/requester-node-http" "5.54.1" - -"@algolia/client-common@5.54.1": - version "5.54.1" - resolved "https://registry.yarnpkg.com/@algolia/client-common/-/client-common-5.54.1.tgz#1d09c57e700f41eeafb23973e61a828c6da459c2" - integrity sha512-lc7N5SAyNaP30ZzAHJxSSsrLU1G/xztGdOArtGfJBEJ3zgNVpw/epLb1f5oA460VCA8BWDzRjcvd0ljekctItQ== - -"@algolia/client-insights@5.54.1": - version "5.54.1" - resolved "https://registry.yarnpkg.com/@algolia/client-insights/-/client-insights-5.54.1.tgz#6e0499f4d80955458be9742f6b7b5f82d81c3402" - integrity sha512-GfA5h/GOoEnigQSGmxs8+OWG+NH2VCaxcYIMoswcgjA77W5gQVG1KOvsoEB6k5Z85J9+lgvuYkmEgieFJIjuhg== - dependencies: - "@algolia/client-common" "5.54.1" - "@algolia/requester-browser-xhr" "5.54.1" - "@algolia/requester-fetch" "5.54.1" - "@algolia/requester-node-http" "5.54.1" - -"@algolia/client-personalization@5.54.1": - version "5.54.1" - resolved "https://registry.yarnpkg.com/@algolia/client-personalization/-/client-personalization-5.54.1.tgz#15ff2574d605768124780c87eca79eca92fa1428" - integrity sha512-OYqOdhDivnWDAygdgobm+jvHvPQNYzdgQcfN3c11du/lRVVrzrpYQha85auVrvtQ6Q9Wgp3Q2o+Htti7eh0Tig== - dependencies: - "@algolia/client-common" "5.54.1" - "@algolia/requester-browser-xhr" "5.54.1" - "@algolia/requester-fetch" "5.54.1" - "@algolia/requester-node-http" "5.54.1" - -"@algolia/client-query-suggestions@5.54.1": - version "5.54.1" - resolved "https://registry.yarnpkg.com/@algolia/client-query-suggestions/-/client-query-suggestions-5.54.1.tgz#07161a4994434b4353acee9ffab7140b2b3eb892" - integrity sha512-d8wpaEfKE1c8/b71v+o5QoPcBZ+MzRLB19CC+j3d8uzCKMAi+8iGv9S89I+qelfHP5sfmkeXSyoAWUlEFzaObA== - dependencies: - "@algolia/client-common" "5.54.1" - "@algolia/requester-browser-xhr" "5.54.1" - "@algolia/requester-fetch" "5.54.1" - "@algolia/requester-node-http" "5.54.1" - -"@algolia/client-search@5.54.1": - version "5.54.1" - resolved "https://registry.yarnpkg.com/@algolia/client-search/-/client-search-5.54.1.tgz#c45005b703052a8ea629d0ef9815a976b54744ad" - integrity sha512-oJdxuIawQpCuZUdQVkJRcv/IRRrz1a6WQBaiXx2F/xkUlrHhpsTkiuVje5hKl5de7asR9I4YuJ/Rm0MmXJt5Fw== - dependencies: - "@algolia/client-common" "5.54.1" - "@algolia/requester-browser-xhr" "5.54.1" - "@algolia/requester-fetch" "5.54.1" - "@algolia/requester-node-http" "5.54.1" +"@algolia/client-abtesting@5.55.1": + version "5.55.1" + resolved "https://registry.yarnpkg.com/@algolia/client-abtesting/-/client-abtesting-5.55.1.tgz#192d0fd57ec9b7d6924a8b19cd98e72c7e8ae6de" + integrity sha512-miW8RzAtBgNiEJ9fGEhsOPgWUpekAe64YcVufqXrlykj0Jjmo5nj0a5f/HAzRVX5ZuU1GAVd7BkzFDx7q50P3A== + dependencies: + "@algolia/client-common" "5.55.1" + "@algolia/requester-browser-xhr" "5.55.1" + "@algolia/requester-fetch" "5.55.1" + "@algolia/requester-node-http" "5.55.1" + +"@algolia/client-analytics@5.55.1": + version "5.55.1" + resolved "https://registry.yarnpkg.com/@algolia/client-analytics/-/client-analytics-5.55.1.tgz#2e6e14d03f1cb181c2086fd60324c24f5f3007f9" + integrity sha512-eR3J3kB9JX6DdCvDRi3I4KPfwO6fR9HWYRXhVke2TXIoOQafMKCRAneg33JRmIrb+DnnJ/eWApJLF1O1CLPERg== + dependencies: + "@algolia/client-common" "5.55.1" + "@algolia/requester-browser-xhr" "5.55.1" + "@algolia/requester-fetch" "5.55.1" + "@algolia/requester-node-http" "5.55.1" + +"@algolia/client-common@5.55.1": + version "5.55.1" + resolved "https://registry.yarnpkg.com/@algolia/client-common/-/client-common-5.55.1.tgz#4e023b4127805f8d2a3229f1711822927f3f4303" + integrity sha512-P5ak7EurwYqgAiDyb95mgA3WRR/Zu8CPMv36lWTISvL2AmlPyqQPy2nX/KEJRTcwaeTWwrk6wJV4/M93GfjOWw== + +"@algolia/client-insights@5.55.1": + version "5.55.1" + resolved "https://registry.yarnpkg.com/@algolia/client-insights/-/client-insights-5.55.1.tgz#f58d7908071e9f5fb328533368e3bb2dad797fe5" + integrity sha512-OVtj9uA//+pjvKQI5INnzbyLrf3ClNv3XRbWswwJ2kHIStQNHtBfHo+LofNB/WhM9xjuXlW5ANn2aMj65UGx7w== + dependencies: + "@algolia/client-common" "5.55.1" + "@algolia/requester-browser-xhr" "5.55.1" + "@algolia/requester-fetch" "5.55.1" + "@algolia/requester-node-http" "5.55.1" + +"@algolia/client-personalization@5.55.1": + version "5.55.1" + resolved "https://registry.yarnpkg.com/@algolia/client-personalization/-/client-personalization-5.55.1.tgz#1b4d2bd3ba031864b1c8cd3e839ecb81146f8023" + integrity sha512-oKlVFlp+qbIEe4p7E54zSiP2gEV/vDu972Ykv8VDMFwEvreS7m0YKA3a8hGGHwc7yiBUGGiR3LlwzMLfnJmy6Q== + dependencies: + "@algolia/client-common" "5.55.1" + "@algolia/requester-browser-xhr" "5.55.1" + "@algolia/requester-fetch" "5.55.1" + "@algolia/requester-node-http" "5.55.1" + +"@algolia/client-query-suggestions@5.55.1": + version "5.55.1" + resolved "https://registry.yarnpkg.com/@algolia/client-query-suggestions/-/client-query-suggestions-5.55.1.tgz#7ddb439a51f348711d2b5b7bddef9497e906be86" + integrity sha512-BOVrld6vdtsFmotVDMTVQfYXwrVplJ+DUvy60JFi+tkWV698q2J9NNPKEO3dr5qxtSLKQP4vHF8n+3U5PDWhOQ== + dependencies: + "@algolia/client-common" "5.55.1" + "@algolia/requester-browser-xhr" "5.55.1" + "@algolia/requester-fetch" "5.55.1" + "@algolia/requester-node-http" "5.55.1" + +"@algolia/client-search@5.55.1": + version "5.55.1" + resolved "https://registry.yarnpkg.com/@algolia/client-search/-/client-search-5.55.1.tgz#8a918fb875b9f3c8e73b7331e508196a33538e71" + integrity sha512-GAqHl9zERhC3bbBfubwUu07G3UXO06gORvOcsiTBZB3et0s3auNUbHlYdYNp4VKa3sUZqH5AcD3OKzU/KDGXjQ== + dependencies: + "@algolia/client-common" "5.55.1" + "@algolia/requester-browser-xhr" "5.55.1" + "@algolia/requester-fetch" "5.55.1" + "@algolia/requester-node-http" "5.55.1" "@algolia/events@^4.0.1": version "4.0.1" resolved "https://registry.npmjs.org/@algolia/events/-/events-4.0.1.tgz" integrity sha512-FQzvOCgoFXAbf5Y6mYozw2aj5KCJoA3m4heImceldzPSMbdyS4atVjJzXKMsfX3wnZTFYwkkt8/z8UesLHlSBQ== -"@algolia/ingestion@1.54.1": - version "1.54.1" - resolved "https://registry.yarnpkg.com/@algolia/ingestion/-/ingestion-1.54.1.tgz#36e282338e28f96e840f4a023211fd36b174ec1b" - integrity sha512-yPyzbcTJ+yEr0LayhqZzmJnx6mVEu9HHIbC4t2RIfaC9FL5Zs9QoKmfcvdQumTXnW/MxPoiKASsCbgiRQGdHdQ== +"@algolia/ingestion@1.55.1": + version "1.55.1" + resolved "https://registry.yarnpkg.com/@algolia/ingestion/-/ingestion-1.55.1.tgz#036d40d394314d3f94dd1779d33378d8a65f48a5" + integrity sha512-BXZw+C+gsWL7pZvbnhJUnCXASiDLGcQxVV7h55Pyh2DmSzwdZIVccE5xc9RVD2trtrhIqk5smuODTxtaZqd0IA== dependencies: - "@algolia/client-common" "5.54.1" - "@algolia/requester-browser-xhr" "5.54.1" - "@algolia/requester-fetch" "5.54.1" - "@algolia/requester-node-http" "5.54.1" + "@algolia/client-common" "5.55.1" + "@algolia/requester-browser-xhr" "5.55.1" + "@algolia/requester-fetch" "5.55.1" + "@algolia/requester-node-http" "5.55.1" -"@algolia/monitoring@1.54.1": - version "1.54.1" - resolved "https://registry.yarnpkg.com/@algolia/monitoring/-/monitoring-1.54.1.tgz#a4843aa2f4aec79ea29dd4cabb029e762dee7831" - integrity sha512-lxTECUGxJMb5gYJyKghsKTPu+VkrGCrvRMq4jWVp/fCI/Egj3ppB9RJH69O2+CH0k3oHDYed6o39d7FGYhL5OQ== +"@algolia/monitoring@1.55.1": + version "1.55.1" + resolved "https://registry.yarnpkg.com/@algolia/monitoring/-/monitoring-1.55.1.tgz#307c71309cd52dbcc43d0fbd7f35a698354dd6d8" + integrity sha512-9g/ceZrZTqA62FA3588Xj0onRPjDNfu0pVQqefK0rrHp9H6Wblph/YmzGjZ2g8uqbTh0ZGIvAGCzErU8f7MHpA== dependencies: - "@algolia/client-common" "5.54.1" - "@algolia/requester-browser-xhr" "5.54.1" - "@algolia/requester-fetch" "5.54.1" - "@algolia/requester-node-http" "5.54.1" + "@algolia/client-common" "5.55.1" + "@algolia/requester-browser-xhr" "5.55.1" + "@algolia/requester-fetch" "5.55.1" + "@algolia/requester-node-http" "5.55.1" -"@algolia/recommend@5.54.1": - version "5.54.1" - resolved "https://registry.yarnpkg.com/@algolia/recommend/-/recommend-5.54.1.tgz#46b3f149acaefd6e9f21b7017d255e8afb7668b7" - integrity sha512-jmuKjXR+Ktb/hDnGrOwhmGn/1/PRuRdhBqwOV15q+wakSmmdQqavK/KLuLgMih4BtEuT7QsPg2Lbo8EMtOMoOg== +"@algolia/recommend@5.55.1": + version "5.55.1" + resolved "https://registry.yarnpkg.com/@algolia/recommend/-/recommend-5.55.1.tgz#72b5440d6221283acf5dde2828cf442769f5315a" + integrity sha512-cZTIrGyAP+W4A6jDVwvWM/JOaoJKQkD/2a5eLUEeNdKAD45jN7BCpsMDONyhZlosLa4UwL8uiINQzj4iFy9nqg== dependencies: - "@algolia/client-common" "5.54.1" - "@algolia/requester-browser-xhr" "5.54.1" - "@algolia/requester-fetch" "5.54.1" - "@algolia/requester-node-http" "5.54.1" + "@algolia/client-common" "5.55.1" + "@algolia/requester-browser-xhr" "5.55.1" + "@algolia/requester-fetch" "5.55.1" + "@algolia/requester-node-http" "5.55.1" -"@algolia/requester-browser-xhr@5.54.1": - version "5.54.1" - resolved "https://registry.yarnpkg.com/@algolia/requester-browser-xhr/-/requester-browser-xhr-5.54.1.tgz#06d47923d72641349bca2d2e07a4dfe39d1a40fc" - integrity sha512-PlbI8tNAG1XN5/dM7ciCe9pRuNhA/qnDg6U6r04kdEg8z6poY+jV2pdgCZrtCEJ8cOEcSLsRdCG8iK158iE7zw== +"@algolia/requester-browser-xhr@5.55.1": + version "5.55.1" + resolved "https://registry.yarnpkg.com/@algolia/requester-browser-xhr/-/requester-browser-xhr-5.55.1.tgz#6809cfab8aab74f6cb3d69cd4c1e2d2c2be362da" + integrity sha512-N6I3leW0UO8Y9Zv90yo2UHgYGuxZO0mjbvzNxDIJDjO0qECEF7Z9XMvSNeUWXQh/iNDA9lr8MfEy3rmZGIcclw== dependencies: - "@algolia/client-common" "5.54.1" + "@algolia/client-common" "5.55.1" -"@algolia/requester-fetch@5.54.1": - version "5.54.1" - resolved "https://registry.yarnpkg.com/@algolia/requester-fetch/-/requester-fetch-5.54.1.tgz#8a4c9676300a8bdc670eed359fd6eb1e7af1c318" - integrity sha512-TtYAKGxevDhM9gyXoNp+G0ysDsDV7qvkkQzWloG2GwqSJQy7r7+kE5xP7wV224XuJpsqoEM/gxk4yi2wHvTShQ== +"@algolia/requester-fetch@5.55.1": + version "5.55.1" + resolved "https://registry.yarnpkg.com/@algolia/requester-fetch/-/requester-fetch-5.55.1.tgz#f75cc8681524bd8eb60d5d976c31f568dbdbe0ea" + integrity sha512-ukU5zeeFs44rQkzv+TRdYard+d+3lmPGs8lPZhHtWE8rfz+LlBSF6s9kP3VQ7LeOYL8Dz0u6tZfnyTrqrumbHQ== dependencies: - "@algolia/client-common" "5.54.1" + "@algolia/client-common" "5.55.1" -"@algolia/requester-node-http@5.54.1": - version "5.54.1" - resolved "https://registry.yarnpkg.com/@algolia/requester-node-http/-/requester-node-http-5.54.1.tgz#dad69064d3400701c4758ec55eaf995cb631c33d" - integrity sha512-FwSyDcOZgzs62qBhO2BBmXarp163iKA6IjKcpmuTOZmEMtukj6sVNC9BF2A05hG8b1fTKv5c1VvIfjtUsUqSeA== +"@algolia/requester-node-http@5.55.1": + version "5.55.1" + resolved "https://registry.yarnpkg.com/@algolia/requester-node-http/-/requester-node-http-5.55.1.tgz#1920f83d70be909739dbc4d1421ca241bc0f1e0e" + integrity sha512-lCwXyijwPm3vbYHpBXPRomMcD6mgiptmps27gnMCf4HK+u/AOeFPBnIFh4V3l4A5SnP9VRiKBZqwGBpUH0vaTg== dependencies: - "@algolia/client-common" "5.54.1" + "@algolia/client-common" "5.55.1" "@antfu/install-pkg@^1.1.0": version "1.1.0" @@ -2180,60 +2180,80 @@ utility-types "^3.10.0" webpack "^5.88.1" -"@dprint/darwin-arm64@0.54.0": - version "0.54.0" - resolved "https://registry.yarnpkg.com/@dprint/darwin-arm64/-/darwin-arm64-0.54.0.tgz#9230db5eb0f79dca83e92e43dce86a623c9d7844" - integrity sha512-yqRI4enH+BDp+4+ZsPVdZM5h873JK1lN7li9l9A5u4C4cvh1oEsiBWAzEPccRkJ2ctF8LgaizBSxO38sqEVYbw== - -"@dprint/darwin-x64@0.54.0": - version "0.54.0" - resolved "https://registry.yarnpkg.com/@dprint/darwin-x64/-/darwin-x64-0.54.0.tgz#f69a648119db311d60ba6dbaa1bdf187906ea8de" - integrity sha512-W9BARpgHypcQwatg5mnHaCpX6pLX5dBxxiv+tZKruhOmq8MKYOrAYDXlceMuHSowmWREfUF5yL4SRgXDGI6WQw== - -"@dprint/linux-arm64-glibc@0.54.0": - version "0.54.0" - resolved "https://registry.yarnpkg.com/@dprint/linux-arm64-glibc/-/linux-arm64-glibc-0.54.0.tgz#2f87723e9fc878b1c8a05f294798f12692646a4f" - integrity sha512-VhM7p70VFuNqxZMdiv1e+nMboPj/hMFlTIBWrRaX7+6VThs9mJr9+94wrUeXgfnfsyaEKSbRFa/dru1PINoSNw== - -"@dprint/linux-arm64-musl@0.54.0": - version "0.54.0" - resolved "https://registry.yarnpkg.com/@dprint/linux-arm64-musl/-/linux-arm64-musl-0.54.0.tgz#8d921aedeb25d60b68d601f7eedeb244b3719d59" - integrity sha512-QS1A74Lv60/L9oemHCzbHgOLbV2smSJG5IxS5fjf8ZWetyUt918WDzIHBilz/+uiB+OlW2UVTsm952UG0YOrLw== - -"@dprint/linux-loong64-glibc@0.54.0": - version "0.54.0" - resolved "https://registry.yarnpkg.com/@dprint/linux-loong64-glibc/-/linux-loong64-glibc-0.54.0.tgz#caf1a1416eb48f4ead2e7505bf672307c19de7cc" - integrity sha512-8Myka2/0KbhuZnEKL6jagPXTgDKVpd/tfXDRa0oibUBgaqOSku6iRMzHGa/PhqHL+s14Gcp+/cIHz0zU3Tkgug== - -"@dprint/linux-loong64-musl@0.54.0": - version "0.54.0" - resolved "https://registry.yarnpkg.com/@dprint/linux-loong64-musl/-/linux-loong64-musl-0.54.0.tgz#a790407dfa8a47a591304bc6b1144cf28341abbc" - integrity sha512-/AN3xCuMhC4PK7Pbj7/4zBuhFGr4m0OHV/5uGTfzpkKX/3+AXoyKl7PbT2VlNMGXAK0kuRThfjtx23gIwlWk7Q== - -"@dprint/linux-riscv64-glibc@0.54.0": - version "0.54.0" - resolved "https://registry.yarnpkg.com/@dprint/linux-riscv64-glibc/-/linux-riscv64-glibc-0.54.0.tgz#6c77a8579d9cd3f6880645c27cd35fb7e0109594" - integrity sha512-Aw2vXzzwFDpPbXh6ajsSabVCkCc66C3hCyMKprR/IxYvFtjYX80nh1ox0c7iaw6c4HacHMRLGw7FUSXvomPaEQ== - -"@dprint/linux-x64-glibc@0.54.0": - version "0.54.0" - resolved "https://registry.yarnpkg.com/@dprint/linux-x64-glibc/-/linux-x64-glibc-0.54.0.tgz#ce4dbcd25aea883ad3dd85e0cc7c44faa9f4be50" - integrity sha512-zZqj3wQELOX8n6QfT2uuWoMf64Wv0lMXNyam3btm+PKkg0P6a54TPL09Bs9XsViOdxgTcamsiQ7HlErt/LEjIA== - -"@dprint/linux-x64-musl@0.54.0": - version "0.54.0" - resolved "https://registry.yarnpkg.com/@dprint/linux-x64-musl/-/linux-x64-musl-0.54.0.tgz#16a01f3c954bd6eb60305a59edaf8365bfb115fa" - integrity sha512-it6Qdt06dyW2adbAXpOCb7/KQLxlm4i1UphUAWqWsZk4t3EYetyAza9J0g3Vu9itIWSEIo9MnccgANckQJ6+qw== - -"@dprint/win32-arm64@0.54.0": - version "0.54.0" - resolved "https://registry.yarnpkg.com/@dprint/win32-arm64/-/win32-arm64-0.54.0.tgz#0a737b41e7d243bda9e037b9637c34dd6a96a534" - integrity sha512-F5kjV/6I9YtNOTDWHUpTqM2HHHS510BPL7z4NJuU0nDnaVeks7GwNEltGr56CcsG8XQYhkiAsqZytPu6AhA2hQ== - -"@dprint/win32-x64@0.54.0": - version "0.54.0" - resolved "https://registry.yarnpkg.com/@dprint/win32-x64/-/win32-x64-0.54.0.tgz#b01c7508e416283428554b682cc3ac09a6fe93e5" - integrity sha512-AAr2ye/DtgYXDplRoPS+5U++x7T6W4a3I9FvTFWFxziFmUptvAg5G2c4FcXoAduSruhYZJvjDZrLseR2c3IwXg== +"@dprint/android-arm64@0.55.1": + version "0.55.1" + resolved "https://registry.yarnpkg.com/@dprint/android-arm64/-/android-arm64-0.55.1.tgz#7fef45b22238cf107e7d29d662ec0964d668c1e1" + integrity sha512-H1UQIcBJEo1dA8AVrkPnw5AYMMGHNe4w0lq9rEDSY3Lds0VUKACu2MK3JLNARpjljh3WKeO109J26Wpq8PTuGQ== + +"@dprint/android-x64@0.55.1": + version "0.55.1" + resolved "https://registry.yarnpkg.com/@dprint/android-x64/-/android-x64-0.55.1.tgz#1ad30763ed50c193c6a752055551c48fbe7ef7b5" + integrity sha512-8dPiGpEo4S4cQhnLaoHtNm4HiqR9DIPr/81z3gWb5qTmJp8bKj+3DO4mAmmldMRBicmpXCss0xudniNTxnWOMg== + +"@dprint/darwin-arm64@0.55.1": + version "0.55.1" + resolved "https://registry.yarnpkg.com/@dprint/darwin-arm64/-/darwin-arm64-0.55.1.tgz#2e5a08dc0da96df34145bcb96761d448e662147f" + integrity sha512-c65u8f63R/etCKP0CZ/RkbMCRC99wgZDt/vhwjY2QdO0k23dMKXKEV6+ruUEwV2toh+QIRT02dRL7BtxjA0V7w== + +"@dprint/darwin-x64@0.55.1": + version "0.55.1" + resolved "https://registry.yarnpkg.com/@dprint/darwin-x64/-/darwin-x64-0.55.1.tgz#f9b46c6b21a59b0f48f5710b74885cff545e2671" + integrity sha512-AsET9+4rMk7ZFEMq3HYl/hYNBMBrR/juiSzclEU0oUCf3koxFbVKL8Srad8Vhyv8jEnOaMn78TCnahmHhp1SCQ== + +"@dprint/linux-arm64-glibc@0.55.1": + version "0.55.1" + resolved "https://registry.yarnpkg.com/@dprint/linux-arm64-glibc/-/linux-arm64-glibc-0.55.1.tgz#ca93d22932a14d77eba33052b5f0b68097d4e33b" + integrity sha512-vt7w+aL2MjtD3RRfnUTDuK/b7xg1/feBe3WoV0S2rGNmPEJI+K7wwBXkOl/I77V9w3PP2zvN/aRY5dMOZrkbSg== + +"@dprint/linux-arm64-musl@0.55.1": + version "0.55.1" + resolved "https://registry.yarnpkg.com/@dprint/linux-arm64-musl/-/linux-arm64-musl-0.55.1.tgz#69dcb5c17ba6c9028fc436cc29444e58c9133ee2" + integrity sha512-7shUIOvJcTn4IYEPlb/Up4KLt5kbDuCTdc3DN68i88XT+nRBFQatD7Qok+6Ysra34KJMxF88QVHiimjH1ABy4A== + +"@dprint/linux-loong64-glibc@0.55.1": + version "0.55.1" + resolved "https://registry.yarnpkg.com/@dprint/linux-loong64-glibc/-/linux-loong64-glibc-0.55.1.tgz#1145eeea70c609ea610ce1c5d4b5774a6df0fe46" + integrity sha512-G2mXiOGpkeC3LP8huSnImxU7hMvRY6rHIW+yBuw1vDDm5r6+H9CVhcXr3TMOk3poZ7IlWwQnDmP0/RuGQUEpYw== + +"@dprint/linux-loong64-musl@0.55.1": + version "0.55.1" + resolved "https://registry.yarnpkg.com/@dprint/linux-loong64-musl/-/linux-loong64-musl-0.55.1.tgz#08e0533d7b7c2ee02694329ebb16d3377c48d5ad" + integrity sha512-uO2r4QPYawDfQUyATr0opqfMkG2hVpz3yScRKs5ve9PWzqx2f5kJaaLuLVoXwSbWqDkozMGPcldAVSpOwGJ24Q== + +"@dprint/linux-ppc64-glibc@0.55.1": + version "0.55.1" + resolved "https://registry.yarnpkg.com/@dprint/linux-ppc64-glibc/-/linux-ppc64-glibc-0.55.1.tgz#3ca234ad910598ff4499f0d6335b42440f6f8ee5" + integrity sha512-NVR+BN4kTQd/vJ91YOGSkBotAM2f1hFD+HD5zq5Oc1djxjl1U2Zvc667UWKamxaXdv/ssZxkLuj0lbvbx9E3/A== + +"@dprint/linux-ppc64-musl@0.55.1": + version "0.55.1" + resolved "https://registry.yarnpkg.com/@dprint/linux-ppc64-musl/-/linux-ppc64-musl-0.55.1.tgz#c4984fe301464f1b1710492ac2c58d68020e3f22" + integrity sha512-34Db83XGoKbwmvIY52sokywIMCmmIZM1vzw4ILlOkKugG51TltBOzLCssT9Rkfxz/TjR9dgwtBHEQJZjftuQ6Q== + +"@dprint/linux-riscv64-glibc@0.55.1": + version "0.55.1" + resolved "https://registry.yarnpkg.com/@dprint/linux-riscv64-glibc/-/linux-riscv64-glibc-0.55.1.tgz#07cc4bc7200358d2d0b81c40a49369d9a9642e66" + integrity sha512-MVeYH48GhR3QkfhMhhYbXl9x+iO2sK1nNpr2ByJ4IKogHIlvlFff0eeukqAZZAod+rhAzJnLMBGgIDbIUHWK1g== + +"@dprint/linux-x64-glibc@0.55.1": + version "0.55.1" + resolved "https://registry.yarnpkg.com/@dprint/linux-x64-glibc/-/linux-x64-glibc-0.55.1.tgz#a01ec6ea6de9842ec12c911298343c67c1435342" + integrity sha512-ILHgXOIIXeZDjt51egaC4kmu744UuNfaauUM1El+dG9QGan4Vv4f0RRDDbQMKN95zTpacECuNKEhuUtS8euNWA== + +"@dprint/linux-x64-musl@0.55.1": + version "0.55.1" + resolved "https://registry.yarnpkg.com/@dprint/linux-x64-musl/-/linux-x64-musl-0.55.1.tgz#1f5a236f7cf68a28fb1b95eb17a1a9421aa5f17a" + integrity sha512-SwWxWd313VkeH63fR4IlQCDfJNJ+mSw50BidP0PQInCNbUHYOIeWTcyzAaVa8GQdLvmhiNcJ0pmTDOL0F98QIQ== + +"@dprint/win32-arm64@0.55.1": + version "0.55.1" + resolved "https://registry.yarnpkg.com/@dprint/win32-arm64/-/win32-arm64-0.55.1.tgz#76233c02e46f47aae4af281125bf258173da91c5" + integrity sha512-9jiZWZ2AkaaXIkaOpOoiP6v0XxuBFp1Hyi3kUMUAY4NVCmvlSEDQhr+UZgnDhozYygDbAV13iNMLERDeoUa0nA== + +"@dprint/win32-x64@0.55.1": + version "0.55.1" + resolved "https://registry.yarnpkg.com/@dprint/win32-x64/-/win32-x64-0.55.1.tgz#aec2b04b9290230bf76ad90bc111ad1e4249bc22" + integrity sha512-+RmeJL8zzlEWTcrvyFN51rz0S98tV43Socs7xeVGE2S/EH5dG2fG2/fBe6RB3kuyZSfGqByApYeicIdshYIAgw== "@emnapi/core@^1.5.0": version "1.9.1" @@ -2243,6 +2263,13 @@ "@emnapi/wasi-threads" "1.2.0" tslib "^2.4.0" +"@emnapi/runtime@^1.11.1": + version "1.11.2" + resolved "https://registry.yarnpkg.com/@emnapi/runtime/-/runtime-1.11.2.tgz#eb22f04d76febfdf4f87fdaff54c8a53f6bf0dbd" + integrity sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA== + dependencies: + tslib "^2.4.0" + "@emnapi/runtime@^1.5.0": version "1.9.1" resolved "https://registry.yarnpkg.com/@emnapi/runtime/-/runtime-1.9.1.tgz#115ff2a0d589865be6bd8e9d701e499c473f2a8d" @@ -2405,6 +2432,167 @@ "@iconify/types" "^2.0.0" import-meta-resolve "^4.2.0" +"@img/colour@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@img/colour/-/colour-1.1.0.tgz#b0c2c2fa661adf75effd6b4964497cd80010bb9d" + integrity sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ== + +"@img/sharp-darwin-arm64@0.35.3": + version "0.35.3" + resolved "https://registry.yarnpkg.com/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz#8b2740884bc58b127fc3020479a01294fa1095cb" + integrity sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg== + optionalDependencies: + "@img/sharp-libvips-darwin-arm64" "1.3.2" + +"@img/sharp-darwin-x64@0.35.3": + version "0.35.3" + resolved "https://registry.yarnpkg.com/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz#92df91320faf57cc54b331185770d5a93d510049" + integrity sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w== + optionalDependencies: + "@img/sharp-libvips-darwin-x64" "1.3.2" + +"@img/sharp-freebsd-wasm32@0.35.3": + version "0.35.3" + resolved "https://registry.yarnpkg.com/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz#48018c1379a8f507d681d6cd8dbe43d9795dc809" + integrity sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg== + dependencies: + "@img/sharp-wasm32" "0.35.3" + +"@img/sharp-libvips-darwin-arm64@1.3.2": + version "1.3.2" + resolved "https://registry.yarnpkg.com/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz#227b41ffc6c99612bceaba56f994a1933d779573" + integrity sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg== + +"@img/sharp-libvips-darwin-x64@1.3.2": + version "1.3.2" + resolved "https://registry.yarnpkg.com/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz#694903ec410c00945ce5b0c26dac83d7184c8b86" + integrity sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw== + +"@img/sharp-libvips-linux-arm64@1.3.2": + version "1.3.2" + resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz#49ddf156728666a21deed0716f3bb72bb351179e" + integrity sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA== + +"@img/sharp-libvips-linux-arm@1.3.2": + version "1.3.2" + resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz#e60e088c806bde1ac28be648b60c3465f41c18a7" + integrity sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ== + +"@img/sharp-libvips-linux-ppc64@1.3.2": + version "1.3.2" + resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz#be3161aafd659417b3e26374dfbbcd2da2bfb62c" + integrity sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw== + +"@img/sharp-libvips-linux-riscv64@1.3.2": + version "1.3.2" + resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz#bf008a6779d9be2b56a4df67b753941f767c957d" + integrity sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w== + +"@img/sharp-libvips-linux-s390x@1.3.2": + version "1.3.2" + resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz#9583814b8db58889c85bad9e5e0b7825257ff394" + integrity sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ== + +"@img/sharp-libvips-linux-x64@1.3.2": + version "1.3.2" + resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz#abc9a3114495b705ba20b7c1568b9eecd62aebbb" + integrity sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w== + +"@img/sharp-libvips-linuxmusl-arm64@1.3.2": + version "1.3.2" + resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz#e58fb14a7879f15d9e70a982870f384f39a832a2" + integrity sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw== + +"@img/sharp-libvips-linuxmusl-x64@1.3.2": + version "1.3.2" + resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz#5611480fcb7465dd5316044db53ad7a843ed4af8" + integrity sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ== + +"@img/sharp-linux-arm64@0.35.3": + version "0.35.3" + resolved "https://registry.yarnpkg.com/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz#44b65823ecc977d4585b308070fff3947256de04" + integrity sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ== + optionalDependencies: + "@img/sharp-libvips-linux-arm64" "1.3.2" + +"@img/sharp-linux-arm@0.35.3": + version "0.35.3" + resolved "https://registry.yarnpkg.com/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz#c8da500c4016893a89d46e9832d08a06eef77f27" + integrity sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA== + optionalDependencies: + "@img/sharp-libvips-linux-arm" "1.3.2" + +"@img/sharp-linux-ppc64@0.35.3": + version "0.35.3" + resolved "https://registry.yarnpkg.com/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz#ea1d7db818f52af1e1e057e82d55f23b2de3864c" + integrity sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA== + optionalDependencies: + "@img/sharp-libvips-linux-ppc64" "1.3.2" + +"@img/sharp-linux-riscv64@0.35.3": + version "0.35.3" + resolved "https://registry.yarnpkg.com/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz#722bd7994658791b02d1037ea09ca5cb78030d8d" + integrity sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ== + optionalDependencies: + "@img/sharp-libvips-linux-riscv64" "1.3.2" + +"@img/sharp-linux-s390x@0.35.3": + version "0.35.3" + resolved "https://registry.yarnpkg.com/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz#e8294817d7da495541a4a870f56e6b5d0f8643cd" + integrity sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw== + optionalDependencies: + "@img/sharp-libvips-linux-s390x" "1.3.2" + +"@img/sharp-linux-x64@0.35.3": + version "0.35.3" + resolved "https://registry.yarnpkg.com/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz#9843c32f39aeac4b60dd60f9e3416520a4e4de46" + integrity sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA== + optionalDependencies: + "@img/sharp-libvips-linux-x64" "1.3.2" + +"@img/sharp-linuxmusl-arm64@0.35.3": + version "0.35.3" + resolved "https://registry.yarnpkg.com/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz#9cfee4ecc3d41ab9a274e019dfe7b4062840510c" + integrity sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w== + optionalDependencies: + "@img/sharp-libvips-linuxmusl-arm64" "1.3.2" + +"@img/sharp-linuxmusl-x64@0.35.3": + version "0.35.3" + resolved "https://registry.yarnpkg.com/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz#204d0678c8c3b15300d9a26ecb9c0dcda11ca5de" + integrity sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg== + optionalDependencies: + "@img/sharp-libvips-linuxmusl-x64" "1.3.2" + +"@img/sharp-wasm32@0.35.3": + version "0.35.3" + resolved "https://registry.yarnpkg.com/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz#42f8979bdbe1a541a2e9b99d3b5be07e6b71c7c0" + integrity sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w== + dependencies: + "@emnapi/runtime" "^1.11.1" + +"@img/sharp-webcontainers-wasm32@0.35.3": + version "0.35.3" + resolved "https://registry.yarnpkg.com/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz#823aaa93d14db84999d5b5dc967ff56cf1966d9f" + integrity sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q== + dependencies: + "@img/sharp-wasm32" "0.35.3" + +"@img/sharp-win32-arm64@0.35.3": + version "0.35.3" + resolved "https://registry.yarnpkg.com/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz#f3554d45cbe24532a0adea736ec57658f74a2911" + integrity sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w== + +"@img/sharp-win32-ia32@0.35.3": + version "0.35.3" + resolved "https://registry.yarnpkg.com/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz#0942b2643bcb57e48419fe4119de84078ae0ac74" + integrity sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw== + +"@img/sharp-win32-x64@0.35.3": + version "0.35.3" + resolved "https://registry.yarnpkg.com/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz#8a25cacdc75a8c26077c07fba51e8056aae474f1" + integrity sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA== + "@jest/schemas@^29.6.3": version "29.6.3" resolved "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz" @@ -3036,11 +3224,11 @@ tsyringe "^4.10.0" "@playwright/test@^1.61.0": - version "1.61.0" - resolved "https://registry.yarnpkg.com/@playwright/test/-/test-1.61.0.tgz#dcc3d43e581aab2985d70413309d89350e980ad8" - integrity sha512-cKA5B6lpFEMyMGjxF54QihfYpB4FkEGH+qZhtArDEG+wezQAJY8Pq6C7T1SjWz+FFzt3TbyoXBQYk/0292TdJA== + version "1.61.1" + resolved "https://registry.yarnpkg.com/@playwright/test/-/test-1.61.1.tgz#48568dc22af7819e55fa5e8e3bc79b7e6a3e6675" + integrity sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig== dependencies: - playwright "1.61.0" + playwright "1.61.1" "@pnpm/config.env-replace@^1.1.0": version "1.1.0" @@ -3068,6 +3256,84 @@ resolved "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz" integrity sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww== +"@resvg/resvg-js-android-arm-eabi@2.6.2": + version "2.6.2" + resolved "https://registry.yarnpkg.com/@resvg/resvg-js-android-arm-eabi/-/resvg-js-android-arm-eabi-2.6.2.tgz#e761e0b688127db64879f455178c92468a9aeabe" + integrity sha512-FrJibrAk6v29eabIPgcTUMPXiEz8ssrAk7TXxsiZzww9UTQ1Z5KAbFJs+Z0Ez+VZTYgnE5IQJqBcoSiMebtPHA== + +"@resvg/resvg-js-android-arm64@2.6.2": + version "2.6.2" + resolved "https://registry.yarnpkg.com/@resvg/resvg-js-android-arm64/-/resvg-js-android-arm64-2.6.2.tgz#b8cb564d7f6b3f37d9b43129f5dc5fe171e249e4" + integrity sha512-VcOKezEhm2VqzXpcIJoITuvUS/fcjIw5NA/w3tjzWyzmvoCdd+QXIqy3FBGulWdClvp4g+IfUemigrkLThSjAQ== + +"@resvg/resvg-js-darwin-arm64@2.6.2": + version "2.6.2" + resolved "https://registry.yarnpkg.com/@resvg/resvg-js-darwin-arm64/-/resvg-js-darwin-arm64-2.6.2.tgz#49bd3faeda5c49f53302d970e6e79d006de18e7d" + integrity sha512-nmok2LnAd6nLUKI16aEB9ydMC6Lidiiq2m1nEBDR1LaaP7FGs4AJ90qDraxX+CWlVuRlvNjyYJTNv8qFjtL9+A== + +"@resvg/resvg-js-darwin-x64@2.6.2": + version "2.6.2" + resolved "https://registry.yarnpkg.com/@resvg/resvg-js-darwin-x64/-/resvg-js-darwin-x64-2.6.2.tgz#e1344173aa27bfb4d880ab576d1acf1c1648faca" + integrity sha512-GInyZLjgWDfsVT6+SHxQVRwNzV0AuA1uqGsOAW+0th56J7Nh6bHHKXHBWzUrihxMetcFDmQMAX1tZ1fZDYSRsw== + +"@resvg/resvg-js-linux-arm-gnueabihf@2.6.2": + version "2.6.2" + resolved "https://registry.yarnpkg.com/@resvg/resvg-js-linux-arm-gnueabihf/-/resvg-js-linux-arm-gnueabihf-2.6.2.tgz#34c445eba45efd68f6130b2ab426d76a7424253d" + integrity sha512-YIV3u/R9zJbpqTTNwTZM5/ocWetDKGsro0SWp70eGEM9eV2MerWyBRZnQIgzU3YBnSBQ1RcxRZvY/UxwESfZIw== + +"@resvg/resvg-js-linux-arm64-gnu@2.6.2": + version "2.6.2" + resolved "https://registry.yarnpkg.com/@resvg/resvg-js-linux-arm64-gnu/-/resvg-js-linux-arm64-gnu-2.6.2.tgz#30da47087dd8153182198b94fe9f8d994890dae5" + integrity sha512-zc2BlJSim7YR4FZDQ8OUoJg5holYzdiYMeobb9pJuGDidGL9KZUv7SbiD4E8oZogtYY42UZEap7dqkkYuA91pg== + +"@resvg/resvg-js-linux-arm64-musl@2.6.2": + version "2.6.2" + resolved "https://registry.yarnpkg.com/@resvg/resvg-js-linux-arm64-musl/-/resvg-js-linux-arm64-musl-2.6.2.tgz#5d75b8ff5c83103729c1ca3779987302753c50d4" + integrity sha512-3h3dLPWNgSsD4lQBJPb4f+kvdOSJHa5PjTYVsWHxLUzH4IFTJUAnmuWpw4KqyQ3NA5QCyhw4TWgxk3jRkQxEKg== + +"@resvg/resvg-js-linux-x64-gnu@2.6.2": + version "2.6.2" + resolved "https://registry.yarnpkg.com/@resvg/resvg-js-linux-x64-gnu/-/resvg-js-linux-x64-gnu-2.6.2.tgz#411abedfaee5edc57cbb7701736cecba522e26f3" + integrity sha512-IVUe+ckIerA7xMZ50duAZzwf1U7khQe2E0QpUxu5MBJNao5RqC0zwV/Zm965vw6D3gGFUl7j4m+oJjubBVoftw== + +"@resvg/resvg-js-linux-x64-musl@2.6.2": + version "2.6.2" + resolved "https://registry.yarnpkg.com/@resvg/resvg-js-linux-x64-musl/-/resvg-js-linux-x64-musl-2.6.2.tgz#fe4984038f0372f279e3ff570b72934dd7eb2a5c" + integrity sha512-UOf83vqTzoYQO9SZ0fPl2ZIFtNIz/Rr/y+7X8XRX1ZnBYsQ/tTb+cj9TE+KHOdmlTFBxhYzVkP2lRByCzqi4jQ== + +"@resvg/resvg-js-win32-arm64-msvc@2.6.2": + version "2.6.2" + resolved "https://registry.yarnpkg.com/@resvg/resvg-js-win32-arm64-msvc/-/resvg-js-win32-arm64-msvc-2.6.2.tgz#d3a053cf7ff687087a2106330c0fdaae706254d1" + integrity sha512-7C/RSgCa+7vqZ7qAbItfiaAWhyRSoD4l4BQAbVDqRRsRgY+S+hgS3in0Rxr7IorKUpGE69X48q6/nOAuTJQxeQ== + +"@resvg/resvg-js-win32-ia32-msvc@2.6.2": + version "2.6.2" + resolved "https://registry.yarnpkg.com/@resvg/resvg-js-win32-ia32-msvc/-/resvg-js-win32-ia32-msvc-2.6.2.tgz#7cdda1ce29ef7209e28191d917fa5bef0624a4ad" + integrity sha512-har4aPAlvjnLcil40AC77YDIk6loMawuJwFINEM7n0pZviwMkMvjb2W5ZirsNOZY4aDbo5tLx0wNMREp5Brk+w== + +"@resvg/resvg-js-win32-x64-msvc@2.6.2": + version "2.6.2" + resolved "https://registry.yarnpkg.com/@resvg/resvg-js-win32-x64-msvc/-/resvg-js-win32-x64-msvc-2.6.2.tgz#cb0ad04525d65f3def4c8d346157a57976d5b388" + integrity sha512-ZXtYhtUr5SSaBrUDq7DiyjOFJqBVL/dOBN7N/qmi/pO0IgiWW/f/ue3nbvu9joWE5aAKDoIzy/CxsY0suwGosQ== + +"@resvg/resvg-js@^2.6.2": + version "2.6.2" + resolved "https://registry.yarnpkg.com/@resvg/resvg-js/-/resvg-js-2.6.2.tgz#3e92a907d88d879256c585347c5b21a7f3bb5b46" + integrity sha512-xBaJish5OeGmniDj9cW5PRa/PtmuVU3ziqrbr5xJj901ZDN4TosrVaNZpEiLZAxdfnhAe7uQ7QFWfjPe9d9K2Q== + optionalDependencies: + "@resvg/resvg-js-android-arm-eabi" "2.6.2" + "@resvg/resvg-js-android-arm64" "2.6.2" + "@resvg/resvg-js-darwin-arm64" "2.6.2" + "@resvg/resvg-js-darwin-x64" "2.6.2" + "@resvg/resvg-js-linux-arm-gnueabihf" "2.6.2" + "@resvg/resvg-js-linux-arm64-gnu" "2.6.2" + "@resvg/resvg-js-linux-arm64-musl" "2.6.2" + "@resvg/resvg-js-linux-x64-gnu" "2.6.2" + "@resvg/resvg-js-linux-x64-musl" "2.6.2" + "@resvg/resvg-js-win32-arm64-msvc" "2.6.2" + "@resvg/resvg-js-win32-ia32-msvc" "2.6.2" + "@resvg/resvg-js-win32-x64-msvc" "2.6.2" + "@rspack/binding-darwin-arm64@1.7.11": version "1.7.11" resolved "https://registry.yarnpkg.com/@rspack/binding-darwin-arm64/-/binding-darwin-arm64-1.7.11.tgz#ea43ac25a9ff99a9faf6c820f5d174a32974e95c" @@ -3150,6 +3416,14 @@ resolved "https://registry.yarnpkg.com/@rspack/lite-tapable/-/lite-tapable-1.1.0.tgz#3cfdafeed01078e116bd4f191b684c8b484de425" integrity sha512-E2B0JhYFmVAwdDiG14+DW0Di4Ze4Jg10Pc4/lILUrd5DRCaklduz2OvJ5HYQ6G+hd+WTzqQb3QnDNfK4yvAFYw== +"@shuding/opentype.js@1.4.0-beta.0": + version "1.4.0-beta.0" + resolved "https://registry.yarnpkg.com/@shuding/opentype.js/-/opentype.js-1.4.0-beta.0.tgz#5d1e7e9e056f546aad41df1c5043f8f85d39e24b" + integrity sha512-3NgmNyH3l/Hv6EvsWJbsvpcpUba6R8IREQ83nH83cyakCw7uM1arZKNfHwv1Wz6jgqrF/j4x5ELvR6PnK9nTcA== + dependencies: + fflate "^0.7.3" + string.prototype.codepointat "^0.2.1" + "@sideway/address@^4.1.5": version "4.1.5" resolved "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz" @@ -3970,12 +4244,12 @@ resolved "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz" integrity sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA== -"@types/node@*", "@types/node@^25.9.3": - version "25.9.3" - resolved "https://registry.yarnpkg.com/@types/node/-/node-25.9.3.tgz#11dfe7a33e68fa5c560f0aa76cc5595621ef26b9" - integrity sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg== +"@types/node@*", "@types/node@^26.0.0": + version "26.0.0" + resolved "https://registry.yarnpkg.com/@types/node/-/node-26.0.0.tgz#d4aece9e9412e9f2008d59bc2d74f5279316b665" + integrity sha512-vf2YFi1iY9lHGwNJMs01biZFbKJkrZR1T6/MlzjhJLPdntOHLhTrDSnSVcdtvjihi4VQNlrFRIxLsDBlQpAipA== dependencies: - undici-types ">=7.24.0 <7.24.7" + undici-types "~8.3.0" "@types/node@^17.0.5": version "17.0.45" @@ -4160,27 +4434,27 @@ xdg-app-paths "5" zod "4.1.11" -"@vercel/cli-exec@0.1.1": - version "0.1.1" - resolved "https://registry.yarnpkg.com/@vercel/cli-exec/-/cli-exec-0.1.1.tgz#c00d76bdd8222435f943883eca5c2ad667a86ead" - integrity sha512-LMRMEai3Z+BODyxGcU9+KiWrS/UElNiOLKiNRfGNt2Vu3NTEmXgFeXG9wBfocAnTe5yJCX/DY6k3k7S/LkPp/g== +"@vercel/cli-exec@1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@vercel/cli-exec/-/cli-exec-1.0.0.tgz#a4d38ea2fae6d850c6b4af3a3e1ad2bda7d52bf9" + integrity sha512-kQF8LGie/Hbdq9/psJxLE7owRTcqMQMhgybU04gCeR7cbQAr5t8OrjefDNColJv1QSSucFt4pLwRiARVmlOnug== dependencies: execa "5.1.1" -"@vercel/functions@^3.7.1": - version "3.7.1" - resolved "https://registry.yarnpkg.com/@vercel/functions/-/functions-3.7.1.tgz#2722df8eecd890cac23360e9875f24dd391673cd" - integrity sha512-7PsCL2Vz4MKz4t+Nxu8u4mu/t66y1xv9I0njb3EYoFoFCsXchOChT7YFgXZYFQiUXFEifBpnoLma4e+ep7cKKA== +"@vercel/functions@^3.7.5": + version "3.7.5" + resolved "https://registry.yarnpkg.com/@vercel/functions/-/functions-3.7.5.tgz#5a505d2fe48b0c8eb8226c1a2e97007bde4ae467" + integrity sha512-ESf8BbeDebqRUyMi09JwRbQqpLn4g6fjcVVHPsHB56j2dSqRrSHO4h3X4aaxJf6iQQjzhAtDGI2xCWQ27JE8PA== dependencies: - "@vercel/oidc" "3.6.1" + "@vercel/oidc" "3.8.0" -"@vercel/oidc@3.6.1": - version "3.6.1" - resolved "https://registry.yarnpkg.com/@vercel/oidc/-/oidc-3.6.1.tgz#b3562c3430b60c49993df760aa9ad7a64a4f3c6b" - integrity sha512-8ipTFoiX3WBRrvXLjSrmgAiwtMDQk3EgSxe8N7v2rXBz39NBIIyoGXeVbJRoBcP8WEuVnvjvIQsggbGU7ZKrMw== +"@vercel/oidc@3.8.0": + version "3.8.0" + resolved "https://registry.yarnpkg.com/@vercel/oidc/-/oidc-3.8.0.tgz#0b9fca3ab12ef4df90fd724d1c247d3ed4c78155" + integrity sha512-r00laGW6Pv778RoR6M2NxX91ycSj+PBwVo+fOb9Bif+F0IyUKt25zrvBzfEzQpeAzbqOgPZyQibEWDdDFApd+A== dependencies: "@vercel/cli-config" "0.2.0" - "@vercel/cli-exec" "0.1.1" + "@vercel/cli-exec" "1.0.0" jose "^5.9.6" "@vimeo/player@2.29.0": @@ -4453,25 +4727,25 @@ algoliasearch-helper@^3.26.0: dependencies: "@algolia/events" "^4.0.1" -algoliasearch@^5.37.0, algoliasearch@^5.54.1: - version "5.54.1" - resolved "https://registry.yarnpkg.com/algoliasearch/-/algoliasearch-5.54.1.tgz#ebc0c6ccbf294b8cc7322584aabc45e3b89166b0" - integrity sha512-v1AwSE7VrqyTn5C6v84fpwp05LqQol8gCJ6ki4bzfpnRqpEAPp/jPqcey1Dzr1fg7Ggsu0O81+3yMgQR9zdH5g== - dependencies: - "@algolia/abtesting" "1.20.1" - "@algolia/client-abtesting" "5.54.1" - "@algolia/client-analytics" "5.54.1" - "@algolia/client-common" "5.54.1" - "@algolia/client-insights" "5.54.1" - "@algolia/client-personalization" "5.54.1" - "@algolia/client-query-suggestions" "5.54.1" - "@algolia/client-search" "5.54.1" - "@algolia/ingestion" "1.54.1" - "@algolia/monitoring" "1.54.1" - "@algolia/recommend" "5.54.1" - "@algolia/requester-browser-xhr" "5.54.1" - "@algolia/requester-fetch" "5.54.1" - "@algolia/requester-node-http" "5.54.1" +algoliasearch@^5.37.0, algoliasearch@^5.55.1: + version "5.55.1" + resolved "https://registry.yarnpkg.com/algoliasearch/-/algoliasearch-5.55.1.tgz#d9c8a4636c79946881802a0d7426fc33333873e1" + integrity sha512-FyaFnnsbVPtevQwqSj/SdxE3jAsSsY0BEH8IVLf9rXxEBdAhAmT6VKCVSMWoaPIHVN1Eufh/1w8q6k8URpIkWw== + dependencies: + "@algolia/abtesting" "1.21.1" + "@algolia/client-abtesting" "5.55.1" + "@algolia/client-analytics" "5.55.1" + "@algolia/client-common" "5.55.1" + "@algolia/client-insights" "5.55.1" + "@algolia/client-personalization" "5.55.1" + "@algolia/client-query-suggestions" "5.55.1" + "@algolia/client-search" "5.55.1" + "@algolia/ingestion" "1.55.1" + "@algolia/monitoring" "1.55.1" + "@algolia/recommend" "5.55.1" + "@algolia/requester-browser-xhr" "5.55.1" + "@algolia/requester-fetch" "5.55.1" + "@algolia/requester-node-http" "5.55.1" ansi-align@^3.0.1: version "3.0.1" @@ -4926,6 +5200,11 @@ bare-url@^2.2.2: dependencies: bare-path "^3.0.0" +base64-js@0.0.8: + version "0.0.8" + resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-0.0.8.tgz#1101e9544f4a76b1bc3b26d452ca96d7a35e7978" + integrity sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw== + baseline-browser-mapping@^2.9.0: version "2.10.11" resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.11.tgz#957bea71ccc2e9854287c2575a037d36b3a94b73" @@ -5177,6 +5456,11 @@ camelcase@^7.0.1: resolved "https://registry.npmjs.org/camelcase/-/camelcase-7.0.1.tgz" integrity sha512-xlx1yCK2Oc1APsPXDL2LdlNP6+uu8OCDdhOBSVT279M/S+y75O30C2VuD8T2ogdePBBl7PfPF4504tnLgX3zfw== +camelize@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/camelize/-/camelize-1.0.1.tgz#89b7e16884056331a35d6b5ad064332c91daa6c3" + integrity sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ== + caniuse-api@^3.0.0: version "3.0.0" resolved "https://registry.npmjs.org/caniuse-api/-/caniuse-api-3.0.0.tgz" @@ -5424,7 +5708,7 @@ color-name@1.1.3: resolved "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz" integrity sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw== -color-name@~1.1.4: +color-name@^1.1.4, color-name@~1.1.4: version "1.1.4" resolved "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz" integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== @@ -5698,6 +5982,11 @@ crypto-random-string@^4.0.0: dependencies: type-fest "^1.0.1" +css-background-parser@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/css-background-parser/-/css-background-parser-0.1.0.tgz#48a17f7fe6d4d4f1bca3177ddf16c5617950741b" + integrity sha512-2EZLisiZQ+7m4wwur/qiYJRniHX4K5Tc9w93MT3AS0WS1u5kaZ4FKXlOTBhOjc+CgEgPiGY+fX1yWD8UwpEqUA== + css-blank-pseudo@^7.0.1: version "7.0.1" resolved "https://registry.npmjs.org/css-blank-pseudo/-/css-blank-pseudo-7.0.1.tgz" @@ -5705,6 +5994,16 @@ css-blank-pseudo@^7.0.1: dependencies: postcss-selector-parser "^7.0.0" +css-box-shadow@1.0.0-3: + version "1.0.0-3" + resolved "https://registry.yarnpkg.com/css-box-shadow/-/css-box-shadow-1.0.0-3.tgz#9eaeb7140947bf5d649fc49a19e4bbaa5f602713" + integrity sha512-9jaqR6e7Ohds+aWwmhe6wILJ99xYQbfmK9QQB9CcMjDbTxPZjwEmUQpU91OG05Xgm8BahT5fW+svbsQGjS/zPg== + +css-color-keywords@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/css-color-keywords/-/css-color-keywords-1.0.0.tgz#fea2616dc676b2962686b3af8dbdbe180b244e05" + integrity sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg== + css-declaration-sorter@^6.3.1: version "6.4.1" resolved "https://registry.npmjs.org/css-declaration-sorter/-/css-declaration-sorter-6.4.1.tgz" @@ -5715,6 +6014,11 @@ css-declaration-sorter@^7.2.0: resolved "https://registry.yarnpkg.com/css-declaration-sorter/-/css-declaration-sorter-7.3.1.tgz#acd204976d7ca5240b5579bfe6e73d4d088fd568" integrity sha512-gz6x+KkgNCjxq3Var03pRYLhyNfwhkKF1g/yoLgDNtFvVu0/fOLV9C8fFEZRjACp/XQLumjAYo7JVjzH3wLbxA== +css-gradient-parser@^0.0.17: + version "0.0.17" + resolved "https://registry.yarnpkg.com/css-gradient-parser/-/css-gradient-parser-0.0.17.tgz#9a523419f328ad2777ae790c296a6932d470aa9a" + integrity sha512-w2Xy9UMMwlKtou0vlRnXvWglPAceXCTtcmVSo8ZBUvqCV5aXEFP/PC6d+I464810I9FT++UACwTD5511bmGPUg== + css-has-pseudo@^7.0.3: version "7.0.3" resolved "https://registry.yarnpkg.com/css-has-pseudo/-/css-has-pseudo-7.0.3.tgz#a5ee2daf5f70a2032f3cefdf1e36e7f52a243873" @@ -5777,6 +6081,15 @@ css-select@^5.1.0: domutils "^3.0.1" nth-check "^2.0.1" +css-to-react-native@^3.0.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/css-to-react-native/-/css-to-react-native-3.2.0.tgz#cdd8099f71024e149e4f6fe17a7d46ecd55f1e32" + integrity sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ== + dependencies: + camelize "^1.0.0" + css-color-keywords "^1.0.0" + postcss-value-parser "^4.0.2" + css-tree@^1.1.2, css-tree@^1.1.3: version "1.1.3" resolved "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz" @@ -6512,7 +6825,7 @@ destroy@1.2.0, destroy@~1.2.0: resolved "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz" integrity sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg== -detect-libc@^2.0.3: +detect-libc@^2.0.3, detect-libc@^2.1.2: version "2.1.2" resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.1.2.tgz#689c5dcdc1900ef5583a4cb9f6d7b473742074ad" integrity sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ== @@ -6685,22 +6998,26 @@ dot-prop@^6.0.1: dependencies: is-obj "^2.0.0" -dprint@^0.54.0: - version "0.54.0" - resolved "https://registry.yarnpkg.com/dprint/-/dprint-0.54.0.tgz#37f0f92479c3ff8ac01c99bcc79ccfd17e416c07" - integrity sha512-sIy25poR2gRP/tWPTgP0MPeJoJcpv0xzYDcsboapvthbEt1Qw3Al252CA0xFyIh2cYEGGKyBJtKokryv4ERlJw== +dprint@^0.55.1: + version "0.55.1" + resolved "https://registry.yarnpkg.com/dprint/-/dprint-0.55.1.tgz#5533db033613593dcdc099aa9db3a4bb879b9033" + integrity sha512-tgUCT9gAM7veMvLX5NmRoYVLnKGKrIYRXpNpJDJj5U7/YIIJef5rLJhPZaJXwB7ad2Vo0Kv//nQM1xkrn5j8kQ== optionalDependencies: - "@dprint/darwin-arm64" "0.54.0" - "@dprint/darwin-x64" "0.54.0" - "@dprint/linux-arm64-glibc" "0.54.0" - "@dprint/linux-arm64-musl" "0.54.0" - "@dprint/linux-loong64-glibc" "0.54.0" - "@dprint/linux-loong64-musl" "0.54.0" - "@dprint/linux-riscv64-glibc" "0.54.0" - "@dprint/linux-x64-glibc" "0.54.0" - "@dprint/linux-x64-musl" "0.54.0" - "@dprint/win32-arm64" "0.54.0" - "@dprint/win32-x64" "0.54.0" + "@dprint/android-arm64" "0.55.1" + "@dprint/android-x64" "0.55.1" + "@dprint/darwin-arm64" "0.55.1" + "@dprint/darwin-x64" "0.55.1" + "@dprint/linux-arm64-glibc" "0.55.1" + "@dprint/linux-arm64-musl" "0.55.1" + "@dprint/linux-loong64-glibc" "0.55.1" + "@dprint/linux-loong64-musl" "0.55.1" + "@dprint/linux-ppc64-glibc" "0.55.1" + "@dprint/linux-ppc64-musl" "0.55.1" + "@dprint/linux-riscv64-glibc" "0.55.1" + "@dprint/linux-x64-glibc" "0.55.1" + "@dprint/linux-x64-musl" "0.55.1" + "@dprint/win32-arm64" "0.55.1" + "@dprint/win32-x64" "0.55.1" dunder-proto@^1.0.0, dunder-proto@^1.0.1: version "1.0.1" @@ -6739,6 +7056,11 @@ electron-to-chromium@^1.5.263: resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.326.tgz#849d1d7212450d0fc00eda2abc7c3c98e8de0943" integrity sha512-uRBlUfKKdsXMkiiOurgaybNC10tjrD+skXLEg7NHbm6h0uAoqj3xMb9uue5BfcSCXJ4mcyJMOucI6q55D7p6KQ== +emoji-regex-xs@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/emoji-regex-xs/-/emoji-regex-xs-2.0.1.tgz#012f4e4d88ecec8397df32d5b4ef89d422559c0e" + integrity sha512-1QFuh8l7LqUcKe24LsPUNzjrzJQ7pgRwp1QMcZ5MX6mFplk2zQ08NVCM84++1cveaUUYtcCYHmeFEuNg16sU4g== + emoji-regex@^10.3.0: version "10.6.0" resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-10.6.0.tgz#bf3d6e8f7f8fd22a65d9703475bc0147357a6b0d" @@ -7412,6 +7734,11 @@ feed@^4.2.2: dependencies: xml-js "^1.6.11" +fflate@^0.7.3: + version "0.7.4" + resolved "https://registry.yarnpkg.com/fflate/-/fflate-0.7.4.tgz#61587e5d958fdabb5a9368a302c25363f4f69f50" + integrity sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw== + file-entry-cache@^8.0.0: version "8.0.0" resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-8.0.0.tgz#7787bddcf1131bffb92636c69457bbc0edd6d81f" @@ -8099,6 +8426,11 @@ he@^1.2.0: resolved "https://registry.npmjs.org/he/-/he-1.2.0.tgz" integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw== +hex-rgb@^4.1.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/hex-rgb/-/hex-rgb-4.3.0.tgz#af5e974e83bb2fefe44d55182b004ec818c07776" + integrity sha512-Ox1pJVrDCyGHMG9CFg1tmrRUMRPRsAWYc/PinY0XzJU4K7y7vjNoLKIQ7BR5UJMCxNN8EM1MNDmHWA/B3aZUuw== + history@^4.9.0: version "4.10.1" resolved "https://registry.npmjs.org/history/-/history-4.10.1.tgz" @@ -8536,18 +8868,18 @@ inline-style-parser@0.2.7: resolved "https://registry.yarnpkg.com/inline-style-parser/-/inline-style-parser-0.2.7.tgz#b1fc68bfc0313b8685745e4464e37f9376b9c909" integrity sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA== -instantsearch-ui-components@0.30.1: - version "0.30.1" - resolved "https://registry.yarnpkg.com/instantsearch-ui-components/-/instantsearch-ui-components-0.30.1.tgz#76a53419493493751d2c9a7acf3552fc76c8e8ac" - integrity sha512-0bwxs9bKTusS0EIr2wmyltcLDkeIU5gHc6W0PDjp8VJrmBCB7ULa2qPIxpumO7B8ifp2pNoJtwpqXLup7/60DQ== +instantsearch-ui-components@0.33.0: + version "0.33.0" + resolved "https://registry.yarnpkg.com/instantsearch-ui-components/-/instantsearch-ui-components-0.33.0.tgz#568d8ae45de37d87daae395a3f12737cfb0ce137" + integrity sha512-fvGafCzi677tQ8kiLwxAndeZ2QfU6bEX3ZKeD+F929qUCEsNoh5qpE1SwmdZyrUmtaBPryW3av8bneJQ90Bixw== dependencies: "@swc/helpers" "0.5.18" markdown-to-jsx "^7.7.15" -instantsearch.js@4.101.1: - version "4.101.1" - resolved "https://registry.yarnpkg.com/instantsearch.js/-/instantsearch.js-4.101.1.tgz#dbae35d25670aa0a30894c0a594fd271d7f34279" - integrity sha512-cSbjGQ85mSNLghcRfcryVl5tOYsNLCbmQC9g1mTJR+gFzunvirxDuQIaMdOw3Uy6RQmFt9ri8i3TVq9xLIkpzw== +instantsearch.js@4.104.0: + version "4.104.0" + resolved "https://registry.yarnpkg.com/instantsearch.js/-/instantsearch.js-4.104.0.tgz#8a2365f6388398cabffc1c671ff0f8cd706c37ea" + integrity sha512-3cVEqklCqz1YiJfAcbbfpDo3xH2Sh9m7WwAdLIJflDmNpW9kwMbiZ4mTIfTlpG69i5lnt4lzLiDnLhOZjmJ8vg== dependencies: "@algolia/events" "^4.0.1" "@swc/helpers" "0.5.18" @@ -8558,7 +8890,7 @@ instantsearch.js@4.101.1: algoliasearch-helper "3.29.1" hogan.js "^3.0.2" htm "^3.0.0" - instantsearch-ui-components "0.30.1" + instantsearch-ui-components "0.33.0" preact "^10.10.0" qs "^6.5.1" react ">= 0.14.0" @@ -9076,13 +9408,20 @@ js-yaml@^3.13.1: argparse "^1.0.7" esprima "^4.0.0" -js-yaml@^4.1.0, js-yaml@^4.1.1, js-yaml@^4.2.0: +js-yaml@^4.1.0, js-yaml@^4.1.1: version "4.2.0" resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.2.0.tgz#2bd9e85682dd91bd469afb809d816043b3d49524" integrity sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw== dependencies: argparse "^2.0.1" +js-yaml@^5.2.1: + version "5.2.1" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-5.2.1.tgz#bb3f2e87295ebfe6ef0a75e17c62834daeff9ce2" + integrity sha512-zfLtNfQqxVqq3uaTqSkh4x4hZw3KHobGUA0fJUj4wawW8bsQLTVqpHdXSIzidh7o+4lEW36tANuAGdaFx6Zgnw== + dependencies: + argparse "^2.0.1" + jsbn@~0.1.0: version "0.1.1" resolved "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz" @@ -9413,6 +9752,14 @@ line-reader@^0.4.0: resolved "https://registry.npmjs.org/line-reader/-/line-reader-0.4.0.tgz" integrity sha512-AYJ8g+eE7v+Ba4s/cuYqzuNulH/WbjdKQ55fvx8fNVn8WQzTpioY6vI1MoxTuMgcHYX3VlmZWbVvnkIqkyJbCA== +linebreak@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/linebreak/-/linebreak-1.1.0.tgz#831cf378d98bced381d8ab118f852bd50d81e46b" + integrity sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ== + dependencies: + base64-js "0.0.8" + unicode-trie "^2.0.0" + lines-and-columns@^1.1.6: version "1.2.4" resolved "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz" @@ -10910,6 +11257,11 @@ package-manager-detector@^1.3.0: resolved "https://registry.yarnpkg.com/package-manager-detector/-/package-manager-detector-1.6.0.tgz#70d0cf0aa02c877eeaf66c4d984ede0be9130734" integrity sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA== +pako@^0.2.5: + version "0.2.9" + resolved "https://registry.yarnpkg.com/pako/-/pako-0.2.9.tgz#f3f7522f4ef782348da8161bad9ecfd51bf83a75" + integrity sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA== + param-case@^2.1.1: version "2.1.1" resolved "https://registry.npmjs.org/param-case/-/param-case-2.1.1.tgz" @@ -10932,6 +11284,14 @@ parent-module@^1.0.0: dependencies: callsites "^3.0.0" +parse-css-color@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/parse-css-color/-/parse-css-color-0.2.1.tgz#b687a583f2e42e66ffdfce80a570706966e807c9" + integrity sha512-bwS/GGIFV3b6KS4uwpzCFj4w297Yl3uqnSgIPsoQkx7GMLROXfMnWvxfNkL0oh8HVhZA4hvJoEoEIqonfJ3BWg== + dependencies: + color-name "^1.1.4" + hex-rgb "^4.1.0" + parse-entities@^4.0.0: version "4.0.2" resolved "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz" @@ -11135,17 +11495,17 @@ player.style@^0.3.0: dependencies: media-chrome "~4.19.0" -playwright-core@1.61.0: - version "1.61.0" - resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.61.0.tgz#caf8078b2a39cd7738dc75ec11cb3b47f385c3f0" - integrity sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA== +playwright-core@1.61.1: + version "1.61.1" + resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.61.1.tgz#3c99841307efbbabc9d724c41a88c914705d15fc" + integrity sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg== -playwright@1.61.0: - version "1.61.0" - resolved "https://registry.yarnpkg.com/playwright/-/playwright-1.61.0.tgz#7082df3df08ffa82b11420ea5fae84a40bd16e3f" - integrity sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ== +playwright@1.61.1: + version "1.61.1" + resolved "https://registry.yarnpkg.com/playwright/-/playwright-1.61.1.tgz#d8c0c06eb93c28981afc747bace453bdbd5018bc" + integrity sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ== dependencies: - playwright-core "1.61.0" + playwright-core "1.61.1" optionalDependencies: fsevents "2.3.2" @@ -11914,7 +12274,7 @@ postcss-unique-selectors@^6.0.4: dependencies: postcss-selector-parser "^6.0.16" -postcss-value-parser@^4.1.0, postcss-value-parser@^4.2.0: +postcss-value-parser@^4.0.2, postcss-value-parser@^4.1.0, postcss-value-parser@^4.2.0: version "4.2.0" resolved "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz" integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ== @@ -12155,25 +12515,25 @@ react-icons@^5.6.0: resolved "https://registry.yarnpkg.com/react-icons/-/react-icons-5.6.0.tgz#27bcc4acbc836e762548d76041cf9b9fef4e3837" integrity sha512-RH93p5ki6LfOiIt0UtDyNg/cee+HLVR6cHHtW3wALfo+eOHTp8RnU2kRkI6E+H19zMIs03DyxUG/GfZMOGvmiA== -react-instantsearch-core@7.35.1: - version "7.35.1" - resolved "https://registry.yarnpkg.com/react-instantsearch-core/-/react-instantsearch-core-7.35.1.tgz#82c6ac296a53727c1e366fe5af9d5e67c306d39e" - integrity sha512-8o9JjuFbYxjt+QPjpTRkyy3YGPf/GOGBaqWhxNkATDpZNlasTrfKHhnIprEImdKpipynDZrEnAJ7eDVb4+PHyA== +react-instantsearch-core@7.38.0: + version "7.38.0" + resolved "https://registry.yarnpkg.com/react-instantsearch-core/-/react-instantsearch-core-7.38.0.tgz#450b771544fb51d1ef67c6a069d689d45691323c" + integrity sha512-Rb28Zu82iiXtnDqFBn5lbwBuQbkuDk1ZCKWbDmk9iANqkHSiFHqd4LuOSWtS+AkU9AqrTzXanOkM26kcyupU3A== dependencies: "@swc/helpers" "0.5.18" algoliasearch-helper "3.29.1" - instantsearch.js "4.101.1" + instantsearch.js "4.104.0" use-sync-external-store "^1.0.0" -react-instantsearch@^7.35.1: - version "7.35.1" - resolved "https://registry.yarnpkg.com/react-instantsearch/-/react-instantsearch-7.35.1.tgz#19e2c24c6d5fe3099f84d14edd0f0a688549a9ce" - integrity sha512-CFllvyd7qsFrzrvBACDL0keWkcAjGMsMfVvxdUde5mQqykCWzy4Q66stROVNy61E7vGA1voNRFgzEdkbVRWUIA== +react-instantsearch@^7.38.0: + version "7.38.0" + resolved "https://registry.yarnpkg.com/react-instantsearch/-/react-instantsearch-7.38.0.tgz#3769e3e14a905b615687b9922232600d1ab552a4" + integrity sha512-6opEJpuw5i3Hs95i6y5iqAr2NmX4h51x9LzfUeUwXvsYGA8mdjSK2NqjRMjv3B+4D0iA+Xx3HBQD6DcU2r/Ikg== dependencies: "@swc/helpers" "0.5.18" - instantsearch-ui-components "0.30.1" - instantsearch.js "4.101.1" - react-instantsearch-core "7.35.1" + instantsearch-ui-components "0.33.0" + instantsearch.js "4.104.0" + react-instantsearch-core "7.38.0" react-is@^16.13.1, react-is@^16.6.0, react-is@^16.7.0: version "16.13.1" @@ -12791,6 +13151,23 @@ safe-regex-test@^1.1.0: resolved "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz" integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== +satori@^0.26.0: + version "0.26.0" + resolved "https://registry.yarnpkg.com/satori/-/satori-0.26.0.tgz#bd99df054f86ffb4939ac8d60aa92a3a5b0d9c80" + integrity sha512-tkMFrfIs3l2mQ2JEcyW0ADTy3zGggFRFzi6Ef8YozQSFsFKEqaSO1Y8F9wJg4//PJGQauMalHGTUEkPrFwhVPA== + dependencies: + "@shuding/opentype.js" "1.4.0-beta.0" + css-background-parser "^0.1.0" + css-box-shadow "1.0.0-3" + css-gradient-parser "^0.0.17" + css-to-react-native "^3.0.0" + emoji-regex-xs "^2.0.1" + escape-html "^1.0.3" + linebreak "^1.1.0" + parse-css-color "^0.2.1" + postcss-value-parser "^4.2.0" + yoga-layout "^3.2.1" + sax@1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.1.tgz#7b8e656190b228e81a66aea748480d828cd2d37a" @@ -12844,7 +13221,7 @@ schemes@^1.0.1: dependencies: extend "^3.0.0" -search-insights@^2.17.2: +search-insights@^2, search-insights@^2.17.2: version "2.17.3" resolved "https://registry.npmjs.org/search-insights/-/search-insights-2.17.3.tgz" integrity sha512-RQPdCYTa8A68uM2jwxoY842xDhvx3E5LFL1LxvxCNMev4o5mLuokczhzjAgGwUZBAmOKZknArSxLKmXtIi2AxQ== @@ -12892,6 +13269,11 @@ semver@^7.3.5, semver@^7.3.7, semver@^7.5.4: resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.4.tgz#28464e36060e991fa7a11d0279d2d3f3b57a7e8a" integrity sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA== +semver@^7.8.5: + version "7.8.5" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.8.5.tgz#39b646037dd50c14fb451e7e4cac58ed8b863f69" + integrity sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA== + send@~0.19.0, send@~0.19.1: version "0.19.2" resolved "https://registry.yarnpkg.com/send/-/send-0.19.2.tgz#59bc0da1b4ea7ad42736fd642b1c4294e114ff29" @@ -13007,6 +13389,41 @@ shallowequal@^1.1.0: resolved "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz" integrity sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ== +sharp@^0.35.3: + version "0.35.3" + resolved "https://registry.yarnpkg.com/sharp/-/sharp-0.35.3.tgz#41ed21a46406be163eacd0be7b364b42fcf2885f" + integrity sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q== + dependencies: + "@img/colour" "^1.1.0" + detect-libc "^2.1.2" + semver "^7.8.5" + optionalDependencies: + "@img/sharp-darwin-arm64" "0.35.3" + "@img/sharp-darwin-x64" "0.35.3" + "@img/sharp-freebsd-wasm32" "0.35.3" + "@img/sharp-libvips-darwin-arm64" "1.3.2" + "@img/sharp-libvips-darwin-x64" "1.3.2" + "@img/sharp-libvips-linux-arm" "1.3.2" + "@img/sharp-libvips-linux-arm64" "1.3.2" + "@img/sharp-libvips-linux-ppc64" "1.3.2" + "@img/sharp-libvips-linux-riscv64" "1.3.2" + "@img/sharp-libvips-linux-s390x" "1.3.2" + "@img/sharp-libvips-linux-x64" "1.3.2" + "@img/sharp-libvips-linuxmusl-arm64" "1.3.2" + "@img/sharp-libvips-linuxmusl-x64" "1.3.2" + "@img/sharp-linux-arm" "0.35.3" + "@img/sharp-linux-arm64" "0.35.3" + "@img/sharp-linux-ppc64" "0.35.3" + "@img/sharp-linux-riscv64" "0.35.3" + "@img/sharp-linux-s390x" "0.35.3" + "@img/sharp-linux-x64" "0.35.3" + "@img/sharp-linuxmusl-arm64" "0.35.3" + "@img/sharp-linuxmusl-x64" "0.35.3" + "@img/sharp-webcontainers-wasm32" "0.35.3" + "@img/sharp-win32-arm64" "0.35.3" + "@img/sharp-win32-ia32" "0.35.3" + "@img/sharp-win32-x64" "0.35.3" + shebang-command@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz" @@ -13339,6 +13756,11 @@ string-width@^7.0.0, string-width@^7.2.0: get-east-asian-width "^1.0.0" strip-ansi "^7.1.0" +string.prototype.codepointat@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/string.prototype.codepointat/-/string.prototype.codepointat-0.2.1.tgz#004ad44c8afc727527b108cd462b4d971cd469bc" + integrity sha512-2cBVCj6I4IOvEnjgO/hWqXjqBGsY+zwPmHl12Srk9IXSZ56Jwwmy+66XO5Iut/oQVR7t5ihYdLB0GMa4alEUcg== + string.prototype.matchall@^4.0.12: version "4.0.12" resolved "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz" @@ -13689,6 +14111,11 @@ tiktok-video-element@^0.1.1: resolved "https://registry.yarnpkg.com/tiktok-video-element/-/tiktok-video-element-0.1.2.tgz#22c93f4148afb99882fec050ca30c34673155f93" integrity sha512-w6TboLm236XJKKiIXIhCbYCnUxbixBbaAoty0etaEAZ/2kHkVIdfZdv2oouMU/HGMsWCHI/VjQ3wU3MJ+s192Q== +tiny-inflate@^1.0.0: + version "1.0.3" + resolved "https://registry.yarnpkg.com/tiny-inflate/-/tiny-inflate-1.0.3.tgz#122715494913a1805166aaf7c93467933eea26c4" + integrity sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw== + tiny-invariant@^1.0.2: version "1.3.3" resolved "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz" @@ -13943,10 +14370,10 @@ underscore@^1.8.3: resolved "https://registry.npmjs.org/underscore/-/underscore-1.13.7.tgz" integrity sha512-GMXzWtsc57XAtguZgaQViUOzs0KTkk8ojr3/xAxXLITqf/3EMwxC0inyETfDFjH/Krbhuep0HNbbjI9i/q3F3g== -"undici-types@>=7.24.0 <7.24.7": - version "7.24.6" - resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-7.24.6.tgz#61275b485d7fd4e9d269c7cf04ec2873c9cc0f91" - integrity sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg== +undici-types@~8.3.0: + version "8.3.0" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-8.3.0.tgz#44e9fc9f3244648cdea35e4f9bb2d681e9410809" + integrity sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ== unicode-canonical-property-names-ecmascript@^2.0.0: version "2.0.1" @@ -13976,6 +14403,14 @@ unicode-property-aliases-ecmascript@^2.0.0: resolved "https://registry.yarnpkg.com/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.2.0.tgz#301d4f8a43d2b75c97adfad87c9dd5350c9475d1" integrity sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ== +unicode-trie@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/unicode-trie/-/unicode-trie-2.0.0.tgz#8fd8845696e2e14a8b67d78fa9e0dd2cad62fec8" + integrity sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ== + dependencies: + pako "^0.2.5" + tiny-inflate "^1.0.0" + unified@^11.0.0, unified@^11.0.3, unified@^11.0.4: version "11.0.5" resolved "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz" @@ -14185,10 +14620,10 @@ utils-merge@1.0.1: resolved "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz" integrity sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA== -"uuid@^11.1.0 || ^12 || ^13 || ^14.0.0", uuid@^14.0.0: - version "14.0.0" - resolved "https://registry.yarnpkg.com/uuid/-/uuid-14.0.0.tgz#0af883220163d264ffe0c084f6b8a89b9666966d" - integrity sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg== +"uuid@^11.1.0 || ^12 || ^13 || ^14.0.0", uuid@^14.0.1: + version "14.0.1" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-14.0.1.tgz#8a5975b3e038902bfd169a10b5202f5ec0cf3faf" + integrity sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew== uuid@^3.3.2: version "3.4.0" @@ -14483,9 +14918,9 @@ webpackbar@^7.0.0: std-env "^3.7.0" websocket-driver@>=0.5.1, websocket-driver@^0.7.4: - version "0.7.4" - resolved "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz" - integrity sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg== + version "0.7.5" + resolved "https://registry.yarnpkg.com/websocket-driver/-/websocket-driver-0.7.5.tgz#569d22764ab21f2de20af0e74b411e8ae5a0fa46" + integrity sha512-ZL2+3c7kMBdIRCMz6l8jQMHyGVxj+UL+xVk74Ombiciboca8rHa15L86B19E5oh1pL9Ii/uj54gtsIrZGMo6zA== dependencies: http-parser-js ">=0.5.1" safe-buffer ">=5.1.0" @@ -14964,6 +15399,11 @@ yocto-queue@^1.0.0: resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-1.2.2.tgz#3e09c95d3f1aa89a58c114c99223edf639152c00" integrity sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ== +yoga-layout@^3.2.1: + version "3.2.1" + resolved "https://registry.yarnpkg.com/yoga-layout/-/yoga-layout-3.2.1.tgz#d2d1ba06f0e81c2eb650c3e5ad8b0b4adde1e843" + integrity sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ== + youtube-video-element@^1.8.0: version "1.9.0" resolved "https://registry.yarnpkg.com/youtube-video-element/-/youtube-video-element-1.9.0.tgz#f0a786819b935bae96b12a9e11d67c5dbb091b82"