durable-workflow 2.4.0__tar.gz → 2.4.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. {durable_workflow-2.4.0/src/durable_workflow.egg-info → durable_workflow-2.4.1}/PKG-INFO +1 -1
  2. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/pyproject.toml +3 -3
  3. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/src/durable_workflow/workflow.py +13 -0
  4. {durable_workflow-2.4.0 → durable_workflow-2.4.1/src/durable_workflow.egg-info}/PKG-INFO +1 -1
  5. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/src/durable_workflow.egg-info/SOURCES.txt +1 -0
  6. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/tests/test_child_workflow_policies.py +3 -4
  7. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/tests/test_committed_cancellation_scope_descendants.py +2 -1
  8. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/tests/test_committed_cancellation_scope_groups.py +2 -1
  9. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/tests/test_committed_cancellation_scope_replay.py +4 -1
  10. durable_workflow-2.4.1/tests/test_pending_operation_replay.py +154 -0
  11. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/tests/test_replay_regression_corpus.py +2 -0
  12. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/tests/test_update_signal_condition_replay.py +10 -7
  13. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/LICENSE +0 -0
  14. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/README.md +0 -0
  15. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/setup.cfg +0 -0
  16. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/src/durable_workflow/__init__.py +0 -0
  17. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/src/durable_workflow/_activity_process.py +0 -0
  18. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/src/durable_workflow/_avro.py +0 -0
  19. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/src/durable_workflow/_cancellation_scope.py +0 -0
  20. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/src/durable_workflow/_cancellation_scope_history.py +0 -0
  21. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/src/durable_workflow/_cooperative_cancellation.py +0 -0
  22. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/src/durable_workflow/_prepared_local_activity.py +0 -0
  23. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/src/durable_workflow/activity.py +0 -0
  24. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/src/durable_workflow/auth_composition.py +0 -0
  25. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/src/durable_workflow/cancellation.py +0 -0
  26. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/src/durable_workflow/client.py +0 -0
  27. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/src/durable_workflow/errors.py +0 -0
  28. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/src/durable_workflow/external_storage.py +0 -0
  29. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/src/durable_workflow/external_task_input.py +0 -0
  30. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/src/durable_workflow/external_task_result.py +0 -0
  31. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/src/durable_workflow/history_bundle_verify.py +0 -0
  32. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/src/durable_workflow/interceptors.py +0 -0
  33. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/src/durable_workflow/invocable.py +0 -0
  34. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/src/durable_workflow/metrics.py +0 -0
  35. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/src/durable_workflow/nexus.py +0 -0
  36. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/src/durable_workflow/py.typed +0 -0
  37. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/src/durable_workflow/python_conformance.py +0 -0
  38. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/src/durable_workflow/replay_conformance.py +0 -0
  39. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/src/durable_workflow/replay_verify.py +0 -0
  40. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/src/durable_workflow/retry_policy.py +0 -0
  41. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/src/durable_workflow/serializer.py +0 -0
  42. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/src/durable_workflow/sync.py +0 -0
  43. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/src/durable_workflow/testing.py +0 -0
  44. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/src/durable_workflow/worker.py +0 -0
  45. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/src/durable_workflow/worker_session.py +0 -0
  46. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/src/durable_workflow/workflow_updates_conformance.py +0 -0
  47. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/src/durable_workflow.egg-info/dependency_links.txt +0 -0
  48. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/src/durable_workflow.egg-info/entry_points.txt +0 -0
  49. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/src/durable_workflow.egg-info/requires.txt +0 -0
  50. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/src/durable_workflow.egg-info/top_level.txt +0 -0
  51. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/tests/test_activity_cancellation_policies.py +0 -0
  52. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/tests/test_activity_context.py +0 -0
  53. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/tests/test_activity_process.py +0 -0
  54. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/tests/test_auth_composition.py +0 -0
  55. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/tests/test_avro_value_benchmark.py +0 -0
  56. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/tests/test_avro_value_protocol.py +0 -0
  57. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/tests/test_cancellation_context.py +0 -0
  58. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/tests/test_cancellation_remaining_time.py +0 -0
  59. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/tests/test_cancellation_scope_admission.py +0 -0
  60. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/tests/test_cancellation_scope_authoring.py +0 -0
  61. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/tests/test_cancellation_scope_boundary.py +0 -0
  62. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/tests/test_cancellation_scope_opening.py +0 -0
  63. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/tests/test_ci_checkout.py +0 -0
  64. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/tests/test_ci_integration_endpoint.py +0 -0
  65. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/tests/test_client.py +0 -0
  66. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/tests/test_client_timeouts.py +0 -0
  67. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/tests/test_codec_regression_corpus.py +0 -0
  68. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/tests/test_committed_cancellation_scope_history.py +0 -0
  69. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/tests/test_control_plane_parity_fixtures.py +0 -0
  70. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/tests/test_cooperative_cancellation.py +0 -0
  71. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/tests/test_cooperative_cancellation_client.py +0 -0
  72. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/tests/test_cooperative_cancellation_worker.py +0 -0
  73. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/tests/test_cooperative_remote_worker.py +0 -0
  74. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/tests/test_docstring_cross_references.py +0 -0
  75. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/tests/test_durable_selection.py +0 -0
  76. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/tests/test_errors.py +0 -0
  77. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/tests/test_external_storage.py +0 -0
  78. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/tests/test_external_task_input.py +0 -0
  79. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/tests/test_external_task_result.py +0 -0
  80. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/tests/test_golden_history_replay.py +0 -0
  81. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/tests/test_history_bundle_verify.py +0 -0
  82. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/tests/test_history_event_contract.py +0 -0
  83. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/tests/test_invocable.py +0 -0
  84. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/tests/test_message_streams.py +0 -0
  85. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/tests/test_metrics.py +0 -0
  86. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/tests/test_order_processing_example.py +0 -0
  87. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/tests/test_parallel_completion_order_replay.py +0 -0
  88. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/tests/test_parallel_saga_authoring.py +0 -0
  89. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/tests/test_prepared_local_activity_client.py +0 -0
  90. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/tests/test_prepared_local_activity_groups.py +0 -0
  91. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/tests/test_prepared_local_activity_policies.py +0 -0
  92. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/tests/test_prepared_local_activity_worker.py +0 -0
  93. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/tests/test_public_boundary_scanner.py +0 -0
  94. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/tests/test_pypi_project_surface.py +0 -0
  95. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/tests/test_python_conformance.py +0 -0
  96. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/tests/test_queries.py +0 -0
  97. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/tests/test_readme_quickstart.py +0 -0
  98. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/tests/test_redrive_replay.py +0 -0
  99. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/tests/test_release_metadata.py +0 -0
  100. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/tests/test_replay.py +0 -0
  101. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/tests/test_replay_conformance.py +0 -0
  102. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/tests/test_replay_verify.py +0 -0
  103. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/tests/test_retry_policy.py +0 -0
  104. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/tests/test_run_cancellation_scope_replay.py +0 -0
  105. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/tests/test_runtime_external_payload_transport.py +0 -0
  106. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/tests/test_schedules.py +0 -0
  107. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/tests/test_scoped_run_cancellation_context.py +0 -0
  108. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/tests/test_serializer.py +0 -0
  109. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/tests/test_signals.py +0 -0
  110. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/tests/test_sleep.py +0 -0
  111. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/tests/test_standalone_activity_client.py +0 -0
  112. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/tests/test_storage_admission.py +0 -0
  113. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/tests/test_sync.py +0 -0
  114. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/tests/test_testing_harness.py +0 -0
  115. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/tests/test_updates.py +0 -0
  116. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/tests/test_wait_condition.py +0 -0
  117. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/tests/test_worker.py +0 -0
  118. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/tests/test_worker_session.py +0 -0
  119. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/tests/test_workflow_result_timeout.py +0 -0
  120. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/tests/test_workflow_streams.py +0 -0
  121. {durable_workflow-2.4.0 → durable_workflow-2.4.1}/tests/test_workflow_updates_conformance.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: durable-workflow
