Skip to content

Commit aa39da0

Browse files
andystaplesCopilot
andcommitted
Reuse Azure Functions durable workers
Cache host-driven workers per function handle and make direct-execute registration thread-safe and idempotent without changing core durabletask registration semantics. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b29a8f40-77ba-48d9-a3f7-2748491f3791
1 parent b871df8 commit aa39da0

7 files changed

Lines changed: 122 additions & 18 deletions

File tree

azure-functions-durable/CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@ the synchronous client into synchronous functions and the asynchronous client
1414
into coroutine functions. Both clients support scheduled-task and history-export
1515
APIs without an async-to-sync bridge.
1616

17+
CHANGED
18+
19+
- Reused host-driven orchestration and entity workers across invocations,
20+
avoiding repeated allocation of unused worker resources.
21+
1722
## 2.0.0b1
1823

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

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,7 @@ def decorator(entity_func: task.Entity[Any, Any]) -> FunctionBuilder:
233233
if entity_name is not None:
234234
entity_func.__durable_entity_name__ = entity_name # type: ignore[union-attr]
235235
# Construct an orchestrator based on the end-user code
236+
worker = DurableFunctionsWorker()
236237

237238
# TODO: Because this handle method is the one actually exposed to the Functions SDK decorator,
238239
# the parameter name will always be "context" here, even if the user specified a different name.
@@ -243,7 +244,7 @@ def decorator(entity_func: task.Entity[Any, Any]) -> FunctionBuilder:
243244
# binding converter to accept it; at runtime the host passes that
244245
# transport context (exposing ``.body``).
245246
def handle(context: func.EntityContext) -> str:
246-
return DurableFunctionsWorker().execute_entity_batch_request(entity_func, context)
247+
return worker.execute_entity_batch_request(entity_func, context)
247248

248249
handle.entity_function = entity_func # pyright: ignore[reportFunctionMemberAccess]
249250

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

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,11 @@ class Orchestrator:
2020
function.
2121
"""
2222

23-
def __init__(self, orchestrator_func: Callable[..., Any]):
23+
def __init__(
24+
self,
25+
orchestrator_func: Callable[..., Any],
26+
worker: DurableFunctionsWorker | None = None,
27+
):
2428
"""Create a new orchestrator wrapper for a user orchestrator function.
2529
2630
The wrapped function may be a durabletask-native two-argument
@@ -30,6 +34,7 @@ def __init__(self, orchestrator_func: Callable[..., Any]):
3034
:param orchestrator_func: The user's orchestrator function to run.
3135
"""
3236
self.fn: Callable[..., Any] = orchestrator_func
37+
self._worker = worker if worker is not None else DurableFunctionsWorker()
3338

3439
def handle(self, context: func.OrchestrationContext) -> str:
3540
"""Handle the orchestration of the user defined generator function.
@@ -49,7 +54,7 @@ def handle(self, context: func.OrchestrationContext) -> str:
4954
Durable Functions host wire format) produced by this invocation.
5055
"""
5156
self.durable_context = context
52-
return DurableFunctionsWorker().execute_orchestration_request(self.fn, context)
57+
return self._worker.execute_orchestration_request(self.fn, context)
5358

5459
@classmethod
5560
def create(cls, fn: Callable[..., Any]) -> Callable[[Any], str]:
@@ -67,13 +72,15 @@ def create(cls, fn: Callable[..., Any]) -> Callable[[Any], str]:
6772
Handle function of the newly created orchestration client
6873
"""
6974

75+
worker = DurableFunctionsWorker()
76+
7077
# The generated handle is the function registered with the Azure
7178
# Functions host. Its ``context`` parameter must be annotated with
7279
# ``azure.functions.OrchestrationContext`` so the host's
7380
# orchestrationTrigger binding converter accepts it; at runtime the
7481
# host passes that transport context (exposing ``.body``).
7582
def handle(context: func.OrchestrationContext) -> str:
76-
return Orchestrator(fn).handle(context)
83+
return Orchestrator(fn, worker).handle(context)
7784

7885
handle.orchestrator_function = fn # pyright: ignore[reportFunctionMemberAccess]
7986

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

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
# Licensed under the MIT License.
33

44
import base64
5+
from threading import Lock
56
from typing import Any, Optional
67

