diff --git a/azure-functions-durable/CHANGELOG.md b/azure-functions-durable/CHANGELOG.md index 5bea7251..d15ee553 100644 --- a/azure-functions-durable/CHANGELOG.md +++ b/azure-functions-durable/CHANGELOG.md @@ -13,6 +13,9 @@ ADDED the synchronous client into synchronous functions and the asynchronous client into coroutine functions. Both clients support scheduled-task and history-export APIs without an async-to-sync bridge. +- Added runnable 2.x samples for function chaining, fan-out/fan-in, human +interaction, and durable entities, plus a migration guide for applications +upgrading from 1.x. ## 2.0.0b1 diff --git a/azure-functions-durable/MIGRATION_GUIDE.md b/azure-functions-durable/MIGRATION_GUIDE.md new file mode 100644 index 00000000..e43165f4 --- /dev/null +++ b/azure-functions-durable/MIGRATION_GUIDE.md @@ -0,0 +1,296 @@ +# Migrate from Azure Functions Durable 1.x to 2.x + +This guide explains how to move an application from +`azure-functions-durable` 1.x to 2.x. Version 2.x is a ground-up rewrite built +on the [`durabletask`](https://pypi.org/project/durabletask/) SDK and its gRPC +runtime. + +> [!WARNING] +> `azure-functions-durable` 2.x is currently in preview. Its APIs may change +> before the stable 2.0.0 release. + +## Choose a migration path + +Version 2.x includes compatibility shims for most 1.x APIs used by +decorator-based apps. This supports a staged migration: + +1. **Compatibility upgrade:** update the runtime and dependencies while keeping + existing one-argument orchestrators, entities, and client calls. These APIs + continue to work but emit `DeprecationWarning` where a native replacement + exists. +2. **Native API migration:** move to durabletask-native function signatures, + client methods, retry policies, and entity types. + +Use the compatibility upgrade first when minimizing application changes is more +important than adopting the new API immediately. + +## Requirements and breaking changes + +Before upgrading, account for these 2.x requirements: + +- **Python 3.13 or later is required.** +- **Only the Azure Functions Python V2 programming model is supported.** Apps + must use `DFApp` or `Blueprint` decorators. The classic programming model with + one directory and `function.json` per function is not supported. +- **A modern Durable Functions host is required.** Local sample apps use the + preview extension bundle shown below. +- **The OpenAI Agents integration was removed.** The + `azure.durable_functions.openai_agents` package is not available in 2.x. +- **The primary APIs now come from `durabletask`.** Compatibility aliases are + available to stage the migration, but new code should use the native names. + +If the app still uses the classic Python programming model, migrate it to the +[decorator-based model](https://learn.microsoft.com/azure/azure-functions/functions-reference-python) +before upgrading this package. + +## Prepare running orchestrations + +Orchestrator code must remain deterministic and replay-compatible for existing +instances. Before changing function names, activity schedules, branching logic, +or serialized inputs: + +- allow existing instances to finish; or +- deploy the migrated app with a new task hub and route new instances there. + +Test replay behavior with representative orchestration histories in a +non-production environment before updating a task hub that contains running +instances. + +## Perform a compatibility upgrade + +### 1. Update the Python runtime + +Update local development, CI, and the deployed Function App to Python 3.13 or +later. + +### 2. Update dependencies + +Replace the 1.x package constraint in `requirements.txt`: + +```text +azure-functions>=2.3.0b2 +azure-functions-durable>=2.0.0b1,<3 +``` + +### 3. Update the extension bundle + +Use the preview extension bundle that contains the required Durable Task gRPC +runtime: + +```json +{ + "version": "2.0", + "extensionBundle": { + "id": "Microsoft.Azure.Functions.ExtensionBundle.Preview", + "version": "[4.*, 5.0.0)" + }, + "extensions": { + "durableTask": { + "storageProvider": { + "type": "AzureStorage" + } + } + } +} +``` + +### 4. Keep supported 1.x code temporarily + +A decorator-based 1.x orchestrator and client starter can run through the 2.x +compatibility layer without changing their function shape: + +```python +import azure.functions as func +import azure.durable_functions as df + +app = df.DFApp(http_auth_level=func.AuthLevel.ANONYMOUS) + + +@app.orchestration_trigger(context_name="context") +def hello(context: df.DurableOrchestrationContext): + name = context.get_input() + return (yield context.call_activity("say_hello", name)) + + +@app.route(route="start", methods=["POST"]) +@app.durable_client_input(client_name="client") +async def start( + req: func.HttpRequest, + client: df.DurableFunctionsClient) -> func.HttpResponse: + instance_id = await client.start_new("hello", client_input="Tokyo") + return client.create_check_status_response(req, instance_id) +``` + +The single-argument orchestrator receives the compatibility +`DurableOrchestrationContext`, and `start_new` delegates to the native client. +Use this state to validate the infrastructure upgrade before changing +application APIs. + +## Migrate to native orchestration APIs + +Native orchestrators accept the durabletask context and input as separate +arguments: + +```python +from typing import Any + +import azure.functions as func +import azure.durable_functions as df +from durabletask import task + +app = df.DFApp(http_auth_level=func.AuthLevel.ANONYMOUS) + + +@app.orchestration_trigger(context_name="context") +def hello(ctx: task.OrchestrationContext, name: Any): + return (yield ctx.call_activity("say_hello", input=name)) + + +@app.route(route="start", methods=["POST"]) +@app.durable_client_input(client_name="client") +async def start( + req: func.HttpRequest, + client: df.DurableFunctionsClient) -> func.HttpResponse: + instance_id = await client.schedule_new_orchestration("hello", input="Tokyo") + return client.create_check_status_response(req, instance_id) +``` + +Activity functions can keep their existing single-input signature: + +```python +@app.activity_trigger(input_name="name") +def say_hello(name: str) -> str: + return f"Hello {name}!" +``` + +### Orchestration API mapping + +| 1.x API | Native 2.x API | +| --- | --- | +| `context.get_input()` | The orchestrator's second argument | +| `context.call_activity(name, input_)` | `ctx.call_activity(name, input=input_)` | +| `context.call_activity_with_retry(...)` | `ctx.call_activity(..., retry_policy=policy)` | +| `context.call_sub_orchestrator(name, input_)` | `ctx.call_sub_orchestrator(name, input=input_)` | +| `call_sub_orchestrator_with_retry` | `ctx.call_sub_orchestrator(..., retry_policy=...)` | +| `context.task_all(tasks)` | `task.when_all(tasks)` | +| `context.task_any(tasks)` | `task.when_any(tasks)` | +| `context.new_guid()` | `ctx.new_uuid()` | +| `df.EntityId(name, key)` | `entities.EntityInstanceId(name, key)` | + +Methods including `create_timer`, `wait_for_external_event`, +`continue_as_new`, `set_custom_status`, `call_entity`, and `signal_entity` +retain their names. Activity, sub-orchestration, and entity calls use keyword +`input` instead of the 1.x `input_` or `operationInput` names. +`continue_as_new` uses `new_input`. + +### Retry policy mapping + +`RetryOptions` accepted millisecond values. Native `RetryPolicy` uses +`timedelta`: + +```python +from datetime import timedelta + +from durabletask import task + +policy = task.RetryPolicy( + first_retry_interval=timedelta(seconds=1), + max_number_of_attempts=3, + backoff_coefficient=2.0, +) + +result = yield ctx.call_activity( + "call_service", + input=request, + retry_policy=policy, +) +``` + +## Migrate client APIs + +`durable_client_input` supplies `DurableFunctionsClient` to coroutine functions +and `SyncDurableFunctionsClient` to synchronous functions. Async client calls +must be awaited; the corresponding sync calls must not be awaited. + +| 1.x client API | Native 2.x client API | +| --- | --- | +| `start_new` | `schedule_new_orchestration` | +| `get_status` | `get_orchestration_state` | +| `get_status_all` | `get_all_orchestration_states` | +| `get_status_by` | `get_all_orchestration_states` with `OrchestrationQuery` | +| `raise_event` | `raise_orchestration_event` | +| `terminate` | `terminate_orchestration` | +| `suspend` | `suspend_orchestration` | +| `resume` | `resume_orchestration` | +| `restart` | `restart_orchestration` | +| `rewind` | `rewind_orchestration` | +| `purge_instance_history` | `purge_orchestration` | +| `purge_instance_history_by` | `purge_orchestrations_by` | +| `read_entity_state` | `get_entity` | +| `get_client_response_links` | `create_http_management_payload` | + +Native `restart_orchestration` reuses the current instance ID by default. Pass +`restart_with_new_instance_id=True` to preserve the 1.x `restart` default. + +Native status APIs return durabletask models rather than the compatibility +wrappers `DurableOrchestrationStatus`, `PurgeHistoryResult`, and +`EntityStateResponse`. Update code that serializes or checks those return +values. Use `get_orchestration_history` when history events are required. + +## Migrate durable entities + +One-argument entity functions remain available through +`DurableEntityContext`. Native code can use a two-argument entity function or a +`DurableEntity` class. A class exposes each method as an entity operation: + +```python +from typing import Any + +import azure.durable_functions as df +from durabletask.entities import DurableEntity + + +@app.entity_trigger(context_name="context") +class Counter(DurableEntity): + def add(self, input_: Any = None) -> int: + value = self.get_state(int, 0) + (input_ or 0) + self.set_state(value) + return value + + def get(self, input_: Any = None) -> int: + return self.get_state(int, 0) +``` + +Replace `df.EntityId` with `durabletask.entities.EntityInstanceId` at call +sites. For function-based entities, return the operation result directly +instead of calling `context.set_result`. + +## Review compatibility limitations + +- `DurableOrchestrationContext.histories` is unavailable. Retrieve history with + `client.get_orchestration_history(instance_id)`. +- The compatibility `show_history` and `show_history_output` status flags are + accepted but ignored. +- Distributed tracing is not yet wired through the Python provider. +- Continuous history export is not supported by Azure Functions. +- Unusual callable signatures can be misclassified by the compatibility layer. + Keep compatibility orchestrators and entities to exactly one positional + argument, and native functions to exactly two. + +See the [changelog](CHANGELOG.md) for the complete compatibility surface and +known limitations. + +## Validate and deploy + +Before production rollout: + +1. Run unit tests against Python 3.13 or later. +2. Run the app locally with Azurite and Azure Functions Core Tools. +3. Start new orchestration, sub-orchestration, entity, retry, timer, and + external-event scenarios used by the app. +4. Verify status queries and HTTP management URLs. +5. Test existing orchestration histories or drain existing instances. +6. Deploy to a staging slot or non-production task hub before production. + +The [2.x samples](samples/) provide runnable native examples for common +orchestration patterns. diff --git a/azure-functions-durable/README.md b/azure-functions-durable/README.md index 7063e2db..5fff5f70 100644 --- a/azure-functions-durable/README.md +++ b/azure-functions-durable/README.md @@ -7,8 +7,7 @@ built on top of the [`durabletask`](https://pypi.org/project/durabletask/) SDK. > [!NOTE] > 2.x is a ground-up rewrite of the Durable Functions Python SDK on top of the > `durabletask` runtime. It is currently a preview (beta) release; APIs may -> change before the stable 2.0.0. See [CHANGELOG.md](CHANGELOG.md) for the -> migration summary from 1.x, including breaking changes. +> change before the stable 2.0.0. ## Requirements @@ -35,6 +34,8 @@ calls (`context.call_http(...)`), recurring scheduled tasks, and history export. ## Links +- [2.x samples](samples/) +- [Migration guide from 1.x](MIGRATION_GUIDE.md) - [Changelog](CHANGELOG.md) - [Durable Functions documentation](https://learn.microsoft.com/azure/azure-functions/durable/) - [`durabletask` on PyPI](https://pypi.org/project/durabletask/) diff --git a/azure-functions-durable/samples/README.md b/azure-functions-durable/samples/README.md new file mode 100644 index 00000000..513ab09f --- /dev/null +++ b/azure-functions-durable/samples/README.md @@ -0,0 +1,128 @@ +# Azure Functions Durable 2.x samples + +These samples use the Azure Functions Python V2 programming model and the +durabletask-native APIs provided by `azure-functions-durable` 2.x. + +| Sample | Demonstrates | +| --- | --- | +| [Function chaining](function-chaining/) | Calling activities in sequence | +| [Fan-out/fan-in](fan-out-fan-in/) | Running activities in parallel and aggregating their results | +| [Human interaction](human-interaction/) | Waiting for an external event with a durable timeout | +| [Durable entities](durable-entities/) | Defining and calling a class-based entity | + +## Prerequisites + +- Python 3.13 or later +- [Azure Functions Core Tools 4.x](https://learn.microsoft.com/azure/azure-functions/functions-run-local) +- [Azurite](https://learn.microsoft.com/azure/storage/common/storage-use-azurite) + +The samples use the preview Azure Functions extension bundle required by the +Durable Functions 2.x provider. Their `local.settings.json` files connect to +Azurite. + +## Run a sample + +Start Azurite, then create a virtual environment and install the selected +sample's dependencies. + +### Bash + +```bash +cd azure-functions-durable/samples/function-chaining +python -m venv .venv +source .venv/bin/activate +python -m pip install -r requirements.txt +func start +``` + +### PowerShell + +```powershell +Set-Location azure-functions-durable\samples\function-chaining +python -m venv .venv +.\.venv\Scripts\Activate.ps1 +python -m pip install -r requirements.txt +func start +``` + +Replace `function-chaining` with the directory for another sample. + +## Invoke the samples + +### Function chaining + +```bash +curl -i -X POST http://localhost:7071/api/orchestrators/hello_cities +``` + +```powershell +Invoke-WebRequest ` + -Method Post ` + -Uri http://localhost:7071/api/orchestrators/hello_cities +``` + +### Fan-out/fan-in + +```bash +curl -i -X POST http://localhost:7071/api/fan-out-fan-in \ + -H "Content-Type: application/json" \ + -d '[1, 2, 3, 4, 5]' +``` + +```powershell +Invoke-WebRequest ` + -Method Post ` + -Uri http://localhost:7071/api/fan-out-fan-in ` + -ContentType application/json ` + -Body '[1, 2, 3, 4, 5]' +``` + +### Human interaction + +Start the orchestration and copy its instance ID from the response: + +```bash +curl -i -X POST http://localhost:7071/api/approvals +``` + +```powershell +Invoke-WebRequest -Method Post -Uri http://localhost:7071/api/approvals +``` + +Send an approval before the one-minute timeout: + +```bash +curl -i -X POST http://localhost:7071/api/approvals/INSTANCE_ID \ + -H "Content-Type: application/json" \ + -d '{"approved": true}' +``` + +```powershell +Invoke-WebRequest ` + -Method Post ` + -Uri http://localhost:7071/api/approvals/INSTANCE_ID ` + -ContentType application/json ` + -Body '{"approved": true}' +``` + +### Durable entities + +Start an orchestration that adds five to a counter entity and returns its new +value: + +```bash +curl -i -X POST http://localhost:7071/api/counters/my-counter \ + -H "Content-Type: application/json" \ + -d '{"amount": 5}' +``` + +```powershell +Invoke-WebRequest ` + -Method Post ` + -Uri http://localhost:7071/api/counters/my-counter ` + -ContentType application/json ` + -Body '{"amount": 5}' +``` + +Each starter returns a standard Durable Functions HTTP management payload. Use +its `statusQueryGetUri` to inspect the orchestration result. diff --git a/azure-functions-durable/samples/durable-entities/function_app.py b/azure-functions-durable/samples/durable-entities/function_app.py new file mode 100644 index 00000000..82e52ba6 --- /dev/null +++ b/azure-functions-durable/samples/durable-entities/function_app.py @@ -0,0 +1,52 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +from collections.abc import Generator +from typing import Any + +import azure.functions as func +import azure.durable_functions as df +from durabletask import entities, task +from durabletask.entities import DurableEntity + + +app = df.DFApp(http_auth_level=func.AuthLevel.ANONYMOUS) + + +@app.route(route="counters/{key}", methods=["POST"]) +@app.durable_client_input(client_name="client") +async def add_to_counter( + req: func.HttpRequest, + client: df.DurableFunctionsClient) -> func.HttpResponse: + input_ = { + "key": req.route_params["key"], + "amount": req.get_json().get("amount", 1), + } + instance_id = await client.schedule_new_orchestration( + "update_counter", input=input_) + return client.create_check_status_response(req, instance_id) + + +@app.orchestration_trigger(context_name="context") +def update_counter( + ctx: task.OrchestrationContext, + input_: dict[str, Any]) -> Generator[task.Task[Any], Any, int]: + counter_id = entities.EntityInstanceId("counter", input_["key"]) + yield ctx.call_entity(counter_id, "add", input_["amount"]) + value: int = yield ctx.call_entity( + counter_id, "get", return_type=int) + return value + + +@app.entity_trigger(context_name="context") +class Counter(DurableEntity): + def add(self, input_: Any = None) -> int: + value = self.get_state(int, 0) + (input_ or 0) + self.set_state(value) + return value + + def get(self, input_: Any = None) -> int: + return self.get_state(int, 0) + + def reset(self, input_: Any = None) -> None: + self.set_state(0) diff --git a/azure-functions-durable/samples/durable-entities/host.json b/azure-functions-durable/samples/durable-entities/host.json new file mode 100644 index 00000000..679e848d --- /dev/null +++ b/azure-functions-durable/samples/durable-entities/host.json @@ -0,0 +1,14 @@ +{ + "version": "2.0", + "extensionBundle": { + "id": "Microsoft.Azure.Functions.ExtensionBundle.Preview", + "version": "[4.*, 5.0.0)" + }, + "extensions": { + "durableTask": { + "storageProvider": { + "type": "AzureStorage" + } + } + } +} diff --git a/azure-functions-durable/samples/durable-entities/local.settings.json b/azure-functions-durable/samples/durable-entities/local.settings.json new file mode 100644 index 00000000..a2ded917 --- /dev/null +++ b/azure-functions-durable/samples/durable-entities/local.settings.json @@ -0,0 +1,7 @@ +{ + "IsEncrypted": false, + "Values": { + "AzureWebJobsStorage": "UseDevelopmentStorage=true", + "FUNCTIONS_WORKER_RUNTIME": "python" + } +} diff --git a/azure-functions-durable/samples/durable-entities/requirements.txt b/azure-functions-durable/samples/durable-entities/requirements.txt new file mode 100644 index 00000000..7ef4b6a5 --- /dev/null +++ b/azure-functions-durable/samples/durable-entities/requirements.txt @@ -0,0 +1,2 @@ +azure-functions>=2.3.0b2 +azure-functions-durable>=2.0.0b1,<3 diff --git a/azure-functions-durable/samples/fan-out-fan-in/function_app.py b/azure-functions-durable/samples/fan-out-fan-in/function_app.py new file mode 100644 index 00000000..002b1e28 --- /dev/null +++ b/azure-functions-durable/samples/fan-out-fan-in/function_app.py @@ -0,0 +1,40 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +from collections.abc import Generator +from typing import Any + +import azure.functions as func +import azure.durable_functions as df +from durabletask import task + + +app = df.DFApp(http_auth_level=func.AuthLevel.ANONYMOUS) + + +@app.route(route="fan-out-fan-in", methods=["POST"]) +@app.durable_client_input(client_name="client") +async def start_fan_out_fan_in( + req: func.HttpRequest, + client: df.DurableFunctionsClient) -> func.HttpResponse: + numbers = req.get_json() + instance_id = await client.schedule_new_orchestration( + "fan_out_fan_in", input=numbers) + return client.create_check_status_response(req, instance_id) + + +@app.orchestration_trigger(context_name="context") +def fan_out_fan_in( + ctx: task.OrchestrationContext, + numbers: list[int]) -> Generator[task.Task[Any], Any, int]: + activities: list[task.Task[int]] = [ + ctx.call_activity("square", input=number, return_type=int) + for number in numbers + ] + squared_numbers: list[int] = yield task.when_all(activities) + return sum(squared_numbers) + + +@app.activity_trigger(input_name="number") +def square(number: int) -> int: + return number * number diff --git a/azure-functions-durable/samples/fan-out-fan-in/host.json b/azure-functions-durable/samples/fan-out-fan-in/host.json new file mode 100644 index 00000000..679e848d --- /dev/null +++ b/azure-functions-durable/samples/fan-out-fan-in/host.json @@ -0,0 +1,14 @@ +{ + "version": "2.0", + "extensionBundle": { + "id": "Microsoft.Azure.Functions.ExtensionBundle.Preview", + "version": "[4.*, 5.0.0)" + }, + "extensions": { + "durableTask": { + "storageProvider": { + "type": "AzureStorage" + } + } + } +} diff --git a/azure-functions-durable/samples/fan-out-fan-in/local.settings.json b/azure-functions-durable/samples/fan-out-fan-in/local.settings.json new file mode 100644 index 00000000..a2ded917 --- /dev/null +++ b/azure-functions-durable/samples/fan-out-fan-in/local.settings.json @@ -0,0 +1,7 @@ +{ + "IsEncrypted": false, + "Values": { + "AzureWebJobsStorage": "UseDevelopmentStorage=true", + "FUNCTIONS_WORKER_RUNTIME": "python" + } +} diff --git a/azure-functions-durable/samples/fan-out-fan-in/requirements.txt b/azure-functions-durable/samples/fan-out-fan-in/requirements.txt new file mode 100644 index 00000000..7ef4b6a5 --- /dev/null +++ b/azure-functions-durable/samples/fan-out-fan-in/requirements.txt @@ -0,0 +1,2 @@ +azure-functions>=2.3.0b2 +azure-functions-durable>=2.0.0b1,<3 diff --git a/azure-functions-durable/samples/function-chaining/function_app.py b/azure-functions-durable/samples/function-chaining/function_app.py new file mode 100644 index 00000000..ad8083f2 --- /dev/null +++ b/azure-functions-durable/samples/function-chaining/function_app.py @@ -0,0 +1,39 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +from collections.abc import Generator +from typing import Any + +import azure.functions as func +import azure.durable_functions as df +from durabletask import task + + +app = df.DFApp(http_auth_level=func.AuthLevel.ANONYMOUS) + + +@app.route(route="orchestrators/hello_cities", methods=["POST"]) +@app.durable_client_input(client_name="client") +async def start_orchestration( + req: func.HttpRequest, + client: df.DurableFunctionsClient) -> func.HttpResponse: + instance_id = await client.schedule_new_orchestration("hello_cities") + return client.create_check_status_response(req, instance_id) + + +@app.orchestration_trigger(context_name="context") +def hello_cities( + ctx: task.OrchestrationContext, + _: Any) -> Generator[task.Task[Any], Any, list[str]]: + result1: str = yield ctx.call_activity( + "say_hello", input="Tokyo", return_type=str) + result2: str = yield ctx.call_activity( + "say_hello", input="Seattle", return_type=str) + result3: str = yield ctx.call_activity( + "say_hello", input="London", return_type=str) + return [result1, result2, result3] + + +@app.activity_trigger(input_name="city") +def say_hello(city: str) -> str: + return f"Hello {city}!" diff --git a/azure-functions-durable/samples/function-chaining/host.json b/azure-functions-durable/samples/function-chaining/host.json new file mode 100644 index 00000000..679e848d --- /dev/null +++ b/azure-functions-durable/samples/function-chaining/host.json @@ -0,0 +1,14 @@ +{ + "version": "2.0", + "extensionBundle": { + "id": "Microsoft.Azure.Functions.ExtensionBundle.Preview", + "version": "[4.*, 5.0.0)" + }, + "extensions": { + "durableTask": { + "storageProvider": { + "type": "AzureStorage" + } + } + } +} diff --git a/azure-functions-durable/samples/function-chaining/local.settings.json b/azure-functions-durable/samples/function-chaining/local.settings.json new file mode 100644 index 00000000..a2ded917 --- /dev/null +++ b/azure-functions-durable/samples/function-chaining/local.settings.json @@ -0,0 +1,7 @@ +{ + "IsEncrypted": false, + "Values": { + "AzureWebJobsStorage": "UseDevelopmentStorage=true", + "FUNCTIONS_WORKER_RUNTIME": "python" + } +} diff --git a/azure-functions-durable/samples/function-chaining/requirements.txt b/azure-functions-durable/samples/function-chaining/requirements.txt new file mode 100644 index 00000000..7ef4b6a5 --- /dev/null +++ b/azure-functions-durable/samples/function-chaining/requirements.txt @@ -0,0 +1,2 @@ +azure-functions>=2.3.0b2 +azure-functions-durable>=2.0.0b1,<3 diff --git a/azure-functions-durable/samples/human-interaction/function_app.py b/azure-functions-durable/samples/human-interaction/function_app.py new file mode 100644 index 00000000..57a33249 --- /dev/null +++ b/azure-functions-durable/samples/human-interaction/function_app.py @@ -0,0 +1,50 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +from collections.abc import Generator +from datetime import timedelta +from typing import Any + +import azure.functions as func +import azure.durable_functions as df +from durabletask import task + + +app = df.DFApp(http_auth_level=func.AuthLevel.ANONYMOUS) + + +@app.route(route="approvals", methods=["POST"]) +@app.durable_client_input(client_name="client") +async def start_approval( + req: func.HttpRequest, + client: df.DurableFunctionsClient) -> func.HttpResponse: + instance_id = await client.schedule_new_orchestration("wait_for_approval") + return client.create_check_status_response(req, instance_id) + + +@app.route(route="approvals/{instance_id}", methods=["POST"]) +@app.durable_client_input(client_name="client") +async def submit_approval( + req: func.HttpRequest, + client: df.DurableFunctionsClient) -> func.HttpResponse: + await client.raise_orchestration_event( + req.route_params["instance_id"], + "approval", + data=req.get_json()) + return func.HttpResponse(status_code=202) + + +@app.orchestration_trigger(context_name="context") +def wait_for_approval( + ctx: task.OrchestrationContext, + _: Any) -> Generator[task.Task[Any], Any, Any]: + approval = ctx.wait_for_external_event("approval") + timeout = ctx.create_timer( + ctx.current_utc_datetime + timedelta(minutes=1)) + + winner: task.Task[Any] = yield task.when_any([approval, timeout]) + if winner is approval: + timeout.cancel() + return approval.get_result() + + return {"approved": False, "reason": "timed out"} diff --git a/azure-functions-durable/samples/human-interaction/host.json b/azure-functions-durable/samples/human-interaction/host.json new file mode 100644 index 00000000..679e848d --- /dev/null +++ b/azure-functions-durable/samples/human-interaction/host.json @@ -0,0 +1,14 @@ +{ + "version": "2.0", + "extensionBundle": { + "id": "Microsoft.Azure.Functions.ExtensionBundle.Preview", + "version": "[4.*, 5.0.0)" + }, + "extensions": { + "durableTask": { + "storageProvider": { + "type": "AzureStorage" + } + } + } +} diff --git a/azure-functions-durable/samples/human-interaction/local.settings.json b/azure-functions-durable/samples/human-interaction/local.settings.json new file mode 100644 index 00000000..a2ded917 --- /dev/null +++ b/azure-functions-durable/samples/human-interaction/local.settings.json @@ -0,0 +1,7 @@ +{ + "IsEncrypted": false, + "Values": { + "AzureWebJobsStorage": "UseDevelopmentStorage=true", + "FUNCTIONS_WORKER_RUNTIME": "python" + } +} diff --git a/azure-functions-durable/samples/human-interaction/requirements.txt b/azure-functions-durable/samples/human-interaction/requirements.txt new file mode 100644 index 00000000..7ef4b6a5 --- /dev/null +++ b/azure-functions-durable/samples/human-interaction/requirements.txt @@ -0,0 +1,2 @@ +azure-functions>=2.3.0b2 +azure-functions-durable>=2.0.0b1,<3