diff --git a/.flake8 b/.flake8 index 239bf20f..3e1ec878 100644 --- a/.flake8 +++ b/.flake8 @@ -3,4 +3,7 @@ ignore = E501,C901,W503 exclude = .git *_pb2* - __pycache__ \ No newline at end of file + __pycache__ + .venv + .nox + .python_packages \ No newline at end of file diff --git a/.github/workflows/durabletask-azurefunctions.yml b/.github/workflows/durabletask-azurefunctions.yml new file mode 100644 index 00000000..5c8f0970 --- /dev/null +++ b/.github/workflows/durabletask-azurefunctions.yml @@ -0,0 +1,119 @@ +name: Durable Task Scheduler SDK (azure-functions-durable) + +on: + push: + branches: + - "main" + tags: + - "azurefunctions-v*" # Only run for tags starting with "azurefunctions-v" + pull_request: + branches: + - "main" + +permissions: + contents: read + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up Python 3.14 + uses: actions/setup-python@v5 + with: + python-version: 3.14 + - name: Install dependencies + working-directory: azure-functions-durable + run: | + python -m pip install --upgrade pip + pip install setuptools wheel tox + pip install flake8 + - name: Run flake8 Linter + working-directory: azure-functions-durable + run: flake8 . + - name: Run flake8 Linter + working-directory: tests/azure-functions-durable + run: flake8 . + + run-tests: + strategy: + fail-fast: false + matrix: + python-version: ["3.13", "3.14"] + needs: lint + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install durabletask dependencies + run: | + python -m pip install --upgrade pip + pip install flake8 pytest + pip install -r requirements.txt + + - name: Install durabletask locally + run: | + pip install . --no-deps --force-reinstall + + - name: Install azure-functions-durable locally + working-directory: azure-functions-durable + run: | + pip install . --no-deps --force-reinstall + + - name: Run unit tests + working-directory: tests/azure-functions-durable + run: | + pytest -m "not dts and not azurite and not functions_e2e" --verbose + + e2e-tests: + needs: run-tests + runs-on: ubuntu-latest + # The end-to-end suite launches a real Azure Functions host, which requires + # ``azure-functions>=2.2.0b6``. That build is not yet published to PyPI, so + # this job is skipped by default. Enable it by setting the repository + # variable ``RUN_FUNCTIONS_E2E`` to ``true`` (Settings > Secrets and + # variables > Actions > Variables) once 2.2.0b6 is available on PyPI, or + # provide the library build via ``AZURE_FUNCTIONS_PYTHON_LIBRARY``. + if: ${{ vars.RUN_FUNCTIONS_E2E == 'true' }} + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python 3.13 + uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Set up Node.js (needed for Azurite and Functions Core Tools) + uses: actions/setup-node@v4 + with: + node-version: "20.x" + + - name: Install Azurite and Azure Functions Core Tools + run: | + npm install -g azurite + npm install -g azure-functions-core-tools@4 --unsafe-perm true + + - name: Start Azurite + shell: bash + run: | + azurite --silent --location /tmp/azurite --blobPort 10000 --queuePort 10001 --tablePort 10002 & + sleep 2 + + - name: Install nox + run: | + python -m pip install --upgrade pip + pip install nox + + - name: Run end-to-end tests + # nox provisions each sample app's in-app virtual environment (a link to + # a single editable install) so the Functions worker loads our + # grpc/protobuf rather than its bundled copies. + run: nox -s functions_e2e + diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml index 28dcddfc..99c639ba 100644 --- a/.github/workflows/typecheck.yml +++ b/.github/workflows/typecheck.yml @@ -7,6 +7,7 @@ on: tags: - "v*" - "azuremanaged-v*" + - "azurefunctions-v*" pull_request: branches: - "main" @@ -46,3 +47,25 @@ jobs: - name: Run pyright (strict, Python 3.10) run: pyright + + pyright-azurefunctions: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python 3.13 (lowest supported by azure-functions-durable) + uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Install packages and dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install -e ".[azure-blob-payloads,opentelemetry]" + pip install -e ./azure-functions-durable + pip install pyright + + - name: Run pyright (strict, Python 3.13) + run: pyright -p azure-functions-durable/pyrightconfig.json diff --git a/.gitignore b/.gitignore index 85400775..85bc6f52 100644 --- a/.gitignore +++ b/.gitignore @@ -111,6 +111,12 @@ ENV/ env.bak/ venv.bak/ +# Azure Functions E2E test host logs (written by the test harness) +_func_host.log + +# Azure Functions E2E history-export output (written by the sample app writer) +_export_output/ + # Spyder project settings .spyderproject .spyproject diff --git a/.vscode/settings.json b/.vscode/settings.json index 824a8c34..9e24d805 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -31,5 +31,6 @@ "coverage.cobertura.xml" ], "makefile.configureOnOpen": false, - "debugpy.debugJustMyCode": false + "debugpy.debugJustMyCode": false, + "python-envs.defaultEnvManager": "ms-python.python:system" } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index e9a7c40e..03de8b31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,41 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ADDED +- Added a `result` property to `Task` as a convenience alias for `get_result()`. - Added `TaskHubGrpcClient.rewind_orchestration()` to rewind a failed orchestration instance to its last known good state. Failed activity and sub-orchestration results are removed from the history and the orchestration replays from the last successful checkpoint, retrying only the failed work. The in-memory testing backend supports rewind as well. +- Exported `bind_context` and `clear_context` from + `durabletask.extensions.history_export` so hosts that register the export + functions themselves (rather than via `ExportHistoryClient.register_worker`) + can supply the activities' runtime dependencies. + +FIXED + +- Fixed orchestrations failing with `OrchestrationStateError: Don't know how to + handle event of type 'genericEvent'` after being rewound over the Azure + Functions Durable extension. The `genericEvent` history event (an + informational marker with no execution semantics) is now ignored during + replay, matching the .NET worker, so `rewind_orchestration` completes the + replay instead of re-failing. +- Fixed durabletask scheduled tasks (`durabletask.scheduled`) failing under + data converters that reconstruct nested custom-object envelopes bottom-up + (such as the Azure Functions Durable `df` codec). `ScheduleState.from_json` + now tolerates an already-reconstructed nested `ScheduleConfiguration` (and + accepts the active `DataConverter` for nested reconstruction), and + `ScheduleOperationRequest` gained `to_json`/`from_json` hooks so it can be + serialized by converters that require them. The default JSON converter's + behavior is unchanged. +- Fixed `OrchestrationContext.lock_entities` failing when used over the legacy + entity protocol (used by the Azure Functions Durable extension). Acquiring an + entity lock raised a `JSONDecodeError` because the worker tried to deserialize + an operation result from the lock-granted event, which carries none; the + result is now only read for entity operation calls, not lock acquisitions. +- Fixed `OrchestrationContext.version` returning an empty string (`''`) instead + of `None` for orchestrations started without an explicit version. The version + field is a protobuf wrapper (a singular message that is always truthy), so it + is now checked for presence with `HasField` before being read. +- `OrchestrationContext.create_timer` now accepts timezone-aware `datetime` + values, normalizing them to UTC instead of raising when compared against the + orchestration's internal clock. ## v1.7.2 diff --git a/azure-functions-durable/CHANGELOG.md b/azure-functions-durable/CHANGELOG.md new file mode 100644 index 00000000..b77fac2d --- /dev/null +++ b/azure-functions-durable/CHANGELOG.md @@ -0,0 +1,159 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## Unreleased + +### Added + +- `DurableFunctionsClient.rewind_orchestration(...)` (inherited from durabletask) + rewinds a failed orchestration to its last known good state. The deprecated v1 + `rewind(...)` method now delegates to it instead of raising + `NotImplementedError`. + +- `DFApp.configure_scheduled_tasks()` opts an app in to durabletask scheduled + tasks by registering the schedule entity and operation orchestrator. Once + enabled, schedules are managed from a client via + `durabletask.scheduled.ScheduledTaskClient`. Scheduled tasks are not + registered unless this method is called. +- `DFApp.configure_history_export()` opts an app in to durabletask history + export by registering the export-job entity, driving orchestrator, and + activities. Once enabled, export jobs are driven from a client via + `durabletask.extensions.history_export.ExportHistoryClient`; supply the + activities' runtime dependencies with `history_export.bind_context(...)`. + The instance-enumeration activity uses a Functions-specific implementation + based on `QueryInstances` because the Durable Functions host extension does + not implement the `ListInstanceIds` gRPC call the core activity relies on. +- `DurableOrchestrationContext.call_http(...)` for making durable HTTP calls + from orchestrators, restoring the v1 API. The request is executed by a + built-in activity and, when the endpoint responds with `202 Accepted` and a + `Location` header, is automatically polled to completion (honoring + `Retry-After`). `ManagedIdentityTokenSource` can be supplied to attach a + Managed Identity bearer token to the request. `DurableHttpRequest` and + `DurableHttpResponse` are exported from `azure.durable_functions`. + +- The `orchestration_trigger` decorator now accepts an `input_type` argument + (v1 parity). When set, a v1-style `context.get_input()` decodes the input to + that type; a call-site `expected_type` on `get_input` takes precedence. +- One-argument (Azure Functions / v1-style) entity functions + (``def entity(context):``) are now supported. The worker detects the entity's + shape and, for single-argument functions, delivers a functional + `DurableEntityContext` that wraps the durabletask `EntityContext` and exposes + the v1 entity API: `entity_name`, `entity_key`, `operation_name`, + `get_input()`, `get_state()` (with `initializer`), `set_state()`, + `set_result()`, and `destruct_on_exit()`. The operation result is taken from + `set_result(...)`, falling back to the function's return value. + durabletask-native two-argument entity functions and class-based + (`DurableEntity`) entities continue to work unchanged. +- One-argument (Azure Functions / v1-style) orchestrator functions + (``def orchestrator(context):``) are now supported. The worker detects the + orchestrator's arity and, for single-argument functions, delivers a + functional `DurableOrchestrationContext` that wraps the durabletask + `OrchestrationContext` and exposes the v1 context API: `get_input()`, + `call_activity`/`call_activity_with_retry`, + `call_sub_orchestrator`/`call_sub_orchestrator_with_retry`, `create_timer`, + `wait_for_external_event`, `continue_as_new`, `set_custom_status`, + `task_all`/`task_any`, `call_entity`/`signal_entity`, and `new_uuid`/`new_guid`. + Two-argument (durabletask-native) orchestrators continue to work unchanged. + `DurableOrchestrationContext.call_http` raises `NotImplementedError` pending a + durabletask durable-HTTP implementation. +- `DurableOrchestrationContext` also exposes `custom_status` (reflecting the + value set via `set_custom_status`) and `will_continue_as_new` (True once + `continue_as_new` has been called). `parent_instance_id`, `function_context`, + and `histories` raise `NotImplementedError` because durabletask does not + surface that information on the orchestration context. + +- Backwards-compatible, deprecated aliases on `DurableFunctionsClient` for the + v1 `DurableOrchestrationClient` method names: `start_new`, `get_status`, + `get_status_all`, `get_status_by`, `raise_event`, `terminate`, + `purge_instance_history`, `purge_instance_history_by`, `suspend`, `resume`, + `restart`, `read_entity_state`, `get_client_response_links`, and + `wait_for_completion_or_create_check_status_response`. Each delegates to the + corresponding durabletask method and emits a `DeprecationWarning`; new code + should use the durabletask names (e.g. `schedule_new_orchestration`, + `get_orchestration_state`). +- `DurableFunctionsClient.signal_entity` now also accepts the v1 + `operation_input` keyword (alias for `input`); `task_hub_name` and + `connection_name` are accepted for compatibility and ignored. +- `DurableFunctionsClient.rewind` is present as a deprecated stub that raises + `NotImplementedError`, pending a durabletask rewind implementation. +- Deprecated v1 compatibility aliases are now exported from + `azure.durable_functions`: `DurableOrchestrationClient` (alias for + `DurableFunctionsClient`), `DurableOrchestrationContext`, `DurableEntityContext`, + `EntityId`, `ManagedIdentityTokenSource`, `TokenSource`, `Entity`, and + `OrchestrationRuntimeStatus`. +- v1-compatible return-type wrappers `DurableOrchestrationStatus`, + `PurgeHistoryResult`, and `EntityStateResponse` (exported from + `azure.durable_functions`). The deprecated client methods now return these: + `get_status`/`get_status_all`/`get_status_by` return + `DurableOrchestrationStatus` (wrapping durabletask `OrchestrationState`, with + v1 attributes like `runtime_status`, `output`, `input_`, `custom_status`, and + a falsy value for missing instances); `purge_instance_history`/`_by` return + `PurgeHistoryResult` (with `instances_deleted`); and `read_entity_state` + returns `EntityStateResponse` (with `entity_exists`/`entity_state`). +- `DurableOrchestrationContext.call_http` is present as a stub that raises + `NotImplementedError`, documenting the durable-HTTP gap. `TokenSource` / + `ManagedIdentityTokenSource` remain constructible but only apply to + `call_http`, which is not yet supported. +- `RetryOptions`, a deprecated shim that maps the v1 millisecond-based + constructor onto durabletask `RetryPolicy` (which uses `timedelta`). + `RetryPolicy` is now also exported from `azure.durable_functions`. + +### Changed + +- `DurableFunctionsClient` is now an async client. Its orchestration and entity + management methods (e.g. `schedule_new_orchestration`, `get_orchestration_state`, + `wait_for_orchestration_completion`) are now coroutines and must be awaited. + This aligns the client with the async API surface of the V1 + `DurableOrchestrationClient`. +- `create_http_management_payload` now accepts either the durabletask + `(request, instance_id)` signature or the v1 `(instance_id)` signature for + backwards compatibility. +- `HttpManagementPayload` now subclasses `dict`, so it is directly + JSON-serializable via `json.dumps(payload)` and supports mapping-style access + (`payload["statusQueryGetUri"]`, iteration, `in`, `keys()`/`items()`/`values()`) + so v1 code that treated the payload as a `dict` keeps working. + +### Fixed + +- Registering a `Blueprint` into a `DFApp` (via `register_functions` / + `register_blueprint`) no longer raises a duplicate-function-name error for the + reserved built-in durable-HTTP functions. Both the app and every blueprint + auto-register those built-ins, so the app now de-duplicates them during + registration (leaving the blueprint itself unmodified). This restores the + standard Azure Functions blueprint authoring pattern. +- `durable_client_input` now injects a rich `DurableFunctionsClient` into the + decorated function's client parameter (the binding's JSON string is converted + to a client object). Previously the client parameter received the raw string. +- `DurableFunctionsClient` now applies the host-provided + `maxGrpcMessageSizeInBytes` to the gRPC channel's send/receive message limits + (when provided), allowing large orchestration payloads to be retrieved. When + the host does not supply a value, the gRPC library defaults are left in place. +- `DurableOrchestrationContext.current_utc_datetime` is now timezone-aware + (UTC), matching v1, so comparisons against timezone-aware datetimes (e.g. a + parsed scheduled-start time) no longer raise. +- `DurableOrchestrationStatus.to_json()` now emits orchestration payloads + (`output`, `input`, `customStatus`) as their raw JSON representation instead + of reconstructed Python objects, so the result is always JSON-serializable + even when payloads are custom types. +- Restored v1 members that were missing on the compatibility types, avoiding + `AttributeError`/`TypeError` for existing code that used them: + - `create_http_management_payload(...)` now returns a `dict`-based + `HttpManagementPayload`, so `json.dumps(payload)` works directly again. + - `RetryOptions.to_json()` returns the v1 + `firstRetryIntervalInMilliseconds`/`maxNumberOfAttempts` dictionary, and the + `first_retry_interval_in_milliseconds` / `max_number_of_attempts` getters + remain available. + - `DurableOrchestrationStatus.from_json(...)` reconstructs a status from its + `to_json()` representation (or the equivalent v1 JSON schema). + - `PurgeHistoryResult.from_json(...)` reconstructs a result from its v1 JSON + representation. + - `DurableOrchestrationContext.version` returns the orchestration instance + version (or `None`). + +## v0.1.0 + +- Initial implementation diff --git a/azure-functions-durable/azure/durable_functions/__init__.py b/azure-functions-durable/azure/durable_functions/__init__.py new file mode 100644 index 00000000..b62091c9 --- /dev/null +++ b/azure-functions-durable/azure/durable_functions/__init__.py @@ -0,0 +1,52 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# This module intentionally re-exports deprecated v1 compatibility aliases. +# pyright: reportDeprecated=false + +from durabletask.task import RetryPolicy + +from .decorators.durable_app import Blueprint, DFApp +from .client import DurableFunctionsClient +from .http.models import DurableHttpRequest, DurableHttpResponse +from .orchestrator import Orchestrator +from .internal.compat.retry_options import RetryOptions +from .internal.compat.orchestration_runtime_status import OrchestrationRuntimeStatus +from .internal.compat.durable_orchestration_status import DurableOrchestrationStatus +from .internal.compat.purge_history_result import PurgeHistoryResult +from .internal.compat.entity_state_response import EntityStateResponse +from .internal.compat.entity_id import EntityId +from .internal.compat.token_source import ManagedIdentityTokenSource, TokenSource +from .internal.compat.orchestration_context import DurableOrchestrationContext +from .internal.compat.entity_context import DurableEntityContext +from .internal.compat.compat_aliases import ( + DurableOrchestrationClient, + Entity, +) + +# IMPORTANT: DO NOT REMOVE. `azure-functions` relies on the presence and value of this variable +# for version detection +version = "2.x" + +__all__ = [ + "Blueprint", + "DFApp", + "DurableEntityContext", + "DurableFunctionsClient", + "DurableHttpRequest", + "DurableHttpResponse", + "DurableOrchestrationClient", + "DurableOrchestrationContext", + "DurableOrchestrationStatus", + "Entity", + "EntityId", + "EntityStateResponse", + "ManagedIdentityTokenSource", + "Orchestrator", + "OrchestrationRuntimeStatus", + "PurgeHistoryResult", + "RetryOptions", + "RetryPolicy", + "TokenSource", + "version", +] diff --git a/azure-functions-durable/azure/durable_functions/client.py b/azure-functions-durable/azure/durable_functions/client.py new file mode 100644 index 00000000..05ff37b2 --- /dev/null +++ b/azure-functions-durable/azure/durable_functions/client.py @@ -0,0 +1,421 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +import json + +from datetime import datetime, timedelta +from typing import Any, Optional, Union +from typing_extensions import deprecated +import azure.functions as func +from urllib.parse import urlparse, quote + +from durabletask.client import ( + AsyncTaskHubGrpcClient, + OrchestrationQuery, + OrchestrationStatus, +) +from durabletask.entities import EntityInstanceId +from durabletask.grpc_options import GrpcChannelOptions +from .internal.azurefunctions_grpc_interceptor import AzureFunctionsAsyncDefaultClientInterceptorImpl +from .internal.serialization import DEFAULT_FUNCTIONS_DATA_CONVERTER +from .http import HttpManagementPayload +from .internal.compat.durable_orchestration_status import DurableOrchestrationStatus +from .internal.compat.entity_state_response import EntityStateResponse +from .internal.compat.orchestration_runtime_status import OrchestrationRuntimeStatus, to_durabletask_statuses +from .internal.compat.purge_history_result import PurgeHistoryResult + + +# Client class used for Durable Functions +class DurableFunctionsClient(AsyncTaskHubGrpcClient): + """A gRPC client passed to Durable Functions durable client bindings. + + Connects to the Durable Functions runtime using async gRPC and provides methods + for creating and managing Durable orchestrations, interacting with Durable entities, + and creating HTTP management payloads and check status responses for use with Durable Functions invocations. + """ + taskHubName: str + connectionName: str + creationUrls: dict[str, str] + managementUrls: dict[str, str] + baseUrl: str + requiredQueryStringParameters: str + rpcBaseUrl: str + httpBaseUrl: str + maxGrpcMessageSizeInBytes: int + grpcHttpClientTimeout: timedelta + + def __init__(self, client_as_string: str): + """Initializes a DurableFunctionsClient instance from a JSON string. + + This string will be provided by the Durable Functions host extension upon invocation of the client trigger. + + Args: + client_as_string (str): A JSON string containing the Durable Functions client configuration. + + Raises: + json.JSONDecodeError: If the provided string is not valid JSON. + """ + self._parse_client_configuration(client_as_string) + + interceptors = [AzureFunctionsAsyncDefaultClientInterceptorImpl(self.taskHubName, self.requiredQueryStringParameters)] + + # Only override the gRPC message size limits when the host explicitly + # provides a value. When unset (0), we leave the gRPC library defaults + # in place rather than applying a large default of our own. + channel_options: GrpcChannelOptions | None = None + if self.maxGrpcMessageSizeInBytes > 0: + channel_options = GrpcChannelOptions( + max_receive_message_length=self.maxGrpcMessageSizeInBytes, + max_send_message_length=self.maxGrpcMessageSizeInBytes) + + # We pass in None for the metadata so we don't construct an additional interceptor in the parent class + # Since the parent class doesn't use anything metadata for anything else, we can set it as None + super().__init__( + host_address=self.rpcBaseUrl, + secure_channel=False, + metadata=None, + interceptors=interceptors, + channel_options=channel_options, + data_converter=DEFAULT_FUNCTIONS_DATA_CONVERTER) + + def _parse_client_configuration(self, client_as_string: str) -> None: + """Parses the client configuration JSON string and sets instance variables. + + Args: + client_as_string (str): A JSON string containing the Durable Functions client configuration. + + Raises: + json.JSONDecodeError: If the provided string is not valid JSON. + """ + client = json.loads(client_as_string) + + self.taskHubName = client.get("taskHubName", "") + self.connectionName = client.get("connectionName", "") + self.creationUrls = client.get("creationUrls", {}) + self.managementUrls = client.get("managementUrls", {}) + self.baseUrl = client.get("baseUrl", "") + self.requiredQueryStringParameters = client.get("requiredQueryStringParameters", "") + self.rpcBaseUrl = client.get("rpcBaseUrl", "") + self.httpBaseUrl = client.get("httpBaseUrl", "") + self.maxGrpcMessageSizeInBytes = client.get("maxGrpcMessageSizeInBytes", 0) + # TODO: convert the string value back to timedelta - annoying regex? + self.grpcHttpClientTimeout = client.get("grpcHttpClientTimeout", timedelta(seconds=30)) + + def create_check_status_response(self, request: func.HttpRequest, instance_id: str) -> func.HttpResponse: + """Creates an HTTP response for checking the status of a Durable Function instance. + + Args: + request (func.HttpRequest): The incoming HTTP request. + instance_id (str): The ID of the Durable Function instance. + """ + location_url = self._get_instance_status_url(request, instance_id) + return func.HttpResponse( + body=str(self._get_client_response_links(request, instance_id)), + status_code=202, + headers={ + 'content-type': 'application/json', + 'Location': location_url, + }, + ) + + def create_http_management_payload( + self, + request: func.HttpRequest | str | None = None, + instance_id: str | None = None) -> HttpManagementPayload: + """Creates an HTTP management payload for a Durable Function instance. + + Two call styles are supported: + + - ``create_http_management_payload(request, instance_id)`` (recommended): + builds the payload URLs relative to the incoming request's origin. + - ``create_http_management_payload(instance_id)`` (deprecated V1 style): + builds the payload URLs from the client binding's base URL when no + request is available. + + Args: + request (func.HttpRequest | str | None): The incoming HTTP request, or, + for backwards compatibility, the instance ID when called with a + single positional argument. + instance_id (str | None): The ID of the Durable Function instance. + """ + # Backwards-compatibility: v1 accepted a single positional ``instance_id``. + if instance_id is None and isinstance(request, str): + instance_id = request + request = None + if instance_id is None: + raise TypeError("instance_id is required") + resolved_request = request if isinstance(request, func.HttpRequest) else None + return self._get_client_response_links(resolved_request, instance_id) + + def _get_client_response_links(self, request: func.HttpRequest | None, instance_id: str) -> HttpManagementPayload: + instance_status_url = self._get_instance_status_url(request, instance_id) + return HttpManagementPayload(instance_id, instance_status_url, self.requiredQueryStringParameters) + + def _get_instance_status_url(self, request: func.HttpRequest | None, instance_id: str) -> str: + encoded_instance_id = quote(instance_id) + if request is not None: + request_url = urlparse(request.url) + location_url = f"{request_url.scheme}://{request_url.netloc}" + location_url = location_url + "/runtime/webhooks/durabletask/instances/" + encoded_instance_id + else: + # No request available (v1-style call): fall back to the base URL + # supplied in the client binding configuration. + base_url = self.baseUrl.rstrip("/") if self.baseUrl else "" + location_url = base_url + "/instances/" + encoded_instance_id + return location_url + + # ------------------------------------------------------------------ + # Backwards-compatibility shims for the v1 azure-functions-durable + # DurableOrchestrationClient API. These delegate to the durabletask + # AsyncTaskHubGrpcClient methods and are deprecated: new code should use + # the durabletask method names directly. + # ------------------------------------------------------------------ + + @deprecated("start_new is deprecated; use schedule_new_orchestration instead.") + async def start_new(self, + orchestration_function_name: str, + instance_id: Optional[str] = None, + client_input: Optional[Any] = None, + version: Optional[str] = None) -> str: + """Deprecated alias for :meth:`schedule_new_orchestration`.""" + return await self.schedule_new_orchestration( + orchestration_function_name, + input=client_input, + instance_id=instance_id, + version=version) + + @deprecated("get_status is deprecated; use get_orchestration_state instead.") + async def get_status( + self, + instance_id: str, + show_history: bool = False, + show_history_output: bool = False, + show_input: bool = False) -> DurableOrchestrationStatus: + """Deprecated alias for :meth:`get_orchestration_state`. + + Returns a :class:`DurableOrchestrationStatus` wrapping the durabletask + ``OrchestrationState`` for v1 back-compat. When the instance does not + exist, a falsy status is returned rather than ``None``. + + The ``show_history`` and ``show_history_output`` flags have no + equivalent in durabletask and are ignored; ``show_input`` maps to + ``fetch_payloads``. + """ + state = await self.get_orchestration_state(instance_id, fetch_payloads=show_input) + return DurableOrchestrationStatus.from_orchestration_state(state) + + @deprecated("get_status_all is deprecated; use get_all_orchestration_states instead.") + async def get_status_all(self) -> list[DurableOrchestrationStatus]: + """Deprecated alias for :meth:`get_all_orchestration_states`.""" + states = await self.get_all_orchestration_states() + return [DurableOrchestrationStatus.from_orchestration_state(state) for state in states] + + @deprecated("raise_event is deprecated; use raise_orchestration_event instead.") + async def raise_event( + self, + instance_id: str, + event_name: str, + event_data: Any = None, + task_hub_name: Optional[str] = None, + connection_name: Optional[str] = None) -> None: + """Deprecated alias for :meth:`raise_orchestration_event`. + + The ``task_hub_name`` and ``connection_name`` arguments have no + equivalent in durabletask and are ignored. + """ + await self.raise_orchestration_event(instance_id, event_name, data=event_data) + + @deprecated("terminate is deprecated; use terminate_orchestration instead.") + async def terminate(self, instance_id: str, reason: Optional[Any] = None) -> None: + """Deprecated alias for :meth:`terminate_orchestration`. + + The v1 ``reason`` maps to the durabletask ``output`` argument. + """ + await self.terminate_orchestration(instance_id, output=reason) + + @deprecated("purge_instance_history is deprecated; use purge_orchestration instead.") + async def purge_instance_history(self, instance_id: str) -> PurgeHistoryResult: + """Deprecated alias for :meth:`purge_orchestration`. + + Returns a :class:`PurgeHistoryResult` wrapping the durabletask + ``PurgeInstancesResult`` for v1 back-compat. + """ + result = await self.purge_orchestration(instance_id) + return PurgeHistoryResult.from_purge_result(result) + + @deprecated("suspend is deprecated; use suspend_orchestration instead.") + async def suspend(self, instance_id: str, reason: Optional[str] = None) -> None: + """Deprecated alias for :meth:`suspend_orchestration`. + + The v1 ``reason`` argument has no equivalent in durabletask and is + ignored. + """ + await self.suspend_orchestration(instance_id) + + @deprecated("resume is deprecated; use resume_orchestration instead.") + async def resume(self, instance_id: str, reason: Optional[str] = None) -> None: + """Deprecated alias for :meth:`resume_orchestration`. + + The v1 ``reason`` argument has no equivalent in durabletask and is + ignored. + """ + await self.resume_orchestration(instance_id) + + @deprecated("restart is deprecated; use restart_orchestration instead.") + async def restart( + self, + instance_id: str, + restart_with_new_instance_id: bool = True) -> str: + """Deprecated alias for :meth:`restart_orchestration`.""" + return await self.restart_orchestration( + instance_id, restart_with_new_instance_id=restart_with_new_instance_id) + + @deprecated("read_entity_state is deprecated; use get_entity instead.") + async def read_entity_state( + self, + entity_instance_id: EntityInstanceId, + task_hub_name: Optional[str] = None, + connection_name: Optional[str] = None) -> EntityStateResponse: + """Deprecated alias for :meth:`get_entity`. + + Returns an :class:`EntityStateResponse` wrapping the durabletask + ``EntityMetadata`` for v1 back-compat. + + The ``task_hub_name`` and ``connection_name`` arguments have no + equivalent in durabletask and are ignored. + """ + metadata = await self.get_entity(entity_instance_id) + return EntityStateResponse.from_entity_metadata(metadata) + + @deprecated("get_status_by is deprecated; use get_all_orchestration_states instead.") + async def get_status_by( + self, + created_time_from: Optional[datetime] = None, + created_time_to: Optional[datetime] = None, + runtime_status: Optional[list[OrchestrationRuntimeStatus]] = None) -> list[DurableOrchestrationStatus]: + """Deprecated alias for :meth:`get_all_orchestration_states`. + + The v1 ``OrchestrationRuntimeStatus`` values are mapped onto the + durabletask ``OrchestrationStatus`` enum, and results are wrapped in + :class:`DurableOrchestrationStatus` for v1 back-compat. + """ + query = OrchestrationQuery( + created_time_from=created_time_from, + created_time_to=created_time_to, + runtime_status=to_durabletask_statuses(runtime_status)) + states = await self.get_all_orchestration_states(query) + return [DurableOrchestrationStatus.from_orchestration_state(state) for state in states] + + @deprecated("purge_instance_history_by is deprecated; use purge_orchestrations_by instead.") + async def purge_instance_history_by( + self, + created_time_from: Optional[datetime] = None, + created_time_to: Optional[datetime] = None, + runtime_status: Optional[list[OrchestrationRuntimeStatus]] = None) -> PurgeHistoryResult: + """Deprecated alias for :meth:`purge_orchestrations_by`. + + The v1 ``OrchestrationRuntimeStatus`` values are mapped onto the + durabletask ``OrchestrationStatus`` enum, and the result is wrapped in + :class:`PurgeHistoryResult` for v1 back-compat. + """ + result = await self.purge_orchestrations_by( + created_time_from=created_time_from, + created_time_to=created_time_to, + runtime_status=to_durabletask_statuses(runtime_status)) + return PurgeHistoryResult.from_purge_result(result) + + async def signal_entity( + self, + entity_instance_id: EntityInstanceId, + operation_name: str, + input: Any = None, + signal_time: Optional[datetime] = None, + *, + operation_input: Any = None, + task_hub_name: Optional[str] = None, + connection_name: Optional[str] = None) -> None: + """Signal an entity to perform an operation. + + Accepts the durabletask ``input`` argument as well as the v1 + ``operation_input`` alias. The ``task_hub_name`` and ``connection_name`` + arguments have no equivalent in durabletask and are ignored. + """ + resolved_input = operation_input if operation_input is not None else input + await super().signal_entity( + entity_instance_id, operation_name, input=resolved_input, signal_time=signal_time) + + @deprecated( + "get_client_response_links is deprecated; use create_http_management_payload instead.") + def get_client_response_links( + self, + request: Optional[func.HttpRequest], + instance_id: str) -> HttpManagementPayload: + """Deprecated alias for :meth:`create_http_management_payload`.""" + return self._get_client_response_links(request, instance_id) + + @deprecated( + "wait_for_completion_or_create_check_status_response is deprecated; use " + "wait_for_orchestration_completion together with create_check_status_response instead.") + async def wait_for_completion_or_create_check_status_response( + self, + request: func.HttpRequest, + instance_id: str, + timeout_in_milliseconds: int = 10000, + retry_interval_in_milliseconds: int = 1000) -> func.HttpResponse: + """Wait for an orchestration to complete, or return a check-status response. + + If the orchestration completes within the timeout, an HTTP response + containing its output (or failure) is returned; otherwise a + check-status response is returned. + + The ``retry_interval_in_milliseconds`` argument has no durabletask + equivalent (durabletask waits server-side) and is ignored. + """ + if retry_interval_in_milliseconds > timeout_in_milliseconds: + raise Exception( + f'Total timeout {timeout_in_milliseconds} (ms) should be bigger than ' + f'retry timeout {retry_interval_in_milliseconds} (ms)') + + try: + state = await self.wait_for_orchestration_completion( + instance_id, timeout=timeout_in_milliseconds / 1000) + except TimeoutError: + return self.create_check_status_response(request, instance_id) + + if state is None: + return self.create_check_status_response(request, instance_id) + + if state.runtime_status == OrchestrationStatus.COMPLETED: + return self._create_http_response(200, state.serialized_output) + if state.runtime_status == OrchestrationStatus.TERMINATED: + return self._create_http_response(200, state.serialized_output) + if state.runtime_status == OrchestrationStatus.FAILED: + return self._create_http_response(500, state.serialized_output) + return self.create_check_status_response(request, instance_id) + + @deprecated("rewind is deprecated; use rewind_orchestration instead.") + async def rewind( + self, + instance_id: str, + reason: str, + task_hub_name: Optional[str] = None, + connection_name: Optional[str] = None) -> None: + """Deprecated alias for :meth:`rewind_orchestration`. + + Rewinds a failed orchestration instance to its last known good state, + removing failed task and sub-orchestration results from the history and + replaying from the last successful checkpoint. + + The ``task_hub_name`` and ``connection_name`` arguments have no + equivalent in durabletask and are ignored. + """ + await self.rewind_orchestration(instance_id, reason=reason) + + @staticmethod + def _create_http_response(status_code: int, body: Union[str, Any]) -> func.HttpResponse: + body_as_json = body if isinstance(body, str) else json.dumps(body) + return func.HttpResponse( + status_code=status_code, + body=body_as_json, + mimetype="application/json", + headers={"Content-Type": "application/json"}) diff --git a/azure-functions-durable/azure/durable_functions/constants.py b/azure-functions-durable/azure/durable_functions/constants.py new file mode 100644 index 00000000..fbd268a7 --- /dev/null +++ b/azure-functions-durable/azure/durable_functions/constants.py @@ -0,0 +1,8 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Constants used to determine the local running context.""" +ORCHESTRATION_TRIGGER = "orchestrationTrigger" +ACTIVITY_TRIGGER = "activityTrigger" +ENTITY_TRIGGER = "entityTrigger" +DURABLE_CLIENT = "durableClient" diff --git a/azure-functions-durable/azure/durable_functions/decorators/__init__.py b/azure-functions-durable/azure/durable_functions/decorators/__init__.py new file mode 100644 index 00000000..59e481eb --- /dev/null +++ b/azure-functions-durable/azure/durable_functions/decorators/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. diff --git a/azure-functions-durable/azure/durable_functions/decorators/durable_app.py b/azure-functions-durable/azure/durable_functions/decorators/durable_app.py new file mode 100644 index 00000000..54caa91f --- /dev/null +++ b/azure-functions-durable/azure/durable_functions/decorators/durable_app.py @@ -0,0 +1,450 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from functools import wraps +from typing import Any, Callable, Optional, Union + +import azure.functions as func +from azure.functions import FunctionRegister, TriggerApi, BindingApi, AuthLevel +from azure.functions.decorators.function_app import DecoratorApi, FunctionBuilder + +from durabletask import task + +from .metadata import OrchestrationTrigger, ActivityTrigger, EntityTrigger, \ + DurableClient +from ..client import DurableFunctionsClient +from ..http.builtin import ( + BUILTIN_HTTP_ACTIVITY_NAME, + BUILTIN_HTTP_POLL_ORCHESTRATOR_NAME, + builtin_http_activity, + builtin_http_poll_orchestrator, +) +from ..worker import DurableFunctionsWorker +from ..orchestrator import Orchestrator + + +class Blueprint(TriggerApi, BindingApi): + """Durable Functions (DF) Blueprint container. + + It allows functions to be declared via trigger and binding decorators, + but does not automatically index/register these functions. + + To register these functions, utilize the `register_functions` method from any + :class:`FunctionRegister` subclass, such as `DFApp`. + """ + + def __init__(self, + http_auth_level: Union[AuthLevel, str] = AuthLevel.FUNCTION): + """Instantiate a Durable Functions app with which to register Functions. + + Parameters + ---------- + http_auth_level: Union[AuthLevel, str] + Authorization level required for Function invocation. + Defaults to AuthLevel.Function. + + Returns + ------- + DFApp + New instance of a Durable Functions app + """ + # The next-in-MRO base (``DecoratorApi.__init__``) is declared with + # untyped ``*args``/``**kwargs``, so pyright cannot see this call's type. + super().__init__(auth_level=http_auth_level) # pyright: ignore[reportUnknownMemberType] + self._register_builtin_http_functions() + + def _register_builtin_http_functions(self) -> None: + """Register the built-in durable HTTP activity and poll orchestrator. + + These back ``DurableOrchestrationContext.call_http``. They are + registered under reserved names on every app so existing code that + calls ``call_http`` works without any additional setup. + """ + self.activity_trigger( + input_name="input", + activity=BUILTIN_HTTP_ACTIVITY_NAME)(builtin_http_activity) + self.orchestration_trigger( + context_name="context", + orchestration=BUILTIN_HTTP_POLL_ORCHESTRATOR_NAME)(builtin_http_poll_orchestrator) + + def configure_scheduled_tasks(self) -> None: + """Opt in to durabletask scheduled tasks by registering their built-ins. + + Unlike durable HTTP (which is always available), scheduled tasks are + opt-in: most apps don't use them, so their schedule entity and + operation orchestrator are only registered when this method is called. + After calling it, manage schedules from the client with + :class:`durabletask.scheduled.ScheduledTaskClient`. + + The schedule entity is self-driving (it re-arms itself with delayed + self-signals), so no additional worker configuration is required in the + host-driven Functions model. + """ + from durabletask.scheduled.orchestrator import ( + execute_schedule_operation_orchestrator, + ) + from durabletask.scheduled.schedule_entity import ENTITY_NAME, Schedule + + self.entity_trigger( + context_name="context", entity_name=ENTITY_NAME)(Schedule) + self.orchestration_trigger( + context_name="context")(execute_schedule_operation_orchestrator) + + def configure_history_export(self) -> None: + """Opt in to durabletask history export by registering its built-ins. + + Like scheduled tasks, history export is opt-in: its export-job entity, + driving orchestrator, and two activities are only registered when this + method is called. After calling it, drive export jobs from the client + with :class:`durabletask.extensions.history_export.ExportHistoryClient`. + + The runtime dependencies the activities need (a durabletask client and a + :class:`~durabletask.extensions.history_export.writer.HistoryWriter`) are + supplied separately via + :func:`durabletask.extensions.history_export.bind_context`, since the + client is only available at request time in the host-driven Functions + model. + + The durabletask export activities take a ``(context, input)`` signature; + they ignore the context, so they are wrapped as single-input Functions + activities to match the host's activity calling convention. + + The enumeration activity uses a Functions-specific implementation + (:mod:`azure.durable_functions.internal.history_export_compat`) that + queries terminal instances via ``QueryInstances`` instead of the core + ``ListInstanceIds`` call, which the Durable Functions host extension + does not implement. + + NOTE: We need to consider whether this will perform well on Azure + Functions' distributed architecture - later. + """ + from durabletask.extensions.history_export._constants import ( + ENTITY_NAME as EXPORT_ENTITY_NAME, + ) + from durabletask.extensions.history_export.activities import ( + EXPORT_INSTANCE_HISTORY_ACTIVITY, + LIST_TERMINAL_INSTANCES_ACTIVITY, + export_instance_history, + ) + from durabletask.extensions.history_export.entity import ExportJobEntity + from durabletask.extensions.history_export.orchestrator import ( + export_job_orchestrator, + ) + from ..internal.history_export_compat import ( + list_terminal_instances, + ) + + self.entity_trigger( + context_name="context", entity_name=EXPORT_ENTITY_NAME)(ExportJobEntity) + self.orchestration_trigger(context_name="context")(export_job_orchestrator) + + def _list_terminal_instances(input: dict) -> dict: + return list_terminal_instances(None, input) # type: ignore[arg-type] + + def _export_instance_history(input: dict) -> dict: + return export_instance_history(None, input) # type: ignore[arg-type] + + _list_terminal_instances.__name__ = LIST_TERMINAL_INSTANCES_ACTIVITY + _export_instance_history.__name__ = EXPORT_INSTANCE_HISTORY_ACTIVITY + self.activity_trigger( + input_name="input", + activity=LIST_TERMINAL_INSTANCES_ACTIVITY)(_list_terminal_instances) + self.activity_trigger( + input_name="input", + activity=EXPORT_INSTANCE_HISTORY_ACTIVITY)(_export_instance_history) + + def _configure_orchestrator_callable( + self, + wrap: Callable[[Callable[..., Any]], FunctionBuilder], + input_type: Optional[type] = None + ) -> Callable[[task.Orchestrator[Any, Any]], FunctionBuilder]: + """Obtain decorator to construct an Orchestrator class from a user-defined Function. + + Parameters + ---------- + wrap: Callable + The next decorator to be applied. + input_type: Optional[type] + The expected type for orchestration input, forwarded from the + ``orchestration_trigger`` decorator so a v1-style + ``context.get_input()`` can decode the input to that type. + + Returns + ------- + Callable + The function to construct an Orchestrator class from the user-defined Function, + wrapped by the next decorator in the sequence. + """ + def decorator(orchestrator_func: task.Orchestrator[Any, Any]) -> FunctionBuilder: + # Construct an orchestrator based on the end-user code + + if input_type is not None: + # Stash the decorator-declared input type so the runtime can + # feed it to a v1-style ``context.get_input()``. + orchestrator_func._df_input_type = input_type # type: ignore[attr-defined] # noqa: E501 + + handle = Orchestrator.create(orchestrator_func) + + # invoke next decorator, with the Orchestrator as input + handle.__name__ = orchestrator_func.__name__ + return wrap(handle) + + return decorator + + def _configure_entity_callable( + self, + wrap: Callable[[Callable[..., Any]], FunctionBuilder] + ) -> Callable[[task.Entity[Any, Any]], FunctionBuilder]: + """Obtain decorator to construct an Entity class from a user-defined Function. + + Parameters + ---------- + wrap: Callable + The next decorator to be applied. + + Returns + ------- + Callable + The function to construct an Entity class from the user-defined Function, + wrapped by the next decorator in the sequence. + """ + def decorator(entity_func: task.Entity[Any, Any]) -> FunctionBuilder: + # Construct an orchestrator based on the end-user code + + # TODO: Because this handle method is the one actually exposed to the Functions SDK decorator, + # the parameter name will always be "context" here, even if the user specified a different name. + # We need to find a way to allow custom context names (like "ctx"). + # The generated handle is what the Azure Functions host registers, + # so its ``context`` parameter must be annotated with + # ``azure.functions.EntityContext`` for the host's entityTrigger + # binding converter to accept it; at runtime the host passes that + # transport context (exposing ``.body``). + def handle(context: func.EntityContext) -> str: + return DurableFunctionsWorker().execute_entity_batch_request(entity_func, context) + + handle.entity_function = entity_func # pyright: ignore[reportFunctionMemberAccess] + + # invoke next decorator, with the Entity as input + handle.__name__ = entity_func.__name__ + return wrap(handle) + + return decorator + + def _add_rich_client( + self, + fb: FunctionBuilder, + parameter_name: str, + client_constructor: Callable[[Any], Any] + ) -> None: + # Obtain user-code and force type annotation on the client-binding parameter to be `str`. + # This ensures a passing type-check of that specific parameter, + # circumventing a limitation of the worker in type-checking rich DF Client objects. + # TODO: Once rich-binding type checking is possible, remove the annotation change. + # ``FunctionBuilder._function`` and ``Function._func`` are private to + # azure-functions with no public accessor for mutating the wrapped + # user function. Holding it as ``Any`` keeps the single private-access + # waiver here rather than spreading it across each ``._func`` use. + function_obj: Any = fb._function # pyright: ignore[reportPrivateUsage] + user_code = function_obj._func + user_code.__annotations__[parameter_name] = str + + # `wraps` This ensures we re-export the same method-signature as the decorated method + @wraps(user_code) + async def df_client_middleware(*args: Any, **kwargs: Any) -> Any: + + # Obtain JSON-string currently passed as DF Client, + # construct rich object from it, + # and assign parameter to that rich object + starter = kwargs[parameter_name] + client = client_constructor(starter) + kwargs[parameter_name] = client + + # Invoke user code with rich DF Client binding + return await user_code(*args, **kwargs) + + # TODO: Is there a better way to support retrieving the unwrapped user code? + df_client_middleware.client_function = function_obj._func # pyright: ignore[reportAttributeAccessIssue] + + function_obj._func = df_client_middleware + + def _build_function( + self, + wrap: Callable[[FunctionBuilder], FunctionBuilder] + ) -> Callable[[Callable[..., Any]], FunctionBuilder]: + """Typed equivalent of the base ``_configure_function_builder``. + + The inherited method is untyped, which would otherwise propagate + ``Unknown`` types through every decorator below. This mirrors its + behaviour exactly using the typed protected members it relies on. + """ + def decorator(func: Callable[..., Any]) -> FunctionBuilder: + fb = self._validate_type(func) + self._function_builders.append(fb) + return wrap(fb) + + return decorator + + def orchestration_trigger(self, context_name: str, + orchestration: Optional[str] = None, + input_type: Optional[type] = None + ) -> Callable[[task.Orchestrator[Any, Any]], FunctionBuilder]: + """Register an Orchestrator Function. + + Parameters + ---------- + context_name: str + Parameter name of the DurableOrchestrationContext object. + orchestration: Optional[str] + Name of Orchestrator Function. + The value is None by default, in which case the name of the method is used. + input_type: Optional[type] + The expected type for the orchestration input. When set, a v1-style + ``context.get_input()`` decodes the input payload to this type. A + call-site ``expected_type`` argument on ``get_input`` takes + precedence over this value. + """ + @self._build_function + def wrap(fb: FunctionBuilder) -> FunctionBuilder: + + def decorator() -> FunctionBuilder: + fb.add_trigger( + trigger=OrchestrationTrigger(name=context_name, + orchestration=orchestration)) + return fb + + return decorator() + + return self._configure_orchestrator_callable(wrap, input_type=input_type) + + def activity_trigger(self, input_name: str, + activity: Optional[str] = None + ) -> Callable[[Callable[..., Any]], FunctionBuilder]: + """Register an Activity Function. + + Parameters + ---------- + input_name: str + Parameter name of the Activity input. + activity: Optional[str] + Name of Activity Function. + The value is None by default, in which case the name of the method is used. + """ + @self._build_function + def wrap(fb: FunctionBuilder) -> FunctionBuilder: + def decorator() -> FunctionBuilder: + fb.add_trigger( + trigger=ActivityTrigger(name=input_name, + activity=activity)) + return fb + + return decorator() + + return wrap + + def entity_trigger(self, + context_name: str, + entity_name: Optional[str] = None + ) -> Callable[[task.Entity[Any, Any]], FunctionBuilder]: + """Register an Entity Function. + + Parameters + ---------- + context_name: str + Parameter name of the Entity input. + entity_name: Optional[str] + Name of Entity Function. + The value is None by default, in which case the name of the method is used. + """ + @self._configure_entity_callable + @self._build_function + def wrap(fb: FunctionBuilder) -> FunctionBuilder: + def decorator() -> FunctionBuilder: + fb.add_trigger( + trigger=EntityTrigger(name=context_name, + entity_name=entity_name)) + return fb + + return decorator() + + return wrap + + def durable_client_input(self, + client_name: str, + task_hub: Optional[str] = None, + connection_name: Optional[str] = None + ) -> Callable[[Callable[..., Any]], FunctionBuilder]: + """Register a Durable-client Function. + + Parameters + ---------- + client_name: str + Parameter name of durable client. + task_hub: Optional[str] + Used in scenarios where multiple function apps share the same storage account + but need to be isolated from each other. If not specified, the default value + from host.json is used. + This value must match the value used by the target orchestrator functions. + connection_name: Optional[str] + The name of an app setting that contains a storage account connection string. + The storage account represented by this connection string must be the same one + used by the target orchestrator functions. If not specified, the default storage + account connection string for the function app is used. + """ + + @self._build_function + def wrap(fb: FunctionBuilder) -> FunctionBuilder: + def decorator() -> FunctionBuilder: + self._add_rich_client(fb, client_name, DurableFunctionsClient) + fb.add_binding( + binding=DurableClient(name=client_name, + task_hub=task_hub, + connection_name=connection_name)) + return fb + + return decorator() + + return wrap + + +class DFApp(Blueprint, FunctionRegister): + """Durable Functions (DF) app. + + Exports the decorators required to declare and index DF Function-types. + """ + + def register_functions(self, function_container: DecoratorApi) -> None: + """Register the functions of a blueprint into this app. + + Every :class:`Blueprint` (and the :class:`DFApp` itself) auto-registers + the reserved built-in durable-HTTP functions so ``call_http`` works out + of the box. Merging a blueprint that carries its own copies would + otherwise raise a duplicate-function-name error, breaking the standard + Functions blueprint pattern. This app already provides the built-ins, so + the blueprint's copies are dropped during registration. The incoming + container is left unmodified, so the same blueprint can be registered + into multiple apps. + """ + reserved = {BUILTIN_HTTP_ACTIVITY_NAME, BUILTIN_HTTP_POLL_ORCHESTRATOR_NAME} + original = function_container._function_builders + filtered = [fb for fb in original if _builder_function_name(fb) not in reserved] + if len(filtered) == len(original): + super().register_functions(function_container) + return + function_container._function_builders = filtered + try: + super().register_functions(function_container) + finally: + function_container._function_builders = original + + # ``register_blueprint`` is an alias of ``register_functions`` in the base + # ``FunctionRegister`` (the same function object), so it must be re-aliased + # here for blueprint registration to get the same built-in de-duplication. + register_blueprint = register_functions + + +def _builder_function_name(function_builder: FunctionBuilder) -> Optional[str]: + """Return a function builder's registered name, or ``None`` if unavailable.""" + try: + return function_builder._function.get_function_name() # pyright: ignore[reportPrivateUsage] + except Exception: # pragma: no cover - defensive; name is always present in practice + return None diff --git a/azure-functions-durable/azure/durable_functions/decorators/metadata.py b/azure-functions-durable/azure/durable_functions/decorators/metadata.py new file mode 100644 index 00000000..efe3983d --- /dev/null +++ b/azure-functions-durable/azure/durable_functions/decorators/metadata.py @@ -0,0 +1,118 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from typing import Optional + +from ..constants import ORCHESTRATION_TRIGGER, \ + ACTIVITY_TRIGGER, ENTITY_TRIGGER, DURABLE_CLIENT +from azure.functions.decorators.core import Trigger, InputBinding + + +class OrchestrationTrigger(Trigger): + """OrchestrationTrigger. + + Trigger representing an Orchestration Function. + """ + + @staticmethod + def get_binding_name() -> str: + """Get the name of this trigger, as a string. + + Returns + ------- + str + The string representation of this trigger. + """ + return ORCHESTRATION_TRIGGER + + def __init__(self, + name: str, + orchestration: Optional[str] = None, + durable_requires_grpc: bool = True, + ) -> None: + self.orchestration = orchestration + self.durable_requires_grpc = durable_requires_grpc + super().__init__(name=name) + + +class ActivityTrigger(Trigger): + """ActivityTrigger. + + Trigger representing a Durable Functions Activity. + """ + + @staticmethod + def get_binding_name() -> str: + """Get the name of this trigger, as a string. + + Returns + ------- + str + The string representation of this trigger. + """ + return ACTIVITY_TRIGGER + + def __init__(self, + name: str, + activity: Optional[str] = None, + durable_requires_grpc: bool = True, + ) -> None: + self.activity = activity + self.durable_requires_grpc = durable_requires_grpc + super().__init__(name=name) + + +class EntityTrigger(Trigger): + """EntityTrigger. + + Trigger representing an Entity Function. + """ + + @staticmethod + def get_binding_name() -> str: + """Get the name of this trigger, as a string. + + Returns + ------- + str + The string representation of this trigger. + """ + return ENTITY_TRIGGER + + def __init__(self, + name: str, + entity_name: Optional[str] = None, + durable_requires_grpc: bool = True, + ) -> None: + self.entity_name = entity_name + self.durable_requires_grpc = durable_requires_grpc + super().__init__(name=name) + + +class DurableClient(InputBinding): + """DurableClient. + + Binding representing a Durable-client object. + """ + + @staticmethod + def get_binding_name() -> str: + """Get the name of this Binding, as a string. + + Returns + ------- + str + The string representation of this binding. + """ + return DURABLE_CLIENT + + def __init__(self, + name: str, + task_hub: Optional[str] = None, + connection_name: Optional[str] = None, + durable_requires_grpc: bool = True, + ) -> None: + self.task_hub = task_hub + self.connection_name = connection_name + self.durable_requires_grpc = durable_requires_grpc + super().__init__(name=name) diff --git a/azure-functions-durable/azure/durable_functions/http/__init__.py b/azure-functions-durable/azure/durable_functions/http/__init__.py new file mode 100644 index 00000000..61232643 --- /dev/null +++ b/azure-functions-durable/azure/durable_functions/http/__init__.py @@ -0,0 +1,7 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +from ..http.http_management_payload import HttpManagementPayload +from .models import DurableHttpRequest, DurableHttpResponse + +__all__ = ["DurableHttpRequest", "DurableHttpResponse", "HttpManagementPayload"] diff --git a/azure-functions-durable/azure/durable_functions/http/builtin.py b/azure-functions-durable/azure/durable_functions/http/builtin.py new file mode 100644 index 00000000..dd582f10 --- /dev/null +++ b/azure-functions-durable/azure/durable_functions/http/builtin.py @@ -0,0 +1,185 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Built-in durable HTTP support for the Azure Functions compatibility layer. + +The v1 ``context.call_http`` API relied on the Durable Functions host extension +to execute the HTTP request (including automatic ``202 Accepted`` polling and +Managed Identity token acquisition). The durabletask protocol this SDK is built +on has no native HTTP action, so the feature is reconstructed here from core +primitives: + +* a built-in **activity** (:func:`builtin_http_activity`) performs a single HTTP + request -- acquiring a bearer token via ``azure-identity`` when a token source + is supplied -- and returns the response, and +* a built-in **poll orchestrator** (:func:`builtin_http_poll_orchestrator`) + issues the request and, while the endpoint returns ``202`` with a ``Location`` + header, waits on a durable timer (honoring ``Retry-After``) and re-polls until + the operation completes. + +``DurableOrchestrationContext.call_http`` schedules the poll orchestrator as a +sub-orchestration, preserving the single-``yield`` v1 ergonomics while keeping +the 202 polling loop durable (checkpointed across restarts). + +Both functions are auto-registered on every ``Blueprint``/``DFApp`` under +reserved names so existing apps that call ``call_http`` work with no changes. +""" + +from __future__ import annotations + +import urllib.error +import urllib.request +from datetime import timedelta +from email.utils import parsedate_to_datetime +from typing import Any, Generator, Optional + +from .models import DurableHttpResponse + +# Reserved built-in function names. The v1 host used ``BuiltIn::HttpActivity``; +# ``::`` is not a valid Azure Functions function name, so ``__`` is used here. +# The reserved names are unlikely to collide with user-defined functions. +BUILTIN_HTTP_ACTIVITY_NAME = "BuiltIn__HttpActivity" +BUILTIN_HTTP_POLL_ORCHESTRATOR_NAME = "BuiltIn__HttpPollOrchestrator" + +# Fallback interval (seconds) between polls when the 202 response carries no +# usable ``Retry-After`` header. +_DEFAULT_POLL_INTERVAL_SECONDS = 1 + + +def _acquire_bearer_token(resource: str) -> str: + """Acquire an AAD bearer token for ``resource`` via ``azure-identity``. + + Imported lazily so the dependency is only touched when a token source is + actually used. + """ + from azure.identity import DefaultAzureCredential + + credential = DefaultAzureCredential() + scope = resource.rstrip("/") + "/.default" + return credential.get_token(scope).token + + +def builtin_http_activity(input: dict) -> dict: + """Execute a single HTTP request and return the response payload. + + ``input`` is the JSON form of a + :class:`~azure.durable_functions.http.models.DurableHttpRequest` + (``method``, ``uri``, ``content``, ``headers``, ``tokenSource``). The + return value is the JSON form of a + :class:`~azure.durable_functions.http.models.DurableHttpResponse`. + + The parameter and return are annotated with the plain ``dict`` type on + purpose. The Azure Functions Python worker inspects trigger annotations + during indexing and requires them to be a real ``type`` (it rejects + parameterized generics such as ``dict[str, Any]`` with + ``FunctionLoadError: ... invalid non-type annotation`` and, for a + ``typing.Union`` origin like ``Optional[...]``, raises + ``TypeError: issubclass() arg 1 must be a class``). A bare ``dict`` keeps + worker indexing happy while the body still defends against ``None`` below. + """ + request = input or {} + method = str(request.get("method", "GET")).upper() + uri = request.get("uri") + if not uri: + raise ValueError("A non-empty 'uri' is required for a durable HTTP call.") + content = request.get("content") + headers: dict[str, str] = dict(request.get("headers") or {}) + + token_source = request.get("tokenSource") + if token_source: + resource = token_source.get("resource") + if resource: + token = _acquire_bearer_token(resource) + headers.setdefault("Authorization", f"Bearer {token}") + + # ``content`` has already been serialized to a string by ``call_http`` (JSON + # unless ``is_raw_str`` was set), so it is sent as-is. + data = content.encode("utf-8") if isinstance(content, str) else None + req = urllib.request.Request(url=uri, data=data, method=method, headers=headers) + + try: + with urllib.request.urlopen(req) as resp: # noqa: S310 - user-supplied URL is the feature + status = int(resp.status) + resp_headers = {k: v for k, v in resp.headers.items()} + body = resp.read().decode("utf-8", errors="replace") + except urllib.error.HTTPError as e: + # Non-2xx responses (including 202) surface here; capture rather than raise + # so the orchestrator can inspect the status code and headers. + status = int(e.code) + resp_headers = {k: v for k, v in (e.headers or {}).items()} + body = e.read().decode("utf-8", errors="replace") + + return DurableHttpResponse( + status_code=status, headers=resp_headers, content=body).to_json() + + +def _get_header(headers: dict[str, str], name: str) -> Optional[str]: + """Case-insensitively look up ``name`` in ``headers``.""" + lowered = name.lower() + for key, value in headers.items(): + if key.lower() == lowered: + return value + return None + + +def _retry_after_seconds(headers: dict[str, str]) -> int: + """Parse the ``Retry-After`` header into a delay in seconds. + + Supports both the delta-seconds and HTTP-date forms; falls back to + :data:`_DEFAULT_POLL_INTERVAL_SECONDS` when absent or unparseable. + """ + raw = _get_header(headers, "Retry-After") + if raw is None: + return _DEFAULT_POLL_INTERVAL_SECONDS + raw = raw.strip() + if raw.isdigit(): + return max(int(raw), 0) + try: + retry_at = parsedate_to_datetime(raw) + except (TypeError, ValueError): + return _DEFAULT_POLL_INTERVAL_SECONDS + if retry_at is None: + return _DEFAULT_POLL_INTERVAL_SECONDS + return _DEFAULT_POLL_INTERVAL_SECONDS + + +def builtin_http_poll_orchestrator(context: Any) -> Generator[Any, Any, dict[str, Any]]: + """Issue a durable HTTP request and poll while it returns ``202``. + + Receives the request payload as its input, calls the built-in HTTP activity, + and while the response is ``202 Accepted`` with a ``Location`` header waits + on a durable timer (honoring ``Retry-After``) before re-polling the + ``Location`` URL. Returns the final response payload. + """ + request: dict[str, Any] = context.get_input() or {} + response: dict[str, Any] = yield context.call_activity( + BUILTIN_HTTP_ACTIVITY_NAME, request) + + while response.get("status_code") == 202: + headers = response.get("headers") or {} + location = _get_header(headers, "Location") + if not location: + # Cannot poll without a Location; return the 202 as-is. + break + + delay = _retry_after_seconds(headers) + fire_at = context.current_utc_datetime + timedelta(seconds=delay) + yield context.create_timer(fire_at) + + poll_request: dict[str, Any] = {"method": "GET", "uri": location} + # Preserve auth for the polling requests. + if request.get("headers") is not None: + poll_request["headers"] = request["headers"] + if request.get("tokenSource") is not None: + poll_request["tokenSource"] = request["tokenSource"] + + response = yield context.call_activity(BUILTIN_HTTP_ACTIVITY_NAME, poll_request) + + return response + + +# The durable dispatch name is the registered function name (its ``__name__``). +# Assign the reserved names so ``call_activity`` / ``call_sub_orchestrator`` in +# ``call_http`` resolve to these built-ins. +builtin_http_activity.__name__ = BUILTIN_HTTP_ACTIVITY_NAME +builtin_http_poll_orchestrator.__name__ = BUILTIN_HTTP_POLL_ORCHESTRATOR_NAME diff --git a/azure-functions-durable/azure/durable_functions/http/http_management_payload.py b/azure-functions-durable/azure/durable_functions/http/http_management_payload.py new file mode 100644 index 00000000..8f33ce91 --- /dev/null +++ b/azure-functions-durable/azure/durable_functions/http/http_management_payload.py @@ -0,0 +1,50 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +import json +from typing import Any + + +class HttpManagementPayload(dict[str, str]): + """A class representing the HTTP management payload for a Durable Function orchestration instance. + + Contains URLs for managing the instance, such as querying status, + sending events, terminating, restarting, etc. + + Subclasses ``dict`` for backwards compatibility with the v1 API, which + returned a plain ``dict``. As a result the payload supports mapping-style + access (``payload["statusQueryGetUri"]``, iteration, ``in``, + ``.keys()``/``.items()``/``.values()``) and is directly JSON-serializable + via ``json.dumps(payload)``. + """ + + def __init__(self, instance_id: str, instance_status_url: str, required_query_string_parameters: str): + """Initializes the HttpManagementPayload with the necessary URLs. + + Args: + instance_id (str): The ID of the Durable Function instance. + instance_status_url (str): The base URL for the instance status. + required_query_string_parameters (str): The required URL parameters provided by the Durable extension. + """ + super().__init__({ + 'id': instance_id, + 'purgeHistoryDeleteUri': instance_status_url + "?" + required_query_string_parameters, + 'restartPostUri': instance_status_url + "/restart?" + required_query_string_parameters, + 'sendEventPostUri': instance_status_url + "/raiseEvent/{eventName}?" + required_query_string_parameters, + 'statusQueryGetUri': instance_status_url + "?" + required_query_string_parameters, + 'terminatePostUri': instance_status_url + "/terminate?reason={text}&" + required_query_string_parameters, + 'resumePostUri': instance_status_url + "/resume?reason={text}&" + required_query_string_parameters, + 'suspendPostUri': instance_status_url + "/suspend?reason={text}&" + required_query_string_parameters + }) + + def __str__(self) -> str: + return json.dumps(self) + + @property + def urls(self) -> dict[str, Any]: + """Return the management URLs as a plain ``dict`` (v1 compatibility).""" + return dict(self) + + def to_json(self) -> dict[str, Any]: + """Return the management URLs as a plain ``dict``.""" + return dict(self) diff --git a/azure-functions-durable/azure/durable_functions/http/models.py b/azure-functions-durable/azure/durable_functions/http/models.py new file mode 100644 index 00000000..57b3250c --- /dev/null +++ b/azure-functions-durable/azure/durable_functions/http/models.py @@ -0,0 +1,122 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Durable HTTP request/response models (v1-compatible). + +These mirror the v1 ``DurableHttpRequest`` and the ``DurableHttpResponse`` shape +returned by ``context.call_http``. In v1 the Durable Functions host extension +executed the request natively; here the request is carried to a built-in +activity that performs the call, so these models double as the (JSON) wire +payload exchanged with that activity. +""" + +from __future__ import annotations + +from typing import Any, Optional, cast + +from ..internal.compat.token_source import TokenSource + + +class DurableHttpRequest: + """Data structure representing a durable HTTP request.""" + + def __init__(self, + method: str, + uri: str, + content: Optional[str] = None, + headers: Optional[dict[str, str]] = None, + token_source: Optional[TokenSource] = None): + self._method = method + self._uri = uri + self._content = content + self._headers = headers + self._token_source = token_source + + @property + def method(self) -> str: + """Get the HTTP request method.""" + return self._method + + @property + def uri(self) -> str: + """Get the HTTP request uri.""" + return self._uri + + @property + def content(self) -> Optional[str]: + """Get the HTTP request content.""" + return self._content + + @property + def headers(self) -> Optional[dict[str, str]]: + """Get the HTTP request headers.""" + return self._headers + + @property + def token_source(self) -> Optional[TokenSource]: + """Get the source of the OAuth token to add to the request.""" + return self._token_source + + def to_json(self) -> dict[str, Any]: + """Convert this request into a JSON-serializable dictionary.""" + json_dict: dict[str, Any] = {"method": self._method, "uri": self._uri} + if self._content is not None: + json_dict["content"] = self._content + if self._headers is not None: + json_dict["headers"] = dict(self._headers) + if self._token_source is not None: + # TokenSource exposes ``to_json`` (e.g. ManagedIdentityTokenSource). + json_dict["tokenSource"] = self._token_source.to_json() # type: ignore[attr-defined] # noqa: E501 + return json_dict + + +class DurableHttpResponse: + """Data structure representing a durable HTTP response. + + Returned from ``context.call_http``. Exposes ``status_code``, ``headers`` + and ``content`` as attributes, matching the v1 access pattern + (``response.status_code`` / ``response.content``). + """ + + def __init__(self, + status_code: int, + headers: Optional[dict[str, str]] = None, + content: Optional[str] = None): + self._status_code = status_code + self._headers = headers or {} + self._content = content + + @property + def status_code(self) -> int: + """Get the HTTP response status code.""" + return self._status_code + + @property + def headers(self) -> dict[str, str]: + """Get the HTTP response headers.""" + return self._headers + + @property + def content(self) -> Optional[str]: + """Get the HTTP response content.""" + return self._content + + def to_json(self) -> dict[str, Any]: + """Convert this response into a JSON-serializable dictionary.""" + return { + "status_code": self._status_code, + "headers": dict(self._headers), + "content": self._content, + } + + @classmethod + def from_json(cls, value: dict[str, Any]) -> "DurableHttpResponse": + """Reconstruct a ``DurableHttpResponse`` from its dictionary payload. + + Accepts both the snake_case wire format produced by :meth:`to_json` and + the camelCase ``statusCode`` key for defensiveness. + """ + status_code = value.get("status_code", value.get("statusCode", 0)) + headers = cast("dict[str, str]", value.get("headers") or {}) + content = value.get("content") + return cls(status_code=int(status_code), headers=headers, content=content) diff --git a/azure-functions-durable/azure/durable_functions/internal/__init__.py b/azure-functions-durable/azure/durable_functions/internal/__init__.py new file mode 100644 index 00000000..59e481eb --- /dev/null +++ b/azure-functions-durable/azure/durable_functions/internal/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. diff --git a/azure-functions-durable/azure/durable_functions/internal/azurefunctions_grpc_interceptor.py b/azure-functions-durable/azure/durable_functions/internal/azurefunctions_grpc_interceptor.py new file mode 100644 index 00000000..35373b20 --- /dev/null +++ b/azure-functions-durable/azure/durable_functions/internal/azurefunctions_grpc_interceptor.py @@ -0,0 +1,48 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +from importlib.metadata import version + +from durabletask.internal.grpc_interceptor import ( + DefaultAsyncClientInterceptorImpl, + DefaultClientInterceptorImpl, +) + + +def _build_metadata(taskhub_name: str) -> list[tuple[str, str]]: + """Build the gRPC metadata headers sent on every Durable Functions call.""" + try: + # Get the version of the azurefunctions package + sdk_version = version('azure-functions-durable') + except Exception: + # Fallback if version cannot be determined + sdk_version = "unknown" + user_agent = f"durabletask-python/{sdk_version}" + return [ + ("taskhub", taskhub_name), + ("x-user-agent", user_agent)] # 'user-agent' is a reserved header in grpc, so we use 'x-user-agent' instead + + +class AzureFunctionsDefaultClientInterceptorImpl(DefaultClientInterceptorImpl): + """The class implements a UnaryUnaryClientInterceptor, UnaryStreamClientInterceptor, + StreamUnaryClientInterceptor and StreamStreamClientInterceptor from grpc to add an + interceptor to add additional headers to all calls as needed.""" + required_query_string_parameters: str + + def __init__(self, taskhub_name: str, required_query_string_parameters: str): + self.required_query_string_parameters = required_query_string_parameters + self._metadata = _build_metadata(taskhub_name) + super().__init__(self._metadata) + + +class AzureFunctionsAsyncDefaultClientInterceptorImpl(DefaultAsyncClientInterceptorImpl): + """Async version of AzureFunctionsDefaultClientInterceptorImpl for use with grpc.aio channels. + + This class implements async gRPC interceptors to add Durable Functions headers + (task hub name and user agent) to all async calls.""" + required_query_string_parameters: str + + def __init__(self, taskhub_name: str, required_query_string_parameters: str): + self.required_query_string_parameters = required_query_string_parameters + self._metadata = _build_metadata(taskhub_name) + super().__init__(self._metadata) diff --git a/azure-functions-durable/azure/durable_functions/internal/azurefunctions_null_stub.py b/azure-functions-durable/azure/durable_functions/internal/azurefunctions_null_stub.py new file mode 100644 index 00000000..af8593d1 --- /dev/null +++ b/azure-functions-durable/azure/durable_functions/internal/azurefunctions_null_stub.py @@ -0,0 +1,23 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +from typing import Any, Callable + + +class AzureFunctionsNullStub: + """A task hub sidecar stub whose every method is a no-op. + + Instances structurally satisfy the methods of + ``ProtoTaskHubSidecarServiceStub`` without inheriting from that + ``Protocol`` (a ``Protocol`` subclass cannot be instantiated). Any + attribute access resolves to a callable that ignores its arguments and + returns ``None``, which is sufficient because the Azure Functions worker + replaces the relevant completion callbacks before invoking the base + worker logic. + """ + + def __getattr__(self, name: str) -> Callable[..., None]: + def _noop(*args: Any, **kwargs: Any) -> None: + return None + + return _noop diff --git a/azure-functions-durable/azure/durable_functions/internal/compat/__init__.py b/azure-functions-durable/azure/durable_functions/internal/compat/__init__.py new file mode 100644 index 00000000..72b690df --- /dev/null +++ b/azure-functions-durable/azure/durable_functions/internal/compat/__init__.py @@ -0,0 +1,8 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Backwards-compatibility shims for the v1 azure-functions-durable API. + +The public names defined here are re-exported from ``azure.durable_functions``; +import them from there rather than from this internal package. +""" diff --git a/azure-functions-durable/azure/durable_functions/internal/compat/compat_aliases.py b/azure-functions-durable/azure/durable_functions/internal/compat/compat_aliases.py new file mode 100644 index 00000000..ddfcf923 --- /dev/null +++ b/azure-functions-durable/azure/durable_functions/internal/compat/compat_aliases.py @@ -0,0 +1,31 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +from typing import Any + +from typing_extensions import deprecated + +from ...client import DurableFunctionsClient + + +@deprecated( + "DurableOrchestrationClient is deprecated; use DurableFunctionsClient instead.") +class DurableOrchestrationClient(DurableFunctionsClient): + """Deprecated alias for :class:`DurableFunctionsClient`.""" + + +@deprecated( + "The Entity class is deprecated and unsupported in v2; register entities " + "with the entity_trigger decorator instead.") +class Entity: + """Deprecated placeholder for the v1 ``Entity`` executor class. + + Entities in v2 are registered with the ``entity_trigger`` decorator and + executed by the durabletask worker; there is no user-facing ``Entity`` + class. This placeholder is retained only so existing imports do not fail. + """ + + def __init__(self, *args: Any, **kwargs: Any): + raise NotImplementedError( + "The Entity class is not supported in v2. Register entities with " + "the entity_trigger decorator instead.") diff --git a/azure-functions-durable/azure/durable_functions/internal/compat/durable_orchestration_status.py b/azure-functions-durable/azure/durable_functions/internal/compat/durable_orchestration_status.py new file mode 100644 index 00000000..29ce55b4 --- /dev/null +++ b/azure-functions-durable/azure/durable_functions/internal/compat/durable_orchestration_status.py @@ -0,0 +1,180 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +import json +from datetime import datetime +from typing import Any, Optional, cast + +from durabletask.client import OrchestrationState, OrchestrationStatus + +from .orchestration_runtime_status import ( + OrchestrationRuntimeStatus, + from_durabletask_status, + to_durabletask_status, +) + + +class DurableOrchestrationStatus: + """Represents the status of a durable orchestration instance. + + Backwards-compatible wrapper around the durabletask + :class:`~durabletask.client.OrchestrationState`. It exposes the v1 + ``DurableOrchestrationStatus`` attribute surface so existing code that reads + ``status.runtime_status``, ``status.output``, ``status.input_``, etc. keeps + working. New code should use ``OrchestrationState`` directly. + + A status wrapping ``None`` (i.e. a non-existent instance) is falsy, matching + the v1 behaviour where ``get_status`` never returned ``None``. + """ + + def __init__(self, state: Optional[OrchestrationState] = None): + self._state = state + + @classmethod + def from_orchestration_state( + cls, state: Optional[OrchestrationState]) -> "DurableOrchestrationStatus": + """Wrap a durabletask ``OrchestrationState`` (or ``None``).""" + return cls(state) + + @classmethod + def from_json(cls, json_obj: Any) -> "DurableOrchestrationStatus": + """Reconstruct a status from its v1 JSON representation. + + Accepts the dictionary produced by :meth:`to_json` (or the equivalent v1 + schema); a JSON string is parsed first. The wrapped + ``OrchestrationState`` is rebuilt so the resulting object exposes the + same attribute surface as one returned by the client. + """ + if isinstance(json_obj, str): + json_obj = json.loads(json_obj) + data = dict(json_obj) + + runtime_status = data.get("runtimeStatus") + dt_status = ( + to_durabletask_status(OrchestrationRuntimeStatus(runtime_status)) + if runtime_status is not None else None) + + def _parse_datetime(value: Any) -> Any: + return datetime.fromisoformat(value) if isinstance(value, str) else value + + def _reserialize(value: Any) -> Optional[str]: + return None if value is None else json.dumps(value) + + state = OrchestrationState( + instance_id=cast(str, data.get("instanceId")), + name=cast(str, data.get("name")), + runtime_status=cast(OrchestrationStatus, dt_status), + created_at=cast(datetime, _parse_datetime(data.get("createdTime"))), + last_updated_at=cast(datetime, _parse_datetime(data.get("lastUpdatedTime"))), + serialized_input=_reserialize(data.get("input")), + serialized_output=_reserialize(data.get("output")), + serialized_custom_status=_reserialize(data.get("customStatus")), + failure_details=None, + ) + return cls(state) + + def __bool__(self) -> bool: + return self._state is not None + + @property + def orchestration_state(self) -> Optional[OrchestrationState]: + """Get the underlying durabletask ``OrchestrationState`` (or ``None``).""" + return self._state + + @property + def name(self) -> Optional[str]: + """Get the orchestrator function name.""" + return self._state.name if self._state is not None else None + + @property + def instance_id(self) -> Optional[str]: + """Get the unique ID of the instance.""" + return self._state.instance_id if self._state is not None else None + + @property + def created_time(self) -> Optional[datetime]: + """Get the time at which the orchestration instance was created.""" + return self._state.created_at if self._state is not None else None + + @property + def last_updated_time(self) -> Optional[datetime]: + """Get the time at which the orchestration instance last updated.""" + return self._state.last_updated_at if self._state is not None else None + + @property + def input_(self) -> Any: + """Get the (deserialized) input of the orchestration instance.""" + return self._state.get_input() if self._state is not None else None + + @property + def output(self) -> Any: + """Get the (deserialized) output of the orchestration instance.""" + return self._state.get_output() if self._state is not None else None + + @property + def runtime_status(self) -> Optional[OrchestrationRuntimeStatus]: + """Get the runtime status as a v1 ``OrchestrationRuntimeStatus``.""" + if self._state is None or self._state.runtime_status is None: + return None + return from_durabletask_status(self._state.runtime_status) + + @property + def custom_status(self) -> Any: + """Get the (deserialized) custom status payload, if any.""" + return self._state.get_custom_status() if self._state is not None else None + + @property + def history(self) -> Optional[list[Any]]: + """Get the execution history. + + History is not available through this compatibility path and is always + ``None``; use ``get_orchestration_history`` on the client instead. + """ + return None + + def to_json(self) -> dict[str, Any]: + """Convert this status into a v1-compatible JSON dictionary. + + Payload fields (``output``, ``input``, ``customStatus``) are emitted as + their raw JSON representation rather than the reconstructed Python + objects, so the result is always JSON-serializable even when the + orchestration payloads are custom types. + """ + result: dict[str, Any] = {} + if self.name is not None: + result["name"] = self.name + if self.instance_id is not None: + result["instanceId"] = self.instance_id + if self.created_time is not None: + result["createdTime"] = self.created_time.isoformat() + if self.last_updated_time is not None: + result["lastUpdatedTime"] = self.last_updated_time.isoformat() + output = self._raw_payload( + self._state.serialized_output if self._state is not None else None) + if output is not None: + result["output"] = output + input_ = self._raw_payload( + self._state.serialized_input if self._state is not None else None) + if input_ is not None: + result["input"] = input_ + if self.runtime_status is not None: + result["runtimeStatus"] = self.runtime_status.name + custom_status = self._raw_payload( + self._state.serialized_custom_status if self._state is not None else None) + if custom_status is not None: + result["customStatus"] = custom_status + return result + + @staticmethod + def _raw_payload(serialized: Optional[str]) -> Any: + """Parse a serialized payload as plain JSON without reconstructing types. + + Returns the parsed JSON value (which is always JSON-serializable), or the + original string if it is not valid JSON, or ``None`` when absent. + """ + if serialized is None: + return None + try: + return json.loads(serialized) + except (TypeError, ValueError): + return serialized diff --git a/azure-functions-durable/azure/durable_functions/internal/compat/entity_context.py b/azure-functions-durable/azure/durable_functions/internal/compat/entity_context.py new file mode 100644 index 00000000..d2043a3f --- /dev/null +++ b/azure-functions-durable/azure/durable_functions/internal/compat/entity_context.py @@ -0,0 +1,116 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +from typing import Any, Callable, Optional + +from durabletask.entities import EntityContext + +from .orchestration_context import accepts_two_positional_args + + +class DurableEntityContext: + """Azure Functions-style entity context (v1-compatible). + + Wraps a durabletask :class:`~durabletask.entities.EntityContext` (and the + operation input) and exposes the v1 ``DurableEntityContext`` API. It is + delivered to one-argument entity functions (``def entity(context):``). + durabletask-native two-argument entity functions + (``def entity(ctx, input):``) and class-based entities are used directly. + """ + + def __init__(self, ctx: EntityContext, operation_input: Any = None): + self._ctx = ctx + self._input = operation_input + self._result: Any = None + + # -- identity ------------------------------------------------------------ + @property + def entity_name(self) -> str: + """Get the entity name.""" + return self._ctx.entity_id.entity + + @property + def entity_key(self) -> str: + """Get the entity key.""" + return self._ctx.entity_id.key + + @property + def operation_name(self) -> str: + """Get the current operation name.""" + return self._ctx.operation + + @property + def is_newly_constructed(self) -> bool: + """Whether the entity was newly constructed. + + The v1 semantics of this flag were unspecified; it is always ``False``. + """ + return False + + # -- input / state / result --------------------------------------------- + def get_input(self, expected_type: Optional[type] = None) -> Any: + """Get the input for the current operation. + + ``expected_type`` is accepted for v1 compatibility but the input is + already deserialized by durabletask, so it is returned as-is. + """ + return self._input + + def get_state(self, + initializer: Optional[Callable[[], Any]] = None, + expected_type: Optional[type] = None) -> Any: + """Get the current state of the entity. + + Parameters + ---------- + initializer : Optional[Callable[[], Any]] + A zero-argument callable providing the initial state when no state + exists yet. + expected_type : Optional[type] + Optional type used to reconstruct the state. + """ + default = initializer() if callable(initializer) else None + return self._ctx.get_state(expected_type, default) + + def set_state(self, state: Any) -> None: + """Set the state of the entity.""" + self._ctx.set_state(state) + + def set_result(self, result: Any) -> None: + """Set the result (return value) of the current operation.""" + self._result = result + + def resolve_result(self, fallback: Any) -> Any: + """Return the value set via :meth:`set_result`, or ``fallback`` if unset.""" + return self._result if self._result is not None else fallback + + def destruct_on_exit(self) -> None: + """Delete this entity after the operation completes.""" + self._ctx.set_state(None) + + +def wrap_entity(fn: Callable[..., Any]) -> Callable[..., Any]: + """Adapt a v1-style one-argument entity function to durabletask's ``(ctx, input)``. + + Class-based entities and durabletask-native two-argument entity functions + are returned unchanged. For a wrapped v1 entity, the operation result is + taken from ``context.set_result(...)`` (falling back to the function's + return value). + """ + if isinstance(fn, type): + # Class-based entity: handled natively by durabletask. + return fn + if accepts_two_positional_args(fn): + # durabletask-native (ctx, input) entity function. + return fn + + def _wrapper(ctx: EntityContext, _input: Any = None) -> Any: + adapter = DurableEntityContext(ctx, _input) + returned = fn(adapter) + return adapter.resolve_result(returned) + + _wrapper.__name__ = getattr(fn, "__name__", "entity") + durable_entity_name = getattr(fn, "__durable_entity_name__", None) + if durable_entity_name is not None: + _wrapper.__durable_entity_name__ = durable_entity_name # type: ignore[attr-defined] + return _wrapper diff --git a/azure-functions-durable/azure/durable_functions/internal/compat/entity_id.py b/azure-functions-durable/azure/durable_functions/internal/compat/entity_id.py new file mode 100644 index 00000000..7f296ae1 --- /dev/null +++ b/azure-functions-durable/azure/durable_functions/internal/compat/entity_id.py @@ -0,0 +1,40 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +from typing_extensions import deprecated + +from durabletask.entities import EntityInstanceId + + +@deprecated( + "EntityId is deprecated; use durabletask.entities.EntityInstanceId instead.") +class EntityId(EntityInstanceId): + """Backwards-compatible shim for the v1 ``EntityId`` class. + + Identifies an entity by its name and key. New code should use + :class:`durabletask.entities.EntityInstanceId`. + """ + + def __init__(self, name: str, key: str): + """Instantiate an EntityId object. + + Args: + name (str): The entity name. + key (str): The entity key. + """ + super().__init__(entity=name, key=key) + + @property + def name(self) -> str: + """Get the entity name (v1 alias for ``entity``).""" + return self.entity + + @staticmethod + def get_scheduler_id(entity_id: EntityInstanceId) -> str: + """Produce a scheduler ID string (``@name@key``) from an entity ID.""" + return str(entity_id) + + @staticmethod + def get_entity_id(scheduler_id: str) -> EntityInstanceId: + """Return an entity ID from a scheduler ID string (``@name@key``).""" + return EntityInstanceId.parse(scheduler_id) diff --git a/azure-functions-durable/azure/durable_functions/internal/compat/entity_state_response.py b/azure-functions-durable/azure/durable_functions/internal/compat/entity_state_response.py new file mode 100644 index 00000000..dd593d39 --- /dev/null +++ b/azure-functions-durable/azure/durable_functions/internal/compat/entity_state_response.py @@ -0,0 +1,41 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +from typing import Any, Optional + +from durabletask.entities.entity_metadata import EntityMetadata + + +class EntityStateResponse: + """Entity state response object for ``read_entity_state``. + + Backwards-compatible wrapper around the durabletask + :class:`~durabletask.entities.entity_metadata.EntityMetadata`. New code + should use ``get_entity`` and the returned ``EntityMetadata`` directly. + """ + + def __init__(self, entity_exists: bool, entity_state: Any = None): + self._entity_exists = entity_exists + self._entity_state = entity_state + + @classmethod + def from_entity_metadata( + cls, metadata: Optional[EntityMetadata]) -> "EntityStateResponse": + """Build a response from a durabletask ``EntityMetadata`` (or ``None``).""" + if metadata is None: + return cls(False) + state = metadata.get_typed_state() if metadata.includes_state else None + return cls(True, state) + + @property + def entity_exists(self) -> bool: + """Get the bool representing whether the entity exists.""" + return self._entity_exists + + @property + def entity_state(self) -> Any: + """Get the state of the entity. + + When ``entity_exists`` is ``False``, this value is ``None``. + """ + return self._entity_state diff --git a/azure-functions-durable/azure/durable_functions/internal/compat/function_context.py b/azure-functions-durable/azure/durable_functions/internal/compat/function_context.py new file mode 100644 index 00000000..984b877f --- /dev/null +++ b/azure-functions-durable/azure/durable_functions/internal/compat/function_context.py @@ -0,0 +1,23 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +from typing import Any + + +class FunctionContext: + """Holds additional function-level attributes not used by Durable. + + Backwards-compatible with the v1 ``FunctionContext``, which was populated + from any *extra* fields present in the orchestration-trigger JSON payload + beyond those the ``DurableOrchestrationContext`` consumed directly. + + In v2 the orchestration request is a protobuf that carries no such arbitrary + extra fields, so this is typically empty -- matching the common v1 case, + where the base trigger payload (``instanceId``, ``parentInstanceId``, + ``isReplaying``, ``input``, ``upperSchemaVersion``, ``history``) is fully + consumed and nothing is left over. + """ + + def __init__(self, **kwargs: Any) -> None: + for key, value in kwargs.items(): + setattr(self, key, value) diff --git a/azure-functions-durable/azure/durable_functions/internal/compat/orchestration_context.py b/azure-functions-durable/azure/durable_functions/internal/compat/orchestration_context.py new file mode 100644 index 00000000..136908b2 --- /dev/null +++ b/azure-functions-durable/azure/durable_functions/internal/compat/orchestration_context.py @@ -0,0 +1,319 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +import inspect +import json +from datetime import datetime, timezone +from typing import Any, Callable, Generator, Optional, cast +from uuid import UUID + +from durabletask import task +from durabletask.entities import EntityInstanceId +from durabletask.task import OrchestrationContext, RetryPolicy, Task + +from ..serialization import DEFAULT_FUNCTIONS_DATA_CONVERTER +from .function_context import FunctionContext +from .token_source import TokenSource + + +class DurableOrchestrationContext: + """Azure Functions-style orchestration context (v1-compatible). + + Wraps a durabletask :class:`~durabletask.task.OrchestrationContext` (and the + orchestration input) and exposes the v1 ``DurableOrchestrationContext`` API. + It is delivered to one-argument orchestrator functions + (``def orchestrator(context):``); durabletask-native two-argument + orchestrators (``def orchestrator(ctx, input):``) receive the durabletask + context directly instead. + """ + + def __init__(self, + ctx: OrchestrationContext, + orchestration_input: Any = None, + input_type: Optional[type] = None): + self._ctx = ctx + self._input = orchestration_input + self._input_type = input_type + self._custom_status: Any = None + self._will_continue_as_new = False + # v1 exposed a FunctionContext bag of any extra trigger-payload fields. + # The durabletask protobuf request carries no such extras, so this is + # an empty bag -- matching the common v1 runtime case. + self._function_context = FunctionContext() + + # -- input --------------------------------------------------------------- + def get_input(self, expected_type: Optional[type] = None) -> Any: + """Get the orchestration input. + + When an ``expected_type`` (or the ``input_type`` declared on the + ``orchestration_trigger`` decorator) is available, the already-decoded + input is coerced to that type; otherwise the raw value is returned. + """ + resolved_type = expected_type or self._input_type + if resolved_type is None: + return self._input + return DEFAULT_FUNCTIONS_DATA_CONVERTER.coerce(self._input, resolved_type) + + # -- properties ---------------------------------------------------------- + @property + def instance_id(self) -> str: + """Get the ID of the current orchestration instance.""" + return self._ctx.instance_id + + @property + def is_replaying(self) -> bool: + """Get whether the orchestrator is currently replaying.""" + return self._ctx.is_replaying + + @property + def current_utc_datetime(self) -> datetime: + """Get the replay-safe current UTC date/time. + + Returned as a timezone-aware (UTC) datetime for v1 compatibility; + durabletask exposes a naive UTC datetime. + """ + value = self._ctx.current_utc_datetime + if value.tzinfo is None: + return value.replace(tzinfo=timezone.utc) + return value + + @property + def custom_status(self) -> Any: + """Get the custom status set during this execution (or ``None``).""" + return self._custom_status + + @property + def will_continue_as_new(self) -> bool: + """Whether :meth:`continue_as_new` has been called in this execution.""" + return self._will_continue_as_new + + @property + def version(self) -> Optional[str]: + """Get the version assigned to the orchestration instance (or ``None``).""" + return self._ctx.version + + @property + def parent_instance_id(self) -> Optional[str]: + """Get the ID of the parent orchestration. + + Returns ``None`` for a top-level orchestration (i.e. one that was not + started as a sub-orchestration). + """ + return self._ctx.parent_instance_id + + @property + def function_context(self) -> FunctionContext: + """Get the Azure Functions-level context (extra trigger-payload fields). + + Returned as an (empty) :class:`FunctionContext` for v1 drop-in + compatibility. The durabletask orchestration request does not carry the + arbitrary extra fields the v1 JSON trigger payload could, so there is + nothing to populate -- which matches the common v1 case where the base + payload is fully consumed and the bag is empty. + """ + return self._function_context + + @property + def histories(self) -> Any: + """Get the running history of scheduled tasks. + + Not available: durabletask manages orchestration history internally and + does not expose it on the context. + """ + raise NotImplementedError( + "histories is not exposed by durabletask; use the client's " + "get_orchestration_history instead.") + + # -- activities ---------------------------------------------------------- + def call_activity(self, name: Callable[..., Any] | str, input_: Any = None) -> Task[Any]: + """Schedule an activity function for execution.""" + return self._ctx.call_activity(name, input=input_) + + def call_activity_with_retry(self, + name: Callable[..., Any] | str, + retry_options: RetryPolicy, + input_: Any = None) -> Task[Any]: + """Schedule an activity function for execution, with retries.""" + return self._ctx.call_activity(name, input=input_, retry_policy=retry_options) + + # -- sub-orchestrators --------------------------------------------------- + def call_sub_orchestrator(self, + name: Callable[..., Any] | str, + input_: Any = None, + instance_id: Optional[str] = None) -> Task[Any]: + """Schedule a sub-orchestrator function for execution.""" + return self._ctx.call_sub_orchestrator(name, input=input_, instance_id=instance_id) + + def call_sub_orchestrator_with_retry(self, + name: Callable[..., Any] | str, + retry_options: RetryPolicy, + input_: Any = None, + instance_id: Optional[str] = None) -> Task[Any]: + """Schedule a sub-orchestrator function for execution, with retries.""" + return self._ctx.call_sub_orchestrator( + name, input=input_, instance_id=instance_id, retry_policy=retry_options) + + # -- timers and events --------------------------------------------------- + def create_timer(self, fire_at: datetime) -> Task[Any]: + """Create a durable timer that fires at the specified time.""" + return self._ctx.create_timer(fire_at) + + def wait_for_external_event(self, + name: str, + expected_type: Optional[type] = None) -> Task[Any]: + """Wait for an external event with the given name.""" + return self._ctx.wait_for_external_event(name, data_type=expected_type) + + # -- control ------------------------------------------------------------- + def continue_as_new(self, input_: Any) -> None: + """Restart the orchestration with a new input.""" + self._will_continue_as_new = True + self._ctx.continue_as_new(input_) + + def set_custom_status(self, status: Any) -> None: + """Set the orchestration's custom status payload.""" + self._custom_status = status + self._ctx.set_custom_status(status) + + # -- deterministic IDs --------------------------------------------------- + def new_uuid(self) -> str: + """Create a new replay-safe UUID string.""" + return self._ctx.new_uuid() + + def new_guid(self) -> UUID: + """Create a new replay-safe UUID.""" + return UUID(self._ctx.new_uuid()) + + # -- fan-out / fan-in ---------------------------------------------------- + def task_all(self, tasks: list[Task[Any]]) -> Task[Any]: + """Schedule all tasks and complete when all of them complete.""" + return task.when_all(tasks) + + def task_any(self, tasks: list[Task[Any]]) -> Task[Any]: + """Schedule all tasks and complete when the first one completes.""" + return task.when_any(tasks) + + # -- entities ------------------------------------------------------------ + def call_entity(self, + entityId: EntityInstanceId, + operationName: str, + operationInput: Any = None) -> Task[Any]: + """Call an entity operation and get its result.""" + return self._ctx.call_entity(entityId, operationName, operationInput) + + def signal_entity(self, + entityId: EntityInstanceId, + operationName: str, + operationInput: Any = None) -> None: + """Signal an entity operation (fire and forget).""" + self._ctx.signal_entity(entityId, operationName, input=operationInput) + + # -- durable HTTP -------------------------------------------------------- + def call_http(self, + method: str, + uri: str, + content: Any = None, + headers: Optional[dict[str, str]] = None, + token_source: Optional[TokenSource] = None, + is_raw_str: bool = False) -> Task[Any]: + """Schedule a durable HTTP call to the specified endpoint. + + The request is executed by a built-in activity and, if the endpoint + responds with ``202 Accepted`` and a ``Location`` header, is polled to + completion via a built-in sub-orchestration (honoring ``Retry-After``). + The task's result is a + :class:`~azure.durable_functions.http.models.DurableHttpResponse`. + + Parameters + ---------- + method: str + The HTTP request method. + uri: str + The HTTP request uri. + content: Any + The HTTP request content. A ``str`` is sent as JSON unless + ``is_raw_str`` is set; other values are serialized to JSON. + headers: Optional[dict[str, str]] + The HTTP request headers. + token_source: Optional[TokenSource] + The source of the OAuth token to add to the request. + is_raw_str: bool + When ``True``, send ``str`` content as-is instead of JSON-encoding + it. Requires ``content`` to be a ``str``. + """ + # Imported lazily to avoid a module import cycle + # (http.models -> compat.token_source). + from ...http.builtin import BUILTIN_HTTP_POLL_ORCHESTRATOR_NAME + from ...http.models import DurableHttpRequest, DurableHttpResponse + + if (not isinstance(content, str)) and is_raw_str: + raise TypeError( + "Invalid use of 'is_raw_str' parameter: 'is_raw_str' is set to " + "'True' but 'content' is not an instance of type 'str'. Either " + "set 'is_raw_str' to 'False', or ensure your 'content' is of " + "type 'str'.") + + json_content: Optional[str] = None + if content is not None: + if isinstance(content, str) and is_raw_str: + json_content = content + else: + json_content = json.dumps(content) + + request = DurableHttpRequest(method, uri, json_content, headers, token_source) + return self._ctx.call_sub_orchestrator( + BUILTIN_HTTP_POLL_ORCHESTRATOR_NAME, + input=request.to_json(), + return_type=DurableHttpResponse) + + +def accepts_two_positional_args(fn: Callable[..., Any]) -> bool: + """Return True if ``fn`` can be called with two positional args ``(ctx, input)``. + + Two-argument functions are treated as durabletask-native orchestrators; + single-argument functions are treated as Azure Functions / v1-style + orchestrators that receive a wrapped :class:`DurableOrchestrationContext`. + """ + try: + sig = inspect.signature(fn) + except (TypeError, ValueError): + # Can't introspect -> assume durabletask-native and pass through. + return True + + positional = 0 + for param in sig.parameters.values(): + if param.kind in (param.POSITIONAL_ONLY, param.POSITIONAL_OR_KEYWORD): + positional += 1 + elif param.kind == param.VAR_POSITIONAL: + return True + return positional >= 2 + + +def wrap_orchestrator(fn: Callable[..., Any]) -> Callable[..., Any]: + """Adapt a v1-style one-argument orchestrator to durabletask's ``(ctx, input)``. + + Two-argument (durabletask-native) orchestrators are returned unchanged. The + returned wrapper deliberately does not set ``__wrapped__`` so durabletask + introspects the wrapper's own ``(context, _input)`` signature (and thus + passes the raw input) rather than the wrapped function's signature. + """ + if accepts_two_positional_args(fn): + return fn + + input_type = getattr(fn, "_df_input_type", None) + name = getattr(fn, "__name__", "orchestrator") + + if inspect.isgeneratorfunction(fn): + def _generator_wrapper(context: OrchestrationContext, _input: Any = None) -> Any: + adapter = DurableOrchestrationContext(context, _input, input_type) + generator = cast("Generator[Any, Any, Any]", fn(adapter)) + result: Any = yield from generator + return result + _generator_wrapper.__name__ = name + return _generator_wrapper + + def _wrapper(context: OrchestrationContext, _input: Any = None) -> Any: + adapter = DurableOrchestrationContext(context, _input, input_type) + return fn(adapter) + _wrapper.__name__ = name + return _wrapper diff --git a/azure-functions-durable/azure/durable_functions/internal/compat/orchestration_runtime_status.py b/azure-functions-durable/azure/durable_functions/internal/compat/orchestration_runtime_status.py new file mode 100644 index 00000000..71b595d4 --- /dev/null +++ b/azure-functions-durable/azure/durable_functions/internal/compat/orchestration_runtime_status.py @@ -0,0 +1,101 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +from enum import Enum + +from durabletask.client import OrchestrationStatus + + +class OrchestrationRuntimeStatus(Enum): + """The status of an orchestration instance. + + Backwards-compatible enum matching the v1 ``OrchestrationRuntimeStatus`` + values. New code should use :class:`durabletask.client.OrchestrationStatus`. + """ + + Running = 'Running' + """The orchestration instance has started running.""" + + Completed = 'Completed' + """The orchestration instance has completed normally.""" + + ContinuedAsNew = 'ContinuedAsNew' + """The orchestration instance has restarted itself with a new history. + + This is a transient state. + """ + + Failed = 'Failed' + """The orchestration instance failed with an error.""" + + Canceled = 'Canceled' + """The orchestration was canceled gracefully.""" + + Terminated = 'Terminated' + """The orchestration instance was stopped abruptly.""" + + Pending = 'Pending' + """The orchestration instance has been scheduled but has not yet started running.""" + + Suspended = 'Suspended' + """The orchestration instance has been suspended and may go back to running at a later time.""" + + +# Maps the v1 OrchestrationRuntimeStatus members onto the durabletask +# OrchestrationStatus enum. ``Canceled`` has no durabletask equivalent. +_TO_DURABLETASK_STATUS: dict[OrchestrationRuntimeStatus, OrchestrationStatus] = { + OrchestrationRuntimeStatus.Running: OrchestrationStatus.RUNNING, + OrchestrationRuntimeStatus.Completed: OrchestrationStatus.COMPLETED, + OrchestrationRuntimeStatus.ContinuedAsNew: OrchestrationStatus.CONTINUED_AS_NEW, + OrchestrationRuntimeStatus.Failed: OrchestrationStatus.FAILED, + OrchestrationRuntimeStatus.Terminated: OrchestrationStatus.TERMINATED, + OrchestrationRuntimeStatus.Pending: OrchestrationStatus.PENDING, + OrchestrationRuntimeStatus.Suspended: OrchestrationStatus.SUSPENDED, +} + + +def to_durabletask_status(status: "OrchestrationRuntimeStatus") -> OrchestrationStatus: + """Convert a v1 ``OrchestrationRuntimeStatus`` to a durabletask ``OrchestrationStatus``. + + Raises + ------ + ValueError + If the status has no durabletask equivalent (e.g. ``Canceled``). + """ + try: + return _TO_DURABLETASK_STATUS[status] + except KeyError: + raise ValueError( + f"OrchestrationRuntimeStatus.{status.name} has no durabletask " + "OrchestrationStatus equivalent.") + + +def to_durabletask_statuses( + statuses: "list[OrchestrationRuntimeStatus] | None") -> "list[OrchestrationStatus] | None": + """Convert a list of v1 statuses to durabletask statuses, preserving ``None``.""" + if statuses is None: + return None + return [to_durabletask_status(status) for status in statuses] + + +# Reverse mapping: durabletask OrchestrationStatus -> v1 OrchestrationRuntimeStatus. +# Every durabletask status has a v1 equivalent (``Canceled`` is v1-only). +_FROM_DURABLETASK_STATUS: dict[OrchestrationStatus, OrchestrationRuntimeStatus] = { + durabletask_status: v1_status + for v1_status, durabletask_status in _TO_DURABLETASK_STATUS.items() +} + + +def from_durabletask_status(status: OrchestrationStatus) -> "OrchestrationRuntimeStatus": + """Convert a durabletask ``OrchestrationStatus`` to a v1 ``OrchestrationRuntimeStatus``. + + Raises + ------ + ValueError + If the status has no v1 equivalent. + """ + try: + return _FROM_DURABLETASK_STATUS[status] + except KeyError: + raise ValueError( + f"OrchestrationStatus {status} has no v1 OrchestrationRuntimeStatus equivalent.") diff --git a/azure-functions-durable/azure/durable_functions/internal/compat/purge_history_result.py b/azure-functions-durable/azure/durable_functions/internal/compat/purge_history_result.py new file mode 100644 index 00000000..adf3dd06 --- /dev/null +++ b/azure-functions-durable/azure/durable_functions/internal/compat/purge_history_result.py @@ -0,0 +1,34 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +from typing import Any + +from durabletask.client import PurgeInstancesResult + + +class PurgeHistoryResult: + """Information provided when a request to purge history has been made. + + Backwards-compatible wrapper around the durabletask + :class:`~durabletask.client.PurgeInstancesResult`. New code should use + ``PurgeInstancesResult`` directly (note the attribute is + ``deleted_instance_count`` there). + """ + + def __init__(self, instances_deleted: int): + self._instances_deleted = instances_deleted + + @classmethod + def from_purge_result(cls, result: PurgeInstancesResult) -> "PurgeHistoryResult": + """Wrap a durabletask ``PurgeInstancesResult``.""" + return cls(result.deleted_instance_count) + + @classmethod + def from_json(cls, json_obj: "dict[str, Any]") -> "PurgeHistoryResult": + """Reconstruct a result from its v1 JSON representation.""" + return cls(instances_deleted=json_obj["instancesDeleted"]) + + @property + def instances_deleted(self) -> int: + """Get the number of deleted instances.""" + return self._instances_deleted diff --git a/azure-functions-durable/azure/durable_functions/internal/compat/retry_options.py b/azure-functions-durable/azure/durable_functions/internal/compat/retry_options.py new file mode 100644 index 00000000..0a615513 --- /dev/null +++ b/azure-functions-durable/azure/durable_functions/internal/compat/retry_options.py @@ -0,0 +1,53 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +from datetime import timedelta + +from typing_extensions import deprecated + +from durabletask.task import RetryPolicy + + +@deprecated( + "RetryOptions is deprecated; use durabletask.task.RetryPolicy with " + "timedelta values instead.") +class RetryOptions(RetryPolicy): + """Backwards-compatible shim for the v1 ``RetryOptions`` class. + + This maps the v1 millisecond-based constructor onto the durabletask + :class:`~durabletask.task.RetryPolicy`, which uses ``timedelta`` values. + New code should use ``RetryPolicy`` directly. + """ + + def __init__( + self, + first_retry_interval_in_milliseconds: int, + max_number_of_attempts: int): + """Create a new RetryOptions instance. + + Args: + first_retry_interval_in_milliseconds (int): The retry interval, in + milliseconds, to use for the first retry attempt. Must be + greater than 0. + max_number_of_attempts (int): The maximum number of retry attempts. + """ + if first_retry_interval_in_milliseconds <= 0: + raise ValueError( + "first_retry_interval_in_milliseconds value must be greater than 0.") + + super().__init__( + first_retry_interval=timedelta( + milliseconds=first_retry_interval_in_milliseconds), + max_number_of_attempts=max_number_of_attempts) + + @property + def first_retry_interval_in_milliseconds(self) -> int: + """Get the first retry interval, in milliseconds.""" + return int(self.first_retry_interval / timedelta(milliseconds=1)) + + def to_json(self) -> dict[str, int]: + """Return the v1 JSON representation of these retry options.""" + return { + "firstRetryIntervalInMilliseconds": self.first_retry_interval_in_milliseconds, + "maxNumberOfAttempts": self.max_number_of_attempts, + } diff --git a/azure-functions-durable/azure/durable_functions/internal/compat/token_source.py b/azure-functions-durable/azure/durable_functions/internal/compat/token_source.py new file mode 100644 index 00000000..b36e3f74 --- /dev/null +++ b/azure-functions-durable/azure/durable_functions/internal/compat/token_source.py @@ -0,0 +1,44 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +from abc import ABC + + +class TokenSource(ABC): + """Token source abstract base class. + + A token source supplies an OAuth token that is attached to the request made + by the orchestrator ``call_http`` API. See + :meth:`DurableOrchestrationContext.call_http`. + """ + + def __init__(self): + super().__init__() + + +class ManagedIdentityTokenSource(TokenSource): + """Returns a ``ManagedIdentityTokenSource`` object. + + Pass an instance to ``call_http`` to have a Managed Identity bearer token + for the given ``resource`` attached to the outbound request. + """ + + def __init__(self, resource: str): + """Create a ManagedIdentityTokenSource. + + Args: + resource (str): The Azure Active Directory resource identifier of the + web API being invoked. + """ + super().__init__() + self._resource: str = resource + self._kind: str = "AzureManagedIdentity" + + @property + def resource(self) -> str: + """Get the Azure Active Directory resource identifier of the web API being invoked.""" + return self._resource + + def to_json(self) -> dict[str, str]: + """Convert this object into a JSON-serializable dictionary.""" + return {"resource": self._resource, "kind": self._kind} diff --git a/azure-functions-durable/azure/durable_functions/internal/history_export_compat.py b/azure-functions-durable/azure/durable_functions/internal/history_export_compat.py new file mode 100644 index 00000000..aaaf7e6f --- /dev/null +++ b/azure-functions-durable/azure/durable_functions/internal/history_export_compat.py @@ -0,0 +1,83 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Compatibility override for the history-export enumeration activity. + +The core durabletask ``list_terminal_instances`` activity enumerates terminal +instances via the ``ListInstanceIds`` gRPC call. The Azure Functions Durable +extension's gRPC endpoint does not implement that method (it returns +``UNIMPLEMENTED``), so this module provides a drop-in replacement that +enumerates via ``QueryInstances`` (:meth:`get_all_orchestration_states`) +instead -- a method the extension does implement. + +> [!NOTE] +> This shim exists only because the Durable Functions host extension does not +> yet implement ``ListInstanceIds``. Once it does, delete this module and have +> :meth:`DFApp.configure_history_export` register the core +> ``durabletask.extensions.history_export.activities.list_terminal_instances`` +> activity directly. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, Optional + +from durabletask import task +from durabletask.client import OrchestrationQuery, OrchestrationStatus +from durabletask.internal.helpers import ensure_aware + +from durabletask.extensions.history_export._internal import dt_from_iso +from durabletask.extensions.history_export.activities import _require_context + +# The activity registers under the same name the export orchestrator calls, so +# it transparently replaces the core activity. +LIST_TERMINAL_INSTANCES_ACTIVITY = "list_terminal_instances" + + +def list_terminal_instances( + _: task.ActivityContext, input: Mapping[str, Any]) -> dict[str, Any]: + """Enumerate terminal instances via ``QueryInstances``. + + Drop-in replacement for the core ``list_terminal_instances`` activity that + avoids the unimplemented ``ListInstanceIds`` call. ``QueryInstances`` filters + by *created* time whereas the export filters by *completed* time, so the + completed-time window is applied client-side against each instance's last + update time. Every match is returned in a single page + (``continuation_token`` is always ``None``) because + ``get_all_orchestration_states`` paginates internally. + """ + ctx = _require_context() + + # A continuation token means the first call already returned everything; + # there is no second page under this enumeration strategy. + if input.get("continuation_token"): + return {"instance_ids": [], "continuation_token": None} + + raw_statuses = input.get("runtime_status") + runtime_status_names: Optional[list[str]] = ( + list(raw_statuses) if raw_statuses is not None else None + ) + completed_time_from = dt_from_iso(input.get("completed_time_from")) + completed_time_to = dt_from_iso(input.get("completed_time_to")) + if completed_time_from is None: + raise ValueError("list_terminal_instances requires 'completed_time_from'") + + runtime_status: Optional[list[OrchestrationStatus]] = None + if runtime_status_names is not None: + runtime_status = [OrchestrationStatus[name] for name in runtime_status_names] + + states = ctx.client.get_all_orchestration_states( + OrchestrationQuery(runtime_status=runtime_status)) + + instance_ids: list[str] = [] + for state in states: + completed_at = ensure_aware(state.last_updated_at) + if completed_at is not None: + if completed_at < completed_time_from: + continue + if completed_time_to is not None and completed_at > completed_time_to: + continue + instance_ids.append(state.instance_id) + + return {"instance_ids": instance_ids, "continuation_token": None} diff --git a/azure-functions-durable/azure/durable_functions/internal/serialization.py b/azure-functions-durable/azure/durable_functions/internal/serialization.py new file mode 100644 index 00000000..0a72aa64 --- /dev/null +++ b/azure-functions-durable/azure/durable_functions/internal/serialization.py @@ -0,0 +1,128 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Azure Functions payload serialization for Durable Task. + +Bridges durabletask's pluggable :class:`~durabletask.serialization.DataConverter` +to the azure-functions SDK's centralized ``df_dumps`` / ``df_loads`` serializers +so that payloads round-trip through the **exact** wire format the Durable +Functions host extension (and the SDK's ``ActivityTriggerConverter``) expect: +builtins as plain JSON, custom objects wrapped in the +``{"__class__", "__module__", "__data__"}`` envelope via their ``to_json`` / +``from_json`` hooks. + +When the installed ``azure-functions`` package exposes ``df_dumps`` / ``df_loads`` +(centralized serializers with optional type validation and strict-typing +support), they are used directly. On older releases that lack them we fall back +to the legacy ``_serialize_custom_object`` / ``_deserialize_custom_object`` hooks +-- the same behavior the SDK converter uses in those versions -- keeping both +sides symmetric. The wire format is unchanged either way. +""" + +from __future__ import annotations + +import importlib +import json +import logging +from typing import Any, Callable, Optional, cast + +from durabletask.serialization import JsonDataConverter + +logger = logging.getLogger("azure.functions.DurableFunctions") + +# ``azure.functions`` only exposes its Durable serialization helpers from a +# private, untyped module. Resolve it dynamically and bind the symbols we need +# to locally-typed callables so the rest of the module stays type-checked. +_df_internal = importlib.import_module("azure.functions._durable_functions") +_serialize_custom_object: Callable[[Any], Any] = getattr( + _df_internal, "_serialize_custom_object") +_deserialize_custom_object: Callable[[dict[str, Any]], Any] = getattr( + _df_internal, "_deserialize_custom_object") + +_FALLBACK_MESSAGE = ( + "The installed 'azure-functions' package does not provide the centralized " + "'df_dumps' / 'df_loads' serializers. Durable Functions is falling back to " + "the legacy serialization pipeline; the wire format is unchanged, but " + "payload type validation (the 'expected_type' argument and strict typing " + "mode) is unavailable. Upgrade to azure-functions>=1.26.0b4 to enable " + "type-validated serialization." +) + +_warned = False + + +def _warn_fallback_once() -> None: + # Deferred to first use (debug level) rather than emitted at import time, so + # users who never exercise the fallback path are not spammed. + global _warned + if not _warned: + _warned = True + logger.debug(_FALLBACK_MESSAGE) + + +def _fallback_df_dumps(value: Any) -> str: + """Serialize ``value`` via the legacy custom-object hook.""" + _warn_fallback_once() + return json.dumps(value, default=_serialize_custom_object) + + +def _fallback_df_loads(s: str, expected_type: Optional[type] = None) -> Any: + """Deserialize ``s`` via the legacy custom-object hook. + + ``expected_type`` is accepted for call-site compatibility but ignored on + this fallback path; type validation is only performed by the SDK's + ``df_loads`` when it is available. + """ + _warn_fallback_once() + return json.loads(s, object_hook=_deserialize_custom_object) + + +# Prefer the SDK's centralized serializers; fall back to the legacy hooks when +# they are unavailable (older azure-functions releases). +_sdk_df_dumps = getattr(_df_internal, "df_dumps", None) +_sdk_df_loads = getattr(_df_internal, "df_loads", None) + +df_dumps: Callable[[Any], str] = ( + cast("Callable[[Any], str]", _sdk_df_dumps) + if callable(_sdk_df_dumps) else _fallback_df_dumps) +df_loads: Callable[..., Any] = ( + cast("Callable[..., Any]", _sdk_df_loads) + if callable(_sdk_df_loads) else _fallback_df_loads) + + +class FunctionsDataConverter(JsonDataConverter): + """:class:`DataConverter` that serializes via azure-functions' codec. + + Overrides only the string boundary (:meth:`serialize` / :meth:`deserialize`) + to route through ``df_dumps`` / ``df_loads`` -- producing the + ``{"__class__", "__module__", "__data__"}`` envelope that the Durable + Functions host expects -- while inheriting :class:`JsonDataConverter`'s + value-level :meth:`coerce` and reconstruction policy + (:meth:`can_reconstruct`), which operate on already-parsed values and are + wire-format agnostic. + """ + + def serialize(self, value: Any) -> str | None: + if value is None: + return None + return df_dumps(value) + + def deserialize(self, data: str | None, target_type: type | None = None) -> Any: + if data is None or data == "": + return None + value = df_loads(data, target_type) + # ``df_loads`` reconstructs the custom-object envelope + # (``{"__class__", "__module__", "__data__"}``), but a payload that was + # serialized as a plain JSON structure -- e.g. a ``dict`` produced by a + # built-in activity/orchestrator such as ``DurableHttpResponse.to_json()`` + # -- is returned as that plain value. Apply the inherited value-level + # coercion so ``from_json``-capable target types (like + # ``DurableHttpResponse``) are still reconstructed. ``coerce`` is a + # no-op when the value already matches ``target_type`` or when no type + # was requested. + return self.coerce(value, target_type) + + +# Shared instance: the converter is stateless, so a single instance is reused +# across the per-invocation worker/client objects. +DEFAULT_FUNCTIONS_DATA_CONVERTER: FunctionsDataConverter = FunctionsDataConverter() diff --git a/azure-functions-durable/azure/durable_functions/orchestrator.py b/azure-functions-durable/azure/durable_functions/orchestrator.py new file mode 100644 index 00000000..e70d033f --- /dev/null +++ b/azure-functions-durable/azure/durable_functions/orchestrator.py @@ -0,0 +1,78 @@ +"""Durable Orchestrator. + +Responsible for orchestrating the execution of the user defined generator +function. +""" +from typing import Any, Callable, Generator + +import azure.functions as func + +from durabletask.task import OrchestrationContext + +from .worker import DurableFunctionsWorker + + +class Orchestrator: + """Durable Orchestration Class. + + Responsible for orchestrating the execution of the user defined generator + function. + """ + + def __init__(self, + activity_func: Callable[[OrchestrationContext, Any], Generator[Any, Any, Any]]): + """Create a new orchestrator for the user defined generator. + + Responsible for orchestrating the execution of the user defined + generator function. + :param activity_func: Generator function to orchestrate. + """ + self.fn: Callable[[OrchestrationContext, Any], Generator[Any, Any, Any]] = activity_func + + def handle(self, context: func.OrchestrationContext) -> str: + """Handle the orchestration of the user defined generator function. + + Parameters + ---------- + context : azure.functions.OrchestrationContext + The Durable Functions orchestration trigger context. This is the + transport wrapper supplied by the host (it exposes ``.body``); the + user's orchestrator function receives a durabletask + ``OrchestrationContext`` during execution inside the worker. + + Returns + ------- + str + The JSON-formatted string representing the user's orchestration + state after this invocation + """ + self.durable_context = context + return DurableFunctionsWorker().execute_orchestration_request(self.fn, context) + + @classmethod + def create(cls, fn: Callable[[OrchestrationContext, Any], Generator[Any, Any, Any]]) \ + -> Callable[[Any], str]: + """Create an instance of the orchestration class. + + Parameters + ---------- + fn: Callable[[DurableOrchestrationContext], Iterator[Any]] + Generator function that needs orchestration + + Returns + ------- + Callable[[Any], str] + Handle function of the newly created orchestration client + """ + + # The generated handle is the function registered with the Azure + # Functions host. Its ``context`` parameter must be annotated with + # ``azure.functions.OrchestrationContext`` so the host's + # orchestrationTrigger binding converter accepts it; at runtime the + # host passes that transport context (exposing ``.body``). + def handle(context: func.OrchestrationContext) -> str: + return Orchestrator(fn).handle(context) + + handle.orchestrator_function = fn # pyright: ignore[reportFunctionMemberAccess] + + return handle diff --git a/azure-functions-durable/azure/durable_functions/worker.py b/azure-functions-durable/azure/durable_functions/worker.py new file mode 100644 index 00000000..72271f03 --- /dev/null +++ b/azure-functions-durable/azure/durable_functions/worker.py @@ -0,0 +1,99 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +import base64 +from typing import Any, Optional + +from durabletask import task +from durabletask.internal.orchestrator_service_pb2 import ( + EntityBatchRequest, + EntityBatchResult, + HistoryEvent, + OrchestratorRequest, + OrchestratorResponse, +) +from durabletask.worker import TaskHubGrpcWorker +from .internal.azurefunctions_null_stub import AzureFunctionsNullStub +from .internal.compat.entity_context import wrap_entity +from .internal.compat.orchestration_context import wrap_orchestrator +from .internal.serialization import DEFAULT_FUNCTIONS_DATA_CONVERTER + + +# Worker class used for Durable Task Scheduler (DTS) +class DurableFunctionsWorker(TaskHubGrpcWorker): + """A worker that can execute orchestrator and entity functions in the context of Azure Functions. + + Used internally by the Durable Functions Python SDK, and should not be visible to functionapps directly. + See TaskHubGrpcWorker for base class documentation. + """ + + def __init__(self) -> None: + # We never start the worker loop or open a gRPC channel. The base + # constructor only initialises in-memory state (registry, logger, + # concurrency options, payload store, etc.) that the inherited + # ``_execute_*`` methods rely on; work items are delivered directly by + # the methods below rather than streamed from a sidecar. + # + # The Functions converter routes payload serialization through the + # azure-functions codec (df_dumps/df_loads) so user types round-trip in + # the wire format the Durable Functions host extension expects. + super().__init__(data_converter=DEFAULT_FUNCTIONS_DATA_CONVERTER) + + def add_named_orchestrator(self, name: str, func: task.Orchestrator[Any, Any]) -> None: + self._registry.add_named_orchestrator(name, func) + + def execute_orchestration_request(self, func: task.Orchestrator[Any, Any], context: Any) -> str: + context_body = getattr(context, "body", None) + if context_body is None: + context_body = context + orchestration_context = context_body + request = OrchestratorRequest() + request.ParseFromString(base64.b64decode(orchestration_context)) + stub: Any = AzureFunctionsNullStub() + response: Optional[OrchestratorResponse] = None + + def stub_complete(stub_response: OrchestratorResponse) -> None: + nonlocal response + response = stub_response + stub.CompleteOrchestratorTask = stub_complete + execution_started_events: list[HistoryEvent] = [] + for e in request.pastEvents: + if e.HasField("executionStarted"): + execution_started_events.append(e) + for e in request.newEvents: + if e.HasField("executionStarted"): + execution_started_events.append(e) + if len(execution_started_events) == 0: + raise Exception("No ExecutionStarted event found in orchestration request.") + + function_name = execution_started_events[-1].executionStarted.name + self.add_named_orchestrator(function_name, wrap_orchestrator(func)) + super()._execute_orchestrator(request, stub, None) + + if response is None: + raise Exception("Orchestrator execution did not produce a response.") + # The Python worker returns the input as type "json", so double-encoding is necessary + return base64.b64encode(response.SerializeToString()).decode('utf-8') + + def execute_entity_batch_request(self, func: task.Entity[Any, Any], context: Any) -> str: + context_body = getattr(context, "body", None) + if context_body is None: + context_body = context + orchestration_context = context_body + request = EntityBatchRequest() + request.ParseFromString(base64.b64decode(orchestration_context)) + stub: Any = AzureFunctionsNullStub() + response: Optional[EntityBatchResult] = None + + def stub_complete(stub_response: EntityBatchResult) -> None: + nonlocal response + response = stub_response + stub.CompleteEntityTask = stub_complete + + self.add_entity(wrap_entity(func)) + super()._execute_entity_batch(request, stub, None) + + if response is None: + raise Exception("Entity execution did not produce a response.") + # The Python worker returns the input as type "json", so double-encoding is necessary + return base64.b64encode(response.SerializeToString()).decode('utf-8') diff --git a/azure-functions-durable/pyproject.toml b/azure-functions-durable/pyproject.toml new file mode 100644 index 00000000..3c6fc6f6 --- /dev/null +++ b/azure-functions-durable/pyproject.toml @@ -0,0 +1,44 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# For more information on pyproject.toml, see https://peps.python.org/pep-0621/ + +[build-system] +requires = ["setuptools", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "azure-functions-durable" +version = "2.0.0dev0" +description = "Durable Task Python SDK provider implementation for Durable Azure Functions" +keywords = [ + "durable", + "task", + "workflow", + "azure", + "azure functions" +] +classifiers = [ + "Development Status :: 3 - Alpha", + "Programming Language :: Python :: 3", + "License :: OSI Approved :: MIT License", +] +requires-python = ">=3.13" +license = {file = "LICENSE"} +readme = "README.md" +dependencies = [ + "durabletask>=1.2.0dev0", + "azure-identity>=1.19.0", + "azure-functions>=2.2.0b6", + "typing-extensions>=4.9.0" +] + +[project.urls] +repository = "https://github.com/microsoft/durabletask-python" +changelog = "https://github.com/microsoft/durabletask-python/blob/main/CHANGELOG.md" + +[tool.setuptools.packages.find] +include = ["azure.durable_functions", "azure.durable_functions.*"] + +[tool.pytest.ini_options] +minversion = "6.0" diff --git a/azure-functions-durable/pyrightconfig.json b/azure-functions-durable/pyrightconfig.json new file mode 100644 index 00000000..fc3affe5 --- /dev/null +++ b/azure-functions-durable/pyrightconfig.json @@ -0,0 +1,16 @@ +{ + "include": [ + "azure" + ], + "extraPaths": [ + ".." + ], + "exclude": [ + "**/__pycache__", + "**/.venv*", + ".venv*", + "build" + ], + "pythonVersion": "3.13", + "typeCheckingMode": "strict" +} diff --git a/dev-requirements.txt b/dev-requirements.txt index 6ab2b924..b10aeb18 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -1,3 +1,4 @@ grpcio-tools +nox pymarkdownlnt pyright diff --git a/durabletask/extensions/history_export/__init__.py b/durabletask/extensions/history_export/__init__.py index b7fdf0bf..1d3c846a 100644 --- a/durabletask/extensions/history_export/__init__.py +++ b/durabletask/extensions/history_export/__init__.py @@ -22,6 +22,8 @@ ) from durabletask.extensions.history_export.activities import ( HistoryExportContext, + bind_context, + clear_context, ) from durabletask.extensions.history_export.client import ( ExportHistoryClient, @@ -76,5 +78,7 @@ "ExportMode", "HistoryExportContext", "HistoryWriter", + "bind_context", + "clear_context", "orchestrator_instance_id_for", ] diff --git a/durabletask/scheduled/models.py b/durabletask/scheduled/models.py index b0ad5430..c0373f1c 100644 --- a/durabletask/scheduled/models.py +++ b/durabletask/scheduled/models.py @@ -8,7 +8,7 @@ from durabletask.internal.helpers import ensure_aware from durabletask.scheduled.schedule_status import ScheduleStatus -from durabletask.serialization import JsonDataConverter +from durabletask.serialization import DataConverter, JsonDataConverter MINIMUM_INTERVAL = timedelta(seconds=1) @@ -425,13 +425,23 @@ def to_json(self) -> dict[str, Any]: } @classmethod - def from_json(cls, data: dict[str, Any]) -> "ScheduleState": - # The nested configuration is reconstructed by calling its own - # ``from_json`` hook directly. ``ScheduleConfiguration`` is an internal - # type, so there is no need to route it through a (possibly custom) - # converter -- keeping this hook converter-free means it round-trips - # under any code path, not only the worker's threaded converter. Reads - # accept both the .NET-compatible and legacy snake_case shapes. + def from_json(cls, data: dict[str, Any], + converter: DataConverter | None = None) -> "ScheduleState": + # Reads accept both the .NET-compatible and legacy snake_case shapes. + # + # The nested ``ScheduleConfiguration`` must round-trip under any + # conforming converter. Converters differ in how they hand nested + # custom objects to this hook: + # * The default JSON converter leaves nested values as plain dicts and + # expects the parent hook to rebuild them; it passes itself as + # ``converter`` (a hook that declares the parameter opts in) so the + # reconstruction can defer to ``converter.coerce``. + # * The Azure Functions ``df`` codec rebuilds nested ``to_json`` / + # ``from_json`` envelopes bottom-up, so it invokes this hook with the + # configuration *already* reconstructed (and without a converter). + # Handle both: skip reconstruction when it is already a + # ``ScheduleConfiguration``, otherwise route a raw dict through the + # converter when one was supplied, falling back to the hook directly. state = cls() state.status = ScheduleStatus.from_dotnet(_get(data, "Status", "status")) # Preserve the token generated by ``__init__`` when the field is absent; @@ -446,8 +456,7 @@ def from_json(cls, data: dict[str, Any]) -> "ScheduleState": state.schedule_last_modified_at = _from_iso( _get(data, "ScheduleLastModifiedAt", "schedule_last_modified_at")) config_data = _get(data, "ScheduleConfiguration", "schedule_configuration") - state.schedule_configuration = ( - ScheduleConfiguration.from_json(config_data) if config_data is not None else None) + state.schedule_configuration = _rebuild_configuration(config_data, converter) return state def to_description(self) -> ScheduleDescription: @@ -470,3 +479,21 @@ def to_description(self) -> ScheduleDescription: def _new_token() -> str: return uuid.uuid4().hex + + +def _rebuild_configuration( + config_data: Any, converter: DataConverter | None) -> "ScheduleConfiguration | None": + """Reconstruct a nested ``ScheduleConfiguration`` for any converter. + + ``config_data`` may be ``None``, an already-reconstructed + ``ScheduleConfiguration`` (codecs that rebuild nested envelopes bottom-up, + e.g. the Azure Functions ``df`` codec), or a plain mapping (the default JSON + converter, which leaves nested values as dicts). When a ``converter`` is + supplied its ``coerce`` drives reconstruction; otherwise the hook is called + directly. + """ + if config_data is None or isinstance(config_data, ScheduleConfiguration): + return config_data + if converter is not None: + return converter.coerce(config_data, ScheduleConfiguration) + return ScheduleConfiguration.from_json(config_data) diff --git a/durabletask/scheduled/orchestrator.py b/durabletask/scheduled/orchestrator.py index 0493b744..6bf8b414 100644 --- a/durabletask/scheduled/orchestrator.py +++ b/durabletask/scheduled/orchestrator.py @@ -13,16 +13,38 @@ class ScheduleOperationRequest: """Request describing an operation to execute against a schedule entity. - A plain dataclass: the serializer round-trips it automatically. ``input`` is - typed ``Any``, so it is reconstructed as the raw deserialized payload; the - concrete options type is rebuilt later, at the entity-method boundary, from - that method's parameter annotation. + ``input`` is typed ``Any``, so it is reconstructed as the raw deserialized + payload; the concrete options type is rebuilt later, at the entity-method + boundary, from that method's parameter annotation. + + The ``to_json`` / ``from_json`` hooks mirror the plain dataclass field + mapping so the wire format is unchanged for the default JSON converter, + while also making the type serializable by converters that require an + explicit hook (for example the Azure Functions ``df`` codec, which cannot + serialize a bare dataclass). This matches the sibling schedule models + (``ScheduleState``, ``ScheduleCreationOptions``), which already define these + hooks. """ entity_id: str operation_name: str input: Any | None = None + def to_json(self) -> dict[str, Any]: + return { + "entity_id": self.entity_id, + "operation_name": self.operation_name, + "input": self.input, + } + + @classmethod + def from_json(cls, data: dict[str, Any]) -> "ScheduleOperationRequest": + return cls( + entity_id=data["entity_id"], + operation_name=data["operation_name"], + input=data.get("input"), + ) + def execute_schedule_operation_orchestrator( ctx: task.OrchestrationContext, diff --git a/durabletask/task.py b/durabletask/task.py index 8fc7d93e..8c5d495b 100644 --- a/durabletask/task.py +++ b/durabletask/task.py @@ -508,6 +508,11 @@ def is_failed(self) -> bool: """Returns True if the task has failed, False otherwise.""" return self._exception is not None + @property + def result(self) -> T: + """Returns the result of the task (alias for :meth:`get_result`).""" + return self.get_result() + def get_result(self) -> T: """Returns the result of the task.""" if not self._is_complete: diff --git a/durabletask/worker.py b/durabletask/worker.py index 491fc48a..89fcd15c 100644 --- a/durabletask/worker.py +++ b/durabletask/worker.py @@ -1434,8 +1434,7 @@ def _execute_entity_batch( stub.CompleteEntityTask(batch_result) except Exception as ex: self._logger.exception( - f"Failed to deliver entity response for '{entity_instance_id}' of orchestration ID '{instance_id}' to sidecar: {ex}" - ) + f"Failed to deliver entity response for '{entity_instance_id}' of orchestration ID '{instance_id}' to sidecar: {ex}") # TODO: Reset context @@ -1674,6 +1673,11 @@ def create_timer_internal( else: final_fire_at = fire_at + # Normalize timezone-aware datetimes to naive UTC so they can be safely + # compared against and combined with the orchestration's naive UTC clock. + if final_fire_at.tzinfo is not None: + final_fire_at = final_fire_at.astimezone(timezone.utc).replace(tzinfo=None) + next_fire_at: datetime = final_fire_at if ( @@ -2350,7 +2354,7 @@ def process_event( f"A '{event.executionStarted.name}' orchestrator was not registered." ) - if event.executionStarted.version: + if event.executionStarted.HasField("version"): ctx._version = event.executionStarted.version.value # pyright: ignore[reportPrivateUsage] # Store the parent orchestration instance ID (set for @@ -2913,6 +2917,12 @@ def _cancel_timer() -> None: elif event.HasField("executionRewound"): # Informational event added when an orchestration is rewound. No action needed. pass + elif event.HasField("genericEvent"): + # Informational history event with no execution semantics (for + # example, the marker the Durable Functions extension appends + # when rewinding an orchestration). Ignored during replay, + # matching the .NET worker. + pass elif event.HasField("eventSent"): # Check if this eventSent corresponds to an entity operation call after being translated to the old # entity protocol by the Durable WebJobs extension. If so, treat this message similarly to @@ -2985,12 +2995,15 @@ def _handle_entity_event_raised(self, return result = None - if response is not None: + if response is not None and not is_lock_event: # The legacy protocol wraps the result as {"result": }, # where the value is a serialized JSON string (like the new protocol's # entityOperationCompleted.output). Deserialize it -- not coerce -- so # the value is fully parsed and the expected type applied; coercing # would skip JSON parsing and leave it double-encoded (e.g. '"done"'). + # Skipped for lock-granted events: those carry no operation result + # (the payload's "result" is empty), and the lock branch below + # ignores ``result`` entirely, so deserializing it would only raise. result = self._data_converter.deserialize( response["result"], entity_task._expected_type, # pyright: ignore[reportPrivateUsage] diff --git a/eng/ci/release.yml b/eng/ci/release.yml index b471e1d6..61c63d2a 100644 --- a/eng/ci/release.yml +++ b/eng/ci/release.yml @@ -98,3 +98,43 @@ extends: serviceendpointurl: "https://api.esrp.microsoft.com" mainpublisher: "durabletask-java" domaintenantid: "33e01921-4d64-4f8c-a055-5bdaffd5e33d" + + - stage: release_azure_functions_durable + displayName: "Release azure-functions-durable" + # Depends on the core durabletask stage (azure-functions-durable + # requires durabletask), not on the azuremanaged stage. If the + # durabletask stage is de-selected at queue time, ADO skips it and + # this stage runs on its own. + dependsOn: release_durabletask + jobs: + - job: azure_functions_durable + displayName: "Release azure-functions-durable" + templateContext: + type: releaseJob + isProduction: true + environment: durabletask-pypi-prod + inputs: + - input: pipelineArtifact + pipeline: DurableTaskPythonBuildPipeline + artifactName: drop + targetPath: $(System.DefaultWorkingDirectory)/drop + + steps: + - task: SFP.release-tasks.custom-build-release-task.EsrpRelease@9 + displayName: "ESRP Release azure-functions-durable" + inputs: + connectedservicename: "dtfx-internal-esrp-prod" + usemanagedidentity: true + keyvaultname: "durable-esrp-akv" + signcertname: "dts-esrp-cert" + clientid: "0b3ed1a4-0727-4a50-b82a-02c2bd9dec89" + intent: "PackageDistribution" + contenttype: "PyPi" + contentsource: "Folder" + folderlocation: "$(System.DefaultWorkingDirectory)/drop/buildoutputs/azure-functions-durable" + waitforreleasecompletion: true + owners: $(Build.RequestedForEmail) + approvers: $(Build.RequestedForEmail) + serviceendpointurl: "https://api.esrp.microsoft.com" + mainpublisher: "durabletask-java" + domaintenantid: "33e01921-4d64-4f8c-a055-5bdaffd5e33d" diff --git a/eng/templates/build.yml b/eng/templates/build.yml index 498ba942..c2294b04 100644 --- a/eng/templates/build.yml +++ b/eng/templates/build.yml @@ -13,9 +13,9 @@ jobs: - checkout: self - task: UsePythonVersion@0 - displayName: "Use Python 3.12" + displayName: "Use Python 3.13" inputs: - versionSpec: "3.12" + versionSpec: "3.13" addToPath: true # The 1ES pool is network-isolated, so direct pypi.org access is blocked. @@ -45,6 +45,11 @@ jobs: displayName: "flake8: durabletask-azuremanaged" workingDirectory: durabletask-azuremanaged + # Lint azurefunctions provider + - script: flake8 . + displayName: "flake8: azure-functions-durable" + workingDirectory: azure-functions-durable + # Build sdist + wheel for durabletask (core SDK) - script: | python -m build --sdist --wheel --outdir $(Build.ArtifactStagingDirectory)/buildoutputs/durabletask . @@ -55,10 +60,16 @@ jobs: python -m build --sdist --wheel --outdir $(Build.ArtifactStagingDirectory)/buildoutputs/durabletask-azuremanaged ./durabletask-azuremanaged displayName: "Build durabletask-azuremanaged (sdist + wheel)" + # Build sdist + wheel for azure-functions-durable + - script: | + python -m build --sdist --wheel --outdir $(Build.ArtifactStagingDirectory)/buildoutputs/azure-functions-durable ./azure-functions-durable + displayName: "Build azure-functions-durable (sdist + wheel)" + # List staged outputs for visibility in logs - script: | ls -la $(Build.ArtifactStagingDirectory)/buildoutputs/durabletask ls -la $(Build.ArtifactStagingDirectory)/buildoutputs/durabletask-azuremanaged + ls -la $(Build.ArtifactStagingDirectory)/buildoutputs/azure-functions-durable displayName: "List build outputs" # Install the built wheels with all declared optional extras and let @@ -89,8 +100,10 @@ jobs: # append the extras correctly. DT_WHEEL=$(ls $(Build.ArtifactStagingDirectory)/buildoutputs/durabletask/*.whl) DT_AM_WHEEL=$(ls $(Build.ArtifactStagingDirectory)/buildoutputs/durabletask-azuremanaged/*.whl) + AF_WHEEL=$(ls $(Build.ArtifactStagingDirectory)/buildoutputs/azure-functions-durable/*.whl) python -m pip install "${DT_WHEEL}[opentelemetry,azure-blob-payloads]" python -m pip install "${DT_AM_WHEEL}[azure-blob-payloads]" + python -m pip install "${AF_WHEEL}" displayName: "Install built wheels" - script: pytest -m "not dts and not azurite" --verbose @@ -104,3 +117,12 @@ jobs: set -e python -P -c "import durabletask.azuremanaged; from durabletask.azuremanaged.client import DurableTaskSchedulerClient; from durabletask.azuremanaged.worker import DurableTaskSchedulerWorker; print('durabletask.azuremanaged smoke import OK')" displayName: "smoke import: durabletask-azuremanaged" + + # azure-functions-durable unit tests run here. Integration tests that + # require Azurite or the Azure Functions host emulator are marked + # (azurite / dts) and excluded since those external services aren't + # provisioned in this network-isolated pool. The full suite runs in + # GitHub Actions on PRs to main and main itself. + - script: pytest -m "not dts and not azurite" --verbose + displayName: "pytest: azure-functions-durable (unit tests, no emulators)" + workingDirectory: tests/azure-functions-durable diff --git a/noxfile.py b/noxfile.py new file mode 100644 index 00000000..f8ac4b2a --- /dev/null +++ b/noxfile.py @@ -0,0 +1,146 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Nox sessions for the azure-functions-durable test suites. + +These sessions build **clean, isolated** virtualenvs so the Azure Functions host +worker loads a predictable dependency set. This matters for the end-to-end +suite: the Functions Python worker imports the app (and therefore durabletask + +its native grpc/protobuf) into its own process, and a polluted ambient +environment can cause hard-to-diagnose native failures during indexing. Running +through nox guarantees the same minimal environment locally and in CI. + +Usage: + + nox -s functions_unit # fast unit tests (no func/azurite needed) + nox -s functions_e2e # end-to-end tests (needs func + azurite) + +``azure-functions>=2.2.0b6`` is not published to PyPI, so it is installed from a +local build of the azure-functions Python library. Point at it with the +``AZURE_FUNCTIONS_PYTHON_LIBRARY`` environment variable, or place that repo as a +sibling of this one (``../azure-functions-python-library``), which is used +automatically when present. +""" + +import os +import shutil +import subprocess + +import nox + +nox.options.reuse_existing_virtualenvs = True + +REPO_ROOT = os.path.dirname(os.path.abspath(__file__)) +AZURE_FUNCTIONS_DURABLE = os.path.join(REPO_ROOT, "azure-functions-durable") +DEFAULT_AF_LIBRARY = os.path.join( + os.path.dirname(REPO_ROOT), "azure-functions-python-library") +E2E_APPS_DIR = os.path.join( + REPO_ROOT, "tests", "azure-functions-durable", "e2e", "apps") +# Sample apps that need an in-app virtual environment for the E2E suite. +E2E_APPS = ("v1_style", "dtask_style") + + +def _resolve_af_library(session: nox.Session) -> "str | None": + """Return the local azure-functions build path, or ``None`` if unavailable.""" + af_library = os.environ.get("AZURE_FUNCTIONS_PYTHON_LIBRARY") + if not af_library and os.path.isdir(DEFAULT_AF_LIBRARY): + af_library = DEFAULT_AF_LIBRARY + if not af_library: + session.warn( + "No local azure-functions build found. Set " + "AZURE_FUNCTIONS_PYTHON_LIBRARY or place the " + "azure-functions-python-library repo alongside this one. Falling " + "back to the package index, which will fail if 2.2.0b6 is " + "unavailable there.") + return af_library + + +def _install_packages(session: nox.Session, editable: bool = False) -> None: + """Install azure-functions (local build), durabletask, and the provider. + + When ``editable`` is set, the two local repo packages are installed with + ``-e`` so source edits are picked up without reinstalling (and so ``nox -R`` + stays fast). + """ + af_library = _resolve_af_library(session) + if af_library: + session.install(af_library) + if editable: + session.install("-e", REPO_ROOT) + session.install("-e", AZURE_FUNCTIONS_DURABLE) + else: + session.install(REPO_ROOT) + session.install(AZURE_FUNCTIONS_DURABLE) + + +def _link_app_venv(session: nox.Session, app_dir: str) -> None: + """Point ``/.venv`` at the session virtualenv via a junction/symlink. + + The Azure Functions Python worker only prioritizes the app's dependencies + over its own bundled ones (grpc/protobuf) when it can locate them *relative + to the app directory*; a venv outside the app dir leaves the worker's + bundled protobuf on the path alongside ours and crashes the worker natively + during indexing. + + Rather than install a full venv per app (slow, and redone every run), we + reuse the single session virtualenv and expose it inside each app dir as a + directory junction (Windows) or symlink (POSIX) named ``.venv``. The link + path is inside the app dir, which is what the worker checks, so isolation + kicks in while installs happen only once. + """ + link = os.path.join(app_dir, ".venv") + target = session.virtualenv.location + + # Clear any stale link or real venv. Never rmtree a junction/symlink -- that + # would delete the shared session venv it points to. + is_junction = getattr(os.path, "isjunction", None) + if is_junction is not None and is_junction(link): + os.rmdir(link) + elif os.path.islink(link): + os.unlink(link) + elif os.path.isdir(link): + shutil.rmtree(link) + elif os.path.exists(link): + os.unlink(link) + + if os.name == "nt": + subprocess.run( + ["cmd", "/c", "mklink", "/J", link, target], + check=True, capture_output=True) + else: + os.symlink(target, link, target_is_directory=True) + session.log(f"Linked {link} -> {target}") + + +@nox.session(python=["3.13"]) +def functions_unit(session: nox.Session) -> None: + """Run the azure-functions-durable unit tests (no func/azurite required).""" + _install_packages(session) + session.install("pytest") + session.run( + "pytest", "tests/azure-functions-durable", + "-m", "not dts and not azurite and not functions_e2e", + *session.posargs) + + +@nox.session(python=["3.13"]) +def functions_e2e(session: nox.Session) -> None: + """Run the azure-functions-durable end-to-end tests. + + Requires the Azure Functions Core Tools (``func``) and a running Azurite + instance; the suite skips itself when either is unavailable. + + The SDK is installed once (editable) into the session virtualenv, which is + then exposed inside each sample app as ``/.venv`` (see + ``_link_app_venv``) so the Functions host worker loads our grpc/protobuf + rather than its bundled copies. The harness starts ``func`` with that + per-app ``.venv`` activated. + """ + _install_packages(session, editable=True) + session.install("pytest") + for app in E2E_APPS: + _link_app_venv(session, os.path.join(E2E_APPS_DIR, app)) + session.run( + "pytest", "tests/azure-functions-durable/e2e", + "-m", "functions_e2e", + *session.posargs) diff --git a/pyproject.toml b/pyproject.toml index fe270dad..44b56132 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,4 +56,5 @@ testpaths = ["tests"] asyncio_mode = "auto" markers = [ "azurite: tests that require Azurite (local Azure Storage emulator)", + "functions_e2e: end-to-end tests that require the Azure Functions Core Tools (func) and Azurite", ] diff --git a/requirements.txt b/requirements.txt index ee1cad9c..ea61d217 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,6 +5,8 @@ pytest pytest-asyncio pytest-cov azure-identity +azure-functions +typing-extensions asyncio packaging opentelemetry-api diff --git a/tests/azure-functions-durable/__init__.py b/tests/azure-functions-durable/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/azure-functions-durable/e2e/__init__.py b/tests/azure-functions-durable/e2e/__init__.py new file mode 100644 index 00000000..59e481eb --- /dev/null +++ b/tests/azure-functions-durable/e2e/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. diff --git a/tests/azure-functions-durable/e2e/_harness.py b/tests/azure-functions-durable/e2e/_harness.py new file mode 100644 index 00000000..6f48ba45 --- /dev/null +++ b/tests/azure-functions-durable/e2e/_harness.py @@ -0,0 +1,480 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Shared helpers for the Azure Functions Durable end-to-end tests. + +These tests launch a *real* Azure Functions host (``func start``) for a sample +function app, backed by Azurite (the local Azure Storage emulator) and the +public Durable Task extension bundle. The Python worker, the host, and the +Durable extension all cooperate exactly as they would in production. + +Everything here is stdlib-only (``urllib``, ``subprocess``, ``socket``) so the +harness adds no test dependencies of its own. +""" + +from __future__ import annotations + +import json +import os +import shutil +import signal +import socket +import subprocess +import time +import urllib.error +import urllib.request +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Callable, Optional + +APPS_DIR = Path(__file__).parent / "apps" + +# Azurite's well-known blob endpoint. The Durable extension's default Azure +# Storage provider also needs queue/table, but a reachable blob port is a good +# proxy for "Azurite is up" and matches the other e2e suites in this repo. +AZURITE_HOST = "127.0.0.1" +AZURITE_BLOB_PORT = 10000 + +# How long to wait for the Functions host to become ready, and for an +# orchestration to reach a terminal state. The host cold-start (extension +# bundle download on first run + worker spin-up) dominates the former. +HOST_STARTUP_TIMEOUT_S = 180 +ORCHESTRATION_TIMEOUT_S = 60 + + +def find_free_port() -> int: + """Return an OS-assigned free TCP port.""" + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +def func_executable() -> Optional[str]: + """Return the path to the Azure Functions Core Tools (``func``), if installed.""" + return shutil.which("func") + + +def azurite_is_running() -> bool: + """Return True if Azurite's blob endpoint accepts TCP connections.""" + try: + with socket.create_connection((AZURITE_HOST, AZURITE_BLOB_PORT), timeout=2): + return True + except OSError: + return False + + +@dataclass +class HttpResult: + status: int + body: str + + def json(self) -> Any: + return json.loads(self.body) if self.body else None + + +def http_request( + method: str, + url: str, + data: Optional[dict[str, Any]] = None, + timeout: float = 30) -> HttpResult: + """Perform an HTTP request using urllib, returning status and body. + + HTTP error responses (4xx/5xx) are returned as an :class:`HttpResult` + rather than raised, so callers can assert on status codes. + """ + payload = None + headers: dict[str, str] = {} + if data is not None: + payload = json.dumps(data).encode("utf-8") + headers["Content-Type"] = "application/json" + req = urllib.request.Request(url, data=payload, headers=headers, method=method) + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + return HttpResult(resp.status, resp.read().decode("utf-8")) + except urllib.error.HTTPError as e: + return HttpResult(e.code, e.read().decode("utf-8")) + + +class _FatalStartupError(RuntimeError): + """Raised when the host aborts startup for a non-transient reason. + + Signals that the app itself failed to load (e.g. an import error), so + retrying on a different port would be pointless. + """ + + +class FunctionApp: + """Manages the lifecycle of a ``func start`` host for a sample app. + + Use as a context manager. On entry it starts the host and blocks until the + app's ``/api/ping`` route responds; on exit it terminates the whole process + group and surfaces the captured host log if startup failed. + """ + + # ``func start`` binds the HTTP port itself, some time after we pick a free + # one. Another process (e.g. the sibling app's host, started moments + # earlier) can claim that port in the interim, so a transient startup + # failure is retried on a freshly chosen free port a few times. + _STARTUP_MAX_ATTEMPTS = 3 + + def __init__(self, app_name: str, port: Optional[int] = None): + self.app_dir = APPS_DIR / app_name + if not self.app_dir.is_dir(): + raise FileNotFoundError(f"Sample app not found: {self.app_dir}") + self.port = port or find_free_port() + self.base_url = f"http://127.0.0.1:{self.port}" + self._process: Optional[subprocess.Popen[str]] = None + self._log_path = self.app_dir / "_func_host.log" + + @property + def venv_dir(self) -> Path: + """The in-app virtual environment directory (``/.venv``).""" + return self.app_dir / ".venv" + + @property + def venv_python(self) -> Path: + """Path to the in-app venv's Python interpreter.""" + if os.name == "nt": + return self.venv_dir / "Scripts" / "python.exe" + return self.venv_dir / "bin" / "python" + + def __enter__(self) -> "FunctionApp": + self.start() + return self + + def __exit__(self, *exc: object) -> None: + self.stop() + + def start(self) -> None: + func = func_executable() + if func is None: + raise RuntimeError("Azure Functions Core Tools ('func') is not installed.") + if not self.venv_python.exists(): + raise RuntimeError( + f"In-app virtual environment not found at {self.venv_dir}. " + "Provision it first (run the suite via 'nox -s functions_e2e', " + "which creates a .venv inside each sample app).") + + env = self._build_env() + + last_exc: Optional[BaseException] = None + for attempt in range(1, self._STARTUP_MAX_ATTEMPTS + 1): + self._launch(func, env) + try: + self._wait_until_ready() + return + except _FatalStartupError: + # The app itself failed to load; a different port won't help. + self.stop() + raise + except (RuntimeError, TimeoutError) as exc: + # Likely a port claimed between our selection and func's bind + # (or a slow cold start). Tear down and retry on a fresh port. + last_exc = exc + self.stop() + if attempt < self._STARTUP_MAX_ATTEMPTS: + self.port = find_free_port() + self.base_url = f"http://127.0.0.1:{self.port}" + assert last_exc is not None + raise last_exc + + def _build_env(self) -> dict[str, str]: + env = os.environ.copy() + # Start the host exactly as a developer would after activating the + # app's OWN virtual environment (``/.venv``). The environment MUST + # live inside the app directory: the Functions Python worker only + # prioritizes the app's dependencies (our grpc/protobuf) over its + # bundled copies when it can locate them relative to the app dir. With + # the venv outside, both protobuf C-extensions load and the worker + # crashes natively during indexing. We replicate activation rather than + # using host-setting overrides (defaultExecutablePath, etc.), which + # interfere with ``func``'s own Python version detection. + interpreter_dir = self.venv_python.parent + env["VIRTUAL_ENV"] = str(self.venv_dir) + env["PATH"] = str(interpreter_dir) + os.pathsep + env.get("PATH", "") + # A stray PYTHONHOME would override the venv; activation clears it. + env.pop("PYTHONHOME", None) + return env + + def _launch(self, func: str, env: dict[str, str]) -> None: + self._log = open(self._log_path, "w", encoding="utf-8") + # start_new_session/CREATE_NEW_PROCESS_GROUP lets us reliably terminate + # the host *and* its child worker processes as a group on teardown. + creationflags = 0 + start_new_session = False + if os.name == "nt": + creationflags = subprocess.CREATE_NEW_PROCESS_GROUP # type: ignore[attr-defined] + else: + start_new_session = True + + self._process = subprocess.Popen( + [func, "start", "--port", str(self.port)], + cwd=str(self.app_dir), + env=env, + stdout=self._log, + stderr=subprocess.STDOUT, + text=True, + creationflags=creationflags, + start_new_session=start_new_session, + ) + + def _wait_until_ready(self) -> None: + deadline = time.time() + HOST_STARTUP_TIMEOUT_S + ping_url = f"{self.base_url}/api/ping" + while time.time() < deadline: + if self._process is not None and self._process.poll() is not None: + raise RuntimeError( + f"Functions host exited early (code {self._process.returncode}).\n" + f"{self._read_log()}") + self._check_log_for_fatal_errors() + try: + result = http_request("GET", ping_url, timeout=5) + if result.status == 200: + return + except (urllib.error.URLError, OSError): + pass + time.sleep(1) + raise TimeoutError( + f"Functions host did not become ready within {HOST_STARTUP_TIMEOUT_S}s.\n" + f"{self._read_log()}") + + # Markers that indicate the host aborted startup (e.g. the app failed to + # import). Detecting these lets us fail fast with the log rather than + # blocking for the full startup timeout. + _FATAL_LOG_MARKERS = ( + "Host startup operation has been canceled", + "Worker failed to load", + "Microsoft.Azure.WebJobs.Script: Worker was unable to load", + ) + + def _check_log_for_fatal_errors(self) -> None: + log = self._read_log() + for marker in self._FATAL_LOG_MARKERS: + if marker in log: + raise _FatalStartupError( + f"Functions host failed to start (matched '{marker}').\n{log}") + + def _read_log(self) -> str: + try: + return "----- func host log -----\n" + self._log_path.read_text(encoding="utf-8") + except OSError: + return "(no host log captured)" + + def stop(self) -> None: + proc = self._process + if proc is not None: + try: + self._terminate_process_tree(proc) + finally: + self._process = None + if getattr(self, "_log", None) is not None: + self._log.close() + + @staticmethod + def _terminate_process_tree(proc: "subprocess.Popen[str]") -> None: + """Terminate the func host *and* its child worker processes. + + ``func start`` spawns children (the .NET host and the Python language + worker); terminating only the top process can orphan them and, on a + fixed port, block later runs. We first ask the process group to shut + down gracefully, then force-kill the whole tree. + """ + try: + if os.name == "nt": + proc.send_signal(signal.CTRL_BREAK_EVENT) # type: ignore[attr-defined] + else: + os.killpg(os.getpgid(proc.pid), signal.SIGTERM) + proc.wait(timeout=20) + return + except subprocess.TimeoutExpired: + pass + except (ProcessLookupError, OSError): + # Already exited, or the group signal could not be delivered. + pass + + # Force-kill the entire process tree. + if os.name == "nt": + subprocess.run( + ["taskkill", "/F", "/T", "/PID", str(proc.pid)], + capture_output=True, check=False) + else: + try: + os.killpg(os.getpgid(proc.pid), signal.SIGKILL) + except (ProcessLookupError, OSError): + proc.kill() + try: + proc.wait(timeout=10) + except subprocess.TimeoutExpired: + pass + + # -- orchestration helpers --------------------------------------------- + + def start_orchestration(self, name: str, body: Any = None) -> str: + """Start an orchestration via the app's ``/api/start/{name}`` route. + + Returns the new instance ID. + """ + result = http_request("POST", f"{self.base_url}/api/start/{name}", data={"input": body}) + assert result.status in (200, 202), f"start failed: {result.status} {result.body}" + return result.json()["id"] + + def get_status(self, instance_id: str) -> dict[str, Any]: + """Fetch orchestration status via the app's ``/api/status/{id}`` route.""" + result = http_request("GET", f"{self.base_url}/api/status/{instance_id}") + assert result.status == 200, f"status failed: {result.status} {result.body}" + return result.json() + + def wait_for_completion( + self, + instance_id: str, + timeout: float = ORCHESTRATION_TIMEOUT_S) -> dict[str, Any]: + """Poll status until the orchestration reaches a terminal state. + + The terminal-state comparison is case-insensitive so it works for both + the v1 status names (``Completed``) and the durabletask enum names + (``COMPLETED``). + """ + terminal = {"completed", "failed", "terminated", "canceled"} + deadline = time.time() + timeout + status: dict[str, Any] = {} + while time.time() < deadline: + status = self.get_status(instance_id) + runtime_status = (status.get("runtimeStatus") or "").lower() + if runtime_status in terminal: + return status + time.sleep(0.5) + raise TimeoutError( + f"Orchestration {instance_id} did not complete within {timeout}s; " + f"last status: {status}") + + def wait_for_status( + self, + instance_id: str, + expected: str, + timeout: float = ORCHESTRATION_TIMEOUT_S) -> dict[str, Any]: + """Poll status until ``runtimeStatus`` equals ``expected`` (case-insensitive).""" + target = expected.lower() + deadline = time.time() + timeout + status: dict[str, Any] = {} + while time.time() < deadline: + status = self.get_status(instance_id) + if (status.get("runtimeStatus") or "").lower() == target: + return status + time.sleep(0.5) + raise TimeoutError( + f"Orchestration {instance_id} did not reach '{expected}' within " + f"{timeout}s; last status: {status}") + + # -- event / entity helpers -------------------------------------------- + + def raise_event(self, instance_id: str, event: str, data: Any = None) -> None: + """Raise an external event via the app's ``/api/raise/{id}/{event}`` route.""" + result = http_request( + "POST", f"{self.base_url}/api/raise/{instance_id}/{event}", data={"data": data}) + assert result.status == 202, f"raise failed: {result.status} {result.body}" + + def read_entity(self, name: str, key: str) -> dict[str, Any]: + """Read entity state via the app's ``/api/entity/{name}/{key}`` route.""" + result = http_request("GET", f"{self.base_url}/api/entity/{name}/{key}") + assert result.status == 200, f"entity read failed: {result.status} {result.body}" + return result.json() + + def list_entities(self, starts_with: Optional[str] = None) -> dict[str, Any]: + """List entities via the app's ``/api/entities`` route. + + An optional ``starts_with`` filters by the entity instance-id prefix + (entity IDs are formatted ``@name@key``). + """ + url = f"{self.base_url}/api/entities" + if starts_with is not None: + url += f"?starts_with={urllib.parse.quote(starts_with)}" + result = http_request("GET", url) + assert result.status == 200, f"list entities failed: {result.status} {result.body}" + return result.json() + + def clean_entity_storage(self) -> dict[str, Any]: + """Trigger entity storage cleanup via the app's ``/api/clean-entities`` route.""" + result = http_request("POST", f"{self.base_url}/api/clean-entities") + assert result.status == 200, f"clean entities failed: {result.status} {result.body}" + return result.json() + + def create_schedule(self, schedule_id: str, interval_seconds: float = 2, + input: Any = None) -> dict[str, Any]: + """Create a scheduled task via the app's ``/api/schedule/{id}`` route.""" + result = http_request( + "POST", f"{self.base_url}/api/schedule/{schedule_id}", + data={"interval_seconds": interval_seconds, "input": input}, timeout=90) + assert result.status == 200, f"create schedule failed: {result.status} {result.body}" + return result.json() + + def describe_schedule(self, schedule_id: str) -> dict[str, Any]: + """Describe a scheduled task via the app's ``/api/schedule/{id}`` route.""" + result = http_request("GET", f"{self.base_url}/api/schedule/{schedule_id}", timeout=90) + assert result.status == 200, f"describe schedule failed: {result.status} {result.body}" + return result.json() + + def delete_schedule(self, schedule_id: str) -> None: + """Delete a scheduled task via the app's ``/api/schedule/{id}/delete`` route.""" + result = http_request( + "POST", f"{self.base_url}/api/schedule/{schedule_id}/delete", timeout=90) + assert result.status == 202, f"delete schedule failed: {result.status} {result.body}" + + def start_export(self, container: str = "exports", + job_id: Optional[str] = None, + completed_from: Optional[str] = None) -> dict[str, Any]: + """Start a history-export job via the app's ``/api/export/start`` route. + + ``completed_from`` (an ISO-8601 timestamp) narrows the export window so + it only covers instances completed at/after that time. + """ + data: dict[str, Any] = {"container": container} + if job_id is not None: + data["job_id"] = job_id + if completed_from is not None: + data["completed_from"] = completed_from + result = http_request("POST", f"{self.base_url}/api/export/start", data=data, timeout=90) + assert result.status == 200, f"start export failed: {result.status} {result.body}" + return result.json() + + def wait_for_export(self, job_id: str, timeout: float = 90) -> dict[str, Any]: + """Poll ``/api/export/status/{id}`` until the export job is terminal.""" + deadline = time.time() + timeout + payload: dict[str, Any] = {} + while time.time() < deadline: + result = http_request("GET", f"{self.base_url}/api/export/status/{job_id}") + assert result.status == 200, f"export status failed: {result.status} {result.body}" + payload = result.json() + if (payload.get("status") or "") in ("Completed", "Failed"): + return payload + time.sleep(0.5) + raise TimeoutError(f"export job {job_id} did not finish within {timeout}s; last: {payload}") + + def signal_entity(self, name: str, key: str, op: str, input: Any = None, + delay_seconds: Optional[float] = None) -> None: + """Signal an entity via the app's ``/api/signal/{name}/{key}/{op}`` route. + + When ``delay_seconds`` is provided, the app schedules the signal for + future delivery (a delayed/scheduled entity signal). + """ + data: dict[str, Any] = {"input": input} + if delay_seconds is not None: + data["delay_seconds"] = delay_seconds + result = http_request( + "POST", f"{self.base_url}/api/signal/{name}/{key}/{op}", data=data) + assert result.status == 202, f"signal failed: {result.status} {result.body}" + + def wait_for_entity( + self, + name: str, + key: str, + predicate: Callable[[dict[str, Any]], bool], + timeout: float = 30) -> dict[str, Any]: + """Poll the entity read route until ``predicate(payload)`` is true.""" + deadline = time.time() + timeout + payload: dict[str, Any] = {} + while time.time() < deadline: + payload = self.read_entity(name, key) + if predicate(payload): + return payload + time.sleep(0.5) + raise TimeoutError(f"entity {name}/{key} predicate not met; last: {payload}") diff --git a/tests/azure-functions-durable/e2e/apps/dtask_style/activities.py b/tests/azure-functions-durable/e2e/apps/dtask_style/activities.py new file mode 100644 index 00000000..b3a32234 --- /dev/null +++ b/tests/azure-functions-durable/e2e/apps/dtask_style/activities.py @@ -0,0 +1,44 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Activity functions for the durabletask-native-style sample app (blueprint). + +Activities are ordinary Azure Functions dispatched by the host (single-argument +input), shared by both authoring styles. Includes the failure/flaky activities +used to exercise error propagation and retry policies. +""" + +import azure.durable_functions as df + +bp = df.Blueprint() + +# In-process attempt counters keyed by a caller-supplied token; see the v1 +# sample's activities module for the rationale. +_ATTEMPTS: dict[str, int] = {} + + +@bp.activity_trigger(input_name="name") +def say_hello(name: str) -> str: + return f"Hello {name}!" + + +@bp.activity_trigger(input_name="n") +def square(n: int) -> int: + return n * n + + +@bp.activity_trigger(input_name="reason") +def always_fail(reason: str) -> str: + raise ValueError(reason or "activity failed on purpose") + + +@bp.activity_trigger(input_name="payload") +def flaky(payload: dict) -> dict: + """Fail until ``threshold`` attempts have been made, then succeed.""" + key = payload["key"] + threshold = int(payload["threshold"]) + _ATTEMPTS[key] = _ATTEMPTS.get(key, 0) + 1 + attempts = _ATTEMPTS[key] + if attempts < threshold: + raise ValueError(f"flaky failure {attempts}/{threshold}") + return {"attempts": attempts} diff --git a/tests/azure-functions-durable/e2e/apps/dtask_style/client_routes.py b/tests/azure-functions-durable/e2e/apps/dtask_style/client_routes.py new file mode 100644 index 00000000..40a67d96 --- /dev/null +++ b/tests/azure-functions-durable/e2e/apps/dtask_style/client_routes.py @@ -0,0 +1,324 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""HTTP routes for the durabletask-native-style sample app (blueprint). + +Exposes the starter and the durabletask client management surface using the +native method names: ``schedule_new_orchestration``, +``get_orchestration_state``, ``get_all_orchestration_states``, +``get_orchestration_history``, ``raise_orchestration_event``, +``terminate_orchestration``, ``suspend_orchestration``, ``resume_orchestration``, +``restart_orchestration``, ``purge_orchestration``, +``wait_for_orchestration_start``, ``wait_for_orchestration_completion``, +``get_entity``, and ``signal_entity``. +""" + +import asyncio +import json +from datetime import datetime, timedelta, timezone +from typing import Any + +import azure.functions as func + +import azure.durable_functions as df +from durabletask import entities +from durabletask.client import EntityQuery, TaskHubGrpcClient +from durabletask.scheduled import ScheduledTaskClient, ScheduleCreationOptions +from azure.durable_functions.internal.azurefunctions_grpc_interceptor import ( + AzureFunctionsDefaultClientInterceptorImpl, +) +from azure.durable_functions.internal.serialization import ( + DEFAULT_FUNCTIONS_DATA_CONVERTER, +) + +bp = df.Blueprint() + + +def _sync_client(client: df.DurableFunctionsClient) -> TaskHubGrpcClient: + """Build a synchronous durabletask client aimed at the same sidecar. + + The scheduled-tasks client (``ScheduledTaskClient``) is built on the + synchronous ``TaskHubGrpcClient``, whereas ``DurableFunctionsClient`` is + async; this bridges to a sync client using the same task hub, endpoint, and + data converter as the durable-client binding. + """ + interceptors = [AzureFunctionsDefaultClientInterceptorImpl( + client.taskHubName, client.requiredQueryStringParameters)] + return TaskHubGrpcClient( + host_address=client.rpcBaseUrl, + secure_channel=False, + interceptors=interceptors, + data_converter=DEFAULT_FUNCTIONS_DATA_CONVERTER) + + +def _state_to_json(state: Any) -> dict[str, Any]: + if state is None: + return {"runtimeStatus": None} + output = None + if state.serialized_output is not None: + try: + output = json.loads(state.serialized_output) + except (TypeError, ValueError): + output = state.serialized_output + custom_status = None + if state.serialized_custom_status is not None: + try: + custom_status = json.loads(state.serialized_custom_status) + except (TypeError, ValueError): + custom_status = state.serialized_custom_status + return { + "instanceId": state.instance_id, + "name": state.name, + "runtimeStatus": state.runtime_status.name, + "output": output, + "customStatus": custom_status, + } + + +@bp.route(route="ping", methods=["GET"]) +def ping(req: func.HttpRequest) -> func.HttpResponse: + return func.HttpResponse("pong") + + +@bp.route(route="start/{name}", methods=["POST"]) +@bp.durable_client_input(client_name="client") +async def start_orchestration( + req: func.HttpRequest, client: df.DurableFunctionsClient) -> func.HttpResponse: + name = req.route_params["name"] + body = req.get_json() + instance_id = await client.schedule_new_orchestration(name, input=body.get("input")) + return func.HttpResponse( + json.dumps({"id": instance_id}), status_code=202, mimetype="application/json") + + +@bp.route(route="status/{id}", methods=["GET"]) +@bp.durable_client_input(client_name="client") +async def get_orchestration_status( + req: func.HttpRequest, client: df.DurableFunctionsClient) -> func.HttpResponse: + state = await client.get_orchestration_state(req.route_params["id"], fetch_payloads=True) + return func.HttpResponse(json.dumps(_state_to_json(state)), mimetype="application/json") + + +@bp.route(route="states", methods=["GET"]) +@bp.durable_client_input(client_name="client") +async def get_all_states( + req: func.HttpRequest, client: df.DurableFunctionsClient) -> func.HttpResponse: + states = await client.get_all_orchestration_states() + ids = [s.instance_id for s in states] + return func.HttpResponse(json.dumps({"ids": ids}), mimetype="application/json") + + +@bp.route(route="history/{id}", methods=["GET"]) +@bp.durable_client_input(client_name="client") +async def get_history( + req: func.HttpRequest, client: df.DurableFunctionsClient) -> func.HttpResponse: + events = await client.get_orchestration_history(req.route_params["id"]) + return func.HttpResponse( + json.dumps({"eventCount": len(events)}), mimetype="application/json") + + +@bp.route(route="wait_start/{id}", methods=["GET"]) +@bp.durable_client_input(client_name="client") +async def wait_start( + req: func.HttpRequest, client: df.DurableFunctionsClient) -> func.HttpResponse: + state = await client.wait_for_orchestration_start(req.route_params["id"], timeout=30) + return func.HttpResponse(json.dumps(_state_to_json(state)), mimetype="application/json") + + +@bp.route(route="wait_complete/{id}", methods=["GET"]) +@bp.durable_client_input(client_name="client") +async def wait_complete( + req: func.HttpRequest, client: df.DurableFunctionsClient) -> func.HttpResponse: + state = await client.wait_for_orchestration_completion(req.route_params["id"], timeout=30) + return func.HttpResponse(json.dumps(_state_to_json(state)), mimetype="application/json") + + +@bp.route(route="raise/{id}/{event}", methods=["POST"]) +@bp.durable_client_input(client_name="client") +async def raise_orchestration_event( + req: func.HttpRequest, client: df.DurableFunctionsClient) -> func.HttpResponse: + body = req.get_json() + await client.raise_orchestration_event( + req.route_params["id"], req.route_params["event"], data=body.get("data")) + return func.HttpResponse(status_code=202) + + +@bp.route(route="terminate/{id}", methods=["POST"]) +@bp.durable_client_input(client_name="client") +async def terminate_orchestration( + req: func.HttpRequest, client: df.DurableFunctionsClient) -> func.HttpResponse: + await client.terminate_orchestration(req.route_params["id"], output="e2e-terminate") + return func.HttpResponse(status_code=202) + + +@bp.route(route="suspend/{id}", methods=["POST"]) +@bp.durable_client_input(client_name="client") +async def suspend_orchestration( + req: func.HttpRequest, client: df.DurableFunctionsClient) -> func.HttpResponse: + await client.suspend_orchestration(req.route_params["id"]) + return func.HttpResponse(status_code=202) + + +@bp.route(route="resume/{id}", methods=["POST"]) +@bp.durable_client_input(client_name="client") +async def resume_orchestration( + req: func.HttpRequest, client: df.DurableFunctionsClient) -> func.HttpResponse: + await client.resume_orchestration(req.route_params["id"]) + return func.HttpResponse(status_code=202) + + +@bp.route(route="restart/{id}", methods=["POST"]) +@bp.durable_client_input(client_name="client") +async def restart_orchestration( + req: func.HttpRequest, client: df.DurableFunctionsClient) -> func.HttpResponse: + new_id = await client.restart_orchestration(req.route_params["id"]) + return func.HttpResponse( + json.dumps({"id": new_id}), status_code=202, mimetype="application/json") + + +@bp.route(route="purge/{id}", methods=["POST"]) +@bp.durable_client_input(client_name="client") +async def purge_orchestration( + req: func.HttpRequest, client: df.DurableFunctionsClient) -> func.HttpResponse: + result = await client.purge_orchestration(req.route_params["id"]) + return func.HttpResponse( + json.dumps({"instancesDeleted": result.deleted_instance_count}), + mimetype="application/json") + + +@bp.route(route="entity/{name}/{key}", methods=["GET"]) +@bp.durable_client_input(client_name="client") +async def read_entity( + req: func.HttpRequest, client: df.DurableFunctionsClient) -> func.HttpResponse: + entity_id = entities.EntityInstanceId(req.route_params["name"], req.route_params["key"]) + metadata = await client.get_entity(entity_id) + if metadata is None: + payload = {"exists": False, "state": None} + else: + state = metadata.get_typed_state() if metadata.includes_state else None + payload = {"exists": True, "state": state} + return func.HttpResponse(json.dumps(payload), mimetype="application/json") + + +@bp.route(route="signal/{name}/{key}/{op}", methods=["POST"]) +@bp.durable_client_input(client_name="client") +async def signal_entity( + req: func.HttpRequest, client: df.DurableFunctionsClient) -> func.HttpResponse: + body = req.get_json() + entity_id = entities.EntityInstanceId(req.route_params["name"], req.route_params["key"]) + # An optional ``delay_seconds`` schedules the signal for future delivery + # (client-side delayed/scheduled signal). + signal_time = None + delay_seconds = body.get("delay_seconds") + if delay_seconds: + signal_time = datetime.now(timezone.utc) + timedelta(seconds=float(delay_seconds)) + await client.signal_entity( + entity_id, req.route_params["op"], input=body.get("input"), signal_time=signal_time) + return func.HttpResponse(status_code=202) + + +@bp.route(route="entities", methods=["GET"]) +@bp.durable_client_input(client_name="client") +async def list_entities( + req: func.HttpRequest, client: df.DurableFunctionsClient) -> func.HttpResponse: + # List All Entities: client.get_all_entities with an optional + # instance-id-prefix filter (entity IDs are formatted "@name@key"). + starts_with = req.params.get("starts_with") + query = EntityQuery(instance_id_starts_with=starts_with, include_state=True) + metadatas = await client.get_all_entities(query) + items = [ + { + "entity": md.id.entity, + "key": md.id.key, + "state": md.get_typed_state() if md.includes_state else None, + } + for md in metadatas + ] + return func.HttpResponse( + json.dumps({"count": len(items), "entities": items}), + mimetype="application/json") + + +@bp.route(route="clean-entities", methods=["POST"]) +@bp.durable_client_input(client_name="client") +async def clean_entities( + req: func.HttpRequest, client: df.DurableFunctionsClient) -> func.HttpResponse: + # Entity storage cleanup: remove empty entities and release orphaned locks. + result = await client.clean_entity_storage( + remove_empty_entities=True, release_orphaned_locks=True) + return func.HttpResponse( + json.dumps({ + "emptyEntitiesRemoved": result.empty_entities_removed, + "orphanedLocksReleased": result.orphaned_locks_released, + }), + mimetype="application/json") + + +@bp.route(route="schedule/{id}", methods=["POST"]) +@bp.durable_client_input(client_name="client") +async def create_schedule( + req: func.HttpRequest, client: df.DurableFunctionsClient) -> func.HttpResponse: + # Scheduled orchestrations: create a schedule that runs "scheduled_tick" + # on an interval. Uses the sync ScheduledTaskClient bridged to the sidecar. + body = req.get_json() + schedule_id = req.route_params["id"] + interval_seconds = float(body.get("interval_seconds", 2)) + tick_key = body.get("input") + + def _create() -> dict[str, Any]: + sync = _sync_client(client) + try: + scheduled = ScheduledTaskClient(sync) + options = ScheduleCreationOptions( + schedule_id=schedule_id, + orchestration_name="scheduled_tick", + interval=timedelta(seconds=interval_seconds), + orchestration_input=tick_key, + start_immediately_if_late=True, + ) + scheduled.create_schedule(options) + desc = scheduled.get_schedule(schedule_id) + return {"scheduleId": desc.schedule_id, "status": str(desc.status)} + finally: + sync.close() + + payload = await asyncio.to_thread(_create) + return func.HttpResponse(json.dumps(payload), mimetype="application/json") + + +@bp.route(route="schedule/{id}", methods=["GET"]) +@bp.durable_client_input(client_name="client") +async def describe_schedule( + req: func.HttpRequest, client: df.DurableFunctionsClient) -> func.HttpResponse: + schedule_id = req.route_params["id"] + + def _describe() -> dict[str, Any]: + sync = _sync_client(client) + try: + scheduled = ScheduledTaskClient(sync) + desc = scheduled.get_schedule(schedule_id) + if desc is None: + return {"exists": False} + return {"exists": True, "scheduleId": desc.schedule_id, "status": str(desc.status)} + finally: + sync.close() + + payload = await asyncio.to_thread(_describe) + return func.HttpResponse(json.dumps(payload), mimetype="application/json") + + +@bp.route(route="schedule/{id}/delete", methods=["POST"]) +@bp.durable_client_input(client_name="client") +async def delete_schedule( + req: func.HttpRequest, client: df.DurableFunctionsClient) -> func.HttpResponse: + schedule_id = req.route_params["id"] + + def _delete() -> None: + sync = _sync_client(client) + try: + ScheduledTaskClient(sync).get_schedule_client(schedule_id).delete() + finally: + sync.close() + + await asyncio.to_thread(_delete) + return func.HttpResponse(status_code=202) diff --git a/tests/azure-functions-durable/e2e/apps/dtask_style/entities.py b/tests/azure-functions-durable/e2e/apps/dtask_style/entities.py new file mode 100644 index 00000000..1c3d7b11 --- /dev/null +++ b/tests/azure-functions-durable/e2e/apps/dtask_style/entities.py @@ -0,0 +1,87 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Entity functions for the durabletask-native-style sample app (blueprint). + +Exercises both durabletask-native entity authoring styles: + +- ``Counter`` uses the **class-based** style (``DurableEntity`` subclass with + one method per operation and ``self.get_state``/``self.set_state``). +- ``probe`` uses the **function-based** two-argument style + (``def entity(ctx, input):``) with the durabletask ``EntityContext`` API. +""" + +from typing import Any + +import azure.durable_functions as df +from durabletask import entities +from durabletask.entities import DurableEntity + +bp = df.Blueprint() + + +@bp.entity_trigger(context_name="context") +class Counter(DurableEntity): + """Class-based counter entity. + + Registered under the lowercased class name (``counter``); each operation is + a method taking ``(self, input)``. + """ + + def add(self, input: Any = None) -> int: + new_state = self.get_state(int, 0) + (input or 0) + self.set_state(new_state) + return new_state + + def reset(self, input: Any = None) -> None: + # Returns None implicitly: verifies that a None entity operation result + # round-trips through ``call_entity`` (rather than being lost or + # surfacing as an error). + self.set_state(0) + + def get(self, input: Any = None) -> int: + return self.get_state(int, 0) + + +@bp.entity_trigger(context_name="context") +def probe(ctx: entities.EntityContext, input: Any = None) -> Any: + """Entity exposing the durabletask ``EntityContext`` surface (function style).""" + operation = ctx.operation + if operation == "set": + ctx.set_state(input) + return ctx.get_state() + if operation == "get": + return ctx.get_state() + if operation == "describe": + return { + "entity": ctx.entity_id.entity, + "key": ctx.entity_id.key, + "operation": ctx.operation, + } + if operation == "delete": + # Setting state to None deletes the entity. + ctx.set_state(None) + return "deleted" + return None + + +@bp.entity_trigger(context_name="context") +class Relay(DurableEntity): + """Class-based entity exercising advanced entity patterns. + + - ``signal_counter``: signals another entity (entity-to-entity signalling). + - ``start_orchestration``: schedules a new orchestration from the entity. + - ``boom``: raises to exercise entity-operation failure propagation. + """ + + def signal_counter(self, input: Any = None) -> None: + # input: {"key": , "amount": } + target = entities.EntityInstanceId("counter", input["key"]) + self.signal_entity(target, "add", input["amount"]) + + def start_orchestration(self, input: Any = None) -> None: + # input: {"name": , "input": } + self.schedule_new_orchestration(input["name"], input=input.get("input")) + + def boom(self, input: Any = None) -> None: + raise ValueError("entity operation failed on purpose") diff --git a/tests/azure-functions-durable/e2e/apps/dtask_style/function_app.py b/tests/azure-functions-durable/e2e/apps/dtask_style/function_app.py new file mode 100644 index 00000000..0c4a179e --- /dev/null +++ b/tests/azure-functions-durable/e2e/apps/dtask_style/function_app.py @@ -0,0 +1,41 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""durabletask-native-style Durable Functions sample app for E2E testing. + +The app is composed from blueprints, each covering one concern, and registered +onto a single ``DFApp``. Every orchestrator and entity uses the modern +durabletask authoring style: two-argument orchestrators +(``def orch(ctx, input):``) and entity functions (``def entity(ctx, input):``) +that use the durabletask ``OrchestrationContext`` / ``EntityContext`` API +directly, and the durabletask client method names. + +Together with the v1-style app it exercises both authoring surfaces the +compatibility layer supports, end-to-end against a real Functions host. +""" + +import azure.functions as func + +import azure.durable_functions as df + +import activities +import client_routes +import entities +import history_export_routes +import orchestrators + +app = df.DFApp(http_auth_level=func.AuthLevel.ANONYMOUS) + +app.register_functions(activities.bp) +app.register_functions(entities.bp) +app.register_functions(orchestrators.bp) +app.register_functions(client_routes.bp) +app.register_functions(history_export_routes.bp) + +# Opt in to durabletask scheduled tasks: registers the schedule entity and +# operation orchestrator so schedules can be managed via ScheduledTaskClient. +app.configure_scheduled_tasks() + +# Opt in to durabletask history export: registers the export-job entity, driving +# orchestrator, and activities so export jobs can be driven via ExportHistoryClient. +app.configure_history_export() diff --git a/tests/azure-functions-durable/e2e/apps/dtask_style/history_export_routes.py b/tests/azure-functions-durable/e2e/apps/dtask_style/history_export_routes.py new file mode 100644 index 00000000..5f7e9e19 --- /dev/null +++ b/tests/azure-functions-durable/e2e/apps/dtask_style/history_export_routes.py @@ -0,0 +1,149 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""HTTP routes for the history-export extension (opt-in). + +Exercises ``durabletask.extensions.history_export`` end-to-end through the +Functions host: the export-job entity, driving orchestrator, and the two +activities (registered via ``app.configure_history_export()``), plus the +public ``ExportHistoryClient`` surface (create / get job). + +The export activities need a durabletask client and a ``HistoryWriter`` bound +into the worker process via ``bind_context``. The client is only available at +request time (from the durable-client binding), so the context is bound here on +each request -- the route handler shares the worker process with the activities, +so the binding is visible to them. A local-filesystem writer sends exported +history to ``/_export_output`` where the test can read it back. +""" + +import json +from collections.abc import Mapping +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any, Optional + +import azure.functions as func + +import azure.durable_functions as df +from durabletask.client import TaskHubGrpcClient +from durabletask.extensions.history_export import ( + ExportDestination, + ExportFormat, + ExportFormatKind, + ExportHistoryClient, + ExportJobCreationOptions, + ExportMode, + HistoryExportContext, + bind_context, +) +from azure.durable_functions.internal.azurefunctions_grpc_interceptor import ( + AzureFunctionsDefaultClientInterceptorImpl, +) +from azure.durable_functions.internal.serialization import ( + DEFAULT_FUNCTIONS_DATA_CONVERTER, +) + +bp = df.Blueprint() + +# Exported history is written under the app directory so the E2E test (running +# on the same machine) can read it back and assert on the contents. +EXPORT_ROOT = Path(__file__).parent / "_export_output" + + +class _FileSystemHistoryWriter: + """Minimal ``HistoryWriter`` that writes each blob to the local filesystem.""" + + def __init__(self, root: Path) -> None: + self._root = Path(root) + + def write( + self, + *, + instance_id: str, + container: str, + blob_name: str, + payload: bytes, + content_type: str, + content_encoding: Optional[str] = None, + metadata: Optional[Mapping[str, str]] = None) -> None: + path = self._root / container / blob_name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(payload) + + +def _sync_client(client: df.DurableFunctionsClient) -> TaskHubGrpcClient: + """Build a synchronous durabletask client aimed at the same sidecar. + + ``ExportHistoryClient`` (and the export activities) use the synchronous + ``TaskHubGrpcClient``, whereas ``DurableFunctionsClient`` is async. + """ + interceptors = [AzureFunctionsDefaultClientInterceptorImpl( + client.taskHubName, client.requiredQueryStringParameters)] + return TaskHubGrpcClient( + host_address=client.rpcBaseUrl, + secure_channel=False, + interceptors=interceptors, + data_converter=DEFAULT_FUNCTIONS_DATA_CONVERTER) + + +def _export_client(client: df.DurableFunctionsClient) -> ExportHistoryClient: + """Build an ExportHistoryClient and bind the activities' runtime context.""" + sync = _sync_client(client) + writer = _FileSystemHistoryWriter(EXPORT_ROOT) + # Bind the process-wide context the export activities resolve at runtime. + # Binding here (the route shares the worker process with the activities) + # makes the client + writer visible before the driving orchestrator runs. + bind_context(HistoryExportContext(client=sync, writer=writer)) + return ExportHistoryClient(sync, writer) + + +@bp.route(route="export/start", methods=["POST"]) +@bp.durable_client_input(client_name="client") +async def start_export( + req: func.HttpRequest, client: df.DurableFunctionsClient) -> func.HttpResponse: + body = req.get_json() or {} + export = _export_client(client) + now = datetime.now(timezone.utc) + # Callers can narrow the completed-time window so an export only covers the + # instances they just produced (rather than every terminal instance in the + # last hour, which would make the fan-out unbounded). + completed_from_raw = body.get("completed_from") + completed_from = ( + datetime.fromisoformat(completed_from_raw) + if completed_from_raw else now - timedelta(hours=1)) + options = ExportJobCreationOptions( + mode=ExportMode.BATCH, + completed_time_from=completed_from, + completed_time_to=now + timedelta(hours=1), + destination=ExportDestination(container=body.get("container", "exports")), + # Uncompressed single-JSON-document-per-instance so the test can read it. + format=ExportFormat(kind=ExportFormatKind.JSON), + ) + desc = export.create_job(options, job_id=body.get("job_id")) + return func.HttpResponse( + json.dumps({ + "jobId": desc.job_id, + "status": desc.status.value, + "exportRoot": str(EXPORT_ROOT), + }), + mimetype="application/json") + + +@bp.route(route="export/status/{job_id}", methods=["GET"]) +@bp.durable_client_input(client_name="client") +async def export_status( + req: func.HttpRequest, client: df.DurableFunctionsClient) -> func.HttpResponse: + export = _export_client(client) + desc = export.get_job(req.route_params["job_id"]) + if desc is None: + return func.HttpResponse( + json.dumps({"status": None}), mimetype="application/json") + payload: dict[str, Any] = { + "jobId": desc.job_id, + "status": desc.status.value, + "scanned": desc.scanned_instances, + "exported": desc.exported_instances, + "failed": desc.failed_instances, + "lastError": desc.last_error, + } + return func.HttpResponse(json.dumps(payload), mimetype="application/json") diff --git a/tests/azure-functions-durable/e2e/apps/dtask_style/host.json b/tests/azure-functions-durable/e2e/apps/dtask_style/host.json new file mode 100644 index 00000000..c34a812b --- /dev/null +++ b/tests/azure-functions-durable/e2e/apps/dtask_style/host.json @@ -0,0 +1,19 @@ +{ + "version": "2.0", + "logging": { + "logLevel": { + "default": "Information" + } + }, + "extensionBundle": { + "id": "Microsoft.Azure.Functions.ExtensionBundle", + "version": "[4.*, 5.0.0)" + }, + "extensions": { + "durableTask": { + "storageProvider": { + "type": "AzureStorage" + } + } + } +} diff --git a/tests/azure-functions-durable/e2e/apps/dtask_style/local.settings.json b/tests/azure-functions-durable/e2e/apps/dtask_style/local.settings.json new file mode 100644 index 00000000..a2ded917 --- /dev/null +++ b/tests/azure-functions-durable/e2e/apps/dtask_style/local.settings.json @@ -0,0 +1,7 @@ +{ + "IsEncrypted": false, + "Values": { + "AzureWebJobsStorage": "UseDevelopmentStorage=true", + "FUNCTIONS_WORKER_RUNTIME": "python" + } +} diff --git a/tests/azure-functions-durable/e2e/apps/dtask_style/orchestrators.py b/tests/azure-functions-durable/e2e/apps/dtask_style/orchestrators.py new file mode 100644 index 00000000..61a86386 --- /dev/null +++ b/tests/azure-functions-durable/e2e/apps/dtask_style/orchestrators.py @@ -0,0 +1,290 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Orchestrator functions for the durabletask-native-style sample app (blueprint). + +Every orchestrator uses the modern durabletask two-argument style +(``def orch(ctx, input):``) and the durabletask ``OrchestrationContext`` API +directly. These cover the inherited durabletask surface -- activity chaining, +fan-out/fan-in (``task.when_all``), sub-orchestrations, external events, timers, +entities (``ctx.call_entity`` / ``ctx.signal_entity``), custom status, +continue-as-new, retries (``RetryPolicy``), deterministic IDs, ``task.when_any``, +and context properties -- plus the failure paths. +""" + +from datetime import timedelta +from typing import Any + +import azure.durable_functions as df +from durabletask import entities, task + +bp = df.Blueprint() + + +# --------------------------------------------------------------------------- +# Core patterns +# --------------------------------------------------------------------------- + +@bp.orchestration_trigger(context_name="context") +def activity_chain(ctx: task.OrchestrationContext, _: Any): + first = yield ctx.call_activity("say_hello", input="Tokyo") + second = yield ctx.call_activity("say_hello", input="Seattle") + third = yield ctx.call_activity("say_hello", input="London") + return [first, second, third] + + +@bp.orchestration_trigger(context_name="context") +def fan_out_fan_in(ctx: task.OrchestrationContext, count: Any): + count = count or 5 + tasks = [ctx.call_activity("square", input=i) for i in range(1, count + 1)] + results = yield task.when_all(tasks) + return sum(results) + + +@bp.orchestration_trigger(context_name="context") +def sub_orchestration_parent(ctx: task.OrchestrationContext, _: Any): + child_result = yield ctx.call_sub_orchestrator("activity_chain") + return {"from_child": child_result} + + +@bp.orchestration_trigger(context_name="context") +def wait_for_approval(ctx: task.OrchestrationContext, _: Any): + ctx.set_custom_status("waiting") + approved = yield ctx.wait_for_external_event("approval") + ctx.set_custom_status("received") + return {"approved": approved} + + +@bp.orchestration_trigger(context_name="context") +def counter_orchestration(ctx: task.OrchestrationContext, _: Any): + entity_id = entities.EntityInstanceId("counter", ctx.instance_id) + yield ctx.call_entity(entity_id, "add", 5) + yield ctx.call_entity(entity_id, "add", 3) + total = yield ctx.call_entity(entity_id, "get") + return total + + +@bp.orchestration_trigger(context_name="context") +def reset_counter(ctx: task.OrchestrationContext, _: Any): + # Exercises an entity operation that returns None (``reset``): the None + # result must round-trip through ``call_entity`` as None. + entity_id = entities.EntityInstanceId("counter", ctx.instance_id) + yield ctx.call_entity(entity_id, "add", 5) + reset_result = yield ctx.call_entity(entity_id, "reset") + total = yield ctx.call_entity(entity_id, "get") + return {"reset_result": reset_result, "reset_result_is_none": reset_result is None, "total": total} + + +@bp.orchestration_trigger(context_name="context") +def continue_as_new_counter(ctx: task.OrchestrationContext, value: Any): + value = (value or 0) + 1 + if value < 5: + ctx.continue_as_new(value) + return value + return value + + +# --------------------------------------------------------------------------- +# Timers and when_any (select) +# --------------------------------------------------------------------------- + +@bp.orchestration_trigger(context_name="context") +def timer_wait(ctx: task.OrchestrationContext, _: Any): + yield ctx.create_timer(ctx.current_utc_datetime + timedelta(seconds=2)) + return "fired" + + +@bp.orchestration_trigger(context_name="context") +def event_or_timeout(ctx: task.OrchestrationContext, _: Any): + event_task = ctx.wait_for_external_event("go") + timeout_task = ctx.create_timer(ctx.current_utc_datetime + timedelta(seconds=30)) + winner = yield task.when_any([event_task, timeout_task]) + if winner is event_task: + return {"result": "event", "data": event_task.get_result()} + return {"result": "timeout"} + + +# --------------------------------------------------------------------------- +# Deterministic IDs, context properties, parent/child +# --------------------------------------------------------------------------- + +@bp.orchestration_trigger(context_name="context") +def deterministic_ids(ctx: task.OrchestrationContext, _: Any): + return {"uuid1": ctx.new_uuid(), "uuid2": ctx.new_uuid()} + + +@bp.orchestration_trigger(context_name="context") +def context_properties(ctx: task.OrchestrationContext, _: Any): + yield ctx.call_activity("say_hello", input="probe") + return { + "instance_id": ctx.instance_id, + "is_replaying": ctx.is_replaying, + "version": ctx.version, + "parent_instance_id": ctx.parent_instance_id, + "has_current_utc_datetime": ctx.current_utc_datetime is not None, + } + + +@bp.orchestration_trigger(context_name="context") +def child_reports_parent(ctx: task.OrchestrationContext, _: Any): + return {"parent": ctx.parent_instance_id, "instance": ctx.instance_id} + + +@bp.orchestration_trigger(context_name="context") +def parent_with_child(ctx: task.OrchestrationContext, _: Any): + child = yield ctx.call_sub_orchestrator("child_reports_parent") + return {"parent_seen_by_child": child["parent"], "my_instance": ctx.instance_id} + + +# --------------------------------------------------------------------------- +# Entity interactions from an orchestrator +# --------------------------------------------------------------------------- + +@bp.orchestration_trigger(context_name="context") +def signal_counter(ctx: task.OrchestrationContext, key: Any): + entity_id = entities.EntityInstanceId("counter", key) + ctx.signal_entity(entity_id, "add", 10) + return key + + +@bp.orchestration_trigger(context_name="context") +def describe_entity(ctx: task.OrchestrationContext, key: Any): + entity_id = entities.EntityInstanceId("probe", key) + description = yield ctx.call_entity(entity_id, "describe") + return description + + +# --------------------------------------------------------------------------- +# Advanced entity patterns: locks, entity-to-entity signal, entity-starts- +# orchestration, and entity-operation failure propagation +# --------------------------------------------------------------------------- + +@bp.orchestration_trigger(context_name="context") +def lock_and_increment(ctx: task.OrchestrationContext, key: Any): + # Critical section around an entity via ctx.lock_entities. + entity_id = entities.EntityInstanceId("counter", key) + with (yield ctx.lock_entities([entity_id])): + yield ctx.call_entity(entity_id, "add", 5) + total = yield ctx.call_entity(entity_id, "get") + return total + + +@bp.orchestration_trigger(context_name="context") +def lock_then_throw(ctx: task.OrchestrationContext, key: Any): + # Acquire a lock, mutate the entity, then raise: the lock must be released + # so a later orchestration can lock the same entity again. + entity_id = entities.EntityInstanceId("counter", key) + with (yield ctx.lock_entities([entity_id])): + yield ctx.call_entity(entity_id, "add", 1) + raise RuntimeError("boom after locking") + + +@bp.orchestration_trigger(context_name="context") +def relay_signal(ctx: task.OrchestrationContext, key: Any): + # Orchestrator calls a Relay entity operation that in turn signals the + # counter entity (entity-to-entity signalling). + relay_id = entities.EntityInstanceId("relay", key) + yield ctx.call_entity(relay_id, "signal_counter", {"key": key, "amount": 7}) + return key + + +@bp.orchestration_trigger(context_name="context") +def signal_counter_delayed(ctx: task.OrchestrationContext, key: Any): + # Schedules a delayed (future) signal to the counter entity, then completes + # immediately. The signal should only be delivered after the delay elapses. + entity_id = entities.EntityInstanceId("counter", key) + signal_at = ctx.current_utc_datetime + timedelta(seconds=3) + ctx.signal_entity(entity_id, "add", 4, signal_time=signal_at) + return key + + +@bp.orchestration_trigger(context_name="context") +def scheduled_tick(ctx: task.OrchestrationContext, key: Any): + # Target orchestration for a scheduled task: each run signals the counter + # entity so tests can observe that the schedule fired repeatedly. + entity_id = entities.EntityInstanceId("counter", key) + ctx.signal_entity(entity_id, "add", 1) + return key + + +@bp.orchestration_trigger(context_name="context") +def relay_start_orch(ctx: task.OrchestrationContext, key: Any): + # Orchestrator calls a Relay entity operation that schedules a new + # orchestration (signal_counter), which then signals the counter entity. + relay_id = entities.EntityInstanceId("relay", key) + yield ctx.call_entity( + relay_id, "start_orchestration", {"name": "signal_counter", "input": key}) + return key + + +@bp.orchestration_trigger(context_name="context") +def call_failing_entity(ctx: task.OrchestrationContext, key: Any): + # Unhandled entity-operation failure must fail the orchestration. + relay_id = entities.EntityInstanceId("relay", key) + result = yield ctx.call_entity(relay_id, "boom") + return result + + +@bp.orchestration_trigger(context_name="context") +def call_failing_entity_handled(ctx: task.OrchestrationContext, key: Any): + # A caught entity-operation failure lets the orchestration complete. + relay_id = entities.EntityInstanceId("relay", key) + try: + yield ctx.call_entity(relay_id, "boom") + return {"caught": False} + except Exception as exc: # noqa: BLE001 - test intentionally catches broadly + return {"caught": True, "error": str(exc)} + + +# --------------------------------------------------------------------------- +# Retries +# --------------------------------------------------------------------------- + +@bp.orchestration_trigger(context_name="context") +def retry_then_succeed(ctx: task.OrchestrationContext, _: Any): + key = ctx.new_uuid() + policy = task.RetryPolicy( + first_retry_interval=timedelta(milliseconds=100), max_number_of_attempts=5) + result = yield ctx.call_activity( + "flaky", input={"key": key, "threshold": 3}, retry_policy=policy) + return result + + +@bp.orchestration_trigger(context_name="context") +def retry_exhausted(ctx: task.OrchestrationContext, _: Any): + policy = task.RetryPolicy( + first_retry_interval=timedelta(milliseconds=100), max_number_of_attempts=2) + result = yield ctx.call_activity("always_fail", input="still failing", retry_policy=policy) + return result + + +@bp.orchestration_trigger(context_name="context") +def flaky_suborch(ctx: task.OrchestrationContext, payload: Any): + result = yield ctx.call_activity("flaky", input=payload) + return result + + +@bp.orchestration_trigger(context_name="context") +def suborch_retry_then_succeed(ctx: task.OrchestrationContext, _: Any): + key = ctx.new_uuid() + policy = task.RetryPolicy( + first_retry_interval=timedelta(milliseconds=100), max_number_of_attempts=5) + result = yield ctx.call_sub_orchestrator( + "flaky_suborch", input={"key": key, "threshold": 2}, retry_policy=policy) + return result + + +# --------------------------------------------------------------------------- +# Failure paths +# --------------------------------------------------------------------------- + +@bp.orchestration_trigger(context_name="context") +def activity_fails(ctx: task.OrchestrationContext, _: Any): + result = yield ctx.call_activity("always_fail", input="boom") + return result + + +@bp.orchestration_trigger(context_name="context") +def sub_orch_fails(ctx: task.OrchestrationContext, _: Any): + result = yield ctx.call_sub_orchestrator("activity_fails") + return result diff --git a/tests/azure-functions-durable/e2e/apps/dtask_style/requirements.txt b/tests/azure-functions-durable/e2e/apps/dtask_style/requirements.txt new file mode 100644 index 00000000..20177ffe --- /dev/null +++ b/tests/azure-functions-durable/e2e/apps/dtask_style/requirements.txt @@ -0,0 +1,6 @@ +# The E2E harness installs azure-functions-durable (and its durabletask +# dependency) from the local source tree into the same interpreter the +# Functions host worker uses, so these are listed for documentation/parity with +# a real app rather than being pip-installed by the host at start time. +azure-functions +azure-functions-durable diff --git a/tests/azure-functions-durable/e2e/apps/v1_style/activities.py b/tests/azure-functions-durable/e2e/apps/v1_style/activities.py new file mode 100644 index 00000000..1350a837 --- /dev/null +++ b/tests/azure-functions-durable/e2e/apps/v1_style/activities.py @@ -0,0 +1,65 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Activity functions for the V1-style sample app (blueprint). + +Activities are ordinary Azure Functions dispatched by the host. This blueprint +also provides the failure/flaky activities used to exercise error propagation +and the activity retry policies. +""" + +import azure.durable_functions as df + +bp = df.Blueprint() + +# Module-global attempt counters keyed by a caller-supplied token. Activity +# retries re-invoke the same function in the same worker process, so this +# in-process state lets ``flaky`` fail a fixed number of times before +# succeeding -- exercising ``call_activity_with_retry`` end to end. +_ATTEMPTS: dict[str, int] = {} + + +@bp.activity_trigger(input_name="name") +def say_hello(name: str) -> str: + return f"Hello {name}!" + + +@bp.activity_trigger(input_name="n") +def square(n: int) -> int: + return n * n + + +@bp.activity_trigger(input_name="reason") +def always_fail(reason: str) -> str: + raise ValueError(reason or "activity failed on purpose") + + +@bp.activity_trigger(input_name="payload") +def flaky(payload: dict) -> dict: + """Fail until ``threshold`` attempts have been made, then succeed. + + The worker rejects parameterized generic and ``Optional`` annotations, so + the parameter and return type must be the plain ``dict`` type. + """ + key = payload["key"] + threshold = int(payload["threshold"]) + _ATTEMPTS[key] = _ATTEMPTS.get(key, 0) + 1 + attempts = _ATTEMPTS[key] + if attempts < threshold: + raise ValueError(f"flaky failure {attempts}/{threshold}") + return {"attempts": attempts} + + +@bp.activity_trigger(input_name="token") +def fail_once(token: str) -> str: + """Fail on the first invocation for ``token``, then succeed. + + Used to exercise rewind: the first attempt fails the orchestration, and a + rewind replays the failed activity -- which now succeeds because the + in-process attempt counter has advanced. + """ + _ATTEMPTS[token] = _ATTEMPTS.get(token, 0) + 1 + attempts = _ATTEMPTS[token] + if attempts == 1: + raise ValueError("failing on first attempt (rewind to retry)") + return f"succeeded on attempt {attempts}" diff --git a/tests/azure-functions-durable/e2e/apps/v1_style/client_routes.py b/tests/azure-functions-durable/e2e/apps/v1_style/client_routes.py new file mode 100644 index 00000000..b6418044 --- /dev/null +++ b/tests/azure-functions-durable/e2e/apps/v1_style/client_routes.py @@ -0,0 +1,212 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""HTTP routes for the V1-style sample app (blueprint). + +Exposes the app's control plane: a ``ping`` health check, helper endpoints used +by the durable-HTTP tests (``fail`` / ``echo``), the orchestration starter, and +the full deprecated v1 ``DurableOrchestrationClient`` management surface +(``start_new``, ``get_status``, ``get_status_all``, ``get_status_by``, +``raise_event``, ``terminate``, ``suspend``, ``resume``, ``restart``, +``purge_instance_history``, ``purge_instance_history_by``, ``read_entity_state``, +``signal_entity``, ``create_http_management_payload``, +``get_client_response_links``, +``wait_for_completion_or_create_check_status_response``, and the ``rewind`` stub). +""" + +import json +from datetime import datetime + +import azure.functions as func + +import azure.durable_functions as df + +bp = df.Blueprint() + + +# --------------------------------------------------------------------------- +# Health + durable-HTTP helper endpoints +# --------------------------------------------------------------------------- + +@bp.route(route="ping", methods=["GET"]) +def ping(req: func.HttpRequest) -> func.HttpResponse: + return func.HttpResponse("pong") + + +@bp.route(route="fail", methods=["GET"]) +def fail(req: func.HttpRequest) -> func.HttpResponse: + return func.HttpResponse("nope", status_code=500) + + +@bp.route(route="echo", methods=["POST"]) +def echo(req: func.HttpRequest) -> func.HttpResponse: + return func.HttpResponse(req.get_body(), mimetype="application/json") + + +# --------------------------------------------------------------------------- +# Starter + status +# --------------------------------------------------------------------------- + +@bp.route(route="start/{name}", methods=["POST"]) +@bp.durable_client_input(client_name="client") +async def start_orchestration( + req: func.HttpRequest, client: df.DurableFunctionsClient) -> func.HttpResponse: + name = req.route_params["name"] + body = req.get_json() + instance_id = await client.start_new(name, client_input=body.get("input")) + return client.create_check_status_response(req, instance_id) + + +@bp.route(route="status/{id}", methods=["GET"]) +@bp.durable_client_input(client_name="client") +async def get_orchestration_status( + req: func.HttpRequest, client: df.DurableFunctionsClient) -> func.HttpResponse: + status = await client.get_status(req.route_params["id"], show_input=True) + return func.HttpResponse(json.dumps(status.to_json()), mimetype="application/json") + + +@bp.route(route="status_all", methods=["GET"]) +@bp.durable_client_input(client_name="client") +async def get_status_all( + req: func.HttpRequest, client: df.DurableFunctionsClient) -> func.HttpResponse: + statuses = await client.get_status_all() + ids = [s.instance_id for s in statuses if s] + return func.HttpResponse(json.dumps({"ids": ids}), mimetype="application/json") + + +@bp.route(route="status_by/{runtime_status}", methods=["GET"]) +@bp.durable_client_input(client_name="client") +async def get_status_by( + req: func.HttpRequest, client: df.DurableFunctionsClient) -> func.HttpResponse: + runtime_status = df.OrchestrationRuntimeStatus(req.route_params["runtime_status"]) + statuses = await client.get_status_by(runtime_status=[runtime_status]) + ids = [s.instance_id for s in statuses if s] + return func.HttpResponse(json.dumps({"ids": ids}), mimetype="application/json") + + +# --------------------------------------------------------------------------- +# Lifecycle: events, terminate, suspend/resume, restart, purge +# --------------------------------------------------------------------------- + +@bp.route(route="raise/{id}/{event}", methods=["POST"]) +@bp.durable_client_input(client_name="client") +async def raise_orchestration_event( + req: func.HttpRequest, client: df.DurableFunctionsClient) -> func.HttpResponse: + body = req.get_json() + await client.raise_event( + req.route_params["id"], req.route_params["event"], event_data=body.get("data")) + return func.HttpResponse(status_code=202) + + +@bp.route(route="terminate/{id}", methods=["POST"]) +@bp.durable_client_input(client_name="client") +async def terminate_orchestration( + req: func.HttpRequest, client: df.DurableFunctionsClient) -> func.HttpResponse: + await client.terminate(req.route_params["id"], reason="e2e-terminate") + return func.HttpResponse(status_code=202) + + +@bp.route(route="suspend/{id}", methods=["POST"]) +@bp.durable_client_input(client_name="client") +async def suspend_orchestration( + req: func.HttpRequest, client: df.DurableFunctionsClient) -> func.HttpResponse: + await client.suspend(req.route_params["id"], reason="e2e-suspend") + return func.HttpResponse(status_code=202) + + +@bp.route(route="resume/{id}", methods=["POST"]) +@bp.durable_client_input(client_name="client") +async def resume_orchestration( + req: func.HttpRequest, client: df.DurableFunctionsClient) -> func.HttpResponse: + await client.resume(req.route_params["id"], reason="e2e-resume") + return func.HttpResponse(status_code=202) + + +@bp.route(route="restart/{id}", methods=["POST"]) +@bp.durable_client_input(client_name="client") +async def restart_orchestration( + req: func.HttpRequest, client: df.DurableFunctionsClient) -> func.HttpResponse: + new_id = await client.restart(req.route_params["id"]) + return func.HttpResponse( + json.dumps({"id": new_id}), status_code=202, mimetype="application/json") + + +@bp.route(route="purge/{id}", methods=["POST"]) +@bp.durable_client_input(client_name="client") +async def purge_orchestration( + req: func.HttpRequest, client: df.DurableFunctionsClient) -> func.HttpResponse: + result = await client.purge_instance_history(req.route_params["id"]) + return func.HttpResponse( + json.dumps({"instancesDeleted": result.instances_deleted}), + mimetype="application/json") + + +@bp.route(route="purge_by", methods=["POST"]) +@bp.durable_client_input(client_name="client") +async def purge_orchestration_by( + req: func.HttpRequest, client: df.DurableFunctionsClient) -> func.HttpResponse: + body = req.get_json() + created_time_from = datetime.fromisoformat(body["from"]) if body.get("from") else None + runtime_status = [df.OrchestrationRuntimeStatus(body["runtimeStatus"])] + result = await client.purge_instance_history_by( + created_time_from=created_time_from, runtime_status=runtime_status) + return func.HttpResponse( + json.dumps({"instancesDeleted": result.instances_deleted}), + mimetype="application/json") + + +# --------------------------------------------------------------------------- +# Management payload + wait-or-check + rewind +# --------------------------------------------------------------------------- + +@bp.route(route="mgmt_payload/{id}", methods=["GET"]) +@bp.durable_client_input(client_name="client") +async def management_payload( + req: func.HttpRequest, client: df.DurableFunctionsClient) -> func.HttpResponse: + instance_id = req.route_params["id"] + payload = client.create_http_management_payload(req, instance_id) + links = client.get_client_response_links(req, instance_id) + return func.HttpResponse( + json.dumps({"payload": dict(payload), "links": dict(links)}), + mimetype="application/json") + + +@bp.route(route="wait_or_check/{id}", methods=["GET"]) +@bp.durable_client_input(client_name="client") +async def wait_or_check( + req: func.HttpRequest, client: df.DurableFunctionsClient) -> func.HttpResponse: + return await client.wait_for_completion_or_create_check_status_response( + req, req.route_params["id"], timeout_in_milliseconds=15000) + + +@bp.route(route="rewind/{id}", methods=["POST"]) +@bp.durable_client_input(client_name="client") +async def rewind_orchestration( + req: func.HttpRequest, client: df.DurableFunctionsClient) -> func.HttpResponse: + await client.rewind(req.route_params["id"], reason="e2e-rewind") + return func.HttpResponse(status_code=202) + + +# --------------------------------------------------------------------------- +# Entities +# --------------------------------------------------------------------------- + +@bp.route(route="entity/{name}/{key}", methods=["GET"]) +@bp.durable_client_input(client_name="client") +async def read_entity( + req: func.HttpRequest, client: df.DurableFunctionsClient) -> func.HttpResponse: + entity_id = df.EntityId(req.route_params["name"], req.route_params["key"]) + state = await client.read_entity_state(entity_id) + return func.HttpResponse( + json.dumps({"exists": state.entity_exists, "state": state.entity_state}), + mimetype="application/json") + + +@bp.route(route="signal/{name}/{key}/{op}", methods=["POST"]) +@bp.durable_client_input(client_name="client") +async def signal_entity( + req: func.HttpRequest, client: df.DurableFunctionsClient) -> func.HttpResponse: + body = req.get_json() + entity_id = df.EntityId(req.route_params["name"], req.route_params["key"]) + await client.signal_entity(entity_id, req.route_params["op"], input=body.get("input")) + return func.HttpResponse(status_code=202) diff --git a/tests/azure-functions-durable/e2e/apps/v1_style/entities.py b/tests/azure-functions-durable/e2e/apps/v1_style/entities.py new file mode 100644 index 00000000..f61b4bd8 --- /dev/null +++ b/tests/azure-functions-durable/e2e/apps/v1_style/entities.py @@ -0,0 +1,50 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Entity functions for the V1-style sample app (blueprint). + +Uses the classic v1 single-argument entity style (``def entity(context):``) and +exercises the full ``DurableEntityContext`` surface: ``operation_name``, +``get_input``/``get_state``/``set_state``/``set_result``, ``entity_name`` / +``entity_key`` / ``is_newly_constructed`` (via the ``describe`` operation), and +``destruct_on_exit`` (via the ``delete`` operation). +""" + +import azure.durable_functions as df + +bp = df.Blueprint() + + +@bp.entity_trigger(context_name="context") +def counter(context: df.DurableEntityContext) -> None: + current = context.get_state(initializer=lambda: 0) + operation = context.operation_name + if operation == "add": + current += context.get_input() + context.set_state(current) + elif operation == "reset": + current = 0 + context.set_state(current) + context.set_result(current) + + +@bp.entity_trigger(context_name="context") +def probe(context: df.DurableEntityContext) -> None: + """Entity exposing the full v1 ``DurableEntityContext`` surface.""" + operation = context.operation_name + if operation == "set": + context.set_state(context.get_input()) + context.set_result(context.get_state()) + elif operation == "get": + context.set_result(context.get_state(initializer=lambda: None)) + elif operation == "describe": + context.set_result({ + "entity_name": context.entity_name, + "entity_key": context.entity_key, + "operation_name": context.operation_name, + "is_newly_constructed": context.is_newly_constructed, + }) + elif operation == "delete": + # destruct_on_exit clears the entity state, deleting the entity. + context.destruct_on_exit() + context.set_result("deleted") diff --git a/tests/azure-functions-durable/e2e/apps/v1_style/function_app.py b/tests/azure-functions-durable/e2e/apps/v1_style/function_app.py new file mode 100644 index 00000000..81f55e98 --- /dev/null +++ b/tests/azure-functions-durable/e2e/apps/v1_style/function_app.py @@ -0,0 +1,32 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""V1-style Durable Functions sample app for end-to-end testing. + +The app is composed from blueprints, each covering one concern, and registered +onto a single ``DFApp``. Every orchestrator and entity uses the classic +``azure-functions-durable`` v1 authoring style (single-argument generator +orchestrators, single-argument entity functions) and the deprecated v1 client +method names. + +Splitting the app across blueprints also exercises the Functions blueprint +registration path (``register_functions``) for the durable app. +""" + +import azure.functions as func + +import azure.durable_functions as df + +import activities +import client_routes +import entities +import http_orchestrators +import orchestrators + +app = df.DFApp(http_auth_level=func.AuthLevel.ANONYMOUS) + +app.register_functions(activities.bp) +app.register_functions(entities.bp) +app.register_functions(orchestrators.bp) +app.register_functions(http_orchestrators.bp) +app.register_functions(client_routes.bp) diff --git a/tests/azure-functions-durable/e2e/apps/v1_style/host.json b/tests/azure-functions-durable/e2e/apps/v1_style/host.json new file mode 100644 index 00000000..c34a812b --- /dev/null +++ b/tests/azure-functions-durable/e2e/apps/v1_style/host.json @@ -0,0 +1,19 @@ +{ + "version": "2.0", + "logging": { + "logLevel": { + "default": "Information" + } + }, + "extensionBundle": { + "id": "Microsoft.Azure.Functions.ExtensionBundle", + "version": "[4.*, 5.0.0)" + }, + "extensions": { + "durableTask": { + "storageProvider": { + "type": "AzureStorage" + } + } + } +} diff --git a/tests/azure-functions-durable/e2e/apps/v1_style/http_orchestrators.py b/tests/azure-functions-durable/e2e/apps/v1_style/http_orchestrators.py new file mode 100644 index 00000000..f99f7f73 --- /dev/null +++ b/tests/azure-functions-durable/e2e/apps/v1_style/http_orchestrators.py @@ -0,0 +1,28 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Durable HTTP orchestrators for the V1-style sample app (blueprint). + +Durable HTTP (``context.call_http``) is a v1-only feature reconstructed on top +of durabletask primitives. These orchestrators cover the happy path (GET), a +request with content (POST), and the non-2xx path (the response is returned to +the orchestrator rather than raising). +""" + +import azure.durable_functions as df + +bp = df.Blueprint() + + +@bp.orchestration_trigger(context_name="context") +def http_call(context: df.DurableOrchestrationContext): + url = context.get_input() + response = yield context.call_http("GET", url) + return {"status_code": response.status_code, "content": response.content} + + +@bp.orchestration_trigger(context_name="context") +def http_post(context: df.DurableOrchestrationContext): + payload = context.get_input() + response = yield context.call_http("POST", payload["url"], content=payload["content"]) + return {"status_code": response.status_code, "content": response.content} diff --git a/tests/azure-functions-durable/e2e/apps/v1_style/local.settings.json b/tests/azure-functions-durable/e2e/apps/v1_style/local.settings.json new file mode 100644 index 00000000..a2ded917 --- /dev/null +++ b/tests/azure-functions-durable/e2e/apps/v1_style/local.settings.json @@ -0,0 +1,7 @@ +{ + "IsEncrypted": false, + "Values": { + "AzureWebJobsStorage": "UseDevelopmentStorage=true", + "FUNCTIONS_WORKER_RUNTIME": "python" + } +} diff --git a/tests/azure-functions-durable/e2e/apps/v1_style/orchestrators.py b/tests/azure-functions-durable/e2e/apps/v1_style/orchestrators.py new file mode 100644 index 00000000..a96467c7 --- /dev/null +++ b/tests/azure-functions-durable/e2e/apps/v1_style/orchestrators.py @@ -0,0 +1,222 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Orchestrator functions for the V1-style sample app (blueprint). + +Every orchestrator uses the classic v1 single-argument generator style +(``def orch(context):``) and the ``DurableOrchestrationContext`` API. Together +these cover the full v1 orchestration surface -- activity chaining, +fan-out/fan-in, sub-orchestrations, external events, timers, entities, custom +status, continue-as-new, retries, deterministic IDs, context properties -- plus +the documented failure paths (activity/sub-orchestration failures, exhausted +retries, and the ``histories`` NotImplementedError). +""" + +from datetime import timedelta + +import azure.durable_functions as df + +bp = df.Blueprint() + + +# --------------------------------------------------------------------------- +# Core patterns +# --------------------------------------------------------------------------- + +@bp.orchestration_trigger(context_name="context") +def activity_chain(context: df.DurableOrchestrationContext): + first = yield context.call_activity("say_hello", "Tokyo") + second = yield context.call_activity("say_hello", "Seattle") + third = yield context.call_activity("say_hello", "London") + return [first, second, third] + + +@bp.orchestration_trigger(context_name="context") +def fan_out_fan_in(context: df.DurableOrchestrationContext): + count = context.get_input() or 5 + tasks = [context.call_activity("square", i) for i in range(1, count + 1)] + results = yield context.task_all(tasks) + return sum(results) + + +@bp.orchestration_trigger(context_name="context") +def sub_orchestration_parent(context: df.DurableOrchestrationContext): + child_result = yield context.call_sub_orchestrator("activity_chain") + return {"from_child": child_result} + + +@bp.orchestration_trigger(context_name="context") +def wait_for_approval(context: df.DurableOrchestrationContext): + context.set_custom_status("waiting") + approved = yield context.wait_for_external_event("approval") + context.set_custom_status("received") + return {"approved": approved} + + +@bp.orchestration_trigger(context_name="context") +def counter_orchestration(context: df.DurableOrchestrationContext): + entity_id = df.EntityId("counter", context.instance_id) + yield context.call_entity(entity_id, "add", 5) + yield context.call_entity(entity_id, "add", 3) + total = yield context.call_entity(entity_id, "get") + return total + + +@bp.orchestration_trigger(context_name="context") +def continue_as_new_counter(context: df.DurableOrchestrationContext): + # Count up to 5 across continue-as-new generations, then stop. + value = context.get_input() or 0 + value += 1 + if value < 5: + context.continue_as_new(value) + return value + return value + + +# --------------------------------------------------------------------------- +# Timers and task_any (select) +# --------------------------------------------------------------------------- + +@bp.orchestration_trigger(context_name="context") +def timer_wait(context: df.DurableOrchestrationContext): + deadline = context.current_utc_datetime + timedelta(seconds=2) + yield context.create_timer(deadline) + return "fired" + + +@bp.orchestration_trigger(context_name="context") +def event_or_timeout(context: df.DurableOrchestrationContext): + event_task = context.wait_for_external_event("go") + timeout_task = context.create_timer( + context.current_utc_datetime + timedelta(seconds=30)) + winner = yield context.task_any([event_task, timeout_task]) + if winner is event_task: + return {"result": "event", "data": event_task.get_result()} + return {"result": "timeout"} + + +# --------------------------------------------------------------------------- +# Deterministic IDs, context properties, parent/child +# --------------------------------------------------------------------------- + +@bp.orchestration_trigger(context_name="context") +def deterministic_ids(context: df.DurableOrchestrationContext): + return {"uuid": context.new_uuid(), "guid": str(context.new_guid())} + + +@bp.orchestration_trigger(context_name="context") +def context_properties(context: df.DurableOrchestrationContext): + # Yield once so the orchestrator replays at least once. + yield context.call_activity("say_hello", "probe") + return { + "instance_id": context.instance_id, + "is_replaying": context.is_replaying, + "version": context.version, + "parent_instance_id": context.parent_instance_id, + "has_current_utc_datetime": context.current_utc_datetime is not None, + "will_continue_as_new": context.will_continue_as_new, + "has_function_context": context.function_context is not None, + } + + +@bp.orchestration_trigger(context_name="context") +def child_reports_parent(context: df.DurableOrchestrationContext): + return {"parent": context.parent_instance_id, "instance": context.instance_id} + + +@bp.orchestration_trigger(context_name="context") +def parent_with_child(context: df.DurableOrchestrationContext): + child = yield context.call_sub_orchestrator("child_reports_parent") + return {"parent_seen_by_child": child["parent"], "my_instance": context.instance_id} + + +# --------------------------------------------------------------------------- +# Entity interactions from an orchestrator +# --------------------------------------------------------------------------- + +@bp.orchestration_trigger(context_name="context") +def signal_counter(context: df.DurableOrchestrationContext): + key = context.get_input() + entity_id = df.EntityId("counter", key) + # Fire-and-forget signal from within an orchestration. + context.signal_entity(entity_id, "add", 10) + return key + + +@bp.orchestration_trigger(context_name="context") +def describe_entity(context: df.DurableOrchestrationContext): + key = context.get_input() + entity_id = df.EntityId("probe", key) + description = yield context.call_entity(entity_id, "describe") + return description + + +# --------------------------------------------------------------------------- +# Retries +# --------------------------------------------------------------------------- + +@bp.orchestration_trigger(context_name="context") +def retry_then_succeed(context: df.DurableOrchestrationContext): + key = context.new_uuid() + options = df.RetryOptions( + first_retry_interval_in_milliseconds=100, max_number_of_attempts=5) + result = yield context.call_activity_with_retry( + "flaky", options, {"key": key, "threshold": 3}) + return result + + +@bp.orchestration_trigger(context_name="context") +def retry_exhausted(context: df.DurableOrchestrationContext): + options = df.RetryOptions( + first_retry_interval_in_milliseconds=100, max_number_of_attempts=2) + result = yield context.call_activity_with_retry("always_fail", options, "still failing") + return result + + +@bp.orchestration_trigger(context_name="context") +def flaky_suborch(context: df.DurableOrchestrationContext): + payload = context.get_input() + result = yield context.call_activity("flaky", payload) + return result + + +@bp.orchestration_trigger(context_name="context") +def suborch_retry_then_succeed(context: df.DurableOrchestrationContext): + key = context.new_uuid() + options = df.RetryOptions( + first_retry_interval_in_milliseconds=100, max_number_of_attempts=5) + result = yield context.call_sub_orchestrator_with_retry( + "flaky_suborch", options, {"key": key, "threshold": 2}) + return result + + +# --------------------------------------------------------------------------- +# Failure paths +# --------------------------------------------------------------------------- + +@bp.orchestration_trigger(context_name="context") +def activity_fails(context: df.DurableOrchestrationContext): + result = yield context.call_activity("always_fail", "boom") + return result + + +@bp.orchestration_trigger(context_name="context") +def sub_orch_fails(context: df.DurableOrchestrationContext): + result = yield context.call_sub_orchestrator("activity_fails") + return result + + +@bp.orchestration_trigger(context_name="context") +def rewind_target(context: df.DurableOrchestrationContext): + # Calls an activity that fails on its first attempt. The orchestration + # fails; a client rewind replays the failed activity, which then succeeds. + result = yield context.call_activity("fail_once", context.instance_id) + return result + + +@bp.orchestration_trigger(context_name="context") +def access_histories(context: df.DurableOrchestrationContext): + # histories is intentionally unsupported and raises NotImplementedError, + # which surfaces as a failed orchestration. + _ = context.histories + return "unreachable" diff --git a/tests/azure-functions-durable/e2e/apps/v1_style/requirements.txt b/tests/azure-functions-durable/e2e/apps/v1_style/requirements.txt new file mode 100644 index 00000000..20177ffe --- /dev/null +++ b/tests/azure-functions-durable/e2e/apps/v1_style/requirements.txt @@ -0,0 +1,6 @@ +# The E2E harness installs azure-functions-durable (and its durabletask +# dependency) from the local source tree into the same interpreter the +# Functions host worker uses, so these are listed for documentation/parity with +# a real app rather than being pip-installed by the host at start time. +azure-functions +azure-functions-durable diff --git a/tests/azure-functions-durable/e2e/conftest.py b/tests/azure-functions-durable/e2e/conftest.py new file mode 100644 index 00000000..a4b8c4eb --- /dev/null +++ b/tests/azure-functions-durable/e2e/conftest.py @@ -0,0 +1,48 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Fixtures for the Azure Functions Durable end-to-end suite. + +The suite is gated behind the ``functions_e2e`` marker and requires: + +- the Azure Functions Core Tools (``func``) on PATH, and +- a running Azurite instance (blob port 10000). + +When either prerequisite is missing the whole module is skipped, so the suite +is a no-op for contributors who have not set up the local toolchain. In CI both +are provisioned before the suite runs. + +Each sample app gets its own session-scoped Functions host so the two apps are +fully isolated and their hosts start/stop once per test session (shared across +all test modules that use the same app). +""" + +import pytest + +from ._harness import FunctionApp, azurite_is_running, func_executable + + +def _require_prerequisites(app_name: str) -> None: + if func_executable() is None: + pytest.skip("Azure Functions Core Tools ('func') is not installed.") + if not azurite_is_running(): + pytest.skip("Azurite is not running on the default blob port (10000).") + if not FunctionApp(app_name).venv_python.exists(): + pytest.skip( + f"In-app virtual environment for '{app_name}' is not provisioned. " + "Run the suite via 'nox -s functions_e2e', which creates a .venv " + "inside each sample app.") + + +@pytest.fixture(scope="session") +def v1_app(): + _require_prerequisites("v1_style") + with FunctionApp("v1_style") as app: + yield app + + +@pytest.fixture(scope="session") +def dtask_app(): + _require_prerequisites("dtask_style") + with FunctionApp("dtask_style") as app: + yield app diff --git a/tests/azure-functions-durable/e2e/test_dtask_client_e2e.py b/tests/azure-functions-durable/e2e/test_dtask_client_e2e.py new file mode 100644 index 00000000..e8726b4c --- /dev/null +++ b/tests/azure-functions-durable/e2e/test_dtask_client_e2e.py @@ -0,0 +1,86 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""E2E tests for the durabletask-native client management surface. + +Covers ``suspend_orchestration`` / ``resume_orchestration``, +``restart_orchestration``, ``get_all_orchestration_states``, +``get_orchestration_history``, ``wait_for_orchestration_start``, and +``wait_for_orchestration_completion``. +""" + +import pytest + +from ._harness import http_request + +pytestmark = pytest.mark.functions_e2e + + +def test_suspend_and_resume(dtask_app): + instance_id = dtask_app.start_orchestration("wait_for_approval") + + result = http_request("POST", f"{dtask_app.base_url}/api/suspend/{instance_id}") + assert result.status == 202 + dtask_app.wait_for_status(instance_id, "SUSPENDED") + + result = http_request("POST", f"{dtask_app.base_url}/api/resume/{instance_id}") + assert result.status == 202 + dtask_app.wait_for_status(instance_id, "RUNNING") + + dtask_app.raise_event(instance_id, "approval", data=True) + status = dtask_app.wait_for_completion(instance_id) + assert status["runtimeStatus"] == "COMPLETED" + assert status["output"] == {"approved": True} + + +def test_restart(dtask_app): + instance_id = dtask_app.start_orchestration("activity_chain") + dtask_app.wait_for_completion(instance_id) + + result = http_request("POST", f"{dtask_app.base_url}/api/restart/{instance_id}") + assert result.status == 202 + new_id = result.json()["id"] + + status = dtask_app.wait_for_completion(new_id) + assert status["runtimeStatus"] == "COMPLETED" + assert status["output"] == ["Hello Tokyo!", "Hello Seattle!", "Hello London!"] + + +def test_get_all_orchestration_states_includes_instance(dtask_app): + instance_id = dtask_app.start_orchestration("activity_chain") + dtask_app.wait_for_completion(instance_id) + + result = http_request("GET", f"{dtask_app.base_url}/api/states") + assert result.status == 200 + assert instance_id in result.json()["ids"] + + +def test_get_orchestration_history(dtask_app): + instance_id = dtask_app.start_orchestration("activity_chain") + dtask_app.wait_for_completion(instance_id) + + result = http_request("GET", f"{dtask_app.base_url}/api/history/{instance_id}") + assert result.status == 200 + assert result.json()["eventCount"] > 0 + + +def test_wait_for_orchestration_start(dtask_app): + instance_id = dtask_app.start_orchestration("wait_for_approval") + + result = http_request("GET", f"{dtask_app.base_url}/api/wait_start/{instance_id}") + assert result.status == 200 + assert result.json()["runtimeStatus"] in ("RUNNING", "PENDING") + + # Clean up the still-running instance. + dtask_app.raise_event(instance_id, "approval", data=True) + dtask_app.wait_for_completion(instance_id) + + +def test_wait_for_orchestration_completion(dtask_app): + instance_id = dtask_app.start_orchestration("activity_chain") + + result = http_request("GET", f"{dtask_app.base_url}/api/wait_complete/{instance_id}") + assert result.status == 200 + body = result.json() + assert body["runtimeStatus"] == "COMPLETED" + assert body["output"] == ["Hello Tokyo!", "Hello Seattle!", "Hello London!"] diff --git a/tests/azure-functions-durable/e2e/test_dtask_entities_advanced_e2e.py b/tests/azure-functions-durable/e2e/test_dtask_entities_advanced_e2e.py new file mode 100644 index 00000000..364b6c68 --- /dev/null +++ b/tests/azure-functions-durable/e2e/test_dtask_entities_advanced_e2e.py @@ -0,0 +1,113 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""E2E tests for advanced durabletask-native entity patterns. + +Covers the entity patterns exercised by the durabletask entity e2e suite that +go beyond basic call/signal: entity locking (``ctx.lock_entities``) including +lock release on failure, entity-to-entity signalling, an entity starting a new +orchestration, and entity-operation failure propagation (handled and +unhandled). +""" + +import time + +import pytest + +pytestmark = pytest.mark.functions_e2e + + +def test_entity_lock_critical_section(dtask_app): + key = f"lock-{int(time.time() * 1000)}" + instance_id = dtask_app.start_orchestration("lock_and_increment", body=key) + status = dtask_app.wait_for_completion(instance_id) + assert status["runtimeStatus"] == "COMPLETED" + assert status["output"] == 5 + + +def test_entity_lock_released_on_throw(dtask_app): + key = f"lock-throw-{int(time.time() * 1000)}" + + # First orchestration locks the entity, mutates it (+1), then throws. + failing_id = dtask_app.start_orchestration("lock_then_throw", body=key) + failed = dtask_app.wait_for_completion(failing_id) + assert failed["runtimeStatus"] == "FAILED" + + # If the lock leaked, this second lock+increment would never complete. + ok_id = dtask_app.start_orchestration("lock_and_increment", body=key) + ok = dtask_app.wait_for_completion(ok_id) + assert ok["runtimeStatus"] == "COMPLETED" + # 1 (from the failed run) + 5 (from this run) == 6. + assert ok["output"] == 6 + + +def test_entity_signals_entity(dtask_app): + key = f"relay-signal-{int(time.time() * 1000)}" + instance_id = dtask_app.start_orchestration("relay_signal", body=key) + status = dtask_app.wait_for_completion(instance_id) + assert status["runtimeStatus"] == "COMPLETED" + + payload = dtask_app.wait_for_entity( + "counter", key, lambda p: p["exists"] and p["state"] == 7) + assert payload["state"] == 7 + + +def test_entity_starts_orchestration(dtask_app): + key = f"relay-start-{int(time.time() * 1000)}" + instance_id = dtask_app.start_orchestration("relay_start_orch", body=key) + status = dtask_app.wait_for_completion(instance_id) + assert status["runtimeStatus"] == "COMPLETED" + + # The Relay entity scheduled the ``signal_counter`` orchestration, which + # signals the counter entity to add 10. + payload = dtask_app.wait_for_entity( + "counter", key, lambda p: p["exists"] and p["state"] == 10) + assert payload["state"] == 10 + + +def test_call_failing_entity_fails_orchestration(dtask_app): + key = f"boom-{int(time.time() * 1000)}" + instance_id = dtask_app.start_orchestration("call_failing_entity", body=key) + status = dtask_app.wait_for_completion(instance_id) + assert status["runtimeStatus"] == "FAILED" + + +def test_call_failing_entity_handled(dtask_app): + key = f"boom-handled-{int(time.time() * 1000)}" + instance_id = dtask_app.start_orchestration("call_failing_entity_handled", body=key) + status = dtask_app.wait_for_completion(instance_id) + assert status["runtimeStatus"] == "COMPLETED" + assert status["output"]["caught"] is True + + +def test_client_delayed_signal_is_deferred(dtask_app): + key = f"client-delay-{int(time.time() * 1000)}" + # Schedule the signal ~3s in the future. + dtask_app.signal_entity("counter", key, "add", input=9, delay_seconds=3) + + # It must not be delivered immediately. + time.sleep(1) + early = dtask_app.read_entity("counter", key) + assert not (early["exists"] and early["state"] == 9), ( + f"delayed signal fired too early: {early}") + + # It should be delivered after the delay elapses. + payload = dtask_app.wait_for_entity( + "counter", key, lambda p: p["exists"] and p["state"] == 9) + assert payload["state"] == 9 + + +def test_orchestration_delayed_signal_is_deferred(dtask_app): + key = f"orch-delay-{int(time.time() * 1000)}" + instance_id = dtask_app.start_orchestration("signal_counter_delayed", body=key) + # The orchestration completes immediately; the signal is deferred. + status = dtask_app.wait_for_completion(instance_id) + assert status["runtimeStatus"] == "COMPLETED" + + early = dtask_app.read_entity("counter", key) + assert not (early["exists"] and early["state"] == 4), ( + f"delayed signal fired too early: {early}") + + payload = dtask_app.wait_for_entity( + "counter", key, lambda p: p["exists"] and p["state"] == 4) + assert payload["state"] == 4 diff --git a/tests/azure-functions-durable/e2e/test_dtask_entities_e2e.py b/tests/azure-functions-durable/e2e/test_dtask_entities_e2e.py new file mode 100644 index 00000000..565178c0 --- /dev/null +++ b/tests/azure-functions-durable/e2e/test_dtask_entities_e2e.py @@ -0,0 +1,71 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""E2E tests for the durabletask-native entity surface. + +Covers ``EntityContext`` state get/set, identity (``entity_id.entity`` / +``entity_id.key`` / ``operation`` via the ``describe`` operation), deletion (set +state to ``None``), and signalling an entity from within an orchestrator +(``ctx.signal_entity``). +""" + +import time + +import pytest + +pytestmark = pytest.mark.functions_e2e + + +def test_entity_set_and_read(dtask_app): + key = f"probe-{int(time.time() * 1000)}" + dtask_app.signal_entity("probe", key, "set", input=42) + payload = dtask_app.wait_for_entity( + "probe", key, lambda p: p["exists"] and p["state"] == 42) + assert payload["state"] == 42 + + +def test_entity_describe_via_orchestration(dtask_app): + key = f"probe-{int(time.time() * 1000)}" + dtask_app.signal_entity("probe", key, "set", input=1) + dtask_app.wait_for_entity("probe", key, lambda p: p["exists"]) + + instance_id = dtask_app.start_orchestration("describe_entity", body=key) + status = dtask_app.wait_for_completion(instance_id) + assert status["runtimeStatus"] == "COMPLETED" + output = status["output"] + assert output["entity"] == "probe" + assert output["key"] == key + assert output["operation"] == "describe" + + +def test_entity_delete(dtask_app): + key = f"probe-{int(time.time() * 1000)}" + dtask_app.signal_entity("probe", key, "set", input=7) + dtask_app.wait_for_entity("probe", key, lambda p: p["exists"] and p["state"] == 7) + + dtask_app.signal_entity("probe", key, "delete") + payload = dtask_app.wait_for_entity("probe", key, lambda p: not p["exists"]) + assert payload["exists"] is False + + +def test_signal_entity_from_orchestrator(dtask_app): + key = f"orch-signal-{int(time.time() * 1000)}" + instance_id = dtask_app.start_orchestration("signal_counter", body=key) + status = dtask_app.wait_for_completion(instance_id) + assert status["runtimeStatus"] == "COMPLETED" + + payload = dtask_app.wait_for_entity( + "counter", key, lambda p: p["exists"] and p["state"] == 10) + assert payload["state"] == 10 + + +def test_entity_operation_returning_none(dtask_app): + # The class-based Counter's ``reset`` returns None; verify that result + # round-trips through ``call_entity`` as None (and the state is reset). + instance_id = dtask_app.start_orchestration("reset_counter") + status = dtask_app.wait_for_completion(instance_id) + assert status["runtimeStatus"] == "COMPLETED" + output = status["output"] + assert output["reset_result"] is None + assert output["reset_result_is_none"] is True + assert output["total"] == 0 diff --git a/tests/azure-functions-durable/e2e/test_dtask_entity_admin_e2e.py b/tests/azure-functions-durable/e2e/test_dtask_entity_admin_e2e.py new file mode 100644 index 00000000..6608cbe3 --- /dev/null +++ b/tests/azure-functions-durable/e2e/test_dtask_entity_admin_e2e.py @@ -0,0 +1,54 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""E2E tests for the durabletask entity administration client surface. + +Covers ``get_all_entities`` (List All Entities) and ``clean_entity_storage`` +(entity storage cleanup) -- both client SDK capabilities that were missing in +the v1 azure-functions-durable Python package. +""" + +import time + +import pytest + +pytestmark = pytest.mark.functions_e2e + + +def test_list_all_entities(dtask_app): + prefix = f"list-{int(time.time() * 1000)}" + keys = [f"{prefix}-a", f"{prefix}-b", f"{prefix}-c"] + for key in keys: + dtask_app.signal_entity("counter", key, "add", input=1) + for key in keys: + dtask_app.wait_for_entity("counter", key, lambda p: p["exists"] and p["state"] == 1) + + # Filter to just this test's counters via the entity-id prefix (@counter@). + result = dtask_app.list_entities(starts_with=f"@counter@{prefix}") + found_keys = {e["key"] for e in result["entities"]} + assert set(keys).issubset(found_keys), ( + f"expected {keys} in listed entities, got {found_keys}") + for entity in result["entities"]: + if entity["key"] in keys: + assert entity["entity"] == "counter" + assert entity["state"] == 1 + + +def test_clean_entity_storage(dtask_app): + # Create an entity, then empty it (state None), then clean storage. The + # emptied entity should be removed. The call must succeed and return + # non-negative counters. + key = f"clean-{int(time.time() * 1000)}" + dtask_app.signal_entity("counter", key, "add", input=1) + dtask_app.wait_for_entity("counter", key, lambda p: p["exists"] and p["state"] == 1) + + # Empty the entity (probe's delete sets state to None); use the counter's + # reset to 0 is non-empty, so use a probe entity for the empty case. + dtask_app.signal_entity("probe", key, "set", input=5) + dtask_app.wait_for_entity("probe", key, lambda p: p["exists"]) + dtask_app.signal_entity("probe", key, "delete") + dtask_app.wait_for_entity("probe", key, lambda p: not p["exists"]) + + result = dtask_app.clean_entity_storage() + assert result["emptyEntitiesRemoved"] >= 0 + assert result["orphanedLocksReleased"] >= 0 diff --git a/tests/azure-functions-durable/e2e/test_dtask_history_export_e2e.py b/tests/azure-functions-durable/e2e/test_dtask_history_export_e2e.py new file mode 100644 index 00000000..42a4bcbe --- /dev/null +++ b/tests/azure-functions-durable/e2e/test_dtask_history_export_e2e.py @@ -0,0 +1,53 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""E2E test for the history-export extension driven through the Functions host. + +Exercises ``durabletask.extensions.history_export`` (opt-in via +``app.configure_history_export()``): the export-job entity schedules the driving +orchestrator, which fans out the export activities to serialize each terminal +instance's history to a local-filesystem writer. Verifies the job completes and +the exported blobs contain the instances we produced. +""" + +import time +from datetime import datetime, timedelta, timezone +from pathlib import Path + +import pytest + +pytestmark = pytest.mark.functions_e2e + + +def test_history_export_batch(dtask_app): + # Scope the export to instances completed from just before this test, so the + # fan-out only covers the instances we create here (not every terminal + # instance produced by the wider session). + completed_from = (datetime.now(timezone.utc) - timedelta(seconds=5)).isoformat() + + # Produce a couple of terminal orchestrations to export. + instance_ids = [] + for _ in range(2): + iid = dtask_app.start_orchestration("activity_chain") + dtask_app.wait_for_completion(iid) + instance_ids.append(iid) + + container = f"exp-{int(time.time() * 1000)}" + started = dtask_app.start_export(container=container, completed_from=completed_from) + job_id = started["jobId"] + export_root = Path(started["exportRoot"]) + + final = dtask_app.wait_for_export(job_id) + assert final["status"] == "Completed", final + # The narrowed window covers exactly the two instances we produced. + assert final["exported"] >= 2 + + container_dir = export_root / container + files = list(container_dir.glob("*.json")) + assert len(files) >= 2, f"expected >=2 exported files in {container_dir}, got {files}" + + # Each exported blob is self-describing (it carries the instance's + # OrchestrationState metadata), so our instance IDs must appear in the output. + exported_text = "\n".join(f.read_text(encoding="utf-8") for f in files) + for iid in instance_ids: + assert iid in exported_text, f"instance {iid} not found in exported history" diff --git a/tests/azure-functions-durable/e2e/test_dtask_patterns_e2e.py b/tests/azure-functions-durable/e2e/test_dtask_patterns_e2e.py new file mode 100644 index 00000000..14280e24 --- /dev/null +++ b/tests/azure-functions-durable/e2e/test_dtask_patterns_e2e.py @@ -0,0 +1,62 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""E2E tests for the durabletask-native orchestration-context surface. + +Covers timers (``ctx.create_timer``), ``task.when_any`` selection, deterministic +IDs (``ctx.new_uuid``), context properties (``is_replaying``, ``version``, +``parent_instance_id``, ``current_utc_datetime``), and parent/child +``parent_instance_id`` propagation. +""" + +from uuid import UUID + +import pytest + +pytestmark = pytest.mark.functions_e2e + + +def test_timer(dtask_app): + instance_id = dtask_app.start_orchestration("timer_wait") + status = dtask_app.wait_for_completion(instance_id) + assert status["runtimeStatus"] == "COMPLETED" + assert status["output"] == "fired" + + +def test_when_any_event_wins(dtask_app): + instance_id = dtask_app.start_orchestration("event_or_timeout") + dtask_app.raise_event(instance_id, "go", data="hello") + status = dtask_app.wait_for_completion(instance_id) + assert status["runtimeStatus"] == "COMPLETED" + assert status["output"] == {"result": "event", "data": "hello"} + + +def test_deterministic_ids(dtask_app): + instance_id = dtask_app.start_orchestration("deterministic_ids") + status = dtask_app.wait_for_completion(instance_id) + assert status["runtimeStatus"] == "COMPLETED" + output = status["output"] + assert str(UUID(output["uuid1"])) == output["uuid1"] + assert str(UUID(output["uuid2"])) == output["uuid2"] + assert output["uuid1"] != output["uuid2"] + + +def test_context_properties(dtask_app): + instance_id = dtask_app.start_orchestration("context_properties") + status = dtask_app.wait_for_completion(instance_id) + assert status["runtimeStatus"] == "COMPLETED" + output = status["output"] + assert output["instance_id"] == instance_id + assert isinstance(output["is_replaying"], bool) + assert output["version"] is None + assert output["parent_instance_id"] is None + assert output["has_current_utc_datetime"] is True + + +def test_parent_instance_id_propagation(dtask_app): + instance_id = dtask_app.start_orchestration("parent_with_child") + status = dtask_app.wait_for_completion(instance_id) + assert status["runtimeStatus"] == "COMPLETED" + output = status["output"] + assert output["parent_seen_by_child"] == output["my_instance"] + assert output["my_instance"] == instance_id diff --git a/tests/azure-functions-durable/e2e/test_dtask_retries_e2e.py b/tests/azure-functions-durable/e2e/test_dtask_retries_e2e.py new file mode 100644 index 00000000..191eb7e4 --- /dev/null +++ b/tests/azure-functions-durable/e2e/test_dtask_retries_e2e.py @@ -0,0 +1,45 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""E2E tests for durabletask-native retries and failure propagation. + +Covers activity ``retry_policy`` (eventual success and exhausted retries), +sub-orchestrator ``retry_policy`` (eventual success), activity-failure +propagation, and sub-orchestration-failure propagation. +""" + +import pytest + +pytestmark = pytest.mark.functions_e2e + + +def test_activity_retry_eventual_success(dtask_app): + instance_id = dtask_app.start_orchestration("retry_then_succeed") + status = dtask_app.wait_for_completion(instance_id) + assert status["runtimeStatus"] == "COMPLETED" + assert status["output"] == {"attempts": 3} + + +def test_activity_retry_exhausted_fails(dtask_app): + instance_id = dtask_app.start_orchestration("retry_exhausted") + status = dtask_app.wait_for_completion(instance_id) + assert status["runtimeStatus"] == "FAILED" + + +def test_sub_orchestrator_retry_eventual_success(dtask_app): + instance_id = dtask_app.start_orchestration("suborch_retry_then_succeed") + status = dtask_app.wait_for_completion(instance_id) + assert status["runtimeStatus"] == "COMPLETED" + assert status["output"] == {"attempts": 2} + + +def test_activity_failure_fails_orchestration(dtask_app): + instance_id = dtask_app.start_orchestration("activity_fails") + status = dtask_app.wait_for_completion(instance_id) + assert status["runtimeStatus"] == "FAILED" + + +def test_sub_orchestration_failure_propagates(dtask_app): + instance_id = dtask_app.start_orchestration("sub_orch_fails") + status = dtask_app.wait_for_completion(instance_id) + assert status["runtimeStatus"] == "FAILED" diff --git a/tests/azure-functions-durable/e2e/test_dtask_scheduled_e2e.py b/tests/azure-functions-durable/e2e/test_dtask_scheduled_e2e.py new file mode 100644 index 00000000..5d00221d --- /dev/null +++ b/tests/azure-functions-durable/e2e/test_dtask_scheduled_e2e.py @@ -0,0 +1,54 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""E2E tests for opt-in durabletask scheduled tasks in a Functions app. + +The dtask app opts in via ``app.configure_scheduled_tasks()``. These tests +create a schedule that runs the ``scheduled_tick`` orchestration (which signals +a counter entity) on a short interval, verify it fires repeatedly, then delete +it -- exercising ``ScheduledTaskClient`` end-to-end through the Functions host. +""" + +import time + +import pytest + +pytestmark = pytest.mark.functions_e2e + + +def test_scheduled_orchestration_fires_repeatedly(dtask_app): + stamp = int(time.time() * 1000) + key = f"sched-{stamp}" + schedule_id = f"schedule-{stamp}" + + created = dtask_app.create_schedule(schedule_id, interval_seconds=2, input=key) + assert created["scheduleId"] == schedule_id + assert created["status"].lower().endswith("active") + + try: + # Each run of ``scheduled_tick`` signals counter[key] += 1. At a 2s + # interval, expect at least two increments within the window. + payload = dtask_app.wait_for_entity( + "counter", key, lambda p: p["exists"] and p["state"] >= 2, timeout=40) + assert payload["state"] >= 2 + + described = dtask_app.describe_schedule(schedule_id) + assert described["exists"] is True + assert described["scheduleId"] == schedule_id + finally: + dtask_app.delete_schedule(schedule_id) + + +def test_scheduled_task_delete_stops_runs(dtask_app): + stamp = int(time.time() * 1000) + key = f"scheddel-{stamp}" + schedule_id = f"scheduledel-{stamp}" + + dtask_app.create_schedule(schedule_id, interval_seconds=2, input=key) + # Let it fire at least once. + dtask_app.wait_for_entity("counter", key, lambda p: p["exists"] and p["state"] >= 1, timeout=40) + dtask_app.delete_schedule(schedule_id) + + # After deletion the schedule should no longer exist. + described = dtask_app.describe_schedule(schedule_id) + assert described["exists"] is False diff --git a/tests/azure-functions-durable/e2e/test_dtask_style_e2e.py b/tests/azure-functions-durable/e2e/test_dtask_style_e2e.py new file mode 100644 index 00000000..d39d9071 --- /dev/null +++ b/tests/azure-functions-durable/e2e/test_dtask_style_e2e.py @@ -0,0 +1,127 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""End-to-end tests for the durabletask-native-style Durable Functions app. + +These run against a real Functions host (see ``conftest.py``) and exercise the +modern two-argument authoring surface plus the durabletask client method names. +""" + +import time + +import pytest + +from ._harness import http_request + +pytestmark = pytest.mark.functions_e2e + + +def _wait_for_entity(app, name, key, predicate, timeout=30): + """Poll the entity read route until ``predicate(payload)`` is true.""" + deadline = time.time() + timeout + payload = None + while time.time() < deadline: + result = http_request("GET", f"{app.base_url}/api/entity/{name}/{key}") + assert result.status == 200, f"entity read failed: {result.status} {result.body}" + payload = result.json() + if predicate(payload): + return payload + time.sleep(0.5) + raise TimeoutError(f"entity {name}/{key} predicate not met; last: {payload}") + + +# --------------------------------------------------------------------------- +# Orchestration patterns +# --------------------------------------------------------------------------- + +def test_activity_chaining(dtask_app): + instance_id = dtask_app.start_orchestration("activity_chain") + status = dtask_app.wait_for_completion(instance_id) + assert status["runtimeStatus"] == "COMPLETED" + assert status["output"] == ["Hello Tokyo!", "Hello Seattle!", "Hello London!"] + + +def test_fan_out_fan_in(dtask_app): + instance_id = dtask_app.start_orchestration("fan_out_fan_in", body=4) + status = dtask_app.wait_for_completion(instance_id) + assert status["runtimeStatus"] == "COMPLETED" + # 1 + 4 + 9 + 16 + assert status["output"] == 30 + + +def test_sub_orchestration(dtask_app): + instance_id = dtask_app.start_orchestration("sub_orchestration_parent") + status = dtask_app.wait_for_completion(instance_id) + assert status["runtimeStatus"] == "COMPLETED" + assert status["output"] == { + "from_child": ["Hello Tokyo!", "Hello Seattle!", "Hello London!"]} + + +def test_continue_as_new(dtask_app): + instance_id = dtask_app.start_orchestration("continue_as_new_counter", body=0) + status = dtask_app.wait_for_completion(instance_id) + assert status["runtimeStatus"] == "COMPLETED" + assert status["output"] == 5 + + +# --------------------------------------------------------------------------- +# External events + custom status +# --------------------------------------------------------------------------- + +def test_external_event_and_custom_status(dtask_app): + instance_id = dtask_app.start_orchestration("wait_for_approval") + + result = http_request( + "POST", f"{dtask_app.base_url}/api/raise/{instance_id}/approval", data={"data": True}) + assert result.status == 202 + + status = dtask_app.wait_for_completion(instance_id) + assert status["runtimeStatus"] == "COMPLETED" + assert status["output"] == {"approved": True} + assert status.get("customStatus") == "received" + + +# --------------------------------------------------------------------------- +# Entities +# --------------------------------------------------------------------------- + +def test_entity_via_orchestration(dtask_app): + instance_id = dtask_app.start_orchestration("counter_orchestration") + status = dtask_app.wait_for_completion(instance_id) + assert status["runtimeStatus"] == "COMPLETED" + assert status["output"] == 8 + + +def test_entity_via_client_signal_and_read(dtask_app): + key = f"client-{int(time.time() * 1000)}" + + result = http_request( + "POST", f"{dtask_app.base_url}/api/signal/counter/{key}/add", data={"input": 7}) + assert result.status == 202 + + payload = _wait_for_entity( + dtask_app, "counter", key, lambda p: p["exists"] and p["state"] == 7) + assert payload["state"] == 7 + + +# --------------------------------------------------------------------------- +# Client management surface +# --------------------------------------------------------------------------- + +def test_terminate(dtask_app): + instance_id = dtask_app.start_orchestration("wait_for_approval") + + result = http_request("POST", f"{dtask_app.base_url}/api/terminate/{instance_id}") + assert result.status == 202 + + status = dtask_app.wait_for_completion(instance_id) + assert status["runtimeStatus"] == "TERMINATED" + + +def test_purge(dtask_app): + instance_id = dtask_app.start_orchestration("activity_chain") + dtask_app.wait_for_completion(instance_id) + + result = http_request("POST", f"{dtask_app.base_url}/api/purge/{instance_id}") + assert result.status == 200 + assert result.json()["instancesDeleted"] == 1 diff --git a/tests/azure-functions-durable/e2e/test_v1_client_e2e.py b/tests/azure-functions-durable/e2e/test_v1_client_e2e.py new file mode 100644 index 00000000..3ba37aff --- /dev/null +++ b/tests/azure-functions-durable/e2e/test_v1_client_e2e.py @@ -0,0 +1,119 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""E2E tests for the deprecated V1 ``DurableOrchestrationClient`` surface. + +Covers ``suspend``/``resume``, ``restart``, ``get_status_all``, +``get_status_by``, ``purge_instance_history_by``, +``create_http_management_payload`` / ``get_client_response_links``, +``wait_for_completion_or_create_check_status_response``, and the ``rewind`` +NotImplementedError stub. +""" + +from datetime import datetime, timedelta, timezone + +import pytest + +from ._harness import http_request + +pytestmark = pytest.mark.functions_e2e + + +def test_suspend_and_resume(v1_app): + instance_id = v1_app.start_orchestration("wait_for_approval") + + result = http_request("POST", f"{v1_app.base_url}/api/suspend/{instance_id}") + assert result.status == 202 + v1_app.wait_for_status(instance_id, "Suspended") + + result = http_request("POST", f"{v1_app.base_url}/api/resume/{instance_id}") + assert result.status == 202 + v1_app.wait_for_status(instance_id, "Running") + + v1_app.raise_event(instance_id, "approval", data=True) + status = v1_app.wait_for_completion(instance_id) + assert status["runtimeStatus"] == "Completed" + assert status["output"] == {"approved": True} + + +def test_restart(v1_app): + instance_id = v1_app.start_orchestration("activity_chain") + v1_app.wait_for_completion(instance_id) + + result = http_request("POST", f"{v1_app.base_url}/api/restart/{instance_id}") + assert result.status == 202 + new_id = result.json()["id"] + + status = v1_app.wait_for_completion(new_id) + assert status["runtimeStatus"] == "Completed" + assert status["output"] == ["Hello Tokyo!", "Hello Seattle!", "Hello London!"] + + +def test_get_status_all_includes_instance(v1_app): + instance_id = v1_app.start_orchestration("activity_chain") + v1_app.wait_for_completion(instance_id) + + result = http_request("GET", f"{v1_app.base_url}/api/status_all") + assert result.status == 200 + assert instance_id in result.json()["ids"] + + +def test_get_status_by_runtime_status(v1_app): + instance_id = v1_app.start_orchestration("activity_chain") + v1_app.wait_for_completion(instance_id) + + result = http_request("GET", f"{v1_app.base_url}/api/status_by/Completed") + assert result.status == 200 + assert instance_id in result.json()["ids"] + + +def test_purge_instance_history_by(v1_app): + created_from = (datetime.now(timezone.utc) - timedelta(minutes=1)).isoformat() + instance_id = v1_app.start_orchestration("activity_chain") + v1_app.wait_for_completion(instance_id) + + result = http_request( + "POST", f"{v1_app.base_url}/api/purge_by", + data={"from": created_from, "runtimeStatus": "Completed"}) + assert result.status == 200 + assert result.json()["instancesDeleted"] >= 1 + + +def test_create_http_management_payload(v1_app): + instance_id = v1_app.start_orchestration("activity_chain") + v1_app.wait_for_completion(instance_id) + + result = http_request("GET", f"{v1_app.base_url}/api/mgmt_payload/{instance_id}") + assert result.status == 200 + body = result.json() + payload = body["payload"] + assert payload["id"] == instance_id + assert "statusQueryGetUri" in payload + assert "terminatePostUri" in payload + # get_client_response_links returns the same links. + assert body["links"] == payload + + +def test_wait_for_completion_or_check_status(v1_app): + instance_id = v1_app.start_orchestration("activity_chain") + v1_app.wait_for_completion(instance_id) + + # Already complete, so the call returns the output with a 200. + result = http_request("GET", f"{v1_app.base_url}/api/wait_or_check/{instance_id}") + assert result.status == 200 + assert result.json() == ["Hello Tokyo!", "Hello Seattle!", "Hello London!"] + + +def test_rewind(v1_app): + # The orchestration's activity fails on its first attempt, so it lands in a + # Failed state; rewinding replays the failed activity, which now succeeds. + instance_id = v1_app.start_orchestration("rewind_target") + failed = v1_app.wait_for_completion(instance_id) + assert failed["runtimeStatus"] == "Failed" + + result = http_request("POST", f"{v1_app.base_url}/api/rewind/{instance_id}") + assert result.status == 202 + + status = v1_app.wait_for_status(instance_id, "Completed") + assert status["runtimeStatus"] == "Completed" + assert status["output"] == "succeeded on attempt 2" diff --git a/tests/azure-functions-durable/e2e/test_v1_entities_e2e.py b/tests/azure-functions-durable/e2e/test_v1_entities_e2e.py new file mode 100644 index 00000000..2d78338a --- /dev/null +++ b/tests/azure-functions-durable/e2e/test_v1_entities_e2e.py @@ -0,0 +1,61 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""E2E tests for the V1-style entity surface. + +Covers the full ``DurableEntityContext`` API: state get/set/result, identity +(``entity_name`` / ``entity_key`` / ``operation_name`` / ``is_newly_constructed`` +via the ``describe`` operation), ``destruct_on_exit`` (delete), and signalling an +entity from within an orchestrator (``context.signal_entity``). +""" + +import time + +import pytest + +pytestmark = pytest.mark.functions_e2e + + +def test_entity_set_and_read(v1_app): + key = f"probe-{int(time.time() * 1000)}" + v1_app.signal_entity("probe", key, "set", input=42) + payload = v1_app.wait_for_entity( + "probe", key, lambda p: p["exists"] and p["state"] == 42) + assert payload["state"] == 42 + + +def test_entity_describe_via_orchestration(v1_app): + key = f"probe-{int(time.time() * 1000)}" + # Ensure the entity exists first. + v1_app.signal_entity("probe", key, "set", input=1) + v1_app.wait_for_entity("probe", key, lambda p: p["exists"]) + + instance_id = v1_app.start_orchestration("describe_entity", body=key) + status = v1_app.wait_for_completion(instance_id) + assert status["runtimeStatus"] == "Completed" + output = status["output"] + assert output["entity_name"] == "probe" + assert output["entity_key"] == key + assert output["operation_name"] == "describe" + assert output["is_newly_constructed"] is False + + +def test_entity_destruct_on_exit(v1_app): + key = f"probe-{int(time.time() * 1000)}" + v1_app.signal_entity("probe", key, "set", input=7) + v1_app.wait_for_entity("probe", key, lambda p: p["exists"] and p["state"] == 7) + + v1_app.signal_entity("probe", key, "delete") + payload = v1_app.wait_for_entity("probe", key, lambda p: not p["exists"]) + assert payload["exists"] is False + + +def test_signal_entity_from_orchestrator(v1_app): + key = f"orch-signal-{int(time.time() * 1000)}" + instance_id = v1_app.start_orchestration("signal_counter", body=key) + status = v1_app.wait_for_completion(instance_id) + assert status["runtimeStatus"] == "Completed" + + payload = v1_app.wait_for_entity( + "counter", key, lambda p: p["exists"] and p["state"] == 10) + assert payload["state"] == 10 diff --git a/tests/azure-functions-durable/e2e/test_v1_http_e2e.py b/tests/azure-functions-durable/e2e/test_v1_http_e2e.py new file mode 100644 index 00000000..dd356752 --- /dev/null +++ b/tests/azure-functions-durable/e2e/test_v1_http_e2e.py @@ -0,0 +1,34 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""E2E tests for the V1-only durable HTTP feature (``context.call_http``). + +Covers a request that returns content (POST + echo) and the non-2xx path (the +response is returned to the orchestrator rather than raising). The GET happy +path is covered in ``test_v1_style_e2e.py``. +""" + +import json + +import pytest + +pytestmark = pytest.mark.functions_e2e + + +def test_call_http_post_with_content(v1_app): + payload = {"url": f"{v1_app.base_url}/api/echo", "content": {"hello": "world"}} + instance_id = v1_app.start_orchestration("http_post", body=payload) + status = v1_app.wait_for_completion(instance_id) + assert status["runtimeStatus"] == "Completed" + output = status["output"] + assert output["status_code"] == 200 + assert json.loads(output["content"]) == {"hello": "world"} + + +def test_call_http_non_2xx_is_returned(v1_app): + instance_id = v1_app.start_orchestration("http_call", body=f"{v1_app.base_url}/api/fail") + status = v1_app.wait_for_completion(instance_id) + # A non-2xx response is returned to the orchestrator, not raised. + assert status["runtimeStatus"] == "Completed" + assert status["output"]["status_code"] == 500 + assert status["output"]["content"] == "nope" diff --git a/tests/azure-functions-durable/e2e/test_v1_patterns_e2e.py b/tests/azure-functions-durable/e2e/test_v1_patterns_e2e.py new file mode 100644 index 00000000..4ba2ae64 --- /dev/null +++ b/tests/azure-functions-durable/e2e/test_v1_patterns_e2e.py @@ -0,0 +1,65 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""E2E tests for the V1-style orchestration-context surface. + +Covers timers, ``task_any`` selection, deterministic IDs (``new_uuid`` / +``new_guid``), context properties (``is_replaying``, ``version``, +``parent_instance_id``, ``current_utc_datetime``, ``will_continue_as_new``, +``function_context``), and parent/child ``parent_instance_id`` propagation. +""" + +from uuid import UUID + +import pytest + +pytestmark = pytest.mark.functions_e2e + + +def test_timer(v1_app): + instance_id = v1_app.start_orchestration("timer_wait") + status = v1_app.wait_for_completion(instance_id) + assert status["runtimeStatus"] == "Completed" + assert status["output"] == "fired" + + +def test_task_any_event_wins(v1_app): + instance_id = v1_app.start_orchestration("event_or_timeout") + v1_app.raise_event(instance_id, "go", data="hello") + status = v1_app.wait_for_completion(instance_id) + assert status["runtimeStatus"] == "Completed" + assert status["output"] == {"result": "event", "data": "hello"} + + +def test_deterministic_ids(v1_app): + instance_id = v1_app.start_orchestration("deterministic_ids") + status = v1_app.wait_for_completion(instance_id) + assert status["runtimeStatus"] == "Completed" + output = status["output"] + # Both must be parseable UUID strings. + assert str(UUID(output["uuid"])) == output["uuid"] + assert str(UUID(output["guid"])) == output["guid"] + + +def test_context_properties(v1_app): + instance_id = v1_app.start_orchestration("context_properties") + status = v1_app.wait_for_completion(instance_id) + assert status["runtimeStatus"] == "Completed" + output = status["output"] + assert output["instance_id"] == instance_id + assert isinstance(output["is_replaying"], bool) + assert output["version"] is None + assert output["parent_instance_id"] is None + assert output["has_current_utc_datetime"] is True + assert output["will_continue_as_new"] is False + assert output["has_function_context"] is True + + +def test_parent_instance_id_propagation(v1_app): + instance_id = v1_app.start_orchestration("parent_with_child") + status = v1_app.wait_for_completion(instance_id) + assert status["runtimeStatus"] == "Completed" + output = status["output"] + # The child must observe its parent's instance ID. + assert output["parent_seen_by_child"] == output["my_instance"] + assert output["my_instance"] == instance_id diff --git a/tests/azure-functions-durable/e2e/test_v1_retries_e2e.py b/tests/azure-functions-durable/e2e/test_v1_retries_e2e.py new file mode 100644 index 00000000..41e73905 --- /dev/null +++ b/tests/azure-functions-durable/e2e/test_v1_retries_e2e.py @@ -0,0 +1,62 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""E2E tests for V1-style retries and failure propagation. + +Covers ``call_activity_with_retry`` (eventual success and exhausted retries), +``call_sub_orchestrator_with_retry`` (eventual success), activity-failure +propagation, sub-orchestration-failure propagation, and the documented +``histories`` NotImplementedError surfacing as a failed orchestration. +""" + +import pytest + +pytestmark = pytest.mark.functions_e2e + + +# --------------------------------------------------------------------------- +# Retries +# --------------------------------------------------------------------------- + +def test_activity_retry_eventual_success(v1_app): + instance_id = v1_app.start_orchestration("retry_then_succeed") + status = v1_app.wait_for_completion(instance_id) + assert status["runtimeStatus"] == "Completed" + # Succeeds on the 3rd attempt (threshold=3). + assert status["output"] == {"attempts": 3} + + +def test_activity_retry_exhausted_fails(v1_app): + instance_id = v1_app.start_orchestration("retry_exhausted") + status = v1_app.wait_for_completion(instance_id) + assert status["runtimeStatus"] == "Failed" + + +def test_sub_orchestrator_retry_eventual_success(v1_app): + instance_id = v1_app.start_orchestration("suborch_retry_then_succeed") + status = v1_app.wait_for_completion(instance_id) + assert status["runtimeStatus"] == "Completed" + # The sub-orchestration is retried; its activity succeeds on attempt 2. + assert status["output"] == {"attempts": 2} + + +# --------------------------------------------------------------------------- +# Failure propagation +# --------------------------------------------------------------------------- + +def test_activity_failure_fails_orchestration(v1_app): + instance_id = v1_app.start_orchestration("activity_fails") + status = v1_app.wait_for_completion(instance_id) + assert status["runtimeStatus"] == "Failed" + + +def test_sub_orchestration_failure_propagates(v1_app): + instance_id = v1_app.start_orchestration("sub_orch_fails") + status = v1_app.wait_for_completion(instance_id) + assert status["runtimeStatus"] == "Failed" + + +def test_histories_not_implemented_fails(v1_app): + instance_id = v1_app.start_orchestration("access_histories") + status = v1_app.wait_for_completion(instance_id) + assert status["runtimeStatus"] == "Failed" diff --git a/tests/azure-functions-durable/e2e/test_v1_style_e2e.py b/tests/azure-functions-durable/e2e/test_v1_style_e2e.py new file mode 100644 index 00000000..35831b5f --- /dev/null +++ b/tests/azure-functions-durable/e2e/test_v1_style_e2e.py @@ -0,0 +1,150 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""End-to-end tests for the V1-style Durable Functions sample app. + +These run against a real Functions host (see ``conftest.py``) and exercise the +classic v1 authoring surface plus the deprecated v1 client management APIs. +""" + +import time + +import pytest + +from ._harness import http_request + +pytestmark = pytest.mark.functions_e2e + + +def _wait_for_entity(app, name, key, predicate, timeout=30): + """Poll the entity read route until ``predicate(payload)`` is true.""" + deadline = time.time() + timeout + payload = None + while time.time() < deadline: + result = http_request("GET", f"{app.base_url}/api/entity/{name}/{key}") + assert result.status == 200, f"entity read failed: {result.status} {result.body}" + payload = result.json() + if predicate(payload): + return payload + time.sleep(0.5) + raise TimeoutError(f"entity {name}/{key} predicate not met; last: {payload}") + + +# --------------------------------------------------------------------------- +# Orchestration patterns +# --------------------------------------------------------------------------- + +def test_activity_chaining(v1_app): + instance_id = v1_app.start_orchestration("activity_chain") + status = v1_app.wait_for_completion(instance_id) + assert status["runtimeStatus"] == "Completed" + assert status["output"] == ["Hello Tokyo!", "Hello Seattle!", "Hello London!"] + + +def test_fan_out_fan_in(v1_app): + instance_id = v1_app.start_orchestration("fan_out_fan_in", body=4) + status = v1_app.wait_for_completion(instance_id) + assert status["runtimeStatus"] == "Completed" + # 1 + 4 + 9 + 16 + assert status["output"] == 30 + + +def test_sub_orchestration(v1_app): + instance_id = v1_app.start_orchestration("sub_orchestration_parent") + status = v1_app.wait_for_completion(instance_id) + assert status["runtimeStatus"] == "Completed" + assert status["output"] == { + "from_child": ["Hello Tokyo!", "Hello Seattle!", "Hello London!"]} + + +def test_continue_as_new(v1_app): + instance_id = v1_app.start_orchestration("continue_as_new_counter", body=0) + status = v1_app.wait_for_completion(instance_id) + assert status["runtimeStatus"] == "Completed" + assert status["output"] == 5 + + +# --------------------------------------------------------------------------- +# External events + custom status +# --------------------------------------------------------------------------- + +def test_external_event_and_custom_status(v1_app): + instance_id = v1_app.start_orchestration("wait_for_approval") + + # Raise the awaited event (buffered by the runtime if not yet subscribed). + result = http_request( + "POST", f"{v1_app.base_url}/api/raise/{instance_id}/approval", data={"data": True}) + assert result.status == 202 + + status = v1_app.wait_for_completion(instance_id) + assert status["runtimeStatus"] == "Completed" + assert status["output"] == {"approved": True} + assert status.get("customStatus") == "received" + + +# --------------------------------------------------------------------------- +# Entities +# --------------------------------------------------------------------------- + +def test_entity_via_orchestration(v1_app): + instance_id = v1_app.start_orchestration("counter_orchestration") + status = v1_app.wait_for_completion(instance_id) + assert status["runtimeStatus"] == "Completed" + assert status["output"] == 8 + + +def test_entity_via_client_signal_and_read(v1_app): + key = f"client-{int(time.time() * 1000)}" + + result = http_request( + "POST", f"{v1_app.base_url}/api/signal/counter/{key}/add", data={"input": 7}) + assert result.status == 202 + + payload = _wait_for_entity( + v1_app, "counter", key, lambda p: p["exists"] and p["state"] == 7) + assert payload["state"] == 7 + + +# --------------------------------------------------------------------------- +# Durable HTTP (V1-only feature) +# --------------------------------------------------------------------------- + +def test_call_http(v1_app): + instance_id = v1_app.start_orchestration("http_call", body=f"{v1_app.base_url}/api/ping") + status = v1_app.wait_for_completion(instance_id) + assert status["runtimeStatus"] == "Completed" + assert status["output"]["status_code"] == 200 + assert status["output"]["content"] == "pong" + + +# --------------------------------------------------------------------------- +# Client management surface +# --------------------------------------------------------------------------- + +def test_check_status_response_shape(v1_app): + result = http_request("POST", f"{v1_app.base_url}/api/start/activity_chain", data={"input": None}) + assert result.status == 202 + payload = result.json() + assert "id" in payload + assert "statusQueryGetUri" in payload + assert "terminatePostUri" in payload + + +def test_terminate(v1_app): + # Start a long-waiting orchestration, then terminate it. + instance_id = v1_app.start_orchestration("wait_for_approval") + + result = http_request("POST", f"{v1_app.base_url}/api/terminate/{instance_id}") + assert result.status == 202 + + status = v1_app.wait_for_completion(instance_id) + assert status["runtimeStatus"] == "Terminated" + + +def test_purge(v1_app): + instance_id = v1_app.start_orchestration("activity_chain") + v1_app.wait_for_completion(instance_id) + + result = http_request("POST", f"{v1_app.base_url}/api/purge/{instance_id}") + assert result.status == 200 + assert result.json()["instancesDeleted"] == 1 diff --git a/tests/azure-functions-durable/test_client_compat.py b/tests/azure-functions-durable/test_client_compat.py new file mode 100644 index 00000000..31793a79 --- /dev/null +++ b/tests/azure-functions-durable/test_client_compat.py @@ -0,0 +1,579 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +import json +from datetime import datetime, timedelta, timezone +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +import azure.functions as func +import pytest + +import azure.durable_functions as df +from azure.durable_functions import RetryOptions +from azure.durable_functions.internal.compat.orchestration_runtime_status import ( + from_durabletask_status, + to_durabletask_status, + to_durabletask_statuses, +) +from durabletask.client import AsyncTaskHubGrpcClient, OrchestrationStatus +from durabletask.entities import EntityInstanceId +from durabletask.task import RetryPolicy + + +_CLIENT_CONFIG = json.dumps({ + "taskHubName": "TestHub", + "requiredQueryStringParameters": "code=xyz", + "baseUrl": "http://localhost:7071/runtime/webhooks/durabletask", + "rpcBaseUrl": "http://localhost:8080/", + "managementUrls": {"id": "INSTANCEID"}, +}) + + +def _make_client() -> df.DurableFunctionsClient: + return df.DurableFunctionsClient(_CLIENT_CONFIG) + + +# --------------------------------------------------------------------------- +# RetryOptions shim +# --------------------------------------------------------------------------- + +def test_retry_options_is_retry_policy_subclass(): + assert issubclass(RetryOptions, RetryPolicy) + + +def test_retry_options_maps_milliseconds_to_timedelta(): + with pytest.warns(DeprecationWarning): + options = RetryOptions( + first_retry_interval_in_milliseconds=1500, + max_number_of_attempts=3) + assert options.first_retry_interval == timedelta(milliseconds=1500) + assert options.max_number_of_attempts == 3 + assert options.first_retry_interval_in_milliseconds == 1500 + + +def test_retry_options_rejects_non_positive_interval(): + with pytest.warns(DeprecationWarning): + with pytest.raises(ValueError): + RetryOptions( + first_retry_interval_in_milliseconds=0, + max_number_of_attempts=3) + + +def test_retry_policy_is_exported(): + assert df.RetryPolicy is RetryPolicy + + +# --------------------------------------------------------------------------- +# create_http_management_payload signature compatibility +# --------------------------------------------------------------------------- + +async def test_create_http_management_payload_v1_signature(): + client = _make_client() + try: + payload = client.create_http_management_payload("inst1") + assert payload.urls["id"] == "inst1" + assert payload.urls["statusQueryGetUri"] == ( + "http://localhost:7071/runtime/webhooks/durabletask/instances/inst1?code=xyz") + finally: + await client.close() + + +async def test_create_http_management_payload_v2_signature(): + client = _make_client() + try: + request = func.HttpRequest( + method="POST", url="http://localhost:7071/api/start", body=b"") + payload = client.create_http_management_payload(request, "inst2") + assert payload.urls["id"] == "inst2" + assert payload.urls["statusQueryGetUri"] == ( + "http://localhost:7071/runtime/webhooks/durabletask/instances/inst2?code=xyz") + finally: + await client.close() + + +async def test_create_http_management_payload_requires_instance_id(): + client = _make_client() + try: + with pytest.raises(TypeError): + client.create_http_management_payload() + finally: + await client.close() + + +# --------------------------------------------------------------------------- +# Deprecated client method aliases +# --------------------------------------------------------------------------- + +async def test_start_new_delegates_to_schedule_new_orchestration(): + client = _make_client() + try: + with patch.object(client, "schedule_new_orchestration", + new=AsyncMock(return_value="new-id")) as mock: + with pytest.warns(DeprecationWarning): + result = await client.start_new( + "MyOrchestrator", instance_id="abc", client_input={"x": 1}) + assert result == "new-id" + mock.assert_awaited_once_with( + "MyOrchestrator", input={"x": 1}, instance_id="abc", version=None) + finally: + await client.close() + + +async def test_get_status_delegates_to_get_orchestration_state(): + client = _make_client() + try: + with patch.object(client, "get_orchestration_state", + new=AsyncMock(return_value=None)) as mock: + with pytest.warns(DeprecationWarning): + await client.get_status("abc", show_input=True) + mock.assert_awaited_once_with("abc", fetch_payloads=True) + finally: + await client.close() + + +async def test_get_status_all_delegates(): + client = _make_client() + try: + with patch.object(client, "get_all_orchestration_states", + new=AsyncMock(return_value=[])) as mock: + with pytest.warns(DeprecationWarning): + await client.get_status_all() + mock.assert_awaited_once_with() + finally: + await client.close() + + +async def test_raise_event_delegates(): + client = _make_client() + try: + with patch.object(client, "raise_orchestration_event", + new=AsyncMock()) as mock: + with pytest.warns(DeprecationWarning): + await client.raise_event("abc", "evt", event_data={"k": "v"}) + mock.assert_awaited_once_with("abc", "evt", data={"k": "v"}) + finally: + await client.close() + + +async def test_terminate_delegates(): + client = _make_client() + try: + with patch.object(client, "terminate_orchestration", + new=AsyncMock()) as mock: + with pytest.warns(DeprecationWarning): + await client.terminate("abc", "because") + mock.assert_awaited_once_with("abc", output="because") + finally: + await client.close() + + +async def test_purge_instance_history_delegates(): + client = _make_client() + try: + with patch.object(client, "purge_orchestration", + new=AsyncMock()) as mock: + with pytest.warns(DeprecationWarning): + await client.purge_instance_history("abc") + mock.assert_awaited_once_with("abc") + finally: + await client.close() + + +async def test_suspend_resume_delegate(): + client = _make_client() + try: + with patch.object(client, "suspend_orchestration", + new=AsyncMock()) as suspend_mock: + with pytest.warns(DeprecationWarning): + await client.suspend("abc", "reason") + suspend_mock.assert_awaited_once_with("abc") + + with patch.object(client, "resume_orchestration", + new=AsyncMock()) as resume_mock: + with pytest.warns(DeprecationWarning): + await client.resume("abc", "reason") + resume_mock.assert_awaited_once_with("abc") + finally: + await client.close() + + +async def test_restart_delegates(): + client = _make_client() + try: + with patch.object(client, "restart_orchestration", + new=AsyncMock(return_value="abc")) as mock: + with pytest.warns(DeprecationWarning): + await client.restart("abc") + mock.assert_awaited_once_with("abc", restart_with_new_instance_id=True) + finally: + await client.close() + + +async def test_read_entity_state_delegates_to_get_entity(): + client = _make_client() + try: + with patch.object(client, "get_entity", + new=AsyncMock(return_value=None)) as mock: + with pytest.warns(DeprecationWarning): + await client.read_entity_state("@counter@one") + mock.assert_awaited_once_with("@counter@one") + finally: + await client.close() + + +# --------------------------------------------------------------------------- +# OrchestrationRuntimeStatus mapping +# --------------------------------------------------------------------------- + +def test_orchestration_runtime_status_is_exported(): + assert df.OrchestrationRuntimeStatus.Running.value == "Running" + + +def test_to_durabletask_status_maps_known_values(): + assert to_durabletask_status( + df.OrchestrationRuntimeStatus.Running) == OrchestrationStatus.RUNNING + assert to_durabletask_status( + df.OrchestrationRuntimeStatus.ContinuedAsNew) == OrchestrationStatus.CONTINUED_AS_NEW + + +def test_to_durabletask_status_rejects_canceled(): + with pytest.raises(ValueError): + to_durabletask_status(df.OrchestrationRuntimeStatus.Canceled) + + +def test_to_durabletask_statuses_preserves_none(): + assert to_durabletask_statuses(None) is None + assert to_durabletask_statuses( + [df.OrchestrationRuntimeStatus.Failed]) == [OrchestrationStatus.FAILED] + + +async def test_get_status_by_maps_statuses(): + client = _make_client() + try: + with patch.object(client, "get_all_orchestration_states", + new=AsyncMock(return_value=[])) as mock: + with pytest.warns(DeprecationWarning): + await client.get_status_by( + runtime_status=[df.OrchestrationRuntimeStatus.Running]) + query = mock.await_args.args[0] + assert query.runtime_status == [OrchestrationStatus.RUNNING] + finally: + await client.close() + + +async def test_purge_instance_history_by_maps_statuses(): + client = _make_client() + try: + with patch.object(client, "purge_orchestrations_by", + new=AsyncMock()) as mock: + with pytest.warns(DeprecationWarning): + await client.purge_instance_history_by( + runtime_status=[df.OrchestrationRuntimeStatus.Completed]) + assert mock.await_args.kwargs["runtime_status"] == [OrchestrationStatus.COMPLETED] + finally: + await client.close() + + +# --------------------------------------------------------------------------- +# signal_entity v1 keyword compatibility +# --------------------------------------------------------------------------- + +async def test_signal_entity_accepts_operation_input(): + client = _make_client() + try: + with patch.object(AsyncTaskHubGrpcClient, "signal_entity", + new=AsyncMock()) as mock: + await client.signal_entity( + "@counter@one", "add", operation_input=5, task_hub_name="hub") + mock.assert_awaited_once_with( + "@counter@one", "add", input=5, signal_time=None) + finally: + await client.close() + + +# --------------------------------------------------------------------------- +# wait_for_completion_or_create_check_status_response +# --------------------------------------------------------------------------- + +def _make_request() -> func.HttpRequest: + return func.HttpRequest( + method="GET", url="http://localhost:7071/api/status", body=b"") + + +async def test_wait_for_completion_returns_output_when_completed(): + client = _make_client() + try: + state = SimpleNamespace( + runtime_status=OrchestrationStatus.COMPLETED, + serialized_output='"done"') + with patch.object(client, "wait_for_orchestration_completion", + new=AsyncMock(return_value=state)): + with pytest.warns(DeprecationWarning): + response = await client.wait_for_completion_or_create_check_status_response( + _make_request(), "abc") + assert response.status_code == 200 + assert response.get_body() == b'"done"' + finally: + await client.close() + + +async def test_wait_for_completion_returns_check_status_on_timeout(): + client = _make_client() + try: + with patch.object(client, "wait_for_orchestration_completion", + new=AsyncMock(side_effect=TimeoutError)): + with pytest.warns(DeprecationWarning): + response = await client.wait_for_completion_or_create_check_status_response( + _make_request(), "abc") + assert response.status_code == 202 + finally: + await client.close() + + +# --------------------------------------------------------------------------- +# rewind +# --------------------------------------------------------------------------- + +async def test_rewind_delegates_to_rewind_orchestration(): + client = _make_client() + try: + with patch.object(client, "rewind_orchestration", + new=AsyncMock()) as mock: + with pytest.warns(DeprecationWarning): + await client.rewind("abc", "reason") + mock.assert_awaited_once_with("abc", reason="reason") + finally: + await client.close() + + +# --------------------------------------------------------------------------- +# get_client_response_links +# --------------------------------------------------------------------------- + +async def test_get_client_response_links_delegates(): + client = _make_client() + try: + with pytest.warns(DeprecationWarning): + payload = client.get_client_response_links(None, "abc") + assert payload.urls["id"] == "abc" + finally: + await client.close() + + +# --------------------------------------------------------------------------- +# Exported class aliases +# --------------------------------------------------------------------------- + +def test_durable_orchestration_client_is_subclass(): + assert issubclass(df.DurableOrchestrationClient, df.DurableFunctionsClient) + + +def test_entity_id_maps_to_entity_instance_id(): + with pytest.warns(DeprecationWarning): + entity_id = df.EntityId("Counter", "one") + assert isinstance(entity_id, EntityInstanceId) + assert entity_id.name == "counter" + assert str(entity_id) == "@counter@one" + + +def test_managed_identity_token_source_shim(): + source = df.ManagedIdentityTokenSource("https://management.core.windows.net") + assert source.resource == "https://management.core.windows.net" + assert source.to_json()["kind"] == "AzureManagedIdentity" + + +def test_entity_class_raises_not_implemented(): + with pytest.warns(DeprecationWarning): + with pytest.raises(NotImplementedError): + df.Entity(lambda ctx: None) + + +# --------------------------------------------------------------------------- +# Return-type shims: DurableOrchestrationStatus +# --------------------------------------------------------------------------- + +def _fake_state(): + return SimpleNamespace( + name="orch", + instance_id="abc", + created_at=datetime(2026, 1, 1, tzinfo=timezone.utc), + last_updated_at=datetime(2026, 1, 2, tzinfo=timezone.utc), + runtime_status=OrchestrationStatus.RUNNING, + serialized_input='{"in": 1}', + serialized_output='{"out": 2}', + serialized_custom_status='"cs"', + get_input=lambda: {"in": 1}, + get_output=lambda: {"out": 2}, + get_custom_status=lambda: "cs", + ) + + +def test_from_durabletask_status_reverse_mapping(): + assert from_durabletask_status(OrchestrationStatus.RUNNING) == df.OrchestrationRuntimeStatus.Running + assert from_durabletask_status( + OrchestrationStatus.CONTINUED_AS_NEW) == df.OrchestrationRuntimeStatus.ContinuedAsNew + + +async def test_get_status_returns_wrapped_status(): + client = _make_client() + try: + with patch.object(client, "get_orchestration_state", + new=AsyncMock(return_value=_fake_state())): + with pytest.warns(DeprecationWarning): + status = await client.get_status("abc") + assert bool(status) is True + assert status.name == "orch" + assert status.instance_id == "abc" + assert status.runtime_status == df.OrchestrationRuntimeStatus.Running + assert status.input_ == {"in": 1} + assert status.output == {"out": 2} + assert status.custom_status == "cs" + assert status.to_json()["runtimeStatus"] == "Running" + finally: + await client.close() + + +async def test_get_status_missing_instance_is_falsy(): + client = _make_client() + try: + with patch.object(client, "get_orchestration_state", + new=AsyncMock(return_value=None)): + with pytest.warns(DeprecationWarning): + status = await client.get_status("missing") + assert bool(status) is False + assert status.runtime_status is None + assert status.output is None + finally: + await client.close() + + +async def test_get_status_all_returns_wrapped_list(): + client = _make_client() + try: + with patch.object(client, "get_all_orchestration_states", + new=AsyncMock(return_value=[_fake_state()])): + with pytest.warns(DeprecationWarning): + statuses = await client.get_status_all() + assert len(statuses) == 1 + assert statuses[0].runtime_status == df.OrchestrationRuntimeStatus.Running + finally: + await client.close() + + +async def test_get_status_by_returns_wrapped_list(): + client = _make_client() + try: + with patch.object(client, "get_all_orchestration_states", + new=AsyncMock(return_value=[_fake_state()])): + with pytest.warns(DeprecationWarning): + statuses = await client.get_status_by( + runtime_status=[df.OrchestrationRuntimeStatus.Running]) + assert statuses[0].instance_id == "abc" + finally: + await client.close() + + +# --------------------------------------------------------------------------- +# Return-type shims: PurgeHistoryResult +# --------------------------------------------------------------------------- + +async def test_purge_instance_history_returns_purge_history_result(): + client = _make_client() + try: + result = SimpleNamespace(deleted_instance_count=3, is_complete=True) + with patch.object(client, "purge_orchestration", + new=AsyncMock(return_value=result)): + with pytest.warns(DeprecationWarning): + purge = await client.purge_instance_history("abc") + assert purge.instances_deleted == 3 + finally: + await client.close() + + +async def test_purge_instance_history_by_returns_purge_history_result(): + client = _make_client() + try: + result = SimpleNamespace(deleted_instance_count=5, is_complete=True) + with patch.object(client, "purge_orchestrations_by", + new=AsyncMock(return_value=result)): + with pytest.warns(DeprecationWarning): + purge = await client.purge_instance_history_by( + runtime_status=[df.OrchestrationRuntimeStatus.Completed]) + assert purge.instances_deleted == 5 + finally: + await client.close() + + +# --------------------------------------------------------------------------- +# Return-type shims: EntityStateResponse +# --------------------------------------------------------------------------- + +async def test_read_entity_state_wraps_metadata_when_present(): + client = _make_client() + try: + metadata = SimpleNamespace( + includes_state=True, get_typed_state=lambda: {"count": 5}) + with patch.object(client, "get_entity", + new=AsyncMock(return_value=metadata)): + with pytest.warns(DeprecationWarning): + response = await client.read_entity_state("@counter@one") + assert response.entity_exists is True + assert response.entity_state == {"count": 5} + finally: + await client.close() + + +async def test_read_entity_state_when_missing(): + client = _make_client() + try: + with patch.object(client, "get_entity", + new=AsyncMock(return_value=None)): + with pytest.warns(DeprecationWarning): + response = await client.read_entity_state("@counter@one") + assert response.entity_exists is False + assert response.entity_state is None + finally: + await client.close() + + +# --------------------------------------------------------------------------- +# HttpManagementPayload dict-like access +# --------------------------------------------------------------------------- + +async def test_http_management_payload_is_mapping_like(): + client = _make_client() + try: + payload = client.create_http_management_payload("inst1") + assert payload["id"] == "inst1" + assert "statusQueryGetUri" in payload + assert "id" in list(payload.keys()) + assert dict(payload.items())["id"] == "inst1" + finally: + await client.close() + + +# --------------------------------------------------------------------------- +# call_http +# --------------------------------------------------------------------------- + +def test_call_http_schedules_sub_orchestrator(): + from unittest.mock import MagicMock + + from azure.durable_functions.http.builtin import ( + BUILTIN_HTTP_POLL_ORCHESTRATOR_NAME, + ) + from azure.durable_functions.internal.compat.orchestration_context import ( + DurableOrchestrationContext, + ) + + fake_ctx = MagicMock() + adapter = DurableOrchestrationContext(fake_ctx) + adapter.call_http("GET", "http://example.com") + assert (fake_ctx.call_sub_orchestrator.call_args.args[0] + == BUILTIN_HTTP_POLL_ORCHESTRATOR_NAME) + + +def test_token_source_is_still_constructible(): + source = df.ManagedIdentityTokenSource("https://graph.microsoft.com") + assert source.resource == "https://graph.microsoft.com" diff --git a/tests/azure-functions-durable/test_decorator_compat.py b/tests/azure-functions-durable/test_decorator_compat.py new file mode 100644 index 00000000..06b41eac --- /dev/null +++ b/tests/azure-functions-durable/test_decorator_compat.py @@ -0,0 +1,245 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +import json + +import azure.durable_functions as df +from azure.durable_functions import DurableFunctionsClient +from azure.durable_functions.constants import ( + ACTIVITY_TRIGGER, + DURABLE_CLIENT, + ENTITY_TRIGGER, + ORCHESTRATION_TRIGGER, +) + + +_CLIENT_CONFIG = json.dumps({ + "taskHubName": "TestHub", + "requiredQueryStringParameters": "code=xyz", + "baseUrl": "http://localhost:7071/runtime/webhooks/durabletask", + "rpcBaseUrl": "http://localhost:8080/", +}) + + +def _trigger(fb): + return fb._function.get_trigger() + + +# --------------------------------------------------------------------------- +# orchestration_trigger +# --------------------------------------------------------------------------- + +def test_orchestration_trigger_v1_signature(): + app = df.DFApp() + + def my_orchestrator(context): + return 1 + + fb = app.orchestration_trigger( + context_name="context", orchestration="MyOrchestrator")(my_orchestrator) + trigger = _trigger(fb) + assert trigger.get_binding_name() == ORCHESTRATION_TRIGGER + assert trigger.name == "context" + assert trigger.orchestration == "MyOrchestrator" + + +def test_orchestration_trigger_accepts_input_type(): + app = df.DFApp() + + def my_orchestrator(context): + return 1 + + # v1 parity: the input_type keyword must be accepted and stashed. + fb = app.orchestration_trigger( + context_name="context", input_type=dict)(my_orchestrator) + assert fb is not None + assert my_orchestrator._df_input_type is dict + + +# --------------------------------------------------------------------------- +# activity_trigger +# --------------------------------------------------------------------------- + +def test_activity_trigger_v1_signature(): + app = df.DFApp() + + def my_activity(myinput): + return myinput + + fb = app.activity_trigger( + input_name="myinput", activity="MyActivity")(my_activity) + trigger = _trigger(fb) + assert trigger.get_binding_name() == ACTIVITY_TRIGGER + assert trigger.name == "myinput" + assert trigger.activity == "MyActivity" + + +# --------------------------------------------------------------------------- +# entity_trigger +# --------------------------------------------------------------------------- + +def test_entity_trigger_v1_signature(): + app = df.DFApp() + + def my_entity(context): + return None + + fb = app.entity_trigger( + context_name="context", entity_name="MyEntity")(my_entity) + trigger = _trigger(fb) + assert trigger.get_binding_name() == ENTITY_TRIGGER + assert trigger.name == "context" + assert trigger.entity_name == "MyEntity" + + +# --------------------------------------------------------------------------- +# durable_client_input +# --------------------------------------------------------------------------- + +def test_durable_client_input_v1_signature_registers_binding(): + app = df.DFApp() + + async def starter(client): + return None + + fb = app.durable_client_input( + client_name="client", task_hub="hub", connection_name="conn")(starter) + bindings = fb._function.get_bindings() + client_bindings = [b for b in bindings if b.get_binding_name() == DURABLE_CLIENT] + assert len(client_bindings) == 1 + binding = client_bindings[0] + assert binding.name == "client" + assert binding.task_hub == "hub" + assert binding.connection_name == "conn" + + +async def test_durable_client_input_injects_rich_client(): + app = df.DFApp() + received = {} + + async def starter(client): + received["client"] = client + + fb = app.durable_client_input(client_name="client")(starter) + # _add_rich_client replaces the user function with middleware that builds + # a DurableFunctionsClient from the binding's JSON string. + middleware = fb._function._func + await middleware(client=_CLIENT_CONFIG) + + client = received["client"] + assert isinstance(client, DurableFunctionsClient) + try: + assert client.taskHubName == "TestHub" + finally: + await client.close() + + +# --------------------------------------------------------------------------- +# All decorators register a function builder +# --------------------------------------------------------------------------- + +def test_decorators_register_function_builders(): + app = df.DFApp() + baseline = len(app._function_builders) + + def orch(context): + return 1 + + app.orchestration_trigger(context_name="context")(orch) + assert len(app._function_builders) == baseline + 1 + + +# --------------------------------------------------------------------------- +# Blueprint registration +# --------------------------------------------------------------------------- + +def _function_names(app): + return [fb._function.get_function_name() for fb in app._function_builders] + + +def test_register_functions_dedupes_builtin_http_functions(): + # Both the DFApp and every Blueprint auto-register the reserved built-in + # durable-HTTP functions. Registering a blueprint must not produce a + # duplicate-function-name conflict for those reserved names. + app = df.DFApp() + bp = df.Blueprint() + + @bp.activity_trigger(input_name="name") + def hello(name): + return name + + app.register_functions(bp) + + names = _function_names(app) + assert names.count("BuiltIn__HttpActivity") == 1 + assert names.count("BuiltIn__HttpPollOrchestrator") == 1 + assert "hello" in names + + +def test_register_blueprint_dedupes_builtin_http_functions(): + # register_blueprint is an alias of register_functions in the base class + # and must get the same built-in de-duplication. + app = df.DFApp() + bp = df.Blueprint() + + @bp.orchestration_trigger(context_name="context") + def orch(context): + return 1 + + app.register_blueprint(bp) + + names = _function_names(app) + assert names.count("BuiltIn__HttpActivity") == 1 + assert names.count("BuiltIn__HttpPollOrchestrator") == 1 + assert "orch" in names + + +def test_register_functions_is_non_destructive_to_blueprint(): + # The same blueprint may be registered into more than one app, so its own + # function builders (including the built-ins) must be left intact. + app1 = df.DFApp() + app2 = df.DFApp() + bp = df.Blueprint() + + @bp.activity_trigger(input_name="name") + def hello(name): + return name + + app1.register_functions(bp) + app2.register_functions(bp) + + bp_names = [fb._function.get_function_name() for fb in bp._function_builders] + assert "BuiltIn__HttpActivity" in bp_names + assert "BuiltIn__HttpPollOrchestrator" in bp_names + assert "hello" in bp_names + + for app in (app1, app2): + names = _function_names(app) + assert names.count("BuiltIn__HttpActivity") == 1 + assert names.count("BuiltIn__HttpPollOrchestrator") == 1 + assert "hello" in names + + +def test_register_multiple_blueprints_no_conflict(): + app = df.DFApp() + bp1 = df.Blueprint() + bp2 = df.Blueprint() + + @bp1.activity_trigger(input_name="name") + def hello(name): + return name + + @bp2.orchestration_trigger(context_name="context") + def orch(context): + return 1 + + app.register_functions(bp1) + app.register_functions(bp2) + + # Building the functions is what the host does at indexing time; it raises + # on duplicate names, so a successful build proves there is no conflict. + names = [fn.get_function_name() for fn in app.get_functions()] + assert names.count("BuiltIn__HttpActivity") == 1 + assert names.count("BuiltIn__HttpPollOrchestrator") == 1 + assert "hello" in names + assert "orch" in names diff --git a/tests/azure-functions-durable/test_durable_orchestration_status_compat.py b/tests/azure-functions-durable/test_durable_orchestration_status_compat.py new file mode 100644 index 00000000..f88b5b3e --- /dev/null +++ b/tests/azure-functions-durable/test_durable_orchestration_status_compat.py @@ -0,0 +1,108 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Unit tests for the ``DurableOrchestrationStatus`` compat wrapper. + +Focuses on the JSON (de)serialization surface used by the v1 client shims: +``from_json`` reconstruction, ``to_json`` emission of raw payloads, and the +falsy/empty behaviour for a non-existent instance. +""" + +from datetime import datetime, timezone + +import azure.durable_functions as df +from azure.durable_functions.internal.compat.durable_orchestration_status import ( + DurableOrchestrationStatus, +) + + +def _sample_json(): + return { + "name": "orch", + "instanceId": "abc", + "createdTime": "2026-01-01T00:00:00+00:00", + "lastUpdatedTime": "2026-01-02T00:00:00+00:00", + "input": {"in": 1}, + "output": {"out": 2}, + "customStatus": "cs", + "runtimeStatus": "Running", + } + + +# --------------------------------------------------------------------------- +# from_json +# --------------------------------------------------------------------------- + +def test_from_json_reconstructs_attribute_surface(): + status = DurableOrchestrationStatus.from_json(_sample_json()) + assert bool(status) is True + assert status.name == "orch" + assert status.instance_id == "abc" + assert status.created_time == datetime(2026, 1, 1, tzinfo=timezone.utc) + assert status.last_updated_time == datetime(2026, 1, 2, tzinfo=timezone.utc) + assert status.input_ == {"in": 1} + assert status.output == {"out": 2} + assert status.custom_status == "cs" + assert status.runtime_status == df.OrchestrationRuntimeStatus.Running + + +def test_from_json_accepts_json_string(): + import json + status = DurableOrchestrationStatus.from_json(json.dumps(_sample_json())) + assert status.instance_id == "abc" + assert status.runtime_status == df.OrchestrationRuntimeStatus.Running + + +def test_from_json_without_runtime_status_returns_none(): + # When the source JSON omits runtimeStatus, the wrapped state has no status; + # runtime_status must return None rather than raising. + status = DurableOrchestrationStatus.from_json({"instanceId": "abc"}) + assert status.instance_id == "abc" + assert status.runtime_status is None + assert "runtimeStatus" not in status.to_json() + + +def test_from_json_to_json_round_trip(): + original = _sample_json() + restored = DurableOrchestrationStatus.from_json(original).to_json() + assert restored == original + + +# --------------------------------------------------------------------------- +# Empty / non-existent instance +# --------------------------------------------------------------------------- + +def test_empty_status_is_falsy_with_none_attributes(): + status = DurableOrchestrationStatus(None) + assert bool(status) is False + assert status.name is None + assert status.instance_id is None + assert status.created_time is None + assert status.last_updated_time is None + assert status.input_ is None + assert status.output is None + assert status.custom_status is None + assert status.runtime_status is None + assert status.orchestration_state is None + assert status.to_json() == {} + + +def test_history_is_always_none(): + status = DurableOrchestrationStatus.from_json(_sample_json()) + assert status.history is None + + +# --------------------------------------------------------------------------- +# _raw_payload +# --------------------------------------------------------------------------- + +def test_raw_payload_parses_json(): + assert DurableOrchestrationStatus._raw_payload('{"a": 1}') == {"a": 1} + + +def test_raw_payload_returns_original_string_when_not_json(): + assert DurableOrchestrationStatus._raw_payload("not json") == "not json" + + +def test_raw_payload_none_returns_none(): + assert DurableOrchestrationStatus._raw_payload(None) is None diff --git a/tests/azure-functions-durable/test_entity_context_compat.py b/tests/azure-functions-durable/test_entity_context_compat.py new file mode 100644 index 00000000..807846cd --- /dev/null +++ b/tests/azure-functions-durable/test_entity_context_compat.py @@ -0,0 +1,128 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +from unittest.mock import MagicMock + +from durabletask.entities import DurableEntity + +from azure.durable_functions.internal.compat.entity_context import ( + DurableEntityContext, + wrap_entity, +) + + +def _adapter(operation_input=None): + fake_ctx = MagicMock() + fake_ctx.entity_id.entity = "counter" + fake_ctx.entity_id.key = "k1" + fake_ctx.operation = "add" + return DurableEntityContext(fake_ctx, operation_input), fake_ctx + + +# --------------------------------------------------------------------------- +# Adapter delegation +# --------------------------------------------------------------------------- + +def test_identity_properties(): + adapter, _ = _adapter() + assert adapter.entity_name == "counter" + assert adapter.entity_key == "k1" + assert adapter.operation_name == "add" + assert adapter.is_newly_constructed is False + + +def test_get_input_returns_stored_input(): + adapter, _ = _adapter(5) + assert adapter.get_input() == 5 + + +def test_get_state_maps_initializer_to_default(): + adapter, fake = _adapter() + fake.get_state.return_value = 0 + result = adapter.get_state(initializer=lambda: 0, expected_type=int) + assert result == 0 + fake.get_state.assert_called_once_with(int, 0) + + +def test_get_state_without_initializer(): + adapter, fake = _adapter() + adapter.get_state() + fake.get_state.assert_called_once_with(None, None) + + +def test_set_state_delegates(): + adapter, fake = _adapter() + adapter.set_state({"count": 3}) + fake.set_state.assert_called_once_with({"count": 3}) + + +def test_destruct_on_exit_clears_state(): + adapter, fake = _adapter() + adapter.destruct_on_exit() + fake.set_state.assert_called_once_with(None) + + +# --------------------------------------------------------------------------- +# wrap_entity +# --------------------------------------------------------------------------- + +def test_wrap_passes_through_two_arg_entity(): + def entity(ctx, inp): + return None + assert wrap_entity(entity) is entity + + +def test_wrap_passes_through_class_based_entity(): + class Counter(DurableEntity): + def add(self, amount): + return amount + assert wrap_entity(Counter) is Counter + + +def test_wrap_adapts_one_arg_entity_with_set_result(): + seen = {} + + def counter_entity(context): + seen["op"] = context.operation_name + seen["input"] = context.get_input() + current = context.get_state(initializer=lambda: 0) + context.set_state(current + context.get_input()) + context.set_result(current + context.get_input()) + + wrapped = wrap_entity(counter_entity) + assert wrapped is not counter_entity + + fake_ctx = MagicMock() + fake_ctx.entity_id.entity = "counter" + fake_ctx.entity_id.key = "k1" + fake_ctx.operation = "add" + fake_ctx.get_state.return_value = 10 + + result = wrapped(fake_ctx, 5) + assert result == 15 + assert seen["op"] == "add" + assert seen["input"] == 5 + fake_ctx.set_state.assert_called_once_with(15) + + +def test_wrap_adapts_one_arg_entity_falls_back_to_return_value(): + def entity(context): + return "returned" + + wrapped = wrap_entity(entity) + fake_ctx = MagicMock() + assert wrapped(fake_ctx, None) == "returned" + + +def test_wrap_preserves_entity_name(): + def my_entity(context): + return None + assert wrap_entity(my_entity).__name__ == "my_entity" + + +def test_wrap_preserves_durable_entity_name(): + def entity_fn(context): + return None + entity_fn.__durable_entity_name__ = "CustomName" + wrapped = wrap_entity(entity_fn) + assert wrapped.__durable_entity_name__ == "CustomName" diff --git a/tests/azure-functions-durable/test_http_builtin_compat.py b/tests/azure-functions-durable/test_http_builtin_compat.py new file mode 100644 index 00000000..54cab81c --- /dev/null +++ b/tests/azure-functions-durable/test_http_builtin_compat.py @@ -0,0 +1,227 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +from datetime import datetime, timedelta, timezone +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest + +from azure.durable_functions.http.builtin import ( + BUILTIN_HTTP_ACTIVITY_NAME, + BUILTIN_HTTP_POLL_ORCHESTRATOR_NAME, + builtin_http_activity, + builtin_http_poll_orchestrator, +) +from azure.durable_functions.http.models import ( + DurableHttpRequest, + DurableHttpResponse, +) +from azure.durable_functions.internal.compat.token_source import ( + ManagedIdentityTokenSource, +) + + +# --------------------------------------------------------------------------- +# Models +# --------------------------------------------------------------------------- + +def test_request_property_getters(): + token = ManagedIdentityTokenSource("https://management.core.windows.net/") + req = DurableHttpRequest( + "POST", "http://example.com", content="body", + headers={"h": "v"}, token_source=token) + assert req.method == "POST" + assert req.uri == "http://example.com" + assert req.content == "body" + assert req.headers == {"h": "v"} + assert req.token_source is token + + +def test_request_optional_getters_default_to_none(): + req = DurableHttpRequest("GET", "http://example.com") + assert req.content is None + assert req.headers is None + assert req.token_source is None + + +def test_request_to_json_minimal(): + req = DurableHttpRequest("GET", "http://example.com") + assert req.to_json() == {"method": "GET", "uri": "http://example.com"} + + +def test_request_to_json_full(): + token = ManagedIdentityTokenSource("https://management.core.windows.net/") + req = DurableHttpRequest( + "POST", "http://example.com", content='{"a": 1}', + headers={"h": "v"}, token_source=token) + assert req.to_json() == { + "method": "POST", + "uri": "http://example.com", + "content": '{"a": 1}', + "headers": {"h": "v"}, + "tokenSource": {"resource": "https://management.core.windows.net/", + "kind": "AzureManagedIdentity"}, + } + + +def test_response_round_trip(): + resp = DurableHttpResponse(200, {"h": "v"}, "body") + restored = DurableHttpResponse.from_json(resp.to_json()) + assert restored.status_code == 200 + assert restored.headers == {"h": "v"} + assert restored.content == "body" + + +def test_response_from_json_accepts_camel_case(): + restored = DurableHttpResponse.from_json({"statusCode": 404}) + assert restored.status_code == 404 + assert restored.headers == {} + assert restored.content is None + + +# --------------------------------------------------------------------------- +# Built-in activity +# --------------------------------------------------------------------------- + +def _fake_urlopen_response(status, headers, body): + resp = MagicMock() + resp.status = status + resp.headers.items.return_value = list(headers.items()) + resp.read.return_value = body.encode("utf-8") + resp.__enter__.return_value = resp + resp.__exit__.return_value = False + return resp + + +def test_activity_executes_request(): + fake_resp = _fake_urlopen_response(200, {"Content-Type": "application/json"}, "ok") + with patch("azure.durable_functions.http.builtin.urllib.request.urlopen", + return_value=fake_resp): + result = builtin_http_activity({"method": "GET", "uri": "http://example.com"}) + assert result["status_code"] == 200 + assert result["headers"] == {"Content-Type": "application/json"} + assert result["content"] == "ok" + + +def test_activity_requires_uri(): + with pytest.raises(ValueError): + builtin_http_activity({"method": "GET"}) + + +def test_activity_adds_bearer_token_for_token_source(): + fake_resp = _fake_urlopen_response(200, {}, "ok") + captured = {} + + def _capture(req): + captured["headers"] = dict(req.headers) + return fake_resp + + fake_credential = MagicMock() + fake_credential.get_token.return_value = SimpleNamespace(token="THE_TOKEN") + with patch("azure.durable_functions.http.builtin.urllib.request.urlopen", + side_effect=_capture), \ + patch("azure.identity.DefaultAzureCredential", + return_value=fake_credential): + builtin_http_activity({ + "method": "GET", + "uri": "http://example.com", + "tokenSource": {"resource": "https://management.core.windows.net/"}, + }) + + # urllib normalizes header keys to title-case. + assert captured["headers"]["Authorization"] == "Bearer THE_TOKEN" + fake_credential.get_token.assert_called_once_with( + "https://management.core.windows.net/.default") + + +# --------------------------------------------------------------------------- +# Built-in poll orchestrator +# --------------------------------------------------------------------------- + +def _fake_orchestration_context(request): + activity_calls = [] + + def call_activity(name, inp): + activity_calls.append((name, inp)) + return ("activity_task", len(activity_calls)) + + def create_timer(fire_at): + return ("timer", fire_at) + + ctx = SimpleNamespace( + get_input=lambda: request, + call_activity=call_activity, + create_timer=create_timer, + current_utc_datetime=datetime(2020, 1, 1, tzinfo=timezone.utc), + _activity_calls=activity_calls, + ) + return ctx + + +def test_poll_orchestrator_returns_non_202_immediately(): + ctx = _fake_orchestration_context({"method": "GET", "uri": "http://x"}) + gen = builtin_http_poll_orchestrator(ctx) + assert next(gen) == ("activity_task", 1) + with pytest.raises(StopIteration) as stop: + gen.send({"status_code": 200, "headers": {}, "content": "done"}) + assert stop.value.value["status_code"] == 200 + assert len(ctx._activity_calls) == 1 + + +def test_poll_orchestrator_polls_until_complete(): + request = {"method": "GET", "uri": "http://x", + "headers": {"h": "v"}, "tokenSource": {"resource": "r"}} + ctx = _fake_orchestration_context(request) + gen = builtin_http_poll_orchestrator(ctx) + + # First request yields the initial activity task. + assert next(gen) == ("activity_task", 1) + + # A 202 with a Location + Retry-After schedules a durable timer. + timer = gen.send({ + "status_code": 202, + "headers": {"Location": "http://poll", "Retry-After": "5"}, + "content": None, + }) + assert timer[0] == "timer" + assert timer[1] == ctx.current_utc_datetime + timedelta(seconds=5) + + # After the timer, the Location URL is polled via the activity. + assert gen.send(None) == ("activity_task", 2) + poll_name, poll_input = ctx._activity_calls[1] + assert poll_name == BUILTIN_HTTP_ACTIVITY_NAME + assert poll_input == { + "method": "GET", + "uri": "http://poll", + "headers": {"h": "v"}, + "tokenSource": {"resource": "r"}, + } + + # A final 200 completes the orchestration. + with pytest.raises(StopIteration) as stop: + gen.send({"status_code": 200, "headers": {}, "content": "done"}) + assert stop.value.value["content"] == "done" + + +def test_poll_orchestrator_stops_when_202_has_no_location(): + ctx = _fake_orchestration_context({"method": "GET", "uri": "http://x"}) + gen = builtin_http_poll_orchestrator(ctx) + next(gen) + with pytest.raises(StopIteration) as stop: + gen.send({"status_code": 202, "headers": {}, "content": None}) + assert stop.value.value["status_code"] == 202 + assert len(ctx._activity_calls) == 1 + + +# --------------------------------------------------------------------------- +# Registration +# --------------------------------------------------------------------------- + +def test_builtins_registered_under_reserved_names(): + import azure.durable_functions as df + + app = df.DFApp() + names = {f.get_function_name() for f in app.get_functions()} + assert BUILTIN_HTTP_ACTIVITY_NAME in names + assert BUILTIN_HTTP_POLL_ORCHESTRATOR_NAME in names diff --git a/tests/azure-functions-durable/test_internal_infra_compat.py b/tests/azure-functions-durable/test_internal_infra_compat.py new file mode 100644 index 00000000..d5e3de82 --- /dev/null +++ b/tests/azure-functions-durable/test_internal_infra_compat.py @@ -0,0 +1,73 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Unit tests for internal infrastructure shims. + +Covers the no-op sidecar stub used to satisfy the base worker's completion +callbacks, and the gRPC interceptors that inject the Durable Functions task-hub +and user-agent headers onto every client call. +""" + +from unittest.mock import patch + +from azure.durable_functions.internal.azurefunctions_grpc_interceptor import ( + AzureFunctionsAsyncDefaultClientInterceptorImpl, + AzureFunctionsDefaultClientInterceptorImpl, + _build_metadata, +) +from azure.durable_functions.internal.azurefunctions_null_stub import ( + AzureFunctionsNullStub, +) + + +# --------------------------------------------------------------------------- +# AzureFunctionsNullStub +# --------------------------------------------------------------------------- + +def test_null_stub_any_method_is_a_noop(): + stub = AzureFunctionsNullStub() + assert stub.CompleteOrchestratorTask("anything") is None + assert stub.CompleteEntityTask(1, 2, key="value") is None + # An arbitrary, never-defined attribute also resolves to a callable no-op. + assert stub.SomeMethodThatDoesNotExist() is None + + +def test_null_stub_returns_callable_for_any_attribute(): + stub = AzureFunctionsNullStub() + assert callable(stub.ArbitraryName) + + +# --------------------------------------------------------------------------- +# _build_metadata +# --------------------------------------------------------------------------- + +def test_build_metadata_contains_taskhub_and_user_agent(): + metadata = _build_metadata("myhub") + as_dict = dict(metadata) + assert as_dict["taskhub"] == "myhub" + assert as_dict["x-user-agent"].startswith("durabletask-python/") + + +def test_build_metadata_falls_back_when_version_lookup_fails(): + with patch( + "azure.durable_functions.internal.azurefunctions_grpc_interceptor.version", + side_effect=Exception("no package"), + ): + metadata = dict(_build_metadata("hub")) + assert metadata["x-user-agent"] == "durabletask-python/unknown" + + +# --------------------------------------------------------------------------- +# Interceptors +# --------------------------------------------------------------------------- + +def test_sync_interceptor_stores_metadata_and_query_params(): + interceptor = AzureFunctionsDefaultClientInterceptorImpl("hub", "code=abc") + assert interceptor.required_query_string_parameters == "code=abc" + assert dict(interceptor._metadata)["taskhub"] == "hub" + + +def test_async_interceptor_stores_metadata_and_query_params(): + interceptor = AzureFunctionsAsyncDefaultClientInterceptorImpl("hub", "code=xyz") + assert interceptor.required_query_string_parameters == "code=xyz" + assert dict(interceptor._metadata)["taskhub"] == "hub" diff --git a/tests/azure-functions-durable/test_orchestration_context_compat.py b/tests/azure-functions-durable/test_orchestration_context_compat.py new file mode 100644 index 00000000..2f4e815e --- /dev/null +++ b/tests/azure-functions-durable/test_orchestration_context_compat.py @@ -0,0 +1,262 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +from unittest.mock import MagicMock, patch +from uuid import UUID + +import pytest + +from azure.durable_functions.internal.compat.orchestration_context import ( + DurableOrchestrationContext, + accepts_two_positional_args, + wrap_orchestrator, +) + + +# --------------------------------------------------------------------------- +# Adapter delegation +# --------------------------------------------------------------------------- + +def _adapter(orchestration_input=None): + fake_ctx = MagicMock() + fake_ctx.instance_id = "iid" + fake_ctx.is_replaying = True + return DurableOrchestrationContext(fake_ctx, orchestration_input), fake_ctx + + +def test_get_input_returns_stored_input(): + adapter, _ = _adapter({"x": 1}) + assert adapter.get_input() == {"x": 1} + + +def test_property_delegation(): + adapter, fake = _adapter() + assert adapter.instance_id == "iid" + assert adapter.is_replaying is True + assert adapter.current_utc_datetime is fake.current_utc_datetime + + +def test_call_activity_delegates(): + adapter, fake = _adapter() + adapter.call_activity("A", input_=3) + fake.call_activity.assert_called_once_with("A", input=3) + + +def test_call_activity_with_retry_delegates(): + adapter, fake = _adapter() + retry = object() + adapter.call_activity_with_retry("A", retry, input_=4) + fake.call_activity.assert_called_once_with("A", input=4, retry_policy=retry) + + +def test_call_sub_orchestrator_delegates(): + adapter, fake = _adapter() + adapter.call_sub_orchestrator("Sub", input_=1, instance_id="sid") + fake.call_sub_orchestrator.assert_called_once_with("Sub", input=1, instance_id="sid") + + +def test_call_sub_orchestrator_with_retry_delegates(): + adapter, fake = _adapter() + retry = object() + adapter.call_sub_orchestrator_with_retry("Sub", retry, input_=1, instance_id="sid") + fake.call_sub_orchestrator.assert_called_once_with( + "Sub", input=1, instance_id="sid", retry_policy=retry) + + +def test_wait_for_external_event_maps_expected_type(): + adapter, fake = _adapter() + adapter.wait_for_external_event("evt", expected_type=str) + fake.wait_for_external_event.assert_called_once_with("evt", data_type=str) + + +def test_create_timer_delegates(): + adapter, fake = _adapter() + adapter.create_timer("fire_at") + fake.create_timer.assert_called_once_with("fire_at") + + +def test_continue_as_new_and_set_custom_status_delegate(): + adapter, fake = _adapter() + adapter.continue_as_new({"n": 1}) + fake.continue_as_new.assert_called_once_with({"n": 1}) + adapter.set_custom_status("status") + fake.set_custom_status.assert_called_once_with("status") + + +def test_entity_operations_delegate(): + adapter, fake = _adapter() + adapter.call_entity("@e@k", "op", 1) + fake.call_entity.assert_called_once_with("@e@k", "op", 1) + adapter.signal_entity("@e@k", "op", 2) + fake.signal_entity.assert_called_once_with("@e@k", "op", input=2) + + +def test_new_uuid_and_new_guid(): + adapter, fake = _adapter() + fake.new_uuid.return_value = "12345678-1234-5678-1234-567812345678" + assert adapter.new_uuid() == "12345678-1234-5678-1234-567812345678" + guid = adapter.new_guid() + assert isinstance(guid, UUID) + assert str(guid) == "12345678-1234-5678-1234-567812345678" + + +def test_task_all_and_task_any_use_when_helpers(): + adapter, _ = _adapter() + with patch("durabletask.task.when_all", return_value="ALL") as when_all, \ + patch("durabletask.task.when_any", return_value="ANY") as when_any: + assert adapter.task_all(["t1", "t2"]) == "ALL" + assert adapter.task_any(["t1", "t2"]) == "ANY" + when_all.assert_called_once_with(["t1", "t2"]) + when_any.assert_called_once_with(["t1", "t2"]) + + +def test_call_http_schedules_poll_sub_orchestrator(): + from azure.durable_functions.http.builtin import ( + BUILTIN_HTTP_POLL_ORCHESTRATOR_NAME, + ) + from azure.durable_functions.http.models import DurableHttpResponse + + adapter, fake = _adapter() + adapter.call_http("GET", "http://example.com") + + fake.call_sub_orchestrator.assert_called_once() + args, kwargs = fake.call_sub_orchestrator.call_args + assert args[0] == BUILTIN_HTTP_POLL_ORCHESTRATOR_NAME + assert kwargs["input"] == {"method": "GET", "uri": "http://example.com"} + assert kwargs["return_type"] is DurableHttpResponse + + +def test_call_http_serializes_content_and_token_source(): + from azure.durable_functions.internal.compat.token_source import ( + ManagedIdentityTokenSource, + ) + + adapter, fake = _adapter() + token = ManagedIdentityTokenSource("https://management.core.windows.net/") + adapter.call_http( + "POST", "http://example.com", + content={"a": 1}, headers={"h": "v"}, token_source=token) + + payload = fake.call_sub_orchestrator.call_args.kwargs["input"] + assert payload["method"] == "POST" + assert payload["content"] == '{"a": 1}' + assert payload["headers"] == {"h": "v"} + assert payload["tokenSource"]["resource"] == "https://management.core.windows.net/" + + +def test_call_http_raw_str_content_is_not_json_encoded(): + adapter, fake = _adapter() + adapter.call_http("POST", "http://example.com", content="raw", is_raw_str=True) + payload = fake.call_sub_orchestrator.call_args.kwargs["input"] + assert payload["content"] == "raw" + + +def test_call_http_is_raw_str_requires_str_content(): + adapter, _ = _adapter() + with pytest.raises(TypeError): + adapter.call_http("POST", "http://example.com", content={"a": 1}, is_raw_str=True) + + +# --------------------------------------------------------------------------- +# Additional context members +# --------------------------------------------------------------------------- + +def test_custom_status_tracks_set_custom_status(): + adapter, fake = _adapter() + assert adapter.custom_status is None + adapter.set_custom_status({"progress": 50}) + assert adapter.custom_status == {"progress": 50} + fake.set_custom_status.assert_called_once_with({"progress": 50}) + + +def test_will_continue_as_new_tracks_continue_as_new(): + adapter, fake = _adapter() + assert adapter.will_continue_as_new is False + adapter.continue_as_new({"next": 1}) + assert adapter.will_continue_as_new is True + fake.continue_as_new.assert_called_once_with({"next": 1}) + + +def test_parent_instance_id_delegates(): + adapter, fake = _adapter() + fake.parent_instance_id = "parent-123" + assert adapter.parent_instance_id == "parent-123" + + +def test_function_context_returns_empty_bag(): + from azure.durable_functions.internal.compat.function_context import FunctionContext + adapter, _ = _adapter() + fc = adapter.function_context + assert isinstance(fc, FunctionContext) + # Empty by default: no extra attributes, matching the common v1 case. + assert [a for a in vars(fc)] == [] + + +def test_histories_raises_not_implemented(): + adapter, _ = _adapter() + with pytest.raises(NotImplementedError): + _ = adapter.histories + + +# --------------------------------------------------------------------------- +# Arity detection and wrapping +# --------------------------------------------------------------------------- + +def test_accepts_two_positional_args(): + assert accepts_two_positional_args(lambda ctx, inp: None) is True + assert accepts_two_positional_args(lambda ctx: None) is False + assert accepts_two_positional_args(lambda *args: None) is True + + +def test_wrap_passes_through_two_arg_orchestrator(): + def orch(ctx, inp): + return None + assert wrap_orchestrator(orch) is orch + + +def test_wrap_adapts_one_arg_non_generator(): + seen = {} + + def orch(context): + seen["input"] = context.get_input() + return "done" + + wrapped = wrap_orchestrator(orch) + assert wrapped is not orch + fake_ctx = MagicMock() + result = wrapped(fake_ctx, 42) + assert result == "done" + assert seen["input"] == 42 + + +def test_wrap_adapts_one_arg_generator_end_to_end(): + seen = {} + + def orch(context): + seen["input"] = context.get_input() + activity_result = yield context.call_activity("A", input_=5) + seen["activity_result"] = activity_result + return activity_result * 2 + + wrapped = wrap_orchestrator(orch) + fake_ctx = MagicMock() + fake_ctx.call_activity.return_value = "SCHEDULED_TASK" + + gen = wrapped(fake_ctx, 7) + # First advance schedules the activity and yields the durabletask task. + yielded = next(gen) + assert yielded == "SCHEDULED_TASK" + fake_ctx.call_activity.assert_called_once_with("A", input=5) + assert seen["input"] == 7 + + # Feeding the activity result resumes the orchestrator to completion. + with pytest.raises(StopIteration) as stop: + gen.send(10) + assert stop.value.value == 20 + assert seen["activity_result"] == 10 + + +def test_wrap_preserves_orchestrator_name(): + def my_orchestrator(context): + return None + assert wrap_orchestrator(my_orchestrator).__name__ == "my_orchestrator" diff --git a/tests/azure-functions-durable/test_orchestrator_compat.py b/tests/azure-functions-durable/test_orchestrator_compat.py new file mode 100644 index 00000000..a3bc136b --- /dev/null +++ b/tests/azure-functions-durable/test_orchestrator_compat.py @@ -0,0 +1,66 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Unit tests for the ``Orchestrator`` handle wrapper. + +``Orchestrator`` is the thin adapter registered with the Azure Functions host: +its generated ``handle`` receives the host's transport context and delegates to +a fresh :class:`DurableFunctionsWorker` per invocation. +""" + +from unittest.mock import MagicMock, patch + +from azure.durable_functions.orchestrator import Orchestrator + + +def test_handle_delegates_to_worker(): + def user_orchestrator(context): + return "result" + + context = MagicMock() + with patch( + "azure.durable_functions.orchestrator.DurableFunctionsWorker" + ) as worker_cls: + worker_cls.return_value.execute_orchestration_request.return_value = "encoded" + result = Orchestrator(user_orchestrator).handle(context) + + assert result == "encoded" + worker_cls.return_value.execute_orchestration_request.assert_called_once_with( + user_orchestrator, context) + + +def test_handle_stores_durable_context(): + def user_orchestrator(context): + return None + + context = MagicMock() + orchestrator = Orchestrator(user_orchestrator) + with patch("azure.durable_functions.orchestrator.DurableFunctionsWorker"): + orchestrator.handle(context) + assert orchestrator.durable_context is context + + +def test_create_returns_callable_handle_exposing_original_fn(): + def user_orchestrator(context): + return None + + handle = Orchestrator.create(user_orchestrator) + assert callable(handle) + assert handle.orchestrator_function is user_orchestrator + + +def test_created_handle_delegates_to_worker(): + def user_orchestrator(context): + return None + + handle = Orchestrator.create(user_orchestrator) + context = MagicMock() + with patch( + "azure.durable_functions.orchestrator.DurableFunctionsWorker" + ) as worker_cls: + worker_cls.return_value.execute_orchestration_request.return_value = "encoded" + result = handle(context) + + assert result == "encoded" + worker_cls.return_value.execute_orchestration_request.assert_called_once_with( + user_orchestrator, context) diff --git a/tests/azure-functions-durable/test_serialization_compat.py b/tests/azure-functions-durable/test_serialization_compat.py new file mode 100644 index 00000000..a210a96f --- /dev/null +++ b/tests/azure-functions-durable/test_serialization_compat.py @@ -0,0 +1,84 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +import json + +import pytest + +from azure.durable_functions.internal.serialization import ( + DEFAULT_FUNCTIONS_DATA_CONVERTER, +) + + +class Point: + """Sample custom type using the v1 to_json / from_json convention.""" + + def __init__(self, x: int, y: int): + self.x = x + self.y = y + + def to_json(self): + return {"x": self.x, "y": self.y} + + @classmethod + def from_json(cls, data): + return cls(data["x"], data["y"]) + + def __eq__(self, other): + return isinstance(other, Point) and self.x == other.x and self.y == other.y + + +def test_custom_object_round_trips(): + point = Point(3, 4) + serialized = DEFAULT_FUNCTIONS_DATA_CONVERTER.serialize(point) + assert isinstance(serialized, str) + + restored = DEFAULT_FUNCTIONS_DATA_CONVERTER.deserialize(serialized) + assert isinstance(restored, Point) + assert restored == point + + +def test_nested_custom_object_round_trips(): + payload = {"points": [Point(1, 1), Point(2, 2)], "label": "path"} + serialized = DEFAULT_FUNCTIONS_DATA_CONVERTER.serialize(payload) + restored = DEFAULT_FUNCTIONS_DATA_CONVERTER.deserialize(serialized) + assert restored["label"] == "path" + assert restored["points"] == [Point(1, 1), Point(2, 2)] + + +@pytest.mark.parametrize("value", [ + {"a": 1, "b": [1, 2, 3]}, + [1, 2, 3], + "hello", + 42, + 3.14, + True, +]) +def test_builtin_values_round_trip(value): + serialized = DEFAULT_FUNCTIONS_DATA_CONVERTER.serialize(value) + assert isinstance(serialized, str) + restored = DEFAULT_FUNCTIONS_DATA_CONVERTER.deserialize(serialized) + assert restored == value + + +def test_none_round_trips(): + assert DEFAULT_FUNCTIONS_DATA_CONVERTER.serialize(None) is None + assert DEFAULT_FUNCTIONS_DATA_CONVERTER.deserialize(None) is None + + +def test_coerce_plain_dict_to_type(): + # get_input(expected_type=...) relies on the converter coercing a plain + # (already-deserialized) dict into the declared type. + coerced = DEFAULT_FUNCTIONS_DATA_CONVERTER.coerce({"x": 5, "y": 6}, Point) + assert coerced == Point(5, 6) + + +def test_deserialize_reconstructs_from_json_type_from_plain_dict(): + # A payload serialized as a plain JSON object (not the custom-object + # envelope) must still be reconstructed to a from_json-capable target type. + # This is the path behind ``call_http``, whose built-in poll orchestrator + # returns a plain dict that must arrive as a DurableHttpResponse. + serialized = json.dumps({"x": 7, "y": 8}) + result = DEFAULT_FUNCTIONS_DATA_CONVERTER.deserialize(serialized, Point) + assert isinstance(result, Point) + assert result == Point(7, 8) diff --git a/tests/azure-functions-durable/test_serialization_fallback_compat.py b/tests/azure-functions-durable/test_serialization_fallback_compat.py new file mode 100644 index 00000000..9f814690 --- /dev/null +++ b/tests/azure-functions-durable/test_serialization_fallback_compat.py @@ -0,0 +1,60 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Unit tests for the legacy serialization fallback path. + +When the installed ``azure-functions`` package predates the centralized +``df_dumps`` / ``df_loads`` serializers, the converter falls back to the legacy +``_serialize_custom_object`` / ``_deserialize_custom_object`` hooks. These tests +exercise the fallback callables directly (they are otherwise only selected at +import time based on the installed SDK version). +""" + +import azure.durable_functions.internal.serialization as serialization + + +class Point: + def __init__(self, x, y): + self.x = x + self.y = y + + def to_json(self): + return {"x": self.x, "y": self.y} + + @classmethod + def from_json(cls, data): + return cls(data["x"], data["y"]) + + def __eq__(self, other): + return isinstance(other, Point) and self.x == other.x and self.y == other.y + + +def test_fallback_dumps_and_loads_round_trip_builtin(): + serialized = serialization._fallback_df_dumps({"a": [1, 2, 3]}) + assert serialization._fallback_df_loads(serialized) == {"a": [1, 2, 3]} + + +def test_fallback_dumps_and_loads_round_trip_custom_object(): + serialized = serialization._fallback_df_dumps(Point(3, 4)) + restored = serialization._fallback_df_loads(serialized) + assert restored == Point(3, 4) + + +def test_fallback_loads_ignores_expected_type(): + serialized = serialization._fallback_df_dumps({"k": "v"}) + # expected_type is accepted for call-site compatibility but ignored. + assert serialization._fallback_df_loads(serialized, expected_type=dict) == {"k": "v"} + + +def test_warn_fallback_is_emitted_only_once(caplog): + # Reset the module-level guard so the warning path is exercised. + serialization._warned = False + try: + with caplog.at_level("DEBUG", logger="azure.functions.DurableFunctions"): + serialization._warn_fallback_once() + serialization._warn_fallback_once() + fallback_records = [ + r for r in caplog.records if "centralized" in r.getMessage()] + assert len(fallback_records) == 1 + finally: + serialization._warned = True diff --git a/tests/azure-functions-durable/test_smoke.py b/tests/azure-functions-durable/test_smoke.py new file mode 100644 index 00000000..1046663e --- /dev/null +++ b/tests/azure-functions-durable/test_smoke.py @@ -0,0 +1,20 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +import azure.durable_functions as df + + +def test_public_api_is_importable(): + """Smoke test: the package imports and exposes its public API. + + This is a no-op placeholder establishing the unit-test structure for the + azure-functions-durable module. Real unit tests should be added alongside + it; integration tests that require Azurite or the Azure Functions host + emulator should be marked (e.g. ``azurite``) so they can be excluded on + the network-isolated ADO build pool. + """ + assert df.version + assert df.DFApp is not None + assert df.Blueprint is not None + assert df.DurableFunctionsClient is not None + assert df.Orchestrator is not None diff --git a/tests/azure-functions-durable/test_worker_compat.py b/tests/azure-functions-durable/test_worker_compat.py new file mode 100644 index 00000000..b66103e1 --- /dev/null +++ b/tests/azure-functions-durable/test_worker_compat.py @@ -0,0 +1,199 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Unit tests for :class:`DurableFunctionsWorker`. + +The worker is the host-driven execution engine: it decodes the base64 protobuf +work item supplied by the Durable Functions host extension, registers the user +function, drives the inherited durabletask executor against an in-memory null +stub, and returns the base64-encoded protobuf response. These tests exercise +that path end-to-end without a sidecar or gRPC channel. +""" + +import base64 +import json +from types import SimpleNamespace + +import pytest + +import durabletask.internal.helpers as helpers +import durabletask.internal.orchestrator_service_pb2 as pb + +from azure.durable_functions.worker import DurableFunctionsWorker + +TEST_INSTANCE_ID = "inst-123" + + +def _encode_orchestrator_request(name, encoded_input=None, instance_id=TEST_INSTANCE_ID): + """Build a base64-encoded ``OrchestratorRequest`` for a single new dispatch.""" + request = pb.OrchestratorRequest(instanceId=instance_id) + request.newEvents.append(helpers.new_orchestrator_started_event()) + request.newEvents.append( + helpers.new_execution_started_event(name, instance_id, encoded_input=encoded_input)) + return base64.b64encode(request.SerializeToString()).decode("utf-8") + + +def _decode_orchestrator_response(encoded): + response = pb.OrchestratorResponse() + response.ParseFromString(base64.b64decode(encoded)) + return response + + +def _get_completion_action(response): + completion_actions = [a for a in response.actions if a.HasField("completeOrchestration")] + assert len(completion_actions) == 1 + return completion_actions[0].completeOrchestration + + +# --------------------------------------------------------------------------- +# execute_orchestration_request +# --------------------------------------------------------------------------- + +def test_execute_orchestration_request_completes_and_returns_output(): + def orchestrator(context): + return {"echo": context.get_input()} + + encoded = _encode_orchestrator_request("orch1", encoded_input=json.dumps({"n": 5})) + result = DurableFunctionsWorker().execute_orchestration_request(orchestrator, encoded) + + response = _decode_orchestrator_response(result) + completion = _get_completion_action(response) + assert completion.orchestrationStatus == pb.ORCHESTRATION_STATUS_COMPLETED + assert json.loads(completion.result.value) == {"echo": {"n": 5}} + + +def test_execute_orchestration_request_registers_under_event_name(): + """The orchestrator is registered under the name from the ExecutionStarted event.""" + def orchestrator(context): + return context.instance_id + + encoded = _encode_orchestrator_request("named-orch") + worker = DurableFunctionsWorker() + result = worker.execute_orchestration_request(orchestrator, encoded) + + assert "named-orch" in worker._registry.orchestrators + completion = _get_completion_action(_decode_orchestrator_response(result)) + assert json.loads(completion.result.value) == TEST_INSTANCE_ID + + +def test_execute_orchestration_request_accepts_context_with_body(): + """A transport context exposing ``.body`` is unwrapped before decoding.""" + def orchestrator(context): + return "ok" + + encoded = _encode_orchestrator_request("orch-body") + context = SimpleNamespace(body=encoded) + result = DurableFunctionsWorker().execute_orchestration_request(orchestrator, context) + + completion = _get_completion_action(_decode_orchestrator_response(result)) + assert json.loads(completion.result.value) == "ok" + + +def test_execute_orchestration_request_uses_last_execution_started_name(): + """When multiple ExecutionStarted events exist, the last one wins (continue-as-new).""" + def orchestrator(context): + return "done" + + request = pb.OrchestratorRequest(instanceId=TEST_INSTANCE_ID) + request.pastEvents.append(helpers.new_orchestrator_started_event()) + request.pastEvents.append( + helpers.new_execution_started_event("old-name", TEST_INSTANCE_ID)) + request.newEvents.append( + helpers.new_execution_started_event("current-name", TEST_INSTANCE_ID)) + encoded = base64.b64encode(request.SerializeToString()).decode("utf-8") + + worker = DurableFunctionsWorker() + worker.execute_orchestration_request(orchestrator, encoded) + assert "current-name" in worker._registry.orchestrators + + +def test_execute_orchestration_request_raises_without_execution_started(): + def orchestrator(context): + return None + + request = pb.OrchestratorRequest(instanceId=TEST_INSTANCE_ID) + request.newEvents.append(helpers.new_orchestrator_started_event()) + encoded = base64.b64encode(request.SerializeToString()).decode("utf-8") + + with pytest.raises(Exception, match="No ExecutionStarted event"): + DurableFunctionsWorker().execute_orchestration_request(orchestrator, encoded) + + +def test_execute_orchestration_request_captures_failure(): + def orchestrator(context): + raise ValueError("boom") + + encoded = _encode_orchestrator_request("failing-orch") + result = DurableFunctionsWorker().execute_orchestration_request(orchestrator, encoded) + + completion = _get_completion_action(_decode_orchestrator_response(result)) + assert completion.orchestrationStatus == pb.ORCHESTRATION_STATUS_FAILED + assert "boom" in completion.failureDetails.errorMessage + + +# --------------------------------------------------------------------------- +# execute_entity_batch_request +# --------------------------------------------------------------------------- + +def _encode_entity_batch_request(entity_id, operation, encoded_input=None, encoded_state=None): + request = pb.EntityBatchRequest(instanceId=entity_id) + if encoded_state is not None: + request.entityState.value = encoded_state + request.operations.append( + pb.OperationRequest( + requestId="req-1", + operation=operation, + input=helpers.get_string_value(encoded_input))) + return base64.b64encode(request.SerializeToString()).decode("utf-8") + + +def _decode_entity_response(encoded): + result = pb.EntityBatchResult() + result.ParseFromString(base64.b64decode(encoded)) + return result + + +def test_execute_entity_batch_request_runs_operation_and_updates_state(): + def counter(context): + current = context.get_state(initializer=lambda: 0) + new_value = current + context.get_input() + context.set_state(new_value) + context.set_result(new_value) + + counter.__name__ = "counter" + + encoded = _encode_entity_batch_request( + "@counter@key1", "add", encoded_input=json.dumps(5), encoded_state=json.dumps(10)) + result = DurableFunctionsWorker().execute_entity_batch_request(counter, encoded) + + response = _decode_entity_response(result) + assert len(response.results) == 1 + assert response.results[0].HasField("success") + assert json.loads(response.results[0].success.result.value) == 15 + assert json.loads(response.entityState.value) == 15 + + +def test_execute_entity_batch_request_accepts_context_with_body(): + def entity(context): + context.set_result("handled") + + entity.__name__ = "counter" + encoded = _encode_entity_batch_request("@counter@key1", "op") + context = SimpleNamespace(body=encoded) + result = DurableFunctionsWorker().execute_entity_batch_request(entity, context) + + response = _decode_entity_response(result) + assert json.loads(response.results[0].success.result.value) == "handled" + + +def test_execute_entity_batch_request_captures_operation_failure(): + def entity(context): + raise RuntimeError("entity failed") + + entity.__name__ = "counter" + encoded = _encode_entity_batch_request("@counter@key1", "op") + result = DurableFunctionsWorker().execute_entity_batch_request(entity, encoded) + + response = _decode_entity_response(result) + assert response.results[0].HasField("failure") + assert "entity failed" in response.results[0].failure.failureDetails.errorMessage