diff --git a/.github/workflows/validate-samples.yaml b/.github/workflows/validate-samples.yaml new file mode 100644 index 0000000..5e04cf5 --- /dev/null +++ b/.github/workflows/validate-samples.yaml @@ -0,0 +1,215 @@ +name: ๐Ÿงช Validate Samples + +# Validates all samples under examples/azure-managed/ on PRs and main pushes. +# Samples are auto-discovered: any subfolder containing a sample.json is treated as a sample. +# The "unit-testing" sample runs without emulator; emulator-dependent samples use Docker. + +on: + push: + branches: [main] + paths: + - "examples/**" + - "packages/**" + - "package.json" + - "tsconfig.base.json" + - ".github/workflows/validate-samples.yaml" + pull_request: + branches: [main] + paths: + - "examples/**" + - "packages/**" + - "package.json" + - "tsconfig.base.json" + - ".github/workflows/validate-samples.yaml" + +permissions: + contents: read + +jobs: + # ----------------------------------------------------------------------- + # 1. Discover all samples dynamically + # ----------------------------------------------------------------------- + discover: + runs-on: ubuntu-latest + outputs: + # JSON arrays of sample directory names + emulator-samples: ${{ steps.find.outputs.emulator }} + no-emulator-samples: ${{ steps.find.outputs.no_emulator }} + steps: + - uses: actions/checkout@v4 + + - name: ๐Ÿ” Discover samples via sample.json + id: find + run: | + SAMPLES_ROOT="examples/azure-managed" + + # Find all sample.json files under the samples root + emulator_samples="[]" + no_emulator_samples="[]" + + for sample_json in $(find "$SAMPLES_ROOT" -mindepth 1 -name "sample.json" | sort); do + dir=$(dirname "$sample_json") + name=$(basename "$dir") + requires_emulator=$(jq -r '.requiresEmulator // true' "$sample_json") + skip_ci=$(jq -r '.skipCi // false' "$sample_json") + + echo "Found sample: $name (requiresEmulator=$requires_emulator, skipCi=$skip_ci)" + + if [ "$skip_ci" = "true" ]; then + echo " โญ๏ธ Skipping $name (skipCi=true)" + continue + fi + + if [ "$requires_emulator" = "false" ]; then + no_emulator_samples=$(echo "$no_emulator_samples" | jq --arg n "$name" '. + [$n]') + else + emulator_samples=$(echo "$emulator_samples" | jq --arg n "$name" '. + [$n]') + fi + done + + echo "emulator=$(echo "$emulator_samples" | jq -c .)" >> "$GITHUB_OUTPUT" + echo "no_emulator=$(echo "$no_emulator_samples" | jq -c .)" >> "$GITHUB_OUTPUT" + + echo "--- Emulator samples ---" + echo "$emulator_samples" | jq . + echo "--- No-emulator samples ---" + echo "$no_emulator_samples" | jq . + + # ----------------------------------------------------------------------- + # 2. Build the SDK (shared by all sample jobs) + # ----------------------------------------------------------------------- + build: + runs-on: ubuntu-latest + strategy: + matrix: + node-version: ["22.x"] + steps: + - uses: actions/checkout@v4 + + - name: โš™๏ธ Setup Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: npm + + - name: ๐Ÿ“ฆ Install dependencies + run: npm ci + + - name: ๐Ÿ”จ Build SDK + run: npm run build + + - name: ๐Ÿ“ Cache build output + uses: actions/cache/save@v4 + with: + path: | + node_modules + packages/*/dist + packages/*/node_modules + key: sdk-build-${{ github.sha }}-node${{ matrix.node-version }} + + # ----------------------------------------------------------------------- + # 3. Run samples that DON'T need the emulator (e.g., unit-testing) + # ----------------------------------------------------------------------- + samples-no-emulator: + needs: [discover, build] + if: needs.discover.outputs.no-emulator-samples != '[]' + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + sample: ${{ fromJson(needs.discover.outputs.no-emulator-samples) }} + node-version: ["22.x"] + steps: + - uses: actions/checkout@v4 + + - name: โš™๏ธ Setup Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + + - name: ๐Ÿ“ฆ Restore build cache + uses: actions/cache/restore@v4 + with: + path: | + node_modules + packages/*/dist + packages/*/node_modules + key: sdk-build-${{ github.sha }}-node${{ matrix.node-version }} + + - name: ๐Ÿงช Run sample โ€” ${{ matrix.sample }} + run: | + echo "Running sample: ${{ matrix.sample }}" + npx ts-node --swc ./examples/azure-managed/${{ matrix.sample }}/index.ts + timeout-minutes: 2 + + # ----------------------------------------------------------------------- + # 4. Run samples that need the DTS emulator (Docker) + # ----------------------------------------------------------------------- + samples-with-emulator: + needs: [discover, build] + if: needs.discover.outputs.emulator-samples != '[]' + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + sample: ${{ fromJson(needs.discover.outputs.emulator-samples) }} + node-version: ["22.x"] + + env: + DURABLE_TASK_SCHEDULER_CONNECTION_STRING: "Endpoint=http://localhost:8080;Authentication=None;TaskHub=default" + + steps: + - uses: actions/checkout@v4 + + - name: ๐Ÿณ Start DTS emulator + run: | + docker pull mcr.microsoft.com/dts/dts-emulator:latest + docker run --name dtsemulator -d -p 8080:8080 mcr.microsoft.com/dts/dts-emulator:latest + + - name: โณ Wait for DTS emulator + run: sleep 10 + + - name: โš™๏ธ Setup Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + + - name: ๐Ÿ“ฆ Restore build cache + uses: actions/cache/restore@v4 + with: + path: | + node_modules + packages/*/dist + packages/*/node_modules + key: sdk-build-${{ github.sha }}-node${{ matrix.node-version }} + + - name: ๐Ÿงช Run sample โ€” ${{ matrix.sample }} + run: | + echo "Running sample: ${{ matrix.sample }}" + npx ts-node --swc ./examples/azure-managed/${{ matrix.sample }}/index.ts + timeout-minutes: 5 + + - name: ๐Ÿงน Stop DTS emulator + if: always() + run: docker rm -f dtsemulator || true + + # ----------------------------------------------------------------------- + # 5. Summary gate โ€” all samples must pass + # ----------------------------------------------------------------------- + samples-gate: + needs: [samples-no-emulator, samples-with-emulator] + if: always() + runs-on: ubuntu-latest + steps: + - name: โœ… Check results + run: | + echo "No-emulator result: ${{ needs.samples-no-emulator.result }}" + echo "Emulator result: ${{ needs.samples-with-emulator.result }}" + + if [[ ! "${{ needs.samples-no-emulator.result }}" =~ ^(success|skipped)$ ]] || \ + [[ ! "${{ needs.samples-with-emulator.result }}" =~ ^(success|skipped)$ ]]; then + echo "โŒ Some samples failed or were cancelled!" + exit 1 + fi + + echo "โœ… All samples passed!" diff --git a/examples/azure-managed/.env.example b/examples/azure-managed/.env.example index a9fd951..38e3cfd 100644 --- a/examples/azure-managed/.env.example +++ b/examples/azure-managed/.env.example @@ -1,10 +1,22 @@ # Azure Managed Durable Task Scheduler (DTS) Configuration # Copy this file to .env and update the values for your environment. +# +# To find your endpoint, run: +# az durabletask scheduler show --resource-group --name --query endpoint -o tsv +# +# Make sure you have the "Durable Task Data Contributor" role assigned on the scheduler resource. +# Authenticate via: az login + # Option 1: Using connection string (recommended) -# Supported authentication types: DefaultAzure, ManagedIdentity, WorkloadIdentity, +# Supported authentication types: DefaultAzure, ManagedIdentity, WorkloadIdentity, # Environment, AzureCli, AzurePowerShell, VisualStudioCode, InteractiveBrowser, None +# Use Authentication=None only for the local emulator. DURABLE_TASK_SCHEDULER_CONNECTION_STRING=Endpoint=https://your-scheduler.eastus.durabletask.io;Authentication=DefaultAzure;TaskHub=your-taskhub + # Option 2: Using explicit parameters (uses DefaultAzureCredential) # Uncomment these lines and comment out DURABLE_TASK_SCHEDULER_CONNECTION_STRING above # AZURE_DTS_ENDPOINT=https://your-scheduler.eastus.durabletask.io -# AZURE_DTS_TASKHUB=your-taskhub \ No newline at end of file +# AZURE_DTS_TASKHUB=your-taskhub + +# Optional: OTLP endpoint for distributed tracing (Jaeger, Azure Monitor, Aspire Dashboard, etc.) +# OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 \ No newline at end of file diff --git a/examples/azure-managed/README.md b/examples/azure-managed/README.md index 4045a13..6cc1ddf 100644 --- a/examples/azure-managed/README.md +++ b/examples/azure-managed/README.md @@ -1,3 +1,162 @@ +# Azure Managed Durable Task Scheduler โ€” Samples + +Runnable samples demonstrating every major feature of the Durable Task JavaScript SDK with Azure Managed DTS. + +## Sample Index + +| Sample | Scenario | Key Features | Emulator Required | +|--------|----------|-------------|-------------------| +| [hello-orchestrations](hello-orchestrations/) | Core patterns | Activity sequence, fan-out/fan-in, sub-orchestrations, `whenAny` | Yes | +| [retry-and-error-handling](retry-and-error-handling/) | Fault tolerance | `RetryPolicy`, `handleFailure`, `AsyncRetryHandler`, sub-orchestration retry, `raiseIfFailed()` | Yes | +| [human-interaction](human-interaction/) | Event-driven workflows | External events, timers, `whenAny` race, `sendEvent`, custom status | Yes | +| [lifecycle-management](lifecycle-management/) | Orchestration control | Terminate (recursive), suspend/resume, restart, continue-as-new, purge, tags | Yes | +| [query-and-history](query-and-history/) | Monitoring & debugging | `getAllInstances`, pagination, `listInstanceIds`, `getOrchestrationHistory`, typed events | Yes | +| [versioning](versioning/) | Safe deployments | Version match strategies, failure strategies, `ctx.version`, `ctx.compareVersionTo()` | Yes | +| [unit-testing](unit-testing/) | Testing without infra | `InMemoryOrchestrationBackend`, `TestOrchestrationClient`, `TestOrchestrationWorker`, `ReplaySafeLogger` | **No** | +| [basics](basics/) | Azure-managed basics | Connection strings, `DefaultAzureCredential`, `createAzureManagedClient` | Yes | +| [distributed-tracing](distributed-tracing/) | OpenTelemetry tracing | `NodeSDK`, OTLP export, Jaeger, `DurableTaskAzureManagedClientBuilder` | Yes | + +### Quick Start (Local Emulator) + +```bash +npm install && npm run build # build SDK +cd examples/azure-managed && docker compose up -d # start emulator +cp .env.emulator .env # configure +cd ../.. +npm run example -- ./examples/azure-managed/hello-orchestrations/index.ts +``` + +### Quick Start (Azure Managed DTS โ€” Cloud) + +To run samples against a **real Azure Managed Durable Task Scheduler** instead of the local emulator: + +#### 1. Create a Durable Task Scheduler resource + +If you haven't already, create a Durable Task Scheduler and a Task Hub in Azure: + +```bash +# Install the Durable Task Scheduler CLI extension +az extension add --name durabletask + +# Create a scheduler +az durabletask scheduler create \ + --resource-group \ + --name \ + --location \ + --sku free + +# Create a task hub +az durabletask taskhub create \ + --resource-group \ + --scheduler-name \ + --name +``` + +#### 2. Assign yourself the "Durable Task Data Contributor" role + +```bash +SCHEDULER_ID=$(az durabletask scheduler show \ + --resource-group \ + --name \ + --query id -o tsv) + +az role assignment create \ + --assignee $(az ad signed-in-user show --query id -o tsv) \ + --role "Durable Task Data Contributor" \ + --scope $SCHEDULER_ID +``` + +#### 3. Configure your `.env` file + +```bash +cd examples/azure-managed +cp .env.example .env +``` + +Edit `.env` with your scheduler's endpoint and task hub name: + +```env +# Option A: Connection string (recommended) +DURABLE_TASK_SCHEDULER_CONNECTION_STRING=Endpoint=https://.eastus.durabletask.io;Authentication=DefaultAzure;TaskHub= + +# Option B: Explicit parameters +# AZURE_DTS_ENDPOINT=https://.eastus.durabletask.io +# AZURE_DTS_TASKHUB= +``` + +#### 4. Authenticate and run + +```bash +az login # authenticate with Azure +cd ../.. # back to repo root +npm run example -- ./examples/azure-managed/hello-orchestrations/index.ts +``` + +> **Supported authentication types** in the connection string: `DefaultAzure`, `ManagedIdentity`, `WorkloadIdentity`, `Environment`, `AzureCli`, `AzurePowerShell`, `VisualStudioCode`, `InteractiveBrowser`. + +See each sample's README for details. See [Feature Coverage Map](#feature-coverage-map) below for full feature mapping. + +### CI Validation + +Samples are validated automatically by [`.github/workflows/validate-samples.yaml`](../../.github/workflows/validate-samples.yaml). Any subfolder with a `sample.json` is auto-discovered and tested on every PR. + +To add a new sample: create a subfolder with `sample.json`, `index.ts`, and `README.md`. CI picks it up automatically. + +### Running All Samples Locally + +```bash +for dir in examples/azure-managed/*/; do + if [ -f "$dir/sample.json" ] && [ -f "$dir/index.ts" ]; then + echo "--- Running $(basename $dir) ---" + npx ts-node --swc "$dir/index.ts" + fi +done +``` + +### Feature Coverage Map + +| Feature | Sample(s) | +|---------|-----------| +| `ctx.callActivity()` | hello-orchestrations, retry-and-error-handling | +| `whenAll()` | hello-orchestrations, unit-testing | +| `whenAny()` | hello-orchestrations, human-interaction | +| `ctx.callSubOrchestrator()` | hello-orchestrations, retry-and-error-handling, lifecycle-management | + +| `ctx.waitForExternalEvent()` | human-interaction, unit-testing | +| `client.raiseOrchestrationEvent()` | human-interaction, unit-testing | +| `ctx.createTimer()` | human-interaction, query-and-history, unit-testing | +| `ctx.sendEvent()` | human-interaction | +| `ctx.setCustomStatus()` | human-interaction | +| `RetryPolicy` | retry-and-error-handling | +| `handleFailure` predicate | retry-and-error-handling | +| `AsyncRetryHandler` | retry-and-error-handling | +| `state.raiseIfFailed()` | retry-and-error-handling | +| `terminateOrchestration()` | lifecycle-management, unit-testing | +| `terminateOptions()` (recursive) | lifecycle-management | +| `suspendOrchestration()` / `resumeOrchestration()` | lifecycle-management, unit-testing | +| `continueAsNew()` | lifecycle-management, unit-testing | +| `restartOrchestration()` | lifecycle-management | +| `purgeOrchestration()` | lifecycle-management | +| Orchestration tags | lifecycle-management | +| `getAllInstances()` / pagination | query-and-history | +| `listInstanceIds()` | query-and-history | +| `getOrchestrationHistory()` | query-and-history | +| Typed `HistoryEvent` | query-and-history | +| `VersionMatchStrategy` | versioning | +| `VersionFailureStrategy` | versioning | +| `ctx.version` / `ctx.compareVersionTo()` | versioning | +| `InMemoryOrchestrationBackend` | unit-testing | +| `TestOrchestrationClient/Worker` | unit-testing | +| `ReplaySafeLogger` | unit-testing | +| `NoOpLogger` | unit-testing | +| Connection strings | basics, all samples | +| `DefaultAzureCredential` | basics | +| `createAzureLogger()` | basics | +| Distributed tracing (OTel) | distributed-tracing | +| `ConsoleLogger` | all samples | + +--- + # Distributed Tracing with Azure Managed Durable Task Scheduler This example demonstrates **OpenTelemetry distributed tracing** with the Durable Task JavaScript SDK and Azure Managed Durable Task Scheduler (DTS). Traces are exported to [Jaeger](https://www.jaegertracing.io/) so you can visualize the full orchestration lifecycle as connected spans. @@ -104,7 +263,7 @@ npm run build ### 5. Run the Example ```bash -npm run example -- ./examples/azure-managed/distributed-tracing.ts +npm run example -- ./examples/azure-managed/distributed-tracing/index.ts ``` You should see output like: @@ -162,7 +321,7 @@ az login ### 3. Run ```bash -npm run example -- ./examples/azure-managed/distributed-tracing.ts +npm run example -- ./examples/azure-managed/distributed-tracing/index.ts ``` --- @@ -175,7 +334,7 @@ To export traces to **Azure Monitor** instead of Jaeger, replace the OTLP export npm install --no-save @azure/monitor-opentelemetry-exporter ``` -Then modify the OpenTelemetry setup in `distributed-tracing.ts`: +Then modify the OpenTelemetry setup in `distributed-tracing/index.ts`: ```typescript import { AzureMonitorTraceExporter } from "@azure/monitor-opentelemetry-exporter"; @@ -245,8 +404,8 @@ docker compose down | File | Description | |------|-------------| -| `distributed-tracing.ts` | Main example โ€“ OTel setup + orchestrations | +| `distributed-tracing/` | Distributed tracing sample โ€“ OTel setup + orchestrations | +| `basics/` | Basic sample โ€“ connection strings, DefaultAzureCredential | | `docker-compose.yml` | DTS Emulator + Jaeger stack | | `.env.emulator` | Pre-configured env vars for the local emulator | | `.env.example` | Template for Azure Managed DTS (cloud) | -| `index.ts` | Basic example (no tracing) | diff --git a/examples/azure-managed/basics/README.md b/examples/azure-managed/basics/README.md new file mode 100644 index 0000000..e71c233 --- /dev/null +++ b/examples/azure-managed/basics/README.md @@ -0,0 +1,39 @@ +# Basics + +Demonstrates the fundamentals of using the Azure Managed Durable Task Scheduler (DTS) with the JavaScript SDK, including connection strings and `DefaultAzureCredential` authentication. + +## Features Covered + +| Feature | API | +|---------|-----| +| Connection string auth | `createAzureManagedClient(connectionString)` | +| DefaultAzureCredential | `createAzureManagedClient(endpoint, taskHub, credential)` | +| Activity sequence | `ctx.callActivity()` in a loop | +| Fan-out/fan-in | `whenAll()` with parallel `callActivity()` | +| Azure logger | `createAzureLogger()` | + +## Prerequisites + +- Node.js โ‰ฅ 22 +- Docker (for the DTS Emulator) + +## Setup + +```bash +# From the repository root +cd examples/azure-managed +docker compose up -d # start DTS emulator +cp .env.emulator .env # configure for local emulator +cd ../.. +npm install && npm run build +``` + +## Run + +```bash +npm run example -- ./examples/azure-managed/basics/index.ts +``` + +## Running Against Azure + +See the [parent README](../README.md#quick-start-azure-managed-dts--cloud) for instructions on running against a real Azure Managed DTS resource. diff --git a/examples/azure-managed/index.ts b/examples/azure-managed/basics/index.ts similarity index 99% rename from examples/azure-managed/index.ts rename to examples/azure-managed/basics/index.ts index 8bebec5..f04edac 100644 --- a/examples/azure-managed/index.ts +++ b/examples/azure-managed/basics/index.ts @@ -8,7 +8,7 @@ // Load environment variables from .env file (recommended for local development) import * as dotenv from "dotenv"; import * as path from "path"; -dotenv.config({ path: path.join(__dirname, ".env") }); +dotenv.config({ path: path.join(__dirname, "..", ".env") }); import { DefaultAzureCredential } from "@azure/identity"; import { diff --git a/examples/azure-managed/basics/sample.json b/examples/azure-managed/basics/sample.json new file mode 100644 index 0000000..80098a7 --- /dev/null +++ b/examples/azure-managed/basics/sample.json @@ -0,0 +1,6 @@ +{ + "name": "azure-managed-basics", + "description": "Basic Azure-managed DTS usage with connection strings and DefaultAzureCredential", + "requiresEmulator": true, + "entrypoint": "index.ts" +} diff --git a/examples/azure-managed/distributed-tracing/README.md b/examples/azure-managed/distributed-tracing/README.md new file mode 100644 index 0000000..f17cea2 --- /dev/null +++ b/examples/azure-managed/distributed-tracing/README.md @@ -0,0 +1,40 @@ +# Distributed Tracing + +Demonstrates how to enable OpenTelemetry distributed tracing with the Azure Managed Durable Task Scheduler (DTS). Traces are exported to a local Jaeger or OTLP-compatible collector so you can visualize the full orchestration lifecycle. + +## Features Covered + +| Feature | API | +|---------|-----| +| OpenTelemetry setup | `NodeSDK`, `OTLPTraceExporter`, `SimpleSpanProcessor` | +| Builder pattern | `DurableTaskAzureManagedClientBuilder`, `DurableTaskAzureManagedWorkerBuilder` | +| Data pipeline | Fan-out/fan-in with chained activities | +| Console logger | `ConsoleLogger` | + +## Prerequisites + +- Node.js โ‰ฅ 22 +- Docker (for the DTS Emulator and Jaeger) + +## Setup + +```bash +# From the repository root +cd examples/azure-managed +docker compose up -d # start DTS emulator (and Jaeger if configured) +cp .env.emulator .env # configure for local emulator +cd ../.. +npm install && npm run build +``` + +## Run + +```bash +npm run example -- ./examples/azure-managed/distributed-tracing/index.ts +``` + +After the orchestrations complete, open the Jaeger UI at [http://localhost:16686](http://localhost:16686) and search for service `durabletask-js-tracing-example` to view traces. + +## Running Against Azure + +See the [parent README](../README.md#quick-start-azure-managed-dts--cloud) for instructions on running against a real Azure Managed DTS resource. diff --git a/examples/azure-managed/distributed-tracing.ts b/examples/azure-managed/distributed-tracing/index.ts similarity index 99% rename from examples/azure-managed/distributed-tracing.ts rename to examples/azure-managed/distributed-tracing/index.ts index dd3f76f..0ca3f6e 100644 --- a/examples/azure-managed/distributed-tracing.ts +++ b/examples/azure-managed/distributed-tracing/index.ts @@ -20,7 +20,7 @@ import { ATTR_SERVICE_NAME } from "@opentelemetry/semantic-conventions"; // Load environment variables from .env file import * as dotenv from "dotenv"; import * as path from "path"; -dotenv.config({ path: path.join(__dirname, ".env") }); +dotenv.config({ path: path.join(__dirname, "..", ".env") }); // Read the OTLP endpoint from the environment (defaults to Jaeger's OTLP HTTP port) const otlpEndpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT || "http://localhost:4318"; diff --git a/examples/azure-managed/distributed-tracing/sample.json b/examples/azure-managed/distributed-tracing/sample.json new file mode 100644 index 0000000..dbf91fa --- /dev/null +++ b/examples/azure-managed/distributed-tracing/sample.json @@ -0,0 +1,6 @@ +{ + "name": "distributed-tracing", + "description": "OpenTelemetry distributed tracing with Jaeger/OTLP export", + "requiresEmulator": true, + "skipCi": true +} diff --git a/examples/azure-managed/hello-orchestrations/README.md b/examples/azure-managed/hello-orchestrations/README.md new file mode 100644 index 0000000..be695de --- /dev/null +++ b/examples/azure-managed/hello-orchestrations/README.md @@ -0,0 +1,95 @@ +# Hello Orchestrations + +Demonstrates four fundamental orchestration patterns every Durable Task developer needs. + +## Features Covered + +| Feature | API | +|---------|-----| +| Activity sequence | `ctx.callActivity()` in a loop | +| Fan-out/fan-in | `whenAll()` with parallel `callActivity()` | +| Sub-orchestrations | `ctx.callSubOrchestrator()` | +| Race pattern | `whenAny()` | + +## Prerequisites + +- Node.js โ‰ฅ 22 +- Docker (for the DTS Emulator) + +## Setup + +```bash +# From the repository root +cd examples/azure-managed +docker compose up -d # start DTS emulator +cp .env.emulator .env # configure for local emulator +cd ../.. +npm install && npm run build +``` + +## Run + +```bash +npm run example -- ./examples/azure-managed/hello-orchestrations/index.ts +``` + +## Expected Output + +``` +=== 1. Activity Sequence === +Result: [1,2,3,4,5,6] + +=== 2. Fan-out/Fan-in === +Result: {"items":4,"totalChars":24} + +=== 3. Sub-orchestrations === +Result: {"result1":12,"result2":22} + +=== 4. whenAny (Race) === +Result: {"winnerResult":5} + +=== All orchestrations completed successfully! === +``` + +## Smoke Test + +```bash +npm run example -- ./examples/azure-managed/hello-orchestrations/index.ts 2>&1 | grep "All orchestrations completed successfully" +``` + +## Troubleshooting + +- **Connection refused**: Ensure the DTS emulator is running (`docker compose up -d` from the `examples/azure-managed` directory). +- **Worker timeout**: The emulator may need a few seconds to start. Retry the command. + +## Running Against Azure Managed DTS (Cloud) + +To run this sample against a real [Azure Managed Durable Task Scheduler](https://learn.microsoft.com/azure/durable-task-scheduler/) instead of the local emulator: + +1. **Create a scheduler and task hub** (if you haven't already) โ€” see the [parent README](../README.md#quick-start-azure-managed-dts--cloud) for `az durabletask` commands. + +2. **Configure `.env`** for your cloud endpoint: + + ```bash + cd examples/azure-managed + cp .env.example .env + # Edit .env with your scheduler endpoint and task hub name + ``` + + Example `.env`: + + ```env + DURABLE_TASK_SCHEDULER_CONNECTION_STRING=Endpoint=https://your-scheduler.eastus.durabletask.io;Authentication=DefaultAzure;TaskHub=your-taskhub + ``` + +3. **Authenticate** with Azure: + + ```bash + az login + ``` + +4. **Run** (no Docker needed): + + ```bash + npm run example -- ./examples/azure-managed/hello-orchestrations/index.ts + ``` diff --git a/examples/azure-managed/hello-orchestrations/index.ts b/examples/azure-managed/hello-orchestrations/index.ts new file mode 100644 index 0000000..d229080 --- /dev/null +++ b/examples/azure-managed/hello-orchestrations/index.ts @@ -0,0 +1,167 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// This sample demonstrates core orchestration patterns with the Azure Managed +// Durable Task Scheduler (DTS): +// 1. Activity sequence โ€” call activities one after another +// 2. Fan-out/fan-in โ€” run activities in parallel, aggregate results +// 3. Sub-orchestrations โ€” compose orchestrators hierarchically +// 4. whenAny โ€” race multiple tasks, use winner's result + +import * as dotenv from "dotenv"; +import * as path from "path"; +dotenv.config({ path: path.join(__dirname, "..", ".env") }); + +import { + DurableTaskAzureManagedClientBuilder, + DurableTaskAzureManagedWorkerBuilder, + ConsoleLogger, +} from "@microsoft/durabletask-js-azuremanaged"; +import { + ActivityContext, + OrchestrationContext, + TOrchestrator, + whenAll, + whenAny, +} from "@microsoft/durabletask-js"; + +// --------------------------------------------------------------------------- +// Activities +// --------------------------------------------------------------------------- + +/** Add one to the input. */ +const plusOne = async (_ctx: ActivityContext, input: number): Promise => { + return input + 1; +}; + +/** Simulate work with variable latency. Returns item length. */ +const processItem = async (_ctx: ActivityContext, item: string): Promise => { + console.log(` [processItem] Processing "${item}"`); + return item.length; +}; + +/** Child orchestration: adds two to the input via two plusOne activity calls. */ +const doubleOrchestrator: TOrchestrator = async function* (ctx: OrchestrationContext, value: number): any { + const doubled: number = yield ctx.callActivity(plusOne, value); + // Call plusOne again so we get value + 2 + const result: number = yield ctx.callActivity(plusOne, doubled); + return result; +}; + +// --------------------------------------------------------------------------- +// Orchestrators +// --------------------------------------------------------------------------- + +/** 1. Activity Sequence โ€” calls plusOne 5 times in a loop. */ +const activitySequence: TOrchestrator = async function* (ctx: OrchestrationContext, startVal: number): any { + let current = startVal; + const numbers = [current]; + + for (let i = 0; i < 5; i++) { + current = yield ctx.callActivity(plusOne, current); + numbers.push(current); + } + + return numbers; // [1, 2, 3, 4, 5, 6] +}; + +/** 2. Fan-out/fan-in โ€” processes items in parallel, sums results. */ +const fanOutFanIn: TOrchestrator = async function* (ctx: OrchestrationContext): any { + const items = ["alpha", "bravo", "charlie", "delta"]; + + // Fan-out: schedule all activities in parallel + const tasks = items.map((item) => ctx.callActivity(processItem, item)); + + // Fan-in: wait for all to complete + const lengths: number[] = yield whenAll(tasks); + + return { items: items.length, totalChars: lengths.reduce((a, b) => a + b, 0) }; +}; + +/** 3. Sub-orchestrations โ€” calls doubleOrchestrator as a child. */ +const parentOrchestrator: TOrchestrator = async function* (ctx: OrchestrationContext): any { + const result1: number = yield ctx.callSubOrchestrator(doubleOrchestrator, 10); + const result2: number = yield ctx.callSubOrchestrator(doubleOrchestrator, 20); + return { result1, result2 }; +}; + +/** 4. whenAny โ€” race two activities, return the first result. */ +const raceOrchestrator: TOrchestrator = async function* (ctx: OrchestrationContext): any { + const taskA = ctx.callActivity(processItem, "short"); + const taskB = ctx.callActivity(processItem, "a-longer-item"); + + const winner = yield whenAny([taskA, taskB]); + + // winner is the task that completed first + return { winnerResult: winner.getResult() }; +}; + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +(async () => { + const logger = new ConsoleLogger(); + const connectionString = + process.env.DURABLE_TASK_SCHEDULER_CONNECTION_STRING || + "Endpoint=http://localhost:8080;Authentication=None;TaskHub=default"; + + const client = new DurableTaskAzureManagedClientBuilder() + .connectionString(connectionString) + .logger(logger) + .build(); + + const worker = new DurableTaskAzureManagedWorkerBuilder() + .connectionString(connectionString) + .logger(logger) + .addOrchestrator(activitySequence) + .addOrchestrator(fanOutFanIn) + .addOrchestrator(parentOrchestrator) + .addOrchestrator(doubleOrchestrator) + .addOrchestrator(raceOrchestrator) + .addActivity(plusOne) + .addActivity(processItem) + .build(); + + try { + await worker.start(); + await new Promise((r) => setTimeout(r, 2000)); + + // --- 1. Activity Sequence --- + console.log("\n=== 1. Activity Sequence ==="); + const seqId = await client.scheduleNewOrchestration(activitySequence, 1); + const seqState = await client.waitForOrchestrationCompletion(seqId, true, 30); + console.log(`Result: ${seqState?.serializedOutput}`); + // Expected: [1,2,3,4,5,6] + + // --- 2. Fan-out/Fan-in --- + console.log("\n=== 2. Fan-out/Fan-in ==="); + const fanId = await client.scheduleNewOrchestration(fanOutFanIn); + const fanState = await client.waitForOrchestrationCompletion(fanId, true, 30); + console.log(`Result: ${fanState?.serializedOutput}`); + // Expected: {"items":4,"totalChars":24} + + // --- 3. Sub-orchestrations --- + console.log("\n=== 3. Sub-orchestrations ==="); + const subId = await client.scheduleNewOrchestration(parentOrchestrator); + const subState = await client.waitForOrchestrationCompletion(subId, true, 30); + console.log(`Result: ${subState?.serializedOutput}`); + // Expected: {"result1":12,"result2":22} + + // --- 4. whenAny (Race) --- + console.log("\n=== 4. whenAny (Race) ==="); + const raceId = await client.scheduleNewOrchestration(raceOrchestrator); + const raceState = await client.waitForOrchestrationCompletion(raceId, true, 30); + console.log(`Result: ${raceState?.serializedOutput}`); + // Expected: {"winnerResult":} + + console.log("\n=== All orchestrations completed successfully! ==="); + } catch (error) { + console.error("Error:", error); + process.exit(1); + } finally { + await worker.stop(); + await client.stop(); + process.exit(0); + } +})(); diff --git a/examples/azure-managed/hello-orchestrations/sample.json b/examples/azure-managed/hello-orchestrations/sample.json new file mode 100644 index 0000000..0f2a412 --- /dev/null +++ b/examples/azure-managed/hello-orchestrations/sample.json @@ -0,0 +1,5 @@ +{ + "name": "hello-orchestrations", + "description": "Core orchestration patterns: activity sequences, fan-out/fan-in, sub-orchestrations, whenAny", + "requiresEmulator": true +} diff --git a/examples/azure-managed/human-interaction/README.md b/examples/azure-managed/human-interaction/README.md new file mode 100644 index 0000000..9890ca0 --- /dev/null +++ b/examples/azure-managed/human-interaction/README.md @@ -0,0 +1,95 @@ +# Human Interaction + +Demonstrates event-driven orchestration patterns for workflows that involve human actors or external systems. + +## Features Covered + +| Feature | API | +|---------|-----| +| External events | `ctx.waitForExternalEvent()`, `client.raiseOrchestrationEvent()` | +| Durable timers | `ctx.createTimer()` | +| Race pattern | `whenAny()` โ€” event vs timer | +| Custom status | `ctx.setCustomStatus()` | +| Orchestration-to-orchestration events | `ctx.sendEvent()` | +| Multiple events | Sequential `waitForExternalEvent()` calls | + +## Prerequisites + +- Node.js โ‰ฅ 22 +- Docker (for the DTS Emulator) + +## Setup + +```bash +cd examples/azure-managed +docker compose up -d +cp .env.emulator .env +cd ../.. +npm install && npm run build +``` + +## Run + +```bash +npm run example -- ./examples/azure-managed/human-interaction/index.ts +``` + +## Expected Output + +``` +=== 1. Approval Workflow (client sends approval) === +Orchestration started: +Custom status: {"stage":"Awaiting approval","requestId":"REQ-..."} +Sent approval event from client +Result: "Order placed" + +=== 2. Approval Workflow (timeout โ€” no event sent) === +Result: "Timed out โ€” auto-rejected" + +=== 3. sendEvent (orchestration โ†’ orchestration) === +Target result: "Order placed" +Notifier result: "Sent approval to " + +=== 4. Multiple External Events === +Result: {"event1":"Hello","event2":"World"} + +=== All human-interaction demos completed successfully! === +``` + +## Smoke Test + +```bash +npm run example -- ./examples/azure-managed/human-interaction/index.ts 2>&1 | grep "All human-interaction demos completed successfully" +``` + +## Running Against Azure Managed DTS (Cloud) + +To run this sample against a real [Azure Managed Durable Task Scheduler](https://learn.microsoft.com/azure/durable-task-scheduler/) instead of the local emulator: + +1. **Create a scheduler and task hub** (if you haven't already) โ€” see the [parent README](../README.md#quick-start-azure-managed-dts--cloud) for `az durabletask` commands. + +2. **Configure `.env`** for your cloud endpoint: + + ```bash + cd examples/azure-managed + cp .env.example .env + # Edit .env with your scheduler endpoint and task hub name + ``` + + Example `.env`: + + ```env + DURABLE_TASK_SCHEDULER_CONNECTION_STRING=Endpoint=https://your-scheduler.eastus.durabletask.io;Authentication=DefaultAzure;TaskHub=your-taskhub + ``` + +3. **Authenticate** with Azure: + + ```bash + az login + ``` + +4. **Run** (no Docker needed): + + ```bash + npm run example -- ./examples/azure-managed/human-interaction/index.ts + ``` diff --git a/examples/azure-managed/human-interaction/index.ts b/examples/azure-managed/human-interaction/index.ts new file mode 100644 index 0000000..e224bb6 --- /dev/null +++ b/examples/azure-managed/human-interaction/index.ts @@ -0,0 +1,208 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// This sample demonstrates event-driven orchestration patterns: +// 1. External events โ€” wait for human approval via waitForExternalEvent +// 2. Timer-based timeout โ€” race approval against a deadline with whenAny +// 3. Custom status โ€” publish orchestration progress via setCustomStatus +// 4. sendEvent โ€” one orchestration sends an event to another +// 5. Multiple external events โ€” wait for several events in sequence + +import * as dotenv from "dotenv"; +import * as path from "path"; +dotenv.config({ path: path.join(__dirname, "..", ".env") }); + +import { + DurableTaskAzureManagedClientBuilder, + DurableTaskAzureManagedWorkerBuilder, + ConsoleLogger, +} from "@microsoft/durabletask-js-azuremanaged"; +import { + OrchestrationContext, + ActivityContext, + TOrchestrator, + whenAny, +} from "@microsoft/durabletask-js"; + +// --------------------------------------------------------------------------- +// Activities +// --------------------------------------------------------------------------- + +const submitRequest = async (_ctx: ActivityContext, request: { amount: number }): Promise => { + console.log(` [submitRequest] Purchase request submitted: $${request.amount}`); + return `REQ-${Date.now()}`; +}; + +const processApproval = async (_ctx: ActivityContext, data: { requestId: string; approved: boolean }): Promise => { + console.log(` [processApproval] Request ${data.requestId}: ${data.approved ? "APPROVED" : "REJECTED"}`); + return data.approved ? "Order placed" : "Order cancelled"; +}; + +const notifyTimeout = async (_ctx: ActivityContext, requestId: string): Promise => { + console.log(` [notifyTimeout] Request ${requestId} timed out โ€” auto-rejected`); + return "Timed out โ€” auto-rejected"; +}; + +// --------------------------------------------------------------------------- +// Orchestrators +// --------------------------------------------------------------------------- + +/** + * 1 & 2 & 3. Approval workflow with timeout and custom status. + * + * - Submits a purchase request + * - Sets custom status to "Awaiting approval" + * - Waits for an external "approval" event OR a 10-second timer + * - Uses whenAny to race the event against the timer + * - Updates custom status based on outcome + */ +const approvalOrchestrator: TOrchestrator = async function* (ctx: OrchestrationContext, amount: number): any { + // Step 1: Submit the request + const requestId: string = yield ctx.callActivity(submitRequest, { amount }); + ctx.setCustomStatus({ stage: "Awaiting approval", requestId }); + + // Step 2: Race external event vs timer + const approvalEvent = ctx.waitForExternalEvent<{ approved: boolean }>("approval"); + const timeout = ctx.createTimer(5); // 5 seconds + + const winner = yield whenAny([approvalEvent, timeout]); + + let result: string; + if (winner === approvalEvent) { + // Human responded in time + const decision = approvalEvent.getResult(); + ctx.setCustomStatus({ stage: "Processing", requestId, approved: decision.approved }); + result = yield ctx.callActivity(processApproval, { requestId, approved: decision.approved }); + } else { + // Timer fired first โ€” timed out + ctx.setCustomStatus({ stage: "Timed out", requestId }); + result = yield ctx.callActivity(notifyTimeout, requestId); + } + + ctx.setCustomStatus({ stage: "Completed", requestId }); + return result; +}; + +/** + * 4. sendEvent โ€” one orchestration sends an event to another. + * + * This "notifier" orchestration sends an approval event to a target + * orchestration identified by its instance ID (passed as input). + */ +const notifierOrchestrator: TOrchestrator = async function* ( + ctx: OrchestrationContext, + targetInstanceId: string, +): any { + // Wait a moment before sending (simulate some processing) + yield ctx.createTimer(1); + + // Send approval event to the target orchestration + ctx.sendEvent(targetInstanceId, "approval", { approved: true }); + + return `Sent approval to ${targetInstanceId}`; +}; + +/** + * 5. Multiple external events โ€” waits for two events in sequence. + */ +const multiEventOrchestrator: TOrchestrator = async function* (ctx: OrchestrationContext): any { + ctx.setCustomStatus("Waiting for event 1"); + const event1: string = yield ctx.waitForExternalEvent("event1"); + + ctx.setCustomStatus("Waiting for event 2"); + const event2: string = yield ctx.waitForExternalEvent("event2"); + + ctx.setCustomStatus("All events received"); + return { event1, event2 }; +}; + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +(async () => { + const logger = new ConsoleLogger(); + const connectionString = + process.env.DURABLE_TASK_SCHEDULER_CONNECTION_STRING || + "Endpoint=http://localhost:8080;Authentication=None;TaskHub=default"; + + const client = new DurableTaskAzureManagedClientBuilder() + .connectionString(connectionString) + .logger(logger) + .build(); + + const worker = new DurableTaskAzureManagedWorkerBuilder() + .connectionString(connectionString) + .logger(logger) + .addOrchestrator(approvalOrchestrator) + .addOrchestrator(notifierOrchestrator) + .addOrchestrator(multiEventOrchestrator) + .addActivity(submitRequest) + .addActivity(processApproval) + .addActivity(notifyTimeout) + .build(); + + try { + await worker.start(); + await new Promise((r) => setTimeout(r, 2000)); + + // --- 1. Approval with client-raised event (approved) --- + console.log("\n=== 1. Approval Workflow (client sends approval) ==="); + const approvalId = await client.scheduleNewOrchestration(approvalOrchestrator, 500); + console.log(`Orchestration started: ${approvalId}`); + + // Wait for it to reach "Awaiting approval", then send approval from client + await new Promise((r) => setTimeout(r, 3000)); + const midState = await client.getOrchestrationState(approvalId, true); + console.log(`Custom status: ${midState?.serializedCustomStatus}`); + + await client.raiseOrchestrationEvent(approvalId, "approval", { approved: true }); + console.log("Sent approval event from client"); + + const finalState = await client.waitForOrchestrationCompletion(approvalId, true, 60); + console.log(`Result: ${finalState?.serializedOutput}`); + console.log(`Final custom status: ${finalState?.serializedCustomStatus}`); + + // --- 2. sendEvent (orchestration-to-orchestration) --- + console.log("\n=== 2. sendEvent (orchestration โ†’ orchestration) ==="); + const targetId = await client.scheduleNewOrchestration(approvalOrchestrator, 250); + console.log(`Target orchestration: ${targetId}`); + + // Start a notifier that will send the approval event + const notifierId = await client.scheduleNewOrchestration(notifierOrchestrator, targetId); + console.log(`Notifier orchestration: ${notifierId}`); + + const targetState = await client.waitForOrchestrationCompletion(targetId, true, 60); + console.log(`Target result: ${targetState?.serializedOutput}`); + + const notifierState = await client.waitForOrchestrationCompletion(notifierId, true, 60); + console.log(`Notifier result: ${notifierState?.serializedOutput}`); + + // --- 3. Multiple external events --- + console.log("\n=== 3. Multiple External Events ==="); + const multiId = await client.scheduleNewOrchestration(multiEventOrchestrator); + await new Promise((r) => setTimeout(r, 2000)); + + await client.raiseOrchestrationEvent(multiId, "event1", "Hello"); + await client.raiseOrchestrationEvent(multiId, "event2", "World"); + + const multiState = await client.waitForOrchestrationCompletion(multiId, true, 60); + console.log(`Result: ${multiState?.serializedOutput}`); + + // --- 4. Approval with timeout (no event sent โ€” timer wins the race) --- + console.log("\n=== 4. Approval Workflow (timeout โ€” no event sent) ==="); + const timeoutId = await client.scheduleNewOrchestration(approvalOrchestrator, 1000); + // Don't send any event โ€” let the 5-second timer expire and auto-reject + const timeoutState = await client.waitForOrchestrationCompletion(timeoutId, true, 60); + console.log(`Result: ${timeoutState?.serializedOutput}`); + + console.log("\n=== All human-interaction demos completed successfully! ==="); + } catch (error) { + console.error("Error:", error); + process.exit(1); + } finally { + await worker.stop(); + await client.stop(); + process.exit(0); + } +})(); diff --git a/examples/azure-managed/human-interaction/sample.json b/examples/azure-managed/human-interaction/sample.json new file mode 100644 index 0000000..091a9f4 --- /dev/null +++ b/examples/azure-managed/human-interaction/sample.json @@ -0,0 +1,5 @@ +{ + "name": "human-interaction", + "description": "External events, timers, sendEvent (orchestration-to-orchestration), custom status, and approval patterns", + "requiresEmulator": true +} diff --git a/examples/azure-managed/lifecycle-management/README.md b/examples/azure-managed/lifecycle-management/README.md new file mode 100644 index 0000000..e69fceb --- /dev/null +++ b/examples/azure-managed/lifecycle-management/README.md @@ -0,0 +1,110 @@ +# Lifecycle Management + +Demonstrates all orchestration lifecycle operations: terminate, suspend/resume, restart, continue-as-new, purge, and tags. + +## Features Covered + +| Feature | API | +|---------|-----| +| Terminate | `client.terminateOrchestration()` | +| Terminate with output | `terminateOptions({ output })` | +| Recursive terminate | `terminateOptions({ recursive: true })` | +| Suspend | `client.suspendOrchestration()` | +| Resume | `client.resumeOrchestration()` | +| Continue-as-new | `ctx.continueAsNew()` | +| Restart | `client.restartOrchestration()` | +| Purge | `client.purgeOrchestration()` | +| Tags | `scheduleNewOrchestration(..., { tags })` | + +## Prerequisites + +- Node.js โ‰ฅ 22 +- Docker (for the DTS Emulator) + +## Setup + +```bash +cd examples/azure-managed +docker compose up -d +cp .env.emulator .env +cd ../.. +npm install && npm run build +``` + +## Run + +```bash +npm run example -- ./examples/azure-managed/lifecycle-management/index.ts +``` + +## Expected Output + +``` +=== 1. Terminate (with output) === +Status: TERMINATED +Output: "Cancelled by admin" + +=== 2. Terminate (recursive โ€” parent + child) === +Parent status: TERMINATED + +=== 3. Suspend / Resume === +After suspend: SUSPENDED +After resume: RUNNING +Final result: "Completed normally" + +=== 4. Continue-as-new === +Status: COMPLETED +Result: {"status":"all batches done","batchNum":3,"processed":9} + +=== 5. Restart Orchestration === +Restarted as new ID: +Result: "Done: original-run" + +=== 6. Purge Orchestration === +Purged instances: 1 +State after purge: undefined (deleted) + +=== 7. Orchestration Tags === +Tags: {"environment":"staging","owner":"demo-user","priority":"high"} +Result: "Done: tagged-run" + +=== All lifecycle demos completed successfully! === +``` + +## Smoke Test + +```bash +npm run example -- ./examples/azure-managed/lifecycle-management/index.ts 2>&1 | grep "All lifecycle demos completed successfully" +``` + +## Running Against Azure Managed DTS (Cloud) + +To run this sample against a real [Azure Managed Durable Task Scheduler](https://learn.microsoft.com/azure/durable-task-scheduler/) instead of the local emulator: + +1. **Create a scheduler and task hub** (if you haven't already) โ€” see the [parent README](../README.md#quick-start-azure-managed-dts--cloud) for `az durabletask` commands. + +2. **Configure `.env`** for your cloud endpoint: + + ```bash + cd examples/azure-managed + cp .env.example .env + # Edit .env with your scheduler endpoint and task hub name + ``` + + Example `.env`: + + ```env + DURABLE_TASK_SCHEDULER_CONNECTION_STRING=Endpoint=https://your-scheduler.eastus.durabletask.io;Authentication=DefaultAzure;TaskHub=your-taskhub + ``` + +3. **Authenticate** with Azure: + + ```bash + az login + ``` + +4. **Run** (no Docker needed): + + ```bash + npm run example -- ./examples/azure-managed/lifecycle-management/index.ts + ``` diff --git a/examples/azure-managed/lifecycle-management/index.ts b/examples/azure-managed/lifecycle-management/index.ts new file mode 100644 index 0000000..6001257 --- /dev/null +++ b/examples/azure-managed/lifecycle-management/index.ts @@ -0,0 +1,180 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// This sample demonstrates orchestration lifecycle management: +// 1. Terminate โ€” cancel a running orchestration (with output) +// 2. Suspend / Resume โ€” pause and unpause an orchestration +// 3. Continue-as-new โ€” restart an orchestration with new input +// 4. Restart โ€” re-run a completed orchestration +// 5. Purge โ€” delete orchestration history +// 6. Tags โ€” attach metadata to orchestrations + +import * as dotenv from "dotenv"; +import * as path from "path"; +dotenv.config({ path: path.join(__dirname, "..", ".env") }); + +import { + DurableTaskAzureManagedClientBuilder, + DurableTaskAzureManagedWorkerBuilder, + ConsoleLogger, +} from "@microsoft/durabletask-js-azuremanaged"; +import { + OrchestrationContext, + ActivityContext, + TOrchestrator, + OrchestrationStatus, + terminateOptions, +} from "@microsoft/durabletask-js"; + +// --------------------------------------------------------------------------- +// Activities +// --------------------------------------------------------------------------- + +const doWork = async (_ctx: ActivityContext, label: string): Promise => { + console.log(` [doWork] Processing: ${label}`); + return `Done: ${label}`; +}; + +// --------------------------------------------------------------------------- +// Orchestrators +// --------------------------------------------------------------------------- + +/** Long-running orchestration (waits for an event, useful for terminate/suspend demos). */ +const longRunning: TOrchestrator = async function* (ctx: OrchestrationContext): any { + yield ctx.callActivity(doWork, "step-1"); + // Wait for an event that may never come โ€” keeps orchestration alive + const signal: string = yield ctx.waitForExternalEvent("proceed"); + yield ctx.callActivity(doWork, `step-2-${signal}`); + return "Completed normally"; +}; + +/** + * Continue-as-new orchestration โ€” processes a batch, then restarts with new input. + * Stops after processing 3 batches total. + */ +const batchProcessor: TOrchestrator = async function* ( + ctx: OrchestrationContext, + state: { batchNum: number; processed: number }, +): any { + const batchSize = 3; + const maxBatches = 3; + + yield ctx.callActivity(doWork, `batch-${state.batchNum}`); + const newState = { batchNum: state.batchNum + 1, processed: state.processed + batchSize }; + + if (newState.batchNum >= maxBatches) { + return { status: "all batches done", ...newState }; + } + + // Continue-as-new: restart with updated state (no history accumulation) + ctx.continueAsNew(newState); +}; + +/** Simple orchestration for restart/purge demos. */ +const simpleOrchestrator: TOrchestrator = async function* (ctx: OrchestrationContext, input: string): any { + const result: string = yield ctx.callActivity(doWork, input); + return result; +}; + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +(async () => { + const logger = new ConsoleLogger(); + const connectionString = + process.env.DURABLE_TASK_SCHEDULER_CONNECTION_STRING || + "Endpoint=http://localhost:8080;Authentication=None;TaskHub=default"; + + const client = new DurableTaskAzureManagedClientBuilder() + .connectionString(connectionString) + .logger(logger) + .build(); + + const worker = new DurableTaskAzureManagedWorkerBuilder() + .connectionString(connectionString) + .logger(logger) + .addOrchestrator(longRunning) + .addOrchestrator(batchProcessor) + .addOrchestrator(simpleOrchestrator) + .addActivity(doWork) + .build(); + + try { + await worker.start(); + await new Promise((r) => setTimeout(r, 2000)); + + // --- 1. Terminate with output --- + console.log("\n=== 1. Terminate (with output) ==="); + const termId = await client.scheduleNewOrchestration(longRunning); + await client.waitForOrchestrationStart(termId); + await client.terminateOrchestration(termId, terminateOptions({ output: "Cancelled by admin" })); + const termState = await client.waitForOrchestrationCompletion(termId, true, 15); + console.log(`Status: ${OrchestrationStatus[termState!.runtimeStatus]}`); + console.log(`Output: ${termState?.serializedOutput}`); + + // --- 2. Suspend / Resume --- + console.log("\n=== 2. Suspend / Resume ==="); + const suspId = await client.scheduleNewOrchestration(longRunning); + await client.waitForOrchestrationStart(suspId); + + await client.suspendOrchestration(suspId); + let suspState = await client.getOrchestrationState(suspId); + console.log(`After suspend: ${OrchestrationStatus[suspState!.runtimeStatus]}`); + + await client.resumeOrchestration(suspId); + suspState = await client.getOrchestrationState(suspId); + console.log(`After resume: ${OrchestrationStatus[suspState!.runtimeStatus]}`); + + // Send event so it completes, then clean up + await client.raiseOrchestrationEvent(suspId, "proceed", "resumed"); + const suspFinal = await client.waitForOrchestrationCompletion(suspId, true, 15); + console.log(`Final result: ${suspFinal?.serializedOutput}`); + + // --- 3. Continue-as-new --- + console.log("\n=== 3. Continue-as-new ==="); + const canId = await client.scheduleNewOrchestration(batchProcessor, { batchNum: 0, processed: 0 }); + const canState = await client.waitForOrchestrationCompletion(canId, true, 30); + console.log(`Status: ${OrchestrationStatus[canState!.runtimeStatus]}`); + console.log(`Result: ${canState?.serializedOutput}`); + + // --- 4. Restart --- + console.log("\n=== 4. Restart Orchestration ==="); + const origId = await client.scheduleNewOrchestration(simpleOrchestrator, "original-run"); + await client.waitForOrchestrationCompletion(origId, true, 15); + console.log(`Original completed: ${origId}`); + + const restartedId = await client.restartOrchestration(origId, true); // new instance ID + const restartState = await client.waitForOrchestrationCompletion(restartedId, true, 15); + console.log(`Restarted as new ID: ${restartedId}`); + console.log(`Result: ${restartState?.serializedOutput}`); + + // --- 5. Purge --- + console.log("\n=== 5. Purge Orchestration ==="); + const purgeId = await client.scheduleNewOrchestration(simpleOrchestrator, "to-be-purged"); + await client.waitForOrchestrationCompletion(purgeId, true, 15); + + const purgeResult = await client.purgeOrchestration(purgeId); + console.log(`Purged instances: ${purgeResult?.deletedInstanceCount}`); + + const purgedState = await client.getOrchestrationState(purgeId); + console.log(`State after purge: ${purgedState ?? "undefined (deleted)"}`); + + // --- 6. Tags --- + console.log("\n=== 6. Orchestration Tags ==="); + const tags = { environment: "staging", owner: "demo-user", priority: "high" }; + const tagId = await client.scheduleNewOrchestration(simpleOrchestrator, "tagged-run", { tags }); + const tagState = await client.waitForOrchestrationCompletion(tagId, true, 15); + console.log(`Tags: ${JSON.stringify(tagState?.tags)}`); + console.log(`Result: ${tagState?.serializedOutput}`); + + console.log("\n=== All lifecycle demos completed successfully! ==="); + } catch (error) { + console.error("Error:", error); + process.exit(1); + } finally { + await worker.stop(); + await client.stop(); + process.exit(0); + } +})(); diff --git a/examples/azure-managed/lifecycle-management/sample.json b/examples/azure-managed/lifecycle-management/sample.json new file mode 100644 index 0000000..49b7b1b --- /dev/null +++ b/examples/azure-managed/lifecycle-management/sample.json @@ -0,0 +1,5 @@ +{ + "name": "lifecycle-management", + "description": "Terminate, suspend/resume, restart, continue-as-new, purge, and orchestration tags", + "requiresEmulator": true +} diff --git a/examples/azure-managed/query-and-history/README.md b/examples/azure-managed/query-and-history/README.md new file mode 100644 index 0000000..90a7c1e --- /dev/null +++ b/examples/azure-managed/query-and-history/README.md @@ -0,0 +1,106 @@ +# Query and History + +Demonstrates the query and history inspection APIs for monitoring and debugging orchestrations. + +## Features Covered + +| Feature | API | +|---------|-----| +| Query instances | `client.getAllInstances(query)` | +| Page-by-page iteration | `pageable.asPages()` | +| Item-by-item iteration | `for await (const item of pageable)` | +| Query filters | `OrchestrationQuery` (status, time range, pageSize) | +| List instance IDs | `client.listInstanceIds()` | +| Cursor pagination | `continuationToken` / `lastInstanceKey` | +| Fetch history | `client.getOrchestrationHistory()` | +| Typed history events | `HistoryEventType`, typed event interfaces | + +## Prerequisites + +- Node.js โ‰ฅ 22 +- Docker (for the DTS Emulator) + +## Setup + +```bash +cd examples/azure-managed +docker compose up -d +cp .env.emulator .env +cd ../.. +npm install && npm run build +``` + +## Run + +```bash +npm run example -- ./examples/azure-managed/query-and-history/index.ts +``` + +## Expected Output + +``` +Creating orchestration instances... +Created 6 orchestration instances. + +=== 1. getAllInstances (query with filters) === + Page 1: 3 instances (hasMore: true) + - [simpleOrchestrator] = "Done: item-1" + ... + Total instances across pages: 6 + +=== 2. listInstanceIds (cursor pagination) === + Page 1: 3 IDs (hasMore: true) + Page 2: 3 IDs (hasMore: ...) + +=== 3. getOrchestrationHistory === + History for : N events + [0] ExecutionStarted @ ... + [1] TimerCreated @ ... + ... + +=== 4. Typed History Event Inspection === + ExecutionStarted: name=richOrchestrator, input="World" + TaskScheduled events: 1 + TaskCompleted events: 1 + TimerCreated events: 1 + +=== All query/history demos completed successfully! === +``` + +## Smoke Test + +```bash +npm run example -- ./examples/azure-managed/query-and-history/index.ts 2>&1 | grep "All query/history demos completed successfully" +``` + +## Running Against Azure Managed DTS (Cloud) + +To run this sample against a real [Azure Managed Durable Task Scheduler](https://learn.microsoft.com/azure/durable-task-scheduler/) instead of the local emulator: + +1. **Create a scheduler and task hub** (if you haven't already) โ€” see the [parent README](../README.md#quick-start-azure-managed-dts--cloud) for `az durabletask` commands. + +2. **Configure `.env`** for your cloud endpoint: + + ```bash + cd examples/azure-managed + cp .env.example .env + # Edit .env with your scheduler endpoint and task hub name + ``` + + Example `.env`: + + ```env + DURABLE_TASK_SCHEDULER_CONNECTION_STRING=Endpoint=https://your-scheduler.eastus.durabletask.io;Authentication=DefaultAzure;TaskHub=your-taskhub + ``` + +3. **Authenticate** with Azure: + + ```bash + az login + ``` + +4. **Run** (no Docker needed): + + ```bash + npm run example -- ./examples/azure-managed/query-and-history/index.ts + ``` diff --git a/examples/azure-managed/query-and-history/index.ts b/examples/azure-managed/query-and-history/index.ts new file mode 100644 index 0000000..0cb13b3 --- /dev/null +++ b/examples/azure-managed/query-and-history/index.ts @@ -0,0 +1,208 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// This sample demonstrates the query and history inspection APIs: +// 1. getAllInstances โ€” query orchestration instances with filters and pagination +// 2. listInstanceIds โ€” list instance IDs with cursor-based pagination +// 3. getOrchestrationHistory โ€” retrieve detailed history events +// 4. Typed history events โ€” inspect individual event types + +import * as dotenv from "dotenv"; +import * as path from "path"; +dotenv.config({ path: path.join(__dirname, "..", ".env") }); + +import { + DurableTaskAzureManagedClientBuilder, + DurableTaskAzureManagedWorkerBuilder, + ConsoleLogger, +} from "@microsoft/durabletask-js-azuremanaged"; +import { + OrchestrationContext, + ActivityContext, + TOrchestrator, + OrchestrationStatus, + HistoryEventType, +} from "@microsoft/durabletask-js"; +import type { + OrchestrationQuery, + HistoryEvent, + ExecutionStartedEvent, + TaskScheduledEvent, + TaskCompletedEvent, + TimerCreatedEvent, +} from "@microsoft/durabletask-js"; + +// --------------------------------------------------------------------------- +// Activities & Orchestrators +// --------------------------------------------------------------------------- + +const greet = async (_ctx: ActivityContext, name: string): Promise => { + return `Hello, ${name}!`; +}; + +/** Creates a timer and calls an activity โ€” produces rich history. */ +const richOrchestrator: TOrchestrator = async function* (ctx: OrchestrationContext, name: string): any { + // Create a short timer (createTimer takes seconds, not milliseconds) + yield ctx.createTimer(1); + + // Call an activity + const greeting: string = yield ctx.callActivity(greet, name); + + return greeting; +}; + +/** Simple orchestrator used to create multiple instances for querying. */ +const simpleOrchestrator: TOrchestrator = async function* (ctx: OrchestrationContext, label: string): any { + yield ctx.callActivity(greet, label); + return `Done: ${label}`; +}; + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +(async () => { + const logger = new ConsoleLogger(); + const connectionString = + process.env.DURABLE_TASK_SCHEDULER_CONNECTION_STRING || + "Endpoint=http://localhost:8080;Authentication=None;TaskHub=default"; + + const client = new DurableTaskAzureManagedClientBuilder() + .connectionString(connectionString) + .logger(logger) + .build(); + + const worker = new DurableTaskAzureManagedWorkerBuilder() + .connectionString(connectionString) + .logger(logger) + .addOrchestrator(richOrchestrator) + .addOrchestrator(simpleOrchestrator) + .addActivity(greet) + .build(); + + try { + await worker.start(); + await new Promise((r) => setTimeout(r, 2000)); + + // Create several orchestration instances for querying + console.log("Creating orchestration instances..."); + const instanceIds: string[] = []; + + for (let i = 1; i <= 5; i++) { + const id = await client.scheduleNewOrchestration(simpleOrchestrator, `item-${i}`); + instanceIds.push(id); + } + + // Also create the rich orchestrator for history inspection + const richId = await client.scheduleNewOrchestration(richOrchestrator, "World"); + + // Wait for all to complete + for (const id of [...instanceIds, richId]) { + await client.waitForOrchestrationCompletion(id, false, 30); + } + console.log(`Created ${instanceIds.length + 1} orchestration instances.`); + + // --- 1. getAllInstances with filters --- + console.log("\n=== 1. getAllInstances (query with filters) ==="); + + // Query completed instances + const query: OrchestrationQuery = { + statuses: [OrchestrationStatus.COMPLETED], + fetchInputsAndOutputs: true, + pageSize: 3, // small page size to demonstrate pagination + }; + + const pageable = client.getAllInstances(query); + + // Iterate page by page + let pageNum = 0; + let totalInstances = 0; + for await (const page of pageable.asPages()) { + pageNum++; + console.log(` Page ${pageNum}: ${page.values.length} instances (hasMore: ${page.hasMoreResults})`); + for (const instance of page.values) { + totalInstances++; + console.log(` - ${instance.instanceId} [${instance.name}] = ${instance.serializedOutput?.substring(0, 50)}`); + } + if (pageNum >= 3) break; // safety limit + } + console.log(`Total instances across pages: ${totalInstances}`); + + // Iterate item by item + console.log("\n Item-by-item iteration (first 5):"); + const pageable2 = client.getAllInstances({ statuses: [OrchestrationStatus.COMPLETED], pageSize: 10 }); + let itemCount = 0; + for await (const instance of pageable2) { + itemCount++; + console.log(` ${itemCount}. ${instance.instanceId} [${instance.name}]`); + if (itemCount >= 5) break; + } + + // --- 2. listInstanceIds --- + console.log("\n=== 2. listInstanceIds (cursor pagination) ==="); + const page1 = await client.listInstanceIds({ pageSize: 3 }); + console.log(` Page 1: ${page1.values.length} IDs (hasMore: ${page1.hasMoreResults})`); + for (const id of page1.values) { + console.log(` - ${id}`); + } + + if (page1.hasMoreResults && page1.continuationToken) { + const page2 = await client.listInstanceIds({ + pageSize: 3, + lastInstanceKey: page1.continuationToken, + }); + console.log(` Page 2: ${page2.values.length} IDs (hasMore: ${page2.hasMoreResults})`); + for (const id of page2.values) { + console.log(` - ${id}`); + } + } + + // --- 3. getOrchestrationHistory --- + console.log("\n=== 3. getOrchestrationHistory ==="); + const history: HistoryEvent[] = await client.getOrchestrationHistory(richId); + console.log(` History for ${richId}: ${history.length} events`); + for (const event of history) { + console.log(` [${event.eventId}] ${event.type} @ ${event.timestamp?.toISOString()}`); + } + + // --- 4. Typed history events --- + console.log("\n=== 4. Typed History Event Inspection ==="); + + const executionStarted = history.find( + (e) => e.type === HistoryEventType.ExecutionStarted, + ) as ExecutionStartedEvent | undefined; + if (executionStarted) { + console.log(` ExecutionStarted: name=${executionStarted.name}, input=${executionStarted.input}`); + } + + const taskScheduled = history.filter( + (e) => e.type === HistoryEventType.TaskScheduled, + ) as TaskScheduledEvent[]; + console.log(` TaskScheduled events: ${taskScheduled.length}`); + for (const ts of taskScheduled) { + console.log(` - name=${ts.name}, input=${ts.input}`); + } + + const taskCompleted = history.filter( + (e) => e.type === HistoryEventType.TaskCompleted, + ) as TaskCompletedEvent[]; + console.log(` TaskCompleted events: ${taskCompleted.length}`); + + const timerCreated = history.filter( + (e) => e.type === HistoryEventType.TimerCreated, + ) as TimerCreatedEvent[]; + console.log(` TimerCreated events: ${timerCreated.length}`); + for (const tc of timerCreated) { + console.log(` - fireAt=${tc.fireAt?.toISOString()}`); + } + + console.log("\n=== All query/history demos completed successfully! ==="); + } catch (error) { + console.error("Error:", error); + process.exit(1); + } finally { + await worker.stop(); + await client.stop(); + process.exit(0); + } +})(); diff --git a/examples/azure-managed/query-and-history/sample.json b/examples/azure-managed/query-and-history/sample.json new file mode 100644 index 0000000..a839514 --- /dev/null +++ b/examples/azure-managed/query-and-history/sample.json @@ -0,0 +1,5 @@ +{ + "name": "query-and-history", + "description": "Query orchestration instances, pagination, filters, and history event inspection", + "requiresEmulator": true +} diff --git a/examples/azure-managed/retry-and-error-handling/README.md b/examples/azure-managed/retry-and-error-handling/README.md new file mode 100644 index 0000000..5c78c97 --- /dev/null +++ b/examples/azure-managed/retry-and-error-handling/README.md @@ -0,0 +1,100 @@ +# Retry and Error Handling + +Demonstrates every retry mechanism and error handling pattern available in the Durable Task SDK. + +## Features Covered + +| Feature | API | +|---------|-----| +| Declarative retry | `RetryPolicy` with exponential backoff | +| Failure predicate | `RetryPolicy.handleFailure` | +| Custom retry handler | `AsyncRetryHandler` with custom delays | +| Sub-orchestration retry | `callSubOrchestrator` with retry options | +| Error inspection | `state.raiseIfFailed()`, `state.failureDetails` | + +## Prerequisites + +- Node.js โ‰ฅ 22 +- Docker (for the DTS Emulator) + +## Setup + +```bash +cd examples/azure-managed +docker compose up -d +cp .env.emulator .env +cd ../.. +npm install && npm run build +``` + +## Run + +```bash +npm run example -- ./examples/azure-managed/retry-and-error-handling/index.ts +``` + +## Expected Output + +``` +=== 1. RetryPolicy (exponential backoff) === +Status: COMPLETED +Result: "Success on attempt 3 for \"retry-policy-...\"" + +=== 2. handleFailure Predicate === +Status: COMPLETED +Result: "Success on attempt 3 for \"handle-failure-...\"" + +=== 3. Custom AsyncRetryHandler === +Status: COMPLETED +Result: "Success on attempt 3 for \"custom-handler-...\"" + +=== 4. Sub-orchestration Retry === +Status: COMPLETED +Result: {"sum":15} + +=== 5. Error Handling (raiseIfFailed) === +Status: FAILED +raiseIfFailed() threw: OrchestrationFailedError โ€” ... +Failure type: Error +Failure message: FatalError: This operation cannot succeed + +=== All retry/error demos completed successfully! === +``` + +## Smoke Test + +```bash +npm run example -- ./examples/azure-managed/retry-and-error-handling/index.ts 2>&1 | grep "All retry/error demos completed successfully" +``` + +## Running Against Azure Managed DTS (Cloud) + +To run this sample against a real [Azure Managed Durable Task Scheduler](https://learn.microsoft.com/azure/durable-task-scheduler/) instead of the local emulator: + +1. **Create a scheduler and task hub** (if you haven't already) โ€” see the [parent README](../README.md#quick-start-azure-managed-dts--cloud) for `az durabletask` commands. + +2. **Configure `.env`** for your cloud endpoint: + + ```bash + cd examples/azure-managed + cp .env.example .env + # Edit .env with your scheduler endpoint and task hub name + ``` + + Example `.env`: + + ```env + DURABLE_TASK_SCHEDULER_CONNECTION_STRING=Endpoint=https://your-scheduler.eastus.durabletask.io;Authentication=DefaultAzure;TaskHub=your-taskhub + ``` + +3. **Authenticate** with Azure: + + ```bash + az login + ``` + +4. **Run** (no Docker needed): + + ```bash + npm run example -- ./examples/azure-managed/retry-and-error-handling/index.ts + ``` diff --git a/examples/azure-managed/retry-and-error-handling/index.ts b/examples/azure-managed/retry-and-error-handling/index.ts new file mode 100644 index 0000000..078568c --- /dev/null +++ b/examples/azure-managed/retry-and-error-handling/index.ts @@ -0,0 +1,252 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// This sample demonstrates retry and error handling in the Durable Task SDK: +// 1. RetryPolicy โ€” declarative retry with exponential backoff +// 2. handleFailure predicate โ€” selectively retry based on error type +// 3. RetryHandler โ€” imperative retry logic with custom control +// 4. Sub-orchestration retry โ€” retry an entire sub-orchestration +// 5. raiseIfFailed() โ€” convenient error checking on orchestration state + +import * as dotenv from "dotenv"; +import * as path from "path"; +dotenv.config({ path: path.join(__dirname, "..", ".env") }); + +import { + DurableTaskAzureManagedClientBuilder, + DurableTaskAzureManagedWorkerBuilder, + ConsoleLogger, +} from "@microsoft/durabletask-js-azuremanaged"; +import { + OrchestrationContext, + ActivityContext, + TOrchestrator, + RetryPolicy, + OrchestrationStatus, +} from "@microsoft/durabletask-js"; +import type { RetryHandler, RetryContext } from "@microsoft/durabletask-js"; + +// --------------------------------------------------------------------------- +// Activities +// --------------------------------------------------------------------------- + +// Track call counts across retries (per activity name + instance) +const callCounts = new Map(); + +/** Activity that fails the first N-1 times, then succeeds on the Nth call. */ +const unreliableActivity = async (_ctx: ActivityContext, input: { key: string; failCount: number }): Promise => { + const count = (callCounts.get(input.key) ?? 0) + 1; + callCounts.set(input.key, count); + + if (count <= input.failCount) { + throw new Error(`TransientError: attempt ${count}/${input.failCount + 1} for "${input.key}"`); + } + + return `Success on attempt ${count} for "${input.key}"`; +}; + +/** Activity that always fails with a specific error type. */ +const permanentFailure = async (_ctx: ActivityContext, errorType: string): Promise => { + throw new Error(`${errorType}: This operation cannot succeed`); +}; + +/** Simple activity for sub-orchestration retry demo. */ +const addNumbers = async (_ctx: ActivityContext, nums: number[]): Promise => { + return nums.reduce((a, b) => a + b, 0); +}; + +// --------------------------------------------------------------------------- +// Orchestrators +// --------------------------------------------------------------------------- + +/** + * 1. Declarative RetryPolicy with exponential backoff. + * The activity fails twice then succeeds on the 3rd attempt. + */ +const retryPolicyOrchestrator: TOrchestrator = async function* (ctx: OrchestrationContext): any { + const retryPolicy = new RetryPolicy({ + maxNumberOfAttempts: 5, + firstRetryIntervalInMilliseconds: 500, + backoffCoefficient: 2.0, + maxRetryIntervalInMilliseconds: 5000, + }); + + const result: string = yield ctx.callActivity( + unreliableActivity, + { key: `retry-policy-${ctx.instanceId}`, failCount: 2 }, + { retry: retryPolicy }, + ); + + return result; +}; + +/** + * 2. RetryPolicy with handleFailure predicate. + * Only retries TransientError; stops immediately on PermanentError. + */ +const handleFailureOrchestrator: TOrchestrator = async function* (ctx: OrchestrationContext): any { + const retryPolicy = new RetryPolicy({ + maxNumberOfAttempts: 5, + firstRetryIntervalInMilliseconds: 200, + handleFailure: (failure) => { + // Only retry if the error message contains "TransientError" + return failure.message?.includes("TransientError") ?? false; + }, + }); + + // This should succeed โ€” fails twice with TransientError then succeeds + const result: string = yield ctx.callActivity( + unreliableActivity, + { key: `handle-failure-${ctx.instanceId}`, failCount: 2 }, + { retry: retryPolicy }, + ); + + return result; +}; + +/** + * 3. RetryHandler โ€” custom imperative retry logic. + * Uses a sync handler that retries up to maxAttempts times. + * Returns true for immediate retry, or false to stop. + */ +const customRetryHandlerOrchestrator: TOrchestrator = async function* (ctx: OrchestrationContext): any { + const maxAttempts = 4; + + const customRetryHandler: RetryHandler = (retryCtx: RetryContext) => { + if (retryCtx.lastAttemptNumber >= maxAttempts) { + return false; // give up + } + // Only retry transient errors + if (!retryCtx.lastFailure.message?.includes("TransientError")) { + return false; + } + return true; // retry immediately + }; + + const result: string = yield ctx.callActivity( + unreliableActivity, + { key: `custom-handler-${ctx.instanceId}`, failCount: 2 }, + { retry: customRetryHandler }, + ); + + return result; +}; + +/** + * 4. Sub-orchestration with retry โ€” retries the entire child orchestration. + */ +const childOrchestrator: TOrchestrator = async function* (ctx: OrchestrationContext, input: number[]): any { + const sum: number = yield ctx.callActivity(addNumbers, input); + return sum; +}; + +const parentWithRetryOrchestrator: TOrchestrator = async function* (ctx: OrchestrationContext): any { + const retryPolicy = new RetryPolicy({ + maxNumberOfAttempts: 3, + firstRetryIntervalInMilliseconds: 200, + }); + + const result: number = yield ctx.callSubOrchestrator( + childOrchestrator, + [1, 2, 3, 4, 5], + { retry: retryPolicy }, + ); + + return { sum: result }; +}; + +/** + * 5. Error handling โ€” demonstrates raiseIfFailed() and failed state inspection. + */ +const alwaysFailOrchestrator: TOrchestrator = async function* (ctx: OrchestrationContext): any { + yield ctx.callActivity(permanentFailure, "FatalError"); + return "unreachable"; +}; + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +(async () => { + const logger = new ConsoleLogger(); + const connectionString = + process.env.DURABLE_TASK_SCHEDULER_CONNECTION_STRING || + "Endpoint=http://localhost:8080;Authentication=None;TaskHub=default"; + + const client = new DurableTaskAzureManagedClientBuilder() + .connectionString(connectionString) + .logger(logger) + .build(); + + const worker = new DurableTaskAzureManagedWorkerBuilder() + .connectionString(connectionString) + .logger(logger) + .addOrchestrator(retryPolicyOrchestrator) + .addOrchestrator(handleFailureOrchestrator) + .addOrchestrator(customRetryHandlerOrchestrator) + .addOrchestrator(childOrchestrator) + .addOrchestrator(parentWithRetryOrchestrator) + .addOrchestrator(alwaysFailOrchestrator) + .addActivity(unreliableActivity) + .addActivity(permanentFailure) + .addActivity(addNumbers) + .build(); + + try { + await worker.start(); + await new Promise((r) => setTimeout(r, 2000)); + + // --- 1. RetryPolicy with exponential backoff --- + console.log("\n=== 1. RetryPolicy (exponential backoff) ==="); + const id1 = await client.scheduleNewOrchestration(retryPolicyOrchestrator); + const state1 = await client.waitForOrchestrationCompletion(id1, true, 60); + console.log(`Status: ${OrchestrationStatus[state1!.runtimeStatus]}`); + console.log(`Result: ${state1?.serializedOutput}`); + + // --- 2. handleFailure predicate --- + console.log("\n=== 2. handleFailure Predicate ==="); + const id2 = await client.scheduleNewOrchestration(handleFailureOrchestrator); + const state2 = await client.waitForOrchestrationCompletion(id2, true, 60); + console.log(`Status: ${OrchestrationStatus[state2!.runtimeStatus]}`); + console.log(`Result: ${state2?.serializedOutput}`); + + // --- 3. Custom RetryHandler --- + console.log("\n=== 3. Custom RetryHandler ==="); + const id3 = await client.scheduleNewOrchestration(customRetryHandlerOrchestrator); + const state3 = await client.waitForOrchestrationCompletion(id3, true, 60); + console.log(`Status: ${OrchestrationStatus[state3!.runtimeStatus]}`); + console.log(`Result: ${state3?.serializedOutput}`); + + // --- 4. Sub-orchestration with retry --- + console.log("\n=== 4. Sub-orchestration Retry ==="); + const id4 = await client.scheduleNewOrchestration(parentWithRetryOrchestrator); + const state4 = await client.waitForOrchestrationCompletion(id4, true, 60); + console.log(`Status: ${OrchestrationStatus[state4!.runtimeStatus]}`); + console.log(`Result: ${state4?.serializedOutput}`); + + // --- 5. Error handling with raiseIfFailed() --- + console.log("\n=== 5. Error Handling (raiseIfFailed) ==="); + const id5 = await client.scheduleNewOrchestration(alwaysFailOrchestrator); + const state5 = await client.waitForOrchestrationCompletion(id5, true, 60); + console.log(`Status: ${OrchestrationStatus[state5!.runtimeStatus]}`); + try { + state5?.raiseIfFailed(); + console.log("ERROR: raiseIfFailed() should have thrown!"); + } catch (e: any) { + console.log(`raiseIfFailed() threw: ${e.constructor.name} โ€” ${e.message.substring(0, 80)}`); + } + if (state5?.failureDetails) { + console.log(`Failure type: ${state5.failureDetails.errorType}`); + console.log(`Failure message: ${state5.failureDetails.message?.substring(0, 80)}`); + } + + console.log("\n=== All retry/error demos completed successfully! ==="); + } catch (error) { + console.error("Error:", error); + process.exit(1); + } finally { + await worker.stop(); + await client.stop(); + process.exit(0); + } +})(); diff --git a/examples/azure-managed/retry-and-error-handling/sample.json b/examples/azure-managed/retry-and-error-handling/sample.json new file mode 100644 index 0000000..5a46a92 --- /dev/null +++ b/examples/azure-managed/retry-and-error-handling/sample.json @@ -0,0 +1,5 @@ +{ + "name": "retry-and-error-handling", + "description": "Retry policies, custom retry handlers, error handling, and failure predicates", + "requiresEmulator": true +} diff --git a/examples/azure-managed/unit-testing/README.md b/examples/azure-managed/unit-testing/README.md new file mode 100644 index 0000000..b7b0b68 --- /dev/null +++ b/examples/azure-managed/unit-testing/README.md @@ -0,0 +1,68 @@ +# Unit Testing + +Demonstrates how to unit-test orchestrations using the built-in in-memory testing framework โ€” **no Docker, no emulator, no network** required. + +## Features Covered + +| Feature | API | +|---------|-----| +| In-memory backend | `InMemoryOrchestrationBackend` | +| Test client | `TestOrchestrationClient` (same API as `TaskHubGrpcClient`) | +| Test worker | `TestOrchestrationWorker` (same API as `TaskHubGrpcWorker`) | +| Replay-safe logger | `ctx.createReplaySafeLogger()` | +| NoOp logger | `NoOpLogger` | + +## Prerequisites + +- Node.js โ‰ฅ 22 +- **No Docker required** โ€” everything runs in-process + +## Setup + +```bash +# From the repository root +npm install && npm run build +``` + +## Run + +```bash +npm run example -- ./examples/azure-managed/unit-testing/index.ts +``` + +## Expected Output + +``` +=== Unit Testing with In-Memory Backend === + +--- Test Results --- + [PASS] Activity Sequence + [PASS] Fan-out/Fan-in + [PASS] Timer + [PASS] External Event + [PASS] Continue-as-new + [PASS] Terminate + [PASS] Suspend / Resume + [PASS] NoOpLogger + +8/8 tests passed. + +=== All unit-testing demos completed successfully! === +``` + +## Smoke Test + +```bash +npm run example -- ./examples/azure-managed/unit-testing/index.ts 2>&1 | grep "All unit-testing demos completed successfully" +``` + +## When to Use + +Use the in-memory backend for: +- **Unit tests** โ€” fast, deterministic, no infrastructure +- **CI pipelines** โ€” runs anywhere without Docker +- **Local development** โ€” instant feedback loop + +Use the DTS Emulator for: +- **Integration tests** โ€” validates gRPC communication +- **End-to-end tests** โ€” tests full stack behavior diff --git a/examples/azure-managed/unit-testing/index.ts b/examples/azure-managed/unit-testing/index.ts new file mode 100644 index 0000000..f7c5b68 --- /dev/null +++ b/examples/azure-managed/unit-testing/index.ts @@ -0,0 +1,236 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// This sample demonstrates the in-memory testing framework for unit-testing +// orchestrations WITHOUT any external dependencies (no Docker, no emulator, no network). +// +// Features demonstrated: +// 1. InMemoryOrchestrationBackend โ€” full in-memory orchestration engine +// 2. TestOrchestrationClient โ€” same API as TaskHubGrpcClient +// 3. TestOrchestrationWorker โ€” same API as TaskHubGrpcWorker +// 4. ReplaySafeLogger โ€” suppress duplicate logs during orchestration replay +// 5. Testing patterns: sequence, fan-out/fan-in, timers, events, terminate + +import { + InMemoryOrchestrationBackend, + TestOrchestrationClient, + TestOrchestrationWorker, + OrchestrationContext, + ActivityContext, + TOrchestrator, + OrchestrationStatus, + whenAll, + ConsoleLogger, + NoOpLogger, +} from "@microsoft/durabletask-js"; + +// --------------------------------------------------------------------------- +// Application code (same code you'd use with the real TaskHubGrpcWorker) +// --------------------------------------------------------------------------- + +const addNumbers = async (_ctx: ActivityContext, nums: number[]): Promise => { + return nums.reduce((a, b) => a + b, 0); +}; + +const greet = async (_ctx: ActivityContext, name: string): Promise => { + return `Hello, ${name}!`; +}; + +/** Sequence: calls greet for each city. */ +const sequenceOrchestrator: TOrchestrator = async function* (ctx: OrchestrationContext): any { + // Demonstrate ReplaySafeLogger โ€” only logs when NOT replaying + const safeLogger = ctx.createReplaySafeLogger(new ConsoleLogger()); + safeLogger.info("Orchestration started (only printed once, not during replay)"); + + const cities = ["Tokyo", "London", "Paris"]; + const results: string[] = []; + + for (const city of cities) { + const greeting: string = yield ctx.callActivity(greet, city); + results.push(greeting); + } + + safeLogger.info("Orchestration finishing"); + return results; +}; + +/** Fan-out/fan-in: parallel sum. */ +const parallelSumOrchestrator: TOrchestrator = async function* (ctx: OrchestrationContext): any { + const batches = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]; + const tasks = batches.map((batch) => ctx.callActivity(addNumbers, batch)); + const partialSums: number[] = yield whenAll(tasks); + return partialSums.reduce((a, b) => a + b, 0); // total = 45 +}; + +/** Timer: waits for a timer then returns. */ +const timerOrchestrator: TOrchestrator = async function* (ctx: OrchestrationContext): any { + yield ctx.createTimer(1); // 1 second timer + return "Timer fired"; +}; + +/** External event: waits for an event, returns its data. */ +const eventOrchestrator: TOrchestrator = async function* (ctx: OrchestrationContext): any { + const data: string = yield ctx.waitForExternalEvent("myEvent"); + return `Received: ${data}`; +}; + +// --------------------------------------------------------------------------- +// Test runner (lightweight, no framework needed) +// --------------------------------------------------------------------------- + +interface TestResult { + name: string; + passed: boolean; + error?: string; +} + +async function runTest( + name: string, + fn: (backend: InMemoryOrchestrationBackend, client: TestOrchestrationClient, worker: TestOrchestrationWorker) => Promise, +): Promise { + const backend = new InMemoryOrchestrationBackend(); + const client = new TestOrchestrationClient(backend); + const worker = new TestOrchestrationWorker(backend); + + try { + await fn(backend, client, worker); + await worker.stop(); + await client.stop(); + backend.reset(); + return { name, passed: true }; + } catch (error: any) { + await worker.stop(); + await client.stop(); + backend.reset(); + return { name, passed: false, error: error.message }; + } +} + +function assert(condition: boolean, message: string): void { + if (!condition) { + throw new Error(`Assertion failed: ${message}`); + } +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +(async () => { + console.log("=== Unit Testing with In-Memory Backend ===\n"); + + const results: TestResult[] = []; + + // Test 1: Activity Sequence + results.push( + await runTest("Activity Sequence", async (_backend, client, worker) => { + worker.addOrchestrator(sequenceOrchestrator); + worker.addActivity(greet); + await worker.start(); + + const id = await client.scheduleNewOrchestration(sequenceOrchestrator); + const state = await client.waitForOrchestrationCompletion(id, true, 10); + + assert(state !== undefined, "State should be defined"); + assert(state!.runtimeStatus === OrchestrationStatus.COMPLETED, "Should be completed"); + const output = JSON.parse(state!.serializedOutput!); + assert(output.length === 3, "Should have 3 greetings"); + assert(output[0] === "Hello, Tokyo!", "First greeting should be Tokyo"); + }), + ); + + // Test 2: Fan-out/Fan-in + results.push( + await runTest("Fan-out/Fan-in", async (_backend, client, worker) => { + worker.addOrchestrator(parallelSumOrchestrator); + worker.addActivity(addNumbers); + await worker.start(); + + const id = await client.scheduleNewOrchestration(parallelSumOrchestrator); + const state = await client.waitForOrchestrationCompletion(id, true, 10); + + assert(state!.runtimeStatus === OrchestrationStatus.COMPLETED, "Should be completed"); + assert(state!.serializedOutput === "45", `Sum should be 45, got ${state!.serializedOutput}`); + }), + ); + + // Test 3: Timer + results.push( + await runTest("Timer", async (_backend, client, worker) => { + worker.addOrchestrator(timerOrchestrator); + await worker.start(); + + const id = await client.scheduleNewOrchestration(timerOrchestrator); + const state = await client.waitForOrchestrationCompletion(id, true, 10); + + assert(state!.runtimeStatus === OrchestrationStatus.COMPLETED, "Should be completed"); + assert(state!.serializedOutput === '"Timer fired"', "Should return timer result"); + }), + ); + + // Test 4: External Event + results.push( + await runTest("External Event", async (_backend, client, worker) => { + worker.addOrchestrator(eventOrchestrator); + await worker.start(); + + const id = await client.scheduleNewOrchestration(eventOrchestrator); + await client.waitForOrchestrationStart(id); + + // Raise the event + await client.raiseOrchestrationEvent(id, "myEvent", "test-data"); + + const state = await client.waitForOrchestrationCompletion(id, true, 10); + assert(state!.runtimeStatus === OrchestrationStatus.COMPLETED, "Should be completed"); + assert(state!.serializedOutput === '"Received: test-data"', `Got: ${state!.serializedOutput}`); + }), + ); + + // Test 5: Terminate + results.push( + await runTest("Terminate", async (_backend, client, worker) => { + worker.addOrchestrator(eventOrchestrator); + await worker.start(); + + const id = await client.scheduleNewOrchestration(eventOrchestrator); + await client.waitForOrchestrationStart(id); + + await client.terminateOrchestration(id, "Cancelled"); + const state = await client.waitForOrchestrationCompletion(id, true, 10); + + assert(state!.runtimeStatus === OrchestrationStatus.TERMINATED, "Should be terminated"); + }), + ); + + // Test 6: NoOpLogger (verify it doesn't throw) + results.push( + await runTest("NoOpLogger", async () => { + const logger = new NoOpLogger(); + logger.info("This should do nothing"); + logger.error("This too"); + logger.warn("And this"); + logger.debug("Silent"); + // If we get here without throwing, the test passes + }), + ); + + // --- Report --- + console.log("\n--- Test Results ---"); + let allPassed = true; + for (const result of results) { + const status = result.passed ? "PASS" : "FAIL"; + console.log(` [${status}] ${result.name}${result.error ? ` โ€” ${result.error}` : ""}`); + if (!result.passed) allPassed = false; + } + + const passCount = results.filter((r) => r.passed).length; + console.log(`\n${passCount}/${results.length} tests passed.`); + + if (allPassed) { + console.log("\n=== All unit-testing demos completed successfully! ==="); + process.exit(0); + } else { + console.error("\nSome tests failed!"); + process.exit(1); + } +})(); diff --git a/examples/azure-managed/unit-testing/sample.json b/examples/azure-managed/unit-testing/sample.json new file mode 100644 index 0000000..b0597d4 --- /dev/null +++ b/examples/azure-managed/unit-testing/sample.json @@ -0,0 +1,5 @@ +{ + "name": "unit-testing", + "description": "In-memory testing framework: TestOrchestrationClient, TestOrchestrationWorker, ReplaySafeLogger", + "requiresEmulator": false +} diff --git a/examples/azure-managed/versioning/README.md b/examples/azure-managed/versioning/README.md new file mode 100644 index 0000000..1a7928d --- /dev/null +++ b/examples/azure-managed/versioning/README.md @@ -0,0 +1,94 @@ +# Versioning + +Demonstrates orchestration versioning for safe side-by-side deployment of different orchestration versions. + +## Features Covered + +| Feature | API | +|---------|-----| +| Schedule with version | `scheduleNewOrchestration(..., { version: "1.0.0" })` | +| Version in orchestrator | `ctx.version` | +| Compare versions | `ctx.compareVersionTo("2.0.0")` | +| Strict matching | `VersionMatchStrategy.Strict` | +| Current-or-older matching | `VersionMatchStrategy.CurrentOrOlder` | +| Fail on mismatch | `VersionFailureStrategy.Fail` | + +## Prerequisites + +- Node.js โ‰ฅ 22 +- Docker (for the DTS Emulator) + +## Setup + +```bash +cd examples/azure-managed +docker compose up -d +cp .env.emulator .env +cd ../.. +npm install && npm run build +``` + +## Run + +```bash +npm run example -- ./examples/azure-managed/versioning/index.ts +``` + +## Expected Output + +``` +=== 1. Schedule Orchestration with Version === +v1.0.0 result: {"version":"1.0.0","result":"Processed: v1-logic (version=1.0.0)","comparedTo2":-1} +v2.5.0 result: {"version":"2.5.0","result":"Processed: v2-logic (version=2.5.0)","comparedTo2":1} + +=== 2. VersionMatchStrategy.Strict === +Exact match (v2.0.0): COMPLETED โ€” "Processed: version=2.0.0" +Mismatch (v1.0.0): FAILED + Failure: Version mismatch: ... + +=== 3. VersionMatchStrategy.CurrentOrOlder === +Older (v2.0.0): COMPLETED โ€” "Processed: version=2.0.0" +Same (v3.0.0): COMPLETED โ€” "Processed: version=3.0.0" +Newer (v4.0.0): FAILED + Failure: Version mismatch: ... + +=== All versioning demos completed successfully! === +``` + +## Smoke Test + +```bash +npm run example -- ./examples/azure-managed/versioning/index.ts 2>&1 | grep "All versioning demos completed successfully" +``` + +## Running Against Azure Managed DTS (Cloud) + +To run this sample against a real [Azure Managed Durable Task Scheduler](https://learn.microsoft.com/azure/durable-task-scheduler/) instead of the local emulator: + +1. **Create a scheduler and task hub** (if you haven't already) โ€” see the [parent README](../README.md#quick-start-azure-managed-dts--cloud) for `az durabletask` commands. + +2. **Configure `.env`** for your cloud endpoint: + + ```bash + cd examples/azure-managed + cp .env.example .env + # Edit .env with your scheduler endpoint and task hub name + ``` + + Example `.env`: + + ```env + DURABLE_TASK_SCHEDULER_CONNECTION_STRING=Endpoint=https://your-scheduler.eastus.durabletask.io;Authentication=DefaultAzure;TaskHub=your-taskhub + ``` + +3. **Authenticate** with Azure: + + ```bash + az login + ``` + +4. **Run** (no Docker needed): + + ```bash + npm run example -- ./examples/azure-managed/versioning/index.ts + ``` diff --git a/examples/azure-managed/versioning/index.ts b/examples/azure-managed/versioning/index.ts new file mode 100644 index 0000000..11b9632 --- /dev/null +++ b/examples/azure-managed/versioning/index.ts @@ -0,0 +1,195 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// This sample demonstrates orchestration versioning: +// 1. Schedule orchestrations with a version +// 2. VersionMatchStrategy.Strict โ€” only exact version match processes +// 3. VersionMatchStrategy.CurrentOrOlder โ€” worker processes same or older versions +// 4. ctx.version and ctx.compareVersionTo() โ€” version-aware orchestration logic +// 5. defaultVersion on client โ€” auto-tag all orchestrations + +import * as dotenv from "dotenv"; +import * as path from "path"; +dotenv.config({ path: path.join(__dirname, "..", ".env") }); + +import { + DurableTaskAzureManagedClientBuilder, + DurableTaskAzureManagedWorkerBuilder, + ConsoleLogger, + VersionMatchStrategy, + VersionFailureStrategy, +} from "@microsoft/durabletask-js-azuremanaged"; +import { + OrchestrationContext, + ActivityContext, + TOrchestrator, + OrchestrationStatus, +} from "@microsoft/durabletask-js"; + +// --------------------------------------------------------------------------- +// Activities +// --------------------------------------------------------------------------- + +const doWork = async (_ctx: ActivityContext, label: string): Promise => { + return `Processed: ${label}`; +}; + +// --------------------------------------------------------------------------- +// Orchestrators +// --------------------------------------------------------------------------- + +/** Version-aware orchestrator โ€” behavior changes based on version. */ +const versionedOrchestrator: TOrchestrator = async function* (ctx: OrchestrationContext): any { + const version = ctx.version; + const comparison = ctx.compareVersionTo("2.0.0"); + + let result: string; + if (comparison >= 0) { + // v2.0.0 or newer โ€” uses new logic + result = yield ctx.callActivity(doWork, `v2-logic (version=${version})`); + } else { + // Older than v2.0.0 โ€” uses legacy logic + result = yield ctx.callActivity(doWork, `v1-logic (version=${version})`); + } + + return { version, result, comparedTo2: comparison }; +}; + +/** Simple orchestrator used with different workers. */ +const simpleOrchestrator: TOrchestrator = async function* (ctx: OrchestrationContext): any { + const result: string = yield ctx.callActivity(doWork, `version=${ctx.version}`); + return result; +}; + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +(async () => { + const logger = new ConsoleLogger(); + const connectionString = + process.env.DURABLE_TASK_SCHEDULER_CONNECTION_STRING || + "Endpoint=http://localhost:8080;Authentication=None;TaskHub=default"; + + // --- 1. Schedule with version --- + console.log("\n=== 1. Schedule Orchestration with Version ==="); + + const clientV1 = new DurableTaskAzureManagedClientBuilder() + .connectionString(connectionString) + .logger(logger) + .build(); + + // Worker with no versioning (MatchStrategy.None = processes everything) + const worker = new DurableTaskAzureManagedWorkerBuilder() + .connectionString(connectionString) + .logger(logger) + .addOrchestrator(versionedOrchestrator) + .addOrchestrator(simpleOrchestrator) + .addActivity(doWork) + .build(); + + try { + await worker.start(); + await new Promise((r) => setTimeout(r, 2000)); + + // Schedule with explicit version + const id1 = await clientV1.scheduleNewOrchestration(versionedOrchestrator, undefined, { version: "1.0.0" }); + const state1 = await clientV1.waitForOrchestrationCompletion(id1, true, 30); + console.log(`v1.0.0 result: ${state1?.serializedOutput}`); + + const id2 = await clientV1.scheduleNewOrchestration(versionedOrchestrator, undefined, { version: "2.5.0" }); + const state2 = await clientV1.waitForOrchestrationCompletion(id2, true, 30); + console.log(`v2.5.0 result: ${state2?.serializedOutput}`); + + await worker.stop(); + } catch (error) { + console.error("Error in demo 1:", error); + await worker.stop(); + } + + // --- 2. Strict version matching --- + console.log("\n=== 2. VersionMatchStrategy.Strict ==="); + + const workerStrict = new DurableTaskAzureManagedWorkerBuilder() + .connectionString(connectionString) + .logger(logger) + .versioning({ + version: "2.0.0", + matchStrategy: VersionMatchStrategy.Strict, + failureStrategy: VersionFailureStrategy.Fail, + }) + .addOrchestrator(simpleOrchestrator) + .addActivity(doWork) + .build(); + + try { + await workerStrict.start(); + await new Promise((r) => setTimeout(r, 2000)); + + // This one should match (version 2.0.0 == worker version 2.0.0) + const matchId = await clientV1.scheduleNewOrchestration(simpleOrchestrator, undefined, { version: "2.0.0" }); + const matchState = await clientV1.waitForOrchestrationCompletion(matchId, true, 30); + console.log(`Exact match (v2.0.0): ${OrchestrationStatus[matchState!.runtimeStatus]} โ€” ${matchState?.serializedOutput}`); + + // This one should fail (version 1.0.0 != worker version 2.0.0) + const mismatchId = await clientV1.scheduleNewOrchestration(simpleOrchestrator, undefined, { version: "1.0.0" }); + const mismatchState = await clientV1.waitForOrchestrationCompletion(mismatchId, true, 15); + console.log(`Mismatch (v1.0.0): ${OrchestrationStatus[mismatchState!.runtimeStatus]}`); + if (mismatchState?.failureDetails) { + console.log(` Failure: ${mismatchState.failureDetails.errorMessage?.substring(0, 80)}`); + } + + await workerStrict.stop(); + } catch (error) { + console.error("Error in demo 2:", error); + await workerStrict.stop(); + } + + // --- 3. CurrentOrOlder matching --- + console.log("\n=== 3. VersionMatchStrategy.CurrentOrOlder ==="); + + const workerCurrentOrOlder = new DurableTaskAzureManagedWorkerBuilder() + .connectionString(connectionString) + .logger(logger) + .versioning({ + version: "3.0.0", + matchStrategy: VersionMatchStrategy.CurrentOrOlder, + failureStrategy: VersionFailureStrategy.Fail, + }) + .addOrchestrator(simpleOrchestrator) + .addActivity(doWork) + .build(); + + try { + await workerCurrentOrOlder.start(); + await new Promise((r) => setTimeout(r, 2000)); + + // Older version โ€” should be processed + const olderId = await clientV1.scheduleNewOrchestration(simpleOrchestrator, undefined, { version: "2.0.0" }); + const olderState = await clientV1.waitForOrchestrationCompletion(olderId, true, 30); + console.log(`Older (v2.0.0): ${OrchestrationStatus[olderState!.runtimeStatus]} โ€” ${olderState?.serializedOutput}`); + + // Same version โ€” should be processed + const sameId = await clientV1.scheduleNewOrchestration(simpleOrchestrator, undefined, { version: "3.0.0" }); + const sameState = await clientV1.waitForOrchestrationCompletion(sameId, true, 30); + console.log(`Same (v3.0.0): ${OrchestrationStatus[sameState!.runtimeStatus]} โ€” ${sameState?.serializedOutput}`); + + // Newer version โ€” should fail (worker is 3.0.0, orchestration is 4.0.0) + const newerId = await clientV1.scheduleNewOrchestration(simpleOrchestrator, undefined, { version: "4.0.0" }); + const newerState = await clientV1.waitForOrchestrationCompletion(newerId, true, 15); + console.log(`Newer (v4.0.0): ${OrchestrationStatus[newerState!.runtimeStatus]}`); + if (newerState?.failureDetails) { + console.log(` Failure: ${newerState.failureDetails.errorMessage?.substring(0, 80)}`); + } + + await workerCurrentOrOlder.stop(); + } catch (error) { + console.error("Error in demo 3:", error); + await workerCurrentOrOlder.stop(); + } + + console.log("\n=== All versioning demos completed successfully! ==="); + + await clientV1.stop(); + process.exit(0); +})(); diff --git a/examples/azure-managed/versioning/sample.json b/examples/azure-managed/versioning/sample.json new file mode 100644 index 0000000..5a9e234 --- /dev/null +++ b/examples/azure-managed/versioning/sample.json @@ -0,0 +1,5 @@ +{ + "name": "versioning", + "description": "Orchestration versioning: match strategies, failure strategies, version comparison, and default version", + "requiresEmulator": true +} diff --git a/test/e2e-azuremanaged/retry-handler.spec.ts b/test/e2e-azuremanaged/retry-handler.spec.ts index c2ff2e3..514a507 100644 --- a/test/e2e-azuremanaged/retry-handler.spec.ts +++ b/test/e2e-azuremanaged/retry-handler.spec.ts @@ -316,7 +316,6 @@ describe("Retry Handler E2E Tests", () => { }; // Retry handler returning delay in ms (fixed 500ms delay) - // eslint-disable-next-line @typescript-eslint/no-explicit-any const retryHandler: any = async (ctx: RetryContext): Promise => { if (ctx.lastAttemptNumber >= 5) { return false; @@ -366,7 +365,7 @@ describe("Retry Handler E2E Tests", () => { }; // Retry handler implementing manual exponential backoff: 200ms, 400ms, 800ms - // eslint-disable-next-line @typescript-eslint/no-explicit-any + const retryHandler: any = async (ctx: RetryContext): Promise => { if (ctx.lastAttemptNumber >= 5) { return false; @@ -417,7 +416,7 @@ describe("Retry Handler E2E Tests", () => { }; // Retry handler returning fixed delay - // eslint-disable-next-line @typescript-eslint/no-explicit-any + const retryHandler: any = async (ctx: RetryContext): Promise => { if (ctx.lastAttemptNumber >= 5) { return false; @@ -463,7 +462,7 @@ describe("Retry Handler E2E Tests", () => { }; // Handler: returns delay for TransientError, false for FatalError - // eslint-disable-next-line @typescript-eslint/no-explicit-any + const retryHandler: any = async (ctx: RetryContext): Promise => { if (ctx.lastFailure.errorType === "TransientError") { return 200; // retry after 200ms @@ -502,7 +501,7 @@ describe("Retry Handler E2E Tests", () => { }; // Sync handler returning a delay - // eslint-disable-next-line @typescript-eslint/no-explicit-any + const syncHandler: any = (ctx: RetryContext): boolean | number => { if (ctx.lastAttemptNumber >= 5) { return false;