From e70573edf1c233f5e068466db4744959507a52e2 Mon Sep 17 00:00:00 2001 From: Varshi Bachu Date: Mon, 27 Apr 2026 16:48:09 -0700 Subject: [PATCH 1/4] initial commit --- .../dotnet/WorkItemFiltering/Functions.cs | 162 ++++++++++++++++++ .../dotnet/WorkItemFiltering/Program.cs | 5 + .../dotnet/WorkItemFiltering/README.md | 130 ++++++++++++++ .../WorkItemFiltering.csproj | 16 ++ .../dotnet/WorkItemFiltering/host.json | 19 ++ .../dotnet/WorkItemFiltering/test.http | 92 ++++++++++ .../python/work-item-filtering/README.md | 122 +++++++++++++ .../work-item-filtering/function_app.py | 148 ++++++++++++++++ .../python/work-item-filtering/host.json | 22 +++ .../work-item-filtering/requirements.txt | 2 + .../python/work-item-filtering/test.http | 80 +++++++++ 11 files changed, 798 insertions(+) create mode 100644 samples/durable-functions/dotnet/WorkItemFiltering/Functions.cs create mode 100644 samples/durable-functions/dotnet/WorkItemFiltering/Program.cs create mode 100644 samples/durable-functions/dotnet/WorkItemFiltering/README.md create mode 100644 samples/durable-functions/dotnet/WorkItemFiltering/WorkItemFiltering.csproj create mode 100644 samples/durable-functions/dotnet/WorkItemFiltering/host.json create mode 100644 samples/durable-functions/dotnet/WorkItemFiltering/test.http create mode 100644 samples/durable-functions/python/work-item-filtering/README.md create mode 100644 samples/durable-functions/python/work-item-filtering/function_app.py create mode 100644 samples/durable-functions/python/work-item-filtering/host.json create mode 100644 samples/durable-functions/python/work-item-filtering/requirements.txt create mode 100644 samples/durable-functions/python/work-item-filtering/test.http diff --git a/samples/durable-functions/dotnet/WorkItemFiltering/Functions.cs b/samples/durable-functions/dotnet/WorkItemFiltering/Functions.cs new file mode 100644 index 00000000..71eae0d5 --- /dev/null +++ b/samples/durable-functions/dotnet/WorkItemFiltering/Functions.cs @@ -0,0 +1,162 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Net; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Http; +using Microsoft.DurableTask; +using Microsoft.DurableTask.Client; +using Microsoft.DurableTask.Entities; +using Microsoft.Extensions.Logging; + +namespace WorkItemFiltering; + +// ============================================================================= +// Orchestrations +// ============================================================================= + +/// +/// A simple orchestration that calls an activity and returns the result. +/// With work item filtering enabled, DTS will only dispatch this orchestration +/// to workers that have it registered. +/// +public static class GreetingOrchestration +{ + [Function(nameof(GreetingOrchestration))] + public static async Task Run([OrchestrationTrigger] TaskOrchestrationContext ctx) + { + ctx.CreateReplaySafeLogger(nameof(GreetingOrchestration)).LogInformation("GreetingOrchestration started"); + return await ctx.CallActivityAsync(nameof(SayHello), "World"); + } + + [Function(nameof(GreetingOrchestration) + "_Start")] + public static async Task Start( + [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "orchestrators/greeting")] HttpRequestData req, + [DurableClient] DurableTaskClient client) + { + string instanceId = await client.ScheduleNewOrchestrationInstanceAsync(nameof(GreetingOrchestration)); + return client.CreateCheckStatusResponse(req, instanceId); + } +} + +/// +/// A fan-out/fan-in orchestration that calls the same activity in parallel. +/// Demonstrates that activity work items are also filtered. +/// +public static class FanOutOrchestration +{ + [Function(nameof(FanOutOrchestration))] + public static async Task Run([OrchestrationTrigger] TaskOrchestrationContext ctx) + { + ctx.CreateReplaySafeLogger(nameof(FanOutOrchestration)).LogInformation("FanOutOrchestration: fanning out to 3 activities"); + return await Task.WhenAll( + ctx.CallActivityAsync(nameof(SayHello), "Tokyo"), + ctx.CallActivityAsync(nameof(SayHello), "London"), + ctx.CallActivityAsync(nameof(SayHello), "Seattle")); + } + + [Function(nameof(FanOutOrchestration) + "_Start")] + public static async Task Start( + [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "orchestrators/fanout")] HttpRequestData req, + [DurableClient] DurableTaskClient client) + { + string instanceId = await client.ScheduleNewOrchestrationInstanceAsync(nameof(FanOutOrchestration)); + return client.CreateCheckStatusResponse(req, instanceId); + } +} + +/// +/// A parent orchestration that calls a child orchestration. +/// Sub-orchestration dispatch is also governed by work item filters. +/// +public static class ParentOrchestration +{ + [Function(nameof(ParentOrchestration))] + public static async Task Run([OrchestrationTrigger] TaskOrchestrationContext ctx) + { + ctx.CreateReplaySafeLogger(nameof(ParentOrchestration)).LogInformation("Calling sub-orchestration"); + string result = await ctx.CallSubOrchestratorAsync(nameof(GreetingOrchestration)); + return $"Parent received: {result}"; + } + + [Function(nameof(ParentOrchestration) + "_Start")] + public static async Task Start( + [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "orchestrators/parent")] HttpRequestData req, + [DurableClient] DurableTaskClient client) + { + string instanceId = await client.ScheduleNewOrchestrationInstanceAsync(nameof(ParentOrchestration)); + return client.CreateCheckStatusResponse(req, instanceId); + } +} + +/// +/// An orchestration that interacts with a durable entity. +/// Entity work items are also filtered. +/// +public static class CounterOrchestration +{ + [Function(nameof(CounterOrchestration))] + public static async Task Run([OrchestrationTrigger] TaskOrchestrationContext ctx) + { + var logger = ctx.CreateReplaySafeLogger(nameof(CounterOrchestration)); + var entityId = new EntityInstanceId(nameof(CounterEntity), "sample-counter"); + + await ctx.Entities.CallEntityAsync(entityId, "Add", 10); + await ctx.Entities.CallEntityAsync(entityId, "Add", 20); + int value = await ctx.Entities.CallEntityAsync(entityId, "Get"); + + logger.LogInformation("Counter value = {Value}", value); + return value; + } + + [Function(nameof(CounterOrchestration) + "_Start")] + public static async Task Start( + [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "orchestrators/counter")] HttpRequestData req, + [DurableClient] DurableTaskClient client) + { + string instanceId = await client.ScheduleNewOrchestrationInstanceAsync(nameof(CounterOrchestration)); + return client.CreateCheckStatusResponse(req, instanceId); + } +} + +// ============================================================================= +// Activities +// ============================================================================= + +public static class SayHello +{ + [Function(nameof(SayHello))] + public static string Run([ActivityTrigger] string name) => $"Hello, {name}!"; +} + +// ============================================================================= +// Entities +// ============================================================================= + +public class CounterEntity : TaskEntity +{ + public void Add(int amount) => this.State += amount; + public void Reset() => this.State = 0; + public int Get() => this.State; + + [Function(nameof(CounterEntity))] + public static Task Dispatch([EntityTrigger] TaskEntityDispatcher dispatcher) + => dispatcher.DispatchAsync(); +} + +// ============================================================================= +// Generic starter (for cross-app filter isolation tests) +// ============================================================================= + +public static class GenericStarter +{ + [Function("StartOrchestration")] + public static async Task Start( + [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "start/{name}")] HttpRequestData req, + [DurableClient] DurableTaskClient client, + string name) + { + string instanceId = await client.ScheduleNewOrchestrationInstanceAsync(name); + return client.CreateCheckStatusResponse(req, instanceId); + } +} diff --git a/samples/durable-functions/dotnet/WorkItemFiltering/Program.cs b/samples/durable-functions/dotnet/WorkItemFiltering/Program.cs new file mode 100644 index 00000000..46bca8a3 --- /dev/null +++ b/samples/durable-functions/dotnet/WorkItemFiltering/Program.cs @@ -0,0 +1,5 @@ +using Microsoft.Azure.Functions.Worker.Builder; +using Microsoft.Extensions.Hosting; + +FunctionsApplicationBuilder builder = FunctionsApplication.CreateBuilder(args); +builder.Build().Run(); diff --git a/samples/durable-functions/dotnet/WorkItemFiltering/README.md b/samples/durable-functions/dotnet/WorkItemFiltering/README.md new file mode 100644 index 00000000..1a8779ce --- /dev/null +++ b/samples/durable-functions/dotnet/WorkItemFiltering/README.md @@ -0,0 +1,130 @@ +# Work Item Filtering with Durable Functions (.NET) + +.NET | Durable Functions + +## Description + +Demonstrates the **work item filtering** feature for Durable Functions with the Durable Task Scheduler (DTS) backend. When multiple Function apps share the same DTS task hub, work item filtering ensures each app only receives work items for the functions it has registered — preventing dispatch failures. + +This sample includes orchestrations, activities, entities, sub-orchestrations, and fan-out/fan-in patterns — all governed by work item filters. + +## Prerequisites + +1. [.NET 8 SDK](https://dotnet.microsoft.com/download/dotnet/8.0) +2. [Docker](https://www.docker.com/products/docker-desktop/) (for running the emulator and Azurite) +3. [Azure Functions Core Tools v4](https://learn.microsoft.com/azure/azure-functions/functions-run-local) + +## Quick Run + +1. Start the Durable Task Scheduler emulator: + ```bash + docker run --name dtsemulator -d -p 8080:8080 -p 8082:8082 mcr.microsoft.com/dts/dts-emulator:latest + ``` + +2. Start Azurite (Azure Storage emulator): + ```bash + docker run --name azurite -d -p 10000:10000 -p 10001:10001 -p 10002:10002 mcr.microsoft.com/azure-storage/azurite + ``` + +3. Start the Function app: + ```bash + func start + ``` + +4. Trigger an orchestration: + ```bash + curl -X POST http://localhost:7071/api/orchestrators/greeting + ``` + +5. Test filter isolation — schedule an orchestration this app does NOT have: + ```bash + curl -X POST http://localhost:7071/api/start/SomeOtherOrchestration + ``` + Check the status — it should stay `Pending` because no worker has `SomeOtherOrchestration` in its filter. Without filtering, this would fail with *"function doesn't exist"*. + +## Expected Output + +``` +# Matching orchestration → Completed +{"name":"GreetingOrchestration","runtimeStatus":"Completed","output":"Hello, World!"} + +# Unknown orchestration → stays Pending (filter isolation working) +{"name":"SomeOtherOrchestration","runtimeStatus":"Pending"} +``` + +## How It Works + +The key configuration in [`host.json`](host.json): + +```json +{ + "extensions": { + "durableTask": { + "storageProvider": { + "type": "azureManaged", + "connectionStringName": "DURABLE_TASK_SCHEDULER_CONNECTION_STRING", + "workItemFilteringEnabled": true + } + } + } +} +``` + +When `workItemFilteringEnabled` is `true`: +1. The Durable Functions extension discovers registered orchestrators, activities, and entities during function indexing +2. These names are sent to DTS as `WorkItemFilters` on the `GetWorkItems` gRPC stream +3. DTS only dispatches work items that match the worker's registered functions +4. Unmatched work items stay in the DTS queue until a matching worker connects + +No code changes are needed — filtering is automatic based on the functions registered in the app. + +## Registered Functions + +| Type | Function | Description | +|---------------|---------------------------|-------------------------------------| +| Orchestration | `GreetingOrchestration` | Simple activity call | +| Orchestration | `FanOutOrchestration` | Parallel fan-out to 3 activities | +| Orchestration | `ParentOrchestration` | Calls GreetingOrchestration as sub | +| Orchestration | `CounterOrchestration` | Interacts with CounterEntity | +| Activity | `SayHello` | Returns a greeting string | +| Entity | `CounterEntity` | Counter with Add/Reset/Get | + +## Multi-App Scenario + +To see filter isolation in action across two apps: + +1. Create a second Function app with **different** orchestrations/activities +2. Point both apps to the **same** DTS task hub +3. Enable `workItemFilteringEnabled: true` in both +4. Schedule orchestrations — each app only processes its own functions + +## Viewing in the Dashboard + +- **Emulator:** Navigate to http://localhost:8082 → select the "default" task hub +- **Azure:** Navigate to your Scheduler resource in the Azure Portal → Task Hub → Dashboard URL + +## Using a Deployed Scheduler (Azure) + +To use a Durable Task Scheduler in Azure instead of the emulator: + +1. Update `local.settings.json`: + ```json + { + "Values": { + "DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=https://.durabletask.io;Authentication=ManagedIdentity" + } + } + ``` + +2. Run the sample using the same commands above. + +## Related Samples + +- [WorkItemFilteringSplitActivities](../../../scenarios/WorkItemFilteringSplitActivities/) — Multi-worker scenario using Durable Task SDK +- [Fan-out/Fan-in (Python)](../../python/fan-out-fan-in/) — Fan-out pattern in Python +- [HelloCities (.NET)](../HelloCities/) — Basic Durable Functions quickstart + +## Learn More + +- [Durable Task Scheduler documentation](https://learn.microsoft.com/azure/azure-functions/durable/durable-functions-task-hubs) +- [Durable Functions patterns](https://learn.microsoft.com/azure/azure-functions/durable/durable-functions-overview) diff --git a/samples/durable-functions/dotnet/WorkItemFiltering/WorkItemFiltering.csproj b/samples/durable-functions/dotnet/WorkItemFiltering/WorkItemFiltering.csproj new file mode 100644 index 00000000..cc3bb4c5 --- /dev/null +++ b/samples/durable-functions/dotnet/WorkItemFiltering/WorkItemFiltering.csproj @@ -0,0 +1,16 @@ + + + net8.0 + v4 + Exe + enable + enable + + + + + + + + + diff --git a/samples/durable-functions/dotnet/WorkItemFiltering/host.json b/samples/durable-functions/dotnet/WorkItemFiltering/host.json new file mode 100644 index 00000000..da06fd07 --- /dev/null +++ b/samples/durable-functions/dotnet/WorkItemFiltering/host.json @@ -0,0 +1,19 @@ +{ + "version": "2.0", + "logging": { + "logLevel": { + "DurableTask.AzureStorage": "Warning", + "DurableTask.Core": "Warning" + } + }, + "extensions": { + "durableTask": { + "hubName": "default", + "storageProvider": { + "type": "azureManaged", + "connectionStringName": "DURABLE_TASK_SCHEDULER_CONNECTION_STRING", + "workItemFilteringEnabled": true + } + } + } +} diff --git a/samples/durable-functions/dotnet/WorkItemFiltering/test.http b/samples/durable-functions/dotnet/WorkItemFiltering/test.http new file mode 100644 index 00000000..84acc9af --- /dev/null +++ b/samples/durable-functions/dotnet/WorkItemFiltering/test.http @@ -0,0 +1,92 @@ +# ============================================================================= +# Work Item Filtering — Durable Functions (.NET) Demo +# ============================================================================= +# +# Prerequisites: +# 1. DTS emulator: docker run -d -p 8080:8080 -p 8082:8082 mcr.microsoft.com/dts/dts-emulator:latest +# 2. Azurite: docker run -d -p 10000:10000 -p 10001:10001 -p 10002:10002 mcr.microsoft.com/azure-storage/azurite +# 3. App running: func start --port 7071 +# +# This app registers: GreetingOrchestration, FanOutOrchestration, +# ParentOrchestration, CounterOrchestration, SayHello, CounterEntity +# +# With workItemFilteringEnabled: true, DTS only dispatches matching work. +# Unmatched orchestrations stay Pending — proving filter isolation. +# ============================================================================= + +@baseUrl = http://localhost:7071/api + + +# --- TEST 1: Simple orchestration + activity --- +# Expected: Completed with "Hello, World!" + +### Start GreetingOrchestration +# @name greeting +POST {{baseUrl}}/orchestrators/greeting + +### Check status +GET {{baseUrl}}/instances/{{greeting.response.body.id}} + + +# --- TEST 2: Fan-out/fan-in (parallel activities) --- +# Expected: Completed with ["Hello, Tokyo!", "Hello, London!", "Hello, Seattle!"] + +### Start FanOutOrchestration +# @name fanout +POST {{baseUrl}}/orchestrators/fanout + +### Check status +GET {{baseUrl}}/instances/{{fanout.response.body.id}} + + +# --- TEST 3: Sub-orchestration --- +# Expected: Completed with "Parent received: Hello, World!" + +### Start ParentOrchestration +# @name parent +POST {{baseUrl}}/orchestrators/parent + +### Check status +GET {{baseUrl}}/instances/{{parent.response.body.id}} + + +# --- TEST 4: Entity interaction --- +# Expected: Completed with 30 (10 + 20) + +### Start CounterOrchestration +# @name counter +POST {{baseUrl}}/orchestrators/counter + +### Check status +GET {{baseUrl}}/instances/{{counter.response.body.id}} + + +# --- TEST 5: Filter isolation — unknown orchestration --- +# Expected: Stays Pending (no worker has this function registered) + +### Start an orchestration this app does NOT have +# @name unknown +POST {{baseUrl}}/start/SomeOtherOrchestration + +### Check status — should be Pending +GET {{baseUrl}}/instances/{{unknown.response.body.id}} + +### Check again after 15 seconds — still Pending +GET {{baseUrl}}/instances/{{unknown.response.body.id}} + + +# --- TEST 6: View all instances --- + +### List all orchestration instances +GET http://localhost:7071/runtime/webhooks/durabletask/instances + + +# --- EXPECTED RESULTS --- +# +# | Test | Orchestration | Status | Why | +# |------|--------------------------|-----------|-----------------------------| +# | 1 | GreetingOrchestration | Completed | Matches filter | +# | 2 | FanOutOrchestration | Completed | Activities also filtered | +# | 3 | ParentOrchestration | Completed | Sub-orch also filtered | +# | 4 | CounterOrchestration | Completed | Entities also filtered | +# | 5 | SomeOtherOrchestration | Pending | No matching filter → held | diff --git a/samples/durable-functions/python/work-item-filtering/README.md b/samples/durable-functions/python/work-item-filtering/README.md new file mode 100644 index 00000000..10cef76f --- /dev/null +++ b/samples/durable-functions/python/work-item-filtering/README.md @@ -0,0 +1,122 @@ +# Work Item Filtering with Durable Functions (Python) + +Python | Durable Functions + +## Description + +Demonstrates the **work item filtering** feature for Durable Functions with the Durable Task Scheduler (DTS) backend. When multiple Function apps share the same DTS task hub, work item filtering ensures each app only receives work items for the functions it has registered — preventing dispatch failures. + +This sample includes orchestrations, activities, entities, sub-orchestrations, and fan-out/fan-in patterns — all governed by work item filters. + +## Prerequisites + +1. [Python 3.9+](https://www.python.org/downloads/) +2. [Docker](https://www.docker.com/products/docker-desktop/) (for running the emulator and Azurite) +3. [Azure Functions Core Tools v4](https://learn.microsoft.com/azure/azure-functions/functions-run-local) + +## Quick Run + +1. Start the Durable Task Scheduler emulator: + ```bash + docker run --name dtsemulator -d -p 8080:8080 -p 8082:8082 mcr.microsoft.com/dts/dts-emulator:latest + ``` + +2. Start Azurite (Azure Storage emulator): + ```bash + docker run --name azurite -d -p 10000:10000 -p 10001:10001 -p 10002:10002 mcr.microsoft.com/azure-storage/azurite + ``` + +3. Set up the Python environment and start the Function app: + ```bash + python -m venv .venv + source .venv/bin/activate # Linux/macOS + # .venv\Scripts\activate # Windows + pip install -r requirements.txt + func start + ``` + +4. Trigger an orchestration: + ```bash + curl -X POST http://localhost:7071/api/orchestrators/greeting + ``` + +5. Test filter isolation — schedule an orchestration this app does NOT have: + ```bash + curl -X POST http://localhost:7071/api/start/SomeOtherOrchestration + ``` + Check the status — it should stay `Pending` because no worker has `SomeOtherOrchestration` in its filter. + +## Expected Output + +``` +# Matching orchestration → Completed +{"runtimeStatus": "Completed", "output": "Hello, World!"} + +# Unknown orchestration → stays Pending (filter isolation working) +{"runtimeStatus": "Pending"} +``` + +## How It Works + +The key configuration in [`host.json`](host.json): + +```json +{ + "extensions": { + "durableTask": { + "storageProvider": { + "type": "azureManaged", + "connectionStringName": "DURABLE_TASK_SCHEDULER_CONNECTION_STRING", + "workItemFilteringEnabled": true + } + } + } +} +``` + +When `workItemFilteringEnabled` is `true`: +1. The Durable Functions extension discovers registered orchestrators, activities, and entities during function indexing +2. These names are sent to DTS as `WorkItemFilters` on the `GetWorkItems` gRPC stream +3. DTS only dispatches work items that match the worker's registered functions +4. Unmatched work items stay in the DTS queue until a matching worker connects + +No code changes are needed — filtering is automatic based on the functions registered in the app. This works the same way for all languages (Python, .NET, JavaScript, Java). + +## Registered Functions + +| Type | Function | Description | +|---------------|---------------------------|-------------------------------------| +| Orchestration | `greeting_orchestration` | Simple activity call | +| Orchestration | `fan_out_orchestration` | Parallel fan-out to 3 activities | +| Orchestration | `parent_orchestration` | Calls greeting as sub-orchestration | +| Orchestration | `counter_orchestration` | Interacts with counter_entity | +| Activity | `say_hello` | Returns a greeting string | +| Entity | `counter_entity` | Counter with add/reset/get | + +## Viewing in the Dashboard + +- **Emulator:** Navigate to http://localhost:8082 → select the "default" task hub +- **Azure:** Navigate to your Scheduler resource in the Azure Portal → Task Hub → Dashboard URL + +## Using a Deployed Scheduler (Azure) + +To use a Durable Task Scheduler in Azure instead of the emulator, update `local.settings.json`: + +```json +{ + "Values": { + "DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=https://.durabletask.io;Authentication=ManagedIdentity" + } +} +``` + +## Related Samples + +- [Work Item Filtering (.NET)](../../dotnet/WorkItemFiltering/) — Same feature in .NET +- [WorkItemFilteringSplitActivities](../../../scenarios/WorkItemFilteringSplitActivities/) — Multi-worker scenario using Durable Task SDK +- [Fan-out/Fan-in (Python)](../fan-out-fan-in/) — Fan-out pattern without filtering + +## Learn More + +- [Durable Task Scheduler documentation](https://learn.microsoft.com/azure/azure-functions/durable/durable-functions-task-hubs) +- [Durable Functions for Python](https://learn.microsoft.com/azure/azure-functions/durable/quickstart-python-vscode) diff --git a/samples/durable-functions/python/work-item-filtering/function_app.py b/samples/durable-functions/python/work-item-filtering/function_app.py new file mode 100644 index 00000000..54e79cc8 --- /dev/null +++ b/samples/durable-functions/python/work-item-filtering/function_app.py @@ -0,0 +1,148 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +""" +Work Item Filtering sample for Durable Functions (Python). + +With workItemFilteringEnabled: true in host.json, this app advertises its +registered orchestrations, activities, and entities to the Durable Task +Scheduler (DTS). DTS then only dispatches matching work items to this worker. + +Orchestrations scheduled for functions NOT registered in this app will stay +in Pending state until a matching worker connects — proving filter isolation. +""" + +import logging +import json +import azure.functions as func +import azure.durable_functions as df + +app = func.FunctionApp(http_auth_level=func.AuthLevel.ANONYMOUS) +bp = df.Blueprint() + + +# ============================================================================= +# Orchestrations +# ============================================================================= + +@bp.orchestration_trigger(context_name="context") +def greeting_orchestration(context: df.DurableOrchestrationContext): + """Simple orchestration that calls an activity.""" + result = yield context.call_activity("say_hello", "World") + return result + + +@bp.orchestration_trigger(context_name="context") +def fan_out_orchestration(context: df.DurableOrchestrationContext): + """Fan-out/fan-in: calls the same activity in parallel with different inputs.""" + cities = ["Tokyo", "London", "Seattle"] + parallel_tasks = [context.call_activity("say_hello", city) for city in cities] + results = yield context.task_all(parallel_tasks) + return results + + +@bp.orchestration_trigger(context_name="context") +def parent_orchestration(context: df.DurableOrchestrationContext): + """Parent orchestration that calls a child orchestration.""" + result = yield context.call_sub_orchestrator("greeting_orchestration") + return f"Parent received: {result}" + + +@bp.orchestration_trigger(context_name="context") +def counter_orchestration(context: df.DurableOrchestrationContext): + """Orchestration that interacts with a durable entity.""" + entity_id = df.EntityId("counter_entity", "sample-counter") + + yield context.call_entity(entity_id, "add", 10) + yield context.call_entity(entity_id, "add", 20) + value = yield context.call_entity(entity_id, "get") + + return value + + +# ============================================================================= +# Activities +# ============================================================================= + +@bp.activity_trigger(input_name="name") +def say_hello(name: str) -> str: + """Simple activity that returns a greeting.""" + logging.info(f"say_hello called with: {name}") + return f"Hello, {name}!" + + +# ============================================================================= +# Entities +# ============================================================================= + +@bp.entity_trigger(context_name="context") +def counter_entity(context: df.DurableEntityContext): + """Simple counter entity with add, reset, and get operations.""" + state = context.get_state(lambda: 0) + operation = context.operation_name + + if operation == "add": + amount = context.get_input() + state += amount + elif operation == "reset": + state = 0 + elif operation == "get": + context.set_result(state) + + context.set_state(state) + + +# ============================================================================= +# HTTP triggers +# ============================================================================= + +@bp.durable_client_input(client_name="client") +@app.route(route="orchestrators/greeting", methods=["POST"]) +async def start_greeting(req: func.HttpRequest, client) -> func.HttpResponse: + """Start the greeting orchestration.""" + client = df.DurableOrchestrationClient(client) + instance_id = await client.start_new("greeting_orchestration") + return client.create_check_status_response(req, instance_id) + + +@bp.durable_client_input(client_name="client") +@app.route(route="orchestrators/fanout", methods=["POST"]) +async def start_fan_out(req: func.HttpRequest, client) -> func.HttpResponse: + """Start the fan-out orchestration.""" + client = df.DurableOrchestrationClient(client) + instance_id = await client.start_new("fan_out_orchestration") + return client.create_check_status_response(req, instance_id) + + +@bp.durable_client_input(client_name="client") +@app.route(route="orchestrators/parent", methods=["POST"]) +async def start_parent(req: func.HttpRequest, client) -> func.HttpResponse: + """Start the parent orchestration.""" + client = df.DurableOrchestrationClient(client) + instance_id = await client.start_new("parent_orchestration") + return client.create_check_status_response(req, instance_id) + + +@bp.durable_client_input(client_name="client") +@app.route(route="orchestrators/counter", methods=["POST"]) +async def start_counter(req: func.HttpRequest, client) -> func.HttpResponse: + """Start the counter orchestration (entity interaction).""" + client = df.DurableOrchestrationClient(client) + instance_id = await client.start_new("counter_orchestration") + return client.create_check_status_response(req, instance_id) + + +@bp.durable_client_input(client_name="client") +@app.route(route="start/{name}", methods=["POST"]) +async def start_any(req: func.HttpRequest, client) -> func.HttpResponse: + """Generic starter — can schedule any orchestration by name. + Useful for testing filter isolation: schedule an orchestration this app + does NOT have and observe it stays Pending. + """ + name = req.route_params.get("name") + client = df.DurableOrchestrationClient(client) + instance_id = await client.start_new(name) + return client.create_check_status_response(req, instance_id) + + +app.register_functions(bp) diff --git a/samples/durable-functions/python/work-item-filtering/host.json b/samples/durable-functions/python/work-item-filtering/host.json new file mode 100644 index 00000000..3bf3ab4b --- /dev/null +++ b/samples/durable-functions/python/work-item-filtering/host.json @@ -0,0 +1,22 @@ +{ + "version": "2.0", + "logging": { + "logLevel": { + "DurableTask.Core": "Warning" + } + }, + "extensions": { + "durableTask": { + "hubName": "default", + "storageProvider": { + "type": "azureManaged", + "connectionStringName": "DURABLE_TASK_SCHEDULER_CONNECTION_STRING", + "workItemFilteringEnabled": true + } + } + }, + "extensionBundle": { + "id": "Microsoft.Azure.Functions.ExtensionBundle", + "version": "[4.*, 5.0.0)" + } +} diff --git a/samples/durable-functions/python/work-item-filtering/requirements.txt b/samples/durable-functions/python/work-item-filtering/requirements.txt new file mode 100644 index 00000000..4d2b03ac --- /dev/null +++ b/samples/durable-functions/python/work-item-filtering/requirements.txt @@ -0,0 +1,2 @@ +azure-functions +azure-functions-durable diff --git a/samples/durable-functions/python/work-item-filtering/test.http b/samples/durable-functions/python/work-item-filtering/test.http new file mode 100644 index 00000000..9199b9f6 --- /dev/null +++ b/samples/durable-functions/python/work-item-filtering/test.http @@ -0,0 +1,80 @@ +# ============================================================================= +# Work Item Filtering — Durable Functions (Python) Demo +# ============================================================================= +# +# Prerequisites: +# 1. DTS emulator: docker run -d -p 8080:8080 -p 8082:8082 mcr.microsoft.com/dts/dts-emulator:latest +# 2. Azurite: docker run -d -p 10000:10000 -p 10001:10001 -p 10002:10002 mcr.microsoft.com/azure-storage/azurite +# 3. App running: python -m venv .venv && .venv/Scripts/activate && pip install -r requirements.txt && func start +# +# Registered functions: greeting_orchestration, fan_out_orchestration, +# parent_orchestration, counter_orchestration, say_hello, counter_entity +# ============================================================================= + +@baseUrl = http://localhost:7071/api + + +# --- TEST 1: Simple orchestration + activity --- +# Expected: Completed with "Hello, World!" + +### Start greeting orchestration +# @name greeting +POST {{baseUrl}}/orchestrators/greeting + +### Check status (use statusQueryGetUri from response) +GET {{baseUrl}}/instances/{{greeting.response.body.id}} + + +# --- TEST 2: Fan-out/fan-in --- +# Expected: Completed with ["Hello, Tokyo!", "Hello, London!", "Hello, Seattle!"] + +### Start fan-out orchestration +# @name fanout +POST {{baseUrl}}/orchestrators/fanout + +### Check status +GET {{baseUrl}}/instances/{{fanout.response.body.id}} + + +# --- TEST 3: Sub-orchestration --- +# Expected: Completed with "Parent received: Hello, World!" + +### Start parent orchestration +# @name parent +POST {{baseUrl}}/orchestrators/parent + +### Check status +GET {{baseUrl}}/instances/{{parent.response.body.id}} + + +# --- TEST 4: Entity interaction --- +# Expected: Completed with 30 + +### Start counter orchestration +# @name counter +POST {{baseUrl}}/orchestrators/counter + +### Check status +GET {{baseUrl}}/instances/{{counter.response.body.id}} + + +# --- TEST 5: Filter isolation --- +# Expected: Stays Pending (no worker has this function) + +### Start unknown orchestration +# @name unknown +POST {{baseUrl}}/start/SomeOtherOrchestration + +### Check — should be Pending +GET {{baseUrl}}/instances/{{unknown.response.body.id}} + + +# --- EXPECTED RESULTS --- +# +# | Test | Orchestration | Status | Why | +# |------|--------------------------|-----------|---------------------------| +# | 1 | greeting_orchestration | Completed | Matches filter | +# | 2 | fan_out_orchestration | Completed | Activities also filtered | +# | 3 | parent_orchestration | Completed | Sub-orch also filtered | +# | 4 | counter_orchestration | Completed | Entities also filtered | +# | 5 | SomeOtherOrchestration | Pending | No matching filter → held | From 55932fb0544f9a73c077924749e8fb4b8a3aa2ca Mon Sep 17 00:00:00 2001 From: Varshi Bachu Date: Tue, 19 May 2026 11:34:33 -0700 Subject: [PATCH 2/4] Add split-worker work item filtering samples for .NET and Python and update versions --- .../dotnet/WorkItemFiltering.AppB/AppB.csproj | 18 +++ .../WorkItemFiltering.AppB/Functions.cs | 72 +++++++++++ .../dotnet/WorkItemFiltering.AppB/Program.cs | 5 + .../dotnet/WorkItemFiltering.AppB/host.json | 22 ++++ .../dotnet/WorkItemFiltering/README.md | 31 ++++- .../WorkItemFiltering.csproj | 4 +- .../dotnet/WorkItemFiltering/host.json | 5 +- samples/durable-functions/dotnet/run-both.ps1 | 91 ++++++++++++++ samples/durable-functions/python/run-both.ps1 | 119 ++++++++++++++++++ .../extensions.csproj | 13 ++ .../work-item-filtering-app-b/function_app.py | 57 +++++++++ .../work-item-filtering-app-b/host.json | 22 ++++ .../requirements.txt | 2 + .../python/work-item-filtering/README.md | 56 ++++++++- .../work-item-filtering/extensions.csproj | 13 ++ .../work-item-filtering/function_app.py | 15 +-- .../python/work-item-filtering/host.json | 10 +- 17 files changed, 529 insertions(+), 26 deletions(-) create mode 100644 samples/durable-functions/dotnet/WorkItemFiltering.AppB/AppB.csproj create mode 100644 samples/durable-functions/dotnet/WorkItemFiltering.AppB/Functions.cs create mode 100644 samples/durable-functions/dotnet/WorkItemFiltering.AppB/Program.cs create mode 100644 samples/durable-functions/dotnet/WorkItemFiltering.AppB/host.json create mode 100644 samples/durable-functions/dotnet/run-both.ps1 create mode 100644 samples/durable-functions/python/run-both.ps1 create mode 100644 samples/durable-functions/python/work-item-filtering-app-b/extensions.csproj create mode 100644 samples/durable-functions/python/work-item-filtering-app-b/function_app.py create mode 100644 samples/durable-functions/python/work-item-filtering-app-b/host.json create mode 100644 samples/durable-functions/python/work-item-filtering-app-b/requirements.txt create mode 100644 samples/durable-functions/python/work-item-filtering/extensions.csproj diff --git a/samples/durable-functions/dotnet/WorkItemFiltering.AppB/AppB.csproj b/samples/durable-functions/dotnet/WorkItemFiltering.AppB/AppB.csproj new file mode 100644 index 00000000..9d45ea39 --- /dev/null +++ b/samples/durable-functions/dotnet/WorkItemFiltering.AppB/AppB.csproj @@ -0,0 +1,18 @@ + + + net8.0 + v4 + Exe + enable + enable + WorkItemFiltering.AppB + WorkItemFiltering.AppB + + + + + + + + + diff --git a/samples/durable-functions/dotnet/WorkItemFiltering.AppB/Functions.cs b/samples/durable-functions/dotnet/WorkItemFiltering.AppB/Functions.cs new file mode 100644 index 00000000..ea5e5340 --- /dev/null +++ b/samples/durable-functions/dotnet/WorkItemFiltering.AppB/Functions.cs @@ -0,0 +1,72 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Net; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Http; +using Microsoft.DurableTask; +using Microsoft.DurableTask.Client; +using Microsoft.Extensions.Logging; + +namespace WorkItemFiltering.AppB; + +// ============================================================================= +// App B — registers an entirely DIFFERENT set of functions from App A. +// Both apps share the same DTS task hub ("default"). Work item filtering ensures +// each app only receives work items for the functions it has registered. +// +// App A owns: GreetingOrchestration, FanOutOrchestration, ParentOrchestration, +// CounterOrchestration, SayHello activity, CounterEntity +// App B owns: OrdersOrchestration, ShipOrder activity +// +// Either app's client endpoint can SCHEDULE any orchestration name. The +// scheduler routes the work item to the app whose filter matches. +// ============================================================================= + +public static class OrdersOrchestration +{ + [Function(nameof(OrdersOrchestration))] + public static async Task Run([OrchestrationTrigger] TaskOrchestrationContext ctx) + { + var logger = ctx.CreateReplaySafeLogger(nameof(OrdersOrchestration)); + logger.LogInformation("OrdersOrchestration started on App B"); + + string orderId = ctx.GetInput() ?? $"order-{ctx.NewGuid():N}"; + string shipResult = await ctx.CallActivityAsync(nameof(ShipOrder), orderId); + return shipResult; + } + + [Function(nameof(OrdersOrchestration) + "_Start")] + public static async Task Start( + [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "orchestrators/orders")] HttpRequestData req, + [DurableClient] DurableTaskClient client) + { + string instanceId = await client.ScheduleNewOrchestrationInstanceAsync( + nameof(OrdersOrchestration), input: "order-42"); + return client.CreateCheckStatusResponse(req, instanceId); + } +} + +public static class ShipOrder +{ + [Function(nameof(ShipOrder))] + public static string Run([ActivityTrigger] string orderId, FunctionContext ctx) + { + ctx.GetLogger(nameof(ShipOrder)).LogInformation("App B shipping {OrderId}", orderId); + return $"Shipped {orderId} from App B"; + } +} + +// Generic starter so you can schedule ANY orchestration name from App B's port too. +public static class GenericStarter +{ + [Function("AppB_StartOrchestration")] + public static async Task Start( + [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "start/{name}")] HttpRequestData req, + [DurableClient] DurableTaskClient client, + string name) + { + string instanceId = await client.ScheduleNewOrchestrationInstanceAsync(name); + return client.CreateCheckStatusResponse(req, instanceId); + } +} diff --git a/samples/durable-functions/dotnet/WorkItemFiltering.AppB/Program.cs b/samples/durable-functions/dotnet/WorkItemFiltering.AppB/Program.cs new file mode 100644 index 00000000..46bca8a3 --- /dev/null +++ b/samples/durable-functions/dotnet/WorkItemFiltering.AppB/Program.cs @@ -0,0 +1,5 @@ +using Microsoft.Azure.Functions.Worker.Builder; +using Microsoft.Extensions.Hosting; + +FunctionsApplicationBuilder builder = FunctionsApplication.CreateBuilder(args); +builder.Build().Run(); diff --git a/samples/durable-functions/dotnet/WorkItemFiltering.AppB/host.json b/samples/durable-functions/dotnet/WorkItemFiltering.AppB/host.json new file mode 100644 index 00000000..cf8d8c43 --- /dev/null +++ b/samples/durable-functions/dotnet/WorkItemFiltering.AppB/host.json @@ -0,0 +1,22 @@ +{ + "version": "2.0", + "logging": { + "logLevel": { + "default": "Information", + "DurableTask.AzureStorage": "Warning", + "DurableTask.Core": "Warning", + "Microsoft.DurableTask": "Information", + "Host.Triggers.DurableTask": "Information" + } + }, + "extensions": { + "durableTask": { + "hubName": "default", + "storageProvider": { + "type": "azureManaged", + "connectionStringName": "DURABLE_TASK_SCHEDULER_CONNECTION_STRING", + "workItemFilteringEnabled": true + } + } + } +} diff --git a/samples/durable-functions/dotnet/WorkItemFiltering/README.md b/samples/durable-functions/dotnet/WorkItemFiltering/README.md index 1a8779ce..1563e101 100644 --- a/samples/durable-functions/dotnet/WorkItemFiltering/README.md +++ b/samples/durable-functions/dotnet/WorkItemFiltering/README.md @@ -91,12 +91,33 @@ No code changes are needed — filtering is automatic based on the functions reg ## Multi-App Scenario -To see filter isolation in action across two apps: +A sibling project [`WorkItemFiltering.AppB`](../WorkItemFiltering.AppB/) registers an entirely **different** set of functions (`OrdersOrchestration`, `ShipOrder` activity) against the **same** DTS task hub (`default`). The scheduler routes each work item to whichever app's filter matches. -1. Create a second Function app with **different** orchestrations/activities -2. Point both apps to the **same** DTS task hub -3. Enable `workItemFilteringEnabled: true` in both -4. Schedule orchestrations — each app only processes its own functions +Run both apps together with the included script: + +```powershell +# From samples/durable-functions/dotnet/ +.\run-both.ps1 +``` + +The script builds both projects, starts App A on `:7071` and App B on `:7072`, and exercises five scenarios: + +| # | Scenario | Expected | +|---|----------------------------------------------------------------|---------------------| +| 1 | App A orchestration, scheduled from App A client | `Completed` | +| 2 | App B orchestration, scheduled from App B client | `Completed` | +| 3 | **Cross-app:** App B's `OrdersOrchestration` from App A client | `Completed` on B | +| 4 | **Cross-app:** App A's `GreetingOrchestration` from App B client | `Completed` on A | +| 5 | Orchestration neither app has registered | `Pending` (forever) | + +Scenarios 3 and 4 are the point: the client app does not need to host the orchestrator. Without `workItemFilteringEnabled`, both apps would race to dispatch every work item and one would fail with *"function does not exist"*. With filtering on, the scheduler delivers only matching work to each app. + +Helpful flags: + +```powershell +.\run-both.ps1 -StartOnly # leave both running for manual testing +.\run-both.ps1 -StopOnly # kill any running func hosts +``` ## Viewing in the Dashboard diff --git a/samples/durable-functions/dotnet/WorkItemFiltering/WorkItemFiltering.csproj b/samples/durable-functions/dotnet/WorkItemFiltering/WorkItemFiltering.csproj index cc3bb4c5..2b802090 100644 --- a/samples/durable-functions/dotnet/WorkItemFiltering/WorkItemFiltering.csproj +++ b/samples/durable-functions/dotnet/WorkItemFiltering/WorkItemFiltering.csproj @@ -8,8 +8,8 @@ - - + + diff --git a/samples/durable-functions/dotnet/WorkItemFiltering/host.json b/samples/durable-functions/dotnet/WorkItemFiltering/host.json index da06fd07..cf8d8c43 100644 --- a/samples/durable-functions/dotnet/WorkItemFiltering/host.json +++ b/samples/durable-functions/dotnet/WorkItemFiltering/host.json @@ -2,8 +2,11 @@ "version": "2.0", "logging": { "logLevel": { + "default": "Information", "DurableTask.AzureStorage": "Warning", - "DurableTask.Core": "Warning" + "DurableTask.Core": "Warning", + "Microsoft.DurableTask": "Information", + "Host.Triggers.DurableTask": "Information" } }, "extensions": { diff --git a/samples/durable-functions/dotnet/run-both.ps1 b/samples/durable-functions/dotnet/run-both.ps1 new file mode 100644 index 00000000..4f726190 --- /dev/null +++ b/samples/durable-functions/dotnet/run-both.ps1 @@ -0,0 +1,91 @@ +# Runs App A (port 7071) and App B (port 7072) against the same DTS task hub. +# Demonstrates work item filtering: each app only processes the orchestrations +# and activities it has registered. Cross-app scheduling still works because +# DTS routes each work item to the app whose filter matches. +# +# Usage: +# .\run-both.ps1 # start both apps, wait, exercise scenarios +# .\run-both.ps1 -StartOnly # just start them and leave running +# .\run-both.ps1 -StopOnly # kill any running func hosts on 7071/7072 + +[CmdletBinding()] +param( + [switch]$StartOnly, + [switch]$StopOnly +) + +$ErrorActionPreference = 'Stop' +$root = Split-Path -Parent $MyInvocation.MyCommand.Path +$appA = Join-Path $root 'WorkItemFiltering' +$appB = Join-Path $root 'WorkItemFiltering.AppB' +$logA = Join-Path $env:TEMP 'wif-appA.log' +$logB = Join-Path $env:TEMP 'wif-appB.log' + +function Stop-Funcs { + Get-Process func, Microsoft.Azure.Functions.JobHost -ErrorAction SilentlyContinue | + Stop-Process -Force -ErrorAction SilentlyContinue +} + +Stop-Funcs +if ($StopOnly) { Write-Host 'Stopped func hosts.'; return } + +Write-Host '== Building App A and App B ==' +dotnet build $appA -c Debug --nologo | Select-Object -Last 3 +dotnet build $appB -c Debug --nologo | Select-Object -Last 3 + +Remove-Item $logA, $logB -ErrorAction SilentlyContinue + +Write-Host '== Starting App A on :7071 ==' +$pA = Start-Process -FilePath func -ArgumentList 'start','--port','7071' ` + -WorkingDirectory $appA -RedirectStandardOutput $logA ` + -RedirectStandardError "$logA.err" -NoNewWindow -PassThru + +Write-Host '== Starting App B on :7072 ==' +$pB = Start-Process -FilePath func -ArgumentList 'start','--port','7072' ` + -WorkingDirectory $appB -RedirectStandardOutput $logB ` + -RedirectStandardError "$logB.err" -NoNewWindow -PassThru + +Write-Host "App A PID=$($pA.Id) App B PID=$($pB.Id)" +Write-Host 'Waiting 30s for both hosts to start and register filters...' +Start-Sleep 30 + +if ($StartOnly) { + Write-Host 'Both apps running. Stop with: .\run-both.ps1 -StopOnly' + return +} + +function Invoke-Scenario([string]$Name, [string]$Url) { + Write-Host "`n-- $Name --" + try { + $r = Invoke-RestMethod -Method Post -Uri $Url -TimeoutSec 30 + Start-Sleep 8 + $s = Invoke-RestMethod -Uri $r.statusQueryGetUri + Write-Host (" status={0} output={1}" -f $s.runtimeStatus, ($s.output | ConvertTo-Json -Compress)) + } catch { + Write-Host " ERROR: $_" + } +} + +Invoke-Scenario 'App A orchestration via App A client (own filter)' ` + 'http://localhost:7071/api/orchestrators/greeting' + +Invoke-Scenario 'App B orchestration via App B client (own filter)' ` + 'http://localhost:7072/api/orchestrators/orders' + +Invoke-Scenario 'CROSS-APP: schedule App B orchestration from App A client' ` + 'http://localhost:7071/api/start/OrdersOrchestration' + +Invoke-Scenario 'CROSS-APP: schedule App A orchestration from App B client' ` + 'http://localhost:7072/api/start/GreetingOrchestration' + +Invoke-Scenario 'UNKNOWN: orchestration that no app has registered' ` + 'http://localhost:7071/api/start/NobodyOwnsThis' + +Write-Host "`n== Filter registration log lines ==" +Write-Host '-- App A --' +Select-String -Path $logA -Pattern 'Work item filtering|Registered \d+ orch' | Select-Object -Last 2 +Write-Host '-- App B --' +Select-String -Path $logB -Pattern 'Work item filtering|Registered \d+ orch' | Select-Object -Last 2 + +Write-Host "`nLogs: $logA / $logB" +Write-Host 'Apps still running. Stop with: .\run-both.ps1 -StopOnly' diff --git a/samples/durable-functions/python/run-both.ps1 b/samples/durable-functions/python/run-both.ps1 new file mode 100644 index 00000000..2ace3fb1 --- /dev/null +++ b/samples/durable-functions/python/run-both.ps1 @@ -0,0 +1,119 @@ +# Runs work-item-filtering App A (port 7071) and App B (port 7072) against the +# same DTS task hub. Both apps have manual extensions (extensions.csproj +# referencing Microsoft.Azure.WebJobs.Extensions.DurableTask.AzureManaged +# v1.8.1 from the local NuGet feed) so they pick up the latest filter changes +# instead of the extension bundle. +# +# Usage: +# .\run-both.ps1 # set up venv, install extensions, run scenarios +# .\run-both.ps1 -StartOnly # set up & start, leave running +# .\run-both.ps1 -StopOnly # kill any running func hosts + +[CmdletBinding()] +param( + [switch]$StartOnly, + [switch]$StopOnly, + [switch]$SkipSetup +) + +$ErrorActionPreference = 'Stop' +$root = Split-Path -Parent $MyInvocation.MyCommand.Path +$appA = Join-Path $root 'work-item-filtering' +$appB = Join-Path $root 'work-item-filtering-app-b' +$venv = Join-Path $root '.venv-wif' +$logA = Join-Path $env:TEMP 'wif-py-appA.log' +$logB = Join-Path $env:TEMP 'wif-py-appB.log' + +function Stop-Funcs { + Get-Process func, Microsoft.Azure.Functions.JobHost, python -ErrorAction SilentlyContinue | + Where-Object { + $_.ProcessName -in @('func','Microsoft.Azure.Functions.JobHost') -or + ($_.ProcessName -eq 'python' -and $_.Path -like "$venv*") + } | + Stop-Process -Force -ErrorAction SilentlyContinue +} + +Stop-Funcs +if ($StopOnly) { Write-Host 'Stopped func hosts.'; return } + +if (-not $SkipSetup) { + if (-not (Test-Path $venv)) { + Write-Host '== Creating shared venv ==' + python -m venv $venv + } + $py = Join-Path $venv 'Scripts\python.exe' + Write-Host '== Installing Python deps ==' + & $py -m pip install --quiet --upgrade pip + & $py -m pip install --quiet -r (Join-Path $appA 'requirements.txt') + + foreach ($app in @($appA, $appB)) { + Write-Host "== func extensions install in $(Split-Path -Leaf $app) ==" + Push-Location $app + try { + # func extensions install shells out to `dotnet build` of extensions.csproj + # The local NuGet.config feed will be picked up automatically. + func extensions install --force 2>&1 | Select-Object -Last 4 + } finally { Pop-Location } + } +} + +# Activate venv for this shell so Start-Process inherits the right python on PATH +$env:VIRTUAL_ENV = $venv +$env:PATH = "$venv\Scripts;$env:PATH" + +Remove-Item $logA, $logB, "$logA.err", "$logB.err" -ErrorAction SilentlyContinue + +Write-Host '== Starting App A on :7071 ==' +$pA = Start-Process -FilePath func -ArgumentList 'start','--port','7071' ` + -WorkingDirectory $appA -RedirectStandardOutput $logA ` + -RedirectStandardError "$logA.err" -NoNewWindow -PassThru + +Write-Host '== Starting App B on :7072 ==' +$pB = Start-Process -FilePath func -ArgumentList 'start','--port','7072' ` + -WorkingDirectory $appB -RedirectStandardOutput $logB ` + -RedirectStandardError "$logB.err" -NoNewWindow -PassThru + +Write-Host "App A PID=$($pA.Id) App B PID=$($pB.Id)" +Write-Host 'Waiting 30s for both hosts to start and register filters...' +Start-Sleep 30 + +if ($StartOnly) { + Write-Host 'Both apps running. Stop with: .\run-both.ps1 -StopOnly' + return +} + +function Invoke-Scenario([string]$Name, [string]$Url) { + Write-Host "`n-- $Name --" + try { + $r = Invoke-RestMethod -Method Post -Uri $Url -TimeoutSec 30 + Start-Sleep 8 + $s = Invoke-RestMethod -Uri $r.statusQueryGetUri + Write-Host (" status={0} output={1}" -f $s.runtimeStatus, ($s.output | ConvertTo-Json -Compress)) + } catch { + Write-Host " ERROR: $_" + } +} + +Invoke-Scenario 'App A orchestration via App A client (own filter)' ` + 'http://localhost:7071/api/orchestrators/greeting' + +Invoke-Scenario 'App B orchestration via App B client (own filter)' ` + 'http://localhost:7072/api/orchestrators/orders' + +Invoke-Scenario 'CROSS-APP: schedule App B orchestration from App A client' ` + 'http://localhost:7071/api/start/orders_orchestration' + +Invoke-Scenario 'CROSS-APP: schedule App A orchestration from App B client' ` + 'http://localhost:7072/api/start/greeting_orchestration' + +Invoke-Scenario 'UNKNOWN: orchestration that no app has registered' ` + 'http://localhost:7071/api/start/nobody_owns_this' + +Write-Host "`n== Filter registration log lines ==" +Write-Host '-- App A --' +Select-String -Path $logA -Pattern 'Work item filtering|Registered \d+ orch|AzureManagedProvider' | Select-Object -Last 3 +Write-Host '-- App B --' +Select-String -Path $logB -Pattern 'Work item filtering|Registered \d+ orch|AzureManagedProvider' | Select-Object -Last 3 + +Write-Host "`nLogs: $logA / $logB" +Write-Host 'Apps still running. Stop with: .\run-both.ps1 -StopOnly' diff --git a/samples/durable-functions/python/work-item-filtering-app-b/extensions.csproj b/samples/durable-functions/python/work-item-filtering-app-b/extensions.csproj new file mode 100644 index 00000000..0c35d3fc --- /dev/null +++ b/samples/durable-functions/python/work-item-filtering-app-b/extensions.csproj @@ -0,0 +1,13 @@ + + + net8.0 + + ** + <_FunctionsSkipCleanOutput>true + + + + + + + diff --git a/samples/durable-functions/python/work-item-filtering-app-b/function_app.py b/samples/durable-functions/python/work-item-filtering-app-b/function_app.py new file mode 100644 index 00000000..fe9c9086 --- /dev/null +++ b/samples/durable-functions/python/work-item-filtering-app-b/function_app.py @@ -0,0 +1,57 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +""" +Work Item Filtering — App B (Python). + +App B registers an entirely DIFFERENT set of functions from App A. Both apps +share the same DTS task hub ("default"). Work item filtering ensures each app +only receives work items for the functions IT has registered. + +App A owns: greeting_orchestration, fan_out_orchestration, + parent_orchestration, counter_orchestration, + say_hello activity, counter_entity +App B owns: orders_orchestration, ship_order activity + +Either app's HTTP client can SCHEDULE any orchestration name. The scheduler +routes the work item to the app whose filter matches. +""" + +import logging +import azure.functions as func +import azure.durable_functions as df + +app = func.FunctionApp(http_auth_level=func.AuthLevel.ANONYMOUS) +bp = df.Blueprint() + + +@bp.orchestration_trigger(context_name="context") +def orders_orchestration(context: df.DurableOrchestrationContext): + order_id = context.get_input() or f"order-{context.new_guid()}" + result = yield context.call_activity("ship_order", order_id) + return result + + +@bp.activity_trigger(input_name="orderId") +def ship_order(orderId: str) -> str: + logging.info("App B shipping %s", orderId) + return f"Shipped {orderId} from App B" + + +@app.route(route="orchestrators/orders", methods=["POST"]) +@app.durable_client_input(client_name="client") +async def start_orders(req: func.HttpRequest, client) -> func.HttpResponse: + instance_id = await client.start_new("orders_orchestration", client_input="order-42") + return client.create_check_status_response(req, instance_id) + + +@app.route(route="start/{name}", methods=["POST"]) +@app.durable_client_input(client_name="client") +async def start_any(req: func.HttpRequest, client) -> func.HttpResponse: + """Generic starter: schedule ANY orchestration by name from App B.""" + name = req.route_params.get("name") + instance_id = await client.start_new(name) + return client.create_check_status_response(req, instance_id) + + +app.register_functions(bp) diff --git a/samples/durable-functions/python/work-item-filtering-app-b/host.json b/samples/durable-functions/python/work-item-filtering-app-b/host.json new file mode 100644 index 00000000..cf8d8c43 --- /dev/null +++ b/samples/durable-functions/python/work-item-filtering-app-b/host.json @@ -0,0 +1,22 @@ +{ + "version": "2.0", + "logging": { + "logLevel": { + "default": "Information", + "DurableTask.AzureStorage": "Warning", + "DurableTask.Core": "Warning", + "Microsoft.DurableTask": "Information", + "Host.Triggers.DurableTask": "Information" + } + }, + "extensions": { + "durableTask": { + "hubName": "default", + "storageProvider": { + "type": "azureManaged", + "connectionStringName": "DURABLE_TASK_SCHEDULER_CONNECTION_STRING", + "workItemFilteringEnabled": true + } + } + } +} diff --git a/samples/durable-functions/python/work-item-filtering-app-b/requirements.txt b/samples/durable-functions/python/work-item-filtering-app-b/requirements.txt new file mode 100644 index 00000000..4d2b03ac --- /dev/null +++ b/samples/durable-functions/python/work-item-filtering-app-b/requirements.txt @@ -0,0 +1,2 @@ +azure-functions +azure-functions-durable diff --git a/samples/durable-functions/python/work-item-filtering/README.md b/samples/durable-functions/python/work-item-filtering/README.md index 10cef76f..7b63ee77 100644 --- a/samples/durable-functions/python/work-item-filtering/README.md +++ b/samples/durable-functions/python/work-item-filtering/README.md @@ -11,8 +11,21 @@ This sample includes orchestrations, activities, entities, sub-orchestrations, a ## Prerequisites 1. [Python 3.9+](https://www.python.org/downloads/) -2. [Docker](https://www.docker.com/products/docker-desktop/) (for running the emulator and Azurite) -3. [Azure Functions Core Tools v4](https://learn.microsoft.com/azure/azure-functions/functions-run-local) +2. [.NET 8 SDK](https://dotnet.microsoft.com/download/dotnet/8.0) — required to build the manual extension bundle (see below) +3. [Docker](https://www.docker.com/products/docker-desktop/) (for running the emulator and Azurite) +4. [Azure Functions Core Tools v4](https://learn.microsoft.com/azure/azure-functions/functions-run-local) + +## Extension Management + +This sample does **not** use the Azure Functions extension bundle. Instead it pins the Durable Task `azureManaged` provider to a specific version via [`extensions.csproj`](extensions.csproj) — required so the app picks up updates to `Microsoft.Azure.WebJobs.Extensions.DurableTask.AzureManaged` ahead of the public bundle. + +Before the first run, install the extensions into `bin/`: + +```powershell +func extensions install +``` + +Core Tools reads `extensions.csproj`, restores the pinned packages, and generates `bin/extensions.json` with the Durable + AzureManaged DLLs so the Functions host loads them at startup. ## Quick Run @@ -26,12 +39,13 @@ This sample includes orchestrations, activities, entities, sub-orchestrations, a docker run --name azurite -d -p 10000:10000 -p 10001:10001 -p 10002:10002 mcr.microsoft.com/azure-storage/azurite ``` -3. Set up the Python environment and start the Function app: +3. Set up the Python environment, build the extensions, and start the Function app: ```bash python -m venv .venv source .venv/bin/activate # Linux/macOS # .venv\Scripts\activate # Windows pip install -r requirements.txt + func extensions install func start ``` @@ -116,6 +130,42 @@ To use a Durable Task Scheduler in Azure instead of the emulator, update `local. - [WorkItemFilteringSplitActivities](../../../scenarios/WorkItemFilteringSplitActivities/) — Multi-worker scenario using Durable Task SDK - [Fan-out/Fan-in (Python)](../fan-out-fan-in/) — Fan-out pattern without filtering +## Multi-App Scenario + +A sibling project [`work-item-filtering-app-b`](../work-item-filtering-app-b/) registers an entirely **different** set of functions (`orders_orchestration`, `ship_order` activity) against the **same** DTS task hub (`default`). The scheduler routes each work item to whichever app's filter matches. + +Run both apps together with the included script: + +```powershell +# From samples/durable-functions/python/ +.\run-both.ps1 +``` + +The script: + +1. Creates a shared venv at `python/.venv-wif` and installs `requirements.txt` +2. Builds `extensions.csproj` for both apps (App A and App B) +3. Starts App A on `:7071` and App B on `:7072` +4. Exercises five scenarios: + +| # | Scenario | Expected | +|---|----------------------------------------------------------------|---------------------| +| 1 | App A orchestration, scheduled from App A client | `Completed` | +| 2 | App B orchestration, scheduled from App B client | `Completed` | +| 3 | **Cross-app:** App B's `orders_orchestration` from App A client | `Completed` on B | +| 4 | **Cross-app:** App A's `greeting_orchestration` from App B client | `Completed` on A | +| 5 | Orchestration neither app has registered | `Pending` (forever) | + +Scenarios 3 and 4 are the point: the client app does not need to host the orchestrator. Without `workItemFilteringEnabled`, both apps would race to dispatch every work item and one would fail with *"function does not exist"*. With filtering on, the scheduler delivers only matching work to each app. + +Helpful flags: + +```powershell +.\run-both.ps1 -StartOnly # leave both running for manual testing +.\run-both.ps1 -StopOnly # kill any running func hosts +.\run-both.ps1 -SkipSetup # skip venv + extension build +``` + ## Learn More - [Durable Task Scheduler documentation](https://learn.microsoft.com/azure/azure-functions/durable/durable-functions-task-hubs) diff --git a/samples/durable-functions/python/work-item-filtering/extensions.csproj b/samples/durable-functions/python/work-item-filtering/extensions.csproj new file mode 100644 index 00000000..0c35d3fc --- /dev/null +++ b/samples/durable-functions/python/work-item-filtering/extensions.csproj @@ -0,0 +1,13 @@ + + + net8.0 + + ** + <_FunctionsSkipCleanOutput>true + + + + + + + diff --git a/samples/durable-functions/python/work-item-filtering/function_app.py b/samples/durable-functions/python/work-item-filtering/function_app.py index 54e79cc8..387f91a4 100644 --- a/samples/durable-functions/python/work-item-filtering/function_app.py +++ b/samples/durable-functions/python/work-item-filtering/function_app.py @@ -96,51 +96,46 @@ def counter_entity(context: df.DurableEntityContext): # HTTP triggers # ============================================================================= -@bp.durable_client_input(client_name="client") @app.route(route="orchestrators/greeting", methods=["POST"]) +@app.durable_client_input(client_name="client") async def start_greeting(req: func.HttpRequest, client) -> func.HttpResponse: """Start the greeting orchestration.""" - client = df.DurableOrchestrationClient(client) instance_id = await client.start_new("greeting_orchestration") return client.create_check_status_response(req, instance_id) -@bp.durable_client_input(client_name="client") @app.route(route="orchestrators/fanout", methods=["POST"]) +@app.durable_client_input(client_name="client") async def start_fan_out(req: func.HttpRequest, client) -> func.HttpResponse: """Start the fan-out orchestration.""" - client = df.DurableOrchestrationClient(client) instance_id = await client.start_new("fan_out_orchestration") return client.create_check_status_response(req, instance_id) -@bp.durable_client_input(client_name="client") @app.route(route="orchestrators/parent", methods=["POST"]) +@app.durable_client_input(client_name="client") async def start_parent(req: func.HttpRequest, client) -> func.HttpResponse: """Start the parent orchestration.""" - client = df.DurableOrchestrationClient(client) instance_id = await client.start_new("parent_orchestration") return client.create_check_status_response(req, instance_id) -@bp.durable_client_input(client_name="client") @app.route(route="orchestrators/counter", methods=["POST"]) +@app.durable_client_input(client_name="client") async def start_counter(req: func.HttpRequest, client) -> func.HttpResponse: """Start the counter orchestration (entity interaction).""" - client = df.DurableOrchestrationClient(client) instance_id = await client.start_new("counter_orchestration") return client.create_check_status_response(req, instance_id) -@bp.durable_client_input(client_name="client") @app.route(route="start/{name}", methods=["POST"]) +@app.durable_client_input(client_name="client") async def start_any(req: func.HttpRequest, client) -> func.HttpResponse: """Generic starter — can schedule any orchestration by name. Useful for testing filter isolation: schedule an orchestration this app does NOT have and observe it stays Pending. """ name = req.route_params.get("name") - client = df.DurableOrchestrationClient(client) instance_id = await client.start_new(name) return client.create_check_status_response(req, instance_id) diff --git a/samples/durable-functions/python/work-item-filtering/host.json b/samples/durable-functions/python/work-item-filtering/host.json index 3bf3ab4b..cf8d8c43 100644 --- a/samples/durable-functions/python/work-item-filtering/host.json +++ b/samples/durable-functions/python/work-item-filtering/host.json @@ -2,7 +2,11 @@ "version": "2.0", "logging": { "logLevel": { - "DurableTask.Core": "Warning" + "default": "Information", + "DurableTask.AzureStorage": "Warning", + "DurableTask.Core": "Warning", + "Microsoft.DurableTask": "Information", + "Host.Triggers.DurableTask": "Information" } }, "extensions": { @@ -14,9 +18,5 @@ "workItemFilteringEnabled": true } } - }, - "extensionBundle": { - "id": "Microsoft.Azure.Functions.ExtensionBundle", - "version": "[4.*, 5.0.0)" } } From 59ce5138d1b5dcab3428810746dd674991d15945 Mon Sep 17 00:00:00 2001 From: Varshi Bachu Date: Tue, 19 May 2026 11:37:52 -0700 Subject: [PATCH 3/4] update build-samples.yml --- .github/workflows/build-samples.yml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.github/workflows/build-samples.yml b/.github/workflows/build-samples.yml index a68f0cc4..f6703281 100644 --- a/.github/workflows/build-samples.yml +++ b/.github/workflows/build-samples.yml @@ -105,6 +105,12 @@ jobs: - name: Build DF - LargePayloadFanOutFanIn run: dotnet build samples/durable-functions/dotnet/LargePayloadFanOutFanIn/LargePayloadFanOutFanIn.csproj + - name: Build DF - WorkItemFiltering + run: dotnet build samples/durable-functions/dotnet/WorkItemFiltering/WorkItemFiltering.csproj + + - name: Build DF - WorkItemFiltering.AppB + run: dotnet build samples/durable-functions/dotnet/WorkItemFiltering.AppB/AppB.csproj + # Durable Functions Aspire samples (net10.0) - name: Build DF - AzureFunctionsAndDtsWithAspire run: dotnet build samples/durable-functions/dotnet/AzureFunctionsAndDtsWithAspire/AspireHost/AspireHost.csproj @@ -209,6 +215,12 @@ jobs: - name: Install DF - pdf-summarizer run: pip install -r samples/durable-functions/python/pdf-summarizer/requirements.txt + - name: Install DF - work-item-filtering + run: pip install -r samples/durable-functions/python/work-item-filtering/requirements.txt + + - name: Install DF - work-item-filtering-app-b + run: pip install -r samples/durable-functions/python/work-item-filtering-app-b/requirements.txt + # Syntax check all Python files - name: Syntax check Python files run: find samples -name "*.py" -not -path "*/__pycache__/*" -exec python -m py_compile {} + From 4700a2f3639b199a543b64b74b10cce8277b06a8 Mon Sep 17 00:00:00 2001 From: Varshi Bachu Date: Tue, 19 May 2026 12:05:04 -0700 Subject: [PATCH 4/4] addressed copilot comments --- .../dotnet/WorkItemFiltering/Functions.cs | 1 - .../dotnet/WorkItemFiltering/test.http | 14 +++++++------- .../work-item-filtering-app-b/function_app.py | 8 ++++---- .../python/work-item-filtering/function_app.py | 1 - .../python/work-item-filtering/test.http | 16 +++++++++------- 5 files changed, 20 insertions(+), 20 deletions(-) diff --git a/samples/durable-functions/dotnet/WorkItemFiltering/Functions.cs b/samples/durable-functions/dotnet/WorkItemFiltering/Functions.cs index 71eae0d5..bca49439 100644 --- a/samples/durable-functions/dotnet/WorkItemFiltering/Functions.cs +++ b/samples/durable-functions/dotnet/WorkItemFiltering/Functions.cs @@ -1,7 +1,6 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -using System.Net; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.DurableTask; diff --git a/samples/durable-functions/dotnet/WorkItemFiltering/test.http b/samples/durable-functions/dotnet/WorkItemFiltering/test.http index 84acc9af..d9912e2c 100644 --- a/samples/durable-functions/dotnet/WorkItemFiltering/test.http +++ b/samples/durable-functions/dotnet/WorkItemFiltering/test.http @@ -24,8 +24,8 @@ # @name greeting POST {{baseUrl}}/orchestrators/greeting -### Check status -GET {{baseUrl}}/instances/{{greeting.response.body.id}} +### Check status (uses statusQueryGetUri returned by the starter) +GET {{greeting.response.body.statusQueryGetUri}} # --- TEST 2: Fan-out/fan-in (parallel activities) --- @@ -36,7 +36,7 @@ GET {{baseUrl}}/instances/{{greeting.response.body.id}} POST {{baseUrl}}/orchestrators/fanout ### Check status -GET {{baseUrl}}/instances/{{fanout.response.body.id}} +GET {{fanout.response.body.statusQueryGetUri}} # --- TEST 3: Sub-orchestration --- @@ -47,7 +47,7 @@ GET {{baseUrl}}/instances/{{fanout.response.body.id}} POST {{baseUrl}}/orchestrators/parent ### Check status -GET {{baseUrl}}/instances/{{parent.response.body.id}} +GET {{parent.response.body.statusQueryGetUri}} # --- TEST 4: Entity interaction --- @@ -58,7 +58,7 @@ GET {{baseUrl}}/instances/{{parent.response.body.id}} POST {{baseUrl}}/orchestrators/counter ### Check status -GET {{baseUrl}}/instances/{{counter.response.body.id}} +GET {{counter.response.body.statusQueryGetUri}} # --- TEST 5: Filter isolation — unknown orchestration --- @@ -69,10 +69,10 @@ GET {{baseUrl}}/instances/{{counter.response.body.id}} POST {{baseUrl}}/start/SomeOtherOrchestration ### Check status — should be Pending -GET {{baseUrl}}/instances/{{unknown.response.body.id}} +GET {{unknown.response.body.statusQueryGetUri}} ### Check again after 15 seconds — still Pending -GET {{baseUrl}}/instances/{{unknown.response.body.id}} +GET {{unknown.response.body.statusQueryGetUri}} # --- TEST 6: View all instances --- diff --git a/samples/durable-functions/python/work-item-filtering-app-b/function_app.py b/samples/durable-functions/python/work-item-filtering-app-b/function_app.py index fe9c9086..0d0703cd 100644 --- a/samples/durable-functions/python/work-item-filtering-app-b/function_app.py +++ b/samples/durable-functions/python/work-item-filtering-app-b/function_app.py @@ -32,10 +32,10 @@ def orders_orchestration(context: df.DurableOrchestrationContext): return result -@bp.activity_trigger(input_name="orderId") -def ship_order(orderId: str) -> str: - logging.info("App B shipping %s", orderId) - return f"Shipped {orderId} from App B" +@bp.activity_trigger(input_name="order_id") +def ship_order(order_id: str) -> str: + logging.info("App B shipping %s", order_id) + return f"Shipped {order_id} from App B" @app.route(route="orchestrators/orders", methods=["POST"]) diff --git a/samples/durable-functions/python/work-item-filtering/function_app.py b/samples/durable-functions/python/work-item-filtering/function_app.py index 387f91a4..8e97e48d 100644 --- a/samples/durable-functions/python/work-item-filtering/function_app.py +++ b/samples/durable-functions/python/work-item-filtering/function_app.py @@ -13,7 +13,6 @@ """ import logging -import json import azure.functions as func import azure.durable_functions as df diff --git a/samples/durable-functions/python/work-item-filtering/test.http b/samples/durable-functions/python/work-item-filtering/test.http index 9199b9f6..4e578b0d 100644 --- a/samples/durable-functions/python/work-item-filtering/test.http +++ b/samples/durable-functions/python/work-item-filtering/test.http @@ -5,7 +5,9 @@ # Prerequisites: # 1. DTS emulator: docker run -d -p 8080:8080 -p 8082:8082 mcr.microsoft.com/dts/dts-emulator:latest # 2. Azurite: docker run -d -p 10000:10000 -p 10001:10001 -p 10002:10002 mcr.microsoft.com/azure-storage/azurite -# 3. App running: python -m venv .venv && .venv/Scripts/activate && pip install -r requirements.txt && func start +# 3. App running: +# Linux/macOS: python -m venv .venv && source .venv/bin/activate && pip install -r requirements.txt && func extensions install && func start +# Windows: python -m venv .venv && .\.venv\Scripts\activate && pip install -r requirements.txt && func extensions install && func start # # Registered functions: greeting_orchestration, fan_out_orchestration, # parent_orchestration, counter_orchestration, say_hello, counter_entity @@ -21,8 +23,8 @@ # @name greeting POST {{baseUrl}}/orchestrators/greeting -### Check status (use statusQueryGetUri from response) -GET {{baseUrl}}/instances/{{greeting.response.body.id}} +### Check status (uses statusQueryGetUri returned by the starter) +GET {{greeting.response.body.statusQueryGetUri}} # --- TEST 2: Fan-out/fan-in --- @@ -33,7 +35,7 @@ GET {{baseUrl}}/instances/{{greeting.response.body.id}} POST {{baseUrl}}/orchestrators/fanout ### Check status -GET {{baseUrl}}/instances/{{fanout.response.body.id}} +GET {{fanout.response.body.statusQueryGetUri}} # --- TEST 3: Sub-orchestration --- @@ -44,7 +46,7 @@ GET {{baseUrl}}/instances/{{fanout.response.body.id}} POST {{baseUrl}}/orchestrators/parent ### Check status -GET {{baseUrl}}/instances/{{parent.response.body.id}} +GET {{parent.response.body.statusQueryGetUri}} # --- TEST 4: Entity interaction --- @@ -55,7 +57,7 @@ GET {{baseUrl}}/instances/{{parent.response.body.id}} POST {{baseUrl}}/orchestrators/counter ### Check status -GET {{baseUrl}}/instances/{{counter.response.body.id}} +GET {{counter.response.body.statusQueryGetUri}} # --- TEST 5: Filter isolation --- @@ -66,7 +68,7 @@ GET {{baseUrl}}/instances/{{counter.response.body.id}} POST {{baseUrl}}/start/SomeOtherOrchestration ### Check — should be Pending -GET {{baseUrl}}/instances/{{unknown.response.body.id}} +GET {{unknown.response.body.statusQueryGetUri}} # --- EXPECTED RESULTS ---