Skip to content

Commit 785c313

Browse files
authored
Merge branch 'main' into andystaples-correct-orchestration-purge-discriminato
2 parents 1ed0f4c + f40ad56 commit 785c313

5 files changed

Lines changed: 177 additions & 10 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ FIXED
1111

1212
- Single-instance `purge_orchestration()` requests now explicitly target
1313
orchestrations rather than entities in both synchronous and asynchronous clients.
14+
- `continue_as_new(..., save_events=True)` now preserves the global arrival
15+
order of unconsumed buffered external events across different event names,
16+
instead of grouping carryover events by name.
1417

1518
## v1.11.0
1619

‎azure-functions-durable/CHANGELOG.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ FIXED
1313
`purge_orchestration()` requests from `DurableFunctionsClient` and
1414
`SyncDurableFunctionsClient` now explicitly target orchestrations rather than
1515
entities. This also applies to the `purge_instance_history()` compatibility alias.
16+
- With the corresponding core `durabletask` SDK fix,
17+
`continue_as_new(..., save_events=True)` preserves the global arrival order
18+
of unconsumed buffered external events across different event names.
1619

1720
## v2.0.0rc2
1821

‎durabletask-azuremanaged/CHANGELOG.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ FIXED
1212
- With the corresponding core `durabletask` SDK fix, single-instance
1313
`purge_orchestration()` requests from synchronous and asynchronous Azure Managed
1414
clients now explicitly target orchestrations rather than entities.
15+
- With the corresponding core `durabletask` SDK fix,
16+
`continue_as_new(..., save_events=True)` preserves the global arrival order
17+
of unconsumed buffered external events across different event names.
1518

1619
## v1.11.0
1720

