durable-workflow 2.5.0__tar.gz → 2.5.2__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 (125) hide show
  1. {durable_workflow-2.5.0/src/durable_workflow.egg-info → durable_workflow-2.5.2}/PKG-INFO +1 -1
  2. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/pyproject.toml +4 -4
  3. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/src/durable_workflow/__init__.py +2 -0
  4. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/src/durable_workflow/errors.py +4 -0
  5. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/src/durable_workflow/testing.py +2 -0
  6. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/src/durable_workflow/worker.py +5 -1
  7. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/src/durable_workflow/workflow.py +127 -14
  8. {durable_workflow-2.5.0 → durable_workflow-2.5.2/src/durable_workflow.egg-info}/PKG-INFO +1 -1
  9. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/src/durable_workflow.egg-info/SOURCES.txt +2 -0
  10. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_cooperative_remote_worker.py +10 -3
  11. durable_workflow-2.5.2/tests/test_patch_history.py +145 -0
  12. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_replay_regression_corpus.py +12 -0
  13. durable_workflow-2.5.2/tests/test_workflow_entrypoint_validation.py +116 -0
  14. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/LICENSE +0 -0
  15. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/README.md +0 -0
  16. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/setup.cfg +0 -0
  17. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/src/durable_workflow/_activity_process.py +0 -0
  18. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/src/durable_workflow/_avro.py +0 -0
  19. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/src/durable_workflow/_cancellation_scope.py +0 -0
  20. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/src/durable_workflow/_cancellation_scope_history.py +0 -0
  21. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/src/durable_workflow/_cooperative_cancellation.py +0 -0
  22. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/src/durable_workflow/_prepared_local_activity.py +0 -0
  23. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/src/durable_workflow/_sticky_workflow_cache.py +0 -0
  24. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/src/durable_workflow/activity.py +0 -0
  25. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/src/durable_workflow/auth_composition.py +0 -0
  26. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/src/durable_workflow/cancellation.py +0 -0
  27. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/src/durable_workflow/client.py +0 -0
  28. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/src/durable_workflow/external_storage.py +0 -0
  29. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/src/durable_workflow/external_task_input.py +0 -0
  30. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/src/durable_workflow/external_task_result.py +0 -0
  31. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/src/durable_workflow/history_bundle_verify.py +0 -0
  32. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/src/durable_workflow/interceptors.py +0 -0
  33. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/src/durable_workflow/invocable.py +0 -0
  34. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/src/durable_workflow/metrics.py +0 -0
  35. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/src/durable_workflow/nexus.py +0 -0
  36. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/src/durable_workflow/py.typed +0 -0
  37. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/src/durable_workflow/python_conformance.py +0 -0
  38. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/src/durable_workflow/replay_conformance.py +0 -0
  39. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/src/durable_workflow/replay_verify.py +0 -0
  40. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/src/durable_workflow/retry_policy.py +0 -0
  41. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/src/durable_workflow/serializer.py +0 -0
  42. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/src/durable_workflow/sync.py +0 -0
  43. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/src/durable_workflow/worker_session.py +0 -0
  44. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/src/durable_workflow/workflow_updates_conformance.py +0 -0
  45. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/src/durable_workflow.egg-info/dependency_links.txt +0 -0
  46. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/src/durable_workflow.egg-info/entry_points.txt +0 -0
  47. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/src/durable_workflow.egg-info/requires.txt +0 -0
  48. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/src/durable_workflow.egg-info/top_level.txt +0 -0
  49. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_activity_cancellation_policies.py +0 -0
  50. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_activity_context.py +0 -0
  51. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_activity_process.py +0 -0
  52. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_auth_composition.py +0 -0
  53. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_avro_value_benchmark.py +0 -0
  54. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_avro_value_protocol.py +0 -0
  55. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_cancellation_context.py +0 -0
  56. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_cancellation_remaining_time.py +0 -0
  57. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_cancellation_scope_admission.py +0 -0
  58. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_cancellation_scope_authoring.py +0 -0
  59. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_cancellation_scope_boundary.py +0 -0
  60. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_cancellation_scope_opening.py +0 -0
  61. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_child_workflow_policies.py +0 -0
  62. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_ci_checkout.py +0 -0
  63. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_ci_integration_endpoint.py +0 -0
  64. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_client.py +0 -0
  65. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_client_timeouts.py +0 -0
  66. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_codec_regression_corpus.py +0 -0
  67. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_committed_cancellation_scope_descendants.py +0 -0
  68. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_committed_cancellation_scope_groups.py +0 -0
  69. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_committed_cancellation_scope_history.py +0 -0
  70. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_committed_cancellation_scope_replay.py +0 -0
  71. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_control_plane_parity_fixtures.py +0 -0
  72. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_cooperative_cancellation.py +0 -0
  73. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_cooperative_cancellation_client.py +0 -0
  74. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_cooperative_cancellation_worker.py +0 -0
  75. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_docstring_cross_references.py +0 -0
  76. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_durable_selection.py +0 -0
  77. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_errors.py +0 -0
  78. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_external_storage.py +0 -0
  79. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_external_task_input.py +0 -0
  80. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_external_task_result.py +0 -0
  81. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_golden_history_replay.py +0 -0
  82. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_history_bundle_verify.py +0 -0
  83. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_history_event_contract.py +0 -0
  84. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_invocable.py +0 -0
  85. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_message_streams.py +0 -0
  86. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_metrics.py +0 -0
  87. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_order_processing_example.py +0 -0
  88. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_parallel_completion_order_replay.py +0 -0
  89. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_parallel_saga_authoring.py +0 -0
  90. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_pending_operation_replay.py +0 -0
  91. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_prepared_local_activity_client.py +0 -0
  92. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_prepared_local_activity_groups.py +0 -0
  93. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_prepared_local_activity_policies.py +0 -0
  94. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_prepared_local_activity_worker.py +0 -0
  95. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_public_boundary_scanner.py +0 -0
  96. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_pypi_project_surface.py +0 -0
  97. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_python_conformance.py +0 -0
  98. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_queries.py +0 -0
  99. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_readme_quickstart.py +0 -0
  100. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_redrive_replay.py +0 -0
  101. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_release_metadata.py +0 -0
  102. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_replay.py +0 -0
  103. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_replay_conformance.py +0 -0
  104. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_replay_verify.py +0 -0
  105. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_retry_policy.py +0 -0
  106. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_run_cancellation_scope_replay.py +0 -0
  107. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_runtime_external_payload_transport.py +0 -0
  108. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_schedules.py +0 -0
  109. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_scoped_run_cancellation_context.py +0 -0
  110. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_serializer.py +0 -0
  111. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_signals.py +0 -0
  112. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_sleep.py +0 -0
  113. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_standalone_activity_client.py +0 -0
  114. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_sticky_execution.py +0 -0
  115. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_storage_admission.py +0 -0
  116. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_sync.py +0 -0
  117. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_testing_harness.py +0 -0
  118. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_update_signal_condition_replay.py +0 -0
  119. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_updates.py +0 -0
  120. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_wait_condition.py +0 -0
  121. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_worker.py +0 -0
  122. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_worker_session.py +0 -0
  123. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_workflow_result_timeout.py +0 -0
  124. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_workflow_streams.py +0 -0
  125. {durable_workflow-2.5.0 → durable_workflow-2.5.2}/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.5.0
