Skip to content

Commit f1aeb63

Browse files
andystaplesCopilot
andcommitted
Preserve synchronous activities and narrow replay payload hydration
Keep orchestration and entity payload I/O asynchronous while restoring native activity calling conventions. Skip unused historical entity input downloads during replay and retain full client history hydration. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 7415068 commit f1aeb63

9 files changed

Lines changed: 300 additions & 125 deletions

File tree

‎azure-functions-durable/CHANGELOG.md‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,11 @@ payloads using a shared payload store, including Azure Blob Storage. Configurati
1414
applies to synchronous and asynchronous clients, orchestration and activity
1515
inputs and outputs, custom status, external events, entity state and results,
1616
and registered blueprints. History APIs also hydrate entity operation inputs
17-
and results. Registered orchestration, entity, and activity handlers await
18-
asynchronous payload storage without occupying synchronous execution threads.
19-
User orchestrators remain generators, synchronous functions retain synchronous
17+
and results. Registered orchestration and entity handlers await asynchronous
18+
payload storage without occupying synchronous execution threads. Replay does
19+
not download unused nested inputs from historical entity requests.
20+
User orchestrators remain generators, and activities retain their synchronous
21+
or asynchronous calling convention. Synchronous functions retain synchronous
2022
client bindings, and invocation logging context is preserved.
2123
Worker payload-size and storage errors retain their original error details.
2224

‎azure-functions-durable/README.md‎

Lines changed: 23 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -97,23 +97,31 @@ agree on the store and reference encoding; Functions references are JSON strings
9797
> host abandons and redelivers a work item after a storage failure. A transient
9898
> storage error can therefore become a terminal orchestration failure.
9999
100-
Registered orchestration, entity, and activity handlers await the store's async
101-
methods before and after execution, including for synchronous user functions.
102-
Orchestrators remain synchronous generators; entities and synchronous activities
103-
run on execution threads with their invocation logging context preserved.
104-
Payload downloads and uploads do not occupy those threads, and serialization
105-
does not access storage during replay. Custom stores must implement genuinely
106-
nonblocking async methods to benefit from this behavior.
107-
108-
The SDK reuses the Functions runtime's thread pool when the runtime exposes it;
109-
otherwise it uses a process-wide SDK pool. Both honor
110-
`PYTHON_THREADPOOL_THREAD_COUNT` for synchronous execution. Synchronous user
111-
functions still receive the synchronous durable client, and synchronous client
112-
APIs still use synchronous storage. Both client history APIs hydrate entity
113-
operation inputs and results, including values nested in the host's entity
114-
protocol envelopes. Direct `Orchestrator.handle()` and `Orchestrator.create()`
100+
Registered orchestration and entity handlers await the store's async methods
101+
before and after execution. Orchestrators remain synchronous generators, and
102+
orchestration replay and entity code run on execution threads with their
103+
invocation logging context preserved. Their payload downloads and uploads do
104+
not occupy those threads, and serialization does not access storage during
105+
replay. Custom stores must implement genuinely nonblocking async methods to
106+
benefit from this behavior.
107+
108+
For orchestration and entity execution, the SDK reuses the Functions runtime's
109+
thread pool when the runtime exposes it; otherwise it uses a process-wide SDK
110+
pool. Both honor `PYTHON_THREADPOOL_THREAD_COUNT`.
111+
112+
Activities retain their synchronous or asynchronous calling convention.
113+
Synchronous activities use synchronous storage inside the host-managed execution
114+
thread and remain directly callable without `await`; async activities await
115+
async storage. Binding converters perform no storage I/O. Synchronous functions
116+
still receive the synchronous durable client, and synchronous client APIs use
117+
synchronous storage. Direct `Orchestrator.handle()` and `Orchestrator.create()`
115118
adapters also remain synchronous.
116119

