From 7ff1525d9c14001176b26a4e56af584b9391d920 Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Thu, 6 Nov 2025 10:01:56 -0700 Subject: [PATCH 01/45] Storing changes commit --- durabletask-azurefunctions/CHANGELOG.md | 10 + durabletask-azurefunctions/__init__.py | 0 .../durabletask/azurefunctions/__init__.py | 0 .../durabletask/azurefunctions/constants.py | 10 + .../azurefunctions/decorators/__init__.py | 11 + .../azurefunctions/decorators/durable_app.py | 193 ++++++++++++++++++ .../azurefunctions/decorators/metadata.py | 109 ++++++++++ .../internal/DurableClientConverter.py | 46 +++++ .../azurefunctions/internal/__init__.py | 3 + .../durabletask/azurefunctions/worker.py | 2 + durabletask-azurefunctions/pyproject.toml | 43 ++++ 11 files changed, 427 insertions(+) create mode 100644 durabletask-azurefunctions/CHANGELOG.md create mode 100644 durabletask-azurefunctions/__init__.py create mode 100644 durabletask-azurefunctions/durabletask/azurefunctions/__init__.py create mode 100644 durabletask-azurefunctions/durabletask/azurefunctions/constants.py create mode 100644 durabletask-azurefunctions/durabletask/azurefunctions/decorators/__init__.py create mode 100644 durabletask-azurefunctions/durabletask/azurefunctions/decorators/durable_app.py create mode 100644 durabletask-azurefunctions/durabletask/azurefunctions/decorators/metadata.py create mode 100644 durabletask-azurefunctions/durabletask/azurefunctions/internal/DurableClientConverter.py create mode 100644 durabletask-azurefunctions/durabletask/azurefunctions/internal/__init__.py create mode 100644 durabletask-azurefunctions/durabletask/azurefunctions/worker.py create mode 100644 durabletask-azurefunctions/pyproject.toml diff --git a/durabletask-azurefunctions/CHANGELOG.md b/durabletask-azurefunctions/CHANGELOG.md new file mode 100644 index 00000000..b9be1590 --- /dev/null +++ b/durabletask-azurefunctions/CHANGELOG.md @@ -0,0 +1,10 @@ +# 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). + +## v0.1.0 + +- Initial implementation diff --git a/durabletask-azurefunctions/__init__.py b/durabletask-azurefunctions/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/__init__.py b/durabletask-azurefunctions/durabletask/azurefunctions/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/constants.py b/durabletask-azurefunctions/durabletask/azurefunctions/constants.py new file mode 100644 index 00000000..78c9792b --- /dev/null +++ b/durabletask-azurefunctions/durabletask/azurefunctions/constants.py @@ -0,0 +1,10 @@ +"""Constants used to determine the local running context.""" +# Todo: Remove unused constants after module is complete +DEFAULT_LOCAL_HOST: str = 'localhost:7071' +DEFAULT_LOCAL_ORIGIN: str = f'http://{DEFAULT_LOCAL_HOST}' +DATETIME_STRING_FORMAT = '%Y-%m-%dT%H:%M:%S.%fZ' +HTTP_ACTION_NAME = 'BuiltIn::HttpActivity' +ORCHESTRATION_TRIGGER = "orchestrationTrigger" +ACTIVITY_TRIGGER = "activityTrigger" +ENTITY_TRIGGER = "entityTrigger" +DURABLE_CLIENT = "durableClient" diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/decorators/__init__.py b/durabletask-azurefunctions/durabletask/azurefunctions/decorators/__init__.py new file mode 100644 index 00000000..f3cfb910 --- /dev/null +++ b/durabletask-azurefunctions/durabletask/azurefunctions/decorators/__init__.py @@ -0,0 +1,11 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Durable Task SDK for Python entities component""" + +import durabletask.azurefunctions.decorators.durable_app as durable_app +import durabletask.azurefunctions.decorators.metadata as metadata + +__all__ = ["durable_app", "metadata"] + +PACKAGE_NAME = "durabletask.entities" diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/decorators/durable_app.py b/durabletask-azurefunctions/durabletask/azurefunctions/decorators/durable_app.py new file mode 100644 index 00000000..152f6d1f --- /dev/null +++ b/durabletask-azurefunctions/durabletask/azurefunctions/decorators/durable_app.py @@ -0,0 +1,193 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +from .metadata import OrchestrationTrigger, ActivityTrigger, EntityTrigger, \ + DurableClient +from typing import Callable, Optional +from typing import Union +from azure.functions import FunctionRegister, TriggerApi, BindingApi, AuthLevel, OrchestrationContext + + +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 + """ + super().__init__(auth_level=http_auth_level) + + def _configure_orchestrator_callable(self, wrap) -> Callable: + """Obtain decorator to construct an Orchestrator class from a user-defined Function. + + In the old programming model, this decorator's logic was unavoidable boilerplate + in user-code. Now, this is handled internally by the framework. + + Parameters + ---------- + wrap: Callable + The next decorator to be applied. + + 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): + # Construct an orchestrator based on the end-user code + + # TODO: Extract this logic (?) + def handle(context: OrchestrationContext) -> str: + context_body = getattr(context, "body", None) + if context_body is None: + context_body = context + orchestration_context = context_body + # TODO: Run the orchestration using the context + return "" + + handle.orchestrator_function = orchestrator_func + + # invoke next decorator, with the Orchestrator as input + handle.__name__ = orchestrator_func.__name__ + return wrap(handle) + + return decorator + + def orchestration_trigger(self, context_name: str, + orchestration: Optional[str] = None): + """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. + """ + @self._configure_orchestrator_callable + @self._configure_function_builder + def wrap(fb): + + def decorator(): + fb.add_trigger( + trigger=OrchestrationTrigger(name=context_name, + orchestration=orchestration)) + return fb + + return decorator() + + return wrap + + def activity_trigger(self, input_name: str, + activity: Optional[str] = None): + """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._configure_function_builder + def wrap(fb): + def decorator(): + 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): + """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_function_builder + def wrap(fb): + def decorator(): + 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 + ): + """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._configure_function_builder + def wrap(fb): + def decorator(): + # self._add_rich_client(fb, client_name, DurableOrchestrationClient) + + 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. + """ + + pass diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/decorators/metadata.py b/durabletask-azurefunctions/durabletask/azurefunctions/decorators/metadata.py new file mode 100644 index 00000000..4bf1d6c5 --- /dev/null +++ b/durabletask-azurefunctions/durabletask/azurefunctions/decorators/metadata.py @@ -0,0 +1,109 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +from typing import Optional + +from durabletask.azurefunctions.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, + ) -> None: + self.orchestration = orchestration + 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, + ) -> None: + self.activity = activity + 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, + ) -> None: + self.entity_name = entity_name + 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 + ) -> None: + self.task_hub = task_hub + self.connection_name = connection_name + super().__init__(name=name) diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/internal/DurableClientConverter.py b/durabletask-azurefunctions/durabletask/azurefunctions/internal/DurableClientConverter.py new file mode 100644 index 00000000..4286967a --- /dev/null +++ b/durabletask-azurefunctions/durabletask/azurefunctions/internal/DurableClientConverter.py @@ -0,0 +1,46 @@ +import abc +from typing import Any, Optional + +from azure.functions import meta + + +class DurableInConverter(meta._BaseConverter, binding=None): + + @classmethod + @abc.abstractmethod + def check_input_type_annotation(cls, pytype: type) -> bool: + pass + + @classmethod + @abc.abstractmethod + def decode(cls, data: meta.Datum, *, trigger_metadata) -> Any: + raise NotImplementedError + + @classmethod + @abc.abstractmethod + def has_implicit_output(cls) -> bool: + return False + + +class DurableOutConverter(meta._BaseConverter, binding=None): + + @classmethod + @abc.abstractmethod + def check_output_type_annotation(cls, pytype: type) -> bool: + pass + + @classmethod + @abc.abstractmethod + def encode(cls, obj: Any, *, + expected_type: Optional[type]) -> Optional[meta.Datum]: + raise NotImplementedError + +# Durable Functions Durable Client Bindings + + +class DurableClientConverter(DurableInConverter, + DurableOutConverter, + binding='durableClient'): + @classmethod + def has_implicit_output(cls) -> bool: + return False diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/internal/__init__.py b/durabletask-azurefunctions/durabletask/azurefunctions/internal/__init__.py new file mode 100644 index 00000000..d5823cf5 --- /dev/null +++ b/durabletask-azurefunctions/durabletask/azurefunctions/internal/__init__.py @@ -0,0 +1,3 @@ +from .DurableClientConverter import DurableClientConverter + +__all__ = ["DurableClientConverter"] diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/worker.py b/durabletask-azurefunctions/durabletask/azurefunctions/worker.py new file mode 100644 index 00000000..a176672e --- /dev/null +++ b/durabletask-azurefunctions/durabletask/azurefunctions/worker.py @@ -0,0 +1,2 @@ +class TempClass: + pass diff --git a/durabletask-azurefunctions/pyproject.toml b/durabletask-azurefunctions/pyproject.toml new file mode 100644 index 00000000..dfb02eb8 --- /dev/null +++ b/durabletask-azurefunctions/pyproject.toml @@ -0,0 +1,43 @@ +# 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 = "durabletask.azurefunctions" +version = "0.1.0" +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.9" +license = {file = "LICENSE"} +readme = "README.md" +dependencies = [ + "durabletask>=0.5.0", + "azure-identity>=1.19.0", + "azure-functions>=1.11.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 = ["durabletask.azurefunctions", "durabletask.azurefunctions.*"] + +[tool.pytest.ini_options] +minversion = "6.0" From 552a2ddbe1d02730dea17c2442e10caac9d7788f Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Fri, 21 Nov 2025 13:46:16 -0700 Subject: [PATCH 02/45] Working orchestrators + activities --- .../durabletask/azurefunctions/client.py | 85 +++++++++++++++++ .../durabletask/azurefunctions/constants.py | 2 +- .../azurefunctions/decorators/durable_app.py | 91 +++++++++++++++++-- .../internal/DurableClientConverter.py | 46 ---------- .../azurefunctions/internal/__init__.py | 3 - .../azurefunctions_grpc_interceptor.py | 27 ++++++ .../internal/azurefunctions_null_stub.py | 39 ++++++++ .../durabletask/azurefunctions/worker.py | 34 ++++++- .../ProtoTaskHubSidecarServiceStub.py | 35 +++++++ durabletask/worker.py | 6 +- 10 files changed, 306 insertions(+), 62 deletions(-) create mode 100644 durabletask-azurefunctions/durabletask/azurefunctions/client.py delete mode 100644 durabletask-azurefunctions/durabletask/azurefunctions/internal/DurableClientConverter.py create mode 100644 durabletask-azurefunctions/durabletask/azurefunctions/internal/azurefunctions_grpc_interceptor.py create mode 100644 durabletask-azurefunctions/durabletask/azurefunctions/internal/azurefunctions_null_stub.py create mode 100644 durabletask/internal/ProtoTaskHubSidecarServiceStub.py diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/client.py b/durabletask-azurefunctions/durabletask/azurefunctions/client.py new file mode 100644 index 00000000..63a267bc --- /dev/null +++ b/durabletask-azurefunctions/durabletask/azurefunctions/client.py @@ -0,0 +1,85 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +import json + +from datetime import timedelta +from typing import Any, Optional +import azure.functions as func + +from durabletask.entities import EntityInstanceId +from durabletask.client import TaskHubGrpcClient +from durabletask.azurefunctions.internal.azurefunctions_grpc_interceptor import AzureFunctionsDefaultClientInterceptorImpl + + +# Client class used for Durable Functions +class DurableFunctionsClient(TaskHubGrpcClient): + 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): + 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)) + interceptors = [AzureFunctionsDefaultClientInterceptorImpl(self.taskHubName, self.requiredQueryStringParameters)] + + # 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) + + 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. + """ + raise NotImplementedError("This method is not implemented yet.") + + def create_http_management_payload(self, instance_id: str) -> dict[str, str]: + """Creates an HTTP management payload for a Durable Function instance. + + Args: + instance_id (str): The ID of the Durable Function instance. + """ + raise NotImplementedError("This method is not implemented yet.") + + def read_entity_state( + self, + entity_id: EntityInstanceId, + task_hub_name: Optional[str], + connection_name: Optional[str] + ) -> tuple[bool, Any]: + """Reads the state of a Durable Entity. + + Args: + entity_id (str): The ID of the Durable Entity. + task_hub_name (Optional[str]): The name of the task hub. + connection_name (Optional[str]): The name of the connection. + + Returns: + (bool, Any): A tuple containing a boolean indicating if the entity exists and its state. + """ + raise NotImplementedError("This method is not implemented yet.") diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/constants.py b/durabletask-azurefunctions/durabletask/azurefunctions/constants.py index 78c9792b..652afcac 100644 --- a/durabletask-azurefunctions/durabletask/azurefunctions/constants.py +++ b/durabletask-azurefunctions/durabletask/azurefunctions/constants.py @@ -1,5 +1,5 @@ """Constants used to determine the local running context.""" -# Todo: Remove unused constants after module is complete +# TODO: Remove unused constants after module is complete DEFAULT_LOCAL_HOST: str = 'localhost:7071' DEFAULT_LOCAL_ORIGIN: str = f'http://{DEFAULT_LOCAL_HOST}' DATETIME_STRING_FORMAT = '%Y-%m-%dT%H:%M:%S.%fZ' diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/decorators/durable_app.py b/durabletask-azurefunctions/durabletask/azurefunctions/decorators/durable_app.py index 152f6d1f..59ccc017 100644 --- a/durabletask-azurefunctions/durabletask/azurefunctions/decorators/durable_app.py +++ b/durabletask-azurefunctions/durabletask/azurefunctions/decorators/durable_app.py @@ -1,10 +1,19 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. +import base64 +from functools import wraps + +from durabletask.internal.orchestrator_service_pb2 import OrchestratorRequest, OrchestratorResponse from .metadata import OrchestrationTrigger, ActivityTrigger, EntityTrigger, \ DurableClient from typing import Callable, Optional from typing import Union -from azure.functions import FunctionRegister, TriggerApi, BindingApi, AuthLevel, OrchestrationContext +from azure.functions import FunctionRegister, TriggerApi, BindingApi, AuthLevel + +# TODO: Use __init__.py to optimize imports +from durabletask.azurefunctions.client import DurableFunctionsClient +from durabletask.azurefunctions.worker import DurableFunctionsWorker +from durabletask.azurefunctions.internal.azurefunctions_null_stub import AzureFunctionsNullStub class Blueprint(TriggerApi, BindingApi): @@ -37,9 +46,6 @@ def __init__(self, def _configure_orchestrator_callable(self, wrap) -> Callable: """Obtain decorator to construct an Orchestrator class from a user-defined Function. - In the old programming model, this decorator's logic was unavoidable boilerplate - in user-code. Now, this is handled internally by the framework. - Parameters ---------- wrap: Callable @@ -54,14 +60,31 @@ def _configure_orchestrator_callable(self, wrap) -> Callable: def decorator(orchestrator_func): # Construct an orchestrator based on the end-user code - # TODO: Extract this logic (?) - def handle(context: OrchestrationContext) -> str: + # TODO: Move this logic somewhere better + def handle(context) -> str: context_body = getattr(context, "body", None) if context_body is None: context_body = context orchestration_context = context_body - # TODO: Run the orchestration using the context - return "" + request = OrchestratorRequest() + request.ParseFromString(base64.b64decode(orchestration_context)) + stub = AzureFunctionsNullStub() + worker = DurableFunctionsWorker() + response: Optional[OrchestratorResponse] = None + + def stub_complete(stub_response): + nonlocal response + response = stub_response + stub.CompleteOrchestratorTask = stub_complete + execution_started_events = [e for e in [e1 for e1 in request.newEvents] + [e2 for e2 in request.pastEvents] if e.HasField("executionStarted")] + function_name = execution_started_events[-1].executionStarted.name + worker.add_named_orchestrator(function_name, orchestrator_func) + worker._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') + '"' handle.orchestrator_function = orchestrator_func @@ -71,6 +94,55 @@ def handle(context: OrchestrationContext) -> str: return decorator + def _configure_entity_callable(self, wrap) -> Callable: + """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): + # TODO: Implement entity support - similar to orchestrators (?) + raise NotImplementedError() + + return decorator + + def _add_rich_client(self, fb, parameter_name, + client_constructor): + # 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. + user_code = fb._function._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, **kwargs): + + # 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 = fb._function._func # type: ignore + + user_code_with_rich_client = df_client_middleware + fb._function._func = user_code_with_rich_client + def orchestration_trigger(self, context_name: str, orchestration: Optional[str] = None): """Register an Orchestrator Function. @@ -133,6 +205,7 @@ def entity_trigger(self, context_name: 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._configure_function_builder def wrap(fb): def decorator(): @@ -171,7 +244,7 @@ def durable_client_input(self, @self._configure_function_builder def wrap(fb): def decorator(): - # self._add_rich_client(fb, client_name, DurableOrchestrationClient) + self._add_rich_client(fb, client_name, DurableFunctionsClient) fb.add_binding( binding=DurableClient(name=client_name, diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/internal/DurableClientConverter.py b/durabletask-azurefunctions/durabletask/azurefunctions/internal/DurableClientConverter.py deleted file mode 100644 index 4286967a..00000000 --- a/durabletask-azurefunctions/durabletask/azurefunctions/internal/DurableClientConverter.py +++ /dev/null @@ -1,46 +0,0 @@ -import abc -from typing import Any, Optional - -from azure.functions import meta - - -class DurableInConverter(meta._BaseConverter, binding=None): - - @classmethod - @abc.abstractmethod - def check_input_type_annotation(cls, pytype: type) -> bool: - pass - - @classmethod - @abc.abstractmethod - def decode(cls, data: meta.Datum, *, trigger_metadata) -> Any: - raise NotImplementedError - - @classmethod - @abc.abstractmethod - def has_implicit_output(cls) -> bool: - return False - - -class DurableOutConverter(meta._BaseConverter, binding=None): - - @classmethod - @abc.abstractmethod - def check_output_type_annotation(cls, pytype: type) -> bool: - pass - - @classmethod - @abc.abstractmethod - def encode(cls, obj: Any, *, - expected_type: Optional[type]) -> Optional[meta.Datum]: - raise NotImplementedError - -# Durable Functions Durable Client Bindings - - -class DurableClientConverter(DurableInConverter, - DurableOutConverter, - binding='durableClient'): - @classmethod - def has_implicit_output(cls) -> bool: - return False diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/internal/__init__.py b/durabletask-azurefunctions/durabletask/azurefunctions/internal/__init__.py index d5823cf5..e69de29b 100644 --- a/durabletask-azurefunctions/durabletask/azurefunctions/internal/__init__.py +++ b/durabletask-azurefunctions/durabletask/azurefunctions/internal/__init__.py @@ -1,3 +0,0 @@ -from .DurableClientConverter import DurableClientConverter - -__all__ = ["DurableClientConverter"] diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/internal/azurefunctions_grpc_interceptor.py b/durabletask-azurefunctions/durabletask/azurefunctions/internal/azurefunctions_grpc_interceptor.py new file mode 100644 index 00000000..a457a5ee --- /dev/null +++ b/durabletask-azurefunctions/durabletask/azurefunctions/internal/azurefunctions_grpc_interceptor.py @@ -0,0 +1,27 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +from importlib.metadata import version + +from durabletask.internal.grpc_interceptor import DefaultClientInterceptorImpl + + +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 + try: + # Get the version of the azurefunctions package + sdk_version = version('durabletask-azurefunctions') + except Exception: + # Fallback if version cannot be determined + sdk_version = "unknown" + user_agent = f"durabletask-python/{sdk_version}" + self._metadata = [ + ("taskhub", taskhub_name), + ("x-user-agent", user_agent)] # 'user-agent' is a reserved header in grpc, so we use 'x-user-agent' instead + super().__init__(self._metadata) diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/internal/azurefunctions_null_stub.py b/durabletask-azurefunctions/durabletask/azurefunctions/internal/azurefunctions_null_stub.py new file mode 100644 index 00000000..18b0116e --- /dev/null +++ b/durabletask-azurefunctions/durabletask/azurefunctions/internal/azurefunctions_null_stub.py @@ -0,0 +1,39 @@ + +from durabletask.internal.ProtoTaskHubSidecarServiceStub import ProtoTaskHubSidecarServiceStub + + +class AzureFunctionsNullStub(ProtoTaskHubSidecarServiceStub): + """Missing associated documentation comment in .proto file.""" + + def __init__(self): + """Constructor. + + Args: + channel: A grpc.Channel. + """ + self.Hello = lambda *args, **kwargs: None + self.StartInstance = lambda *args, **kwargs: None + self.GetInstance = lambda *args, **kwargs: None + self.RewindInstance = lambda *args, **kwargs: None + self.WaitForInstanceStart = lambda *args, **kwargs: None + self.WaitForInstanceCompletion = lambda *args, **kwargs: None + self.RaiseEvent = lambda *args, **kwargs: None + self.TerminateInstance = lambda *args, **kwargs: None + self.SuspendInstance = lambda *args, **kwargs: None + self.ResumeInstance = lambda *args, **kwargs: None + self.QueryInstances = lambda *args, **kwargs: None + self.PurgeInstances = lambda *args, **kwargs: None + self.GetWorkItems = lambda *args, **kwargs: None + self.CompleteActivityTask = lambda *args, **kwargs: None + self.CompleteOrchestratorTask = lambda *args, **kwargs: None + self.CompleteEntityTask = lambda *args, **kwargs: None + self.StreamInstanceHistory = lambda *args, **kwargs: None + self.CreateTaskHub = lambda *args, **kwargs: None + self.DeleteTaskHub = lambda *args, **kwargs: None + self.SignalEntity = lambda *args, **kwargs: None + self.GetEntity = lambda *args, **kwargs: None + self.QueryEntities = lambda *args, **kwargs: None + self.CleanEntityStorage = lambda *args, **kwargs: None + self.AbandonTaskActivityWorkItem = lambda *args, **kwargs: None + self.AbandonTaskOrchestratorWorkItem = lambda *args, **kwargs: None + self.AbandonTaskEntityWorkItem = lambda *args, **kwargs: None diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/worker.py b/durabletask-azurefunctions/durabletask/azurefunctions/worker.py index a176672e..a3e82237 100644 --- a/durabletask-azurefunctions/durabletask/azurefunctions/worker.py +++ b/durabletask-azurefunctions/durabletask/azurefunctions/worker.py @@ -1,2 +1,32 @@ -class TempClass: - pass +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +from threading import Event +from durabletask.worker import _Registry, ConcurrencyOptions +from durabletask.internal import shared +from durabletask.worker import TaskHubGrpcWorker + + +# Worker class used for Durable Task Scheduler (DTS) +class DurableFunctionsWorker(TaskHubGrpcWorker): + """TOOD: Docs + """ + + def __init__(self): + # Don't call the parent constructor - we don't actually want to start an AsyncWorkerLoop + # or recieve work items from anywhere but the method that is creating this worker + self._registry = _Registry() + self._host_address = "" + self._logger = shared.get_logger("worker") + self._shutdown = Event() + self._is_running = False + self._secure_channel = False + + self._concurrency_options = ConcurrencyOptions() + + self._interceptors = None + + def add_named_orchestrator(self, name: str, func): + """TOOD: Docs + """ + self._registry.add_named_orchestrator(name, func) diff --git a/durabletask/internal/ProtoTaskHubSidecarServiceStub.py b/durabletask/internal/ProtoTaskHubSidecarServiceStub.py new file mode 100644 index 00000000..9500b964 --- /dev/null +++ b/durabletask/internal/ProtoTaskHubSidecarServiceStub.py @@ -0,0 +1,35 @@ +from typing import Any, Callable + + +class ProtoTaskHubSidecarServiceStub(object): + """TODO: Docs""" + + def __init__(self): + """Constructor. + """ + self.Hello: Callable[..., None] + self.StartInstance: Callable[..., None] + self.GetInstance: Callable[..., None] + self.RewindInstance: Callable[..., None] + self.WaitForInstanceStart: Callable[..., None] + self.WaitForInstanceCompletion: Callable[..., None] + self.RaiseEvent: Callable[..., None] + self.TerminateInstance: Callable[..., None] + self.SuspendInstance: Callable[..., None] + self.ResumeInstance: Callable[..., None] + self.QueryInstances: Callable[..., None] + self.PurgeInstances: Callable[..., None] + self.GetWorkItems: Callable[..., None] + self.CompleteActivityTask: Callable[..., None] + self.CompleteOrchestratorTask: Callable[..., None] + self.CompleteEntityTask: Callable[..., None] + self.StreamInstanceHistory: Callable[..., None] + self.CreateTaskHub: Callable[..., None] + self.DeleteTaskHub: Callable[..., None] + self.SignalEntity: Callable[..., None] + self.GetEntity: Callable[..., None] + self.QueryEntities: Callable[..., None] + self.CleanEntityStorage: Callable[..., None] + self.AbandonTaskActivityWorkItem: Callable[..., None] + self.AbandonTaskOrchestratorWorkItem: Callable[..., None] + self.AbandonTaskEntityWorkItem: Callable[..., None] diff --git a/durabletask/worker.py b/durabletask/worker.py index 09f6559b..f9f5f8d2 100644 --- a/durabletask/worker.py +++ b/durabletask/worker.py @@ -19,6 +19,7 @@ from google.protobuf import empty_pb2 from durabletask.internal import helpers +from durabletask.internal.ProtoTaskHubSidecarServiceStub import ProtoTaskHubSidecarServiceStub from durabletask.internal.entity_state_shim import StateShim from durabletask.internal.helpers import new_timestamp from durabletask.entities import DurableEntity, EntityLock, EntityInstanceId, EntityContext @@ -625,7 +626,7 @@ def stop(self): def _execute_orchestrator( self, req: pb.OrchestratorRequest, - stub: stubs.TaskHubSidecarServiceStub, + stub: Union[stubs.TaskHubSidecarServiceStub, ProtoTaskHubSidecarServiceStub], completionToken, ): try: @@ -1689,6 +1690,9 @@ def process_event( self._logger.info(f"{ctx.instance_id}: Entity operation failed.") self._logger.info(f"Data: {json.dumps(event.entityOperationFailed)}") pass + elif event.HasField("orchestratorCompleted"): + # Added in Functions only (for some reason) and does not affect orchestrator flow + pass else: eventType = event.WhichOneof("eventType") raise task.OrchestrationStateError( From af0e3c2bc2580799dbcb1cdb9fd74bd398975a72 Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Fri, 21 Nov 2025 14:36:16 -0700 Subject: [PATCH 03/45] Nitpicks and cleanup --- .../durabletask/azurefunctions/__init__.py | 2 ++ .../azurefunctions/decorators/durable_app.py | 12 +++++++++++- .../internal/azurefunctions_null_stub.py | 7 +++---- .../durabletask/azurefunctions/worker.py | 4 ++-- .../internal/ProtoTaskHubSidecarServiceStub.py | 7 +++++-- 5 files changed, 23 insertions(+), 9 deletions(-) diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/__init__.py b/durabletask-azurefunctions/durabletask/azurefunctions/__init__.py index e69de29b..59e481eb 100644 --- a/durabletask-azurefunctions/durabletask/azurefunctions/__init__.py +++ b/durabletask-azurefunctions/durabletask/azurefunctions/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/decorators/durable_app.py b/durabletask-azurefunctions/durabletask/azurefunctions/decorators/durable_app.py index 59ccc017..d4ae41cf 100644 --- a/durabletask-azurefunctions/durabletask/azurefunctions/decorators/durable_app.py +++ b/durabletask-azurefunctions/durabletask/azurefunctions/decorators/durable_app.py @@ -1,5 +1,6 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. + import base64 from functools import wraps @@ -76,7 +77,16 @@ def stub_complete(stub_response): nonlocal response response = stub_response stub.CompleteOrchestratorTask = stub_complete - execution_started_events = [e for e in [e1 for e1 in request.newEvents] + [e2 for e2 in request.pastEvents] if e.HasField("executionStarted")] + execution_started_events = [] + 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 worker.add_named_orchestrator(function_name, orchestrator_func) worker._execute_orchestrator(request, stub, None) diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/internal/azurefunctions_null_stub.py b/durabletask-azurefunctions/durabletask/azurefunctions/internal/azurefunctions_null_stub.py index 18b0116e..47a0ce7e 100644 --- a/durabletask-azurefunctions/durabletask/azurefunctions/internal/azurefunctions_null_stub.py +++ b/durabletask-azurefunctions/durabletask/azurefunctions/internal/azurefunctions_null_stub.py @@ -1,15 +1,14 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. from durabletask.internal.ProtoTaskHubSidecarServiceStub import ProtoTaskHubSidecarServiceStub class AzureFunctionsNullStub(ProtoTaskHubSidecarServiceStub): - """Missing associated documentation comment in .proto file.""" + """A task hub sidecar stub class that implements all methods as no-ops.""" def __init__(self): """Constructor. - - Args: - channel: A grpc.Channel. """ self.Hello = lambda *args, **kwargs: None self.StartInstance = lambda *args, **kwargs: None diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/worker.py b/durabletask-azurefunctions/durabletask/azurefunctions/worker.py index a3e82237..8b4aca30 100644 --- a/durabletask-azurefunctions/durabletask/azurefunctions/worker.py +++ b/durabletask-azurefunctions/durabletask/azurefunctions/worker.py @@ -9,7 +9,7 @@ # Worker class used for Durable Task Scheduler (DTS) class DurableFunctionsWorker(TaskHubGrpcWorker): - """TOOD: Docs + """TODO: Docs """ def __init__(self): @@ -27,6 +27,6 @@ def __init__(self): self._interceptors = None def add_named_orchestrator(self, name: str, func): - """TOOD: Docs + """TODO: Docs """ self._registry.add_named_orchestrator(name, func) diff --git a/durabletask/internal/ProtoTaskHubSidecarServiceStub.py b/durabletask/internal/ProtoTaskHubSidecarServiceStub.py index 9500b964..7ccfd589 100644 --- a/durabletask/internal/ProtoTaskHubSidecarServiceStub.py +++ b/durabletask/internal/ProtoTaskHubSidecarServiceStub.py @@ -1,8 +1,11 @@ -from typing import Any, Callable +from typing import Callable class ProtoTaskHubSidecarServiceStub(object): - """TODO: Docs""" + """A stub class roughly matching the TaskHubSidecarServiceStub generated from the .proto file. + Used by Azure Functions during orchestration and entity executions to inject custom behavior, + as no real sidecar stub is available. + """ def __init__(self): """Constructor. From 349714882fecdb6c5468309bb6ba62a383c835f7 Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Fri, 21 Nov 2025 15:11:54 -0700 Subject: [PATCH 04/45] Save-all nits --- .../durabletask/azurefunctions/constants.py | 3 +++ .../durabletask/azurefunctions/decorators/__init__.py | 2 +- .../durabletask/azurefunctions/decorators/metadata.py | 1 + .../durabletask/azurefunctions/internal/__init__.py | 2 ++ .../azurefunctions/internal/azurefunctions_grpc_interceptor.py | 2 +- 5 files changed, 8 insertions(+), 2 deletions(-) diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/constants.py b/durabletask-azurefunctions/durabletask/azurefunctions/constants.py index 652afcac..f647e31d 100644 --- a/durabletask-azurefunctions/durabletask/azurefunctions/constants.py +++ b/durabletask-azurefunctions/durabletask/azurefunctions/constants.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + """Constants used to determine the local running context.""" # TODO: Remove unused constants after module is complete DEFAULT_LOCAL_HOST: str = 'localhost:7071' diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/decorators/__init__.py b/durabletask-azurefunctions/durabletask/azurefunctions/decorators/__init__.py index f3cfb910..59283bac 100644 --- a/durabletask-azurefunctions/durabletask/azurefunctions/decorators/__init__.py +++ b/durabletask-azurefunctions/durabletask/azurefunctions/decorators/__init__.py @@ -8,4 +8,4 @@ __all__ = ["durable_app", "metadata"] -PACKAGE_NAME = "durabletask.entities" +PACKAGE_NAME = "durabletask.azurefunctions.decorators" diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/decorators/metadata.py b/durabletask-azurefunctions/durabletask/azurefunctions/decorators/metadata.py index 4bf1d6c5..30dc6ff5 100644 --- a/durabletask-azurefunctions/durabletask/azurefunctions/decorators/metadata.py +++ b/durabletask-azurefunctions/durabletask/azurefunctions/decorators/metadata.py @@ -1,5 +1,6 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. + from typing import Optional from durabletask.azurefunctions.constants import ORCHESTRATION_TRIGGER, \ diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/internal/__init__.py b/durabletask-azurefunctions/durabletask/azurefunctions/internal/__init__.py index e69de29b..59e481eb 100644 --- a/durabletask-azurefunctions/durabletask/azurefunctions/internal/__init__.py +++ b/durabletask-azurefunctions/durabletask/azurefunctions/internal/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/internal/azurefunctions_grpc_interceptor.py b/durabletask-azurefunctions/durabletask/azurefunctions/internal/azurefunctions_grpc_interceptor.py index a457a5ee..8736bf6f 100644 --- a/durabletask-azurefunctions/durabletask/azurefunctions/internal/azurefunctions_grpc_interceptor.py +++ b/durabletask-azurefunctions/durabletask/azurefunctions/internal/azurefunctions_grpc_interceptor.py @@ -6,7 +6,7 @@ from durabletask.internal.grpc_interceptor import DefaultClientInterceptorImpl -class AzureFunctionsDefaultClientInterceptorImpl (DefaultClientInterceptorImpl): +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.""" From 57de87804e7ba8147d07625cf4ba9fd6b6ed0b5a Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Mon, 24 Nov 2025 11:59:18 -0700 Subject: [PATCH 05/45] Add entity support (needs extension change) --- .../azurefunctions/decorators/durable_app.py | 47 +++++++++++++++++-- durabletask/worker.py | 2 +- 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/decorators/durable_app.py b/durabletask-azurefunctions/durabletask/azurefunctions/decorators/durable_app.py index d4ae41cf..477db9a0 100644 --- a/durabletask-azurefunctions/durabletask/azurefunctions/decorators/durable_app.py +++ b/durabletask-azurefunctions/durabletask/azurefunctions/decorators/durable_app.py @@ -4,7 +4,7 @@ import base64 from functools import wraps -from durabletask.internal.orchestrator_service_pb2 import OrchestratorRequest, OrchestratorResponse +from durabletask.internal.orchestrator_service_pb2 import EntityRequest, EntityBatchRequest, EntityBatchResult, OrchestratorRequest, OrchestratorResponse from .metadata import OrchestrationTrigger, ActivityTrigger, EntityTrigger, \ DurableClient from typing import Callable, Optional @@ -119,8 +119,49 @@ def _configure_entity_callable(self, wrap) -> Callable: wrapped by the next decorator in the sequence. """ def decorator(entity_func): - # TODO: Implement entity support - similar to orchestrators (?) - raise NotImplementedError() + # Construct an orchestrator based on the end-user code + + # TODO: Move this logic somewhere better + # 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"). + def handle(context) -> str: + context_body = getattr(context, "body", None) + if context_body is None: + context_body = context + orchestration_context = context_body + request = EntityBatchRequest() + request_2 = EntityRequest() + try: + request.ParseFromString(base64.b64decode(orchestration_context)) + except Exception: + pass + try: + request_2.ParseFromString(base64.b64decode(orchestration_context)) + except Exception: + pass + stub = AzureFunctionsNullStub() + worker = DurableFunctionsWorker() + response: Optional[EntityBatchResult] = None + + def stub_complete(stub_response: EntityBatchResult): + nonlocal response + response = stub_response + stub.CompleteEntityTask = stub_complete + + worker.add_entity(entity_func) + worker._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') + '"' + + handle.entity_function = entity_func + + # invoke next decorator, with the Entity as input + handle.__name__ = entity_func.__name__ + return wrap(handle) return decorator diff --git a/durabletask/worker.py b/durabletask/worker.py index e84189e5..f3da1582 100644 --- a/durabletask/worker.py +++ b/durabletask/worker.py @@ -737,7 +737,7 @@ def _cancel_activity( def _execute_entity_batch( self, req: Union[pb.EntityBatchRequest, pb.EntityRequest], - stub: stubs.TaskHubSidecarServiceStub, + stub: Union[stubs.TaskHubSidecarServiceStub, ProtoTaskHubSidecarServiceStub], completionToken, ): if isinstance(req, pb.EntityRequest): From 18145f8f980785567ff3c16efec3da8fc41ea729 Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Wed, 3 Dec 2025 08:59:42 -0700 Subject: [PATCH 06/45] Refine entity support - Still needs eventSent and eventRecieved implementations --- .../durabletask/azurefunctions/client.py | 42 ++++++++++--------- .../azurefunctions/decorators/durable_app.py | 14 ++----- .../azurefunctions/http/__init__.py | 6 +++ .../http/http_management_payload.py | 18 ++++++++ durabletask/internal/helpers.py | 7 +++- durabletask/worker.py | 10 ++++- 6 files changed, 64 insertions(+), 33 deletions(-) create mode 100644 durabletask-azurefunctions/durabletask/azurefunctions/http/__init__.py create mode 100644 durabletask-azurefunctions/durabletask/azurefunctions/http/http_management_payload.py diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/client.py b/durabletask-azurefunctions/durabletask/azurefunctions/client.py index 63a267bc..0a4058b1 100644 --- a/durabletask-azurefunctions/durabletask/azurefunctions/client.py +++ b/durabletask-azurefunctions/durabletask/azurefunctions/client.py @@ -6,10 +6,12 @@ from datetime import timedelta from typing import Any, Optional import azure.functions as func +from urllib.parse import urlparse, quote from durabletask.entities import EntityInstanceId from durabletask.client import TaskHubGrpcClient from durabletask.azurefunctions.internal.azurefunctions_grpc_interceptor import AzureFunctionsDefaultClientInterceptorImpl +from durabletask.azurefunctions.http import HttpManagementPayload # Client class used for Durable Functions @@ -56,30 +58,32 @@ def create_check_status_response(self, request: func.HttpRequest, instance_id: s request (func.HttpRequest): The incoming HTTP request. instance_id (str): The ID of the Durable Function instance. """ - raise NotImplementedError("This method is not implemented yet.") + 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=501, + headers={ + 'content-type': 'application/json', + 'Location': location_url, + }, + ) - def create_http_management_payload(self, instance_id: str) -> dict[str, str]: + def create_http_management_payload(self, request: func.HttpRequest, instance_id: str) -> HttpManagementPayload: """Creates an HTTP management payload for a Durable Function instance. Args: instance_id (str): The ID of the Durable Function instance. """ - raise NotImplementedError("This method is not implemented yet.") + return self._get_client_response_links(request, instance_id) - def read_entity_state( - self, - entity_id: EntityInstanceId, - task_hub_name: Optional[str], - connection_name: Optional[str] - ) -> tuple[bool, Any]: - """Reads the state of a Durable Entity. + def _get_client_response_links(self, request: func.HttpRequest, instance_id: str) -> HttpManagementPayload: + instance_status_url = self._get_instance_status_url(request, instance_id) + return HttpManagementPayload(instance_id, instance_status_url, self.requiredQueryStringParameters) - Args: - entity_id (str): The ID of the Durable Entity. - task_hub_name (Optional[str]): The name of the task hub. - connection_name (Optional[str]): The name of the connection. - - Returns: - (bool, Any): A tuple containing a boolean indicating if the entity exists and its state. - """ - raise NotImplementedError("This method is not implemented yet.") + @staticmethod + def _get_instance_status_url(request: func.HttpRequest, instance_id: str) -> str: + request_url = urlparse(request.url) + location_url = f"{request_url.scheme}://{request_url.netloc}{request_url.path}" + encoded_instance_id = quote(instance_id) + location_url = location_url + "/runtime/webhooks/durabletask/instances/" + encoded_instance_id + return location_url diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/decorators/durable_app.py b/durabletask-azurefunctions/durabletask/azurefunctions/decorators/durable_app.py index 477db9a0..2a6489f5 100644 --- a/durabletask-azurefunctions/durabletask/azurefunctions/decorators/durable_app.py +++ b/durabletask-azurefunctions/durabletask/azurefunctions/decorators/durable_app.py @@ -96,7 +96,7 @@ def stub_complete(stub_response): # The Python worker returns the input as type "json", so double-encoding is necessary return '"' + base64.b64encode(response.SerializeToString()).decode('utf-8') + '"' - handle.orchestrator_function = orchestrator_func + handle.orchestrator_function = orchestrator_func # type: ignore # invoke next decorator, with the Orchestrator as input handle.__name__ = orchestrator_func.__name__ @@ -131,15 +131,7 @@ def handle(context) -> str: context_body = context orchestration_context = context_body request = EntityBatchRequest() - request_2 = EntityRequest() - try: - request.ParseFromString(base64.b64decode(orchestration_context)) - except Exception: - pass - try: - request_2.ParseFromString(base64.b64decode(orchestration_context)) - except Exception: - pass + request.ParseFromString(base64.b64decode(orchestration_context)) stub = AzureFunctionsNullStub() worker = DurableFunctionsWorker() response: Optional[EntityBatchResult] = None @@ -157,7 +149,7 @@ def stub_complete(stub_response: EntityBatchResult): # The Python worker returns the input as type "json", so double-encoding is necessary return '"' + base64.b64encode(response.SerializeToString()).decode('utf-8') + '"' - handle.entity_function = entity_func + handle.entity_function = entity_func # type: ignore # invoke next decorator, with the Entity as input handle.__name__ = entity_func.__name__ diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/http/__init__.py b/durabletask-azurefunctions/durabletask/azurefunctions/http/__init__.py new file mode 100644 index 00000000..fc1cb6ba --- /dev/null +++ b/durabletask-azurefunctions/durabletask/azurefunctions/http/__init__.py @@ -0,0 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +from durabletask.azurefunctions.http.http_management_payload import HttpManagementPayload + +__all__ = ["HttpManagementPayload"] diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/http/http_management_payload.py b/durabletask-azurefunctions/durabletask/azurefunctions/http/http_management_payload.py new file mode 100644 index 00000000..1fb2a7cf --- /dev/null +++ b/durabletask-azurefunctions/durabletask/azurefunctions/http/http_management_payload.py @@ -0,0 +1,18 @@ +import json + + +class HttpManagementPayload: + def __init__(self, instance_id: str, instance_status_url: str, required_query_string_parameters: str): + self.urls = { + '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): + return json.dumps(self.urls) diff --git a/durabletask/internal/helpers.py b/durabletask/internal/helpers.py index ccd8558b..f1ca8dfa 100644 --- a/durabletask/internal/helpers.py +++ b/durabletask/internal/helpers.py @@ -4,6 +4,7 @@ import traceback from datetime import datetime from typing import Optional +import uuid from google.protobuf import timestamp_pb2, wrappers_pb2 @@ -197,8 +198,9 @@ def new_schedule_task_action(id: int, name: str, encoded_input: Optional[str], def new_call_entity_action(id: int, parent_instance_id: str, entity_id: EntityInstanceId, operation: str, encoded_input: Optional[str]): + request_id = str(uuid.uuid4()) return pb.OrchestratorAction(id=id, sendEntityMessage=pb.SendEntityMessageAction(entityOperationCalled=pb.EntityOperationCalledEvent( - requestId=f"{parent_instance_id}:{id}", + requestId=request_id, operation=operation, scheduledTime=None, input=get_string_value(encoded_input), @@ -209,8 +211,9 @@ def new_call_entity_action(id: int, parent_instance_id: str, entity_id: EntityIn def new_signal_entity_action(id: int, entity_id: EntityInstanceId, operation: str, encoded_input: Optional[str]): + request_id = str(uuid.uuid4()) return pb.OrchestratorAction(id=id, sendEntityMessage=pb.SendEntityMessageAction(entityOperationSignaled=pb.EntityOperationSignaledEvent( - requestId=f"{entity_id}:{id}", + requestId=request_id, operation=operation, scheduledTime=None, input=get_string_value(encoded_input), diff --git a/durabletask/worker.py b/durabletask/worker.py index f3da1582..d5e35c54 100644 --- a/durabletask/worker.py +++ b/durabletask/worker.py @@ -13,6 +13,7 @@ from types import GeneratorType from enum import Enum from typing import Any, Generator, Optional, Sequence, TypeVar, Union +import uuid from packaging.version import InvalidVersion, parse import grpc @@ -740,6 +741,7 @@ def _execute_entity_batch( stub: Union[stubs.TaskHubSidecarServiceStub, ProtoTaskHubSidecarServiceStub], completionToken, ): + operation_infos = None if isinstance(req, pb.EntityRequest): req, operation_infos = helpers.convert_to_entity_batch_request(req) @@ -1200,7 +1202,7 @@ def lock_entities_function_helper(self, id: int, entities: list[EntityInstanceId if not transition_valid: raise RuntimeError(error_message) - critical_section_id = f"{self.instance_id}:{id:04x}" + critical_section_id = str(uuid.uuid4()) request, target = self._entity_context.emit_acquire_message(critical_section_id, entities) @@ -1747,6 +1749,12 @@ def process_event( elif event.HasField("orchestratorCompleted"): # Added in Functions only (for some reason) and does not affect orchestrator flow pass + elif event.HasField("eventSent"): + # Added in Functions only (for some reason) and does not affect orchestrator flow + pass + elif event.HasField("eventRaised"): + # Added in Functions only (for some reason) and does not affect orchestrator flow + pass else: eventType = event.WhichOneof("eventType") raise task.OrchestrationStateError( From 9965ba4100d0396325e511361ad4fea87545b522 Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Wed, 3 Dec 2025 10:58:43 -0800 Subject: [PATCH 07/45] Finish entity support --- .../durabletask/azurefunctions/client.py | 2 +- durabletask/entities/entity_instance_id.py | 2 +- durabletask/worker.py | 79 ++++++++++++------- examples/entities/function_based_entity.py | 2 +- 4 files changed, 54 insertions(+), 31 deletions(-) diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/client.py b/durabletask-azurefunctions/durabletask/azurefunctions/client.py index 0a4058b1..ffd9cd12 100644 --- a/durabletask-azurefunctions/durabletask/azurefunctions/client.py +++ b/durabletask-azurefunctions/durabletask/azurefunctions/client.py @@ -83,7 +83,7 @@ def _get_client_response_links(self, request: func.HttpRequest, instance_id: str @staticmethod def _get_instance_status_url(request: func.HttpRequest, instance_id: str) -> str: request_url = urlparse(request.url) - location_url = f"{request_url.scheme}://{request_url.netloc}{request_url.path}" + location_url = f"{request_url.scheme}://{request_url.netloc}" encoded_instance_id = quote(instance_id) location_url = location_url + "/runtime/webhooks/durabletask/instances/" + encoded_instance_id return location_url diff --git a/durabletask/entities/entity_instance_id.py b/durabletask/entities/entity_instance_id.py index 53c1171f..72335d11 100644 --- a/durabletask/entities/entity_instance_id.py +++ b/durabletask/entities/entity_instance_id.py @@ -20,7 +20,7 @@ def __lt__(self, other): return str(self) < str(other) @staticmethod - def parse(entity_id: str) -> Optional["EntityInstanceId"]: + def parse(entity_id: str) -> "EntityInstanceId": """Parse a string representation of an entity ID into an EntityInstanceId object. Parameters diff --git a/durabletask/worker.py b/durabletask/worker.py index d5e35c54..20657444 100644 --- a/durabletask/worker.py +++ b/durabletask/worker.py @@ -1593,33 +1593,52 @@ def process_event( else: raise TypeError("Unexpected sub-orchestration task type") elif event.HasField("eventRaised"): - # event names are case-insensitive - event_name = event.eventRaised.name.casefold() - if not ctx.is_replaying: - self._logger.info(f"{ctx.instance_id} Event raised: {event_name}") - task_list = ctx._pending_events.get(event_name, None) - decoded_result: Optional[Any] = None - if task_list: - event_task = task_list.pop(0) + if event.eventRaised.name in ctx._entity_task_id_map: + # This eventRaised represents the result of an entity operation after being translated to the old + # entity protocol by the Durable WebJobs extension + entity_id, task_id = ctx._entity_task_id_map.get(event.eventRaised.name, (None, None)) + if entity_id is None: + raise RuntimeError(f"Could not retrieve entity ID for entity-related eventRaised with ID '{event.eventId}'") + if task_id is None: + raise RuntimeError(f"Could not retrieve task ID for entity-related eventRaised with ID '{event.eventId}'") + entity_task = ctx._pending_tasks.pop(task_id, None) + if not entity_task: + raise RuntimeError(f"Could not retrieve entity task for entity-related eventRaised with ID '{event.eventId}'") + result = None if not ph.is_empty(event.eventRaised.input): - decoded_result = shared.from_json(event.eventRaised.input.value) - event_task.complete(decoded_result) - if not task_list: - del ctx._pending_events[event_name] + # TODO: Investigate why the event result is wrapped in a dict with "result" key + result = shared.from_json(event.eventRaised.input.value)["result"] + ctx._entity_context.recover_lock_after_call(entity_id) + entity_task.complete(result) ctx.resume() else: - # buffer the event - event_list = ctx._received_events.get(event_name, None) - if not event_list: - event_list = [] - ctx._received_events[event_name] = event_list - if not ph.is_empty(event.eventRaised.input): - decoded_result = shared.from_json(event.eventRaised.input.value) - event_list.append(decoded_result) + # event names are case-insensitive + event_name = event.eventRaised.name.casefold() if not ctx.is_replaying: - self._logger.info( - f"{ctx.instance_id}: Event '{event_name}' has been buffered as there are no tasks waiting for it." - ) + self._logger.info(f"{ctx.instance_id} Event raised: {event_name}") + task_list = ctx._pending_events.get(event_name, None) + decoded_result: Optional[Any] = None + if task_list: + event_task = task_list.pop(0) + if not ph.is_empty(event.eventRaised.input): + decoded_result = shared.from_json(event.eventRaised.input.value) + event_task.complete(decoded_result) + if not task_list: + del ctx._pending_events[event_name] + ctx.resume() + else: + # buffer the event + event_list = ctx._received_events.get(event_name, None) + if not event_list: + event_list = [] + ctx._received_events[event_name] = event_list + if not ph.is_empty(event.eventRaised.input): + decoded_result = shared.from_json(event.eventRaised.input.value) + event_list.append(decoded_result) + if not ctx.is_replaying: + self._logger.info( + f"{ctx.instance_id}: Event '{event_name}' has been buffered as there are no tasks waiting for it." + ) elif event.HasField("executionSuspended"): if not self._is_suspended and not ctx.is_replaying: self._logger.info(f"{ctx.instance_id}: Execution suspended.") @@ -1750,11 +1769,15 @@ def process_event( # Added in Functions only (for some reason) and does not affect orchestrator flow pass elif event.HasField("eventSent"): - # Added in Functions only (for some reason) and does not affect orchestrator flow - pass - elif event.HasField("eventRaised"): - # Added in Functions only (for some reason) and does not affect orchestrator flow - pass + # 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 + # entityOperationCalled and remove the pending action. Also store the entity id and event id for later + action = ctx._pending_actions.pop(event.eventId, None) + if action and action.HasField("sendEntityMessage") and action.sendEntityMessage.HasField("entityOperationCalled"): + entity_id = EntityInstanceId.parse(event.eventSent.instanceId) + event_id = json.loads(event.eventSent.input.value)["id"] + ctx._entity_task_id_map[event_id] = (entity_id, event.eventId) + return else: eventType = event.WhichOneof("eventType") raise task.OrchestrationStateError( diff --git a/examples/entities/function_based_entity.py b/examples/entities/function_based_entity.py index a43b86d2..32d94692 100644 --- a/examples/entities/function_based_entity.py +++ b/examples/entities/function_based_entity.py @@ -13,7 +13,7 @@ def counter(ctx: entities.EntityContext, input: int) -> Optional[int]: if ctx.operation == "set": ctx.set_state(input) - if ctx.operation == "add": + elif ctx.operation == "add": current_state = ctx.get_state(int, 0) new_state = current_state + (input or 1) ctx.set_state(new_state) From 209443ec9df9e6eccb77a5279aa4de68fa71c692 Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Thu, 4 Dec 2025 11:26:09 -0700 Subject: [PATCH 08/45] Fixes and improvements - Add new_uuid method to OrchestrationContext for deterministic replay-safe UUIDs - Fix entity locking behavior for Functions - Align _RuntimeOrchestrationContext param names with OrchestrationContext - Remap __init__.py files for new module - Update version to 0.0.1dev0 - Add docstrings to missing methods - Move code for executing orchestrators/entities to DurableFunctionsWorker - Add function metadata to triggers for detection by extension --- .../durabletask/azurefunctions/__init__.py | 5 ++ .../durabletask/azurefunctions/client.py | 16 +++++ .../durabletask/azurefunctions/constants.py | 5 -- .../azurefunctions/decorators/__init__.py | 9 --- .../azurefunctions/decorators/durable_app.py | 65 +---------------- .../azurefunctions/decorators/metadata.py | 6 +- .../http/http_management_payload.py | 13 ++++ .../durabletask/azurefunctions/worker.py | 67 ++++++++++++++++- durabletask-azurefunctions/pyproject.toml | 2 +- durabletask/internal/helpers.py | 15 ++-- durabletask/task.py | 16 +++++ durabletask/worker.py | 71 ++++++++++++++----- 12 files changed, 185 insertions(+), 105 deletions(-) diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/__init__.py b/durabletask-azurefunctions/durabletask/azurefunctions/__init__.py index 59e481eb..c7680213 100644 --- a/durabletask-azurefunctions/durabletask/azurefunctions/__init__.py +++ b/durabletask-azurefunctions/durabletask/azurefunctions/__init__.py @@ -1,2 +1,7 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. + +from durabletask.azurefunctions.decorators.durable_app import Blueprint, DFApp +from durabletask.azurefunctions.client import DurableFunctionsClient + +__all__ = ["Blueprint", "DFApp", "DurableFunctionsClient"] diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/client.py b/durabletask-azurefunctions/durabletask/azurefunctions/client.py index ffd9cd12..362ef899 100644 --- a/durabletask-azurefunctions/durabletask/azurefunctions/client.py +++ b/durabletask-azurefunctions/durabletask/azurefunctions/client.py @@ -16,6 +16,12 @@ # Client class used for Durable Functions class DurableFunctionsClient(TaskHubGrpcClient): + """A gRPC client passed to Durable Functions durable client bindings. + + Connects to the Durable Functions runtime using 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] @@ -28,6 +34,16 @@ class DurableFunctionsClient(TaskHubGrpcClient): 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. + """ client = json.loads(client_as_string) self.taskHubName = client.get("taskHubName", "") diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/constants.py b/durabletask-azurefunctions/durabletask/azurefunctions/constants.py index f647e31d..fbd268a7 100644 --- a/durabletask-azurefunctions/durabletask/azurefunctions/constants.py +++ b/durabletask-azurefunctions/durabletask/azurefunctions/constants.py @@ -2,11 +2,6 @@ # Licensed under the MIT License. """Constants used to determine the local running context.""" -# TODO: Remove unused constants after module is complete -DEFAULT_LOCAL_HOST: str = 'localhost:7071' -DEFAULT_LOCAL_ORIGIN: str = f'http://{DEFAULT_LOCAL_HOST}' -DATETIME_STRING_FORMAT = '%Y-%m-%dT%H:%M:%S.%fZ' -HTTP_ACTION_NAME = 'BuiltIn::HttpActivity' ORCHESTRATION_TRIGGER = "orchestrationTrigger" ACTIVITY_TRIGGER = "activityTrigger" ENTITY_TRIGGER = "entityTrigger" diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/decorators/__init__.py b/durabletask-azurefunctions/durabletask/azurefunctions/decorators/__init__.py index 59283bac..59e481eb 100644 --- a/durabletask-azurefunctions/durabletask/azurefunctions/decorators/__init__.py +++ b/durabletask-azurefunctions/durabletask/azurefunctions/decorators/__init__.py @@ -1,11 +1,2 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. - -"""Durable Task SDK for Python entities component""" - -import durabletask.azurefunctions.decorators.durable_app as durable_app -import durabletask.azurefunctions.decorators.metadata as metadata - -__all__ = ["durable_app", "metadata"] - -PACKAGE_NAME = "durabletask.azurefunctions.decorators" diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/decorators/durable_app.py b/durabletask-azurefunctions/durabletask/azurefunctions/decorators/durable_app.py index 2a6489f5..15a13e59 100644 --- a/durabletask-azurefunctions/durabletask/azurefunctions/decorators/durable_app.py +++ b/durabletask-azurefunctions/durabletask/azurefunctions/decorators/durable_app.py @@ -1,20 +1,16 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -import base64 from functools import wraps -from durabletask.internal.orchestrator_service_pb2 import EntityRequest, EntityBatchRequest, EntityBatchResult, OrchestratorRequest, OrchestratorResponse from .metadata import OrchestrationTrigger, ActivityTrigger, EntityTrigger, \ DurableClient from typing import Callable, Optional from typing import Union from azure.functions import FunctionRegister, TriggerApi, BindingApi, AuthLevel -# TODO: Use __init__.py to optimize imports from durabletask.azurefunctions.client import DurableFunctionsClient from durabletask.azurefunctions.worker import DurableFunctionsWorker -from durabletask.azurefunctions.internal.azurefunctions_null_stub import AzureFunctionsNullStub class Blueprint(TriggerApi, BindingApi): @@ -61,40 +57,8 @@ def _configure_orchestrator_callable(self, wrap) -> Callable: def decorator(orchestrator_func): # Construct an orchestrator based on the end-user code - # TODO: Move this logic somewhere better def handle(context) -> 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 = AzureFunctionsNullStub() - worker = DurableFunctionsWorker() - response: Optional[OrchestratorResponse] = None - - def stub_complete(stub_response): - nonlocal response - response = stub_response - stub.CompleteOrchestratorTask = stub_complete - execution_started_events = [] - 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 - worker.add_named_orchestrator(function_name, orchestrator_func) - worker._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') + '"' + return DurableFunctionsWorker()._execute_orchestrator(orchestrator_func, context) handle.orchestrator_function = orchestrator_func # type: ignore @@ -121,33 +85,11 @@ def _configure_entity_callable(self, wrap) -> Callable: def decorator(entity_func): # Construct an orchestrator based on the end-user code - # TODO: Move this logic somewhere better # 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"). def handle(context) -> 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 = AzureFunctionsNullStub() - worker = DurableFunctionsWorker() - response: Optional[EntityBatchResult] = None - - def stub_complete(stub_response: EntityBatchResult): - nonlocal response - response = stub_response - stub.CompleteEntityTask = stub_complete - - worker.add_entity(entity_func) - worker._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') + '"' + return DurableFunctionsWorker()._execute_entity_batch(entity_func, context) handle.entity_function = entity_func # type: ignore @@ -157,8 +99,7 @@ def stub_complete(stub_response: EntityBatchResult): return decorator - def _add_rich_client(self, fb, parameter_name, - client_constructor): + def _add_rich_client(self, fb, parameter_name, client_constructor): # 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. diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/decorators/metadata.py b/durabletask-azurefunctions/durabletask/azurefunctions/decorators/metadata.py index 30dc6ff5..93f3545c 100644 --- a/durabletask-azurefunctions/durabletask/azurefunctions/decorators/metadata.py +++ b/durabletask-azurefunctions/durabletask/azurefunctions/decorators/metadata.py @@ -28,6 +28,7 @@ def get_binding_name() -> str: def __init__(self, name: str, orchestration: Optional[str] = None, + durable_requires_grpc=True, ) -> None: self.orchestration = orchestration super().__init__(name=name) @@ -53,6 +54,7 @@ def get_binding_name() -> str: def __init__(self, name: str, activity: Optional[str] = None, + durable_requires_grpc=True, ) -> None: self.activity = activity super().__init__(name=name) @@ -78,6 +80,7 @@ def get_binding_name() -> str: def __init__(self, name: str, entity_name: Optional[str] = None, + durable_requires_grpc=True, ) -> None: self.entity_name = entity_name super().__init__(name=name) @@ -103,7 +106,8 @@ def get_binding_name() -> str: def __init__(self, name: str, task_hub: Optional[str] = None, - connection_name: Optional[str] = None + connection_name: Optional[str] = None, + durable_requires_grpc=True, ) -> None: self.task_hub = task_hub self.connection_name = connection_name diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/http/http_management_payload.py b/durabletask-azurefunctions/durabletask/azurefunctions/http/http_management_payload.py index 1fb2a7cf..9d470c6c 100644 --- a/durabletask-azurefunctions/durabletask/azurefunctions/http/http_management_payload.py +++ b/durabletask-azurefunctions/durabletask/azurefunctions/http/http_management_payload.py @@ -2,7 +2,20 @@ class HttpManagementPayload: + """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. + """ + 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. + """ self.urls = { 'id': instance_id, 'purgeHistoryDeleteUri': instance_status_url + "?" + required_query_string_parameters, diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/worker.py b/durabletask-azurefunctions/durabletask/azurefunctions/worker.py index 8b4aca30..540f3759 100644 --- a/durabletask-azurefunctions/durabletask/azurefunctions/worker.py +++ b/durabletask-azurefunctions/durabletask/azurefunctions/worker.py @@ -1,15 +1,22 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. +import base64 from threading import Event +from typing import Optional +from durabletask.internal.orchestrator_service_pb2 import EntityBatchRequest, EntityBatchResult, OrchestratorRequest, OrchestratorResponse from durabletask.worker import _Registry, ConcurrencyOptions from durabletask.internal import shared from durabletask.worker import TaskHubGrpcWorker +from durabletask.azurefunctions.internal.azurefunctions_null_stub import AzureFunctionsNullStub # Worker class used for Durable Task Scheduler (DTS) class DurableFunctionsWorker(TaskHubGrpcWorker): - """TODO: Docs + """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): @@ -27,6 +34,60 @@ def __init__(self): self._interceptors = None def add_named_orchestrator(self, name: str, func): - """TODO: Docs - """ self._registry.add_named_orchestrator(name, func) + + def _execute_orchestrator(self, func, context) -> 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 = AzureFunctionsNullStub() + response: Optional[OrchestratorResponse] = None + + def stub_complete(stub_response): + nonlocal response + response = stub_response + stub.CompleteOrchestratorTask = stub_complete + execution_started_events = [] + 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, 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(self, func, context) -> 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 = AzureFunctionsNullStub() + response: Optional[EntityBatchResult] = None + + def stub_complete(stub_response: EntityBatchResult): + nonlocal response + response = stub_response + stub.CompleteEntityTask = stub_complete + + self.add_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/durabletask-azurefunctions/pyproject.toml b/durabletask-azurefunctions/pyproject.toml index dfb02eb8..8780b01d 100644 --- a/durabletask-azurefunctions/pyproject.toml +++ b/durabletask-azurefunctions/pyproject.toml @@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta" [project] name = "durabletask.azurefunctions" -version = "0.1.0" +version = "0.0.1dev0" description = "Durable Task Python SDK provider implementation for Durable Azure Functions" keywords = [ "durable", diff --git a/durabletask/internal/helpers.py b/durabletask/internal/helpers.py index f1ca8dfa..612915ca 100644 --- a/durabletask/internal/helpers.py +++ b/durabletask/internal/helpers.py @@ -4,7 +4,6 @@ import traceback from datetime import datetime from typing import Optional -import uuid from google.protobuf import timestamp_pb2, wrappers_pb2 @@ -197,8 +196,11 @@ def new_schedule_task_action(id: int, name: str, encoded_input: Optional[str], )) -def new_call_entity_action(id: int, parent_instance_id: str, entity_id: EntityInstanceId, operation: str, encoded_input: Optional[str]): - request_id = str(uuid.uuid4()) +def new_call_entity_action(id: int, + parent_instance_id: str, + entity_id: EntityInstanceId, + operation: str, encoded_input: Optional[str], + request_id: str): return pb.OrchestratorAction(id=id, sendEntityMessage=pb.SendEntityMessageAction(entityOperationCalled=pb.EntityOperationCalledEvent( requestId=request_id, operation=operation, @@ -210,8 +212,11 @@ def new_call_entity_action(id: int, parent_instance_id: str, entity_id: EntityIn ))) -def new_signal_entity_action(id: int, entity_id: EntityInstanceId, operation: str, encoded_input: Optional[str]): - request_id = str(uuid.uuid4()) +def new_signal_entity_action(id: int, + entity_id: EntityInstanceId, + operation: str, + encoded_input: Optional[str], + request_id: str): return pb.OrchestratorAction(id=id, sendEntityMessage=pb.SendEntityMessageAction(entityOperationSignaled=pb.EntityOperationSignaledEvent( requestId=request_id, operation=operation, diff --git a/durabletask/task.py b/durabletask/task.py index 35708388..2f763bc4 100644 --- a/durabletask/task.py +++ b/durabletask/task.py @@ -258,6 +258,22 @@ def continue_as_new(self, new_input: Any, *, save_events: bool = False) -> None: """ pass + @abstractmethod + def new_uuid(self) -> str: + """Create a new UUID that is safe for replay within an orchestration or operation. + + The default implementation of this method creates a name-based UUID + using the algorithm from RFC 4122 §4.3. The name input used to generate + this value is a combination of the orchestration instance ID and an + internally managed sequence number. + + Returns + ------- + str + New UUID that is safe for replay within an orchestration or operation. + """ + pass + @abstractmethod def _exit_critical_section(self) -> None: pass diff --git a/durabletask/worker.py b/durabletask/worker.py index 20657444..3ae37845 100644 --- a/durabletask/worker.py +++ b/durabletask/worker.py @@ -35,6 +35,7 @@ TInput = TypeVar("TInput") TOutput = TypeVar("TOutput") +DATETIME_STRING_FORMAT = '%Y-%m-%dT%H:%M:%S.%fZ' class ConcurrencyOptions: @@ -797,7 +798,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 orchestration ID '{instance_id}' to sidecar: {ex}" ) # TODO: Reset context @@ -830,10 +831,11 @@ def __init__(self, instance_id: str, registry: _Registry): self._pending_actions: dict[int, pb.OrchestratorAction] = {} self._pending_tasks: dict[int, task.CompletableTask] = {} # Maps entity ID to task ID - self._entity_task_id_map: dict[str, tuple[EntityInstanceId, int]] = {} + self._entity_task_id_map: dict[str, tuple[EntityInstanceId, int, Optional[str]]] = {} # Maps criticalSectionId to task ID self._entity_lock_id_map: dict[str, int] = {} self._sequence_number = 0 + self._new_uuid_counter = 0 self._current_utc_datetime = datetime(1000, 1, 1) self._instance_id = instance_id self._registry = registry @@ -1041,14 +1043,14 @@ def call_activity( def call_entity( self, - entity_id: EntityInstanceId, + entity: EntityInstanceId, operation: str, input: Optional[TInput] = None, ) -> task.Task: id = self.next_sequence_number() self.call_entity_function_helper( - id, entity_id, operation, input=input + id, entity, operation, input=input ) return self._pending_tasks.get(id, task.CompletableTask()) @@ -1056,13 +1058,13 @@ def call_entity( def signal_entity( self, entity_id: EntityInstanceId, - operation: str, + operation_name: str, input: Optional[TInput] = None ) -> None: id = self.next_sequence_number() self.signal_entity_function_helper( - id, entity_id, operation, input + id, entity_id, operation_name, input ) def lock_entities(self, entities: list[EntityInstanceId]) -> task.Task[EntityLock]: @@ -1168,7 +1170,12 @@ def call_entity_function_helper( raise RuntimeError(error_message) encoded_input = shared.to_json(input) if input is not None else None - action = ph.new_call_entity_action(id, self.instance_id, entity_id, operation, encoded_input) + action = ph.new_call_entity_action(id, + self.instance_id, + entity_id, + operation, + encoded_input, + self.new_uuid()) self._pending_actions[id] = action fn_task = task.CompletableTask() @@ -1191,7 +1198,7 @@ def signal_entity_function_helper( encoded_input = shared.to_json(input) if input is not None else None - action = ph.new_signal_entity_action(id, entity_id, operation, encoded_input) + action = ph.new_signal_entity_action(id, entity_id, operation, encoded_input, self.new_uuid()) self._pending_actions[id] = action def lock_entities_function_helper(self, id: int, entities: list[EntityInstanceId]) -> None: @@ -1202,7 +1209,7 @@ def lock_entities_function_helper(self, id: int, entities: list[EntityInstanceId if not transition_valid: raise RuntimeError(error_message) - critical_section_id = str(uuid.uuid4()) + critical_section_id = self.new_uuid() request, target = self._entity_context.emit_acquire_message(critical_section_id, entities) @@ -1254,6 +1261,17 @@ def continue_as_new(self, new_input, *, save_events: bool = False) -> None: self.set_continued_as_new(new_input, save_events) + def new_uuid(self) -> str: + URL_NAMESPACE: str = "9e952958-5e33-4daf-827f-2fa12937b875" + + uuid_name_value = \ + f"{self._instance_id}" \ + f"_{self.current_utc_datetime.strftime(DATETIME_STRING_FORMAT)}" \ + f"_{self._new_uuid_counter}" + self._new_uuid_counter += 1 + namespace_uuid = uuid.uuid5(uuid.NAMESPACE_OID, URL_NAMESPACE) + return str(uuid.uuid5(namespace_uuid, uuid_name_value)) + class ExecutionResults: actions: list[pb.OrchestratorAction] @@ -1596,7 +1614,7 @@ def process_event( if event.eventRaised.name in ctx._entity_task_id_map: # This eventRaised represents the result of an entity operation after being translated to the old # entity protocol by the Durable WebJobs extension - entity_id, task_id = ctx._entity_task_id_map.get(event.eventRaised.name, (None, None)) + entity_id, task_id, action_type = ctx._entity_task_id_map.get(event.eventRaised.name, (None, None, None)) if entity_id is None: raise RuntimeError(f"Could not retrieve entity ID for entity-related eventRaised with ID '{event.eventId}'") if task_id is None: @@ -1608,9 +1626,18 @@ def process_event( if not ph.is_empty(event.eventRaised.input): # TODO: Investigate why the event result is wrapped in a dict with "result" key result = shared.from_json(event.eventRaised.input.value)["result"] - ctx._entity_context.recover_lock_after_call(entity_id) - entity_task.complete(result) - ctx.resume() + if action_type == "entityOperationCalled": + ctx._entity_context.recover_lock_after_call(entity_id) + entity_task.complete(result) + ctx.resume() + elif action_type == "entityLockRequested": + ctx._entity_context.complete_acquire(event.eventRaised.name) + entity_task.complete(EntityLock(ctx)) + ctx.resume() + else: + raise RuntimeError(f"Unknown action type '{action_type}' for entity-related eventRaised " + f"with ID '{event.eventId}'") + else: # event names are case-insensitive event_name = event.eventRaised.name.casefold() @@ -1681,7 +1708,7 @@ def process_event( entity_id = EntityInstanceId.parse(event.entityOperationCalled.targetInstanceId.value) if not entity_id: raise RuntimeError(f"Could not parse entity ID from targetInstanceId '{event.entityOperationCalled.targetInstanceId.value}'") - ctx._entity_task_id_map[event.entityOperationCalled.requestId] = (entity_id, entity_call_id) + ctx._entity_task_id_map[event.entityOperationCalled.requestId] = (entity_id, entity_call_id, None) elif event.HasField("entityOperationSignaled"): # This history event confirms that the entity signal was successfully scheduled. # Remove the entityOperationSignaled event from the pending action list so we don't schedule it @@ -1742,7 +1769,7 @@ def process_event( ctx.resume() elif event.HasField("entityOperationCompleted"): request_id = event.entityOperationCompleted.requestId - entity_id, task_id = ctx._entity_task_id_map.pop(request_id, (None, None)) + entity_id, task_id, _ = ctx._entity_task_id_map.pop(request_id, (None, None, None)) if not entity_id: raise RuntimeError(f"Could not parse entity ID from request ID '{request_id}'") if not task_id: @@ -1770,14 +1797,20 @@ def process_event( 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 + # entity protocol by the Durable WebJobs extension. If so, treat this message similarly to # entityOperationCalled and remove the pending action. Also store the entity id and event id for later action = ctx._pending_actions.pop(event.eventId, None) - if action and action.HasField("sendEntityMessage") and action.sendEntityMessage.HasField("entityOperationCalled"): + if action and action.HasField("sendEntityMessage"): + if action.sendEntityMessage.HasField("entityOperationCalled"): + action_type = "entityOperationCalled" + elif action.sendEntityMessage.HasField("entityLockRequested"): + action_type = "entityLockRequested" + else: + return + entity_id = EntityInstanceId.parse(event.eventSent.instanceId) event_id = json.loads(event.eventSent.input.value)["id"] - ctx._entity_task_id_map[event_id] = (entity_id, event.eventId) - return + ctx._entity_task_id_map[event_id] = (entity_id, event.eventId, action_type) else: eventType = event.WhichOneof("eventType") raise task.OrchestrationStateError( From cc005ae4f9022a79974614f850c33f7f15156334 Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Thu, 4 Dec 2025 12:06:23 -0700 Subject: [PATCH 09/45] Bump durabletask version, fix metadata --- .../workflows/durabletask-azurefunctions.yml | 126 ++++++++++++++++++ .../azurefunctions/decorators/metadata.py | 4 + durabletask-azurefunctions/pyproject.toml | 2 +- pyproject.toml | 2 +- 4 files changed, 132 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/durabletask-azurefunctions.yml diff --git a/.github/workflows/durabletask-azurefunctions.yml b/.github/workflows/durabletask-azurefunctions.yml new file mode 100644 index 00000000..ba800944 --- /dev/null +++ b/.github/workflows/durabletask-azurefunctions.yml @@ -0,0 +1,126 @@ +name: Durable Task Scheduler SDK (durabletask-azurefunctions) + +on: + push: + branches: + - "main" + tags: + - "azurefunctions-v*" # Only run for tags starting with "azurefunctions-v" + pull_request: + branches: + - "main" + +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: durabletask-azurefunctions + run: | + python -m pip install --upgrade pip + pip install setuptools wheel tox + pip install flake8 + - name: Run flake8 Linter + working-directory: durabletask-azurefunctions + run: flake8 . + - name: Run flake8 Linter + working-directory: tests/durabletask-azurefunctions + run: flake8 . + + run-docker-tests: + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] + env: + EMULATOR_VERSION: "latest" + needs: lint + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Pull Docker image + run: docker pull mcr.microsoft.com/dts/dts-emulator:$EMULATOR_VERSION + + - name: Run Docker container + run: | + docker run --name dtsemulator -d -p 8080:8080 mcr.microsoft.com/dts/dts-emulator:$EMULATOR_VERSION + + - name: Wait for container to be ready + run: sleep 10 # Adjust if your service needs more time to start + + - name: Set environment variables + run: | + echo "TASKHUB=default" >> $GITHUB_ENV + echo "ENDPOINT=http://localhost:8080" >> $GITHUB_ENV + + - name: Install durabletask dependencies + run: | + python -m pip install --upgrade pip + pip install flake8 pytest + pip install -r requirements.txt + + - name: Install durabletask-azurefunctions dependencies + working-directory: examples + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Install durabletask-azurefunctions locally + working-directory: durabletask-azurefunctions + run: | + pip install . --no-deps --force-reinstall + + - name: Install durabletask locally + run: | + pip install . --no-deps --force-reinstall + + - name: Run the tests + working-directory: tests/durabletask-azurefunctions + run: | + pytest -m "dts" --verbose + + publish: + if: startsWith(github.ref, 'refs/tags/azurefunctions-v') # Only run if a matching tag is pushed + needs: run-docker-tests + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Extract version from tag + run: echo "VERSION=${GITHUB_REF#refs/tags/azurefunctions-v}" >> $GITHUB_ENV # Extract version from the tag + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.14" # Adjust Python version as needed + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install build twine + + - name: Build package from directory durabletask-azurefunctions + working-directory: durabletask-azurefunctions + run: | + python -m build + + - name: Check package + working-directory: durabletask-azurefunctions + run: | + twine check dist/* + + - name: Publish package to PyPI + env: + TWINE_USERNAME: __token__ + TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN_AZUREFUNCTIONS }} # Store your PyPI API token in GitHub Secrets + working-directory: durabletask-azurefunctions + run: | + twine upload dist/* \ No newline at end of file diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/decorators/metadata.py b/durabletask-azurefunctions/durabletask/azurefunctions/decorators/metadata.py index 93f3545c..21cd7f42 100644 --- a/durabletask-azurefunctions/durabletask/azurefunctions/decorators/metadata.py +++ b/durabletask-azurefunctions/durabletask/azurefunctions/decorators/metadata.py @@ -31,6 +31,7 @@ def __init__(self, durable_requires_grpc=True, ) -> None: self.orchestration = orchestration + self.durable_requires_grpc = durable_requires_grpc super().__init__(name=name) @@ -57,6 +58,7 @@ def __init__(self, durable_requires_grpc=True, ) -> None: self.activity = activity + self.durable_requires_grpc = durable_requires_grpc super().__init__(name=name) @@ -83,6 +85,7 @@ def __init__(self, durable_requires_grpc=True, ) -> None: self.entity_name = entity_name + self.durable_requires_grpc = durable_requires_grpc super().__init__(name=name) @@ -111,4 +114,5 @@ def __init__(self, ) -> 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/durabletask-azurefunctions/pyproject.toml b/durabletask-azurefunctions/pyproject.toml index 8780b01d..b1e72e5a 100644 --- a/durabletask-azurefunctions/pyproject.toml +++ b/durabletask-azurefunctions/pyproject.toml @@ -27,7 +27,7 @@ requires-python = ">=3.9" license = {file = "LICENSE"} readme = "README.md" dependencies = [ - "durabletask>=0.5.0", + "durabletask>=1.2.0dev0", "azure-identity>=1.19.0", "azure-functions>=1.11.0" ] diff --git a/pyproject.toml b/pyproject.toml index 547eb7ad..958981e7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta" [project] name = "durabletask" -version = "1.0.0" +version = "1.2.0dev0" description = "A Durable Task Client SDK for Python" keywords = [ "durable", From bf6d6f2bcd6309b32a8bb10b4940f8a3092151c8 Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Fri, 5 Dec 2025 12:14:02 -0700 Subject: [PATCH 10/45] Use Protocol for stubs --- .../internal/azurefunctions_null_stub.py | 58 +++++++++--------- .../ProtoTaskHubSidecarServiceStub.py | 60 +++++++++---------- 2 files changed, 55 insertions(+), 63 deletions(-) diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/internal/azurefunctions_null_stub.py b/durabletask-azurefunctions/durabletask/azurefunctions/internal/azurefunctions_null_stub.py index 47a0ce7e..75a48a0a 100644 --- a/durabletask-azurefunctions/durabletask/azurefunctions/internal/azurefunctions_null_stub.py +++ b/durabletask-azurefunctions/durabletask/azurefunctions/internal/azurefunctions_null_stub.py @@ -1,38 +1,34 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. -from durabletask.internal.ProtoTaskHubSidecarServiceStub import ProtoTaskHubSidecarServiceStub +from durabletask.internal.proto_task_hub_sidecar_service_stub import ProtoTaskHubSidecarServiceStub class AzureFunctionsNullStub(ProtoTaskHubSidecarServiceStub): """A task hub sidecar stub class that implements all methods as no-ops.""" - - def __init__(self): - """Constructor. - """ - self.Hello = lambda *args, **kwargs: None - self.StartInstance = lambda *args, **kwargs: None - self.GetInstance = lambda *args, **kwargs: None - self.RewindInstance = lambda *args, **kwargs: None - self.WaitForInstanceStart = lambda *args, **kwargs: None - self.WaitForInstanceCompletion = lambda *args, **kwargs: None - self.RaiseEvent = lambda *args, **kwargs: None - self.TerminateInstance = lambda *args, **kwargs: None - self.SuspendInstance = lambda *args, **kwargs: None - self.ResumeInstance = lambda *args, **kwargs: None - self.QueryInstances = lambda *args, **kwargs: None - self.PurgeInstances = lambda *args, **kwargs: None - self.GetWorkItems = lambda *args, **kwargs: None - self.CompleteActivityTask = lambda *args, **kwargs: None - self.CompleteOrchestratorTask = lambda *args, **kwargs: None - self.CompleteEntityTask = lambda *args, **kwargs: None - self.StreamInstanceHistory = lambda *args, **kwargs: None - self.CreateTaskHub = lambda *args, **kwargs: None - self.DeleteTaskHub = lambda *args, **kwargs: None - self.SignalEntity = lambda *args, **kwargs: None - self.GetEntity = lambda *args, **kwargs: None - self.QueryEntities = lambda *args, **kwargs: None - self.CleanEntityStorage = lambda *args, **kwargs: None - self.AbandonTaskActivityWorkItem = lambda *args, **kwargs: None - self.AbandonTaskOrchestratorWorkItem = lambda *args, **kwargs: None - self.AbandonTaskEntityWorkItem = lambda *args, **kwargs: None + Hello = lambda *args, **kwargs: None + StartInstance = lambda *args, **kwargs: None + GetInstance = lambda *args, **kwargs: None + RewindInstance = lambda *args, **kwargs: None + WaitForInstanceStart = lambda *args, **kwargs: None + WaitForInstanceCompletion = lambda *args, **kwargs: None + RaiseEvent = lambda *args, **kwargs: None + TerminateInstance = lambda *args, **kwargs: None + SuspendInstance = lambda *args, **kwargs: None + ResumeInstance = lambda *args, **kwargs: None + QueryInstances = lambda *args, **kwargs: None + PurgeInstances = lambda *args, **kwargs: None + GetWorkItems = lambda *args, **kwargs: None + CompleteActivityTask = lambda *args, **kwargs: None + CompleteOrchestratorTask = lambda *args, **kwargs: None + CompleteEntityTask = lambda *args, **kwargs: None + StreamInstanceHistory = lambda *args, **kwargs: None + CreateTaskHub = lambda *args, **kwargs: None + DeleteTaskHub = lambda *args, **kwargs: None + SignalEntity = lambda *args, **kwargs: None + GetEntity = lambda *args, **kwargs: None + QueryEntities = lambda *args, **kwargs: None + CleanEntityStorage = lambda *args, **kwargs: None + AbandonTaskActivityWorkItem = lambda *args, **kwargs: None + AbandonTaskOrchestratorWorkItem = lambda *args, **kwargs: None + AbandonTaskEntityWorkItem = lambda *args, **kwargs: None diff --git a/durabletask/internal/ProtoTaskHubSidecarServiceStub.py b/durabletask/internal/ProtoTaskHubSidecarServiceStub.py index 7ccfd589..f91a15c4 100644 --- a/durabletask/internal/ProtoTaskHubSidecarServiceStub.py +++ b/durabletask/internal/ProtoTaskHubSidecarServiceStub.py @@ -1,38 +1,34 @@ -from typing import Callable +from typing import Any, Callable, Protocol -class ProtoTaskHubSidecarServiceStub(object): +class ProtoTaskHubSidecarServiceStub(Protocol): """A stub class roughly matching the TaskHubSidecarServiceStub generated from the .proto file. Used by Azure Functions during orchestration and entity executions to inject custom behavior, as no real sidecar stub is available. """ - - def __init__(self): - """Constructor. - """ - self.Hello: Callable[..., None] - self.StartInstance: Callable[..., None] - self.GetInstance: Callable[..., None] - self.RewindInstance: Callable[..., None] - self.WaitForInstanceStart: Callable[..., None] - self.WaitForInstanceCompletion: Callable[..., None] - self.RaiseEvent: Callable[..., None] - self.TerminateInstance: Callable[..., None] - self.SuspendInstance: Callable[..., None] - self.ResumeInstance: Callable[..., None] - self.QueryInstances: Callable[..., None] - self.PurgeInstances: Callable[..., None] - self.GetWorkItems: Callable[..., None] - self.CompleteActivityTask: Callable[..., None] - self.CompleteOrchestratorTask: Callable[..., None] - self.CompleteEntityTask: Callable[..., None] - self.StreamInstanceHistory: Callable[..., None] - self.CreateTaskHub: Callable[..., None] - self.DeleteTaskHub: Callable[..., None] - self.SignalEntity: Callable[..., None] - self.GetEntity: Callable[..., None] - self.QueryEntities: Callable[..., None] - self.CleanEntityStorage: Callable[..., None] - self.AbandonTaskActivityWorkItem: Callable[..., None] - self.AbandonTaskOrchestratorWorkItem: Callable[..., None] - self.AbandonTaskEntityWorkItem: Callable[..., None] + Hello: Callable[..., Any] + StartInstance: Callable[..., Any] + GetInstance: Callable[..., Any] + RewindInstance: Callable[..., Any] + WaitForInstanceStart: Callable[..., Any] + WaitForInstanceCompletion: Callable[..., Any] + RaiseEvent: Callable[..., Any] + TerminateInstance: Callable[..., Any] + SuspendInstance: Callable[..., Any] + ResumeInstance: Callable[..., Any] + QueryInstances: Callable[..., Any] + PurgeInstances: Callable[..., Any] + GetWorkItems: Callable[..., Any] + CompleteActivityTask: Callable[..., Any] + CompleteOrchestratorTask: Callable[..., Any] + CompleteEntityTask: Callable[..., Any] + StreamInstanceHistory: Callable[..., Any] + CreateTaskHub: Callable[..., Any] + DeleteTaskHub: Callable[..., Any] + SignalEntity: Callable[..., Any] + GetEntity: Callable[..., Any] + QueryEntities: Callable[..., Any] + CleanEntityStorage: Callable[..., Any] + AbandonTaskActivityWorkItem: Callable[..., Any] + AbandonTaskOrchestratorWorkItem: Callable[..., Any] + AbandonTaskEntityWorkItem: Callable[..., Any] From 1176d0325f6b67906879ba51a2623c839493354e Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Fri, 5 Dec 2025 13:08:17 -0700 Subject: [PATCH 11/45] Update to new workflow pattern --- .../durabletask-azurefunctions-dev.yml | 52 +++++++++++++++++++ ...urabletask-azurefunctions-experimental.yml | 50 ++++++++++++++++++ .../workflows/durabletask-azurefunctions.yml | 2 +- 3 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/durabletask-azurefunctions-dev.yml create mode 100644 .github/workflows/durabletask-azurefunctions-experimental.yml diff --git a/.github/workflows/durabletask-azurefunctions-dev.yml b/.github/workflows/durabletask-azurefunctions-dev.yml new file mode 100644 index 00000000..fa7b720f --- /dev/null +++ b/.github/workflows/durabletask-azurefunctions-dev.yml @@ -0,0 +1,52 @@ +name: Durable Task Scheduler SDK (durabletask-azurefunctions) Dev Release + +on: + workflow_run: + workflows: ["Durable Task Scheduler SDK (durabletask-azurefunctions)"] + types: + - completed + branches: + - main + +jobs: + publish-dev: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Extract version from tag + run: echo "VERSION=${GITHUB_REF#refs/tags/azurefunctions-v}" >> $GITHUB_ENV # Extract version from the tag + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.14" # Adjust Python version as needed + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install build twine + + - name: Append dev to version in pyproject.toml + working-directory: durabletask-azurefunctions + run: | + sed -i 's/^version = "\(.*\)"/version = "\1.dev${{ github.run_number }}"/' pyproject.toml + + - name: Build package from directory durabletask-azurefunctions + working-directory: durabletask-azurefunctions + run: | + python -m build + + - name: Check package + working-directory: durabletask-azurefunctions + run: | + twine check dist/* + + - name: Publish package to PyPI + env: + TWINE_USERNAME: __token__ + TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN_AZUREFUNCTIONS }} # Store your PyPI API token in GitHub Secrets + working-directory: durabletask-azurefunctions + run: | + twine upload dist/* \ No newline at end of file diff --git a/.github/workflows/durabletask-azurefunctions-experimental.yml b/.github/workflows/durabletask-azurefunctions-experimental.yml new file mode 100644 index 00000000..06b663de --- /dev/null +++ b/.github/workflows/durabletask-azurefunctions-experimental.yml @@ -0,0 +1,50 @@ +name: Durable Task Scheduler SDK (durabletask-azurefunctions) Experimental Release + +on: + push: + branches-ignore: + - main + - release/* + +jobs: + publish-experimental: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Extract version from tag + run: echo "VERSION=${GITHUB_REF#refs/tags/azurefunctions-v}" >> $GITHUB_ENV # Extract version from the tag + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.14" # Adjust Python version as needed + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install build twine + + - name: Change the version in pyproject.toml to 0.0.0dev{github.run_number} + working-directory: durabletask-azurefunctions + run: | + sed -i 's/^version = ".*"/version = "0.0.0.dev${{ github.run_number }}"/' pyproject.toml + + - name: Build package from directory durabletask-azurefunctions + working-directory: durabletask-azurefunctions + run: | + python -m build + + - name: Check package + working-directory: durabletask-azurefunctions + run: | + twine check dist/* + + - name: Publish package to PyPI + env: + TWINE_USERNAME: __token__ + TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN_AZUREFUNCTIONS }} # Store your PyPI API token in GitHub Secrets + working-directory: durabletask-azurefunctions + run: | + twine upload dist/* \ No newline at end of file diff --git a/.github/workflows/durabletask-azurefunctions.yml b/.github/workflows/durabletask-azurefunctions.yml index ba800944..2fc74540 100644 --- a/.github/workflows/durabletask-azurefunctions.yml +++ b/.github/workflows/durabletask-azurefunctions.yml @@ -86,7 +86,7 @@ jobs: run: | pytest -m "dts" --verbose - publish: + publish-release: if: startsWith(github.ref, 'refs/tags/azurefunctions-v') # Only run if a matching tag is pushed needs: run-docker-tests runs-on: ubuntu-latest From 7bf763af9d09eddb58c4ceafa92416dc6fdb0177 Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Fri, 5 Dec 2025 13:35:45 -0700 Subject: [PATCH 12/45] Rename stub file --- ...decarServiceStub.py => proto_task_hub_sidecar_service_stub.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename durabletask/internal/{ProtoTaskHubSidecarServiceStub.py => proto_task_hub_sidecar_service_stub.py} (100%) diff --git a/durabletask/internal/ProtoTaskHubSidecarServiceStub.py b/durabletask/internal/proto_task_hub_sidecar_service_stub.py similarity index 100% rename from durabletask/internal/ProtoTaskHubSidecarServiceStub.py rename to durabletask/internal/proto_task_hub_sidecar_service_stub.py From 811653e024b4f7a5e670047f23bdf077300bdf3e Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Fri, 5 Dec 2025 13:39:34 -0700 Subject: [PATCH 13/45] Fix import --- durabletask/worker.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/durabletask/worker.py b/durabletask/worker.py index 3ae37845..cd1f899c 100644 --- a/durabletask/worker.py +++ b/durabletask/worker.py @@ -20,7 +20,7 @@ from google.protobuf import empty_pb2 from durabletask.internal import helpers -from durabletask.internal.ProtoTaskHubSidecarServiceStub import ProtoTaskHubSidecarServiceStub +from durabletask.internal.proto_task_hub_sidecar_service_stub import ProtoTaskHubSidecarServiceStub from durabletask.internal.entity_state_shim import StateShim from durabletask.internal.helpers import new_timestamp from durabletask.entities import DurableEntity, EntityLock, EntityInstanceId, EntityContext From 827d2013d08c4ec88781af25f99a77bdb1033ac8 Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Fri, 5 Dec 2025 14:29:52 -0700 Subject: [PATCH 14/45] Experimental dependency revision --- .github/workflows/durabletask-azurefunctions-experimental.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/durabletask-azurefunctions-experimental.yml b/.github/workflows/durabletask-azurefunctions-experimental.yml index 06b663de..49b8c250 100644 --- a/.github/workflows/durabletask-azurefunctions-experimental.yml +++ b/.github/workflows/durabletask-azurefunctions-experimental.yml @@ -30,6 +30,7 @@ jobs: working-directory: durabletask-azurefunctions run: | sed -i 's/^version = ".*"/version = "0.0.0.dev${{ github.run_number }}"/' pyproject.toml + sed -i 's/"durabletask>=.*"/"durabletask>=0.0.0dev1"/' pyproject.toml - name: Build package from directory durabletask-azurefunctions working-directory: durabletask-azurefunctions From fde02c501a22d0e970c161ab2daf69d7a91a8e73 Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Thu, 11 Dec 2025 10:38:59 -0700 Subject: [PATCH 15/45] Update to match changes in functions SDK --- .../azurefunctions/decorators/durable_app.py | 11 +++++++---- .../durabletask/azurefunctions/worker.py | 11 ++++++----- durabletask-azurefunctions/pyproject.toml | 2 +- 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/decorators/durable_app.py b/durabletask-azurefunctions/durabletask/azurefunctions/decorators/durable_app.py index 15a13e59..e4e249ff 100644 --- a/durabletask-azurefunctions/durabletask/azurefunctions/decorators/durable_app.py +++ b/durabletask-azurefunctions/durabletask/azurefunctions/decorators/durable_app.py @@ -3,6 +3,8 @@ from functools import wraps +from durabletask import task + from .metadata import OrchestrationTrigger, ActivityTrigger, EntityTrigger, \ DurableClient from typing import Callable, Optional @@ -54,7 +56,7 @@ def _configure_orchestrator_callable(self, wrap) -> 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): + def decorator(orchestrator_func: task.Orchestrator): # Construct an orchestrator based on the end-user code def handle(context) -> str: @@ -82,7 +84,7 @@ def _configure_entity_callable(self, wrap) -> 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): + def decorator(entity_func: task.Entity): # Construct an orchestrator based on the end-user code # TODO: Because this handle method is the one actually exposed to the Functions SDK decorator, @@ -177,7 +179,8 @@ def decorator(): return wrap - def entity_trigger(self, context_name: str, + def entity_trigger(self, + context_name: str, entity_name: Optional[str] = None): """Register an Entity Function. @@ -228,7 +231,7 @@ def durable_client_input(self, @self._configure_function_builder def wrap(fb): def decorator(): - self._add_rich_client(fb, client_name, DurableFunctionsClient) + # self._add_rich_client(fb, client_name, DurableFunctionsClient) fb.add_binding( binding=DurableClient(name=client_name, diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/worker.py b/durabletask-azurefunctions/durabletask/azurefunctions/worker.py index 540f3759..5cef7f4e 100644 --- a/durabletask-azurefunctions/durabletask/azurefunctions/worker.py +++ b/durabletask-azurefunctions/durabletask/azurefunctions/worker.py @@ -4,6 +4,7 @@ import base64 from threading import Event from typing import Optional +from durabletask import task from durabletask.internal.orchestrator_service_pb2 import EntityBatchRequest, EntityBatchResult, OrchestratorRequest, OrchestratorResponse from durabletask.worker import _Registry, ConcurrencyOptions from durabletask.internal import shared @@ -33,10 +34,10 @@ def __init__(self): self._interceptors = None - def add_named_orchestrator(self, name: str, func): + def add_named_orchestrator(self, name: str, func: task.Orchestrator): self._registry.add_named_orchestrator(name, func) - def _execute_orchestrator(self, func, context) -> str: + def _execute_orchestrator(self, func: task.Orchestrator, context) -> str: context_body = getattr(context, "body", None) if context_body is None: context_body = context @@ -67,9 +68,9 @@ def stub_complete(stub_response): 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') + '"' + return base64.b64encode(response.SerializeToString()).decode('utf-8') - def _execute_entity_batch(self, func, context) -> str: + def _execute_entity_batch(self, func: task.Entity, context) -> str: context_body = getattr(context, "body", None) if context_body is None: context_body = context @@ -90,4 +91,4 @@ def stub_complete(stub_response: EntityBatchResult): 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') + '"' + return base64.b64encode(response.SerializeToString()).decode('utf-8') diff --git a/durabletask-azurefunctions/pyproject.toml b/durabletask-azurefunctions/pyproject.toml index b1e72e5a..79704f0e 100644 --- a/durabletask-azurefunctions/pyproject.toml +++ b/durabletask-azurefunctions/pyproject.toml @@ -29,7 +29,7 @@ readme = "README.md" dependencies = [ "durabletask>=1.2.0dev0", "azure-identity>=1.19.0", - "azure-functions>=1.11.0" + "azure-functions>=1.25.0b3.dev1" ] [project.urls] From 2df96dccb55ce374fe8527bfd862a51f07873b68 Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Fri, 12 Dec 2025 12:26:41 -0700 Subject: [PATCH 16/45] Merge issue fix --- durabletask/worker.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/durabletask/worker.py b/durabletask/worker.py index 7fd2f6d9..838d4abe 100644 --- a/durabletask/worker.py +++ b/durabletask/worker.py @@ -835,6 +835,8 @@ def __init__(self, instance_id: str, registry: _Registry): # Maps entity ID to task ID self._entity_task_id_map: dict[str, tuple[EntityInstanceId, int]] = {} self._entity_lock_task_id_map: dict[str, tuple[EntityInstanceId, int]] = {} + # Maps criticalSectionId to task ID + self._entity_lock_id_map: dict[str, int] = {} self._sequence_number = 0 self._new_uuid_counter = 0 self._current_utc_datetime = datetime(1000, 1, 1) @@ -1171,6 +1173,7 @@ def call_entity_function_helper( raise RuntimeError(error_message) encoded_input = shared.to_json(input) if input is not None else None + action = ph.new_call_entity_action(id, self.instance_id, entity_id, operation, encoded_input, self.new_uuid()) self._pending_actions[id] = action @@ -1684,7 +1687,7 @@ def process_event( entity_id = EntityInstanceId.parse(event.entityOperationCalled.targetInstanceId.value) except ValueError: raise RuntimeError(f"Could not parse entity ID from targetInstanceId '{event.entityOperationCalled.targetInstanceId.value}'") - ctx._entity_task_id_map[event.entityOperationCalled.requestId] = (entity_id, entity_call_id, None) + ctx._entity_task_id_map[event.entityOperationCalled.requestId] = (entity_id, entity_call_id) elif event.HasField("entityOperationSignaled"): # This history event confirms that the entity signal was successfully scheduled. # Remove the entityOperationSignaled event from the pending action list so we don't schedule it @@ -1745,7 +1748,7 @@ def process_event( ctx.resume() elif event.HasField("entityOperationCompleted"): request_id = event.entityOperationCompleted.requestId - entity_id, task_id, _ = ctx._entity_task_id_map.pop(request_id, (None, None, None)) + entity_id, task_id = ctx._entity_task_id_map.pop(request_id, (None, None)) if not entity_id: raise RuntimeError(f"Could not parse entity ID from request ID '{request_id}'") if not task_id: From eac9efda2549a05f6ee167724877f623f83a390e Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Tue, 6 Jan 2026 14:50:20 -0700 Subject: [PATCH 17/45] Various --- .../durabletask/azurefunctions/__init__.py | 4 ++++ .../durabletask/azurefunctions/client.py | 2 +- .../azurefunctions/decorators/durable_app.py | 2 -- .../azurefunctions/http/http_management_payload.py | 3 +++ .../azurefunctions/internal/functions_json.py | 10 ++++++++++ durabletask/internal/shared.py | 8 ++++++-- durabletask/worker.py | 14 +++++++++++--- 7 files changed, 35 insertions(+), 8 deletions(-) create mode 100644 durabletask-azurefunctions/durabletask/azurefunctions/internal/functions_json.py diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/__init__.py b/durabletask-azurefunctions/durabletask/azurefunctions/__init__.py index c7680213..f34a9668 100644 --- a/durabletask-azurefunctions/durabletask/azurefunctions/__init__.py +++ b/durabletask-azurefunctions/durabletask/azurefunctions/__init__.py @@ -1,6 +1,10 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. +# This import ensures that the replacement of the global JSON encoder/decoder +# happens as soon as the durabletask.azurefunctions package is imported. +import durabletask.azurefunctions.internal.functions_json as _ + from durabletask.azurefunctions.decorators.durable_app import Blueprint, DFApp from durabletask.azurefunctions.client import DurableFunctionsClient diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/client.py b/durabletask-azurefunctions/durabletask/azurefunctions/client.py index 362ef899..181e9c39 100644 --- a/durabletask-azurefunctions/durabletask/azurefunctions/client.py +++ b/durabletask-azurefunctions/durabletask/azurefunctions/client.py @@ -77,7 +77,7 @@ def create_check_status_response(self, request: func.HttpRequest, instance_id: s 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=501, + status_code=202, headers={ 'content-type': 'application/json', 'Location': location_url, diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/decorators/durable_app.py b/durabletask-azurefunctions/durabletask/azurefunctions/decorators/durable_app.py index e4e249ff..f3f02e0a 100644 --- a/durabletask-azurefunctions/durabletask/azurefunctions/decorators/durable_app.py +++ b/durabletask-azurefunctions/durabletask/azurefunctions/decorators/durable_app.py @@ -231,8 +231,6 @@ def durable_client_input(self, @self._configure_function_builder def wrap(fb): def decorator(): - # self._add_rich_client(fb, client_name, DurableFunctionsClient) - fb.add_binding( binding=DurableClient(name=client_name, task_hub=task_hub, diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/http/http_management_payload.py b/durabletask-azurefunctions/durabletask/azurefunctions/http/http_management_payload.py index 9d470c6c..a6836844 100644 --- a/durabletask-azurefunctions/durabletask/azurefunctions/http/http_management_payload.py +++ b/durabletask-azurefunctions/durabletask/azurefunctions/http/http_management_payload.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + import json diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/internal/functions_json.py b/durabletask-azurefunctions/durabletask/azurefunctions/internal/functions_json.py new file mode 100644 index 00000000..71d2b721 --- /dev/null +++ b/durabletask-azurefunctions/durabletask/azurefunctions/internal/functions_json.py @@ -0,0 +1,10 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +import json +from azure.functions._durable_functions import _serialize_custom_object, _deserialize_custom_object +from durabletask.internal import shared + + +shared.to_json = lambda obj: json.dumps(obj, default=_serialize_custom_object) +shared.from_json = lambda json_str: json.loads(json_str, object_hook=_deserialize_custom_object) \ No newline at end of file diff --git a/durabletask/internal/shared.py b/durabletask/internal/shared.py index 1872ad45..298ba20c 100644 --- a/durabletask/internal/shared.py +++ b/durabletask/internal/shared.py @@ -84,11 +84,11 @@ def get_logger( def to_json(obj): - return json.dumps(obj, cls=InternalJSONEncoder) + return json.dumps(obj, cls=global_json_encoder) def from_json(json_str): - return json.loads(json_str, cls=InternalJSONDecoder) + return json.loads(json_str, cls=global_json_decoder) class InternalJSONEncoder(json.JSONEncoder): @@ -127,3 +127,7 @@ def dict_to_object(self, d: dict[str, Any]): if d.pop(AUTO_SERIALIZED, False): return SimpleNamespace(**d) return d + + +global_json_encoder: type = InternalJSONEncoder +global_json_decoder: type = InternalJSONDecoder \ No newline at end of file diff --git a/durabletask/worker.py b/durabletask/worker.py index 838d4abe..e68b3bcd 100644 --- a/durabletask/worker.py +++ b/durabletask/worker.py @@ -20,7 +20,6 @@ from google.protobuf import empty_pb2 from durabletask.internal import helpers -from durabletask.internal.proto_task_hub_sidecar_service_stub import ProtoTaskHubSidecarServiceStub from durabletask.internal.entity_state_shim import StateShim from durabletask.internal.helpers import new_timestamp from durabletask.entities import DurableEntity, EntityLock, EntityInstanceId, EntityContext @@ -800,8 +799,7 @@ def _execute_entity_batch( stub.CompleteEntityTask(batch_result) except Exception as ex: self._logger.exception( - f"Failed to deliver entity response for 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 @@ -1825,6 +1823,16 @@ def _handle_entity_event_raised(self, if not ph.is_empty(event.eventRaised.input): # TODO: Investigate why the event result is wrapped in a dict with "result" key result = shared.from_json(event.eventRaised.input.value)["result"] + # The result here is double-encoded somewhere, so we need to decode it again. This does not happen + # with entityOperationCompleted, so it's either part of the event entity messaging protocol in Core, + # or something done by the WebJobs extension. + if result and isinstance(result, str): + try: + result = shared.from_json(result) + except Exception as ex: + self._logger.warning(f"{ctx.instance_id}: Could not deserialize entity operation result to object " + f"for entity '{entity_id}', defaulting to encoded string." + f"Decode error: {ex}") if is_lock_event: ctx._entity_context.complete_acquire(event.eventRaised.name) entity_task.complete(EntityLock(ctx)) From 20aacab6cedf7b1cd52ea05f1e46daea9fbb523c Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Tue, 6 Jan 2026 14:50:40 -0700 Subject: [PATCH 18/45] Add Functions to requirements --- requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements.txt b/requirements.txt index f32d3500..4907828e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,5 +4,6 @@ protobuf pytest pytest-cov azure-identity +azure-functions asyncio packaging \ No newline at end of file From 5df87b11338501767edd0161b09ab922fd233f06 Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Fri, 30 Jan 2026 12:45:52 -0700 Subject: [PATCH 19/45] Rename to azure-functions-durable v2 --- .../CHANGELOG.md | 0 .../azure/durable_functions/__init__.py | 15 +++++ .../azure/durable_functions}/client.py | 66 +++++++++++++++---- .../azure/durable_functions}/constants.py | 0 .../durable_functions}/decorators/__init__.py | 0 .../decorators/durable_app.py | 7 +- .../durable_functions}/decorators/metadata.py | 2 +- .../azure/durable_functions}/http/__init__.py | 2 +- .../http/http_management_payload.py | 0 .../durable_functions}/internal/__init__.py | 0 .../azurefunctions_grpc_interceptor.py | 0 .../internal/azurefunctions_null_stub.py | 0 .../internal/functions_json.py | 0 .../azure/durable_functions}/worker.py | 2 +- .../pyproject.toml | 8 +-- durabletask-azurefunctions/__init__.py | 0 .../durabletask/azurefunctions/__init__.py | 11 ---- durabletask/client.py | 17 ++++- 18 files changed, 93 insertions(+), 37 deletions(-) rename {durabletask-azurefunctions => azure-functions-durable}/CHANGELOG.md (100%) create mode 100644 azure-functions-durable/azure/durable_functions/__init__.py rename {durabletask-azurefunctions/durabletask/azurefunctions => azure-functions-durable/azure/durable_functions}/client.py (66%) rename {durabletask-azurefunctions/durabletask/azurefunctions => azure-functions-durable/azure/durable_functions}/constants.py (100%) rename {durabletask-azurefunctions/durabletask/azurefunctions => azure-functions-durable/azure/durable_functions}/decorators/__init__.py (100%) rename {durabletask-azurefunctions/durabletask/azurefunctions => azure-functions-durable/azure/durable_functions}/decorators/durable_app.py (98%) rename {durabletask-azurefunctions/durabletask/azurefunctions => azure-functions-durable/azure/durable_functions}/decorators/metadata.py (97%) rename {durabletask-azurefunctions/durabletask/azurefunctions => azure-functions-durable/azure/durable_functions}/http/__init__.py (55%) rename {durabletask-azurefunctions/durabletask/azurefunctions => azure-functions-durable/azure/durable_functions}/http/http_management_payload.py (100%) rename {durabletask-azurefunctions/durabletask/azurefunctions => azure-functions-durable/azure/durable_functions}/internal/__init__.py (100%) rename {durabletask-azurefunctions/durabletask/azurefunctions => azure-functions-durable/azure/durable_functions}/internal/azurefunctions_grpc_interceptor.py (100%) rename {durabletask-azurefunctions/durabletask/azurefunctions => azure-functions-durable/azure/durable_functions}/internal/azurefunctions_null_stub.py (100%) rename {durabletask-azurefunctions/durabletask/azurefunctions => azure-functions-durable/azure/durable_functions}/internal/functions_json.py (100%) rename {durabletask-azurefunctions/durabletask/azurefunctions => azure-functions-durable/azure/durable_functions}/worker.py (97%) rename {durabletask-azurefunctions => azure-functions-durable}/pyproject.toml (86%) delete mode 100644 durabletask-azurefunctions/__init__.py delete mode 100644 durabletask-azurefunctions/durabletask/azurefunctions/__init__.py diff --git a/durabletask-azurefunctions/CHANGELOG.md b/azure-functions-durable/CHANGELOG.md similarity index 100% rename from durabletask-azurefunctions/CHANGELOG.md rename to azure-functions-durable/CHANGELOG.md 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..1c0b6f42 --- /dev/null +++ b/azure-functions-durable/azure/durable_functions/__init__.py @@ -0,0 +1,15 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# This import ensures that the replacement of the global JSON encoder/decoder +# happens as soon as the durabletask.azurefunctions package is imported. +from .internal import functions_json as _ + +from .decorators.durable_app import Blueprint, DFApp +from .client import DurableFunctionsClient + +# IMPORTANT: DO NOT REMOVE. `azure-functions` relies on the presence and value of this variable +# for version detection +version = "2.x" + +__all__ = ["Blueprint", "DFApp", "DurableFunctionsClient", "version"] diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/client.py b/azure-functions-durable/azure/durable_functions/client.py similarity index 66% rename from durabletask-azurefunctions/durabletask/azurefunctions/client.py rename to azure-functions-durable/azure/durable_functions/client.py index 181e9c39..7ca31466 100644 --- a/durabletask-azurefunctions/durabletask/azurefunctions/client.py +++ b/azure-functions-durable/azure/durable_functions/client.py @@ -4,14 +4,13 @@ import json from datetime import timedelta -from typing import Any, Optional import azure.functions as func -from urllib.parse import urlparse, quote +from urllib.parse import urlparse, urljoin, quote -from durabletask.entities import EntityInstanceId from durabletask.client import TaskHubGrpcClient -from durabletask.azurefunctions.internal.azurefunctions_grpc_interceptor import AzureFunctionsDefaultClientInterceptorImpl -from durabletask.azurefunctions.http import HttpManagementPayload +from .internal.azurefunctions_grpc_interceptor import AzureFunctionsDefaultClientInterceptorImpl +from .http import HttpManagementPayload +import requests # Client class used for Durable Functions @@ -38,6 +37,31 @@ def __init__(self, client_as_string: str): 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) + if self.httpBaseUrl is None: + # This happens when the extension has not been configured for gRPC yet. For some reason, instead of + # the client returning with null rpcBaseUrl and httpBaseUrl, it returns rpcBaseUrl with the http url. + self.configure_extension_for_grpc() + + interceptors = [AzureFunctionsDefaultClientInterceptorImpl(self.taskHubName, self.requiredQueryStringParameters)] + + # 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) + + 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. @@ -57,15 +81,31 @@ def __init__(self, client_as_string: str): self.maxGrpcMessageSizeInBytes = client.get("maxGrpcMessageSizeInBytes", 0) # TODO: convert the string value back to timedelta - annoying regex? self.grpcHttpClientTimeout = client.get("grpcHttpClientTimeout", timedelta(seconds=30)) - interceptors = [AzureFunctionsDefaultClientInterceptorImpl(self.taskHubName, self.requiredQueryStringParameters)] - # 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) + def configure_extension_for_grpc(self) -> None: + """Configures the Durable Functions extension for gRPC communication. + + Makes an HTTP request to the extension's management endpoint to enable gRPC. + """ + + # Make an HTTP request to the extension to configure gRPC + configure_base_url = self.httpBaseUrl + if not configure_base_url: + # For some reason, in the "bad" case when rpc has not been configured, the httpBaseUrl is empty and sent in rpcBaseUrl + configure_base_url = self.rpcBaseUrl + # configure_base_url = urlparse(configure_base_url) + # url = f"{configure_base_url.scheme}://{configure_base_url.netloc}/management/configureGrpc" + url = urljoin(configure_base_url, "management/configureGrpc") + params = { + "taskHubName": self.taskHubName, + "connectionName": self.connectionName + } + response = requests.get(url, params=params) + if response.status_code != 200: + raise Exception(f"Failed to configure gRPC for Durable Functions extension. Status code: {response.status_code}, Response: {response.text}") + + # Parse the response to update client configuration - it's double-encoded so we need to load it twice + self._parse_client_configuration(json.loads(response.text)) 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. diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/constants.py b/azure-functions-durable/azure/durable_functions/constants.py similarity index 100% rename from durabletask-azurefunctions/durabletask/azurefunctions/constants.py rename to azure-functions-durable/azure/durable_functions/constants.py diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/decorators/__init__.py b/azure-functions-durable/azure/durable_functions/decorators/__init__.py similarity index 100% rename from durabletask-azurefunctions/durabletask/azurefunctions/decorators/__init__.py rename to azure-functions-durable/azure/durable_functions/decorators/__init__.py diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/decorators/durable_app.py b/azure-functions-durable/azure/durable_functions/decorators/durable_app.py similarity index 98% rename from durabletask-azurefunctions/durabletask/azurefunctions/decorators/durable_app.py rename to azure-functions-durable/azure/durable_functions/decorators/durable_app.py index f3f02e0a..584d3bfa 100644 --- a/durabletask-azurefunctions/durabletask/azurefunctions/decorators/durable_app.py +++ b/azure-functions-durable/azure/durable_functions/decorators/durable_app.py @@ -5,14 +5,13 @@ from durabletask import task -from .metadata import OrchestrationTrigger, ActivityTrigger, EntityTrigger, \ - DurableClient from typing import Callable, Optional from typing import Union from azure.functions import FunctionRegister, TriggerApi, BindingApi, AuthLevel -from durabletask.azurefunctions.client import DurableFunctionsClient -from durabletask.azurefunctions.worker import DurableFunctionsWorker +from .metadata import OrchestrationTrigger, ActivityTrigger, EntityTrigger, \ + DurableClient +from ..worker import DurableFunctionsWorker class Blueprint(TriggerApi, BindingApi): diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/decorators/metadata.py b/azure-functions-durable/azure/durable_functions/decorators/metadata.py similarity index 97% rename from durabletask-azurefunctions/durabletask/azurefunctions/decorators/metadata.py rename to azure-functions-durable/azure/durable_functions/decorators/metadata.py index 21cd7f42..00fed0e5 100644 --- a/durabletask-azurefunctions/durabletask/azurefunctions/decorators/metadata.py +++ b/azure-functions-durable/azure/durable_functions/decorators/metadata.py @@ -3,7 +3,7 @@ from typing import Optional -from durabletask.azurefunctions.constants import ORCHESTRATION_TRIGGER, \ +from ..constants import ORCHESTRATION_TRIGGER, \ ACTIVITY_TRIGGER, ENTITY_TRIGGER, DURABLE_CLIENT from azure.functions.decorators.core import Trigger, InputBinding diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/http/__init__.py b/azure-functions-durable/azure/durable_functions/http/__init__.py similarity index 55% rename from durabletask-azurefunctions/durabletask/azurefunctions/http/__init__.py rename to azure-functions-durable/azure/durable_functions/http/__init__.py index fc1cb6ba..b4d2c355 100644 --- a/durabletask-azurefunctions/durabletask/azurefunctions/http/__init__.py +++ b/azure-functions-durable/azure/durable_functions/http/__init__.py @@ -1,6 +1,6 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. -from durabletask.azurefunctions.http.http_management_payload import HttpManagementPayload +from ..http.http_management_payload import HttpManagementPayload __all__ = ["HttpManagementPayload"] diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/http/http_management_payload.py b/azure-functions-durable/azure/durable_functions/http/http_management_payload.py similarity index 100% rename from durabletask-azurefunctions/durabletask/azurefunctions/http/http_management_payload.py rename to azure-functions-durable/azure/durable_functions/http/http_management_payload.py diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/internal/__init__.py b/azure-functions-durable/azure/durable_functions/internal/__init__.py similarity index 100% rename from durabletask-azurefunctions/durabletask/azurefunctions/internal/__init__.py rename to azure-functions-durable/azure/durable_functions/internal/__init__.py diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/internal/azurefunctions_grpc_interceptor.py b/azure-functions-durable/azure/durable_functions/internal/azurefunctions_grpc_interceptor.py similarity index 100% rename from durabletask-azurefunctions/durabletask/azurefunctions/internal/azurefunctions_grpc_interceptor.py rename to azure-functions-durable/azure/durable_functions/internal/azurefunctions_grpc_interceptor.py diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/internal/azurefunctions_null_stub.py b/azure-functions-durable/azure/durable_functions/internal/azurefunctions_null_stub.py similarity index 100% rename from durabletask-azurefunctions/durabletask/azurefunctions/internal/azurefunctions_null_stub.py rename to azure-functions-durable/azure/durable_functions/internal/azurefunctions_null_stub.py diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/internal/functions_json.py b/azure-functions-durable/azure/durable_functions/internal/functions_json.py similarity index 100% rename from durabletask-azurefunctions/durabletask/azurefunctions/internal/functions_json.py rename to azure-functions-durable/azure/durable_functions/internal/functions_json.py diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/worker.py b/azure-functions-durable/azure/durable_functions/worker.py similarity index 97% rename from durabletask-azurefunctions/durabletask/azurefunctions/worker.py rename to azure-functions-durable/azure/durable_functions/worker.py index 5cef7f4e..47713725 100644 --- a/durabletask-azurefunctions/durabletask/azurefunctions/worker.py +++ b/azure-functions-durable/azure/durable_functions/worker.py @@ -9,7 +9,7 @@ from durabletask.worker import _Registry, ConcurrencyOptions from durabletask.internal import shared from durabletask.worker import TaskHubGrpcWorker -from durabletask.azurefunctions.internal.azurefunctions_null_stub import AzureFunctionsNullStub +from .internal.azurefunctions_null_stub import AzureFunctionsNullStub # Worker class used for Durable Task Scheduler (DTS) diff --git a/durabletask-azurefunctions/pyproject.toml b/azure-functions-durable/pyproject.toml similarity index 86% rename from durabletask-azurefunctions/pyproject.toml rename to azure-functions-durable/pyproject.toml index 79704f0e..46faa264 100644 --- a/durabletask-azurefunctions/pyproject.toml +++ b/azure-functions-durable/pyproject.toml @@ -8,8 +8,8 @@ requires = ["setuptools", "wheel"] build-backend = "setuptools.build_meta" [project] -name = "durabletask.azurefunctions" -version = "0.0.1dev0" +name = "azure-functions-durable" +version = "2.0.0dev0" description = "Durable Task Python SDK provider implementation for Durable Azure Functions" keywords = [ "durable", @@ -23,7 +23,7 @@ classifiers = [ "Programming Language :: Python :: 3", "License :: OSI Approved :: MIT License", ] -requires-python = ">=3.9" +requires-python = ">=3.13" license = {file = "LICENSE"} readme = "README.md" dependencies = [ @@ -37,7 +37,7 @@ repository = "https://github.com/microsoft/durabletask-python" changelog = "https://github.com/microsoft/durabletask-python/blob/main/CHANGELOG.md" [tool.setuptools.packages.find] -include = ["durabletask.azurefunctions", "durabletask.azurefunctions.*"] +include = ["azure.durable_functions", "azure.durable_functions.*"] [tool.pytest.ini_options] minversion = "6.0" diff --git a/durabletask-azurefunctions/__init__.py b/durabletask-azurefunctions/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/__init__.py b/durabletask-azurefunctions/durabletask/azurefunctions/__init__.py deleted file mode 100644 index f34a9668..00000000 --- a/durabletask-azurefunctions/durabletask/azurefunctions/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -# This import ensures that the replacement of the global JSON encoder/decoder -# happens as soon as the durabletask.azurefunctions package is imported. -import durabletask.azurefunctions.internal.functions_json as _ - -from durabletask.azurefunctions.decorators.durable_app import Blueprint, DFApp -from durabletask.azurefunctions.client import DurableFunctionsClient - -__all__ = ["Blueprint", "DFApp", "DurableFunctionsClient"] diff --git a/durabletask/client.py b/durabletask/client.py index 7d037585..39fe3d0b 100644 --- a/durabletask/client.py +++ b/durabletask/client.py @@ -103,6 +103,21 @@ def __init__(self, *, interceptors: Optional[Sequence[shared.ClientInterceptor]] = None, default_version: Optional[str] = None): + self.configure_grpc_channel( + host_address=host_address, + metadata=metadata, + secure_channel=secure_channel, + interceptors=interceptors + ) + + self._logger = shared.get_logger("client", log_handler, log_formatter) + self.default_version = default_version + + def configure_grpc_channel(self, + host_address: Optional[str] = None, + metadata: Optional[list[tuple[str, str]]] = None, + secure_channel: bool = False, + interceptors: Optional[Sequence[shared.ClientInterceptor]] = None) -> None: # If the caller provided metadata, we need to create a new interceptor for it and # add it to the list of interceptors. if interceptors is not None: @@ -120,8 +135,6 @@ def __init__(self, *, interceptors=interceptors ) self._stub = stubs.TaskHubSidecarServiceStub(channel) - self._logger = shared.get_logger("client", log_handler, log_formatter) - self.default_version = default_version def schedule_new_orchestration(self, orchestrator: Union[task.Orchestrator[TInput, TOutput], str], *, input: Optional[TInput] = None, From ea5c2b06fc7695bdcb20a36f88bc70c9414b003c Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Mon, 9 Feb 2026 11:07:14 -0700 Subject: [PATCH 20/45] Re-add Orchestrator object/model --- .../azure/durable_functions/__init__.py | 3 +- .../decorators/durable_app.py | 6 +- .../azure/durable_functions/orchestrator.py | 69 +++++++++++++++++++ 3 files changed, 73 insertions(+), 5 deletions(-) create mode 100644 azure-functions-durable/azure/durable_functions/orchestrator.py diff --git a/azure-functions-durable/azure/durable_functions/__init__.py b/azure-functions-durable/azure/durable_functions/__init__.py index 1c0b6f42..c15bc696 100644 --- a/azure-functions-durable/azure/durable_functions/__init__.py +++ b/azure-functions-durable/azure/durable_functions/__init__.py @@ -7,9 +7,10 @@ from .decorators.durable_app import Blueprint, DFApp from .client import DurableFunctionsClient +from .orchestrator import Orchestrator # IMPORTANT: DO NOT REMOVE. `azure-functions` relies on the presence and value of this variable # for version detection version = "2.x" -__all__ = ["Blueprint", "DFApp", "DurableFunctionsClient", "version"] +__all__ = ["Blueprint", "DFApp", "DurableFunctionsClient", "Orchestrator", "version"] diff --git a/azure-functions-durable/azure/durable_functions/decorators/durable_app.py b/azure-functions-durable/azure/durable_functions/decorators/durable_app.py index 584d3bfa..26572d40 100644 --- a/azure-functions-durable/azure/durable_functions/decorators/durable_app.py +++ b/azure-functions-durable/azure/durable_functions/decorators/durable_app.py @@ -12,6 +12,7 @@ from .metadata import OrchestrationTrigger, ActivityTrigger, EntityTrigger, \ DurableClient from ..worker import DurableFunctionsWorker +from ..orchestrator import Orchestrator class Blueprint(TriggerApi, BindingApi): @@ -58,10 +59,7 @@ def _configure_orchestrator_callable(self, wrap) -> Callable: def decorator(orchestrator_func: task.Orchestrator): # Construct an orchestrator based on the end-user code - def handle(context) -> str: - return DurableFunctionsWorker()._execute_orchestrator(orchestrator_func, context) - - handle.orchestrator_function = orchestrator_func # type: ignore + handle = Orchestrator.create(orchestrator_func) # invoke next decorator, with the Orchestrator as input handle.__name__ = orchestrator_func.__name__ 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..6e7c6496 --- /dev/null +++ b/azure-functions-durable/azure/durable_functions/orchestrator.py @@ -0,0 +1,69 @@ +"""Durable Orchestrator. + +Responsible for orchestrating the execution of the user defined generator +function. +""" +from typing import Callable, Any, 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: OrchestrationContext) -> str: + """Handle the orchestration of the user defined generator function. + + Parameters + ---------- + context : DurableOrchestrationContext + The DF orchestration context + + Returns + ------- + str + The JSON-formatted string representing the user's orchestration + state after this invocation + """ + self.durable_context = context + return DurableFunctionsWorker()._execute_orchestrator(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 + """ + + def handle(context) -> str: + return Orchestrator(fn).handle(context) + + handle.orchestrator_function = fn # type: ignore + + return handle From 0505a0d1712fd6789097649491f05f08be6e8c9b Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Tue, 23 Jun 2026 11:01:20 -0600 Subject: [PATCH 21/45] Modernize pipelines for functions package --- .../durabletask-azurefunctions-dev.yml | 52 ---------- ...urabletask-azurefunctions-experimental.yml | 51 ---------- .../workflows/durabletask-azurefunctions.yml | 97 ++++--------------- azure-functions-durable/pyproject.toml | 3 +- durabletask/internal/shared.py | 2 +- eng/ci/release.yml | 32 ++++++ eng/templates/build.yml | 26 ++++- tests/azure-functions-durable/__init__.py | 0 tests/azure-functions-durable/test_smoke.py | 20 ++++ 9 files changed, 98 insertions(+), 185 deletions(-) delete mode 100644 .github/workflows/durabletask-azurefunctions-dev.yml delete mode 100644 .github/workflows/durabletask-azurefunctions-experimental.yml create mode 100644 tests/azure-functions-durable/__init__.py create mode 100644 tests/azure-functions-durable/test_smoke.py diff --git a/.github/workflows/durabletask-azurefunctions-dev.yml b/.github/workflows/durabletask-azurefunctions-dev.yml deleted file mode 100644 index fa7b720f..00000000 --- a/.github/workflows/durabletask-azurefunctions-dev.yml +++ /dev/null @@ -1,52 +0,0 @@ -name: Durable Task Scheduler SDK (durabletask-azurefunctions) Dev Release - -on: - workflow_run: - workflows: ["Durable Task Scheduler SDK (durabletask-azurefunctions)"] - types: - - completed - branches: - - main - -jobs: - publish-dev: - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Extract version from tag - run: echo "VERSION=${GITHUB_REF#refs/tags/azurefunctions-v}" >> $GITHUB_ENV # Extract version from the tag - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: "3.14" # Adjust Python version as needed - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install build twine - - - name: Append dev to version in pyproject.toml - working-directory: durabletask-azurefunctions - run: | - sed -i 's/^version = "\(.*\)"/version = "\1.dev${{ github.run_number }}"/' pyproject.toml - - - name: Build package from directory durabletask-azurefunctions - working-directory: durabletask-azurefunctions - run: | - python -m build - - - name: Check package - working-directory: durabletask-azurefunctions - run: | - twine check dist/* - - - name: Publish package to PyPI - env: - TWINE_USERNAME: __token__ - TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN_AZUREFUNCTIONS }} # Store your PyPI API token in GitHub Secrets - working-directory: durabletask-azurefunctions - run: | - twine upload dist/* \ No newline at end of file diff --git a/.github/workflows/durabletask-azurefunctions-experimental.yml b/.github/workflows/durabletask-azurefunctions-experimental.yml deleted file mode 100644 index 49b8c250..00000000 --- a/.github/workflows/durabletask-azurefunctions-experimental.yml +++ /dev/null @@ -1,51 +0,0 @@ -name: Durable Task Scheduler SDK (durabletask-azurefunctions) Experimental Release - -on: - push: - branches-ignore: - - main - - release/* - -jobs: - publish-experimental: - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Extract version from tag - run: echo "VERSION=${GITHUB_REF#refs/tags/azurefunctions-v}" >> $GITHUB_ENV # Extract version from the tag - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: "3.14" # Adjust Python version as needed - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install build twine - - - name: Change the version in pyproject.toml to 0.0.0dev{github.run_number} - working-directory: durabletask-azurefunctions - run: | - sed -i 's/^version = ".*"/version = "0.0.0.dev${{ github.run_number }}"/' pyproject.toml - sed -i 's/"durabletask>=.*"/"durabletask>=0.0.0dev1"/' pyproject.toml - - - name: Build package from directory durabletask-azurefunctions - working-directory: durabletask-azurefunctions - run: | - python -m build - - - name: Check package - working-directory: durabletask-azurefunctions - run: | - twine check dist/* - - - name: Publish package to PyPI - env: - TWINE_USERNAME: __token__ - TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN_AZUREFUNCTIONS }} # Store your PyPI API token in GitHub Secrets - working-directory: durabletask-azurefunctions - run: | - twine upload dist/* \ No newline at end of file diff --git a/.github/workflows/durabletask-azurefunctions.yml b/.github/workflows/durabletask-azurefunctions.yml index 2fc74540..6df274f1 100644 --- a/.github/workflows/durabletask-azurefunctions.yml +++ b/.github/workflows/durabletask-azurefunctions.yml @@ -1,4 +1,4 @@ -name: Durable Task Scheduler SDK (durabletask-azurefunctions) +name: Durable Task Scheduler SDK (azure-functions-durable) on: push: @@ -10,6 +10,9 @@ on: branches: - "main" +permissions: + contents: read + jobs: lint: runs-on: ubuntu-latest @@ -20,107 +23,45 @@ jobs: with: python-version: 3.14 - name: Install dependencies - working-directory: durabletask-azurefunctions + 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: durabletask-azurefunctions + working-directory: azure-functions-durable run: flake8 . - name: Run flake8 Linter - working-directory: tests/durabletask-azurefunctions + working-directory: tests/azure-functions-durable run: flake8 . - run-docker-tests: + run-tests: strategy: fail-fast: false matrix: - python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] - env: - EMULATOR_VERSION: "latest" + python-version: ["3.13", "3.14"] needs: lint runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@v4 - - name: Pull Docker image - run: docker pull mcr.microsoft.com/dts/dts-emulator:$EMULATOR_VERSION - - - name: Run Docker container - run: | - docker run --name dtsemulator -d -p 8080:8080 mcr.microsoft.com/dts/dts-emulator:$EMULATOR_VERSION - - - name: Wait for container to be ready - run: sleep 10 # Adjust if your service needs more time to start - - - name: Set environment variables - run: | - echo "TASKHUB=default" >> $GITHUB_ENV - echo "ENDPOINT=http://localhost:8080" >> $GITHUB_ENV - - - name: Install durabletask dependencies - run: | - python -m pip install --upgrade pip - pip install flake8 pytest - pip install -r requirements.txt - - - name: Install durabletask-azurefunctions dependencies - working-directory: examples - run: | - python -m pip install --upgrade pip - pip install -r requirements.txt - - - name: Install durabletask-azurefunctions locally - working-directory: durabletask-azurefunctions - run: | - pip install . --no-deps --force-reinstall - - - name: Install durabletask locally - run: | - pip install . --no-deps --force-reinstall - - - name: Run the tests - working-directory: tests/durabletask-azurefunctions - run: | - pytest -m "dts" --verbose - - publish-release: - if: startsWith(github.ref, 'refs/tags/azurefunctions-v') # Only run if a matching tag is pushed - needs: run-docker-tests - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Extract version from tag - run: echo "VERSION=${GITHUB_REF#refs/tags/azurefunctions-v}" >> $GITHUB_ENV # Extract version from the tag - - - name: Set up Python + - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: - python-version: "3.14" # Adjust Python version as needed + python-version: ${{ matrix.python-version }} - - name: Install dependencies + - name: Install durabletask locally run: | python -m pip install --upgrade pip - pip install build twine + pip install pytest + pip install . --force-reinstall - - name: Build package from directory durabletask-azurefunctions - working-directory: durabletask-azurefunctions + - name: Install azure-functions-durable locally run: | - python -m build + pip install ./azure-functions-durable --force-reinstall - - name: Check package - working-directory: durabletask-azurefunctions + - name: Run unit tests + working-directory: tests/azure-functions-durable run: | - twine check dist/* - - - name: Publish package to PyPI - env: - TWINE_USERNAME: __token__ - TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN_AZUREFUNCTIONS }} # Store your PyPI API token in GitHub Secrets - working-directory: durabletask-azurefunctions - run: | - twine upload dist/* \ No newline at end of file + pytest -m "not dts and not azurite" --verbose diff --git a/azure-functions-durable/pyproject.toml b/azure-functions-durable/pyproject.toml index 46faa264..5f171e71 100644 --- a/azure-functions-durable/pyproject.toml +++ b/azure-functions-durable/pyproject.toml @@ -29,7 +29,8 @@ readme = "README.md" dependencies = [ "durabletask>=1.2.0dev0", "azure-identity>=1.19.0", - "azure-functions>=1.25.0b3.dev1" + "azure-functions>=1.25.0b3.dev1", + "requests>=2.31.0" ] [project.urls] diff --git a/durabletask/internal/shared.py b/durabletask/internal/shared.py index 162e09a8..f8afc7c0 100644 --- a/durabletask/internal/shared.py +++ b/durabletask/internal/shared.py @@ -203,4 +203,4 @@ def dict_to_object(self, d: dict[str, Any]) -> Any: # If the object was serialized by the InternalJSONEncoder, deserialize it as a SimpleNamespace if d.pop(AUTO_SERIALIZED, False): return SimpleNamespace(**d) - return d \ No newline at end of file + return d diff --git a/eng/ci/release.yml b/eng/ci/release.yml index 7b58c7fd..335e58ee 100644 --- a/eng/ci/release.yml +++ b/eng/ci/release.yml @@ -90,3 +90,35 @@ extends: serviceendpointurl: "https://api.esrp.microsoft.com" mainpublisher: "durabletask-java" domaintenantid: "33e01921-4d64-4f8c-a055-5bdaffd5e33d" + + - 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/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/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 From c9ea2fbcf3eb83b21d9b65ae50b81433e1d526eb Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Tue, 23 Jun 2026 11:35:34 -0600 Subject: [PATCH 22/45] Cleanup pyright errors --- .github/workflows/typecheck.yml | 23 ++++ .../azure/durable_functions/__init__.py | 9 +- .../azure/durable_functions/client.py | 8 +- .../decorators/durable_app.py | 107 ++++++++++++------ .../durable_functions/decorators/metadata.py | 8 +- .../internal/azurefunctions_null_stub.py | 47 +++----- .../internal/functions_json.py | 33 +++++- .../azure/durable_functions/orchestrator.py | 11 +- .../azure/durable_functions/worker.py | 50 ++++---- azure-functions-durable/pyrightconfig.json | 16 +++ 10 files changed, 201 insertions(+), 111 deletions(-) create mode 100644 azure-functions-durable/pyrightconfig.json diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml index f10463ad..cc0c2bdd 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" @@ -36,3 +37,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/azure-functions-durable/azure/durable_functions/__init__.py b/azure-functions-durable/azure/durable_functions/__init__.py index c15bc696..01389b80 100644 --- a/azure-functions-durable/azure/durable_functions/__init__.py +++ b/azure-functions-durable/azure/durable_functions/__init__.py @@ -1,14 +1,15 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. -# This import ensures that the replacement of the global JSON encoder/decoder -# happens as soon as the durabletask.azurefunctions package is imported. -from .internal import functions_json as _ - +from .internal.functions_json import install_custom_serialization from .decorators.durable_app import Blueprint, DFApp from .client import DurableFunctionsClient from .orchestrator import Orchestrator +# Ensure the durabletask JSON encoder/decoder is replaced as soon as the +# durable_functions package is imported. +install_custom_serialization() + # IMPORTANT: DO NOT REMOVE. `azure-functions` relies on the presence and value of this variable # for version detection version = "2.x" diff --git a/azure-functions-durable/azure/durable_functions/client.py b/azure-functions-durable/azure/durable_functions/client.py index 7ca31466..0f9583f5 100644 --- a/azure-functions-durable/azure/durable_functions/client.py +++ b/azure-functions-durable/azure/durable_functions/client.py @@ -18,7 +18,7 @@ class DurableFunctionsClient(TaskHubGrpcClient): """A gRPC client passed to Durable Functions durable client bindings. Connects to the Durable Functions runtime using gRPC and provides methods - for creating and managing Durable orchestrations, interacting with Durable entities, + 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 @@ -44,7 +44,7 @@ def __init__(self, client_as_string: str): json.JSONDecodeError: If the provided string is not valid JSON. """ self._parse_client_configuration(client_as_string) - if self.httpBaseUrl is None: + if not self.httpBaseUrl: # This happens when the extension has not been configured for gRPC yet. For some reason, instead of # the client returning with null rpcBaseUrl and httpBaseUrl, it returns rpcBaseUrl with the http url. self.configure_extension_for_grpc() @@ -87,7 +87,7 @@ def configure_extension_for_grpc(self) -> None: Makes an HTTP request to the extension's management endpoint to enable gRPC. """ - + # Make an HTTP request to the extension to configure gRPC configure_base_url = self.httpBaseUrl if not configure_base_url: @@ -103,7 +103,7 @@ def configure_extension_for_grpc(self) -> None: response = requests.get(url, params=params) if response.status_code != 200: raise Exception(f"Failed to configure gRPC for Durable Functions extension. Status code: {response.status_code}, Response: {response.text}") - + # Parse the response to update client configuration - it's double-encoded so we need to load it twice self._parse_client_configuration(json.loads(response.text)) diff --git a/azure-functions-durable/azure/durable_functions/decorators/durable_app.py b/azure-functions-durable/azure/durable_functions/decorators/durable_app.py index 26572d40..4f828a11 100644 --- a/azure-functions-durable/azure/durable_functions/decorators/durable_app.py +++ b/azure-functions-durable/azure/durable_functions/decorators/durable_app.py @@ -2,12 +2,12 @@ # Licensed under the MIT License. from functools import wraps +from typing import Any, Callable, Optional, Union -from durabletask import task - -from typing import Callable, Optional -from typing import Union from azure.functions import FunctionRegister, TriggerApi, BindingApi, AuthLevel +from azure.functions.decorators.function_app import FunctionBuilder + +from durabletask import task from .metadata import OrchestrationTrigger, ActivityTrigger, EntityTrigger, \ DurableClient @@ -40,9 +40,14 @@ def __init__(self, DFApp New instance of a Durable Functions app """ - super().__init__(auth_level=http_auth_level) - - def _configure_orchestrator_callable(self, wrap) -> Callable: + # 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] + + def _configure_orchestrator_callable( + self, + wrap: Callable[[Callable[..., Any]], FunctionBuilder] + ) -> Callable[[task.Orchestrator[Any, Any]], FunctionBuilder]: """Obtain decorator to construct an Orchestrator class from a user-defined Function. Parameters @@ -56,7 +61,7 @@ def _configure_orchestrator_callable(self, wrap) -> 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): + def decorator(orchestrator_func: task.Orchestrator[Any, Any]) -> FunctionBuilder: # Construct an orchestrator based on the end-user code handle = Orchestrator.create(orchestrator_func) @@ -67,7 +72,10 @@ def decorator(orchestrator_func: task.Orchestrator): return decorator - def _configure_entity_callable(self, wrap) -> Callable: + 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 @@ -81,16 +89,16 @@ def _configure_entity_callable(self, wrap) -> 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): + 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"). - def handle(context) -> str: - return DurableFunctionsWorker()._execute_entity_batch(entity_func, context) + def handle(context: Any) -> str: + return DurableFunctionsWorker().execute_entity_batch_request(entity_func, context) - handle.entity_function = entity_func # type: ignore + handle.entity_function = entity_func # pyright: ignore[reportFunctionMemberAccess] # invoke next decorator, with the Entity as input handle.__name__ = entity_func.__name__ @@ -98,17 +106,27 @@ def handle(context) -> str: return decorator - def _add_rich_client(self, fb, parameter_name, client_constructor): + 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. - user_code = fb._function._func + # ``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, **kwargs): + async def df_client_middleware(*args: Any, **kwargs: Any) -> Any: # Obtain JSON-string currently passed as DF Client, # construct rich object from it, @@ -121,13 +139,30 @@ async def df_client_middleware(*args, **kwargs): return await user_code(*args, **kwargs) # TODO: Is there a better way to support retrieving the unwrapped user code? - df_client_middleware.client_function = fb._function._func # type: ignore + df_client_middleware.client_function = function_obj._func # pyright: ignore[reportAttributeAccessIssue] - user_code_with_rich_client = df_client_middleware - fb._function._func = user_code_with_rich_client + 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): + orchestration: Optional[str] = None + ) -> Callable[[task.Orchestrator[Any, Any]], FunctionBuilder]: """Register an Orchestrator Function. Parameters @@ -139,10 +174,10 @@ def orchestration_trigger(self, context_name: str, The value is None by default, in which case the name of the method is used. """ @self._configure_orchestrator_callable - @self._configure_function_builder - def wrap(fb): + @self._build_function + def wrap(fb: FunctionBuilder) -> FunctionBuilder: - def decorator(): + def decorator() -> FunctionBuilder: fb.add_trigger( trigger=OrchestrationTrigger(name=context_name, orchestration=orchestration)) @@ -153,7 +188,8 @@ def decorator(): return wrap def activity_trigger(self, input_name: str, - activity: Optional[str] = None): + activity: Optional[str] = None + ) -> Callable[[Callable[..., Any]], FunctionBuilder]: """Register an Activity Function. Parameters @@ -164,9 +200,9 @@ def activity_trigger(self, input_name: str, Name of Activity Function. The value is None by default, in which case the name of the method is used. """ - @self._configure_function_builder - def wrap(fb): - def decorator(): + @self._build_function + def wrap(fb: FunctionBuilder) -> FunctionBuilder: + def decorator() -> FunctionBuilder: fb.add_trigger( trigger=ActivityTrigger(name=input_name, activity=activity)) @@ -178,7 +214,8 @@ def decorator(): def entity_trigger(self, context_name: str, - entity_name: Optional[str] = None): + entity_name: Optional[str] = None + ) -> Callable[[task.Entity[Any, Any]], FunctionBuilder]: """Register an Entity Function. Parameters @@ -190,9 +227,9 @@ def entity_trigger(self, The value is None by default, in which case the name of the method is used. """ @self._configure_entity_callable - @self._configure_function_builder - def wrap(fb): - def decorator(): + @self._build_function + def wrap(fb: FunctionBuilder) -> FunctionBuilder: + def decorator() -> FunctionBuilder: fb.add_trigger( trigger=EntityTrigger(name=context_name, entity_name=entity_name)) @@ -206,7 +243,7 @@ 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 @@ -225,9 +262,9 @@ def durable_client_input(self, account connection string for the function app is used. """ - @self._configure_function_builder - def wrap(fb): - def decorator(): + @self._build_function + def wrap(fb: FunctionBuilder) -> FunctionBuilder: + def decorator() -> FunctionBuilder: fb.add_binding( binding=DurableClient(name=client_name, task_hub=task_hub, diff --git a/azure-functions-durable/azure/durable_functions/decorators/metadata.py b/azure-functions-durable/azure/durable_functions/decorators/metadata.py index 00fed0e5..efe3983d 100644 --- a/azure-functions-durable/azure/durable_functions/decorators/metadata.py +++ b/azure-functions-durable/azure/durable_functions/decorators/metadata.py @@ -28,7 +28,7 @@ def get_binding_name() -> str: def __init__(self, name: str, orchestration: Optional[str] = None, - durable_requires_grpc=True, + durable_requires_grpc: bool = True, ) -> None: self.orchestration = orchestration self.durable_requires_grpc = durable_requires_grpc @@ -55,7 +55,7 @@ def get_binding_name() -> str: def __init__(self, name: str, activity: Optional[str] = None, - durable_requires_grpc=True, + durable_requires_grpc: bool = True, ) -> None: self.activity = activity self.durable_requires_grpc = durable_requires_grpc @@ -82,7 +82,7 @@ def get_binding_name() -> str: def __init__(self, name: str, entity_name: Optional[str] = None, - durable_requires_grpc=True, + durable_requires_grpc: bool = True, ) -> None: self.entity_name = entity_name self.durable_requires_grpc = durable_requires_grpc @@ -110,7 +110,7 @@ def __init__(self, name: str, task_hub: Optional[str] = None, connection_name: Optional[str] = None, - durable_requires_grpc=True, + durable_requires_grpc: bool = True, ) -> None: self.task_hub = task_hub self.connection_name = connection_name 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 index 75a48a0a..af8593d1 100644 --- a/azure-functions-durable/azure/durable_functions/internal/azurefunctions_null_stub.py +++ b/azure-functions-durable/azure/durable_functions/internal/azurefunctions_null_stub.py @@ -1,34 +1,23 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. -from durabletask.internal.proto_task_hub_sidecar_service_stub import ProtoTaskHubSidecarServiceStub +from typing import Any, Callable -class AzureFunctionsNullStub(ProtoTaskHubSidecarServiceStub): - """A task hub sidecar stub class that implements all methods as no-ops.""" - Hello = lambda *args, **kwargs: None - StartInstance = lambda *args, **kwargs: None - GetInstance = lambda *args, **kwargs: None - RewindInstance = lambda *args, **kwargs: None - WaitForInstanceStart = lambda *args, **kwargs: None - WaitForInstanceCompletion = lambda *args, **kwargs: None - RaiseEvent = lambda *args, **kwargs: None - TerminateInstance = lambda *args, **kwargs: None - SuspendInstance = lambda *args, **kwargs: None - ResumeInstance = lambda *args, **kwargs: None - QueryInstances = lambda *args, **kwargs: None - PurgeInstances = lambda *args, **kwargs: None - GetWorkItems = lambda *args, **kwargs: None - CompleteActivityTask = lambda *args, **kwargs: None - CompleteOrchestratorTask = lambda *args, **kwargs: None - CompleteEntityTask = lambda *args, **kwargs: None - StreamInstanceHistory = lambda *args, **kwargs: None - CreateTaskHub = lambda *args, **kwargs: None - DeleteTaskHub = lambda *args, **kwargs: None - SignalEntity = lambda *args, **kwargs: None - GetEntity = lambda *args, **kwargs: None - QueryEntities = lambda *args, **kwargs: None - CleanEntityStorage = lambda *args, **kwargs: None - AbandonTaskActivityWorkItem = lambda *args, **kwargs: None - AbandonTaskOrchestratorWorkItem = lambda *args, **kwargs: None - AbandonTaskEntityWorkItem = lambda *args, **kwargs: None +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/functions_json.py b/azure-functions-durable/azure/durable_functions/internal/functions_json.py index 71d2b721..383e255d 100644 --- a/azure-functions-durable/azure/durable_functions/internal/functions_json.py +++ b/azure-functions-durable/azure/durable_functions/internal/functions_json.py @@ -1,10 +1,37 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. +import importlib import json -from azure.functions._durable_functions import _serialize_custom_object, _deserialize_custom_object +from typing import Any, Callable + from durabletask.internal import shared +# ``azure.functions`` only exposes its custom-object (de)serialization helpers +# from a private module, and they are untyped. Resolve them dynamically and +# bind them to locally-typed callables so the rest of the module stays fully +# type-checked. +_df_serializers = importlib.import_module("azure.functions._durable_functions") +_serialize_custom_object: Callable[[Any], Any] = getattr( + _df_serializers, "_serialize_custom_object") +_deserialize_custom_object: Callable[[dict[str, Any]], Any] = getattr( + _df_serializers, "_deserialize_custom_object") + + +def _to_json(obj: Any) -> str: + return json.dumps(obj, default=_serialize_custom_object) + + +def _from_json(json_str: str | bytes | bytearray) -> Any: + return json.loads(json_str, object_hook=_deserialize_custom_object) + + +def install_custom_serialization() -> None: + """Replace durabletask's global JSON (de)serialization helpers. -shared.to_json = lambda obj: json.dumps(obj, default=_serialize_custom_object) -shared.from_json = lambda json_str: json.loads(json_str, object_hook=_deserialize_custom_object) \ No newline at end of file + Routes ``durabletask`` payload serialization through azure-functions' + custom-object (de)serializers so that user types round-trip consistently + between the Functions host and the durabletask runtime. + """ + shared.to_json = _to_json + shared.from_json = _from_json diff --git a/azure-functions-durable/azure/durable_functions/orchestrator.py b/azure-functions-durable/azure/durable_functions/orchestrator.py index 6e7c6496..168ee61e 100644 --- a/azure-functions-durable/azure/durable_functions/orchestrator.py +++ b/azure-functions-durable/azure/durable_functions/orchestrator.py @@ -3,14 +3,13 @@ Responsible for orchestrating the execution of the user defined generator function. """ -from typing import Callable, Any, Generator - -import azure.functions as func +from typing import Any, Callable, Generator from durabletask.task import OrchestrationContext from .worker import DurableFunctionsWorker + class Orchestrator: """Durable Orchestration Class. @@ -43,7 +42,7 @@ def handle(self, context: OrchestrationContext) -> str: state after this invocation """ self.durable_context = context - return DurableFunctionsWorker()._execute_orchestrator(self.fn, context) + return DurableFunctionsWorker().execute_orchestration_request(self.fn, context) @classmethod def create(cls, fn: Callable[[OrchestrationContext, Any], Generator[Any, Any, Any]]) \ @@ -61,9 +60,9 @@ def create(cls, fn: Callable[[OrchestrationContext, Any], Generator[Any, Any, An Handle function of the newly created orchestration client """ - def handle(context) -> str: + def handle(context: Any) -> str: return Orchestrator(fn).handle(context) - handle.orchestrator_function = fn # type: ignore + 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 index 47713725..b17f0c16 100644 --- a/azure-functions-durable/azure/durable_functions/worker.py +++ b/azure-functions-durable/azure/durable_functions/worker.py @@ -2,12 +2,16 @@ # Licensed under the MIT License. import base64 -from threading import Event -from typing import Optional +from typing import Any, Optional + from durabletask import task -from durabletask.internal.orchestrator_service_pb2 import EntityBatchRequest, EntityBatchResult, OrchestratorRequest, OrchestratorResponse -from durabletask.worker import _Registry, ConcurrencyOptions -from durabletask.internal import shared +from durabletask.internal.orchestrator_service_pb2 import ( + EntityBatchRequest, + EntityBatchResult, + HistoryEvent, + OrchestratorRequest, + OrchestratorResponse, +) from durabletask.worker import TaskHubGrpcWorker from .internal.azurefunctions_null_stub import AzureFunctionsNullStub @@ -20,38 +24,32 @@ class DurableFunctionsWorker(TaskHubGrpcWorker): See TaskHubGrpcWorker for base class documentation. """ - def __init__(self): - # Don't call the parent constructor - we don't actually want to start an AsyncWorkerLoop - # or recieve work items from anywhere but the method that is creating this worker - self._registry = _Registry() - self._host_address = "" - self._logger = shared.get_logger("worker") - self._shutdown = Event() - self._is_running = False - self._secure_channel = False - - self._concurrency_options = ConcurrencyOptions() - - self._interceptors = None + 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. + super().__init__() - def add_named_orchestrator(self, name: str, func: task.Orchestrator): + def add_named_orchestrator(self, name: str, func: task.Orchestrator[Any, Any]) -> None: self._registry.add_named_orchestrator(name, func) - def _execute_orchestrator(self, func: task.Orchestrator, context) -> str: + 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 = AzureFunctionsNullStub() + stub: Any = AzureFunctionsNullStub() response: Optional[OrchestratorResponse] = None - def stub_complete(stub_response): + def stub_complete(stub_response: OrchestratorResponse) -> None: nonlocal response response = stub_response stub.CompleteOrchestratorTask = stub_complete - execution_started_events = [] + execution_started_events: list[HistoryEvent] = [] for e in request.pastEvents: if e.HasField("executionStarted"): execution_started_events.append(e) @@ -70,17 +68,17 @@ def stub_complete(stub_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(self, func: task.Entity, context) -> str: + 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 = AzureFunctionsNullStub() + stub: Any = AzureFunctionsNullStub() response: Optional[EntityBatchResult] = None - def stub_complete(stub_response: EntityBatchResult): + def stub_complete(stub_response: EntityBatchResult) -> None: nonlocal response response = stub_response stub.CompleteEntityTask = stub_complete 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" +} From 5b906bd90f58436229bf041b5c35476d1c4f746b Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Tue, 23 Jun 2026 11:56:10 -0600 Subject: [PATCH 23/45] Remove non-existent extension call --- .../azure/durable_functions/client.py | 32 +------------------ azure-functions-durable/pyproject.toml | 3 +- 2 files changed, 2 insertions(+), 33 deletions(-) diff --git a/azure-functions-durable/azure/durable_functions/client.py b/azure-functions-durable/azure/durable_functions/client.py index 0f9583f5..b3f7e549 100644 --- a/azure-functions-durable/azure/durable_functions/client.py +++ b/azure-functions-durable/azure/durable_functions/client.py @@ -5,12 +5,11 @@ from datetime import timedelta import azure.functions as func -from urllib.parse import urlparse, urljoin, quote +from urllib.parse import urlparse, quote from durabletask.client import TaskHubGrpcClient from .internal.azurefunctions_grpc_interceptor import AzureFunctionsDefaultClientInterceptorImpl from .http import HttpManagementPayload -import requests # Client class used for Durable Functions @@ -44,10 +43,6 @@ def __init__(self, client_as_string: str): json.JSONDecodeError: If the provided string is not valid JSON. """ self._parse_client_configuration(client_as_string) - if not self.httpBaseUrl: - # This happens when the extension has not been configured for gRPC yet. For some reason, instead of - # the client returning with null rpcBaseUrl and httpBaseUrl, it returns rpcBaseUrl with the http url. - self.configure_extension_for_grpc() interceptors = [AzureFunctionsDefaultClientInterceptorImpl(self.taskHubName, self.requiredQueryStringParameters)] @@ -82,31 +77,6 @@ def _parse_client_configuration(self, client_as_string: str) -> None: # TODO: convert the string value back to timedelta - annoying regex? self.grpcHttpClientTimeout = client.get("grpcHttpClientTimeout", timedelta(seconds=30)) - def configure_extension_for_grpc(self) -> None: - """Configures the Durable Functions extension for gRPC communication. - - Makes an HTTP request to the extension's management endpoint to enable gRPC. - """ - - # Make an HTTP request to the extension to configure gRPC - configure_base_url = self.httpBaseUrl - if not configure_base_url: - # For some reason, in the "bad" case when rpc has not been configured, the httpBaseUrl is empty and sent in rpcBaseUrl - configure_base_url = self.rpcBaseUrl - # configure_base_url = urlparse(configure_base_url) - # url = f"{configure_base_url.scheme}://{configure_base_url.netloc}/management/configureGrpc" - url = urljoin(configure_base_url, "management/configureGrpc") - params = { - "taskHubName": self.taskHubName, - "connectionName": self.connectionName - } - response = requests.get(url, params=params) - if response.status_code != 200: - raise Exception(f"Failed to configure gRPC for Durable Functions extension. Status code: {response.status_code}, Response: {response.text}") - - # Parse the response to update client configuration - it's double-encoded so we need to load it twice - self._parse_client_configuration(json.loads(response.text)) - 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. diff --git a/azure-functions-durable/pyproject.toml b/azure-functions-durable/pyproject.toml index 5f171e71..46faa264 100644 --- a/azure-functions-durable/pyproject.toml +++ b/azure-functions-durable/pyproject.toml @@ -29,8 +29,7 @@ readme = "README.md" dependencies = [ "durabletask>=1.2.0dev0", "azure-identity>=1.19.0", - "azure-functions>=1.25.0b3.dev1", - "requests>=2.31.0" + "azure-functions>=1.25.0b3.dev1" ] [project.urls] From df9932db4d1b741d65cf70a8c69176569024847b Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Wed, 1 Jul 2026 11:18:19 -0600 Subject: [PATCH 24/45] Serialization compat, fix typing, working again --- .../azure/durable_functions/__init__.py | 5 - .../azure/durable_functions/client.py | 4 +- .../decorators/durable_app.py | 8 +- .../internal/functions_json.py | 37 ------ .../internal/serialization.py | 118 ++++++++++++++++++ .../azure/durable_functions/orchestrator.py | 18 ++- .../azure/durable_functions/worker.py | 7 +- azure-functions-durable/pyproject.toml | 2 +- 8 files changed, 149 insertions(+), 50 deletions(-) delete mode 100644 azure-functions-durable/azure/durable_functions/internal/functions_json.py create mode 100644 azure-functions-durable/azure/durable_functions/internal/serialization.py diff --git a/azure-functions-durable/azure/durable_functions/__init__.py b/azure-functions-durable/azure/durable_functions/__init__.py index 01389b80..470afe84 100644 --- a/azure-functions-durable/azure/durable_functions/__init__.py +++ b/azure-functions-durable/azure/durable_functions/__init__.py @@ -1,15 +1,10 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. -from .internal.functions_json import install_custom_serialization from .decorators.durable_app import Blueprint, DFApp from .client import DurableFunctionsClient from .orchestrator import Orchestrator -# Ensure the durabletask JSON encoder/decoder is replaced as soon as the -# durable_functions package is imported. -install_custom_serialization() - # IMPORTANT: DO NOT REMOVE. `azure-functions` relies on the presence and value of this variable # for version detection version = "2.x" diff --git a/azure-functions-durable/azure/durable_functions/client.py b/azure-functions-durable/azure/durable_functions/client.py index b3f7e549..23aa0c42 100644 --- a/azure-functions-durable/azure/durable_functions/client.py +++ b/azure-functions-durable/azure/durable_functions/client.py @@ -9,6 +9,7 @@ from durabletask.client import TaskHubGrpcClient from .internal.azurefunctions_grpc_interceptor import AzureFunctionsDefaultClientInterceptorImpl +from .internal.serialization import DEFAULT_FUNCTIONS_DATA_CONVERTER from .http import HttpManagementPayload @@ -52,7 +53,8 @@ def __init__(self, client_as_string: str): host_address=self.rpcBaseUrl, secure_channel=False, metadata=None, - interceptors=interceptors) + interceptors=interceptors, + 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. diff --git a/azure-functions-durable/azure/durable_functions/decorators/durable_app.py b/azure-functions-durable/azure/durable_functions/decorators/durable_app.py index 4f828a11..8c2d68df 100644 --- a/azure-functions-durable/azure/durable_functions/decorators/durable_app.py +++ b/azure-functions-durable/azure/durable_functions/decorators/durable_app.py @@ -4,6 +4,7 @@ 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 FunctionBuilder @@ -95,7 +96,12 @@ def decorator(entity_func: task.Entity[Any, Any]) -> FunctionBuilder: # 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"). - def handle(context: Any) -> str: + # 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] diff --git a/azure-functions-durable/azure/durable_functions/internal/functions_json.py b/azure-functions-durable/azure/durable_functions/internal/functions_json.py deleted file mode 100644 index 383e255d..00000000 --- a/azure-functions-durable/azure/durable_functions/internal/functions_json.py +++ /dev/null @@ -1,37 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -import importlib -import json -from typing import Any, Callable - -from durabletask.internal import shared - -# ``azure.functions`` only exposes its custom-object (de)serialization helpers -# from a private module, and they are untyped. Resolve them dynamically and -# bind them to locally-typed callables so the rest of the module stays fully -# type-checked. -_df_serializers = importlib.import_module("azure.functions._durable_functions") -_serialize_custom_object: Callable[[Any], Any] = getattr( - _df_serializers, "_serialize_custom_object") -_deserialize_custom_object: Callable[[dict[str, Any]], Any] = getattr( - _df_serializers, "_deserialize_custom_object") - - -def _to_json(obj: Any) -> str: - return json.dumps(obj, default=_serialize_custom_object) - - -def _from_json(json_str: str | bytes | bytearray) -> Any: - return json.loads(json_str, object_hook=_deserialize_custom_object) - - -def install_custom_serialization() -> None: - """Replace durabletask's global JSON (de)serialization helpers. - - Routes ``durabletask`` payload serialization through azure-functions' - custom-object (de)serializers so that user types round-trip consistently - between the Functions host and the durabletask runtime. - """ - shared.to_json = _to_json - shared.from_json = _from_json 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..803b26fd --- /dev/null +++ b/azure-functions-durable/azure/durable_functions/internal/serialization.py @@ -0,0 +1,118 @@ +# 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 + return df_loads(data, 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 index 168ee61e..e70d033f 100644 --- a/azure-functions-durable/azure/durable_functions/orchestrator.py +++ b/azure-functions-durable/azure/durable_functions/orchestrator.py @@ -5,6 +5,8 @@ """ from typing import Any, Callable, Generator +import azure.functions as func + from durabletask.task import OrchestrationContext from .worker import DurableFunctionsWorker @@ -27,13 +29,16 @@ def __init__(self, """ self.fn: Callable[[OrchestrationContext, Any], Generator[Any, Any, Any]] = activity_func - def handle(self, context: OrchestrationContext) -> str: + def handle(self, context: func.OrchestrationContext) -> str: """Handle the orchestration of the user defined generator function. Parameters ---------- - context : DurableOrchestrationContext - The DF orchestration context + 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 ------- @@ -60,7 +65,12 @@ def create(cls, fn: Callable[[OrchestrationContext, Any], Generator[Any, Any, An Handle function of the newly created orchestration client """ - def handle(context: Any) -> str: + # 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] diff --git a/azure-functions-durable/azure/durable_functions/worker.py b/azure-functions-durable/azure/durable_functions/worker.py index b17f0c16..1e972141 100644 --- a/azure-functions-durable/azure/durable_functions/worker.py +++ b/azure-functions-durable/azure/durable_functions/worker.py @@ -14,6 +14,7 @@ ) from durabletask.worker import TaskHubGrpcWorker from .internal.azurefunctions_null_stub import AzureFunctionsNullStub +from .internal.serialization import DEFAULT_FUNCTIONS_DATA_CONVERTER # Worker class used for Durable Task Scheduler (DTS) @@ -30,7 +31,11 @@ def __init__(self) -> None: # 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. - super().__init__() + # + # 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) diff --git a/azure-functions-durable/pyproject.toml b/azure-functions-durable/pyproject.toml index 46faa264..1f6941d9 100644 --- a/azure-functions-durable/pyproject.toml +++ b/azure-functions-durable/pyproject.toml @@ -29,7 +29,7 @@ readme = "README.md" dependencies = [ "durabletask>=1.2.0dev0", "azure-identity>=1.19.0", - "azure-functions>=1.25.0b3.dev1" + "azure-functions>=2.2.0b6" ] [project.urls] From a7bdd0134df41d3112b5e1b1a525c9ce780f9485 Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Thu, 2 Jul 2026 10:23:17 -0600 Subject: [PATCH 25/45] Use the async client (match old behavior) --- azure-functions-durable/CHANGELOG.md | 10 +++++ .../azure/durable_functions/client.py | 10 ++--- .../azurefunctions_grpc_interceptor.py | 43 ++++++++++++++----- 3 files changed, 47 insertions(+), 16 deletions(-) diff --git a/azure-functions-durable/CHANGELOG.md b/azure-functions-durable/CHANGELOG.md index b9be1590..2eab7ecb 100644 --- a/azure-functions-durable/CHANGELOG.md +++ b/azure-functions-durable/CHANGELOG.md @@ -5,6 +5,16 @@ 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 + +### 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`. + ## v0.1.0 - Initial implementation diff --git a/azure-functions-durable/azure/durable_functions/client.py b/azure-functions-durable/azure/durable_functions/client.py index 23aa0c42..5404e1e0 100644 --- a/azure-functions-durable/azure/durable_functions/client.py +++ b/azure-functions-durable/azure/durable_functions/client.py @@ -7,17 +7,17 @@ import azure.functions as func from urllib.parse import urlparse, quote -from durabletask.client import TaskHubGrpcClient -from .internal.azurefunctions_grpc_interceptor import AzureFunctionsDefaultClientInterceptorImpl +from durabletask.client import AsyncTaskHubGrpcClient +from .internal.azurefunctions_grpc_interceptor import AzureFunctionsAsyncDefaultClientInterceptorImpl from .internal.serialization import DEFAULT_FUNCTIONS_DATA_CONVERTER from .http import HttpManagementPayload # Client class used for Durable Functions -class DurableFunctionsClient(TaskHubGrpcClient): +class DurableFunctionsClient(AsyncTaskHubGrpcClient): """A gRPC client passed to Durable Functions durable client bindings. - Connects to the Durable Functions runtime using gRPC and provides methods + 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. """ @@ -45,7 +45,7 @@ def __init__(self, client_as_string: str): """ self._parse_client_configuration(client_as_string) - interceptors = [AzureFunctionsDefaultClientInterceptorImpl(self.taskHubName, self.requiredQueryStringParameters)] + interceptors = [AzureFunctionsAsyncDefaultClientInterceptorImpl(self.taskHubName, self.requiredQueryStringParameters)] # 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 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 index 8736bf6f..f4011322 100644 --- a/azure-functions-durable/azure/durable_functions/internal/azurefunctions_grpc_interceptor.py +++ b/azure-functions-durable/azure/durable_functions/internal/azurefunctions_grpc_interceptor.py @@ -3,7 +3,24 @@ from importlib.metadata import version -from durabletask.internal.grpc_interceptor import DefaultClientInterceptorImpl +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('durabletask-azurefunctions') + 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): @@ -14,14 +31,18 @@ class AzureFunctionsDefaultClientInterceptorImpl(DefaultClientInterceptorImpl): def __init__(self, taskhub_name: str, required_query_string_parameters: str): self.required_query_string_parameters = required_query_string_parameters - try: - # Get the version of the azurefunctions package - sdk_version = version('durabletask-azurefunctions') - except Exception: - # Fallback if version cannot be determined - sdk_version = "unknown" - user_agent = f"durabletask-python/{sdk_version}" - self._metadata = [ - ("taskhub", taskhub_name), - ("x-user-agent", user_agent)] # 'user-agent' is a reserved header in grpc, so we use 'x-user-agent' instead + 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) From 1891d94b5936cc118741d26caf54779bd412a839 Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Thu, 2 Jul 2026 11:39:46 -0600 Subject: [PATCH 26/45] Compat layer V1 --- azure-functions-durable/CHANGELOG.md | 45 ++ .../azure/durable_functions/__init__.py | 39 +- .../azure/durable_functions/client.py | 310 +++++++++- .../azure/durable_functions/compat_aliases.py | 71 +++ .../durable_orchestration_status.py | 115 ++++ .../azure/durable_functions/entity_id.py | 40 ++ .../entity_state_response.py | 41 ++ .../http/http_management_payload.py | 29 + .../orchestration_runtime_status.py | 101 +++ .../durable_functions/purge_history_result.py | 27 + .../azure/durable_functions/retry_options.py | 46 ++ .../azure/durable_functions/token_source.py | 51 ++ azure-functions-durable/pyproject.toml | 3 +- .../test_client_compat.py | 573 ++++++++++++++++++ 14 files changed, 1478 insertions(+), 13 deletions(-) create mode 100644 azure-functions-durable/azure/durable_functions/compat_aliases.py create mode 100644 azure-functions-durable/azure/durable_functions/durable_orchestration_status.py create mode 100644 azure-functions-durable/azure/durable_functions/entity_id.py create mode 100644 azure-functions-durable/azure/durable_functions/entity_state_response.py create mode 100644 azure-functions-durable/azure/durable_functions/orchestration_runtime_status.py create mode 100644 azure-functions-durable/azure/durable_functions/purge_history_result.py create mode 100644 azure-functions-durable/azure/durable_functions/retry_options.py create mode 100644 azure-functions-durable/azure/durable_functions/token_source.py create mode 100644 tests/azure-functions-durable/test_client_compat.py diff --git a/azure-functions-durable/CHANGELOG.md b/azure-functions-durable/CHANGELOG.md index 2eab7ecb..6dc0ece5 100644 --- a/azure-functions-durable/CHANGELOG.md +++ b/azure-functions-durable/CHANGELOG.md @@ -7,6 +7,44 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Added + +- 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 @@ -14,6 +52,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `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 supports mapping-style access + (`payload["statusQueryGetUri"]`, iteration, `in`, `keys()`/`items()`/`values()`) + so v1 code that treated the payload as a `dict` keeps working. + ## v0.1.0 diff --git a/azure-functions-durable/azure/durable_functions/__init__.py b/azure-functions-durable/azure/durable_functions/__init__.py index 470afe84..e415b137 100644 --- a/azure-functions-durable/azure/durable_functions/__init__.py +++ b/azure-functions-durable/azure/durable_functions/__init__.py @@ -1,12 +1,49 @@ # 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 .orchestrator import Orchestrator +from .retry_options import RetryOptions +from .orchestration_runtime_status import OrchestrationRuntimeStatus +from .durable_orchestration_status import DurableOrchestrationStatus +from .purge_history_result import PurgeHistoryResult +from .entity_state_response import EntityStateResponse +from .entity_id import EntityId +from .token_source import ManagedIdentityTokenSource, TokenSource +from .compat_aliases import ( + DurableEntityContext, + DurableOrchestrationClient, + DurableOrchestrationContext, + 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", "DurableFunctionsClient", "Orchestrator", "version"] +__all__ = [ + "Blueprint", + "DFApp", + "DurableEntityContext", + "DurableFunctionsClient", + "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 index 5404e1e0..c98048ae 100644 --- a/azure-functions-durable/azure/durable_functions/client.py +++ b/azure-functions-durable/azure/durable_functions/client.py @@ -3,14 +3,25 @@ import json -from datetime import timedelta +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 +from durabletask.client import ( + AsyncTaskHubGrpcClient, + OrchestrationQuery, + OrchestrationStatus, +) +from durabletask.entities import EntityInstanceId from .internal.azurefunctions_grpc_interceptor import AzureFunctionsAsyncDefaultClientInterceptorImpl from .internal.serialization import DEFAULT_FUNCTIONS_DATA_CONVERTER from .http import HttpManagementPayload +from .durable_orchestration_status import DurableOrchestrationStatus +from .entity_state_response import EntityStateResponse +from .orchestration_runtime_status import OrchestrationRuntimeStatus, to_durabletask_statuses +from .purge_history_result import PurgeHistoryResult # Client class used for Durable Functions @@ -96,22 +107,299 @@ def create_check_status_response(self, request: func.HttpRequest, instance_id: s }, ) - def create_http_management_payload(self, request: func.HttpRequest, instance_id: str) -> HttpManagementPayload: + 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: - instance_id (str): The ID of the Durable Function instance. + 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. """ - return self._get_client_response_links(request, instance_id) + # 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, instance_id: str) -> HttpManagementPayload: + 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) - @staticmethod - def _get_instance_status_url(request: func.HttpRequest, instance_id: str) -> str: - request_url = urlparse(request.url) - location_url = f"{request_url.scheme}://{request_url.netloc}" + def _get_instance_status_url(self, request: func.HttpRequest | None, instance_id: str) -> str: encoded_instance_id = quote(instance_id) - location_url = location_url + "/runtime/webhooks/durabletask/instances/" + encoded_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 not yet supported in durabletask; this shim raises " + "NotImplementedError.") + async def rewind( + self, + instance_id: str, + reason: str, + task_hub_name: Optional[str] = None, + connection_name: Optional[str] = None) -> None: + """Not implemented: durabletask has no rewind equivalent yet.""" + raise NotImplementedError( + "rewind is not yet supported by durabletask.") + + @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/compat_aliases.py b/azure-functions-durable/azure/durable_functions/compat_aliases.py new file mode 100644 index 00000000..1d53a17b --- /dev/null +++ b/azure-functions-durable/azure/durable_functions/compat_aliases.py @@ -0,0 +1,71 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +from typing import Any, Optional + +from typing_extensions import deprecated + +from durabletask.entities import EntityContext +from durabletask.task import OrchestrationContext + +from .client import DurableFunctionsClient +from .token_source import TokenSource + + +@deprecated( + "DurableOrchestrationClient is deprecated; use DurableFunctionsClient instead.") +class DurableOrchestrationClient(DurableFunctionsClient): + """Deprecated alias for :class:`DurableFunctionsClient`.""" + + +@deprecated( + "DurableOrchestrationContext is deprecated; use " + "durabletask.task.OrchestrationContext instead.") +class DurableOrchestrationContext(OrchestrationContext): + """Deprecated alias for :class:`durabletask.task.OrchestrationContext`. + + Retained so v1 type hints (``def my_orchestrator(context: DurableOrchestrationContext)``) + continue to import. At runtime the durabletask worker passes an + ``OrchestrationContext`` instance. + """ + + def call_http(self, + method: str, + uri: str, + content: Optional[str] = None, + headers: Optional[dict[str, str]] = None, + token_source: Optional[TokenSource] = None, + is_raw_str: bool = False) -> Any: + """Schedule a durable HTTP call (v1 API). + + Not yet supported: durabletask has no durable-HTTP (``call_http``) + equivalent, so this raises ``NotImplementedError``. It is present to + document the v1 API surface and the current gap. + """ + raise NotImplementedError( + "call_http is not yet supported by durabletask. The durable-HTTP " + "API (and its TokenSource auth) has no durabletask equivalent yet.") + + +@deprecated( + "DurableEntityContext is deprecated; use " + "durabletask.entities.EntityContext instead.") +class DurableEntityContext(EntityContext): + """Deprecated alias for :class:`durabletask.entities.EntityContext`.""" + + +@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/durable_orchestration_status.py b/azure-functions-durable/azure/durable_functions/durable_orchestration_status.py new file mode 100644 index 00000000..2db4f94f --- /dev/null +++ b/azure-functions-durable/azure/durable_functions/durable_orchestration_status.py @@ -0,0 +1,115 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +from datetime import datetime +from typing import Any, Optional + +from durabletask.client import OrchestrationState + +from .orchestration_runtime_status import ( + OrchestrationRuntimeStatus, + from_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) + + 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: + 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.""" + 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() + if self.output is not None: + result["output"] = self.output + if self.input_ is not None: + result["input"] = self.input_ + if self.runtime_status is not None: + result["runtimeStatus"] = self.runtime_status.name + if self.custom_status is not None: + result["customStatus"] = self.custom_status + return result diff --git a/azure-functions-durable/azure/durable_functions/entity_id.py b/azure-functions-durable/azure/durable_functions/entity_id.py new file mode 100644 index 00000000..7f296ae1 --- /dev/null +++ b/azure-functions-durable/azure/durable_functions/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/entity_state_response.py b/azure-functions-durable/azure/durable_functions/entity_state_response.py new file mode 100644 index 00000000..dd593d39 --- /dev/null +++ b/azure-functions-durable/azure/durable_functions/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/http/http_management_payload.py b/azure-functions-durable/azure/durable_functions/http/http_management_payload.py index a6836844..4c1b634b 100644 --- a/azure-functions-durable/azure/durable_functions/http/http_management_payload.py +++ b/azure-functions-durable/azure/durable_functions/http/http_management_payload.py @@ -2,6 +2,7 @@ # Licensed under the MIT License. import json +from collections.abc import Iterator class HttpManagementPayload: @@ -9,6 +10,10 @@ class HttpManagementPayload: Contains URLs for managing the instance, such as querying status, sending events, terminating, restarting, etc. + + Supports mapping-style access (``payload["statusQueryGetUri"]``, iteration, + ``in``, ``.keys()``/``.items()``/``.values()``) for backwards compatibility + with the v1 API, which returned a plain ``dict``. """ def __init__(self, instance_id: str, instance_status_url: str, required_query_string_parameters: str): @@ -32,3 +37,27 @@ def __init__(self, instance_id: str, instance_status_url: str, required_query_st def __str__(self): return json.dumps(self.urls) + + def __getitem__(self, key: str) -> str: + return self.urls[key] + + def __iter__(self) -> Iterator[str]: + return iter(self.urls) + + def __len__(self) -> int: + return len(self.urls) + + def __contains__(self, key: object) -> bool: + return key in self.urls + + def keys(self): + """Return the management URL keys.""" + return self.urls.keys() + + def items(self): + """Return the management URL (key, value) pairs.""" + return self.urls.items() + + def values(self): + """Return the management URL values.""" + return self.urls.values() diff --git a/azure-functions-durable/azure/durable_functions/orchestration_runtime_status.py b/azure-functions-durable/azure/durable_functions/orchestration_runtime_status.py new file mode 100644 index 00000000..71b595d4 --- /dev/null +++ b/azure-functions-durable/azure/durable_functions/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/purge_history_result.py b/azure-functions-durable/azure/durable_functions/purge_history_result.py new file mode 100644 index 00000000..e9d300ff --- /dev/null +++ b/azure-functions-durable/azure/durable_functions/purge_history_result.py @@ -0,0 +1,27 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +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) + + @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/retry_options.py b/azure-functions-durable/azure/durable_functions/retry_options.py new file mode 100644 index 00000000..1943007b --- /dev/null +++ b/azure-functions-durable/azure/durable_functions/retry_options.py @@ -0,0 +1,46 @@ +# 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)) diff --git a/azure-functions-durable/azure/durable_functions/token_source.py b/azure-functions-durable/azure/durable_functions/token_source.py new file mode 100644 index 00000000..5b990f85 --- /dev/null +++ b/azure-functions-durable/azure/durable_functions/token_source.py @@ -0,0 +1,51 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +from abc import ABC + +from typing_extensions import deprecated + + +class TokenSource(ABC): + """Token source abstract base class. + + Backwards-compatible shim for the v1 ``TokenSource``. Token sources are + consumed only by the orchestrator ``call_http`` API, which has no + durabletask equivalent yet — see + :meth:`DurableOrchestrationContext.call_http`. Constructing a token source + is harmless, but it cannot be used until ``call_http`` is supported. + """ + + def __init__(self): + super().__init__() + + +@deprecated( + "ManagedIdentityTokenSource is deprecated; it is only usable with the " + "orchestrator call_http API, which is not yet available in durabletask.") +class ManagedIdentityTokenSource(TokenSource): + """Returns a ``ManagedIdentityTokenSource`` object. + + Only meaningful when passed to ``call_http`` (not yet supported in + durabletask). Constructing one is allowed for import/config compatibility. + """ + + 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/pyproject.toml b/azure-functions-durable/pyproject.toml index 1f6941d9..3c6fc6f6 100644 --- a/azure-functions-durable/pyproject.toml +++ b/azure-functions-durable/pyproject.toml @@ -29,7 +29,8 @@ readme = "README.md" dependencies = [ "durabletask>=1.2.0dev0", "azure-identity>=1.19.0", - "azure-functions>=2.2.0b6" + "azure-functions>=2.2.0b6", + "typing-extensions>=4.9.0" ] [project.urls] 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..fa18d2e1 --- /dev/null +++ b/tests/azure-functions-durable/test_client_compat.py @@ -0,0 +1,573 @@ +# 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.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 (not implemented) +# --------------------------------------------------------------------------- + +async def test_rewind_raises_not_implemented(): + client = _make_client() + try: + with pytest.warns(DeprecationWarning): + with pytest.raises(NotImplementedError): + await client.rewind("abc", "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(): + with pytest.warns(DeprecationWarning): + 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) + + +def test_context_aliases_are_subclasses(): + from durabletask.entities import EntityContext + from durabletask.task import OrchestrationContext + assert issubclass(df.DurableOrchestrationContext, OrchestrationContext) + assert issubclass(df.DurableEntityContext, EntityContext) + + +# --------------------------------------------------------------------------- +# 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, + 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 not implemented +# --------------------------------------------------------------------------- + +def test_call_http_raises_not_implemented(): + # call_http ignores self, so invoke via the class to avoid instantiating + # the abstract context. + with pytest.raises(NotImplementedError): + df.DurableOrchestrationContext.call_http(None, "GET", "http://example.com") + + +def test_token_source_is_still_constructible(): + with pytest.warns(DeprecationWarning): + source = df.ManagedIdentityTokenSource("https://graph.microsoft.com") + assert source.resource == "https://graph.microsoft.com" From d4a84bc1ef5c7c1d9329d0b054056c15f990ceea Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Thu, 2 Jul 2026 11:41:38 -0600 Subject: [PATCH 27/45] Reorganize compat files --- .../azure/durable_functions/__init__.py | 16 ++++++++-------- .../azure/durable_functions/client.py | 8 ++++---- .../internal/compat/__init__.py | 8 ++++++++ .../{ => internal/compat}/compat_aliases.py | 2 +- .../compat}/durable_orchestration_status.py | 0 .../{ => internal/compat}/entity_id.py | 0 .../compat}/entity_state_response.py | 0 .../compat}/orchestration_runtime_status.py | 0 .../compat}/purge_history_result.py | 0 .../{ => internal/compat}/retry_options.py | 0 .../{ => internal/compat}/token_source.py | 0 .../test_client_compat.py | 2 +- 12 files changed, 22 insertions(+), 14 deletions(-) create mode 100644 azure-functions-durable/azure/durable_functions/internal/compat/__init__.py rename azure-functions-durable/azure/durable_functions/{ => internal/compat}/compat_aliases.py (98%) rename azure-functions-durable/azure/durable_functions/{ => internal/compat}/durable_orchestration_status.py (100%) rename azure-functions-durable/azure/durable_functions/{ => internal/compat}/entity_id.py (100%) rename azure-functions-durable/azure/durable_functions/{ => internal/compat}/entity_state_response.py (100%) rename azure-functions-durable/azure/durable_functions/{ => internal/compat}/orchestration_runtime_status.py (100%) rename azure-functions-durable/azure/durable_functions/{ => internal/compat}/purge_history_result.py (100%) rename azure-functions-durable/azure/durable_functions/{ => internal/compat}/retry_options.py (100%) rename azure-functions-durable/azure/durable_functions/{ => internal/compat}/token_source.py (100%) diff --git a/azure-functions-durable/azure/durable_functions/__init__.py b/azure-functions-durable/azure/durable_functions/__init__.py index e415b137..f917d5de 100644 --- a/azure-functions-durable/azure/durable_functions/__init__.py +++ b/azure-functions-durable/azure/durable_functions/__init__.py @@ -9,14 +9,14 @@ from .decorators.durable_app import Blueprint, DFApp from .client import DurableFunctionsClient from .orchestrator import Orchestrator -from .retry_options import RetryOptions -from .orchestration_runtime_status import OrchestrationRuntimeStatus -from .durable_orchestration_status import DurableOrchestrationStatus -from .purge_history_result import PurgeHistoryResult -from .entity_state_response import EntityStateResponse -from .entity_id import EntityId -from .token_source import ManagedIdentityTokenSource, TokenSource -from .compat_aliases import ( +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.compat_aliases import ( DurableEntityContext, DurableOrchestrationClient, DurableOrchestrationContext, diff --git a/azure-functions-durable/azure/durable_functions/client.py b/azure-functions-durable/azure/durable_functions/client.py index c98048ae..fda5df28 100644 --- a/azure-functions-durable/azure/durable_functions/client.py +++ b/azure-functions-durable/azure/durable_functions/client.py @@ -18,10 +18,10 @@ from .internal.azurefunctions_grpc_interceptor import AzureFunctionsAsyncDefaultClientInterceptorImpl from .internal.serialization import DEFAULT_FUNCTIONS_DATA_CONVERTER from .http import HttpManagementPayload -from .durable_orchestration_status import DurableOrchestrationStatus -from .entity_state_response import EntityStateResponse -from .orchestration_runtime_status import OrchestrationRuntimeStatus, to_durabletask_statuses -from .purge_history_result import PurgeHistoryResult +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 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/compat_aliases.py b/azure-functions-durable/azure/durable_functions/internal/compat/compat_aliases.py similarity index 98% rename from azure-functions-durable/azure/durable_functions/compat_aliases.py rename to azure-functions-durable/azure/durable_functions/internal/compat/compat_aliases.py index 1d53a17b..79a440b5 100644 --- a/azure-functions-durable/azure/durable_functions/compat_aliases.py +++ b/azure-functions-durable/azure/durable_functions/internal/compat/compat_aliases.py @@ -8,7 +8,7 @@ from durabletask.entities import EntityContext from durabletask.task import OrchestrationContext -from .client import DurableFunctionsClient +from ...client import DurableFunctionsClient from .token_source import TokenSource diff --git a/azure-functions-durable/azure/durable_functions/durable_orchestration_status.py b/azure-functions-durable/azure/durable_functions/internal/compat/durable_orchestration_status.py similarity index 100% rename from azure-functions-durable/azure/durable_functions/durable_orchestration_status.py rename to azure-functions-durable/azure/durable_functions/internal/compat/durable_orchestration_status.py diff --git a/azure-functions-durable/azure/durable_functions/entity_id.py b/azure-functions-durable/azure/durable_functions/internal/compat/entity_id.py similarity index 100% rename from azure-functions-durable/azure/durable_functions/entity_id.py rename to azure-functions-durable/azure/durable_functions/internal/compat/entity_id.py diff --git a/azure-functions-durable/azure/durable_functions/entity_state_response.py b/azure-functions-durable/azure/durable_functions/internal/compat/entity_state_response.py similarity index 100% rename from azure-functions-durable/azure/durable_functions/entity_state_response.py rename to azure-functions-durable/azure/durable_functions/internal/compat/entity_state_response.py diff --git a/azure-functions-durable/azure/durable_functions/orchestration_runtime_status.py b/azure-functions-durable/azure/durable_functions/internal/compat/orchestration_runtime_status.py similarity index 100% rename from azure-functions-durable/azure/durable_functions/orchestration_runtime_status.py rename to azure-functions-durable/azure/durable_functions/internal/compat/orchestration_runtime_status.py diff --git a/azure-functions-durable/azure/durable_functions/purge_history_result.py b/azure-functions-durable/azure/durable_functions/internal/compat/purge_history_result.py similarity index 100% rename from azure-functions-durable/azure/durable_functions/purge_history_result.py rename to azure-functions-durable/azure/durable_functions/internal/compat/purge_history_result.py diff --git a/azure-functions-durable/azure/durable_functions/retry_options.py b/azure-functions-durable/azure/durable_functions/internal/compat/retry_options.py similarity index 100% rename from azure-functions-durable/azure/durable_functions/retry_options.py rename to azure-functions-durable/azure/durable_functions/internal/compat/retry_options.py diff --git a/azure-functions-durable/azure/durable_functions/token_source.py b/azure-functions-durable/azure/durable_functions/internal/compat/token_source.py similarity index 100% rename from azure-functions-durable/azure/durable_functions/token_source.py rename to azure-functions-durable/azure/durable_functions/internal/compat/token_source.py diff --git a/tests/azure-functions-durable/test_client_compat.py b/tests/azure-functions-durable/test_client_compat.py index fa18d2e1..5ee91ed2 100644 --- a/tests/azure-functions-durable/test_client_compat.py +++ b/tests/azure-functions-durable/test_client_compat.py @@ -11,7 +11,7 @@ import azure.durable_functions as df from azure.durable_functions import RetryOptions -from azure.durable_functions.orchestration_runtime_status import ( +from azure.durable_functions.internal.compat.orchestration_runtime_status import ( from_durabletask_status, to_durabletask_status, to_durabletask_statuses, From a1b54bd8a76245630da992caae5e9969fe548ac5 Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Thu, 2 Jul 2026 12:08:34 -0600 Subject: [PATCH 28/45] Add shims for old orchestration and entity call arg patterns --- azure-functions-durable/CHANGELOG.md | 23 ++ .../azure/durable_functions/__init__.py | 4 +- .../internal/compat/compat_aliases.py | 42 +--- .../internal/compat/entity_context.py | 116 ++++++++++ .../internal/compat/orchestration_context.py | 200 ++++++++++++++++++ .../azure/durable_functions/worker.py | 6 +- .../test_client_compat.py | 7 - .../test_entity_context_compat.py | 128 +++++++++++ .../test_orchestration_context_compat.py | 180 ++++++++++++++++ 9 files changed, 654 insertions(+), 52 deletions(-) create mode 100644 azure-functions-durable/azure/durable_functions/internal/compat/entity_context.py create mode 100644 azure-functions-durable/azure/durable_functions/internal/compat/orchestration_context.py create mode 100644 tests/azure-functions-durable/test_entity_context_compat.py create mode 100644 tests/azure-functions-durable/test_orchestration_context_compat.py diff --git a/azure-functions-durable/CHANGELOG.md b/azure-functions-durable/CHANGELOG.md index 6dc0ece5..f5856673 100644 --- a/azure-functions-durable/CHANGELOG.md +++ b/azure-functions-durable/CHANGELOG.md @@ -9,6 +9,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- 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. + - 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`, diff --git a/azure-functions-durable/azure/durable_functions/__init__.py b/azure-functions-durable/azure/durable_functions/__init__.py index f917d5de..b24c353e 100644 --- a/azure-functions-durable/azure/durable_functions/__init__.py +++ b/azure-functions-durable/azure/durable_functions/__init__.py @@ -16,10 +16,10 @@ 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 ( - DurableEntityContext, DurableOrchestrationClient, - DurableOrchestrationContext, Entity, ) 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 index 79a440b5..ddfcf923 100644 --- a/azure-functions-durable/azure/durable_functions/internal/compat/compat_aliases.py +++ b/azure-functions-durable/azure/durable_functions/internal/compat/compat_aliases.py @@ -1,15 +1,11 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. -from typing import Any, Optional +from typing import Any from typing_extensions import deprecated -from durabletask.entities import EntityContext -from durabletask.task import OrchestrationContext - from ...client import DurableFunctionsClient -from .token_source import TokenSource @deprecated( @@ -18,42 +14,6 @@ class DurableOrchestrationClient(DurableFunctionsClient): """Deprecated alias for :class:`DurableFunctionsClient`.""" -@deprecated( - "DurableOrchestrationContext is deprecated; use " - "durabletask.task.OrchestrationContext instead.") -class DurableOrchestrationContext(OrchestrationContext): - """Deprecated alias for :class:`durabletask.task.OrchestrationContext`. - - Retained so v1 type hints (``def my_orchestrator(context: DurableOrchestrationContext)``) - continue to import. At runtime the durabletask worker passes an - ``OrchestrationContext`` instance. - """ - - def call_http(self, - method: str, - uri: str, - content: Optional[str] = None, - headers: Optional[dict[str, str]] = None, - token_source: Optional[TokenSource] = None, - is_raw_str: bool = False) -> Any: - """Schedule a durable HTTP call (v1 API). - - Not yet supported: durabletask has no durable-HTTP (``call_http``) - equivalent, so this raises ``NotImplementedError``. It is present to - document the v1 API surface and the current gap. - """ - raise NotImplementedError( - "call_http is not yet supported by durabletask. The durable-HTTP " - "API (and its TokenSource auth) has no durabletask equivalent yet.") - - -@deprecated( - "DurableEntityContext is deprecated; use " - "durabletask.entities.EntityContext instead.") -class DurableEntityContext(EntityContext): - """Deprecated alias for :class:`durabletask.entities.EntityContext`.""" - - @deprecated( "The Entity class is deprecated and unsupported in v2; register entities " "with the entity_trigger decorator instead.") 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/orchestration_context.py b/azure-functions-durable/azure/durable_functions/internal/compat/orchestration_context.py new file mode 100644 index 00000000..9cfb7a12 --- /dev/null +++ b/azure-functions-durable/azure/durable_functions/internal/compat/orchestration_context.py @@ -0,0 +1,200 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +import inspect +from datetime import datetime +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 .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): + self._ctx = ctx + self._input = orchestration_input + + # -- input --------------------------------------------------------------- + def get_input(self) -> Any: + """Get the orchestration input.""" + return self._input + + # -- 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.""" + return self._ctx.current_utc_datetime + + # -- 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._ctx.continue_as_new(input_) + + def set_custom_status(self, status: Any) -> None: + """Set the orchestration's custom status payload.""" + 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 (not yet supported) ------------------------------------ + def call_http(self, + method: str, + uri: str, + content: Optional[str] = None, + headers: Optional[dict[str, str]] = None, + token_source: Optional[TokenSource] = None, + is_raw_str: bool = False) -> Any: + """Schedule a durable HTTP call (v1 API). + + Not yet supported: durabletask has no durable-HTTP (``call_http``) + equivalent, so this raises ``NotImplementedError``. + """ + raise NotImplementedError( + "call_http is not yet supported by durabletask. The durable-HTTP " + "API (and its TokenSource auth) has no durabletask equivalent yet.") + + +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 + + name = getattr(fn, "__name__", "orchestrator") + + if inspect.isgeneratorfunction(fn): + def _generator_wrapper(context: OrchestrationContext, _input: Any = None) -> Any: + adapter = DurableOrchestrationContext(context, _input) + 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) + return fn(adapter) + _wrapper.__name__ = name + return _wrapper diff --git a/azure-functions-durable/azure/durable_functions/worker.py b/azure-functions-durable/azure/durable_functions/worker.py index 1e972141..72271f03 100644 --- a/azure-functions-durable/azure/durable_functions/worker.py +++ b/azure-functions-durable/azure/durable_functions/worker.py @@ -14,6 +14,8 @@ ) 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 @@ -65,7 +67,7 @@ def stub_complete(stub_response: OrchestratorResponse) -> None: raise Exception("No ExecutionStarted event found in orchestration request.") function_name = execution_started_events[-1].executionStarted.name - self.add_named_orchestrator(function_name, func) + self.add_named_orchestrator(function_name, wrap_orchestrator(func)) super()._execute_orchestrator(request, stub, None) if response is None: @@ -88,7 +90,7 @@ def stub_complete(stub_response: EntityBatchResult) -> None: response = stub_response stub.CompleteEntityTask = stub_complete - self.add_entity(func) + self.add_entity(wrap_entity(func)) super()._execute_entity_batch(request, stub, None) if response is None: diff --git a/tests/azure-functions-durable/test_client_compat.py b/tests/azure-functions-durable/test_client_compat.py index 5ee91ed2..fcdb17c2 100644 --- a/tests/azure-functions-durable/test_client_compat.py +++ b/tests/azure-functions-durable/test_client_compat.py @@ -388,13 +388,6 @@ def test_entity_class_raises_not_implemented(): df.Entity(lambda ctx: None) -def test_context_aliases_are_subclasses(): - from durabletask.entities import EntityContext - from durabletask.task import OrchestrationContext - assert issubclass(df.DurableOrchestrationContext, OrchestrationContext) - assert issubclass(df.DurableEntityContext, EntityContext) - - # --------------------------------------------------------------------------- # Return-type shims: DurableOrchestrationStatus # --------------------------------------------------------------------------- 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_orchestration_context_compat.py b/tests/azure-functions-durable/test_orchestration_context_compat.py new file mode 100644 index 00000000..119e6c29 --- /dev/null +++ b/tests/azure-functions-durable/test_orchestration_context_compat.py @@ -0,0 +1,180 @@ +# 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_raises_not_implemented(): + adapter, _ = _adapter() + with pytest.raises(NotImplementedError): + adapter.call_http("GET", "http://example.com") + + +# --------------------------------------------------------------------------- +# 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" From 3c05264432c3782921a4bb3210ab1ad55ac15801 Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Thu, 2 Jul 2026 12:14:39 -0600 Subject: [PATCH 29/45] Add remaining orchestration surface --- azure-functions-durable/CHANGELOG.md | 5 +++ .../internal/compat/orchestration_context.py | 45 +++++++++++++++++++ .../test_orchestration_context_compat.py | 38 ++++++++++++++++ 3 files changed, 88 insertions(+) diff --git a/azure-functions-durable/CHANGELOG.md b/azure-functions-durable/CHANGELOG.md index f5856673..febfe6df 100644 --- a/azure-functions-durable/CHANGELOG.md +++ b/azure-functions-durable/CHANGELOG.md @@ -31,6 +31,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 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`, 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 index 9cfb7a12..76c22720 100644 --- a/azure-functions-durable/azure/durable_functions/internal/compat/orchestration_context.py +++ b/azure-functions-durable/azure/durable_functions/internal/compat/orchestration_context.py @@ -27,6 +27,8 @@ class DurableOrchestrationContext: def __init__(self, ctx: OrchestrationContext, orchestration_input: Any = None): self._ctx = ctx self._input = orchestration_input + self._custom_status: Any = None + self._will_continue_as_new = False # -- input --------------------------------------------------------------- def get_input(self) -> Any: @@ -49,6 +51,47 @@ def current_utc_datetime(self) -> datetime: """Get the replay-safe current UTC date/time.""" return self._ctx.current_utc_datetime + @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 parent_instance_id(self) -> str: + """Get the ID of the parent orchestration. + + Not available: durabletask does not currently surface the parent + instance ID on the orchestration context. + """ + raise NotImplementedError( + "parent_instance_id is not currently exposed by durabletask.") + + @property + def function_context(self) -> Any: + """Get the Azure Functions-level context. + + Not available: durabletask does not provide the v1 ``FunctionContext`` + binding metadata. + """ + raise NotImplementedError( + "function_context is not available in this SDK.") + + @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.""" @@ -92,10 +135,12 @@ def wait_for_external_event(self, # -- 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 --------------------------------------------------- diff --git a/tests/azure-functions-durable/test_orchestration_context_compat.py b/tests/azure-functions-durable/test_orchestration_context_compat.py index 119e6c29..fecd7f93 100644 --- a/tests/azure-functions-durable/test_orchestration_context_compat.py +++ b/tests/azure-functions-durable/test_orchestration_context_compat.py @@ -116,6 +116,44 @@ def test_call_http_raises_not_implemented(): adapter.call_http("GET", "http://example.com") +# --------------------------------------------------------------------------- +# 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_raises_not_implemented(): + adapter, _ = _adapter() + with pytest.raises(NotImplementedError): + _ = adapter.parent_instance_id + + +def test_function_context_raises_not_implemented(): + adapter, _ = _adapter() + with pytest.raises(NotImplementedError): + _ = adapter.function_context + + +def test_histories_raises_not_implemented(): + adapter, _ = _adapter() + with pytest.raises(NotImplementedError): + _ = adapter.histories + + # --------------------------------------------------------------------------- # Arity detection and wrapping # --------------------------------------------------------------------------- From 46570e9b4b47f5bd6c664625ed937322e075476b Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Thu, 2 Jul 2026 12:25:04 -0600 Subject: [PATCH 30/45] Add missing type coersion, rich client, tests --- .../decorators/durable_app.py | 25 ++- .../internal/compat/orchestration_context.py | 26 ++- .../test_decorator_compat.py | 148 ++++++++++++++++++ .../test_serialization_compat.py | 71 +++++++++ 4 files changed, 260 insertions(+), 10 deletions(-) create mode 100644 tests/azure-functions-durable/test_decorator_compat.py create mode 100644 tests/azure-functions-durable/test_serialization_compat.py diff --git a/azure-functions-durable/azure/durable_functions/decorators/durable_app.py b/azure-functions-durable/azure/durable_functions/decorators/durable_app.py index 8c2d68df..2b3473bb 100644 --- a/azure-functions-durable/azure/durable_functions/decorators/durable_app.py +++ b/azure-functions-durable/azure/durable_functions/decorators/durable_app.py @@ -12,6 +12,7 @@ from .metadata import OrchestrationTrigger, ActivityTrigger, EntityTrigger, \ DurableClient +from ..client import DurableFunctionsClient from ..worker import DurableFunctionsWorker from ..orchestrator import Orchestrator @@ -47,7 +48,8 @@ def __init__(self, def _configure_orchestrator_callable( self, - wrap: Callable[[Callable[..., Any]], FunctionBuilder] + 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. @@ -55,6 +57,10 @@ def _configure_orchestrator_callable( ---------- 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 ------- @@ -65,6 +71,11 @@ def _configure_orchestrator_callable( 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 @@ -167,7 +178,8 @@ def decorator(func: Callable[..., Any]) -> FunctionBuilder: return decorator def orchestration_trigger(self, context_name: str, - orchestration: Optional[str] = None + orchestration: Optional[str] = None, + input_type: Optional[type] = None ) -> Callable[[task.Orchestrator[Any, Any]], FunctionBuilder]: """Register an Orchestrator Function. @@ -178,8 +190,12 @@ def orchestration_trigger(self, context_name: str, 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._configure_orchestrator_callable @self._build_function def wrap(fb: FunctionBuilder) -> FunctionBuilder: @@ -191,7 +207,7 @@ def decorator() -> FunctionBuilder: return decorator() - return wrap + return self._configure_orchestrator_callable(wrap, input_type=input_type) def activity_trigger(self, input_name: str, activity: Optional[str] = None @@ -271,6 +287,7 @@ def durable_client_input(self, @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, 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 index 76c22720..0c324206 100644 --- a/azure-functions-durable/azure/durable_functions/internal/compat/orchestration_context.py +++ b/azure-functions-durable/azure/durable_functions/internal/compat/orchestration_context.py @@ -10,6 +10,7 @@ from durabletask.entities import EntityInstanceId from durabletask.task import OrchestrationContext, RetryPolicy, Task +from ..serialization import DEFAULT_FUNCTIONS_DATA_CONVERTER from .token_source import TokenSource @@ -24,16 +25,28 @@ class DurableOrchestrationContext: context directly instead. """ - def __init__(self, ctx: OrchestrationContext, orchestration_input: Any = None): + 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 # -- input --------------------------------------------------------------- - def get_input(self) -> Any: - """Get the orchestration input.""" - return self._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 @@ -227,11 +240,12 @@ def wrap_orchestrator(fn: Callable[..., Any]) -> Callable[..., Any]: 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) + adapter = DurableOrchestrationContext(context, _input, input_type) generator = cast("Generator[Any, Any, Any]", fn(adapter)) result: Any = yield from generator return result @@ -239,7 +253,7 @@ def _generator_wrapper(context: OrchestrationContext, _input: Any = None) -> Any return _generator_wrapper def _wrapper(context: OrchestrationContext, _input: Any = None) -> Any: - adapter = DurableOrchestrationContext(context, _input) + adapter = DurableOrchestrationContext(context, _input, input_type) return fn(adapter) _wrapper.__name__ = name return _wrapper 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..2ffe2ed9 --- /dev/null +++ b/tests/azure-functions-durable/test_decorator_compat.py @@ -0,0 +1,148 @@ +# 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() + + def orch(context): + return 1 + + app.orchestration_trigger(context_name="context")(orch) + assert len(app._function_builders) == 1 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..39c98dcc --- /dev/null +++ b/tests/azure-functions-durable/test_serialization_compat.py @@ -0,0 +1,71 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +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) From af18afed481c6594f78bedc6d570a20679900474 Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Thu, 2 Jul 2026 13:13:13 -0600 Subject: [PATCH 31/45] Fix gaps found via integration tests --- CHANGELOG.md | 10 +++++ azure-functions-durable/CHANGELOG.md | 20 +++++++++ .../azure/durable_functions/client.py | 11 +++++ .../compat/durable_orchestration_status.py | 41 +++++++++++++++---- .../internal/compat/orchestration_context.py | 13 ++++-- durabletask/task.py | 5 +++ durabletask/worker.py | 5 +++ .../test_client_compat.py | 3 ++ 8 files changed, 98 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 46df1f48..15346b80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## Unreleased +ADDED + +- Added a `result` property to `Task` as a convenience alias for `get_result()`. + +FIXED + +- `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.0 ADDED diff --git a/azure-functions-durable/CHANGELOG.md b/azure-functions-durable/CHANGELOG.md index febfe6df..54510821 100644 --- a/azure-functions-durable/CHANGELOG.md +++ b/azure-functions-durable/CHANGELOG.md @@ -7,8 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Fixed + +- `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. + ### Added +- 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 diff --git a/azure-functions-durable/azure/durable_functions/client.py b/azure-functions-durable/azure/durable_functions/client.py index fda5df28..d4de1ab6 100644 --- a/azure-functions-durable/azure/durable_functions/client.py +++ b/azure-functions-durable/azure/durable_functions/client.py @@ -15,6 +15,7 @@ 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 @@ -58,6 +59,15 @@ def __init__(self, client_as_string: str): 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__( @@ -65,6 +75,7 @@ def __init__(self, client_as_string: str): 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: 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 index 2db4f94f..d389c474 100644 --- 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 @@ -1,6 +1,7 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. +import json from datetime import datetime from typing import Any, Optional @@ -94,7 +95,13 @@ def history(self) -> Optional[list[Any]]: return None def to_json(self) -> dict[str, Any]: - """Convert this status into a v1-compatible JSON dictionary.""" + """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 @@ -104,12 +111,32 @@ def to_json(self) -> dict[str, Any]: result["createdTime"] = self.created_time.isoformat() if self.last_updated_time is not None: result["lastUpdatedTime"] = self.last_updated_time.isoformat() - if self.output is not None: - result["output"] = self.output - if self.input_ is not None: - result["input"] = self.input_ + 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 - if self.custom_status is not None: - result["customStatus"] = self.custom_status + 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/orchestration_context.py b/azure-functions-durable/azure/durable_functions/internal/compat/orchestration_context.py index 0c324206..0e08f834 100644 --- a/azure-functions-durable/azure/durable_functions/internal/compat/orchestration_context.py +++ b/azure-functions-durable/azure/durable_functions/internal/compat/orchestration_context.py @@ -2,7 +2,7 @@ # Licensed under the MIT License. import inspect -from datetime import datetime +from datetime import datetime, timezone from typing import Any, Callable, Generator, Optional, cast from uuid import UUID @@ -61,8 +61,15 @@ def is_replaying(self) -> bool: @property def current_utc_datetime(self) -> datetime: - """Get the replay-safe current UTC date/time.""" - return self._ctx.current_utc_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: diff --git a/durabletask/task.py b/durabletask/task.py index 2085fd9c..564506f4 100644 --- a/durabletask/task.py +++ b/durabletask/task.py @@ -503,6 +503,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 29824632..10da62cf 100644 --- a/durabletask/worker.py +++ b/durabletask/worker.py @@ -1668,6 +1668,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 ( diff --git a/tests/azure-functions-durable/test_client_compat.py b/tests/azure-functions-durable/test_client_compat.py index fcdb17c2..263ab872 100644 --- a/tests/azure-functions-durable/test_client_compat.py +++ b/tests/azure-functions-durable/test_client_compat.py @@ -399,6 +399,9 @@ def _fake_state(): 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", From 013f6740521db2a156cc9e5b11391733d50353f7 Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Thu, 2 Jul 2026 15:21:55 -0600 Subject: [PATCH 32/45] Fix old-name interceptor lookup --- .../internal/azurefunctions_grpc_interceptor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index f4011322..35373b20 100644 --- a/azure-functions-durable/azure/durable_functions/internal/azurefunctions_grpc_interceptor.py +++ b/azure-functions-durable/azure/durable_functions/internal/azurefunctions_grpc_interceptor.py @@ -13,7 +13,7 @@ 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('durabletask-azurefunctions') + sdk_version = version('azure-functions-durable') except Exception: # Fallback if version cannot be determined sdk_version = "unknown" From 60b61956e8d3d2ea8181f3b38196c17d8466376d Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Thu, 2 Jul 2026 15:41:21 -0600 Subject: [PATCH 33/45] More compat layer stuff --- azure-functions-durable/CHANGELOG.md | 18 ++++++- .../http/http_management_payload.py | 49 +++++++------------ .../compat/durable_orchestration_status.py | 42 +++++++++++++++- .../internal/compat/orchestration_context.py | 5 ++ .../internal/compat/purge_history_result.py | 7 +++ .../internal/compat/retry_options.py | 7 +++ 6 files changed, 93 insertions(+), 35 deletions(-) diff --git a/azure-functions-durable/CHANGELOG.md b/azure-functions-durable/CHANGELOG.md index 54510821..84f9aed3 100644 --- a/azure-functions-durable/CHANGELOG.md +++ b/azure-functions-durable/CHANGELOG.md @@ -23,6 +23,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 (`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`). ### Added @@ -103,11 +117,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `create_http_management_payload` now accepts either the durabletask `(request, instance_id)` signature or the v1 `(instance_id)` signature for backwards compatibility. -- `HttpManagementPayload` now supports mapping-style access +- `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. - ## v0.1.0 - Initial implementation 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 index 4c1b634b..8f33ce91 100644 --- a/azure-functions-durable/azure/durable_functions/http/http_management_payload.py +++ b/azure-functions-durable/azure/durable_functions/http/http_management_payload.py @@ -2,18 +2,20 @@ # Licensed under the MIT License. import json -from collections.abc import Iterator +from typing import Any -class HttpManagementPayload: +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. - Supports mapping-style access (``payload["statusQueryGetUri"]``, iteration, - ``in``, ``.keys()``/``.items()``/``.values()``) for backwards compatibility - with the v1 API, which returned a plain ``dict``. + 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): @@ -24,7 +26,7 @@ def __init__(self, instance_id: str, instance_status_url: str, required_query_st 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. """ - self.urls = { + super().__init__({ 'id': instance_id, 'purgeHistoryDeleteUri': instance_status_url + "?" + required_query_string_parameters, 'restartPostUri': instance_status_url + "/restart?" + required_query_string_parameters, @@ -33,31 +35,16 @@ def __init__(self, instance_id: str, instance_status_url: str, required_query_st '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): - return json.dumps(self.urls) + def __str__(self) -> str: + return json.dumps(self) - def __getitem__(self, key: str) -> str: - return self.urls[key] + @property + def urls(self) -> dict[str, Any]: + """Return the management URLs as a plain ``dict`` (v1 compatibility).""" + return dict(self) - def __iter__(self) -> Iterator[str]: - return iter(self.urls) - - def __len__(self) -> int: - return len(self.urls) - - def __contains__(self, key: object) -> bool: - return key in self.urls - - def keys(self): - """Return the management URL keys.""" - return self.urls.keys() - - def items(self): - """Return the management URL (key, value) pairs.""" - return self.urls.items() - - def values(self): - """Return the management URL values.""" - return self.urls.values() + 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/internal/compat/durable_orchestration_status.py b/azure-functions-durable/azure/durable_functions/internal/compat/durable_orchestration_status.py index d389c474..a1e7f9af 100644 --- 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 @@ -3,13 +3,14 @@ import json from datetime import datetime -from typing import Any, Optional +from typing import Any, Optional, cast -from durabletask.client import OrchestrationState +from durabletask.client import OrchestrationState, OrchestrationStatus from .orchestration_runtime_status import ( OrchestrationRuntimeStatus, from_durabletask_status, + to_durabletask_status, ) @@ -35,6 +36,43 @@ def from_orchestration_state( """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 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 index 0e08f834..294fe755 100644 --- a/azure-functions-durable/azure/durable_functions/internal/compat/orchestration_context.py +++ b/azure-functions-durable/azure/durable_functions/internal/compat/orchestration_context.py @@ -81,6 +81,11 @@ 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) -> str: """Get the ID of the parent orchestration. 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 index e9d300ff..adf3dd06 100644 --- 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 @@ -1,6 +1,8 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. +from typing import Any + from durabletask.client import PurgeInstancesResult @@ -21,6 +23,11 @@ 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.""" 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 index 1943007b..0a615513 100644 --- a/azure-functions-durable/azure/durable_functions/internal/compat/retry_options.py +++ b/azure-functions-durable/azure/durable_functions/internal/compat/retry_options.py @@ -44,3 +44,10 @@ def __init__( 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, + } From 2c7d5b200fddd2967d5dd675b70c6c58f290486d Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Thu, 9 Jul 2026 10:10:44 -0600 Subject: [PATCH 34/45] Return an emtpy function context instead of raising --- .../internal/compat/function_context.py | 23 +++++++++++++++++++ .../internal/compat/orchestration_context.py | 21 +++++++++++------ .../test_orchestration_context_compat.py | 9 +++++--- 3 files changed, 43 insertions(+), 10 deletions(-) create mode 100644 azure-functions-durable/azure/durable_functions/internal/compat/function_context.py 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 index 886d5d99..8d523251 100644 --- a/azure-functions-durable/azure/durable_functions/internal/compat/orchestration_context.py +++ b/azure-functions-durable/azure/durable_functions/internal/compat/orchestration_context.py @@ -11,6 +11,7 @@ from durabletask.task import OrchestrationContext, RetryPolicy, Task from ..serialization import DEFAULT_FUNCTIONS_DATA_CONVERTER +from .function_context import FunctionContext from .token_source import TokenSource @@ -34,6 +35,10 @@ def __init__(self, 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: @@ -96,14 +101,16 @@ def parent_instance_id(self) -> Optional[str]: return self._ctx.parent_instance_id @property - def function_context(self) -> Any: - """Get the Azure Functions-level context. - - Not available: durabletask does not provide the v1 ``FunctionContext`` - binding metadata. + 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. """ - raise NotImplementedError( - "function_context is not available in this SDK.") + return self._function_context @property def histories(self) -> Any: diff --git a/tests/azure-functions-durable/test_orchestration_context_compat.py b/tests/azure-functions-durable/test_orchestration_context_compat.py index 20e7815b..53cfa467 100644 --- a/tests/azure-functions-durable/test_orchestration_context_compat.py +++ b/tests/azure-functions-durable/test_orchestration_context_compat.py @@ -142,10 +142,13 @@ def test_parent_instance_id_delegates(): assert adapter.parent_instance_id == "parent-123" -def test_function_context_raises_not_implemented(): +def test_function_context_returns_empty_bag(): + from azure.durable_functions.internal.compat.function_context import FunctionContext adapter, _ = _adapter() - with pytest.raises(NotImplementedError): - _ = adapter.function_context + 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(): From 69cbc4568d58eb80ee07d1534a4d85d09e900a20 Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Thu, 9 Jul 2026 13:13:09 -0600 Subject: [PATCH 35/45] Asyncio in test matrix --- .github/workflows/durabletask-azurefunctions.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/durabletask-azurefunctions.yml b/.github/workflows/durabletask-azurefunctions.yml index 6df274f1..d021911e 100644 --- a/.github/workflows/durabletask-azurefunctions.yml +++ b/.github/workflows/durabletask-azurefunctions.yml @@ -54,7 +54,7 @@ jobs: - name: Install durabletask locally run: | python -m pip install --upgrade pip - pip install pytest + pip install pytest pytest-asyncio pip install . --force-reinstall - name: Install azure-functions-durable locally From 95e0b9e5b0c23bbd0213885fa8f25347e3d40a9c Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Thu, 9 Jul 2026 14:57:52 -0600 Subject: [PATCH 36/45] CI Tweaks --- .github/workflows/durabletask-azurefunctions.yml | 13 +++++++++---- eng/ci/release.yml | 8 ++++++++ requirements.txt | 1 + 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/.github/workflows/durabletask-azurefunctions.yml b/.github/workflows/durabletask-azurefunctions.yml index d021911e..ba105f1d 100644 --- a/.github/workflows/durabletask-azurefunctions.yml +++ b/.github/workflows/durabletask-azurefunctions.yml @@ -51,15 +51,20 @@ jobs: with: python-version: ${{ matrix.python-version }} - - name: Install durabletask locally + - name: Install durabletask dependencies run: | python -m pip install --upgrade pip - pip install pytest pytest-asyncio - pip install . --force-reinstall + 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 ./azure-functions-durable --force-reinstall + pip install . --no-deps --force-reinstall - name: Run unit tests working-directory: tests/azure-functions-durable diff --git a/eng/ci/release.yml b/eng/ci/release.yml index f27c1545..61c63d2a 100644 --- a/eng/ci/release.yml +++ b/eng/ci/release.yml @@ -99,6 +99,14 @@ extends: 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: diff --git a/requirements.txt b/requirements.txt index 61b4ca6b..ea61d217 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,6 +6,7 @@ pytest-asyncio pytest-cov azure-identity azure-functions +typing-extensions asyncio packaging opentelemetry-api From b69eeeb134d982ef732d20f12fd5fee4e408f475 Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Fri, 10 Jul 2026 15:02:30 -0600 Subject: [PATCH 37/45] Add Durable HTTP feature --- .vscode/settings.json | 3 +- .../azure/durable_functions/__init__.py | 3 + .../decorators/durable_app.py | 21 ++ .../azure/durable_functions/http/__init__.py | 3 +- .../azure/durable_functions/http/builtin.py | 176 +++++++++++++++ .../azure/durable_functions/http/models.py | 122 ++++++++++ .../internal/compat/orchestration_context.py | 62 +++++- .../internal/compat/token_source.py | 17 +- .../test_client_compat.py | 28 ++- .../test_decorator_compat.py | 3 +- .../test_http_builtin_compat.py | 208 ++++++++++++++++++ .../test_orchestration_context_compat.py | 47 +++- 12 files changed, 655 insertions(+), 38 deletions(-) create mode 100644 azure-functions-durable/azure/durable_functions/http/builtin.py create mode 100644 azure-functions-durable/azure/durable_functions/http/models.py create mode 100644 tests/azure-functions-durable/test_http_builtin_compat.py 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/azure-functions-durable/azure/durable_functions/__init__.py b/azure-functions-durable/azure/durable_functions/__init__.py index b24c353e..b62091c9 100644 --- a/azure-functions-durable/azure/durable_functions/__init__.py +++ b/azure-functions-durable/azure/durable_functions/__init__.py @@ -8,6 +8,7 @@ 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 @@ -32,6 +33,8 @@ "DFApp", "DurableEntityContext", "DurableFunctionsClient", + "DurableHttpRequest", + "DurableHttpResponse", "DurableOrchestrationClient", "DurableOrchestrationContext", "DurableOrchestrationStatus", diff --git a/azure-functions-durable/azure/durable_functions/decorators/durable_app.py b/azure-functions-durable/azure/durable_functions/decorators/durable_app.py index 2b3473bb..e04320c8 100644 --- a/azure-functions-durable/azure/durable_functions/decorators/durable_app.py +++ b/azure-functions-durable/azure/durable_functions/decorators/durable_app.py @@ -13,6 +13,12 @@ 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 @@ -45,6 +51,21 @@ def __init__(self, # 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_orchestrator_callable( self, diff --git a/azure-functions-durable/azure/durable_functions/http/__init__.py b/azure-functions-durable/azure/durable_functions/http/__init__.py index b4d2c355..61232643 100644 --- a/azure-functions-durable/azure/durable_functions/http/__init__.py +++ b/azure-functions-durable/azure/durable_functions/http/__init__.py @@ -2,5 +2,6 @@ # Licensed under the MIT License. from ..http.http_management_payload import HttpManagementPayload +from .models import DurableHttpRequest, DurableHttpResponse -__all__ = ["HttpManagementPayload"] +__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..ab2c70c5 --- /dev/null +++ b/azure-functions-durable/azure/durable_functions/http/builtin.py @@ -0,0 +1,176 @@ +# 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: Optional[dict[str, Any]]) -> dict[str, Any]: + """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`. + """ + 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/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/compat/orchestration_context.py b/azure-functions-durable/azure/durable_functions/internal/compat/orchestration_context.py index 8d523251..136908b2 100644 --- a/azure-functions-durable/azure/durable_functions/internal/compat/orchestration_context.py +++ b/azure-functions-durable/azure/durable_functions/internal/compat/orchestration_context.py @@ -2,6 +2,7 @@ # 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 @@ -207,22 +208,63 @@ def signal_entity(self, """Signal an entity operation (fire and forget).""" self._ctx.signal_entity(entityId, operationName, input=operationInput) - # -- durable HTTP (not yet supported) ------------------------------------ + # -- durable HTTP -------------------------------------------------------- def call_http(self, method: str, uri: str, - content: Optional[str] = None, + content: Any = None, headers: Optional[dict[str, str]] = None, token_source: Optional[TokenSource] = None, - is_raw_str: bool = False) -> Any: - """Schedule a durable HTTP call (v1 API). - - Not yet supported: durabletask has no durable-HTTP (``call_http``) - equivalent, so this raises ``NotImplementedError``. + 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``. """ - raise NotImplementedError( - "call_http is not yet supported by durabletask. The durable-HTTP " - "API (and its TokenSource auth) has no durabletask equivalent yet.") + # 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: 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 index 5b990f85..b36e3f74 100644 --- a/azure-functions-durable/azure/durable_functions/internal/compat/token_source.py +++ b/azure-functions-durable/azure/durable_functions/internal/compat/token_source.py @@ -3,31 +3,24 @@ from abc import ABC -from typing_extensions import deprecated - class TokenSource(ABC): """Token source abstract base class. - Backwards-compatible shim for the v1 ``TokenSource``. Token sources are - consumed only by the orchestrator ``call_http`` API, which has no - durabletask equivalent yet — see - :meth:`DurableOrchestrationContext.call_http`. Constructing a token source - is harmless, but it cannot be used until ``call_http`` is supported. + 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__() -@deprecated( - "ManagedIdentityTokenSource is deprecated; it is only usable with the " - "orchestrator call_http API, which is not yet available in durabletask.") class ManagedIdentityTokenSource(TokenSource): """Returns a ``ManagedIdentityTokenSource`` object. - Only meaningful when passed to ``call_http`` (not yet supported in - durabletask). Constructing one is allowed for import/config compatibility. + 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): diff --git a/tests/azure-functions-durable/test_client_compat.py b/tests/azure-functions-durable/test_client_compat.py index 263ab872..cc3a0fab 100644 --- a/tests/azure-functions-durable/test_client_compat.py +++ b/tests/azure-functions-durable/test_client_compat.py @@ -376,8 +376,7 @@ def test_entity_id_maps_to_entity_instance_id(): def test_managed_identity_token_source_shim(): - with pytest.warns(DeprecationWarning): - source = df.ManagedIdentityTokenSource("https://management.core.windows.net") + source = df.ManagedIdentityTokenSource("https://management.core.windows.net") assert source.resource == "https://management.core.windows.net" assert source.to_json()["kind"] == "AzureManagedIdentity" @@ -553,17 +552,26 @@ async def test_http_management_payload_is_mapping_like(): # --------------------------------------------------------------------------- -# call_http not implemented +# call_http # --------------------------------------------------------------------------- -def test_call_http_raises_not_implemented(): - # call_http ignores self, so invoke via the class to avoid instantiating - # the abstract context. - with pytest.raises(NotImplementedError): - df.DurableOrchestrationContext.call_http(None, "GET", "http://example.com") +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(): - with pytest.warns(DeprecationWarning): - source = df.ManagedIdentityTokenSource("https://graph.microsoft.com") + 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 index 2ffe2ed9..fd2adb30 100644 --- a/tests/azure-functions-durable/test_decorator_compat.py +++ b/tests/azure-functions-durable/test_decorator_compat.py @@ -140,9 +140,10 @@ async def starter(client): 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) == 1 + assert len(app._function_builders) == baseline + 1 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..80927061 --- /dev/null +++ b/tests/azure-functions-durable/test_http_builtin_compat.py @@ -0,0 +1,208 @@ +# 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_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_orchestration_context_compat.py b/tests/azure-functions-durable/test_orchestration_context_compat.py index 53cfa467..2f4e815e 100644 --- a/tests/azure-functions-durable/test_orchestration_context_compat.py +++ b/tests/azure-functions-durable/test_orchestration_context_compat.py @@ -110,10 +110,51 @@ def test_task_all_and_task_any_use_when_helpers(): when_any.assert_called_once_with(["t1", "t2"]) -def test_call_http_raises_not_implemented(): +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(NotImplementedError): - adapter.call_http("GET", "http://example.com") + with pytest.raises(TypeError): + adapter.call_http("POST", "http://example.com", content={"a": 1}, is_raw_str=True) # --------------------------------------------------------------------------- From 905a400731595c87ad89416044c4c9957aca5514 Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Fri, 10 Jul 2026 15:03:35 -0600 Subject: [PATCH 38/45] CHANGELOG for now --- azure-functions-durable/CHANGELOG.md | 70 ++++++++++++++++------------ 1 file changed, 39 insertions(+), 31 deletions(-) diff --git a/azure-functions-durable/CHANGELOG.md b/azure-functions-durable/CHANGELOG.md index 84f9aed3..1d568564 100644 --- a/azure-functions-durable/CHANGELOG.md +++ b/azure-functions-durable/CHANGELOG.md @@ -7,39 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased -### Fixed - -- `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`). - ### Added +- `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. @@ -122,6 +99,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 (`payload["statusQueryGetUri"]`, iteration, `in`, `keys()`/`items()`/`values()`) so v1 code that treated the payload as a `dict` keeps working. +### Fixed + +- `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 From 8de1900668709f175ade668d41c50bdcedced3d7 Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Fri, 10 Jul 2026 15:13:02 -0600 Subject: [PATCH 39/45] Add unit testing coverage --- .../compat/durable_orchestration_status.py | 2 +- ...est_durable_orchestration_status_compat.py | 108 ++++++++++ .../test_http_builtin_compat.py | 19 ++ .../test_internal_infra_compat.py | 73 +++++++ .../test_orchestrator_compat.py | 66 ++++++ .../test_serialization_fallback_compat.py | 60 ++++++ .../test_worker_compat.py | 199 ++++++++++++++++++ 7 files changed, 526 insertions(+), 1 deletion(-) create mode 100644 tests/azure-functions-durable/test_durable_orchestration_status_compat.py create mode 100644 tests/azure-functions-durable/test_internal_infra_compat.py create mode 100644 tests/azure-functions-durable/test_orchestrator_compat.py create mode 100644 tests/azure-functions-durable/test_serialization_fallback_compat.py create mode 100644 tests/azure-functions-durable/test_worker_compat.py 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 index a1e7f9af..29ce55b4 100644 --- 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 @@ -114,7 +114,7 @@ def output(self) -> Any: @property def runtime_status(self) -> Optional[OrchestrationRuntimeStatus]: """Get the runtime status as a v1 ``OrchestrationRuntimeStatus``.""" - if self._state is None: + if self._state is None or self._state.runtime_status is None: return None return from_durabletask_status(self._state.runtime_status) 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_http_builtin_compat.py b/tests/azure-functions-durable/test_http_builtin_compat.py index 80927061..54cab81c 100644 --- a/tests/azure-functions-durable/test_http_builtin_compat.py +++ b/tests/azure-functions-durable/test_http_builtin_compat.py @@ -26,6 +26,25 @@ # 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"} 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_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_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_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 From 7d48ad3bd859ea5615a93eee8181df05bc709c3a Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Fri, 10 Jul 2026 18:23:26 -0600 Subject: [PATCH 40/45] Add E2E harness --- .flake8 | 5 +- .../workflows/durabletask-azurefunctions.yml | 49 ++- .gitignore | 3 + .../azure/durable_functions/http/builtin.py | 11 +- .../internal/serialization.py | 12 +- dev-requirements.txt | 1 + noxfile.py | 146 ++++++++ pyproject.toml | 1 + tests/azure-functions-durable/e2e/__init__.py | 2 + tests/azure-functions-durable/e2e/_harness.py | 314 ++++++++++++++++++ .../e2e/apps/dtask_style/function_app.py | 214 ++++++++++++ .../e2e/apps/dtask_style/host.json | 19 ++ .../e2e/apps/dtask_style/local.settings.json | 7 + .../e2e/apps/dtask_style/requirements.txt | 6 + .../e2e/apps/v1_style/function_app.py | 193 +++++++++++ .../e2e/apps/v1_style/host.json | 19 ++ .../e2e/apps/v1_style/local.settings.json | 7 + .../e2e/apps/v1_style/requirements.txt | 6 + tests/azure-functions-durable/e2e/conftest.py | 47 +++ .../e2e/test_dtask_style_e2e.py | 127 +++++++ .../e2e/test_v1_style_e2e.py | 150 +++++++++ .../test_serialization_compat.py | 13 + 22 files changed, 1348 insertions(+), 4 deletions(-) create mode 100644 noxfile.py create mode 100644 tests/azure-functions-durable/e2e/__init__.py create mode 100644 tests/azure-functions-durable/e2e/_harness.py create mode 100644 tests/azure-functions-durable/e2e/apps/dtask_style/function_app.py create mode 100644 tests/azure-functions-durable/e2e/apps/dtask_style/host.json create mode 100644 tests/azure-functions-durable/e2e/apps/dtask_style/local.settings.json create mode 100644 tests/azure-functions-durable/e2e/apps/dtask_style/requirements.txt create mode 100644 tests/azure-functions-durable/e2e/apps/v1_style/function_app.py create mode 100644 tests/azure-functions-durable/e2e/apps/v1_style/host.json create mode 100644 tests/azure-functions-durable/e2e/apps/v1_style/local.settings.json create mode 100644 tests/azure-functions-durable/e2e/apps/v1_style/requirements.txt create mode 100644 tests/azure-functions-durable/e2e/conftest.py create mode 100644 tests/azure-functions-durable/e2e/test_dtask_style_e2e.py create mode 100644 tests/azure-functions-durable/e2e/test_v1_style_e2e.py 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 index ba105f1d..5c8f0970 100644 --- a/.github/workflows/durabletask-azurefunctions.yml +++ b/.github/workflows/durabletask-azurefunctions.yml @@ -69,4 +69,51 @@ jobs: - name: Run unit tests working-directory: tests/azure-functions-durable run: | - pytest -m "not dts and not azurite" --verbose + 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/.gitignore b/.gitignore index 85400775..bf61e134 100644 --- a/.gitignore +++ b/.gitignore @@ -111,6 +111,9 @@ ENV/ env.bak/ venv.bak/ +# Azure Functions E2E test host logs (written by the test harness) +_func_host.log + # Spyder project settings .spyderproject .spyproject diff --git a/azure-functions-durable/azure/durable_functions/http/builtin.py b/azure-functions-durable/azure/durable_functions/http/builtin.py index ab2c70c5..dd582f10 100644 --- a/azure-functions-durable/azure/durable_functions/http/builtin.py +++ b/azure-functions-durable/azure/durable_functions/http/builtin.py @@ -59,7 +59,7 @@ def _acquire_bearer_token(resource: str) -> str: return credential.get_token(scope).token -def builtin_http_activity(input: Optional[dict[str, Any]]) -> dict[str, Any]: +def builtin_http_activity(input: dict) -> dict: """Execute a single HTTP request and return the response payload. ``input`` is the JSON form of a @@ -67,6 +67,15 @@ def builtin_http_activity(input: Optional[dict[str, Any]]) -> dict[str, Any]: (``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() diff --git a/azure-functions-durable/azure/durable_functions/internal/serialization.py b/azure-functions-durable/azure/durable_functions/internal/serialization.py index 803b26fd..0a72aa64 100644 --- a/azure-functions-durable/azure/durable_functions/internal/serialization.py +++ b/azure-functions-durable/azure/durable_functions/internal/serialization.py @@ -110,7 +110,17 @@ def serialize(self, value: Any) -> str | None: def deserialize(self, data: str | None, target_type: type | None = None) -> Any: if data is None or data == "": return None - return df_loads(data, target_type) + 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 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/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 fca9df36..d9ad69dd 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/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..ac47f42b --- /dev/null +++ b/tests/azure-functions-durable/e2e/_harness.py @@ -0,0 +1,314 @@ +# 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, 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 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. + """ + + 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 = 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) + + 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, + ) + + try: + self._wait_until_ready() + except Exception: + self.stop() + raise + + 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 RuntimeError( + 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}") 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..25baa7c3 --- /dev/null +++ b/tests/azure-functions-durable/e2e/apps/dtask_style/function_app.py @@ -0,0 +1,214 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""durabletask-native-style Durable Functions sample app for E2E testing. + +Every orchestrator and entity here uses the modern durabletask authoring style: +two-argument orchestrators (``def orch(ctx, input):``) that use the durabletask +``OrchestrationContext`` API directly (``ctx.call_activity(name, input=...)``, +``task.when_all``, ``ctx.call_sub_orchestrator``, ``ctx.call_entity`` with an +``EntityInstanceId``), two-argument entity functions (``def entity(ctx, input):``), +and the durabletask client method names (``schedule_new_orchestration``, +``get_orchestration_state``, ``raise_orchestration_event``, +``terminate_orchestration``, ``purge_orchestration``, ``get_entity``, +``signal_entity``). + +Together with the v1-style app it exercises both authoring surfaces the +compatibility layer supports, end-to-end against a real Functions host. +""" + +import json +from typing import Any + +import azure.functions as func + +import azure.durable_functions as df +from durabletask import entities, task + +app = df.DFApp(http_auth_level=func.AuthLevel.ANONYMOUS) + + +# --------------------------------------------------------------------------- +# Activities (dispatched by the host; single-argument input, as in Functions) +# --------------------------------------------------------------------------- + +@app.activity_trigger(input_name="name") +def say_hello(name: str) -> str: + return f"Hello {name}!" + + +@app.activity_trigger(input_name="n") +def square(n: int) -> int: + return n * n + + +# --------------------------------------------------------------------------- +# Entity (durabletask native style: two arguments) +# --------------------------------------------------------------------------- + +@app.entity_trigger(context_name="context") +def counter(ctx: entities.EntityContext, input: Any = None) -> Any: + if ctx.operation == "add": + new_state = ctx.get_state(int, 0) + (input or 0) + ctx.set_state(new_state) + return new_state + if ctx.operation == "reset": + ctx.set_state(0) + return 0 + return ctx.get_state(int, 0) + + +# --------------------------------------------------------------------------- +# Orchestrators (durabletask native style: two arguments) +# --------------------------------------------------------------------------- + +@app.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] + + +@app.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) + + +@app.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} + + +@app.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} + + +@app.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 + + +@app.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 + + +# --------------------------------------------------------------------------- +# HTTP routes: starter + client management surface (durabletask method names) +# --------------------------------------------------------------------------- + +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, + } + + +@app.route(route="ping", methods=["GET"]) +def ping(req: func.HttpRequest) -> func.HttpResponse: + return func.HttpResponse("pong") + + +@app.route(route="start/{name}", methods=["POST"]) +@app.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") + + +@app.route(route="status/{id}", methods=["GET"]) +@app.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") + + +@app.route(route="raise/{id}/{event}", methods=["POST"]) +@app.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) + + +@app.route(route="terminate/{id}", methods=["POST"]) +@app.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) + + +@app.route(route="purge/{id}", methods=["POST"]) +@app.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") + + +@app.route(route="entity/{name}/{key}", methods=["GET"]) +@app.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") + + +@app.route(route="signal/{name}/{key}/{op}", methods=["POST"]) +@app.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"]) + 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/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/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/function_app.py b/tests/azure-functions-durable/e2e/apps/v1_style/function_app.py new file mode 100644 index 00000000..21187e9b --- /dev/null +++ b/tests/azure-functions-durable/e2e/apps/v1_style/function_app.py @@ -0,0 +1,193 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""V1-style Durable Functions sample app for end-to-end testing. + +Every orchestrator and entity here uses the classic ``azure-functions-durable`` +v1 authoring style: single-argument generator orchestrators +(``def orch(context):``), single-argument entity functions +(``def entity(context):``), and the deprecated v1 client method names +(``start_new``, ``get_status``, ``raise_event``, ``terminate``, +``purge_instance_history``, ``read_entity_state``, ``signal_entity``). + +The app is driven by the E2E suite through its HTTP routes. It is intentionally +broad, covering activity chaining, fan-out/fan-in, sub-orchestrations, external +events + timers, entities, custom status, continue-as-new, durable HTTP, and the +client management surface. +""" + +import json + +import azure.functions as func + +import azure.durable_functions as df + +app = df.DFApp(http_auth_level=func.AuthLevel.ANONYMOUS) + + +# --------------------------------------------------------------------------- +# Activities +# --------------------------------------------------------------------------- + +@app.activity_trigger(input_name="name") +def say_hello(name: str) -> str: + return f"Hello {name}!" + + +@app.activity_trigger(input_name="n") +def square(n: int) -> int: + return n * n + + +# --------------------------------------------------------------------------- +# Entity (v1 style: single context argument) +# --------------------------------------------------------------------------- + +@app.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) + + +# --------------------------------------------------------------------------- +# Orchestrators (v1 style: single context argument, generator) +# --------------------------------------------------------------------------- + +@app.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] + + +@app.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) + + +@app.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} + + +@app.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} + + +@app.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 + + +@app.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 + + +@app.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} + + +# --------------------------------------------------------------------------- +# HTTP routes: starter + client management surface +# --------------------------------------------------------------------------- + +@app.route(route="ping", methods=["GET"]) +def ping(req: func.HttpRequest) -> func.HttpResponse: + return func.HttpResponse("pong") + + +@app.route(route="start/{name}", methods=["POST"]) +@app.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) + + +@app.route(route="status/{id}", methods=["GET"]) +@app.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") + + +@app.route(route="raise/{id}/{event}", methods=["POST"]) +@app.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) + + +@app.route(route="terminate/{id}", methods=["POST"]) +@app.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) + + +@app.route(route="purge/{id}", methods=["POST"]) +@app.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") + + +@app.route(route="entity/{name}/{key}", methods=["GET"]) +@app.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") + + +@app.route(route="signal/{name}/{key}/{op}", methods=["POST"]) +@app.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/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/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/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..66d9b4ba --- /dev/null +++ b/tests/azure-functions-durable/e2e/conftest.py @@ -0,0 +1,47 @@ +# 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 module-scoped Functions host so the two apps are +fully isolated and their hosts start/stop once per test module. +""" + +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="module") +def v1_app(): + _require_prerequisites("v1_style") + with FunctionApp("v1_style") as app: + yield app + + +@pytest.fixture(scope="module") +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_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_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_serialization_compat.py b/tests/azure-functions-durable/test_serialization_compat.py index 39c98dcc..a210a96f 100644 --- a/tests/azure-functions-durable/test_serialization_compat.py +++ b/tests/azure-functions-durable/test_serialization_compat.py @@ -1,6 +1,8 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. +import json + import pytest from azure.durable_functions.internal.serialization import ( @@ -69,3 +71,14 @@ def test_coerce_plain_dict_to_type(): # (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) From 4a0ed32043d256d8283599999884fd113ce2d95e Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Fri, 10 Jul 2026 19:09:51 -0600 Subject: [PATCH 41/45] Expand E2E coverage --- CHANGELOG.md | 4 + azure-functions-durable/CHANGELOG.md | 6 + .../decorators/durable_app.py | 39 +++- durabletask/worker.py | 2 +- tests/azure-functions-durable/e2e/_harness.py | 104 ++++++++- .../e2e/apps/dtask_style/activities.py | 44 ++++ .../e2e/apps/dtask_style/client_routes.py | 183 +++++++++++++++ .../e2e/apps/dtask_style/entities.py | 49 ++++ .../e2e/apps/dtask_style/function_app.py | 213 ++--------------- .../e2e/apps/dtask_style/orchestrators.py | 197 ++++++++++++++++ .../e2e/apps/v1_style/activities.py | 50 ++++ .../e2e/apps/v1_style/client_routes.py | 215 ++++++++++++++++++ .../e2e/apps/v1_style/entities.py | 50 ++++ .../e2e/apps/v1_style/function_app.py | 199 ++-------------- .../e2e/apps/v1_style/http_orchestrators.py | 28 +++ .../e2e/apps/v1_style/orchestrators.py | 214 +++++++++++++++++ tests/azure-functions-durable/e2e/conftest.py | 9 +- .../e2e/test_dtask_client_e2e.py | 86 +++++++ .../e2e/test_dtask_entities_e2e.py | 59 +++++ .../e2e/test_dtask_patterns_e2e.py | 62 +++++ .../e2e/test_dtask_retries_e2e.py | 45 ++++ .../e2e/test_v1_client_e2e.py | 113 +++++++++ .../e2e/test_v1_entities_e2e.py | 61 +++++ .../e2e/test_v1_http_e2e.py | 34 +++ .../e2e/test_v1_patterns_e2e.py | 65 ++++++ .../e2e/test_v1_retries_e2e.py | 62 +++++ .../test_decorator_compat.py | 96 ++++++++ 27 files changed, 1896 insertions(+), 393 deletions(-) create mode 100644 tests/azure-functions-durable/e2e/apps/dtask_style/activities.py create mode 100644 tests/azure-functions-durable/e2e/apps/dtask_style/client_routes.py create mode 100644 tests/azure-functions-durable/e2e/apps/dtask_style/entities.py create mode 100644 tests/azure-functions-durable/e2e/apps/dtask_style/orchestrators.py create mode 100644 tests/azure-functions-durable/e2e/apps/v1_style/activities.py create mode 100644 tests/azure-functions-durable/e2e/apps/v1_style/client_routes.py create mode 100644 tests/azure-functions-durable/e2e/apps/v1_style/entities.py create mode 100644 tests/azure-functions-durable/e2e/apps/v1_style/http_orchestrators.py create mode 100644 tests/azure-functions-durable/e2e/apps/v1_style/orchestrators.py create mode 100644 tests/azure-functions-durable/e2e/test_dtask_client_e2e.py create mode 100644 tests/azure-functions-durable/e2e/test_dtask_entities_e2e.py create mode 100644 tests/azure-functions-durable/e2e/test_dtask_patterns_e2e.py create mode 100644 tests/azure-functions-durable/e2e/test_dtask_retries_e2e.py create mode 100644 tests/azure-functions-durable/e2e/test_v1_client_e2e.py create mode 100644 tests/azure-functions-durable/e2e/test_v1_entities_e2e.py create mode 100644 tests/azure-functions-durable/e2e/test_v1_http_e2e.py create mode 100644 tests/azure-functions-durable/e2e/test_v1_patterns_e2e.py create mode 100644 tests/azure-functions-durable/e2e/test_v1_retries_e2e.py diff --git a/CHANGELOG.md b/CHANGELOG.md index b4b82066..537c756e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,10 @@ CHANGED FIXED +- 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. diff --git a/azure-functions-durable/CHANGELOG.md b/azure-functions-durable/CHANGELOG.md index 1d568564..fa317058 100644 --- a/azure-functions-durable/CHANGELOG.md +++ b/azure-functions-durable/CHANGELOG.md @@ -101,6 +101,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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. diff --git a/azure-functions-durable/azure/durable_functions/decorators/durable_app.py b/azure-functions-durable/azure/durable_functions/decorators/durable_app.py index e04320c8..8a6aee58 100644 --- a/azure-functions-durable/azure/durable_functions/decorators/durable_app.py +++ b/azure-functions-durable/azure/durable_functions/decorators/durable_app.py @@ -6,7 +6,7 @@ import azure.functions as func from azure.functions import FunctionRegister, TriggerApi, BindingApi, AuthLevel -from azure.functions.decorators.function_app import FunctionBuilder +from azure.functions.decorators.function_app import DecoratorApi, FunctionBuilder from durabletask import task @@ -326,4 +326,39 @@ class DFApp(Blueprint, FunctionRegister): Exports the decorators required to declare and index DF Function-types. """ - pass + 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/durabletask/worker.py b/durabletask/worker.py index 36b77354..1c29970a 100644 --- a/durabletask/worker.py +++ b/durabletask/worker.py @@ -2228,7 +2228,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 diff --git a/tests/azure-functions-durable/e2e/_harness.py b/tests/azure-functions-durable/e2e/_harness.py index ac47f42b..8e1cbe33 100644 --- a/tests/azure-functions-durable/e2e/_harness.py +++ b/tests/azure-functions-durable/e2e/_harness.py @@ -25,7 +25,7 @@ import urllib.request from dataclasses import dataclass from pathlib import Path -from typing import Any, Optional +from typing import Any, Callable, Optional APPS_DIR = Path(__file__).parent / "apps" @@ -95,6 +95,14 @@ def http_request( 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. @@ -103,6 +111,12 @@ class FunctionApp: 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(): @@ -141,6 +155,30 @@ def start(self) -> None: "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 @@ -156,7 +194,9 @@ def start(self) -> None: 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. @@ -178,12 +218,6 @@ def start(self) -> None: start_new_session=start_new_session, ) - try: - self._wait_until_ready() - except Exception: - self.stop() - raise - def _wait_until_ready(self) -> None: deadline = time.time() + HOST_STARTUP_TIMEOUT_S ping_url = f"{self.base_url}/api/ping" @@ -217,7 +251,7 @@ def _check_log_for_fatal_errors(self) -> None: log = self._read_log() for marker in self._FATAL_LOG_MARKERS: if marker in log: - raise RuntimeError( + raise _FatalStartupError( f"Functions host failed to start (matched '{marker}').\n{log}") def _read_log(self) -> str: @@ -312,3 +346,57 @@ def wait_for_completion( 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 signal_entity(self, name: str, key: str, op: str, input: Any = None) -> None: + """Signal an entity via the app's ``/api/signal/{name}/{key}/{op}`` route.""" + result = http_request( + "POST", f"{self.base_url}/api/signal/{name}/{key}/{op}", data={"input": input}) + 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..83cbdce0 --- /dev/null +++ b/tests/azure-functions-durable/e2e/apps/dtask_style/client_routes.py @@ -0,0 +1,183 @@ +# 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 json +from typing import Any + +import azure.functions as func + +import azure.durable_functions as df +from durabletask import entities + +bp = df.Blueprint() + + +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"]) + 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/dtask_style/entities.py b/tests/azure-functions-durable/e2e/apps/dtask_style/entities.py new file mode 100644 index 00000000..fb60c514 --- /dev/null +++ b/tests/azure-functions-durable/e2e/apps/dtask_style/entities.py @@ -0,0 +1,49 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Entity functions for the durabletask-native-style sample app (blueprint). + +Uses the modern durabletask two-argument entity style +(``def entity(ctx, input):``) with the durabletask ``EntityContext`` API. +""" + +from typing import Any + +import azure.durable_functions as df +from durabletask import entities + +bp = df.Blueprint() + + +@bp.entity_trigger(context_name="context") +def counter(ctx: entities.EntityContext, input: Any = None) -> Any: + if ctx.operation == "add": + new_state = ctx.get_state(int, 0) + (input or 0) + ctx.set_state(new_state) + return new_state + if ctx.operation == "reset": + ctx.set_state(0) + return 0 + return ctx.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.""" + 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 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 index 25baa7c3..1f517c09 100644 --- a/tests/azure-functions-durable/e2e/apps/dtask_style/function_app.py +++ b/tests/azure-functions-durable/e2e/apps/dtask_style/function_app.py @@ -3,212 +3,29 @@ """durabletask-native-style Durable Functions sample app for E2E testing. -Every orchestrator and entity here uses the modern durabletask authoring style: -two-argument orchestrators (``def orch(ctx, input):``) that use the durabletask -``OrchestrationContext`` API directly (``ctx.call_activity(name, input=...)``, -``task.when_all``, ``ctx.call_sub_orchestrator``, ``ctx.call_entity`` with an -``EntityInstanceId``), two-argument entity functions (``def entity(ctx, input):``), -and the durabletask client method names (``schedule_new_orchestration``, -``get_orchestration_state``, ``raise_orchestration_event``, -``terminate_orchestration``, ``purge_orchestration``, ``get_entity``, -``signal_entity``). +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 json -from typing import Any - import azure.functions as func import azure.durable_functions as df -from durabletask import entities, task - -app = df.DFApp(http_auth_level=func.AuthLevel.ANONYMOUS) - - -# --------------------------------------------------------------------------- -# Activities (dispatched by the host; single-argument input, as in Functions) -# --------------------------------------------------------------------------- - -@app.activity_trigger(input_name="name") -def say_hello(name: str) -> str: - return f"Hello {name}!" - - -@app.activity_trigger(input_name="n") -def square(n: int) -> int: - return n * n - - -# --------------------------------------------------------------------------- -# Entity (durabletask native style: two arguments) -# --------------------------------------------------------------------------- - -@app.entity_trigger(context_name="context") -def counter(ctx: entities.EntityContext, input: Any = None) -> Any: - if ctx.operation == "add": - new_state = ctx.get_state(int, 0) + (input or 0) - ctx.set_state(new_state) - return new_state - if ctx.operation == "reset": - ctx.set_state(0) - return 0 - return ctx.get_state(int, 0) - - -# --------------------------------------------------------------------------- -# Orchestrators (durabletask native style: two arguments) -# --------------------------------------------------------------------------- - -@app.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] - - -@app.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) - - -@app.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} - - -@app.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} - -@app.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 - - -@app.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 - - -# --------------------------------------------------------------------------- -# HTTP routes: starter + client management surface (durabletask method names) -# --------------------------------------------------------------------------- - -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, - } - - -@app.route(route="ping", methods=["GET"]) -def ping(req: func.HttpRequest) -> func.HttpResponse: - return func.HttpResponse("pong") - - -@app.route(route="start/{name}", methods=["POST"]) -@app.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") - - -@app.route(route="status/{id}", methods=["GET"]) -@app.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") - - -@app.route(route="raise/{id}/{event}", methods=["POST"]) -@app.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) - - -@app.route(route="terminate/{id}", methods=["POST"]) -@app.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) - - -@app.route(route="purge/{id}", methods=["POST"]) -@app.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") - - -@app.route(route="entity/{name}/{key}", methods=["GET"]) -@app.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") +import activities +import client_routes +import entities +import orchestrators +app = df.DFApp(http_auth_level=func.AuthLevel.ANONYMOUS) -@app.route(route="signal/{name}/{key}/{op}", methods=["POST"]) -@app.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"]) - await client.signal_entity(entity_id, req.route_params["op"], input=body.get("input")) - return func.HttpResponse(status_code=202) +app.register_functions(activities.bp) +app.register_functions(entities.bp) +app.register_functions(orchestrators.bp) +app.register_functions(client_routes.bp) 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..65e4fdb1 --- /dev/null +++ b/tests/azure-functions-durable/e2e/apps/dtask_style/orchestrators.py @@ -0,0 +1,197 @@ +# 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 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 + + +# --------------------------------------------------------------------------- +# 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/v1_style/activities.py b/tests/azure-functions-durable/e2e/apps/v1_style/activities.py new file mode 100644 index 00000000..646d3183 --- /dev/null +++ b/tests/azure-functions-durable/e2e/apps/v1_style/activities.py @@ -0,0 +1,50 @@ +# 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} 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..90ab09ef --- /dev/null +++ b/tests/azure-functions-durable/e2e/apps/v1_style/client_routes.py @@ -0,0 +1,215 @@ +# 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: + try: + await client.rewind(req.route_params["id"], reason="e2e-rewind") + except NotImplementedError as exc: + return func.HttpResponse(str(exc), status_code=501) + 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 index 21187e9b..81f55e98 100644 --- a/tests/azure-functions-durable/e2e/apps/v1_style/function_app.py +++ b/tests/azure-functions-durable/e2e/apps/v1_style/function_app.py @@ -3,191 +3,30 @@ """V1-style Durable Functions sample app for end-to-end testing. -Every orchestrator and entity here uses the classic ``azure-functions-durable`` -v1 authoring style: single-argument generator orchestrators -(``def orch(context):``), single-argument entity functions -(``def entity(context):``), and the deprecated v1 client method names -(``start_new``, ``get_status``, ``raise_event``, ``terminate``, -``purge_instance_history``, ``read_entity_state``, ``signal_entity``). - -The app is driven by the E2E suite through its HTTP routes. It is intentionally -broad, covering activity chaining, fan-out/fan-in, sub-orchestrations, external -events + timers, entities, custom status, continue-as-new, durable HTTP, and the -client management surface. +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 json - import azure.functions as func import azure.durable_functions as df -app = df.DFApp(http_auth_level=func.AuthLevel.ANONYMOUS) - - -# --------------------------------------------------------------------------- -# Activities -# --------------------------------------------------------------------------- - -@app.activity_trigger(input_name="name") -def say_hello(name: str) -> str: - return f"Hello {name}!" - - -@app.activity_trigger(input_name="n") -def square(n: int) -> int: - return n * n - - -# --------------------------------------------------------------------------- -# Entity (v1 style: single context argument) -# --------------------------------------------------------------------------- - -@app.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) - - -# --------------------------------------------------------------------------- -# Orchestrators (v1 style: single context argument, generator) -# --------------------------------------------------------------------------- - -@app.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] - - -@app.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) - - -@app.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} - - -@app.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} - - -@app.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 - - -@app.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 - - -@app.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} - - -# --------------------------------------------------------------------------- -# HTTP routes: starter + client management surface -# --------------------------------------------------------------------------- - -@app.route(route="ping", methods=["GET"]) -def ping(req: func.HttpRequest) -> func.HttpResponse: - return func.HttpResponse("pong") - - -@app.route(route="start/{name}", methods=["POST"]) -@app.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) - - -@app.route(route="status/{id}", methods=["GET"]) -@app.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") - - -@app.route(route="raise/{id}/{event}", methods=["POST"]) -@app.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) - - -@app.route(route="terminate/{id}", methods=["POST"]) -@app.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) - - -@app.route(route="purge/{id}", methods=["POST"]) -@app.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") - - -@app.route(route="entity/{name}/{key}", methods=["GET"]) -@app.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") +import activities +import client_routes +import entities +import http_orchestrators +import orchestrators +app = df.DFApp(http_auth_level=func.AuthLevel.ANONYMOUS) -@app.route(route="signal/{name}/{key}/{op}", methods=["POST"]) -@app.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) +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/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/orchestrators.py b/tests/azure-functions-durable/e2e/apps/v1_style/orchestrators.py new file mode 100644 index 00000000..989d6238 --- /dev/null +++ b/tests/azure-functions-durable/e2e/apps/v1_style/orchestrators.py @@ -0,0 +1,214 @@ +# 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 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/conftest.py b/tests/azure-functions-durable/e2e/conftest.py index 66d9b4ba..a4b8c4eb 100644 --- a/tests/azure-functions-durable/e2e/conftest.py +++ b/tests/azure-functions-durable/e2e/conftest.py @@ -12,8 +12,9 @@ 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 module-scoped Functions host so the two apps are -fully isolated and their hosts start/stop once per test module. +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 @@ -33,14 +34,14 @@ def _require_prerequisites(app_name: str) -> None: "inside each sample app.") -@pytest.fixture(scope="module") +@pytest.fixture(scope="session") def v1_app(): _require_prerequisites("v1_style") with FunctionApp("v1_style") as app: yield app -@pytest.fixture(scope="module") +@pytest.fixture(scope="session") def dtask_app(): _require_prerequisites("dtask_style") with FunctionApp("dtask_style") as 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_e2e.py b/tests/azure-functions-durable/e2e/test_dtask_entities_e2e.py new file mode 100644 index 00000000..607c93a3 --- /dev/null +++ b/tests/azure-functions-durable/e2e/test_dtask_entities_e2e.py @@ -0,0 +1,59 @@ +# 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 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_v1_client_e2e.py b/tests/azure-functions-durable/e2e/test_v1_client_e2e.py new file mode 100644 index 00000000..0378aa8d --- /dev/null +++ b/tests/azure-functions-durable/e2e/test_v1_client_e2e.py @@ -0,0 +1,113 @@ +# 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_not_implemented(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/rewind/{instance_id}") + # rewind is a deprecated stub that raises NotImplementedError. + assert result.status == 501 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/test_decorator_compat.py b/tests/azure-functions-durable/test_decorator_compat.py index fd2adb30..06b41eac 100644 --- a/tests/azure-functions-durable/test_decorator_compat.py +++ b/tests/azure-functions-durable/test_decorator_compat.py @@ -147,3 +147,99 @@ def orch(context): 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 From ff203b782639dbfcae7dd68b52a99df40f04b1dc Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Fri, 10 Jul 2026 19:57:57 -0600 Subject: [PATCH 42/45] More E2E test edge cases, fixes, add scheduled tasks --- CHANGELOG.md | 5 + .../decorators/durable_app.py | 23 +++ durabletask/scheduled/models.py | 47 ++++-- durabletask/scheduled/orchestrator.py | 30 +++- durabletask/worker.py | 5 +- tests/azure-functions-durable/e2e/_harness.py | 54 ++++++- .../e2e/apps/dtask_style/client_routes.py | 143 +++++++++++++++++- .../e2e/apps/dtask_style/entities.py | 60 ++++++-- .../e2e/apps/dtask_style/function_app.py | 4 + .../e2e/apps/dtask_style/orchestrators.py | 93 ++++++++++++ .../e2e/test_dtask_entities_advanced_e2e.py | 113 ++++++++++++++ .../e2e/test_dtask_entities_e2e.py | 13 ++ .../e2e/test_dtask_entity_admin_e2e.py | 54 +++++++ .../e2e/test_dtask_scheduled_e2e.py | 54 +++++++ 14 files changed, 668 insertions(+), 30 deletions(-) create mode 100644 tests/azure-functions-durable/e2e/test_dtask_entities_advanced_e2e.py create mode 100644 tests/azure-functions-durable/e2e/test_dtask_entity_admin_e2e.py create mode 100644 tests/azure-functions-durable/e2e/test_dtask_scheduled_e2e.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 537c756e..65f7f2e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,11 @@ CHANGED FIXED +- 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 diff --git a/azure-functions-durable/azure/durable_functions/decorators/durable_app.py b/azure-functions-durable/azure/durable_functions/decorators/durable_app.py index 8a6aee58..2d8f3b21 100644 --- a/azure-functions-durable/azure/durable_functions/decorators/durable_app.py +++ b/azure-functions-durable/azure/durable_functions/decorators/durable_app.py @@ -67,6 +67,29 @@ def _register_builtin_http_functions(self) -> None: 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_orchestrator_callable( self, wrap: Callable[[Callable[..., Any]], FunctionBuilder], 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/worker.py b/durabletask/worker.py index 1c29970a..509d2e31 100644 --- a/durabletask/worker.py +++ b/durabletask/worker.py @@ -2857,12 +2857,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/tests/azure-functions-durable/e2e/_harness.py b/tests/azure-functions-durable/e2e/_harness.py index 8e1cbe33..0fb2bbf9 100644 --- a/tests/azure-functions-durable/e2e/_harness.py +++ b/tests/azure-functions-durable/e2e/_harness.py @@ -379,10 +379,58 @@ def read_entity(self, name: str, key: str) -> dict[str, Any]: assert result.status == 200, f"entity read failed: {result.status} {result.body}" return result.json() - def signal_entity(self, name: str, key: str, op: str, input: Any = None) -> None: - """Signal an entity via the app's ``/api/signal/{name}/{key}/{op}`` route.""" + 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 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={"input": input}) + "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( 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 index 83cbdce0..40a67d96 100644 --- a/tests/azure-functions-durable/e2e/apps/dtask_style/client_routes.py +++ b/tests/azure-functions-durable/e2e/apps/dtask_style/client_routes.py @@ -13,17 +13,44 @@ ``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} @@ -179,5 +206,119 @@ 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"]) - await client.signal_entity(entity_id, req.route_params["op"], input=body.get("input")) + # 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 index fb60c514..1c3d7b11 100644 --- a/tests/azure-functions-durable/e2e/apps/dtask_style/entities.py +++ b/tests/azure-functions-durable/e2e/apps/dtask_style/entities.py @@ -3,33 +3,49 @@ """Entity functions for the durabletask-native-style sample app (blueprint). -Uses the modern durabletask two-argument entity style -(``def entity(ctx, input):``) with the durabletask ``EntityContext`` API. +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") -def counter(ctx: entities.EntityContext, input: Any = None) -> Any: - if ctx.operation == "add": - new_state = ctx.get_state(int, 0) + (input or 0) - ctx.set_state(new_state) +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 - if ctx.operation == "reset": - ctx.set_state(0) - return 0 - return ctx.get_state(int, 0) + + 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.""" + """Entity exposing the durabletask ``EntityContext`` surface (function style).""" operation = ctx.operation if operation == "set": ctx.set_state(input) @@ -47,3 +63,25 @@ def probe(ctx: entities.EntityContext, input: Any = None) -> Any: 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 index 1f517c09..f033f455 100644 --- a/tests/azure-functions-durable/e2e/apps/dtask_style/function_app.py +++ b/tests/azure-functions-durable/e2e/apps/dtask_style/function_app.py @@ -29,3 +29,7 @@ app.register_functions(entities.bp) app.register_functions(orchestrators.bp) app.register_functions(client_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() diff --git a/tests/azure-functions-durable/e2e/apps/dtask_style/orchestrators.py b/tests/azure-functions-durable/e2e/apps/dtask_style/orchestrators.py index 65e4fdb1..61a86386 100644 --- a/tests/azure-functions-durable/e2e/apps/dtask_style/orchestrators.py +++ b/tests/azure-functions-durable/e2e/apps/dtask_style/orchestrators.py @@ -64,6 +64,17 @@ def counter_orchestration(ctx: task.OrchestrationContext, _: Any): 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 @@ -143,6 +154,88 @@ def describe_entity(ctx: task.OrchestrationContext, key: Any): 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 # --------------------------------------------------------------------------- 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 index 607c93a3..ac6873fb 100644 --- a/tests/azure-functions-durable/e2e/test_dtask_entities_e2e.py +++ b/tests/azure-functions-durable/e2e/test_dtask_entities_e2e.py @@ -57,3 +57,16 @@ def test_signal_entity_from_orchestrator(dtask_app): 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_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 From 25f21df30ac0ddb9bb4d70e0f4d4d9d2741425f1 Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Fri, 10 Jul 2026 20:22:22 -0600 Subject: [PATCH 43/45] Add history export to afd --- .gitignore | 3 + CHANGELOG.md | 12 ++ azure-functions-durable/CHANGELOG.md | 13 ++ .../decorators/durable_app.py | 63 ++++++++ .../internal/history_export_compat.py | 83 ++++++++++ .../extensions/history_export/__init__.py | 4 + tests/azure-functions-durable/e2e/_harness.py | 30 ++++ .../e2e/apps/dtask_style/function_app.py | 6 + .../apps/dtask_style/history_export_routes.py | 149 ++++++++++++++++++ .../e2e/test_dtask_entities_e2e.py | 1 - .../e2e/test_dtask_history_export_e2e.py | 53 +++++++ 11 files changed, 416 insertions(+), 1 deletion(-) create mode 100644 azure-functions-durable/azure/durable_functions/internal/history_export_compat.py create mode 100644 tests/azure-functions-durable/e2e/apps/dtask_style/history_export_routes.py create mode 100644 tests/azure-functions-durable/e2e/test_dtask_history_export_e2e.py diff --git a/.gitignore b/.gitignore index bf61e134..85bc6f52 100644 --- a/.gitignore +++ b/.gitignore @@ -114,6 +114,9 @@ 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/CHANGELOG.md b/CHANGELOG.md index 65f7f2e3..b5a8a15c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,10 @@ ADDED - Added `OrchestrationContext.parent_instance_id`, which returns the instance ID of the parent orchestration for a sub-orchestration, or `None` for a top-level orchestration. +- 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. CHANGED @@ -20,6 +24,14 @@ CHANGED FIXED +- 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 diff --git a/azure-functions-durable/CHANGELOG.md b/azure-functions-durable/CHANGELOG.md index fa317058..0f61da49 100644 --- a/azure-functions-durable/CHANGELOG.md +++ b/azure-functions-durable/CHANGELOG.md @@ -9,6 +9,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- `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 diff --git a/azure-functions-durable/azure/durable_functions/decorators/durable_app.py b/azure-functions-durable/azure/durable_functions/decorators/durable_app.py index 2d8f3b21..8af852ab 100644 --- a/azure-functions-durable/azure/durable_functions/decorators/durable_app.py +++ b/azure-functions-durable/azure/durable_functions/decorators/durable_app.py @@ -90,6 +90,69 @@ def configure_scheduled_tasks(self) -> None: 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], 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/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/tests/azure-functions-durable/e2e/_harness.py b/tests/azure-functions-durable/e2e/_harness.py index 0fb2bbf9..6f48ba45 100644 --- a/tests/azure-functions-durable/e2e/_harness.py +++ b/tests/azure-functions-durable/e2e/_harness.py @@ -419,6 +419,36 @@ def delete_schedule(self, schedule_id: str) -> None: "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. 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 index f033f455..0c4a179e 100644 --- a/tests/azure-functions-durable/e2e/apps/dtask_style/function_app.py +++ b/tests/azure-functions-durable/e2e/apps/dtask_style/function_app.py @@ -21,6 +21,7 @@ import activities import client_routes import entities +import history_export_routes import orchestrators app = df.DFApp(http_auth_level=func.AuthLevel.ANONYMOUS) @@ -29,7 +30,12 @@ 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/test_dtask_entities_e2e.py b/tests/azure-functions-durable/e2e/test_dtask_entities_e2e.py index ac6873fb..565178c0 100644 --- a/tests/azure-functions-durable/e2e/test_dtask_entities_e2e.py +++ b/tests/azure-functions-durable/e2e/test_dtask_entities_e2e.py @@ -69,4 +69,3 @@ def test_entity_operation_returning_none(dtask_app): 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_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" From f0fdf0541e76d9b4cee78346ef09ea577dc39b6e Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Fri, 10 Jul 2026 20:39:36 -0600 Subject: [PATCH 44/45] Add afd rewind --- CHANGELOG.md | 6 ++++++ azure-functions-durable/CHANGELOG.md | 5 +++++ .../azure/durable_functions/client.py | 17 +++++++++++------ durabletask/worker.py | 6 ++++++ .../e2e/apps/v1_style/activities.py | 15 +++++++++++++++ .../e2e/apps/v1_style/client_routes.py | 5 +---- .../e2e/apps/v1_style/orchestrators.py | 8 ++++++++ .../e2e/test_v1_client_e2e.py | 16 +++++++++++----- .../test_client_compat.py | 10 ++++++---- 9 files changed, 69 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f2ad856e..03de8b31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,12 @@ ADDED 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` diff --git a/azure-functions-durable/CHANGELOG.md b/azure-functions-durable/CHANGELOG.md index 0f61da49..b77fac2d 100644 --- a/azure-functions-durable/CHANGELOG.md +++ b/azure-functions-durable/CHANGELOG.md @@ -9,6 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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 diff --git a/azure-functions-durable/azure/durable_functions/client.py b/azure-functions-durable/azure/durable_functions/client.py index d4de1ab6..05ff37b2 100644 --- a/azure-functions-durable/azure/durable_functions/client.py +++ b/azure-functions-durable/azure/durable_functions/client.py @@ -393,18 +393,23 @@ async def wait_for_completion_or_create_check_status_response( return self._create_http_response(500, state.serialized_output) return self.create_check_status_response(request, instance_id) - @deprecated( - "rewind is not yet supported in durabletask; this shim raises " - "NotImplementedError.") + @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: - """Not implemented: durabletask has no rewind equivalent yet.""" - raise NotImplementedError( - "rewind is not yet supported by durabletask.") + """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: diff --git a/durabletask/worker.py b/durabletask/worker.py index 34d6f64f..89fcd15c 100644 --- a/durabletask/worker.py +++ b/durabletask/worker.py @@ -2917,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 diff --git a/tests/azure-functions-durable/e2e/apps/v1_style/activities.py b/tests/azure-functions-durable/e2e/apps/v1_style/activities.py index 646d3183..1350a837 100644 --- a/tests/azure-functions-durable/e2e/apps/v1_style/activities.py +++ b/tests/azure-functions-durable/e2e/apps/v1_style/activities.py @@ -48,3 +48,18 @@ def flaky(payload: dict) -> dict: 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 index 90ab09ef..b6418044 100644 --- a/tests/azure-functions-durable/e2e/apps/v1_style/client_routes.py +++ b/tests/azure-functions-durable/e2e/apps/v1_style/client_routes.py @@ -183,10 +183,7 @@ async def wait_or_check( @bp.durable_client_input(client_name="client") async def rewind_orchestration( req: func.HttpRequest, client: df.DurableFunctionsClient) -> func.HttpResponse: - try: - await client.rewind(req.route_params["id"], reason="e2e-rewind") - except NotImplementedError as exc: - return func.HttpResponse(str(exc), status_code=501) + await client.rewind(req.route_params["id"], reason="e2e-rewind") return func.HttpResponse(status_code=202) diff --git a/tests/azure-functions-durable/e2e/apps/v1_style/orchestrators.py b/tests/azure-functions-durable/e2e/apps/v1_style/orchestrators.py index 989d6238..a96467c7 100644 --- a/tests/azure-functions-durable/e2e/apps/v1_style/orchestrators.py +++ b/tests/azure-functions-durable/e2e/apps/v1_style/orchestrators.py @@ -206,6 +206,14 @@ def sub_orch_fails(context: df.DurableOrchestrationContext): 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, diff --git a/tests/azure-functions-durable/e2e/test_v1_client_e2e.py b/tests/azure-functions-durable/e2e/test_v1_client_e2e.py index 0378aa8d..3ba37aff 100644 --- a/tests/azure-functions-durable/e2e/test_v1_client_e2e.py +++ b/tests/azure-functions-durable/e2e/test_v1_client_e2e.py @@ -104,10 +104,16 @@ def test_wait_for_completion_or_check_status(v1_app): assert result.json() == ["Hello Tokyo!", "Hello Seattle!", "Hello London!"] -def test_rewind_not_implemented(v1_app): - instance_id = v1_app.start_orchestration("activity_chain") - v1_app.wait_for_completion(instance_id) +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}") - # rewind is a deprecated stub that raises NotImplementedError. - assert result.status == 501 + 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/test_client_compat.py b/tests/azure-functions-durable/test_client_compat.py index cc3a0fab..31793a79 100644 --- a/tests/azure-functions-durable/test_client_compat.py +++ b/tests/azure-functions-durable/test_client_compat.py @@ -332,15 +332,17 @@ async def test_wait_for_completion_returns_check_status_on_timeout(): # --------------------------------------------------------------------------- -# rewind (not implemented) +# rewind # --------------------------------------------------------------------------- -async def test_rewind_raises_not_implemented(): +async def test_rewind_delegates_to_rewind_orchestration(): client = _make_client() try: - with pytest.warns(DeprecationWarning): - with pytest.raises(NotImplementedError): + 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() From 483c4aa760b2e49b78dd27ecd690f631e08d6d58 Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Fri, 10 Jul 2026 20:41:15 -0600 Subject: [PATCH 45/45] lint fix --- .../azure/durable_functions/decorators/durable_app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/azure-functions-durable/azure/durable_functions/decorators/durable_app.py b/azure-functions-durable/azure/durable_functions/decorators/durable_app.py index 8af852ab..54caa91f 100644 --- a/azure-functions-durable/azure/durable_functions/decorators/durable_app.py +++ b/azure-functions-durable/azure/durable_functions/decorators/durable_app.py @@ -115,7 +115,7 @@ def configure_history_export(self) -> None: ``ListInstanceIds`` call, which the Durable Functions host extension does not implement. - NOTE: We need to consider whether this will perform well on Azure + NOTE: We need to consider whether this will perform well on Azure Functions' distributed architecture - later. """ from durabletask.extensions.history_export._constants import (