Skip to content

Commit 0feba8f

Browse files
Preserve workflow memo upserts when continue-as-new is yielded
1 parent e979ef0 commit 0feba8f

6 files changed

Lines changed: 250 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,9 @@ Earlier SDK versions remain historical releases and are not alternate supported
8989
`NamespaceDescription.deleted`.
9090

9191
### Fixed
92+
- Yielded continue-as-new commands now preserve pending memo and search-
93+
attribute upserts in the same order as returned continuations, while replayed
94+
metadata updates remain consumed exactly once.
9295
- High-level workers now explicitly advertise memo-upsert and typed-search-
9396
attribute command support when registering with Server. Worker requests use
9497
protocol 1.16, while direct `Client.register_worker()` callers retain full

src/durable_workflow/workflow.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4168,7 +4168,7 @@ def _terminal_state(value: Any, *, include_pending: bool) -> _ReplayState:
41684168
pending.extend(cmd)
41694169
return _state(pending)
41704170
if isinstance(cmd, ContinueAsNew):
4171-
return _state([cmd])
4171+
return _terminal_state(cmd, include_pending=True)
41724172
if isinstance(cmd, NexusServiceCall):
41734173
if result_cursor < len(resolved_results):
41744174
_assert_next_step_matches(cmd)
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
{
2+
"$schema": "https://raw.githubusercontent.com/durable-workflow/.github/main/regression-corpus/evidence-schema.json",
3+
"fixture_schema": "durable-workflow.replay-regression/v1",
4+
"id": "yielded-continue-preserves-pending-metadata",
5+
"protocol_version": "1.16",
6+
"bindings": [
7+
"python"
8+
],
9+
"workflow": {
10+
"type": "tests.replay.yielded-continue-after-metadata",
11+
"input": [],
12+
"payload_codec": "avro"
13+
},
14+
"command_sequence": [
15+
{
16+
"type": "upsert_search_attributes",
17+
"attributes": {
18+
"stage": "continued"
19+
}
20+
},
21+
{
22+
"type": "upsert_memo",
23+
"entries": {
24+
"added": "from-upsert",
25+
"overwritten": "after"
26+
}
27+
},
28+
{
29+
"type": "continue_as_new",
30+
"arguments": [
31+
"successor"
32+
]
33+
}
34+
],
35+
"expected": {
36+
"command_sequence": [
37+
{
38+
"type": "upsert_search_attributes",
39+
"attributes": {
40+
"stage": "continued"
41+
}
42+
},
43+
{
44+
"type": "upsert_memo",
45+
"entries": {
46+
"added": "from-upsert",
47+
"overwritten": "after"
48+
}
49+
},
50+
{
51+
"type": "continue_as_new",
52+
"arguments": [
53+
"successor"
54+
]
55+
}
56+
]
57+
}
58+
}

tests/integration/test_memo_restart.py

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import pytest
99

1010
from durable_workflow import Client, Worker, serializer, workflow
11+
from durable_workflow.workflow import commands_to_server_commands, replay
1112

1213
MEMO_ENTRIES: dict[str, Any] = {
1314
"binary": b"same",
@@ -26,6 +27,22 @@
2627
"CGxvbmcEDgxuZXN0ZWQOBAphbHBoYQQCCGJldGEEBAAIdGV4dAoIc2FtZQA="
2728
)
2829

30+
CONTINUE_INITIAL_MEMO = {
31+
"existing": "retained",
32+
"overwritten": "before",
33+
}
34+
35+
CONTINUE_MEMO_PATCH = {
36+
"added": "from-upsert",
37+
"overwritten": "after",
38+
}
39+
40+
CONTINUE_MERGED_MEMO = {
41+
"added": "from-upsert",
42+
"existing": "retained",
43+
"overwritten": "after",
44+
}
45+
2946

3047
@workflow.defn(name="tests.memo-restart-python")
3148
class MemoRestartWorkflow:
@@ -46,6 +63,15 @@ def run(self, ctx): # type: ignore[no-untyped-def]
4663
return "python-replayed-memo"
4764

4865

66+
@workflow.defn(name="tests.memo-yielded-continue-python")
67+
class MemoYieldedContinueWorkflow:
68+
def run(self, ctx, generation: int): # type: ignore[no-untyped-def]
69+
if generation == 0:
70+
yield ctx.upsert_memo(CONTINUE_MEMO_PATCH)
71+
yield ctx.continue_as_new(1)
72+
return "python-continued-with-memo"
73+
74+
4975
async def _wait_until_waiting_with_memo(handle: Any) -> Any:
5076
deadline = asyncio.get_running_loop().time() + 30
5177
last_description = None
@@ -94,6 +120,7 @@ async def _seed_worker_contract(client: Client, worker: Worker) -> None:
94120
workflow_definition_fingerprints=worker.workflow_definition_fingerprints,
95121
workflow_command_contracts=worker.workflow_command_contracts,
96122
supported_activity_types=[],
123+
capabilities=["memo_upserts"],
97124
)
98125