3
- Version: 2.4.0
3
+ Version: 2.4.1
4
4
  Summary: Python client and worker SDK for Durable Workflow Cloud and self-hosted Server
5
5
  Author: Durable Workflow Contributors
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "durable-workflow"
7
- version = "2.4.0"
7
+ version = "2.4.1"
8
8
  description = "Python client and worker SDK for Durable Workflow Cloud and self-hosted Server"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -71,8 +71,8 @@ durable-workflow-replay-conformance = "durable_workflow.replay_conformance:main"
71
71
  durable-workflow-workflow-updates-conformance = "durable_workflow.workflow_updates_conformance:main"
72
72
 
73
73
  [tool.durable-workflow]
74
- product-train = "2.4.0"
75
- registry-version = "2.4.0"
74
+ product-train = "2.4.1"
75
+ registry-version = "2.4.1"
76
76
  supported-server-versions = "2.5.0"
77
77
  worker-protocol-version = "1.19"
78
78
  control-plane-version = "2"
@@ -3624,6 +3624,8 @@ def _activity_type_from_payload(payload: Mapping[str, Any]) -> str | None:
3624
3624
 
3625
3625
  def _recorded_step_details(payload: Mapping[str, Any]) -> dict[str, Any]:
3626
3626
  details: dict[str, Any] = {}
