Skip to content

Commit 47bf69b

Browse files
authored
Merge branch 'main' into dependabot/github_actions/github-actions-4b2c77c676
2 parents 08c9b34 + 3a2ac21 commit 47bf69b

11 files changed

Lines changed: 547 additions & 8 deletions

File tree

‎CHANGELOG.md‎

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

1010
FIXED
1111

12+
- Single-instance `purge_orchestration()` requests now explicitly target
13+
orchestrations rather than entities in both synchronous and asynchronous clients.
1214
- Timer callbacks no longer schedule additional long-timer chunks, retry
1315
activities or sub-orchestrations, or resume orchestrator code after completion,
1416
failure, termination, or continue-as-new. Work scheduled before the terminal
1517
state is preserved.
1618
- `continue_as_new(..., save_events=True)` now preserves the global arrival
1719
order of unconsumed buffered external events across different event names,
1820
instead of grouping carryover events by name.
21+
- Fixed external events arriving after `continue_as_new(..., save_events=True)`
22+
being lost to abandoned waits instead of carried into the next execution.
23+
Events already delivered to live waits are not carried over, and
24+
`save_events=False` still discards unprocessed events.
1925

2026
## v1.11.0
2127

‎azure-functions-durable/CHANGELOG.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
FIXED
1111

12+
- With the corresponding core `durabletask` SDK fix, single-instance
13+
`purge_orchestration()` requests from `DurableFunctionsClient` and
14+
`SyncDurableFunctionsClient` now explicitly target orchestrations rather than
15+
entities. This also applies to the `purge_instance_history()` compatibility alias.
1216
- With the corresponding core `durabletask` SDK fix, timer callbacks no longer
1317
schedule additional long-timer chunks, retry activities or sub-orchestrations,
1418
or resume orchestrator code after completion, failure, termination, or
1519
continue-as-new. This applies to both native and compatibility orchestration APIs.
1620
- With the corresponding core `durabletask` SDK fix,
1721
`continue_as_new(..., save_events=True)` preserves the global arrival order
1822
of unconsumed buffered external events across different event names.
23+
- With a corrected core `durabletask` SDK, native durabletask orchestrators
24+
preserve external events arriving after `continue_as_new(..., save_events=True)`
25+
instead of losing them to abandoned waits.
1926

2027
## v2.0.0rc2
2128

‎durabletask-azuremanaged/CHANGELOG.md‎

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

1010
FIXED
1111

12+
- With the corresponding core `durabletask` SDK fix, single-instance
13+
`purge_orchestration()` requests from synchronous and asynchronous Azure Managed
14+
clients now explicitly target orchestrations rather than entities.
1215
- With the corresponding core `durabletask` SDK fix, timer callbacks no longer
1316
retry activities or sub-orchestrations, or resume orchestrator code after
1417
completion, failure, termination, or continue-as-new. Azure Managed uses native
@@ -17,6 +20,9 @@ change that behavior.
1720
- With the corresponding core `durabletask` SDK fix,
1821
`continue_as_new(..., save_events=True)` preserves the global arrival order
1922
of unconsumed buffered external events across different event names.
23+
- With a corrected core `durabletask` SDK, external events arriving after
24+
`continue_as_new(..., save_events=True)` are no longer lost to abandoned waits
25+
and are carried into the next execution.
2026

2127
## v1.11.0
2228