‎durabletask/worker.py‎

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1534,7 +1534,9 @@ def __init__(self,
15341534
self._version: str | None = None
15351535
self._parent_instance_id: str | None = None
15361536
self._completion_status: pb.OrchestrationStatus | None = None
1537-
self._received_events: dict[str, list[str | None]] = {}
1537+
# Keep arrival indexes separate from task IDs and retain raw payloads.
1538+
self._received_events: dict[str, list[tuple[int, str | None]]] = {}
1539+
self._received_event_sequence = 0
15381540
self._pending_events: dict[str, list[task.CancellableTask[Any]]] = {}
15391541
self._new_input: Any | None = None
15401542
self._new_version: str | None = None
@@ -1674,13 +1676,16 @@ def get_actions(self) -> list[pb.OrchestratorAction]:
16741676
carryover_events = []
16751677
# We need to save the current set of pending events so that they can be
16761678
# replayed when the new instance starts.
1677-
for event_name, values in self._received_events.items():
1678-
for event_value in values:
1679-
# Buffered events are stored as their raw JSON payload
1680-
# (or None), so carry them over as-is without re-encoding.
1681-
carryover_events.append(
1682-
ph.new_event_raised_event(event_name, event_value)
1683-
)
1679+
buffered_events = sorted(
1680+
(index, event_name, value)
1681+
for event_name, values in self._received_events.items()
1682+
for index, value in values
1683+
)
1684+
for _, event_name, event_value in buffered_events:
1685+
# Carry over raw JSON payloads (or None) without re-encoding.
1686+
carryover_events.append(
1687+
ph.new_event_raised_event(event_name, event_value)
1688+
)
16841689
action = ph.new_complete_orchestration_action(
16851690
self.next_sequence_number(),
16861691
pb.ORCHESTRATION_STATUS_CONTINUED_AS_NEW,
@@ -2109,7 +2114,7 @@ def wait_for_external_event(self, name: str, *,
21092114
event_name = name.casefold()
21102115
event_list = self._received_events.get(event_name, None)
21112116
if event_list:
2112-
event_data = event_list.pop(0)
2117+
_, event_data = event_list.pop(0)
21132118
if not event_list:
21142119
del self._received_events[event_name]
21152120
external_event_task.complete(self._data_converter.deserialize(event_data, data_type))
@@ -2866,7 +2871,8 @@ def _cancel_timer() -> None:
28662871
buffered_payload: str | None = None
28672872
if not ph.is_empty(event.eventRaised.input):
28682873
buffered_payload = event.eventRaised.input.value
2869-
event_list.append(buffered_payload)
2874+
event_list.append((ctx._received_event_sequence, buffered_payload)) # pyright: ignore[reportPrivateUsage]
2875+
ctx._received_event_sequence += 1 # pyright: ignore[reportPrivateUsage]
28702876
if not ctx.is_replaying:
28712877
self._logger.info(
28722878
f"{ctx.instance_id}: Event '{event_name}' has been buffered as there are no tasks waiting for it."

‎tests/durabletask/test_orchestration_executor.py‎

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1857,6 +1857,158 @@ def orchestrator(ctx: task.OrchestrationContext, input: int):
18571857
assert event.eventRaised.input.value == json.dumps(42 + i)
18581858

18591859

1860+
@pytest.mark.parametrize("save_events", [True, False])
1861+
@pytest.mark.parametrize("replayed_events", [0, 1, 3])
1862+
@pytest.mark.parametrize("timer_fires_first", [True, False])
1863+
def test_continue_as_new_preserves_global_event_order(
1864+
save_events: bool, replayed_events: int, timer_fires_first: bool):
1865+
def orchestrator(ctx: task.OrchestrationContext, _):
1866+
yield ctx.create_timer(ctx.current_utc_datetime + timedelta(seconds=1))
1867+
ctx.continue_as_new(None, save_events=save_events)
1868+
1869+
registry = worker._Registry()
1870+
name = registry.add_orchestrator(orchestrator)
1871+
start_time = datetime(2026, 1, 1)
1872+
fire_at = start_time + timedelta(seconds=1)
1873+
raised_events = [
1874+
helpers.new_event_raised_event("A", "1"),
1875+
helpers.new_event_raised_event("b", "2"),
1876+
helpers.new_event_raised_event("a", "3"),
1877+
]
1878+
old_events = [
1879+
helpers.new_orchestrator_started_event(start_time),
1880+
helpers.new_execution_started_event(name, TEST_INSTANCE_ID),
1881+
helpers.new_timer_created_event(1, fire_at),
1882+
*raised_events[:replayed_events],
1883+
]
1884+
timer_fired = helpers.new_timer_fired_event(1, fire_at)
1885+
new_events = raised_events[replayed_events:]
1886+
new_events.insert(0 if timer_fires_first else len(new_events), timer_fired)
1887+
1888+
executor = worker._OrchestrationExecutor(registry, TEST_LOGGER, JsonDataConverter())
1889+
result = executor.execute(TEST_INSTANCE_ID, old_events, new_events)
1890+
1891+
complete_action = get_and_validate_complete_orchestration_action_list(1, result.actions)
1892+
assert complete_action.orchestrationStatus == pb.ORCHESTRATION_STATUS_CONTINUED_AS_NEW
1893+
assert result.actions[0].id == 2
1894+
expected = [("a", "1"), ("b", "2"), ("a", "3")] if save_events else []
1895+
assert [
1896+
(event.eventRaised.name, event.eventRaised.input.value)
1897+
for event in complete_action.carryoverEvents
1898+
] == expected
1899+
1900+
1901+
@pytest.mark.parametrize("replay_consumption", [True, False])
1902+
def test_continue_as_new_preserves_order_after_selective_event_consumption(
1903+
replay_consumption: bool):
1904+
def orchestrator(ctx: task.OrchestrationContext, _):
1905+
live_result = yield ctx.wait_for_external_event("LIVE")
1906+
cancelled_wait = ctx.wait_for_external_event("C")
1907+
cancelled_wait.cancel()
1908+
yield ctx.create_timer(ctx.current_utc_datetime + timedelta(seconds=1))
1909+
first = yield ctx.wait_for_external_event("a", data_type=int)
1910+
second = yield ctx.wait_for_external_event("A", data_type=int)
1911+
third = yield ctx.wait_for_external_event("c", data_type=int)
1912+
yield ctx.create_timer(ctx.current_utc_datetime + timedelta(seconds=2))
1913+
ctx.continue_as_new([live_result, first, second, third], save_events=True)
1914+
1915+
registry = worker._Registry()
1916+
name = registry.add_orchestrator(orchestrator)
1917+
start_time = datetime(2026, 1, 1)
1918+
first_fire_at = start_time + timedelta(seconds=1)
1919+
second_fire_at = start_time + timedelta(seconds=2)
1920+
history = [
1921+
helpers.new_orchestrator_started_event(start_time),
1922+
helpers.new_execution_started_event(name, TEST_INSTANCE_ID),
1923+
helpers.new_event_raised_event("live", "0"),
1924+
helpers.new_timer_created_event(1, first_fire_at),
1925+
helpers.new_event_raised_event("A", "1"),
1926+
helpers.new_event_raised_event("b", "2"),
1927+
helpers.new_event_raised_event("a", "3"),
1928+
helpers.new_event_raised_event("C", "4"),
1929+
helpers.new_event_raised_event("B", "5"),
1930+
helpers.new_event_raised_event("A", "6"),
1931+
helpers.new_timer_fired_event(1, first_fire_at),
1932+
helpers.new_timer_created_event(2, second_fire_at),
1933+
]
1934+
trailing_events = [
1935+
helpers.new_event_raised_event("c", "7"),
1936+
helpers.new_event_raised_event("a", "8"),
1937+
helpers.new_event_raised_event("b", "9"),
1938+
helpers.new_timer_fired_event(2, second_fire_at),
1939+
]
1940+
old_events = history if replay_consumption else []
1941+
new_events = trailing_events if replay_consumption else history + trailing_events
1942+
executor = worker._OrchestrationExecutor(registry, TEST_LOGGER, JsonDataConverter())
1943+
result = executor.execute(TEST_INSTANCE_ID, old_events, new_events)
1944+
1945+
complete_action = get_and_validate_complete_orchestration_action_list(1, result.actions)
1946+
assert complete_action.orchestrationStatus == pb.ORCHESTRATION_STATUS_CONTINUED_AS_NEW
1947+
assert result.actions[0].id == 3
1948+
assert json.loads(complete_action.result.value) == [0, 1, 3, 4]
1949+
assert [
1950+
(event.eventRaised.name, event.eventRaised.input.value)
1951+
for event in complete_action.carryoverEvents
1952+
] == [("b", "2"), ("b", "5"), ("a", "6"), ("c", "7"), ("a", "8"), ("b", "9")]
1953+
1954+
1955+
@pytest.mark.parametrize("consumed_payload", [None, "null", "23"])
1956+
def test_continue_as_new_preserves_order_and_raw_event_payloads(consumed_payload: str | None):
1957+
class RecordingConverter(JsonDataConverter):
1958+
def __init__(self):
1959+
self.serialized: list[Any] = []
1960+
self.deserialized: list[tuple[str | None, type | None]] = []
1961+
1962+
def serialize(self, value: Any) -> str | None:
1963+
self.serialized.append(value)
1964+
return None if value is None else json.dumps({"wrapped": value})
1965+
1966+
def deserialize(self, data: str | None, target_type: type | None = None) -> Any:
1967+
self.deserialized.append((data, target_type))
1968+
return super().deserialize(data, target_type)
1969+
1970+
def orchestrator(ctx: task.OrchestrationContext, _):
1971+
yield ctx.create_timer(ctx.current_utc_datetime + timedelta(seconds=1))
1972+
consumed = yield ctx.wait_for_external_event("Consume", data_type=int)
1973+
ctx.continue_as_new(consumed, save_events=True)
1974+
1975+
registry = worker._Registry()
1976+
name = registry.add_orchestrator(orchestrator)
1977+
start_time = datetime(2026, 1, 1)
1978+
fire_at = start_time + timedelta(seconds=1)
1979+
expected = [
1980+
("a", '{ "value" : [1, 2] }'),
1981+
("b", None),
1982+
("a", "null"),
1983+
("b", '"hello\\u0020world"'),
1984+
("a", '{ "value" : [1, 2] }'),
1985+
("c", "false"),
1986+
("b", "0"),
1987+
("a", '""'),
1988+
]
1989+
old_events = [
1990+
helpers.new_orchestrator_started_event(start_time),
1991+
helpers.new_execution_started_event(name, TEST_INSTANCE_ID),
1992+
helpers.new_timer_created_event(1, fire_at),
1993+
helpers.new_event_raised_event("consume", consumed_payload),
1994+
*[helpers.new_event_raised_event(name, payload) for name, payload in expected],
1995+
]
1996+
converter = RecordingConverter()
1997+
executor = worker._OrchestrationExecutor(registry, TEST_LOGGER, converter)
1998+
result = executor.execute(
1999+
TEST_INSTANCE_ID, old_events, [helpers.new_timer_fired_event(1, fire_at)])
2000+
2001+
complete_action = get_and_validate_complete_orchestration_action_list(1, result.actions)
2002+
assert complete_action.orchestrationStatus == pb.ORCHESTRATION_STATUS_CONTINUED_AS_NEW
2003+
assert [
2004+
(event.eventRaised.name,
2005+
event.eventRaised.input.value if event.eventRaised.HasField("input") else None)
2006+
for event in complete_action.carryoverEvents
2007+
] == expected
2008+
assert converter.deserialized == [(consumed_payload, int)]
2009+
assert converter.serialized == [json.loads(consumed_payload) if consumed_payload else None]
2010+
2011+
18602012
def test_fan_out():
18612013
"""Tests that a fan-out pattern correctly schedules N tasks"""
18622014
def hello(_, name: str):

0 commit comments

Comments
 (0)