3627
+ if "delay_seconds" in payload:
3628
+ details["delay_seconds"] = payload["delay_seconds"]
3627
3629
  if "cancellation_cleanup" in payload:
3628
3630
  details["cancellation_cleanup"] = payload["cancellation_cleanup"]
3629
3631
  local_preparation = payload.get("local_preparation")
@@ -3846,6 +3848,12 @@ def _recorded_detail_mismatch(command: Any, step: _RecordedStep) -> str | None:
3846
3848
  f"child_workflow_policy_changed: recorded {field} {recorded_policy!r}, "
3847
3849
  f"but current workflow requested {actual_policy!r}."
3848
3850
  )
3851
+ elif isinstance(command, StartTimer):
3852
+ if "delay_seconds" in step.details and step.details["delay_seconds"] != command.delay_seconds:
3853
+ return (
3854
+ f"Recorded delay_seconds {step.details['delay_seconds']!r}, but current workflow "
3855
+ f"requested {command.delay_seconds!r}."
3856
+ )
3849
3857
  elif isinstance(command, RecordVersionMarker):
3850
3858
  recorded = step.details.get("change_id")
3851
3859
  if isinstance(recorded, str) and recorded != command.change_id:
@@ -6667,6 +6675,11 @@ def _replay_state(
6667
6675
  continue
6668
6676
  ctx.logger._set_replaying(False)
6669
6677
  _assert_pending_step_matches(cmd)
6678
+ if not isinstance(cmd, RecordLocalActivity) and _next_unconsumed_recorded_step() is not None:
6679
+ # A signal may wake a workflow while its existing operation is
6680
+ # still pending. Reissuing it allocates a new durable sequence
6681
+ # and can move a timer's deadline or duplicate external work.
6682
+ return _state(pending)
6670
6683
  if isinstance(cmd, RecordLocalActivity) and prepare_local_activities:
6671
6684
  return _state(pending, _prepared_call(cmd, current_call_sequence))
6672
6685
  if isinstance(cmd, RecordLocalActivity) and local_activity_executor is not None:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: durable-workflow
3
- Version: 2.4.0
3
+ Version: 2.4.1
4
4
  Summary: Python client and worker SDK for Durable Workflow Cloud and self-hosted Server
5
5
  Author: Durable Workflow Contributors
6
6
  License-Expression: MIT
@@ -81,6 +81,7 @@ tests/test_metrics.py
81
81
  tests/test_order_processing_example.py
82
82
  tests/test_parallel_completion_order_replay.py
83
83
  tests/test_parallel_saga_authoring.py
84
+ tests/test_pending_operation_replay.py
84
85
  tests/test_prepared_local_activity_client.py
85
86
  tests/test_prepared_local_activity_groups.py
86
87
  tests/test_prepared_local_activity_policies.py
@@ -104,9 +104,8 @@ def test_existing_positional_constructor_and_omitted_wire_fields_are_preserved()
104
104
  result = replay(workflow("sequential", {
105
105
  "parent_close_policy": ParentClosePolicy.ABANDON, "cancellation_policy": CancellationPolicy.ABANDON,
106
106
  }), scheduled([StartChildWorkflow("child", ["argument"])]), [])
107
- assert len(result.commands) == 1
108
- assert isinstance(result.commands[0], StartChildWorkflow)
109
- assert result.commands[0].cancellation_policy == CancellationPolicy.ABANDON
107
+ # Omitted historical fields retain their defaults, without starting another child.
108
+ assert result.commands == []
110
109
 
111
110
 
112
111
  @pytest.mark.parametrize("mode", ["sequential", "parallel", "selection"])
@@ -120,7 +119,7 @@ def test_changed_policies_fail_cold_replay_and_cancellation_delivery(mode: str,
120
119
  assert replay(workflow(mode, policies), history, [], run_id="run-1").commands == [CompleteWorkflow("request-1")]
121
120
  else:
122
121
  cold = replay(workflow(mode, policies), history, [])
123
- if mode == "selection":
122
+ if mode in ("sequential", "selection"):
124
123
  assert cold.commands == []
125
124
  else:
126
125
  assert scheduled(cold.commands) == history
@@ -227,7 +227,8 @@ def test_cleanup_timer_retains_original_ancestor_receipt_and_each_scope_authorit
227
227
  "2026-10-04T00:00:10.123456Z",
228
228
  )
229
229
  value["task"].update({"lease_owner": "replacement", "workflow_task_attempt": 17})
230
- assert workflow.commands_to_server_commands(run(cls, value).commands, "queue") == fresh
230
+ assert run(cls, value).commands == []
231
+ assert run(cls, value).commands == []
231
232
  append_cleanup_event(
232
233
  value,
233
234
  "TimerFired",
@@ -217,7 +217,8 @@ def test_group_cleanup_timer_replays_original_receipt_and_narrower_authority(lay
217
217
  "cancellation_cleanup": snapshot,
218
218
  }, "2026-10-04T00:00:09.123456Z")
219
219
  value["task"].update({"workflow_task_attempt": 23, "lease_owner": "replacement"})
220
- assert workflow.commands_to_server_commands(run(cls, value).commands, "queue") == first
220
+ assert run(cls, value).commands == []
221
+ assert run(cls, value).commands == []
221
222
  append_cleanup_event(value, "TimerFired", {
222
223
  "sequence": 8, "timer_id": "cleanup-timer", "delay_seconds": 1,
223
224
  "cancellation_scope_id": original.scope_id,
@@ -76,7 +76,10 @@ def test_scoped_cleanup_timer_carries_original_delivery_and_replays_without_exte
76
76
  field: snapshot[field] for field in ("scope_id", "request_id", "delivery_history_event_id")
77
77
  }
78
78
  assert wire[0]["cancellation_scope_id"] == snapshot["scope_id"]
79
- assert workflow.commands_to_server_commands(run(Probe, value).commands, "queue") == wire
79
+ # The original cleanup timer and its authority are already durable.
80
+ # Cold replay waits for it rather than starting a second timer.
81
+ assert run(Probe, value).commands == []
82
+ assert run(Probe, value).commands == []
80
83
  append_cleanup_event(value, "TimerFired", {
81
84
  "sequence": 4, "timer_id": "cleanup-timer", "delay_seconds": 1,
82
85
  "cancellation_scope_id": snapshot["scope_id"],
@@ -0,0 +1,154 @@
1
+ """Scheduled scalar work stays the same operation when signals wake replay."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Any
5
+
6
+ import pytest
7
+
8
+ from durable_workflow import serializer, workflow
9
+ from durable_workflow.errors import NonDeterministicReplayError
10
+ from durable_workflow.workflow import CompleteWorkflow, WorkflowContext, query_state, replay
11
+
12
+
13
+ @workflow.defn(name="tests.replay.pending-operation")
14
+ class PendingOperation:
15
+ def __init__(self) -> None:
16
+ self.total = 0
17
+ self.released = False
18
+
19
+ @workflow.signal("increment")
20
+ def increment(self, amount: int) -> None:
21
+ self.total += amount
22
+
23
+ @workflow.signal("release")
24
+ def release(self) -> None:
25
+ self.released = True
26
+
27
+ @workflow.query("total")
28
+ def current(self) -> int:
29
+ return self.total
30
+
31
+ def run(self, ctx: WorkflowContext, kind: str): # type: ignore[no-untyped-def]
32
+ first = yield ctx.schedule_activity("echo", ["sample"])
33
+ if kind == "timer":
34
+ yield ctx.sleep(120)
35
+ elif kind == "activity":
36
+ yield ctx.schedule_activity("work", [first])
37
+ else:
38
+ yield ctx.start_child_workflow("child", [first])
39
+ yield ctx.wait_condition(lambda: self.released, key="release")
40
+ return self.total
41
+
42
+
43
+ def initial_history() -> list[dict[str, Any]]:
44
+ return [
45
+ {"event_type": "ActivityCompleted", "payload": {
46
+ "sequence": 1, "activity_type": "echo", "result": serializer.envelope("sample"),
47
+ }},
48
+ ]
49
+
50
+
51
+ OPENINGS = {
52
+ "timer": {"event_type": "TimerScheduled", "payload": {
53
+ "sequence": 2, "timer_id": "original-timer", "delay_seconds": 120,
54
+ "fire_at": "2026-10-07T06:02:00Z",
55
+ }},
56
+ "activity": {"event_type": "ActivityScheduled", "payload": {
57
+ "sequence": 2, "activity_type": "work", "activity_execution_id": "original-activity",
58
+ }},
59
+ "child": {"event_type": "ChildWorkflowScheduled", "payload": {
60
+ "sequence": 2, "workflow_type": "child", "child_workflow_instance_id": "original-child",
61
+ }},
62
+ }
63
+
64
+
65
+ @pytest.mark.parametrize("kind", OPENINGS)
66
+ def test_signal_replay_does_not_reschedule_pending_operation(kind: str) -> None:
67
+ history = initial_history() + [OPENINGS[kind]]
68
+ for amount in (7, 11):
69
+ history.append({"event_type": "SignalReceived", "payload": {
70
+ "signal_name": "increment", "arguments": serializer.envelope([amount]),
71
+ }})
72
+ # Each call constructs a fresh workflow instance, as a replacement worker does.
73
+ outcome = replay(PendingOperation, history, [kind])
74
+ assert outcome.commands == []
75
+ assert query_state(PendingOperation, history, [kind], "total") == sum(
76
+ serializer.decode_envelope(event["payload"]["arguments"])[0]
77
+ for event in history if event["event_type"] == "SignalReceived"
78
+ )
79
+
80
+
81
+ @pytest.mark.parametrize("kind", OPENINGS)
82
+ def test_new_operation_is_still_scheduled(kind: str) -> None:
83
+ outcome = replay(PendingOperation, initial_history(), [kind])
84
+ assert len(outcome.commands) == 1
85
+ assert outcome.commands[0].to_server_command("queue")["type"] == {
86
+ "timer": "start_timer", "activity": "schedule_activity", "child": "start_child_workflow",
87
+ }[kind]
88
+
89
+
90
+ def test_original_timer_completion_consumes_signals_once() -> None:
91
+ history = initial_history() + [OPENINGS["timer"],
92
+ {"event_type": "SignalReceived", "payload": {
93
+ "signal_name": "increment", "arguments": serializer.envelope([7]),
94
+ }},
95
+ {"event_type": "TimerFired", "payload": {"sequence": 2, "timer_id": "original-timer"}},
96
+ {"event_type": "SignalReceived", "payload": {
97
+ "signal_name": "release", "arguments": serializer.envelope([]),
98
+ }},
99
+ ]
100
+ outcome = replay(PendingOperation, history, ["timer"])
101
+ assert len(outcome.commands) == 1
102
+ assert isinstance(outcome.commands[0], CompleteWorkflow)
103
+ assert outcome.commands[0].result == 7
104
+
105
+
106
+ def test_pending_timer_still_rejects_changed_delay() -> None:
107
+ changed = {"event_type": "TimerScheduled", "payload": {**OPENINGS["timer"]["payload"], "delay_seconds": 121}}
108
+ with pytest.raises(NonDeterministicReplayError) as captured:
109
+ replay(PendingOperation, initial_history() + [changed], ["timer"])
110
+ assert captured.value.workflow_sequence == 2
111
+ assert captured.value.recorded_event_types == ["TimerScheduled"]
112
+
113
+
114
+ @pytest.mark.parametrize("kind", ["activity", "child"])
115
+ def test_original_external_operation_completion_consumes_signals_once(kind: str) -> None:
116
+ terminal = {
117
+ "event_type": "ActivityCompleted" if kind == "activity" else "ChildRunCompleted",
118
+ "payload": {**OPENINGS[kind]["payload"], "result": serializer.envelope("original-result")},
119
+ }
120
+ history = initial_history() + [OPENINGS[kind],
121
+ {"event_type": "SignalReceived", "payload": {
122
+ "signal_name": "increment", "arguments": serializer.envelope([7]),
123
+ }}, terminal,
124
+ {"event_type": "SignalReceived", "payload": {
125
+ "signal_name": "release", "arguments": serializer.envelope([]),
126
+ }},
127
+ ]
128
+ assert replay(PendingOperation, history, [kind]).commands == [CompleteWorkflow(7)]
129
+
130
+
131
+ @pytest.mark.parametrize("kind", OPENINGS)
132
+ def test_recorded_operation_cannot_be_replaced_by_a_different_kind(kind: str) -> None:
133
+ different = "activity" if kind == "timer" else "timer"
134
+ with pytest.raises(NonDeterministicReplayError) as captured:
135
+ replay(PendingOperation, initial_history() + [OPENINGS[kind]], [different])
136
+ assert captured.value.workflow_sequence == 2
137
+
138
+
139
+ def test_legacy_timer_without_recorded_delay_still_waits_for_original_operation() -> None:
140
+ legacy = {"event_type": "TimerScheduled", "payload": {"sequence": 2, "timer_id": "original-timer"}}
141
+ assert replay(PendingOperation, initial_history() + [legacy], ["timer"]).commands == []
142
+
143
+
144
+ def test_already_duplicated_timers_are_not_silently_skipped() -> None:
145
+ duplicate = {"event_type": "TimerScheduled", "payload": {
146
+ **OPENINGS["timer"]["payload"], "sequence": 3, "timer_id": "duplicate-timer",
147
+ }}
148
+ history = initial_history() + [OPENINGS["timer"], duplicate,
149
+ {"event_type": "TimerFired", "payload": {"sequence": 2, "timer_id": "original-timer"}},
150
+ {"event_type": "TimerFired", "payload": {"sequence": 3, "timer_id": "duplicate-timer"}},
151
+ ]
152
+ with pytest.raises(NonDeterministicReplayError) as captured:
153
+ replay(PendingOperation, history, ["timer"])
154
+ assert captured.value.workflow_sequence == 3
@@ -24,6 +24,7 @@ from tests.test_golden_history_replay import (
24
24
  GoldenTimeoutWaitWorkflow,
25
25
  GoldenVersionMarkerWorkflow,
26
26
  )
27
+ from tests.test_pending_operation_replay import PendingOperation
27
28
  from tests.test_update_signal_condition_replay import (
28
29
  PostConditionReceiversWorkflow,
29
30
  UpdateSignalConditionTimerWorkflow,
@@ -265,6 +266,7 @@ WORKFLOWS = [
265
266
  NestedParallelPathWorkflow,
266
267
  ParallelMetadataProducerWorkflow,
267
268
  ParallelResultBindingWorkflow,
269
+ PendingOperation,
268
270
  PagedRecordedSideEffectsWorkflow,
269
271
  PostConditionReceiversWorkflow,
270
272
  RecordedSideEffectWorkflow,
@@ -460,13 +460,13 @@ class TestUpdateSignalConditionReplay:
460
460
  expected_commands = [
461
461
  StartTimer,
462
462
  StartTimer,
463
- StartTimer,
464
- ScheduleActivity,
465
- ScheduleActivity,
466
- ScheduleActivity,
467
- ScheduleActivity,
463
+ None,
468
464
  ScheduleActivity,
465
+ None,
466
+ None,
469
467
  ScheduleActivity,
468
+ None,
469
+ None,
470
470
  WaitCondition,
471
471
  WaitCondition,
472
472
  WaitCondition,
@@ -475,8 +475,8 @@ class TestUpdateSignalConditionReplay:
475
475
  WaitCondition,
476
476
  ScheduleActivity,
477
477
  ScheduleActivity,
478
- ScheduleActivity,
479
- ScheduleActivity,
478
+ None,
479
+ None,
480
480
  WaitCondition,
481
481
  WaitCondition,
482
482
  WaitCondition,
@@ -492,6 +492,9 @@ class TestUpdateSignalConditionReplay:
492
492
  [],
493
493
  )
494
494
 
495
+ if expected_command is None:
496
+ assert outcome.commands == [], prefix_length
497
+ continue
495
498
  assert len(outcome.commands) == 1, prefix_length
496
499
  assert isinstance(outcome.commands[0], expected_command), prefix_length
497
500
  if prefix_length in (15, 16, 17, 18):