Skip to content

Commit 82fe0cc

Browse files
authored
Merge branch 'main' into andystaples-improve-ci-validation
2 parents c284126 + 8356fe2 commit 82fe0cc

17 files changed

Lines changed: 806 additions & 186 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
99

1010
ADDED
1111

12+
- Added asynchronous `AsyncScheduledTaskClient` / `AsyncScheduleClient` and
13+
`AsyncExportHistoryClient` / `AsyncExportHistoryJobClient` APIs. Applications
14+
using `AsyncTaskHubGrpcClient` can now manage scheduled tasks and history
15+
export jobs without a synchronous client bridge.
1216
- Added `FailureDetails.is_caused_by()` to check whether a task failure was caused by a given exception type, mirroring .NET's `TaskFailureDetails.IsCausedBy<T>()` and Java's `FailureDetails.isCausedBy()`. Passing an exception type performs a base-type-aware match (a failure caused by a subclass matches its base type) against exception classes already imported in the process; passing a string matches by qualified or unqualified name.
1317
- Added a per-invocation dependency-resolution API to `durabletask.extensions.history_export` so any hosting model can supply the export client and writer without a process-global. The export activities now resolve their `HistoryExportContext` from a resolver invoked once per activity execution; new public building blocks are the `HistoryExportContextResolver` type, the pure activity bodies `run_list_terminal_instances(context, input)` and `run_export_instance_history(context, input)`, and the `build_activities(resolver)` factory. This lets host-driven, multi-process models (such as Azure Functions, where the process that starts an export job is not the worker that runs an export activity) inject dependencies lazily per invocation.
1418

azure-functions-durable/CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,15 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## Unreleased
9+
10+
ADDED
11+
12+
- Added `SyncDurableFunctionsClient`. `DFApp.durable_client_input()` now injects
13+
the synchronous client into synchronous functions and the asynchronous client
14+
into coroutine functions. Both clients support scheduled-task and history-export
15+
APIs without an async-to-sync bridge.
16+
817
## 2.0.0b1
918

1019
First preview (beta) release of `azure-functions-durable` 2.x — a ground-up

azure-functions-durable/azure/durable_functions/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from durabletask.task import RetryPolicy
88

99
from .decorators.durable_app import Blueprint, DFApp
10-
from .client import DurableFunctionsClient
10+
from .client import DurableFunctionsClient, SyncDurableFunctionsClient
1111
from .http.models import DurableHttpRequest, DurableHttpResponse
1212
from .orchestrator import Orchestrator
1313
from .internal.compat.retry_options import RetryOptions
@@ -42,6 +42,7 @@
4242
"DFApp",
4343
"DurableEntityContext",
4444
"DurableFunctionsClient",
45+
"SyncDurableFunctionsClient",
4546
"DurableHttpRequest",
4647
"DurableHttpResponse",
4748
"DurableOrchestrationClient",

azure-functions-durable/azure/durable_functions/client.py

Lines changed: 129 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22
# Licensed under the MIT License.
33

44
import asyncio
5+
import atexit
56
import json
7+
import threading
68

79
from datetime import datetime, timedelta
810
from typing import Any, Optional, Union
@@ -14,10 +16,14 @@
1416
AsyncTaskHubGrpcClient,
1517
OrchestrationQuery,
1618
OrchestrationStatus,
19+
TaskHubGrpcClient,
1720
)
1821
from durabletask.entities import EntityInstanceId
1922
from durabletask.grpc_options import GrpcChannelOptions
20-
from .internal.azurefunctions_grpc_interceptor import AzureFunctionsAsyncDefaultClientInterceptorImpl
23+
from .internal.azurefunctions_grpc_interceptor import (
24+
AzureFunctionsAsyncDefaultClientInterceptorImpl,
25+
AzureFunctionsDefaultClientInterceptorImpl,
26+
)
2127
from .internal.serialization import DEFAULT_FUNCTIONS_DATA_CONVERTER
2228
from .http import HttpManagementPayload
2329
from .internal.compat.durable_orchestration_status import DurableOrchestrationStatus
@@ -26,6 +32,10 @@
2632
from .internal.compat.purge_history_result import PurgeHistoryResult
2733

2834

