Skip to content

Commit 231eff2

Browse files
committed
Add sync and async client parity
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 76f2163a-0773-4889-b627-1bd4e5c7bea0
1 parent 2269c44 commit 231eff2

14 files changed

Lines changed: 465 additions & 94 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` and
13+
`DFApp.durable_client_input_sync()` for synchronous durable-client functions.
14+
Both synchronous and asynchronous Functions durable clients can now use the
15+
scheduled-tasks and history-export client 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: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,14 @@
1414
AsyncTaskHubGrpcClient,
1515
OrchestrationQuery,
1616
OrchestrationStatus,
17+
TaskHubGrpcClient,
1718
)
1819
from durabletask.entities import EntityInstanceId
1920
from durabletask.grpc_options import GrpcChannelOptions
20-
from .internal.azurefunctions_grpc_interceptor import AzureFunctionsAsyncDefaultClientInterceptorImpl
21+
from .internal.azurefunctions_grpc_interceptor import (
22+
AzureFunctionsAsyncDefaultClientInterceptorImpl,
23+
AzureFunctionsDefaultClientInterceptorImpl,
24+
)
2125
from .internal.serialization import DEFAULT_FUNCTIONS_DATA_CONVERTER
2226
from .http import HttpManagementPayload
2327
from .internal.compat.durable_orchestration_status import DurableOrchestrationStatus
@@ -490,3 +494,30 @@ def _create_http_response(status_code: int, body: Union[str, Any]) -> func.HttpR
490494
body=body_as_json,
491495
mimetype="application/json",
492496
headers={"Content-Type": "application/json"})
497+
498+
499+
class SyncDurableFunctionsClient(TaskHubGrpcClient):
500+
"""Synchronous durable client supplied by a Functions durable-client binding."""
501+
502+
def __init__(self, client_as_string: str):
503+
self._parse_client_configuration(client_as_string)
504+
interceptors = [AzureFunctionsDefaultClientInterceptorImpl(
505+
self.taskHubName, self.requiredQueryStringParameters)]
506+
channel_options: GrpcChannelOptions | None = None
507+
if self.maxGrpcMessageSizeInBytes > 0:
508+
channel_options = GrpcChannelOptions(
509+
max_receive_message_length=self.maxGrpcMessageSizeInBytes,
510+
max_send_message_length=self.maxGrpcMessageSizeInBytes)
511+
super().__init__(
512+
host_address=self.rpcBaseUrl,
513+
secure_channel=False,
514+
metadata=None,
515+
interceptors=interceptors,
516+
channel_options=channel_options,
517+
data_converter=DEFAULT_FUNCTIONS_DATA_CONVERTER)
518+
519+
_parse_client_configuration = DurableFunctionsClient._parse_client_configuration
520+
create_check_status_response = DurableFunctionsClient.create_check_status_response
521+
create_http_management_payload = DurableFunctionsClient.create_http_management_payload
522+
_get_client_response_links = DurableFunctionsClient._get_client_response_links
523+
_get_instance_status_url = DurableFunctionsClient._get_instance_status_url

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

Lines changed: 51 additions & 6 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
@@ -150,12 +152,12 @@ def configure_history_export(self, writer: Any) -> None:
150152
# whatever worker runs them). ``durable_client_input`` is applied as the
151153
# outer decorator over ``activity_trigger`` so the built function carries
152154
# both bindings.
153-
self.durable_client_input(client_name="client")(
155+
self.durable_client_input(client_name="client", sync=True)(
154156
self.activity_trigger(
155157
input_name="input",
156158
activity=LIST_TERMINAL_INSTANCES_ACTIVITY)(
157159
list_terminal_instances_client_bound))
158-
self.durable_client_input(client_name="client")(
160+
self.durable_client_input(client_name="client", sync=True)(
159161
self.activity_trigger(
160162
input_name="input",
161163
activity=EXPORT_INSTANCE_HISTORY_ACTIVITY)(
@@ -356,7 +358,9 @@ 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,
362+
*,
363+
sync: bool = False,
360364
) -> Callable[[Callable[..., Any]], FunctionBuilder]:
361365
"""Register a Durable-client Function.
362366
@@ -374,6 +378,9 @@ def durable_client_input(self,
374378
The storage account represented by this connection string must be the same one
375379
used by the target orchestrator functions. If not specified, the default storage
376380
account connection string for the function app is used.
381+
sync: bool
382+
When ``True``, inject a :class:`SyncDurableFunctionsClient`. The
383+
default injects the asynchronous :class:`DurableFunctionsClient`.
377384
"""
378385

379386
@self._build_function
@@ -400,12 +407,50 @@ def attach_client_function(user_fn: Callable[..., Any]) -> FunctionBuilder:
400407
# user function is registered directly and ``.client_function``
401408
# points back at it. Internal callers pass an already-built
402409
# ``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)
410+
from ..client import DurableFunctionsClient, SyncDurableFunctionsClient
411+
412+
function = (user_fn._function._func if isinstance(user_fn, FunctionBuilder)
413+
else user_fn)
414+
signature = inspect.signature(function)
415+
416+
@wraps(function)
417+
async def client_bound(*args: Any, **kwargs: Any) -> Any:
418+
bound = signature.bind(*args, **kwargs)
419+
raw_client = bound.arguments[client_name]
420+
if not isinstance(raw_client, str):
421+
raise TypeError(
422+
f"durable client binding '{client_name}' did not provide its configuration")
423+
client = (SyncDurableFunctionsClient(raw_client) if sync
424+
else DurableFunctionsClient(raw_client))
425+
bound.arguments[client_name] = client
426+
try:
427+
result = function(*bound.args, **bound.kwargs)
428+
return await result if inspect.isawaitable(result) else result
429+
finally:
430+
if sync:
431+
client.close()
432+
else:
433+
client.schedule_close()
434+
435+
client_bound.__annotations__[client_name] = str
436+
client_bound.client_function = function # pyright: ignore[reportFunctionMemberAccess]
437+
if isinstance(user_fn, FunctionBuilder):
438+
user_fn._function._func = client_bound
439+
return wrap(user_fn)
440+
return wrap(client_bound)
406441

407442
return attach_client_function
408443

444+
def durable_client_input_sync(
445+
self,
446+
client_name: str,
447+
task_hub: Optional[str] = None,
448+
connection_name: Optional[str] = None,
449+
) -> Callable[[Callable[..., Any]], FunctionBuilder]:
450+
"""Register a durable-client binding that injects a synchronous client."""
451+
return self.durable_client_input(
452+
client_name, task_hub, connection_name, sync=True)
453+
409454

410455
class DFApp(Blueprint, FunctionRegister):
411456
"""Durable Functions (DF) app.

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

Lines changed: 7 additions & 4 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,8 +214,10 @@ 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:

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

Lines changed: 7 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@
2020

2121
from __future__ import annotations
2222

23-
import atexit
2423
import threading
2524
from collections.abc import Mapping
2625
from datetime import datetime, timezone
@@ -217,30 +216,13 @@ def _close_sync_export_client() -> None:
217216
pass
218217

219218

220-
def _context_for(client: Any) -> HistoryExportContext:
221-
"""Return the per-process export context, building it once from *client*.
222-
223-
Uses the per-invocation injected client to build the synchronous client and
224-
pairs it with the configured writer. The result is cached per process: the
225-
endpoint and writer are stable for the app's lifetime, so the first
226-
invocation in each process establishes the context for the rest. A lock
227-
guards the first-build race so concurrent fan-out activities do not each open
228-
a channel; the built client is closed at process exit.
229-
"""
230-
global _export_context
231-
if _export_context is not None:
232-
return _export_context
233-
with _context_lock:
234-
if _export_context is None:
235-
if _export_writer is None:
236-
raise RuntimeError(
237-
"history export writer is not configured; pass a writer to "
238-
"DFApp.configure_history_export(writer=...) at app startup")
239-
context = HistoryExportContext(
240-
client=_build_sync_client(client), writer=_export_writer)
241-
atexit.register(_close_sync_export_client)
242-
_export_context = context
243-
return _export_context
219+
def _context_for(client: TaskHubGrpcClient) -> HistoryExportContext:
220+
"""Resolve an export context for the invocation's native sync client."""
221+
if _export_writer is None:
222+
raise RuntimeError(
223+
"history export writer is not configured; pass a writer to "
224+
"DFApp.configure_history_export(writer=...) at app startup")
225+
return HistoryExportContext(client=client, writer=_export_writer)
244226

245227

246228
def list_terminal_instances_client_bound(

durabletask/extensions/history_export/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@
2828
run_list_terminal_instances,
2929
)
3030
from durabletask.extensions.history_export.client import (
31+
AsyncExportHistoryClient,
32+
AsyncExportHistoryJobClient,
3133
ExportHistoryClient,
3234
ExportHistoryJobClient,
3335
)
@@ -67,6 +69,8 @@
6769
"ExportFormatKind",
6870
"ExportHistoryClient",
6971
"ExportHistoryJobClient",
72+
"AsyncExportHistoryClient",
73+
"AsyncExportHistoryJobClient",
7074
"ExportJobConfiguration",
7175
"ExportJobCreationOptions",
7276
"ExportJobDescription",

0 commit comments

Comments
 (0)