120+
Both client history APIs hydrate entity operation inputs and results, including
121+
values nested in the host's entity protocol envelopes. During orchestration
122+
replay, nested entity results are hydrated, but historical nested request inputs
123+
are not downloaded because replay only needs their correlation metadata.
124+
117125
> [!WARNING]
118126
> With payload storage configured, whole payload strings recognized by the
119127
> store's `is_known_token()` are reserved references, not literal application

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

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -326,9 +326,8 @@ def activity_trigger(self, input_name: str,
326326
"""
327327
@self._build_function
328328
def wrap(fb: FunctionBuilder) -> FunctionBuilder:
329-
registered = fb._function._func # pyright: ignore[reportPrivateUsage]
330329
fb.add_trigger(
331-
trigger=ActivityTrigger(name=getattr(registered, "_df_trigger_name", input_name), activity=activity))
330+
trigger=ActivityTrigger(name=input_name, activity=activity))
332331
return fb
333332

334333
def decorator(user_fn: Callable[..., Any]) -> FunctionBuilder:
@@ -337,8 +336,7 @@ def decorator(user_fn: Callable[..., Any]) -> FunctionBuilder:
337336
# pass through unchanged.
338337
function = (user_fn._function._func # pyright: ignore[reportPrivateUsage]
339338
if isinstance(user_fn, FunctionBuilder) else user_fn)
340-
registered = wrap_invocation(
341-
wrap_activity_payloads(wrap_activity(function, input_name), input_name), input_name)
339+
registered = wrap_activity_payloads(wrap_activity(function, input_name), input_name)
342340
if isinstance(user_fn, FunctionBuilder):
343341
user_fn._function._func = registered # pyright: ignore[reportPrivateUsage]
344342
return wrap(user_fn)
@@ -425,7 +423,6 @@ def attach_client_function(user_fn: Callable[..., Any]) -> FunctionBuilder:
425423
if isinstance(user_fn, FunctionBuilder) else user_fn)
426424
signature = inspect.signature(function)
427425
is_async_function = inspect.iscoroutinefunction(function)
428-
is_async_user = getattr(function, "_df_user_is_async", is_async_function)
429426

430427
def bind_client(
431428
args: tuple[Any, ...],
@@ -440,7 +437,7 @@ def bind_client(
440437
if not isinstance(raw_client, str):
441438
raise TypeError(
442439
f"durable client binding '{client_name}' did not provide its configuration")
443-
client = (DurableFunctionsClient(raw_client) if is_async_user
440+
client = (DurableFunctionsClient(raw_client) if is_async_function
444441
else SyncDurableFunctionsClient.get_cached(raw_client))
445442
bound.arguments[client_name] = client
446443
return bound, client

‎azure-functions-durable/azure/durable_functions/internal/compat/activity.py‎

Lines changed: 36 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,11 @@
3333
from azure.functions._durable_functions import df_loads
3434

3535
from ..converters import ActivityTriggerConverter
36-
from ..invocation import run_sync
3736
from ..payloads import (
3837
ActivityPayload,
38+
deexternalize_payload,
3939
deexternalize_payload_async,
40+
externalize_activity_output,
4041
externalize_activity_output_async,
4142
get_payload_store,
4243
)
@@ -177,30 +178,48 @@ def wrap_activity(fn: Callable[..., Any], input_name: str) -> Callable[..., Any]
177178

178179

179180
def wrap_activity_payloads(fn: Callable[..., Any], input_name: str) -> Callable[..., Any]:
180-
"""Await payload transport and offload synchronous user activities."""
181+
"""Run payload I/O inside the invocation, preserving sync/async dispatch."""
181182
signature = inspect.signature(fn)
182-
is_async = inspect.iscoroutinefunction(fn)
183183

184184
def decode(value: str) -> Any:
185185
try:
186186
return df_loads(value)
187187
except json.JSONDecodeError:
188188
return value
189189

190-
@wraps(fn)
191-
async def wrapper(*args: Any, **kwargs: Any) -> Any:
192-
enabled = get_payload_store() is not None
193-
bound = signature.bind(*args, **kwargs)
194-
value = bound.arguments.get(input_name)
195-
if enabled and isinstance(value, ActivityPayload):
196-
bound.arguments[input_name] = decode(await deexternalize_payload_async(value.value))
197-
result = (await fn(*bound.args, **bound.kwargs) if is_async
198-
else await run_sync(fn, *bound.args, **bound.kwargs))
199-
if not enabled or result is None:
200-
return result
201-
encoded = ActivityTriggerConverter.encode(result, expected_type=None).value
202-
return ActivityPayload(await externalize_activity_output_async(encoded))
190+
wrapper: Callable[..., Any]
191+
if inspect.iscoroutinefunction(fn):
192+
@wraps(fn)
193+
async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
194+
if get_payload_store() is None:
195+
return await fn(*args, **kwargs)
196+
bound = signature.bind(*args, **kwargs)
197+
value = bound.arguments.get(input_name)
198+
if isinstance(value, ActivityPayload):
199+
bound.arguments[input_name] = decode(await deexternalize_payload_async(value.value))
200+
result = await fn(*bound.args, **bound.kwargs)
201+
if result is None:
202+
return None
203+
encoded = ActivityTriggerConverter.encode(result, expected_type=None).value
204+
return ActivityPayload(await externalize_activity_output_async(encoded))
205+
206+
wrapper = async_wrapper
207+
else:
208+
@wraps(fn)
209+
def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
210+
if get_payload_store() is None:
211+
return fn(*args, **kwargs)
212+
bound = signature.bind(*args, **kwargs)
213+
value = bound.arguments.get(input_name)
214+
if isinstance(value, ActivityPayload):
215+
bound.arguments[input_name] = decode(deexternalize_payload(value.value))
216+
result = fn(*bound.args, **bound.kwargs)
217+
if result is None:
218+
return None
219+
encoded = ActivityTriggerConverter.encode(result, expected_type=None).value
220+
return ActivityPayload(externalize_activity_output(encoded))
221+
222+
wrapper = sync_wrapper
203223

204224
setattr(wrapper, "__signature__", signature)
205-
setattr(wrapper, "_df_user_is_async", getattr(fn, "_df_user_is_async", is_async))
206225
return wrapper

‎azure-functions-durable/azure/durable_functions/internal/payloads.py‎

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,7 @@ def externalize_activity_output(value: str) -> str:
137137

138138

139139
def _entity_payload_fields(
140-
events: Sequence[history.HistoryEvent], instance_id: str,
140+
events: Sequence[history.HistoryEvent], instance_id: str, *, include_inputs: bool,
141141
) -> Iterator[tuple[history.EventSentEvent | history.EventRaisedEvent, dict[str, Any], str]]:
142142
pending: set[str] = set()
143143
for event in events:
@@ -182,17 +182,18 @@ def _entity_payload_fields(
182182
pending.add(request_id)
183183
elif envelope.get("signal") is not True:
184184
continue
185-
if isinstance(envelope.get(field), str):
185+
if (include_inputs or field == "result") and isinstance(envelope.get(field), str):
186186
yield event, envelope, field
187187

188188

189189
def hydrate_entity_history(
190190
events: list[history.HistoryEvent], store: PayloadStore | None, instance_id: str,
191+
*, include_inputs: bool = True,
191192
) -> None:
192193
"""Hydrate serialized entity protocol fields, never arbitrary object members."""
193194
if store is None:
194195
return
195-
for event, envelope, field in _entity_payload_fields(events, instance_id):
196+
for event, envelope, field in _entity_payload_fields(events, instance_id, include_inputs=include_inputs):
196197
request = ActivityRequest(input=StringValue(value=envelope[field]))
197198
deexternalize_payloads(request, store)
198199
if request.input.value != envelope[field]:
@@ -202,11 +203,12 @@ def hydrate_entity_history(
202203

203204
async def hydrate_entity_history_async(
204205
events: list[history.HistoryEvent], store: PayloadStore | None, instance_id: str,
206+
*, include_inputs: bool = True,
205207
) -> None:
206208
"""Hydrate entity history using the store's asynchronous download API."""
207209
if store is None:
208210
return
209-
for event, envelope, field in _entity_payload_fields(events, instance_id):
211+
for event, envelope, field in _entity_payload_fields(events, instance_id, include_inputs=include_inputs):
210212
request = ActivityRequest(input=StringValue(value=envelope[field]))
211213
await deexternalize_payloads_async(request, store)
212214
if request.input.value != envelope[field]:
@@ -224,19 +226,19 @@ def _entity_request_events(request: OrchestratorRequest) -> list[tuple[HistoryEv
224226

225227
def _update_entity_request_events(events: list[tuple[HistoryEvent, history.HistoryEvent]]) -> None:
226228
for source, event in events:
227-
if isinstance(event, history.EventSentEvent) and event.input is not None:
228-
source.eventSent.input.value = event.input
229-
elif isinstance(event, history.EventRaisedEvent) and event.input is not None:
229+
if isinstance(event, history.EventRaisedEvent) and event.input is not None:
230230
source.eventRaised.input.value = event.input
231231

232232

233233
def hydrate_entity_request(request: OrchestratorRequest, store: PayloadStore) -> None:
234+
"""Hydrate entity replies for replay, leaving unused historical inputs alone."""
234235
events = _entity_request_events(request)
235-
hydrate_entity_history([event for _, event in events], store, request.instanceId)
236+
hydrate_entity_history([event for _, event in events], store, request.instanceId, include_inputs=False)
236237
_update_entity_request_events(events)
237238

238239

239240
async def hydrate_entity_request_async(request: OrchestratorRequest, store: PayloadStore) -> None:
241+
"""Await entity reply payloads before replay, without downloading historical inputs."""
240242
events = _entity_request_events(request)
241-
await hydrate_entity_history_async([event for _, event in events], store, request.instanceId)
243+
await hydrate_entity_history_async([event for _, event in events], store, request.instanceId, include_inputs=False)
242244
_update_entity_request_events(events)

‎tests/azure-functions-durable/test_activity_adapter_compat.py‎

Lines changed: 26 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -177,8 +177,7 @@ async def native_activity(context, payload):
177177
store.upload_async.assert_awaited_once()
178178

179179

180-
@pytest.mark.asyncio
181-
async def test_sync_activity_awaits_storage_and_offloads_user_code(monkeypatch, payload_store_factory):
180+
def test_sync_activity_uses_sync_storage_on_the_calling_thread(monkeypatch, payload_store_factory):
182181
store = payload_store_factory()
183182
monkeypatch.setattr(payloads, "_payload_store", store)
184183
value = {"large": "x" * 200}
@@ -187,11 +186,11 @@ async def test_sync_activity_awaits_storage_and_offloads_user_code(monkeypatch,
187186
original_download = store.download
188187
original_upload = store.upload
189188

190-
async def download(reference):
189+
def download(reference):
191190
thread_ids.append(threading.get_ident())
192191
return original_download(reference)
193192

194-
async def upload(data, *, instance_id=None):
193+
def upload(data, *, instance_id=None):
195194
thread_ids.append(threading.get_ident())
196195
return original_upload(data, instance_id=instance_id)
197196

@@ -200,18 +199,22 @@ def activity(payload):
200199
assert payload == value
201200
return payload
202201

203-
monkeypatch.setattr(store, "download_async", download)
204-
monkeypatch.setattr(store, "upload_async", upload)
205-
monkeypatch.setattr(store, "download", Mock(side_effect=AssertionError("sync download")))
206-
monkeypatch.setattr(store, "upload", Mock(side_effect=AssertionError("sync upload")))
202+
monkeypatch.setattr(store, "download", Mock(side_effect=download))
203+
monkeypatch.setattr(store, "upload", Mock(side_effect=upload))
204+
monkeypatch.setattr(store, "download_async", AsyncMock(side_effect=AssertionError("async download")))
205+
monkeypatch.setattr(store, "upload_async", AsyncMock(side_effect=AssertionError("async upload")))
207206
wrapper = wrap_activity_payloads(activity, "payload")
208-
assert inspect.iscoroutinefunction(wrapper)
207+
assert not inspect.iscoroutinefunction(wrapper)
209208
decoded = ActivityTriggerConverter.decode(
210209
meta.Datum(type="string", value=token), trigger_metadata=None)
211-
await wrapper(decoded)
212-
assert len(thread_ids) == 3
213-
assert thread_ids[0] == thread_ids[2] == threading.get_ident()
214-
assert thread_ids[1] != threading.get_ident()
210+
result = wrapper(decoded)
211+
encoded = ActivityTriggerConverter.encode(result, expected_type=None)
212+
assert json.loads(original_download(json.loads(encoded.value))) == value
213+
assert thread_ids == [threading.get_ident()] * 3
214+
store.download.assert_called_once_with(token)
215+
store.upload.assert_called_once()
216+
store.download_async.assert_not_called()
217+
store.upload_async.assert_not_called()
215218

216219

217220
@pytest.mark.asyncio
@@ -223,8 +226,8 @@ async def test_activity_storage_errors_propagate_without_retry(
223226
monkeypatch.setattr(payloads, "_payload_store", store)
224227
token = store.upload(json.dumps("x" * 200).encode())
225228
error = OSError("payload storage unavailable")
226-
failure = AsyncMock(side_effect=error)
227-
monkeypatch.setattr(store, operation + "_async", failure)
229+
failure = AsyncMock(side_effect=error) if use_async else Mock(side_effect=error)
230+
monkeypatch.setattr(store, operation + ("_async" if use_async else ""), failure)
228231
called = []
229232

230233
def activity(payload):
@@ -238,10 +241,16 @@ async def async_activity(payload):
238241
decoded = ActivityTriggerConverter.decode(
239242
meta.Datum(type="string", value=token), trigger_metadata=None)
240243
with pytest.raises(OSError) as raised:
241-
await wrapper(decoded)
244+
if use_async:
245+
await wrapper(decoded)
246+
else:
247+
wrapper(decoded)
242248
assert raised.value is error
243249
assert called == ([True] if operation == "upload" else [])
244-
failure.assert_awaited_once()
250+
if use_async:
251+
failure.assert_awaited_once()
252+
else:
253+
failure.assert_called_once()
245254

246255

247256
@pytest.mark.parametrize("setting, expected", [(None, None), ("2", 2), ("invalid", None), ("0", None)])
@@ -289,42 +298,3 @@ async def handle(payload):
289298
assert await loop.run_in_executor(executor, host_invocation_id.get) is None
290299
assert await loop.run_in_executor(executor, getattr, storage, "invocation_id") == "previous"
291300
assert invocation._invocation_context.get() is None
292-
293-
294-
async def test_storage_downloads_do_not_wait_for_execution_thread(monkeypatch, payload_store_factory):
295-
store = payload_store_factory()
296-
monkeypatch.setattr(payloads, "_payload_store", store)
297-
token = store.upload(b'"input"')
298-
loop = asyncio.get_running_loop()
299-
executing = asyncio.Event()
300-
downloaded = asyncio.Event()
301-
release = threading.Event()
302-
downloads = []
303-
304-
async def download(reference):
305-
downloads.append(reference)
306-
if len(downloads) == 2:
307-
downloaded.set()
308-
return store._blobs[reference]
309-
310-
def activity(payload):
311-
loop.call_soon_threadsafe(executing.set)
312-
assert release.wait(timeout=5)
313-
return payload
314-
315-
monkeypatch.setattr(store, "download_async", download)
316-
monkeypatch.setattr(store, "download", Mock(side_effect=AssertionError("sync download")))
317-
wrapper = wrap_activity_payloads(activity, "payload")
318-
with ThreadPoolExecutor(max_workers=1) as executor:
319-
monkeypatch.setattr(invocation, "_executor", lambda: executor)
320-
first = asyncio.create_task(wrapper(payloads.ActivityPayload(token)))
321-
second = None
322-
try:
323-
await asyncio.wait_for(executing.wait(), timeout=5)
324-
second = asyncio.create_task(wrapper(payloads.ActivityPayload(token)))
325-
await asyncio.wait_for(downloaded.wait(), timeout=5)
326-
assert not first.done()
327-
assert not second.done()
328-
finally:
329-
release.set()
330-
await asyncio.gather(first, *([second] if second is not None else []))

0 commit comments

Comments
 (0)