Skip to content

Commit b44c5f3

Browse files
committed
Skip unused scheduled activity inputs during Functions replay
1 parent a35190b commit b44c5f3

5 files changed

Lines changed: 63 additions & 4 deletions

File tree

‎azure-functions-durable/CHANGELOG.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@ inputs and outputs, custom status, external events, entity state and results,
1616
and registered blueprints. History APIs also hydrate entity operation inputs
1717
and results. Registered orchestration and entity handlers await asynchronous
1818
payload storage without occupying synchronous execution threads. Replay does
19-
not download unused nested inputs from historical entity requests.
19+
not download unused inputs from historical scheduled activities or nested inputs
20+
from historical entity requests. Explicit history retrieval still hydrates them.
2021
User orchestrators remain generators, and activities retain their synchronous
2122
or asynchronous calling convention. Synchronous functions retain synchronous
2223
client bindings, and invocation logging context is preserved.

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,13 @@ def _entity_request_events(request: OrchestratorRequest) -> list[tuple[HistoryEv
224224
]
225225

226226

227+
def discard_scheduled_activity_inputs(request: OrchestratorRequest) -> None:
228+
"""Omit activity inputs that replay never consumes before downloading references."""
229+
for event in chain(request.pastEvents, request.newEvents):
230+
if event.HasField("taskScheduled"):
231+
event.taskScheduled.ClearField("input")
232+
233+
227234
def _update_entity_request_events(events: list[tuple[HistoryEvent, history.HistoryEvent]]) -> None:
228235
for source, event in events:
229236
if isinstance(event, history.EventRaisedEvent) and event.input is not None:

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,10 @@
2323
from .internal.invocation import run_sync
2424
from .internal.compat.entity_context import wrap_entity
2525
from .internal.compat.orchestration_context import wrap_orchestrator
26-
from .internal.payloads import get_transport_payload_store, hydrate_entity_request, hydrate_entity_request_async
26+
from .internal.payloads import (
27+
discard_scheduled_activity_inputs, get_transport_payload_store,
28+
hydrate_entity_request, hydrate_entity_request_async,
29+
)
2730
from .internal.serialization import DEFAULT_FUNCTIONS_DATA_CONVERTER
2831

2932
_LOGGER = logging.getLogger(__name__)
@@ -98,6 +101,7 @@ def execute_orchestration_request(self, func: task.Orchestrator[Any, Any], conte
98101
request = OrchestratorRequest()
99102
request.ParseFromString(base64.b64decode(orchestration_context))
100103
if payload_store is not None:
104+
discard_scheduled_activity_inputs(request)
101105
deexternalize_payloads(request, payload_store)
102106
hydrate_entity_request(request, payload_store)
103107
response = self._run_orchestration(func, request)
@@ -110,6 +114,7 @@ async def execute_orchestration_request_async(self, func: task.Orchestrator[Any,
110114
request = OrchestratorRequest()
111115
request.ParseFromString(base64.b64decode(getattr(context, "body", None) or context))
112116
if payload_store is not None:
117+
discard_scheduled_activity_inputs(request)
113118
await deexternalize_payloads_async(request, payload_store)
114119
await hydrate_entity_request_async(request, payload_store)
115120
response = await run_sync(self._run_orchestration, func, request)

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

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,10 @@ async def test_history_hydrates_only_correlated_entity_envelopes(
161161
ordinary = pb.HistoryEvent(eventId=3)
162162
ordinary.eventRaised.name = "application-event"
163163
ordinary.eventRaised.input.value = reply.eventRaised.input.value
164-
chunks = [pb.HistoryChunk(events=[request]), pb.HistoryChunk(events=[reply, ordinary])]
164+
scheduled = pb.HistoryEvent(eventId=4)
165+
scheduled.taskScheduled.name = "echo"
166+
scheduled.taskScheduled.input.value = token
167+
chunks = [pb.HistoryChunk(events=[request]), pb.HistoryChunk(events=[reply, ordinary, scheduled])]
165168

166169
async def stream():
167170
for chunk in chunks:
@@ -180,11 +183,12 @@ async def stream():
180183
events = client.get_orchestration_history("instance", execution_id="execution")
181184
assert json.loads(json.loads(events[1].input)["result"]) == {"data": "hydrated"}
182185
assert events[2].input == ordinary.eventRaised.input.value
186+
assert json.loads(events[3].input) == {"data": "hydrated"}
183187
assert json.loads(reply.eventRaised.input.value)["result"] == token
184188
if not modern_request:
185189
assert json.loads(json.loads(events[0].input)["input"]) == {"data": "hydrated"}
186190
assert stub.StreamInstanceHistory.call_args.args[0].executionId.value == "execution"
187-
expected_downloads = 1 if modern_request else 2
191+
expected_downloads = 2 if modern_request else 3
188192
assert store.download.call_count == (0 if use_async else expected_downloads)
189193
assert store.download_async.await_count == (expected_downloads if use_async else 0)
190194
finally:

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

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -353,6 +353,48 @@ def orchestrator(context, value):
353353
store.download_async.assert_not_called()
354354

355355

356+
@pytest.mark.parametrize("use_async", [False, True])
357+
@pytest.mark.parametrize("missing_blobs", [False, True])
358+
async def test_replay_skips_scheduled_inputs(monkeypatch, payload_store_factory, use_async, missing_blobs):
359+
store = payload_store_factory()
360+
monkeypatch.setattr(payloads, "_payload_store", store)
361+
request = pb.OrchestratorRequest(instanceId=TEST_INSTANCE_ID)
362+
request.pastEvents.extend([
363+
helpers.new_orchestrator_started_event(),
364+
helpers.new_execution_started_event("activity-replay", TEST_INSTANCE_ID),
365+
])
366+
for task_id in range(1, 21):
367+
token = store.upload(json.dumps("x" * 300_000).encode())
368+
events = request.pastEvents if task_id < 20 else request.newEvents
369+
events.extend([
370+
helpers.new_orchestrator_started_event(),
371+
helpers.new_task_scheduled_event(task_id, "echo", encoded_input=json.dumps(token)),
372+
helpers.new_task_completed_event(task_id, json.dumps("ok")),
373+
])
374+
if missing_blobs:
375+
store._blobs.clear()
376+
download = store.download
377+
monkeypatch.setattr(store, "download", Mock(side_effect=download))
378+
monkeypatch.setattr(store, "download_async", AsyncMock(side_effect=download))
379+
380+
def orchestrator(context, value):
381+
for index in range(20):
382+
result = yield context.call_activity("echo", input="x" * 300_000)
383+
assert result == "ok"
384+
return "complete"
385+
386+
worker = DurableFunctionsWorker()
387+
encoded = base64.b64encode(request.SerializeToString()).decode()
388+
result = (await worker.execute_orchestration_request_async(orchestrator, encoded) if use_async
389+
else worker.execute_orchestration_request(orchestrator, encoded))
390+
completion = _get_completion_action(_decode_orchestrator_response(result))
391+
assert completion.orchestrationStatus == pb.ORCHESTRATION_STATUS_COMPLETED
392+
assert json.loads(completion.result.value) == "complete"
393+
store.download.assert_not_called()
394+
store.download_async.assert_not_called()
395+
assert request.pastEvents[3].taskScheduled.HasField("input")
396+
397+
356398
def _encode_orchestrator_request(name, encoded_input=None, instance_id=TEST_INSTANCE_ID):
357399
"""Build a base64-encoded ``OrchestratorRequest`` for a single new dispatch."""
358400
request = pb.OrchestratorRequest(instanceId=instance_id)

0 commit comments

Comments
 (0)