78
from durabletask import task
@@ -41,10 +42,26 @@ def __init__(self) -> None:
4142
# azure-functions codec (df_dumps/df_loads) so user types round-trip in
4243
# the wire format the Durable Functions host extension expects.
4344
super().__init__(data_converter=DEFAULT_FUNCTIONS_DATA_CONVERTER)
45+
self._registration_lock = Lock()
4446

4547
def add_named_orchestrator(self, name: str, func: task.Orchestrator[Any, Any]) -> None:
4648
self._registry.add_named_orchestrator(name, func)
4749

50+
def _register_orchestrator_once(
51+
self,
52+
name: str,
53+
func: task.Orchestrator[Any, Any],
54+
) -> None:
55+
with self._registration_lock:
56+
if self._registry.get_orchestrator(name) is None:
57+
self.add_named_orchestrator(name, wrap_orchestrator(func))
58+
59+
def _register_entity_once(self, func: task.Entity[Any, Any]) -> None:
60+
name = task.get_entity_name(func).lower()
61+
with self._registration_lock:
62+
if self._registry.get_entity(name) is None:
63+
self.add_entity(wrap_entity(func), name=name)
64+
4865
def execute_orchestration_request(self, func: task.Orchestrator[Any, Any], context: Any) -> str:
4966
context_body = getattr(context, "body", None)
5067
if context_body is None:
@@ -70,7 +87,7 @@ def stub_complete(stub_response: OrchestratorResponse) -> None:
7087
raise ValueError("No ExecutionStarted event found in orchestration request.")
7188

7289
function_name = execution_started_events[-1].executionStarted.name
73-
self.add_named_orchestrator(function_name, wrap_orchestrator(func))
90+
self._register_orchestrator_once(function_name, func)
7491
super()._execute_orchestrator(request, stub, None)
7592

7693
if response is None:
@@ -94,7 +111,7 @@ def stub_complete(stub_response: EntityBatchResult) -> None:
94111
response = stub_response
95112
stub.CompleteEntityTask = stub_complete
96113

97-
self.add_entity(wrap_entity(func))
114+
self._register_entity_once(func)
98115
super()._execute_entity_batch(request, stub, None)
99116

100117
if response is None:

tests/azure-functions-durable/test_decorator_compat.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import azure.durable_functions as df
55
import inspect
66
import pytest
7+
from unittest.mock import MagicMock, patch
78
from azure.durable_functions.constants import (
89
ACTIVITY_TRIGGER,
910
DURABLE_CLIENT,
@@ -104,6 +105,32 @@ def my_entity(context):
104105
assert trigger.entity_name == "MyEntity"
105106

106107

108+
def test_entity_trigger_reuses_worker_across_invocations():
109+
app = df.DFApp()
110+
111+
def my_entity(context):
112+
return None
113+
114+
with patch(
115+
"azure.durable_functions.decorators.durable_app.DurableFunctionsWorker"
116+
) as worker_cls:
117+
worker = worker_cls.return_value
118+
worker.execute_entity_batch_request.return_value = "encoded"
119+
fb = app.entity_trigger(context_name="context")(my_entity)
120+
handle = fb._function._func
121+
first_context = MagicMock()
122+
second_context = MagicMock()
123+
first_result = handle(first_context)
124+
second_result = handle(second_context)
125+
126+
assert first_result == second_result == "encoded"
127+
worker_cls.assert_called_once_with()
128+
assert worker.execute_entity_batch_request.call_args_list == [
129+
((my_entity, first_context),),
130+
((my_entity, second_context),),
131+
]
132+
133+
107134
# ---------------------------------------------------------------------------
108135
# durable_client_input
109136
# ---------------------------------------------------------------------------

tests/azure-functions-durable/test_orchestrator_compat.py

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
66
``Orchestrator`` is the thin adapter registered with the Azure Functions host:
77
its generated ``handle`` receives the host's transport context and delegates to
8-
a fresh :class:`DurableFunctionsWorker` per invocation.
8+
a shared :class:`DurableFunctionsWorker`.
99
"""
1010

1111
from unittest.mock import MagicMock, patch
@@ -21,11 +21,12 @@ def user_orchestrator(context):
2121
with patch(
2222
"azure.durable_functions.orchestrator.DurableFunctionsWorker"
2323
) as worker_cls:
24-
worker_cls.return_value.execute_orchestration_request.return_value = "encoded"
24+
worker = worker_cls.return_value
25+
worker.execute_orchestration_request.return_value = "encoded"
2526
result = Orchestrator(user_orchestrator).handle(context)
2627

2728
assert result == "encoded"
28-
worker_cls.return_value.execute_orchestration_request.assert_called_once_with(
29+
worker.execute_orchestration_request.assert_called_once_with(
2930
user_orchestrator, context)
3031

3132

@@ -34,8 +35,8 @@ def user_orchestrator(context):
3435
return None
3536

3637
context = MagicMock()
37-
orchestrator = Orchestrator(user_orchestrator)
3838
with patch("azure.durable_functions.orchestrator.DurableFunctionsWorker"):
39+
orchestrator = Orchestrator(user_orchestrator)
3940
orchestrator.handle(context)
4041
assert orchestrator.durable_context is context
4142

@@ -53,14 +54,20 @@ def test_created_handle_delegates_to_worker():
5354
def user_orchestrator(context):
5455
return None
5556

56-
handle = Orchestrator.create(user_orchestrator)
57-
context = MagicMock()
5857
with patch(
5958
"azure.durable_functions.orchestrator.DurableFunctionsWorker"
6059
) as worker_cls:
61-
worker_cls.return_value.execute_orchestration_request.return_value = "encoded"
62-
result = handle(context)
63-
64-
assert result == "encoded"
65-
worker_cls.return_value.execute_orchestration_request.assert_called_once_with(
66-
user_orchestrator, context)
60+
worker = worker_cls.return_value
61+
worker.execute_orchestration_request.return_value = "encoded"
62+
handle = Orchestrator.create(user_orchestrator)
63+
first_context = MagicMock()
64+
second_context = MagicMock()
65+
first_result = handle(first_context)
66+
second_result = handle(second_context)
67+
68+
assert first_result == second_result == "encoded"
69+
worker_cls.assert_called_once_with()
70+
assert worker.execute_orchestration_request.call_args_list == [
71+
((user_orchestrator, first_context),),
72+
((user_orchestrator, second_context),),
73+
]

tests/azure-functions-durable/test_worker_compat.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
import base64
1414
import json
15+
from concurrent.futures import ThreadPoolExecutor
1516
from types import SimpleNamespace
1617

1718
import pytest
@@ -131,6 +132,25 @@ def orchestrator(context):
131132
assert "boom" in completion.failureDetails.errorMessage
132133

133134

135+
def test_execute_orchestration_request_supports_concurrent_reinvocation():
136+
def orchestrator(context):
137+
return context.instance_id
138+
139+
worker = DurableFunctionsWorker()
140+
encoded = _encode_orchestrator_request("concurrent-orch")
141+
with ThreadPoolExecutor(max_workers=4) as executor:
142+
results = list(executor.map(
143+
lambda _: worker.execute_orchestration_request(orchestrator, encoded),
144+
range(8),
145+
))
146+
147+
assert all(
148+
json.loads(_get_completion_action(
149+
_decode_orchestrator_response(result)).result.value) == TEST_INSTANCE_ID
150+
for result in results
151+
)
152+
153+
134154
# ---------------------------------------------------------------------------
135155
# execute_entity_batch_request
136156
# ---------------------------------------------------------------------------
@@ -197,3 +217,23 @@ def entity(context):
197217
response = _decode_entity_response(result)
198218
assert response.results[0].HasField("failure")
199219
assert "entity failed" in response.results[0].failure.failureDetails.errorMessage
220+
221+
222+
def test_execute_entity_batch_request_supports_concurrent_reinvocation():
223+
def entity(context):
224+
context.set_result("handled")
225+
226+
entity.__durable_entity_name__ = "Counter"
227+
worker = DurableFunctionsWorker()
228+
encoded = _encode_entity_batch_request("@counter@key1", "op")
229+
with ThreadPoolExecutor(max_workers=4) as executor:
230+
results = list(executor.map(
231+
lambda _: worker.execute_entity_batch_request(entity, encoded),
232+
range(8),
233+
))
234+
235+
assert all(
236+
json.loads(_decode_entity_response(
237+
result).results[0].success.result.value) == "handled"
238+
for result in results
239+
)

0 commit comments

Comments
 (0)