‎durabletask/client.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -857,7 +857,7 @@ def restart_orchestration(self, instance_id: str, *,
857857
return res.instanceId
858858

859859
def purge_orchestration(self, instance_id: str, recursive: bool = True) -> PurgeInstancesResult:
860-
req = pb.PurgeInstancesRequest(instanceId=instance_id, recursive=recursive)
860+
req = pb.PurgeInstancesRequest(instanceId=instance_id, recursive=recursive, isOrchestration=True)
861861
self._logger.info(f"Purging instance '{instance_id}'.")
862862
resp: pb.PurgeInstancesResponse = self._stub.PurgeInstances(req)
863863
return new_purge_instances_result(resp)
@@ -1402,7 +1402,7 @@ async def restart_orchestration(self, instance_id: str, *,
14021402
return res.instanceId
14031403

14041404
async def purge_orchestration(self, instance_id: str, recursive: bool = True) -> PurgeInstancesResult:
1405-
req = pb.PurgeInstancesRequest(instanceId=instance_id, recursive=recursive)
1405+
req = pb.PurgeInstancesRequest(instanceId=instance_id, recursive=recursive, isOrchestration=True)
14061406
self._logger.info(f"Purging instance '{instance_id}'.")
14071407
resp: pb.PurgeInstancesResponse = await self._get_stub().PurgeInstances(req)
14081408
return new_purge_instances_result(resp)

‎durabletask/task.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -393,12 +393,18 @@ def continue_as_new(self, new_input: Any, *, save_events: bool = False,
393393
new_version: str | None = None) -> None:
394394
"""Continue the orchestration execution as a new instance.
395395
396+
Orchestrators should return immediately after calling this method.
397+
Subsequent external events are no longer delivered to pending waits
398+
in the current execution.
399+
396400
Parameters
397401
----------
398402
new_input : Any
399403
The new input to use for the new orchestration instance.
400404
save_events : bool
401405
A flag indicating whether to add any unprocessed external events in the new orchestration history.
406+
Events already delivered to a waiting task are not saved, even if
407+
the orchestrator has not yielded that task.
402408
new_version : str | None
403409
An optional version to assign to the new orchestration instance.
404410
"""

‎durabletask/worker.py‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2852,7 +2852,9 @@ def _cancel_timer() -> None:
28522852
self._logger.info(f"{ctx.instance_id} Event raised: {event_name}")
28532853
task_list = ctx._pending_events.get(event_name, None) # pyright: ignore[reportPrivateUsage]
28542854
decoded_result: Any | None = None
2855-
if task_list:
2855+
# Completed executions leave abandoned waits behind. Buffer
2856+
# trailing events instead, so continue-as-new can carry them over.
2857+
if task_list and not ctx._is_complete: # pyright: ignore[reportPrivateUsage]
28562858
event_task = task_list.pop(0)
28572859
if not ph.is_empty(event.eventRaised.input):
28582860
decoded_result = self._data_converter.deserialize(
@@ -2879,7 +2881,7 @@ def _cancel_timer() -> None:
28792881
ctx._received_event_sequence += 1 # pyright: ignore[reportPrivateUsage]
28802882
if not ctx.is_replaying:
28812883
self._logger.info(
2882-
f"{ctx.instance_id}: Event '{event_name}' has been buffered as there are no tasks waiting for it."
2884+
f"{ctx.instance_id}: Event '{event_name}' has been buffered as there are no active tasks waiting for it."
28832885
)
28842886
elif event.HasField("executionSuspended"):
28852887
if not self._is_suspended and not ctx.is_replaying:

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

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
replace_url_origin,
2121
)
2222
from durabletask import history as dt_history, task as dt_task
23-
from durabletask.client import AsyncTaskHubGrpcClient, OrchestrationStatus
23+
from durabletask.client import AsyncTaskHubGrpcClient, OrchestrationStatus, PurgeInstancesResult
2424
from durabletask.entities import EntityInstanceId
2525
from durabletask.internal import orchestrator_service_pb2 as pb
2626
from durabletask.task import RetryPolicy
@@ -1288,14 +1288,54 @@ async def test_get_status_by_returns_wrapped_list():
12881288
# Return-type shims: PurgeHistoryResult
12891289
# ---------------------------------------------------------------------------
12901290

1291+
@pytest.mark.parametrize("recursive", [None, True, False])
1292+
def test_sync_purge_orchestration_request(recursive: bool | None) -> None:
1293+
client = df.SyncDurableFunctionsClient(_CLIENT_CONFIG)
1294+
stub = Mock()
1295+
stub.PurgeInstances.return_value = pb.PurgeInstancesResponse(deletedInstanceCount=3)
1296+
try:
1297+
with patch.object(client, "_stub", stub):
1298+
if recursive is None:
1299+
result = client.purge_orchestration("abc")
1300+
else:
1301+
result = client.purge_orchestration("abc", recursive=recursive)
1302+
stub.PurgeInstances.assert_called_once_with(pb.PurgeInstancesRequest(
1303+
instanceId="abc", recursive=True if recursive is None else recursive,
1304+
isOrchestration=True))
1305+
assert result == PurgeInstancesResult(deleted_instance_count=3, is_complete=None)
1306+
finally:
1307+
client.close()
1308+
1309+
1310+
@pytest.mark.parametrize("recursive", [None, True, False])
1311+
async def test_async_purge_orchestration_request(recursive: bool | None) -> None:
1312+
client = _make_client()
1313+
stub = Mock()
1314+
stub.PurgeInstances = AsyncMock(return_value=pb.PurgeInstancesResponse(deletedInstanceCount=3))
1315+
try:
1316+
with patch.object(client, "_get_stub", return_value=stub):
1317+
if recursive is None:
1318+
result = await client.purge_orchestration("abc")
1319+
else:
1320+
result = await client.purge_orchestration("abc", recursive=recursive)
1321+
stub.PurgeInstances.assert_awaited_once_with(pb.PurgeInstancesRequest(
1322+
instanceId="abc", recursive=True if recursive is None else recursive,
1323+
isOrchestration=True))
1324+
assert result == PurgeInstancesResult(deleted_instance_count=3, is_complete=None)
1325+
finally:
1326+
await client.close()
1327+
1328+
12911329
async def test_purge_instance_history_returns_purge_history_result():
12921330
client = _make_client()
1331+
stub = Mock()
1332+
stub.PurgeInstances = AsyncMock(return_value=pb.PurgeInstancesResponse(deletedInstanceCount=3))
12931333
try:
1294-
result = SimpleNamespace(deleted_instance_count=3, is_complete=True)
1295-
with patch.object(client, "purge_orchestration",
1296-
new=AsyncMock(return_value=result)):
1334+
with patch.object(client, "_get_stub", return_value=stub):
12971335
with pytest.warns(DeprecationWarning):
12981336
purge = await client.purge_instance_history("abc")
1337+
stub.PurgeInstances.assert_awaited_once_with(pb.PurgeInstancesRequest(
1338+
instanceId="abc", recursive=True, isOrchestration=True))
12991339
assert purge.instances_deleted == 3
13001340
finally:
13011341
await client.close()

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

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -542,6 +542,37 @@ def orchestrator(context):
542542
assert "boom" in completion.failureDetails.errorMessage
543543

544544

545+
@pytest.mark.parametrize("save_events", [True, False])
546+
def test_continue_as_new_preserves_trailing_events_in_worker_response(save_events: bool):
547+
def orchestrator(ctx: task.OrchestrationContext, _):
548+
event_task = ctx.wait_for_external_event("event")
549+
timer = ctx.create_timer(timedelta(seconds=1))
550+
yield task.when_any([event_task, timer])
551+
ctx.continue_as_new(None, save_events=save_events)
552+
553+
started_at = datetime(2026, 1, 1)
554+
fire_at = started_at + timedelta(seconds=1)
555+
request = pb.OrchestratorRequest(instanceId=TEST_INSTANCE_ID)
556+
request.pastEvents.extend([
557+
helpers.new_orchestrator_started_event(started_at),
558+
helpers.new_execution_started_event("continue-events", TEST_INSTANCE_ID),
559+
helpers.new_timer_created_event(1, fire_at),
560+
])
561+
request.newEvents.extend([
562+
helpers.new_timer_fired_event(1, fire_at),
563+
helpers.new_event_raised_event("event", "1"),
564+
helpers.new_event_raised_event("event", "2"),
565+
])
566+
encoded = base64.b64encode(request.SerializeToString()).decode("utf-8")
567+
response = _decode_orchestrator_response(
568+
DurableFunctionsWorker().execute_orchestration_request(orchestrator, encoded))
569+
completion = _get_completion_action(response)
570+
571+
assert completion.orchestrationStatus == pb.ORCHESTRATION_STATUS_CONTINUED_AS_NEW
572+
assert [event.eventRaised.input.value for event in completion.carryoverEvents] == (
573+
["1", "2"] if save_events else [])
574+
575+
545576
def test_activity_retry_then_fan_out_uses_distinct_task_ids():
546577
"""Regression test for Azure/azure-functions-durable-python#603."""
547578
def orchestrator(context):
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT License.
3+
4+
from unittest.mock import AsyncMock, MagicMock, patch
5+
6+
import pytest
7+
8+
from durabletask.azuremanaged.client import (
9+
AsyncDurableTaskSchedulerClient,
10+
DurableTaskSchedulerClient,
11+
)
12+
from durabletask.client import PurgeInstancesResult
13+
from durabletask.internal import orchestrator_service_pb2 as pb
14+
15+
16+
@pytest.mark.parametrize("recursive", [None, True, False])
17+
def test_dts_purge_orchestration_request(recursive: bool | None) -> None:
18+
stub = MagicMock()
19+
stub.PurgeInstances.return_value = pb.PurgeInstancesResponse(deletedInstanceCount=3)
20+
21+
with patch("durabletask.client.stubs.TaskHubSidecarServiceStub", return_value=stub):
22+
with DurableTaskSchedulerClient(
23+
host_address="localhost:4001", taskhub="hub",
24+
token_credential=None, channel=MagicMock()) as client:
25+
if recursive is None:
26+
result = client.purge_orchestration("instance")
27+
else:
28+
result = client.purge_orchestration("instance", recursive=recursive)
29+
30+
stub.PurgeInstances.assert_called_once_with(pb.PurgeInstancesRequest(
31+
instanceId="instance", recursive=True if recursive is None else recursive,
32+
isOrchestration=True))
33+
assert result == PurgeInstancesResult(deleted_instance_count=3, is_complete=None)
34+
35+
36+
@pytest.mark.asyncio
37+
@pytest.mark.parametrize("recursive", [None, True, False])
38+
async def test_async_dts_purge_orchestration_request(recursive: bool | None) -> None:
39+
stub = MagicMock()
40+
stub.PurgeInstances = AsyncMock(return_value=pb.PurgeInstancesResponse(deletedInstanceCount=3))
41+
42+
with patch("durabletask.client.stubs.TaskHubSidecarServiceStub", return_value=stub):
43+
async with AsyncDurableTaskSchedulerClient(
44+
host_address="localhost:4001", taskhub="hub",
45+
token_credential=None, channel=MagicMock()) as client:
46+
if recursive is None:
47+
result = await client.purge_orchestration("instance")
48+
else:
49+
result = await client.purge_orchestration("instance", recursive=recursive)
50+
51+
stub.PurgeInstances.assert_awaited_once_with(pb.PurgeInstancesRequest(
52+
instanceId="instance", recursive=True if recursive is None else recursive,
53+
isOrchestration=True))
54+
assert result == PurgeInstancesResult(deleted_instance_count=3, is_complete=None)

0 commit comments

Comments
 (0)