3
+ Version: 2.5.2
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.5.0"
7
+ version = "2.5.2"
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,9 +71,9 @@ 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.5.0"
75
- registry-version = "2.5.0"
76
- supported-server-versions = "2.5.0"
74
+ product-train = "2.5.2"
75
+ registry-version = "2.5.2"
76
+ supported-server-versions = "2.5.16"
77
77
  worker-protocol-version = "1.19"
78
78
  control-plane-version = "2"
79
79
  durable-selection = true
@@ -81,6 +81,7 @@ from .errors import (
81
81
  ExternalPayloadUnavailable,
82
82
  ExternalPayloadUnsupported,
83
83
  InvalidArgument,
84
+ InvalidWorkflowDefinition,
84
85
  NamespaceNotFound,
85
86
  NexusOperationFailed,
86
87
  NonDeterministicReplayError,
@@ -369,6 +370,7 @@ __all__ = [
369
370
  "NexusOperationResult",
370
371
  "NexusServiceCall",
371
372
  "NonDeterministicReplayError",
373
+ "InvalidWorkflowDefinition",
372
374
  "NoopMetrics",
373
375
  "QueryFailed",
374
376
  "RuntimeCapabilityUnsupported",
@@ -27,6 +27,10 @@ class DurableWorkflowError(Exception):
27
27
  """Base class for every exception raised by the SDK."""
28
28
 
29
29
 
30
+ class InvalidWorkflowDefinition(DurableWorkflowError, ValueError):
31
+ """Workflow authoring uses an unsupported entry point or result."""
32
+
33
+
30
34
  class DurableOperationCancelled(DurableWorkflowError):
31
35
  """A durable selection member was explicitly cancelled."""
32
36
 
@@ -51,6 +51,7 @@ from .workflow import (
51
51
  StartChildWorkflow,
52
52
  StartTimer,
53
53
  UpsertSearchAttributes,
54
+ _validate_workflow_entrypoint,
54
55
  registry,
55
56
  replay,
56
57
  )
@@ -134,6 +135,7 @@ class WorkflowEnvironment:
134
135
  f"{workflow_cls!r} is not a workflow class; "
135
136
  "decorate it with @workflow.defn(name=...) first."
136
137
  )
138
+ _validate_workflow_entrypoint(workflow_cls)
137
139
  self._registered_workflows[name] = workflow_cls
138
140
 
139
141
  def signal(
@@ -104,6 +104,7 @@ from .workflow import (
104
104
  ScheduleActivity,
105
105
  StartChildWorkflow,
106
106
  UpsertMemo,
107
+ _validate_workflow_entrypoint,
107
108
  apply_update,
108
109
  commands_to_server_commands,
109
110
  query_state,
@@ -1023,7 +1024,10 @@ class Worker:
1023
1024
  ) -> None:
1024
1025
  self.client = client
1025
1026
  self.task_queue = task_queue
1026
- self.workflows = {_workflow_name(w): w for w in workflows}
1027
+ self.workflows: dict[str, type] = {}
1028
+ for definition in workflows:
1029
+ _validate_workflow_entrypoint(definition)
1030
+ self.workflows[_workflow_name(definition)] = definition
1027
1031
  self.workflow_definition_fingerprints = {
1028
1032
  workflow_type: _workflow_definition_fingerprint(workflow_cls)
1029
1033
  for workflow_type, workflow_cls in self.workflows.items()
@@ -21,6 +21,7 @@ from __future__ import annotations
21
21
  import base64
22
22
  import contextlib
23
23
  import hashlib
24
+ import inspect
24
25
  import logging
25
26
  import math
26
27
  import random
@@ -58,6 +59,7 @@ from .errors import (
58
59
  ChildWorkflowFailed,
59
60
  ChildWorkflowTerminated,
60
61
  DurableOperationCancelled,
62
+ InvalidWorkflowDefinition,
61
63
  NexusOperationFailed,
62
64
  NonDeterministicReplayError,
63
65
  QueryFailed,
@@ -97,6 +99,31 @@ MESSAGE_STREAM_CURSOR_SCHEMA = "durable-workflow.v2.message-stream.cursor"
97
99
  MESSAGE_STREAM_MAX_BATCH = 100
98
100
 
99
101
 
102
+ def _validate_workflow_entrypoint(cls: type) -> None:
103
+ entrypoint = getattr(cls, "run", None)
104
+ if not callable(entrypoint):
105
+ return
106
+ targets = [inspect.unwrap(entrypoint)]
107
+ if not inspect.isroutine(targets[0]):
108
+ targets.append(inspect.unwrap(targets[0].__call__))
109
+ if any(inspect.iscoroutinefunction(target) or inspect.isasyncgenfunction(target) for target in targets):
110
+ name = getattr(cls, "__workflow_name__", cls.__name__)
111
+ raise InvalidWorkflowDefinition(
112
+ f"unsupported_workflow_entrypoint: workflow {name!r} ({cls.__qualname__}.run) "
113
+ "must use a synchronous function or generator; yield durable commands instead of async def."
114
+ )
115
+
116
+
117
+ def _validate_workflow_result(value: Any, cls: type) -> None:
118
+ if inspect.isawaitable(value) or inspect.isasyncgen(value):
119
+ if inspect.iscoroutine(value):
120
+ value.close()
121
+ raise InvalidWorkflowDefinition(
122
+ f"unsupported_workflow_result: {cls.__qualname__}.run returned an async value; "
123
+ "use a synchronous result or yield durable commands."
124
+ )
125
+
126
+
100
127
  def defn(*, name: str) -> Callable[[type[_WorkflowT]], type[_WorkflowT]]:
101
128
  """Register a class as a workflow type under a language-neutral name.
102
129
 
@@ -108,6 +135,7 @@ def defn(*, name: str) -> Callable[[type[_WorkflowT]], type[_WorkflowT]]:
108
135
 
109
136
  def wrap(cls: type[_WorkflowT]) -> type[_WorkflowT]:
110
137
  cls.__workflow_name__ = name # type: ignore[attr-defined]
138
+ _validate_workflow_entrypoint(cls)
111
139
  signals: dict[str, str] = {}
112
140
  queries: dict[str, str] = {}
113
141
  updates: dict[str, str] = {}
@@ -4037,6 +4065,7 @@ def _replay_state(
4037
4065
  allow_cancellation_scope_authoring: bool = False,
4038
4066
  allow_cancellation_scope_delivery: bool = False,
4039
4067
  ) -> _ReplayState:
4068
+ _validate_workflow_entrypoint(workflow_cls)
4040
4069
  if allow_cancellation_scope_delivery and not allow_cancellation_scope_authoring:
4041
4070
  raise LocalActivityExecutionAborted("scope delivery requires canonical scope authoring")
4042
4071
  if prepare_local_activity_groups and not prepare_local_activities:
@@ -5172,6 +5201,7 @@ def _replay_state(
5172
5201
  except Exception as exc:
5173
5202
  return _state([_fail_workflow_from_exception(exc)])
5174
5203
  if not hasattr(gen, "__next__"):
5204
+ _validate_workflow_result(gen, workflow_cls)
5175
5205
  if isinstance(gen, ContinueAsNew):
5176
5206
  _assert_no_unconsumed_history("continue as new")
5177
5207
  return _state([gen])
@@ -5185,6 +5215,96 @@ def _replay_state(
5185
5215
  pending: list[Command] = []
5186
5216
  advanced_cmd: Any = None
5187
5217
  terminal_condition_reopen_cmd: WaitCondition | None = None
5218
+ version_decisions: dict[str, tuple[int, str]] = {}
5219
+
5220
+ def _version_error(detail: str, step: _RecordedStep | None = None) -> NonDeterministicReplayError:
5221
+ return NonDeterministicReplayError(
5222
+ step.workflow_sequence if step is not None else authored_sequence,
5223
+ "compatible version marker",
5224
+ step.event_types if step is not None else [],
5225
+ detail=detail,
5226
+ )
5227
+
5228
+ def _recorded_version(step: _RecordedStep) -> int:
5229
+ if step.history_index is None:
5230
+ raise _version_error("Version marker is missing its history position.", step)
5231
+ payload = events[step.history_index].get("payload") or {}
5232
+ version = payload.get("version")
5233
+ if type(version) is not int:
5234
+ raise _version_error("Version marker must contain an integer version.", step)
5235
+ change_id = payload.get("change_id")
5236
+ if "change_id" in payload and (not isinstance(change_id, str) or not change_id.strip()):
5237
+ raise _version_error("Version marker must retain a non-empty change ID.", step)
5238
+ if "min_supported" in payload or "max_supported" in payload:
5239
+ minimum, maximum = payload.get("min_supported"), payload.get("max_supported")
5240
+ if (type(minimum) is not int or type(maximum) is not int
5241
+ or not minimum <= version <= maximum):
5242
+ raise _version_error("Recorded version marker has an invalid supported range.", step)
5243
+ return version
5244
+
5245
+ def _consume_duplicate_version_markers() -> None:
5246
+ nonlocal result_cursor, authored_sequence
5247
+ if not version_decisions:
5248
+ return
5249
+ while result_cursor < len(recorded_steps):
5250
+ step = recorded_steps[result_cursor]
5251
+ change_id = step.details.get("change_id")
5252
+ if step.shape != "version marker" or change_id not in version_decisions:
5253
+ return
5254
+ if _next_unconsumed_recorded_step() is not step:
5255
+ return
5256
+ version = _recorded_version(step)
5257
+ if version != version_decisions[change_id][0]:
5258
+ raise _version_error(f"Conflicting recorded decisions for change ID {change_id!r}.", step)
5259
+ # Earlier SDKs emitted a marker for every invocation. Consume these
5260
+ # aliases while retaining their physical positions for cancellation
5261
+ # and prepared operations. The stored history itself is unchanged.
5262
+ result_cursor += 1
5263
+ authored_sequence = max(authored_sequence, step.workflow_sequence + 1)
5264
+
5265
+ def _resolve_version(command: RecordVersionMarker) -> Any:
5266
+ nonlocal result_cursor, authored_sequence
5267
+ change_id = command.change_id
5268
+ if not isinstance(change_id, str) or not change_id.strip():
5269
+ raise _version_error("Version markers require a stable non-empty change ID.")
5270
+ if (type(command.min_supported) is not int or type(command.max_supported) is not int
5271
+ or command.min_supported > command.max_supported):
5272
+ raise _version_error(f"Invalid supported range for change ID {change_id!r}.")
5273
+ family = "version" if command.result_kind == "version" else "patch"
5274
+ decision = version_decisions.get(change_id)
5275
+ step = _next_unconsumed_recorded_step()
5276
+ if decision is not None:
5277
+ version, original_family = decision
5278
+ if original_family != family:
5279
+ raise _version_error(f"Change ID {change_id!r} cannot switch between version and patch helpers.", step)
5280
+ elif step is not None and step.shape == "version marker":
5281
+ _assert_step_matches(command, step)
5282
+ version = _recorded_version(step)
5283
+ result_cursor += 1
5284
+ authored_sequence = max(authored_sequence, step.workflow_sequence + 1)
5285
+ elif step is not None or (cancellation.delivery is not None and not cancellation_consumed) or any(
5286
+ sequence not in consumed_scope_deliveries for sequence in committed_scopes.deliveries
5287
+ ) or any(_history_event_type(event) in {
5288
+ "WorkflowCompleted", "WorkflowFailed", "WorkflowCancelled", "WorkflowTerminated",
5289
+ "WorkflowContinuedAsNew", "WorkflowTimedOut",
5290
+ } for event in events):
5291
+ # An unmarked recorded command proves this history passed the new
5292
+ # patch site. Leave that command available for its original call.
5293
+ version = -1
5294
+ else:
5295
+ version = command.version
5296
+ if type(version) is not int:
5297
+ raise _version_error(f"Invalid version for change ID {change_id!r}.")
5298
+ ctx.logger._set_replaying(False)
5299
+ pending.append(command)
5300
+ authored_sequence += 1
5301
+ if not command.min_supported <= version <= command.max_supported:
5302
+ raise _version_error(
5303
+ f"Recorded version {version} for change ID {change_id!r} is outside "
5304
+ f"the supported range {command.min_supported}..{command.max_supported}.", step,
5305
+ )
5306
+ version_decisions.setdefault(change_id, (version, family))
5307
+ return _version_marker_result(command, version)
5188
5308
 
5189
5309
  def _parallel_leaf_kind(command: Any) -> str:
5190
5310
  if isinstance(command, ScheduleActivity | RecordLocalActivity):
@@ -5942,6 +6062,7 @@ def _replay_state(
5942
6062
  return next(iter(memberships))
5943
6063
 
5944
6064
  def _terminal_state(value: Any, *, include_pending: bool) -> _ReplayState:
6065
+ _validate_workflow_result(value, workflow_cls)
5945
6066
  _apply_due_receivers()
5946
6067
  _consume_terminal_condition_reopens()
5947
6068
  commands = list(pending) if include_pending else []
@@ -6064,6 +6185,7 @@ def _replay_state(
6064
6185
  replay_token = _ACTIVE_WORKFLOW_REPLAY.set(ctx)
6065
6186
  try:
6066
6187
  while True:
6188
+ _consume_duplicate_version_markers()
6067
6189
  # Cursor-0 receivers are start-boundary events. Enter run() once
6068
6190
  # before applying them so workflow initialization observes the
6069
6191
  # same WorkflowStarted-before-Signal/Update ordering the server
@@ -6081,6 +6203,9 @@ def _replay_state(
6081
6203
  first = False
6082
6204
  _apply_due_receivers()
6083
6205
  _validate_local_activity_policies(cmd)
6206
+ if isinstance(cmd, RecordVersionMarker):
6207
+ next_value = _resolve_version(cmd)
6208
+ continue
6084
6209
  if prepare_local_activities and isinstance(cmd, list | SelectGroup) and _contains_local_activity(cmd) and (
6085
6210
  not prepare_local_activity_groups or isinstance(cmd, SelectGroup)
6086
6211
  ):
@@ -6497,18 +6622,6 @@ def _replay_state(
6497
6622
  pending.append(cmd)
6498
6623
  next_value = None
6499
6624
  continue
6500
- if isinstance(cmd, RecordVersionMarker):
6501
- if result_cursor < len(resolved_results):
6502
- _assert_next_step_matches(cmd)
6503
- val = resolved_results[result_cursor]
6504
- result_cursor += 1
6505
- next_value = _version_marker_result(cmd, val)
6506
- continue
6507
- ctx.logger._set_replaying(False)
6508
- _assert_pending_step_matches(cmd)
6509
- pending.append(cmd)
6510
- next_value = _version_marker_result(cmd, cmd.version)
6511
- continue
6512
6625
  if isinstance(cmd, WaitCondition):
6513
6626
  # A condition that is already true is not persisted. Apply
6514
6627
  # receivers at the yield boundary before deciding whether the
@@ -6702,10 +6815,10 @@ def _replay_state(
6702
6815
  try:
6703
6816
  return _terminal_state(stop.value, include_pending=True)
6704
6817
  except Exception as exc:
6705
- if isinstance(exc, NonDeterministicReplayError):
6818
+ if isinstance(exc, (NonDeterministicReplayError, InvalidWorkflowDefinition)):
6706
6819
  raise
6707
6820
  return _state(pending + [_fail_workflow_from_exception(exc)])
6708
- except NonDeterministicReplayError:
6821
+ except (NonDeterministicReplayError, InvalidWorkflowDefinition):
6709
6822
  raise
6710
6823
  except LocalActivityExecutionAborted:
6711
6824
  raise
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: durable-workflow
3
- Version: 2.5.0
3
+ Version: 2.5.2
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
@@ -82,6 +82,7 @@ tests/test_metrics.py
82
82
  tests/test_order_processing_example.py
83
83
  tests/test_parallel_completion_order_replay.py
84
84
  tests/test_parallel_saga_authoring.py
85
+ tests/test_patch_history.py
85
86
  tests/test_pending_operation_replay.py
86
87
  tests/test_prepared_local_activity_client.py
87
88
  tests/test_prepared_local_activity_groups.py
@@ -116,6 +117,7 @@ tests/test_updates.py
116
117
  tests/test_wait_condition.py
117
118
  tests/test_worker.py
118
119
  tests/test_worker_session.py
120
+ tests/test_workflow_entrypoint_validation.py
119
121
  tests/test_workflow_result_timeout.py
120
122
  tests/test_workflow_streams.py
121
123
  tests/test_workflow_updates_conformance.py
@@ -47,22 +47,29 @@ def stop_receipt() -> dict[str, Any]:
47
47
  "heartbeat_recorded": False, "history_event_id": "receipt-event"}
48
48
 
49
49
 
50
+ def write_pid_marker(marker: str) -> None:
51
+ target = Path(marker)
52
+ pending = target.with_name(target.name + ".pending")
53
+ pending.write_text(str(os.getpid()))
54
+ pending.replace(target)
55
+
56
+
50
57
  async def blocked_async(marker: str, heartbeat: bool = False) -> None:
51
58
  if heartbeat:
52
59
  await activity.context().heartbeat({"authored": True})
53
- Path(marker).write_text(str(os.getpid()))
60
+ write_pid_marker(marker)
54
61
  await asyncio.Event().wait()
55
62
 
56
63
 
57
64
  def blocked_sync(marker: str, heartbeat: bool = False) -> None:
58
65
  if heartbeat:
59
66
  asyncio.run(activity.context().heartbeat({"authored": True}))
60
- Path(marker).write_text(str(os.getpid()))
67
+ write_pid_marker(marker)
61
68
  time.sleep(60)
62
69
 
63
70
 
64
71
  def released_sync(marker: str) -> bytes:
65
- Path(marker).write_text(str(os.getpid()))
72
+ write_pid_marker(marker)
66
73
  while not Path(marker + ".release").exists():
67
74
  time.sleep(0.02)
68
75
  return b"late result"
@@ -0,0 +1,145 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ import pytest
6
+
7
+ from durable_workflow import serializer, workflow
8
+ from durable_workflow.errors import NonDeterministicReplayError, WorkflowCancelled
9
+ from durable_workflow.workflow import CompleteWorkflow, RecordVersionMarker, ScheduleActivity, replay
10
+ from tests.test_cooperative_cancellation import marker as cancellation_marker
11
+ from tests.test_cooperative_cancellation import request
12
+
13
+
14
+ def version_marker(sequence: int, change_id: str = "same", version: int = 1) -> dict[str, Any]:
15
+ return {"event_type": "VersionMarkerRecorded", "payload": {
16
+ "sequence": sequence, "change_id": change_id, "version": version,
17
+ "min_supported": -1, "max_supported": 1,
18
+ }}
19
+
20
+
21
+ def old_activity(sequence: int, completed: bool = True) -> list[dict[str, Any]]:
22
+ history = [{"event_type": "ActivityScheduled", "payload": {
23
+ "sequence": sequence, "activity_type": "old",
24
+ }}]
25
+ if completed:
26
+ history.append({"event_type": "ActivityCompleted", "payload": {
27
+ "sequence": sequence, "activity_type": "old", "result": serializer.encode("recorded"),
28
+ "payload_codec": "avro",
29
+ }})
30
+ return history
31
+
32
+
33
+ @workflow.defn(name="tests.patch.insertion")
34
+ class PatchInsertionWorkflow:
35
+ def run(self, ctx): # type: ignore[no-untyped-def]
36
+ if (yield ctx.patched("same")):
37
+ yield ctx.schedule_activity("new", [])
38
+ return (yield ctx.schedule_activity("old", []))
39
+
40
+
41
+ @workflow.defn(name="tests.patch.repeated")
42
+ class RepeatedPatchWorkflow:
43
+ def run(self, ctx, repeat=True): # type: ignore[no-untyped-def]
44
+ first = yield ctx.patched("same")
45
+ second = (yield ctx.patched("same")) if repeat else first
46
+ result = yield ctx.schedule_activity("old", [])
47
+ return [first, second, result]
48
+
49
+
50
+ @workflow.defn(name="tests.patch.interleaved")
51
+ class InterleavedPatchWorkflow:
52
+ def run(self, ctx, repeat=True): # type: ignore[no-untyped-def]
53
+ first = yield ctx.patched("same")
54
+ result = yield ctx.schedule_activity("old", [])
55
+ second = (yield ctx.patched("same")) if repeat else first
56
+ return [first, second, result]
57
+
58
+
59
+ @workflow.defn(name="tests.patch.ranges")
60
+ class VersionRangeWorkflow:
61
+ def run(self, ctx, minimum=1, mixed=False): # type: ignore[no-untyped-def]
62
+ first = yield ctx.get_version("same", 1, 2)
63
+ second = (yield ctx.patched("same")) if mixed else (yield ctx.get_version("same", minimum, 3))
64
+ return [first, second]
65
+
66
+
67
+ @workflow.defn(name="tests.patch.cancellation")
68
+ class PatchCancellationWorkflow:
69
+ def run(self, ctx): # type: ignore[no-untyped-def]
70
+ decisions = [(yield ctx.patched("same")), (yield ctx.patched("same"))]
71
+ try:
72
+ yield ctx.schedule_activity("old", [])
73
+ except WorkflowCancelled:
74
+ return [decisions, "cancelled"]
75
+
76
+
77
+ @pytest.mark.parametrize("completed", [False, True])
78
+ def test_inserted_patch_preserves_unmarked_activity(completed: bool) -> None:
79
+ outcome = replay(PatchInsertionWorkflow, old_activity(1, completed), [])
80
+ if completed:
81
+ assert len(outcome.commands) == 1
82
+ command = outcome.commands[0]
83
+ assert isinstance(command, CompleteWorkflow)
84
+ assert command.result == "recorded"
85
+ else:
86
+ # The old activity is already scheduled. Await it without duplicating
87
+ # that command or introducing the new patched activity.
88
+ assert outcome.commands == []
89
+
90
+
91
+ def test_repeated_patch_emits_one_marker_on_new_history() -> None:
92
+ commands = replay(RepeatedPatchWorkflow, [], []).commands
93
+ assert [type(command) for command in commands] == [RecordVersionMarker, ScheduleActivity]
94
+
95
+
96
+ @pytest.mark.parametrize("repeat", [False, True])
97
+ @pytest.mark.parametrize("duplicates", [False, True])
98
+ def test_old_consistent_duplicate_markers_remain_replayable(repeat: bool, duplicates: bool) -> None:
99
+ history = [version_marker(1)]
100
+ if duplicates:
101
+ history.append(version_marker(2))
102
+ history += old_activity(3 if duplicates else 2)
103
+ commands = replay(RepeatedPatchWorkflow, history, [repeat]).commands
104
+ assert len(commands) == 1
105
+ assert isinstance(commands[0], CompleteWorkflow)
106
+ assert commands[0].result == [True, True, "recorded"]
107
+
108
+
109
+ @pytest.mark.parametrize("repeat", [False, True])
110
+ def test_duplicate_markers_after_an_activity_remain_replayable(repeat: bool) -> None:
111
+ history = [version_marker(1)] + old_activity(2) + [version_marker(3)]
112
+ commands = replay(InterleavedPatchWorkflow, history, [repeat]).commands
113
+ assert len(commands) == 1
114
+ assert isinstance(commands[0], CompleteWorkflow)
115
+ assert commands[0].result == [True, True, "recorded"]
116
+
117
+
118
+ def test_repeated_version_reuses_first_decision() -> None:
119
+ commands = replay(VersionRangeWorkflow, [], []).commands
120
+ assert [type(command) for command in commands] == [RecordVersionMarker, CompleteWorkflow]
121
+ assert commands[-1].result == [2, 2]
122
+
123
+
124
+ @pytest.mark.parametrize("arguments", [[3], [1, True]])
125
+ def test_repeated_version_rejects_incompatible_range_or_family(arguments: list[Any]) -> None:
126
+ with pytest.raises(NonDeterministicReplayError):
127
+ replay(VersionRangeWorkflow, [], arguments)
128
+
129
+
130
+ @pytest.mark.parametrize("version", [-1, 2, True])
131
+ def test_conflicting_duplicate_marker_is_rejected(version: Any) -> None:
132
+ history = [version_marker(1), version_marker(2, version=version)] + old_activity(3)
133
+ with pytest.raises(NonDeterministicReplayError):
134
+ replay(RepeatedPatchWorkflow, history, [])
135
+
136
+
137
+ @pytest.mark.parametrize("marker_count", [0, 1, 2])
138
+ def test_patch_decisions_preserve_committed_cancellation_sequence(marker_count: int) -> None:
139
+ history = [version_marker(sequence) for sequence in range(1, marker_count + 1)]
140
+ history += old_activity(marker_count + 1, False)
141
+ history += [request(), cancellation_marker(marker_count + 1)]
142
+ commands = replay(PatchCancellationWorkflow, history, [], run_id="run-1").commands
143
+ assert len(commands) == 1
144
+ assert isinstance(commands[0], CompleteWorkflow)
145
+ assert commands[0].result == [[marker_count > 0, marker_count > 0], "cancelled"]
@@ -24,6 +24,7 @@ from tests.test_golden_history_replay import (
24
24
  GoldenTimeoutWaitWorkflow,
25
25
  GoldenVersionMarkerWorkflow,
26
26
  )
27
+ from tests.test_patch_history import PatchCancellationWorkflow, PatchInsertionWorkflow, RepeatedPatchWorkflow
27
28
  from tests.test_pending_operation_replay import PendingOperation
28
29
  from tests.test_update_signal_condition_replay import (
29
30
  PostConditionReceiversWorkflow,
@@ -250,7 +251,15 @@ class ChildPolicyAuthorWorkflow:
250
251
  return (yield ctx.start_child_workflow("child", []))
251
252
 
252
253
 
254
+ class UnsupportedAsyncWorkflow:
255
+ __workflow_name__ = "tests.replay.unsupported-async"
256
+
257
+ async def run(self, ctx: WorkflowContext) -> str:
258
+ return "body was never executed"
259
+
260
+
253
261
  WORKFLOWS = [
262
+ UnsupportedAsyncWorkflow,
254
263
  ChildPolicyAuthorWorkflow,
255
264
  ColdReplacementSatisfiedConditionWorkflow,
256
265
  CooperativeReopenedConditionCleanupWorkflow,
@@ -266,6 +275,9 @@ WORKFLOWS = [
266
275
  NestedParallelPathWorkflow,
267
276
  ParallelMetadataProducerWorkflow,
268
277
  ParallelResultBindingWorkflow,
278
+ PatchCancellationWorkflow,
279
+ PatchInsertionWorkflow,
280
+ RepeatedPatchWorkflow,
269
281
  PendingOperation,
270
282
  PagedRecordedSideEffectsWorkflow,
271
283
  PostConditionReceiversWorkflow,
@@ -0,0 +1,116 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import AsyncIterator, Generator
4
+ from functools import partial, wraps
5
+ from typing import Any
6
+
7
+ import pytest
8
+
9
+ from durable_workflow import Client, Worker, workflow
10
+ from durable_workflow.testing import WorkflowEnvironment
11
+
12
+
13
+ class CoroutineWorkflow:
14
+ async def run(self, ctx: Any) -> str:
15
+ return "never executed"
16
+
17
+
18
+ class AsyncGeneratorWorkflow:
19
+ async def run(self, ctx: Any) -> AsyncIterator[str]:
20
+ yield "unsupported"
21
+
22
+
23
+ class AsyncCallable:
24
+ async def __call__(self, ctx: Any) -> str:
25
+ return "unsupported"
26
+
27
+
28
+ class CallableWorkflow:
29
+ run = AsyncCallable()
30
+
31
+
32
+ class PartialWorkflow:
33
+ run = staticmethod(partial(CoroutineWorkflow.run, object()))
34
+
35
+
36
+ ASYNC_DEFINITIONS = [CoroutineWorkflow, AsyncGeneratorWorkflow, CallableWorkflow, PartialWorkflow]
37
+
38
+
39
+ @pytest.mark.parametrize("definition", ASYNC_DEFINITIONS)
40
+ def test_definition_rejects_async_entrypoints(definition: type) -> None:
41
+ with pytest.raises(ValueError, match="unsupported_workflow_entrypoint.*synchronous"):
42
+ workflow.defn(name="invalid-async")(definition)
43
+
44
+
45
+ @pytest.mark.parametrize("definition", ASYNC_DEFINITIONS)
46
+ def test_worker_rejects_undecorated_async_entrypoints_before_network(definition: type) -> None:
47
+ with pytest.raises(ValueError, match=definition.__name__):
48
+ Worker(Client("http://127.0.0.1:1"), task_queue="unused", workflows=[definition])
49
+
50
+
51
+ @pytest.mark.parametrize("definition", ASYNC_DEFINITIONS)
52
+ def test_direct_replay_rejects_async_without_creating_a_coroutine(definition: type) -> None:
53
+ with pytest.raises(ValueError, match="unsupported_workflow_entrypoint"):
54
+ workflow.replay(definition, [], [])
55
+
56
+
57
+ def test_test_environment_revalidates_entrypoint_after_decoration() -> None:
58
+ @workflow.defn(name="changed-entrypoint")
59
+ class ChangedWorkflow:
60
+ def run(self, ctx: Any) -> str:
61
+ return "valid"
62
+
63
+ ChangedWorkflow.run = CoroutineWorkflow.run # type: ignore[assignment]
64
+ with pytest.raises(ValueError, match="changed-entrypoint"):
65
+ WorkflowEnvironment().register_workflow(ChangedWorkflow)
66
+
67
+
68
+ def test_wrapped_and_inherited_async_entrypoints_are_rejected() -> None:
69
+ class InheritedWorkflow(CoroutineWorkflow):
70
+ pass
71
+
72
+ class WrappedWorkflow:
73
+ @wraps(CoroutineWorkflow.run)
74
+ def run(self, ctx: Any) -> Any:
75
+ return CoroutineWorkflow.run(self, ctx)
76
+
77
+ for definition in [InheritedWorkflow, WrappedWorkflow]:
78
+ with pytest.raises(ValueError, match="unsupported_workflow_entrypoint"):
79
+ workflow.replay(definition, [], [])
80
+
81
+
82
+ @pytest.mark.parametrize("generator", [False, True])
83
+ def test_runtime_awaitable_result_is_not_completed(generator: bool) -> None:
84
+ class AwaitableResultWorkflow:
85
+ def run(self, ctx: Any) -> Any:
86
+ return CoroutineWorkflow.run(self, ctx)
87
+
88
+ class GeneratorAwaitableResultWorkflow:
89
+ def run(self, ctx: Any) -> Generator[Any, Any, Any]:
90
+ if False:
91
+ yield None
92
+ return CoroutineWorkflow.run(self, ctx)
93
+
94
+ definition = GeneratorAwaitableResultWorkflow if generator else AwaitableResultWorkflow
95
+ with pytest.raises(ValueError, match="unsupported_workflow_result"):
96
+ workflow.replay(definition, [], [])
97
+
98
+
99
+ def test_synchronous_and_generator_workflows_remain_supported() -> None:
100
+ @workflow.defn(name="valid-sync")
101
+ class SynchronousWorkflow:
102
+ def run(self, ctx: Any) -> str:
103
+ return "done"
104
+
105
+ @workflow.defn(name="valid-generator")
106
+ class GeneratorWorkflow:
107
+ def run(self, ctx: Any) -> Generator[Any, Any, str]:
108
+ if False:
109
+ yield None
110
+ return "done"
111
+
112
+ Worker(Client("http://127.0.0.1:1"), task_queue="unused", workflows=[SynchronousWorkflow, GeneratorWorkflow])
113
+ environment = WorkflowEnvironment()
114
+ for definition in [SynchronousWorkflow, GeneratorWorkflow]:
115
+ assert environment.execute_workflow(definition) == "done"
116
+ assert workflow.replay(definition, [], []).commands[0].result == "done" # type: ignore[union-attr]