35+
_sync_client_cache: dict[str, "SyncDurableFunctionsClient"] = {}
36+
_sync_client_cache_lock = threading.Lock()
37+
38+
2939
# Client class used for Durable Functions
3040
class DurableFunctionsClient(AsyncTaskHubGrpcClient):
3141
"""A gRPC client passed to Durable Functions durable client bindings.
@@ -490,3 +500,121 @@ def _create_http_response(status_code: int, body: Union[str, Any]) -> func.HttpR
490500
body=body_as_json,
491501
mimetype="application/json",
492502
headers={"Content-Type": "application/json"})
503+
504+
505+
class SyncDurableFunctionsClient(TaskHubGrpcClient):
506+
"""Synchronous durable client supplied by a Functions durable-client binding."""
507+
508+
taskHubName: str
509+
connectionName: str
510+
creationUrls: dict[str, str]
511+
managementUrls: dict[str, str]
512+
baseUrl: str
513+
requiredQueryStringParameters: str
514+
rpcBaseUrl: str
515+
httpBaseUrl: str
516+
maxGrpcMessageSizeInBytes: int
517+
grpcHttpClientTimeout: timedelta | str
518+
519+
def __init__(self, client_as_string: str):
520+
self._parse_client_configuration(client_as_string)
521+
interceptors = [AzureFunctionsDefaultClientInterceptorImpl(
522+
self.taskHubName, self.requiredQueryStringParameters)]
523+
channel_options: GrpcChannelOptions | None = None
524+
if self.maxGrpcMessageSizeInBytes > 0:
525+
channel_options = GrpcChannelOptions(
526+
max_receive_message_length=self.maxGrpcMessageSizeInBytes,
527+
max_send_message_length=self.maxGrpcMessageSizeInBytes)
528+
super().__init__(
529+
host_address=self.rpcBaseUrl,
530+
secure_channel=False,
531+
metadata=None,
532+
interceptors=interceptors,
533+
channel_options=channel_options,
534+
data_converter=DEFAULT_FUNCTIONS_DATA_CONVERTER)
535+
536+
@classmethod
537+
def get_cached(cls, client_as_string: str) -> "SyncDurableFunctionsClient":
538+
"""Get the process-wide client for a durable-client binding configuration.
539+
540+
Synchronous Functions bindings can be invoked frequently by history
541+
export fan-out activities. Reusing the gRPC channel avoids creating and
542+
tearing down a channel for every activity invocation.
543+
"""
544+
with _sync_client_cache_lock:
545+
cached = _sync_client_cache.get(client_as_string)
546+
if cached is None:
547+
cached = cls(client_as_string)
548+
_sync_client_cache[client_as_string] = cached
549+
return cached
550+
551+
def _parse_client_configuration(self, client_as_string: str) -> None:
552+
client = json.loads(client_as_string)
553+
self.taskHubName = client.get("taskHubName") or ""
554+
self.connectionName = client.get("connectionName") or ""
555+
self.creationUrls = client.get("creationUrls") or {}
556+
self.managementUrls = client.get("managementUrls") or {}
557+
self.baseUrl = client.get("baseUrl") or ""
558+
self.requiredQueryStringParameters = client.get(
559+
"requiredQueryStringParameters") or ""
560+
self.rpcBaseUrl = client.get("rpcBaseUrl") or ""
561+
self.httpBaseUrl = client.get("httpBaseUrl") or ""
562+
self.maxGrpcMessageSizeInBytes = client.get(
563+
"maxGrpcMessageSizeInBytes") or 0
564+
self.grpcHttpClientTimeout = client.get(
565+
"grpcHttpClientTimeout") or timedelta(seconds=30)
566+
567+
def create_check_status_response(
568+
self, request: func.HttpRequest, instance_id: str) -> func.HttpResponse:
569+
payload = self._get_client_response_links(request, instance_id)
570+
return func.HttpResponse(
571+
body=str(payload),
572+
status_code=202,
573+
headers={
574+
"content-type": "application/json",
575+
"Location": payload["statusQueryGetUri"],
576+
},
577+
)
578+
579+
def create_http_management_payload(
580+
self,
581+
request: func.HttpRequest | str | None = None,
582+
instance_id: str | None = None) -> HttpManagementPayload:
583+
if instance_id is None and isinstance(request, str):
584+
instance_id = request
585+
request = None
586+
if instance_id is None:
587+
raise TypeError("instance_id is required")
588+
resolved_request = request if isinstance(request, func.HttpRequest) else None
589+
return self._get_client_response_links(resolved_request, instance_id)
590+
591+
def _get_client_response_links(
592+
self, request: func.HttpRequest | None,
593+
instance_id: str) -> HttpManagementPayload:
594+
return HttpManagementPayload(
595+
instance_id,
596+
self._get_instance_status_url(request, instance_id),
597+
self.requiredQueryStringParameters)
598+
599+
def _get_instance_status_url(
600+
self, request: func.HttpRequest | None, instance_id: str) -> str:
601+
encoded_instance_id = quote(instance_id)
602+
if request is not None:
603+
request_url = urlparse(request.url)
604+
return (
605+
f"{request_url.scheme}://{request_url.netloc}"
606+
f"/runtime/webhooks/durabletask/instances/{encoded_instance_id}")
607+
base_url = self.baseUrl.rstrip("/") if self.baseUrl else ""
608+
return f"{base_url}/instances/{encoded_instance_id}"
609+
610+
611+
def _close_cached_sync_clients() -> None:
612+
"""Release process-wide synchronous durable-client channels on shutdown."""
613+
with _sync_client_cache_lock:
614+
clients = list(_sync_client_cache.values())
615+
_sync_client_cache.clear()
616+
for client in clients:
617+
client.close()
618+
619+
620+
atexit.register(_close_cached_sync_clients)

azure-functions-durable/azure/durable_functions/decorators/durable_app.py

Lines changed: 79 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
# Copyright (c) Microsoft Corporation.
22
# Licensed under the MIT License.
33

4+
import inspect
5+
from functools import wraps
46
from typing import Any, Callable, Optional, Union
57

68
import azure.functions as func
@@ -356,10 +358,14 @@ def decorator() -> FunctionBuilder:
356358
def durable_client_input(self,
357359
client_name: str,
358360
task_hub: Optional[str] = None,
359-
connection_name: Optional[str] = None
361+
connection_name: Optional[str] = None,
360362
) -> Callable[[Callable[..., Any]], FunctionBuilder]:
361363
"""Register a Durable-client Function.
362364
365+
Coroutine functions receive an asynchronous
366+
:class:`DurableFunctionsClient`; synchronous functions receive a cached
367+
:class:`SyncDurableFunctionsClient`.
368+
363369
Parameters
364370
----------
365371
client_name: str
@@ -379,11 +385,9 @@ def durable_client_input(self,
379385
@self._build_function
380386
def wrap(fb: FunctionBuilder) -> FunctionBuilder:
381387
def decorator() -> FunctionBuilder:
382-
# The durableClient binding converter
383-
# (``DurableClientConverter``) constructs the rich
384-
# ``DurableFunctionsClient`` from the host-supplied configuration
385-
# string during decode, so no per-function client middleware is
386-
# needed here.
388+
# The converter returns the host configuration string. The
389+
# function wrapper below constructs the rich client matching
390+
# the decorated function's synchronous or asynchronous type.
387391
fb.add_binding(
388392
binding=DurableClient(name=client_name,
389393
task_hub=task_hub,
@@ -393,16 +397,75 @@ def decorator() -> FunctionBuilder:
393397
return decorator()
394398

395399
def attach_client_function(user_fn: Callable[..., Any]) -> FunctionBuilder:
396-
# Expose the raw client function for unit testing, mirroring the
397-
# ``.orchestrator_function`` and ``.entity_function`` attributes.
398-
# Unlike v1 there is no client middleware wrapper (the durableClient
399-
# binding converter builds the rich client during decode), so the
400-
# user function is registered directly and ``.client_function``
401-
# points back at it. Internal callers pass an already-built
402-
# ``FunctionBuilder`` (e.g. history export), which is left untouched.
403-
if not isinstance(user_fn, FunctionBuilder):
404-
user_fn.client_function = user_fn # pyright: ignore[reportFunctionMemberAccess]
405-
return wrap(user_fn)
400+
# Expose the original client function for unit testing, mirroring
401+
# ``.orchestrator_function`` and ``.entity_function``. The registered
402+
# wrapper resolves the client from the binding configuration and
403+
# closes it after the invocation.
404+
from ..client import DurableFunctionsClient, SyncDurableFunctionsClient
405+
406+
function = (user_fn._function._func # pyright: ignore[reportPrivateUsage]
407+
if isinstance(user_fn, FunctionBuilder) else user_fn)
408+
signature = inspect.signature(function)
409+
is_async_function = inspect.iscoroutinefunction(function)
410+
411+
def bind_client(
412+
args: tuple[Any, ...],
413+
kwargs: dict[str, Any],
414+
) -> tuple[inspect.BoundArguments, DurableFunctionsClient | SyncDurableFunctionsClient]:
415+
bound = signature.bind(*args, **kwargs)
416+
if client_name not in bound.arguments:
417+
raise TypeError(
418+
f"durable client binding parameter '{client_name}' is not "
419+
f"declared by function '{function.__name__}'")
420+
raw_client = bound.arguments[client_name]
421+
if not isinstance(raw_client, str):
422+
raise TypeError(
423+
f"durable client binding '{client_name}' did not provide its configuration")
424+
client = (DurableFunctionsClient(raw_client) if is_async_function
425+
else SyncDurableFunctionsClient.get_cached(raw_client))
426+
bound.arguments[client_name] = client
427+
return bound, client
428+
429+
def set_client_metadata(client_bound: Callable[..., Any]) -> None:
430+
annotations = dict(function.__annotations__)
431+
supported_client_types = (
432+
str,
433+
bytes,
434+
DurableFunctionsClient,
435+
SyncDurableFunctionsClient,
436+
)
437+
if annotations.get(client_name) not in supported_client_types:
438+
annotations[client_name] = str
439+
client_bound.__annotations__ = annotations
440+
setattr(client_bound, "client_function", function)
441+
442+
if is_async_function:
443+
@wraps(function)
444+
async def async_client_bound(*args: Any, **kwargs: Any) -> Any:
445+
bound, client = bind_client(args, kwargs)
446+
try:
447+
result = function(*bound.args, **bound.kwargs)
448+
return await result
449+
finally:
450+
if isinstance(client, DurableFunctionsClient):
451+
client.schedule_close()
452+
client_bound = async_client_bound
453+
else:
454+
@wraps(function)
455+
def sync_client_bound(*args: Any, **kwargs: Any) -> Any:
456+
bound, client = bind_client(args, kwargs)
457+
try:
458+
return function(*bound.args, **bound.kwargs)
459+
finally:
460+
if isinstance(client, DurableFunctionsClient):
461+
client.schedule_close()
462+
client_bound = sync_client_bound
463+
464+
set_client_metadata(client_bound)
465+
if isinstance(user_fn, FunctionBuilder):
466+
user_fn._function._func = client_bound # pyright: ignore[reportPrivateUsage]
467+
return wrap(user_fn)
468+
return wrap(client_bound)
406469

407470
return attach_client_function
408471

azure-functions-durable/azure/durable_functions/internal/converters.py

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -180,8 +180,9 @@ def has_trigger_support(cls) -> bool:
180180

181181
@classmethod
182182
def check_input_type_annotation(cls, pytype: type) -> bool:
183-
from ..client import DurableFunctionsClient
184-
return issubclass(pytype, (str, bytes, DurableFunctionsClient))
183+
from ..client import DurableFunctionsClient, SyncDurableFunctionsClient
184+
return issubclass(
185+
pytype, (str, bytes, DurableFunctionsClient, SyncDurableFunctionsClient))
185186

186187
@classmethod
187188
def check_output_type_annotation(cls, pytype: type) -> bool:
@@ -213,16 +214,19 @@ def encode(cls, obj: Any, *,
213214
@classmethod
214215
def decode(cls, data: meta.Datum, *,
215216
trigger_metadata: _TriggerMetadata) -> Any:
216-
from ..client import DurableFunctionsClient
217-
return DurableFunctionsClient(data.value)
217+
# The decorator turns this host configuration into the requested sync or
218+
# async rich client. Keeping conversion here as a string lets one binding
219+
# type support both client shapes without a hidden sync bridge.
220+
return data.value
218221

219222

220223
def register_durable_converters() -> None:
221224
"""Register this package's durable binding converters with azure-functions.
222225
223226
Overrides the SDK's built-in converters for the four durable binding types
224-
so the host uses the durabletask-based encodings and the durable-client
225-
binding is decoded to a :class:`DurableFunctionsClient`.
227+
so the host uses the durabletask-based encodings. The durable-client binding
228+
is decoded to its host configuration string, which the decorator converts to
229+
the requested synchronous or asynchronous rich client per invocation.
226230
"""
227231
func.register_converter(
228232
ORCHESTRATION_TRIGGER, OrchestrationTriggerConverter, overwrite=True)

0 commit comments

Comments
 (0)