99126

@@ -175,3 +202,105 @@ async def test_fresh_python_worker_replays_persisted_server_memo_history(
175202
final_memo_events = _memo_events(final_history)
176203
assert len(final_memo_events) == 1
177204
_assert_typed_memo_event(final_memo_events[0])
205+
206+
207+
@pytest.mark.asyncio
208+
async def test_yielded_continue_inherits_merged_memo_after_worker_restart(
209+
server_url: str,
210+
server_token: str,
211+
) -> None:
212+
suffix = uuid.uuid4().hex[:8]
213+
task_queue = f"memo-continue-python-{suffix}"
214+
workflow_id = f"memo-continue-python-{suffix}"
215+
216+
async with Client(server_url, token=server_token, namespace="default") as client:
217+
first_worker = Worker(
218+
client,
219+
task_queue=task_queue,
220+
workflows=[MemoYieldedContinueWorkflow],
221+
activities=[],
222+
worker_id=f"memo-continue-before-{suffix}",
223+
)
224+
await _seed_worker_contract(client, first_worker)
225+
226+
handle = await client.start_workflow(
227+
workflow_type="tests.memo-yielded-continue-python",
228+
task_queue=task_queue,
229+
workflow_id=workflow_id,
230+
input=[0],
231+
memo=CONTINUE_INITIAL_MEMO,
232+
)
233+
assert handle.run_id is not None
234+
first_run_id = handle.run_id
235+
236+
try:
237+
first_task = await client.poll_workflow_task(
238+
worker_id=first_worker.worker_id,
239+
task_queue=task_queue,
240+
timeout=10.0,
241+
)
242+
assert first_task is not None, "expected the initial workflow task"
243+
244+
payload_codec = first_task.get("payload_codec") or serializer.AVRO_CODEC
245+
decoded = serializer.decode_envelope(
246+
first_task.get("arguments"),
247+
codec=payload_codec,
248+
)
249+
start_input = decoded if isinstance(decoded, list) else [decoded]
250+
outcome = replay(
251+
MemoYieldedContinueWorkflow,
252+
first_task.get("history_events", []),
253+
start_input,
254+
run_id=first_task.get("run_id", ""),
255+
)
256+
commands = commands_to_server_commands(
257+
outcome.commands,
258+
task_queue,
259+
payload_codec=payload_codec,
260+
)
261+
assert [command["type"] for command in commands] == [
262+
"upsert_memo",
263+
"continue_as_new",
264+
]
265+
266+
await client.complete_workflow_task(
267+
task_id=first_task["task_id"],
268+
lease_owner=first_worker.worker_id,
269+
workflow_task_attempt=first_task.get("workflow_task_attempt", 1),
270+
commands=commands,
271+
)
272+
finally:
273+
await client.deregister_worker_registration(first_worker.worker_id)
274+
275+
replacement_worker = Worker(
276+
client,
277+
task_queue=task_queue,
278+
workflows=[MemoYieldedContinueWorkflow],
279+
activities=[],
280+
worker_id=f"memo-continue-after-{suffix}",
281+
poll_timeout=1.0,
282+
shutdown_timeout=5.0,
283+
)
284+
completed = await replacement_worker.run_until(
285+
workflow_id=workflow_id,
286+
timeout=30.0,
287+
poll_interval=0.1,
288+
)
289+
290+
assert (completed.status or "").lower() == "completed"
291+
assert completed.output == "python-continued-with-memo"
292+
assert completed.run_id != first_run_id
293+
assert completed.memo == CONTINUE_MERGED_MEMO
294+
295+
runs = await handle.list_runs()
296+
assert runs.run_count == 2
297+
assert [run.run_id for run in runs.runs] == [first_run_id, completed.run_id]
298+
assert runs.runs[1].memo == CONTINUE_MERGED_MEMO
299+
300+
first_history = await client.get_history(workflow_id, first_run_id)
301+
successor_history = await client.get_history(workflow_id, completed.run_id or "")
302+
first_memo_events = _memo_events(first_history)
303+
assert len(first_memo_events) == 1
304+
assert serializer.decode_envelope(first_memo_events[0]["payload"]["entries"]) == CONTINUE_MEMO_PATCH
305+
assert serializer.decode_envelope(first_memo_events[0]["payload"]["merged"]) == CONTINUE_MERGED_MEMO
306+
assert _memo_events(successor_history) == []

tests/test_replay.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1003,6 +1003,22 @@ def run(self, ctx: WorkflowContext, counter: int): # type: ignore[no-untyped-de
10031003
return ctx.continue_as_new(counter - 1)
10041004

10051005

1006+
@workflow.defn(name="continue-as-new-after-metadata-return-wf")
1007+
class ContinueAsNewAfterMetadataReturnWorkflow:
1008+
def run(self, ctx: WorkflowContext): # type: ignore[no-untyped-def]
1009+
yield ctx.upsert_search_attributes({"stage": "continued"})
1010+
yield ctx.upsert_memo({"added": "from-upsert", "overwritten": "after"})
1011+
return ctx.continue_as_new("successor")
1012+
1013+
1014+
@workflow.defn(name="continue-as-new-after-metadata-yield-wf")
1015+
class ContinueAsNewAfterMetadataYieldWorkflow:
1016+
def run(self, ctx: WorkflowContext): # type: ignore[no-untyped-def]
1017+
yield ctx.upsert_search_attributes({"stage": "continued"})
1018+
yield ctx.upsert_memo({"added": "from-upsert", "overwritten": "after"})
1019+
yield ctx.continue_as_new("successor")
1020+
1021+
10061022
@workflow.defn(name="side-effect-wf")
10071023
class SideEffectWorkflow:
10081024
def run(self, ctx: WorkflowContext): # type: ignore[no-untyped-def]
@@ -1042,6 +1058,40 @@ def test_generator_return_continue(self) -> None:
10421058
assert isinstance(cmd, ContinueAsNew)
10431059
assert cmd.arguments == [4]
10441060

1061+
@pytest.mark.parametrize(
1062+
"workflow_cls",
1063+
[
1064+
ContinueAsNewAfterMetadataReturnWorkflow,
1065+
ContinueAsNewAfterMetadataYieldWorkflow,
1066+
],
1067+
)
1068+
def test_pending_metadata_precedes_every_continue_authoring_form(self, workflow_cls: type) -> None:
1069+
first = replay(workflow_cls, [], [])
1070+
1071+
assert [command.to_server_command("q")["type"] for command in first.commands] == [
1072+
"upsert_search_attributes",
1073+
"upsert_memo",
1074+
"continue_as_new",
1075+
]
1076+
1077+
entries = _avro({"added": "from-upsert", "overwritten": "after"})
1078+
merged = _avro({"added": "from-upsert", "overwritten": "after"})
1079+
replayed = replay(
1080+
workflow_cls,
1081+
[
1082+
{"event_type": "SearchAttributesUpserted", "payload": {"sequence": 1}},
1083+
{
1084+
"event_type": "MemoUpserted",
1085+
"payload": {"sequence": 2, "entries": entries, "merged": merged},
1086+
},
1087+
],
1088+
[],
1089+
)
1090+
1091+
assert len(replayed.commands) == 1
1092+
assert isinstance(replayed.commands[0], ContinueAsNew)
1093+
assert replayed.commands[0].arguments == ["successor"]
1094+
10451095
def test_server_command_shape(self) -> None:
10461096
cmd = ContinueAsNew(workflow_type="other", arguments=[1, 2], task_queue="q2")
10471097
sc = cmd.to_server_command("default-q")

tests/test_replay_regression_corpus.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,14 @@ def run(self, ctx: WorkflowContext): # type: ignore[no-untyped-def]
101101
return "memo-replayed"
102102

103103

104+
@workflow.defn(name="tests.replay.yielded-continue-after-metadata")
105+
class YieldedContinueAfterMetadataWorkflow:
106+
def run(self, ctx: WorkflowContext): # type: ignore[no-untyped-def]
107+
yield ctx.upsert_search_attributes({"stage": "continued"})
108+
yield ctx.upsert_memo({"added": "from-upsert", "overwritten": "after"})
109+
yield ctx.continue_as_new("successor")
110+
111+
104112
WORKFLOWS = [
105113
GoldenSagaCompensationWorkflow,
106114
GoldenSignalWaitWorkflow,
@@ -114,6 +122,7 @@ def run(self, ctx: WorkflowContext): # type: ignore[no-untyped-def]
114122
UpdateSignalConditionTimerWorkflow,
115123
WorkflowStreamAuthorWorkflow,
116124
WorkflowMemoAuthorWorkflow,
125+
YieldedContinueAfterMetadataWorkflow,
117126
]
118127
WORKFLOW_TYPES = {str(getattr(workflow, "__workflow_name__", workflow.__name__)): workflow for workflow in WORKFLOWS}
119128

0 commit comments

Comments
 (0)