From b346d3fb5e815c9c3842fe80891f674b19282cb7 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Tue, 3 Nov 2026 20:34:46 -0600 Subject: [PATCH] Scope redriven signal waits and search-attribute types out for Python The SDK cannot author a Core signal wait, so reused signal-wait events replay for history validity only. A new test proves a cold replay applies the recorded signal once and opens no wait again, with a control that opens the wait when the signal is absent. The CHANGELOG states that this does not qualify Python workers for Core signal waits, and that recorded attribute_types are not compared with authored types. Refs #235. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- tests/test_replay_clock.py | 24 +++++++++++++++++++++++- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2936b414..2aca2814 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Replay of Workflow 2.19 redrive reuse is qualified for `MemoUpserted` and for `SearchAttributesUpserted` value identity. Neither event is a replay clock source: `ctx.now()` stays the persisted start time, which on a redriven run is the failed run's start, and the cancellation budget does not move on an upsert. Search-attribute type identity is not qualified, because the Server records namespace-registered types the SDK cannot see. Signal waits (`SignalWaitOpened`, `SignalReceived`, `SignalApplied`) are not qualified for Python: the SDK has no signal-wait primitive. Replaying such reused events through a signal handler applies the signal once and takes no time from them, but that does not qualify signal waits. (#235) +- Replay of Workflow 2.19 redrive reuse is qualified for `MemoUpserted` and for `SearchAttributesUpserted` value identity. Neither event is a replay clock source: `ctx.now()` stays the persisted start time, which on a redriven run is the failed run's start, and the cancellation budget does not move on an upsert. Search-attribute type identity is not qualified. The SDK's upsert declares no `attribute_types`, and the Server records namespace-registered types the SDK cannot see, so recorded types are not compared with authored ones. They are only checked to be well formed and able to hold the recorded value. Signal waits (`SignalWaitOpened`, `SignalReceived`, `SignalApplied`) are scoped out for Python, because the SDK cannot author a Core signal wait. Reused signal-wait events replay for history validity only: a cold replay applies the recorded signal once, takes no time from these events and opens no wait again. This does not qualify Python workers for Core signal waits. (#235) ## [2.13.1] - 2026-11-03 diff --git a/tests/test_replay_clock.py b/tests/test_replay_clock.py index a991e606..b2a36da3 100644 --- a/tests/test_replay_clock.py +++ b/tests/test_replay_clock.py @@ -11,7 +11,7 @@ from durable_workflow import serializer, workflow from durable_workflow.errors import NonDeterministicReplayError, WorkflowCancelled -from durable_workflow.workflow import CompleteWorkflow, WorkflowContext, query_state, replay +from durable_workflow.workflow import CompleteWorkflow, WaitCondition, WorkflowContext, query_state, replay from tests.redrive_fixture_registry import VALID_SUCCESSORS @@ -344,6 +344,28 @@ def test_redriven_signal_wait_replays_at_the_failed_run_start_and_applies_once() assert serializer.decode(received["arguments"], codec="avro") == ["Grace"] +def test_a_cold_worker_replays_reused_signal_wait_events_without_reopening_the_wait() -> None: + # #235 scope: the SDK cannot author a Core signal wait, so these reused + # events replay for history validity only. A cold replay applies the + # recorded signal once and opens no wait again. This does not qualify + # Python workers for Core signal waits: the counterpart's next call is + # numbered from 1, while Core's wait occupies step 1. + history = redriven("redrive-reused-signal-wait-clock") + for _ in range(2): + RedriveSignalWorkflow.applied = [] + commands = replay_redriven(RedriveSignalWorkflow, history) + assert not any(isinstance(command, WaitCondition) for command in commands) + assert RedriveSignalWorkflow.applied == ["Grace"] + # Control: without the recorded signal the same replay opens the wait. + unsignalled = [deepcopy(event) for event in history + if event["event_type"] not in ("SignalReceived", "SignalApplied")] + for number, event in enumerate(unsignalled, start=1): + event["sequence"] = number + RedriveSignalWorkflow.applied = [] + assert [type(command) for command in replay_redriven(RedriveSignalWorkflow, unsignalled)] == [WaitCondition] + assert RedriveSignalWorkflow.applied == [] + + def _retimed(history: list[dict[str, Any]], event_type: str, mode: str) -> list[dict[str, Any]]: changed = deepcopy(history) event = only_event(changed, event_type)