durable-workflow 2.5.2__tar.gz → 2.5.4__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 (127) hide show
  1. {durable_workflow-2.5.2/src/durable_workflow.egg-info → durable_workflow-2.5.4}/PKG-INFO +1 -1
  2. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/pyproject.toml +3 -3
  3. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/src/durable_workflow/__init__.py +2 -0
  4. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/src/durable_workflow/errors.py +24 -0
  5. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/src/durable_workflow/testing.py +5 -1
  6. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/src/durable_workflow/worker.py +43 -16
  7. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/src/durable_workflow/workflow.py +46 -33
  8. {durable_workflow-2.5.2 → durable_workflow-2.5.4/src/durable_workflow.egg-info}/PKG-INFO +1 -1
  9. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/src/durable_workflow.egg-info/SOURCES.txt +2 -0
  10. durable_workflow-2.5.4/tests/test_condition_replay_drift.py +132 -0
  11. durable_workflow-2.5.4/tests/test_registration_ambiguity.py +193 -0
  12. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_replay_regression_corpus.py +13 -1
  13. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_wait_condition.py +10 -15
  14. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/LICENSE +0 -0
  15. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/README.md +0 -0
  16. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/setup.cfg +0 -0
  17. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/src/durable_workflow/_activity_process.py +0 -0
  18. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/src/durable_workflow/_avro.py +0 -0
  19. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/src/durable_workflow/_cancellation_scope.py +0 -0
  20. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/src/durable_workflow/_cancellation_scope_history.py +0 -0
  21. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/src/durable_workflow/_cooperative_cancellation.py +0 -0
  22. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/src/durable_workflow/_prepared_local_activity.py +0 -0
  23. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/src/durable_workflow/_sticky_workflow_cache.py +0 -0
  24. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/src/durable_workflow/activity.py +0 -0
  25. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/src/durable_workflow/auth_composition.py +0 -0
  26. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/src/durable_workflow/cancellation.py +0 -0
  27. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/src/durable_workflow/client.py +0 -0
  28. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/src/durable_workflow/external_storage.py +0 -0
  29. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/src/durable_workflow/external_task_input.py +0 -0
  30. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/src/durable_workflow/external_task_result.py +0 -0
  31. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/src/durable_workflow/history_bundle_verify.py +0 -0
  32. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/src/durable_workflow/interceptors.py +0 -0
  33. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/src/durable_workflow/invocable.py +0 -0
  34. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/src/durable_workflow/metrics.py +0 -0
  35. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/src/durable_workflow/nexus.py +0 -0
  36. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/src/durable_workflow/py.typed +0 -0
  37. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/src/durable_workflow/python_conformance.py +0 -0
  38. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/src/durable_workflow/replay_conformance.py +0 -0
  39. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/src/durable_workflow/replay_verify.py +0 -0
  40. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/src/durable_workflow/retry_policy.py +0 -0
  41. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/src/durable_workflow/serializer.py +0 -0
  42. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/src/durable_workflow/sync.py +0 -0
  43. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/src/durable_workflow/worker_session.py +0 -0
  44. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/src/durable_workflow/workflow_updates_conformance.py +0 -0
  45. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/src/durable_workflow.egg-info/dependency_links.txt +0 -0
  46. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/src/durable_workflow.egg-info/entry_points.txt +0 -0
  47. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/src/durable_workflow.egg-info/requires.txt +0 -0
  48. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/src/durable_workflow.egg-info/top_level.txt +0 -0
  49. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_activity_cancellation_policies.py +0 -0
  50. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_activity_context.py +0 -0
  51. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_activity_process.py +0 -0
  52. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_auth_composition.py +0 -0
  53. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_avro_value_benchmark.py +0 -0
  54. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_avro_value_protocol.py +0 -0
  55. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_cancellation_context.py +0 -0
  56. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_cancellation_remaining_time.py +0 -0
  57. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_cancellation_scope_admission.py +0 -0
  58. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_cancellation_scope_authoring.py +0 -0
  59. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_cancellation_scope_boundary.py +0 -0
  60. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_cancellation_scope_opening.py +0 -0
  61. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_child_workflow_policies.py +0 -0
  62. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_ci_checkout.py +0 -0
  63. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_ci_integration_endpoint.py +0 -0
  64. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_client.py +0 -0
  65. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_client_timeouts.py +0 -0
  66. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_codec_regression_corpus.py +0 -0
  67. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_committed_cancellation_scope_descendants.py +0 -0
  68. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_committed_cancellation_scope_groups.py +0 -0
  69. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_committed_cancellation_scope_history.py +0 -0
  70. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_committed_cancellation_scope_replay.py +0 -0
  71. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_control_plane_parity_fixtures.py +0 -0
  72. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_cooperative_cancellation.py +0 -0
  73. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_cooperative_cancellation_client.py +0 -0
  74. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_cooperative_cancellation_worker.py +0 -0
  75. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_cooperative_remote_worker.py +0 -0
  76. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_docstring_cross_references.py +0 -0
  77. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_durable_selection.py +0 -0
  78. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_errors.py +0 -0
  79. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_external_storage.py +0 -0
  80. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_external_task_input.py +0 -0
  81. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_external_task_result.py +0 -0
  82. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_golden_history_replay.py +0 -0
  83. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_history_bundle_verify.py +0 -0
  84. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_history_event_contract.py +0 -0
  85. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_invocable.py +0 -0
  86. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_message_streams.py +0 -0
  87. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_metrics.py +0 -0
  88. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_order_processing_example.py +0 -0
  89. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_parallel_completion_order_replay.py +0 -0
  90. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_parallel_saga_authoring.py +0 -0
  91. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_patch_history.py +0 -0
  92. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_pending_operation_replay.py +0 -0
  93. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_prepared_local_activity_client.py +0 -0
  94. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_prepared_local_activity_groups.py +0 -0
  95. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_prepared_local_activity_policies.py +0 -0
  96. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_prepared_local_activity_worker.py +0 -0
  97. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_public_boundary_scanner.py +0 -0
  98. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_pypi_project_surface.py +0 -0
  99. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_python_conformance.py +0 -0
  100. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_queries.py +0 -0
  101. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_readme_quickstart.py +0 -0
  102. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_redrive_replay.py +0 -0
  103. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_release_metadata.py +0 -0
  104. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_replay.py +0 -0
  105. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_replay_conformance.py +0 -0
  106. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_replay_verify.py +0 -0
  107. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_retry_policy.py +0 -0
  108. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_run_cancellation_scope_replay.py +0 -0
  109. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_runtime_external_payload_transport.py +0 -0
  110. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_schedules.py +0 -0
  111. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_scoped_run_cancellation_context.py +0 -0
  112. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_serializer.py +0 -0
  113. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_signals.py +0 -0
  114. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_sleep.py +0 -0
  115. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_standalone_activity_client.py +0 -0
  116. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_sticky_execution.py +0 -0
  117. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_storage_admission.py +0 -0
  118. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_sync.py +0 -0
  119. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_testing_harness.py +0 -0
  120. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_update_signal_condition_replay.py +0 -0
  121. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_updates.py +0 -0
  122. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_worker.py +0 -0
  123. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_worker_session.py +0 -0
  124. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_workflow_entrypoint_validation.py +0 -0
  125. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_workflow_result_timeout.py +0 -0
  126. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_workflow_streams.py +0 -0
  127. {durable_workflow-2.5.2 → durable_workflow-2.5.4}/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.2
3
+ Version: 2.5.4
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.2"
7
+ version = "2.5.4"
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.5.2"
75
- registry-version = "2.5.2"
74
+ product-train = "2.5.4"
75
+ registry-version = "2.5.4"
76
76
  supported-server-versions = "2.5.16"
77
77
  worker-protocol-version = "1.19"
78
78
  control-plane-version = "2"
@@ -70,6 +70,7 @@ from .errors import (
70
70
  ChildWorkflowCancelled,
71
71
  ChildWorkflowFailed,
72
72
  ChildWorkflowTerminated,
73
+ DuplicateRegistrationError,
73
74
  DurableOperationCancelled,
74
75
  DurableWorkflowError,
75
76
  ExternalPayloadError,
@@ -370,6 +371,7 @@ __all__ = [
370
371
  "NexusOperationResult",
371
372
  "NexusServiceCall",
372
373
  "NonDeterministicReplayError",
374
+ "DuplicateRegistrationError",
373
375
  "InvalidWorkflowDefinition",
374
376
  "NoopMetrics",
375
377
  "QueryFailed",
@@ -31,6 +31,30 @@ class InvalidWorkflowDefinition(DurableWorkflowError, ValueError):
31
31
  """Workflow authoring uses an unsupported entry point or result."""
32
32
 
33
33
 
34
+ class DuplicateRegistrationError(DurableWorkflowError, ValueError):
35
+ """One registration scope assigns a wire name to multiple definitions."""
36
+
37
+ def __init__(
38
+ self, registration_kind: str, wire_name: str,
39
+ first_definition: object, second_definition: object,
40
+ ) -> None:
41
+ def label(definition: object) -> str:
42
+ if isinstance(definition, str):
43
+ return definition
44
+ module = getattr(definition, "__module__", type(definition).__module__)
45
+ name = getattr(definition, "__qualname__", type(definition).__qualname__)
46
+ return f"{module}.{name}"
47
+
48
+ self.registration_kind = registration_kind
49
+ self.wire_name = wire_name
50
+ self.first_definition = label(first_definition)
51
+ self.second_definition = label(second_definition)
52
+ super().__init__(
53
+ f"duplicate {registration_kind} name {wire_name!r}: "
54
+ f"{self.first_definition} conflicts with {self.second_definition}"
55
+ )
56
+
57
+
34
58
  class DurableOperationCancelled(DurableWorkflowError):
35
59
  """A durable selection member was explicitly cancelled."""
36
60
 
@@ -39,7 +39,7 @@ from pathlib import Path
39
39
  from typing import Any
40
40
 
41
41
  from . import serializer
42
- from .errors import WorkflowCancelled, WorkflowFailed, WorkflowTerminated
42
+ from .errors import DuplicateRegistrationError, WorkflowCancelled, WorkflowFailed, WorkflowTerminated
43
43
  from .workflow import (
44
44
  CompleteWorkflow,
45
45
  ContinueAsNew,
@@ -52,6 +52,7 @@ from .workflow import (
52
52
  StartTimer,
53
53
  UpsertSearchAttributes,
54
54
  _validate_workflow_entrypoint,
55
+ _workflow_handler_registries,
55
56
  registry,
56
57
  replay,
57
58
  )
@@ -136,6 +137,9 @@ class WorkflowEnvironment:
136
137
  "decorate it with @workflow.defn(name=...) first."
137
138
  )
138
139
  _validate_workflow_entrypoint(workflow_cls)
140
+ _workflow_handler_registries(workflow_cls)
141
+ if name in self._registered_workflows and self._registered_workflows[name] is not workflow_cls:
142
+ raise DuplicateRegistrationError("workflow", name, self._registered_workflows[name], workflow_cls)
139
143
  self._registered_workflows[name] = workflow_cls
140
144
 
141
145
  def signal(
@@ -66,9 +66,11 @@ from .errors import (
66
66
  ActivityCancelled,
67
67
  ActivityFailed,
68
68
  AvroNotInstalledError,
69
+ DuplicateRegistrationError,
69
70
  DurableWorkflowError,
70
71
  ExternalPayloadError,
71
72
  InvalidArgument,
73
+ NonDeterministicReplayError,
72
74
  NonRetryableError,
73
75
  QueryFailed,
74
76
  ServerError,
@@ -105,6 +107,7 @@ from .workflow import (
105
107
  StartChildWorkflow,
106
108
  UpsertMemo,
107
109
  _validate_workflow_entrypoint,
110
+ _workflow_handler_registries,
108
111
  apply_update,
109
112
  commands_to_server_commands,
110
113
  query_state,
@@ -1027,7 +1030,11 @@ class Worker:
1027
1030
  self.workflows: dict[str, type] = {}
1028
1031
  for definition in workflows:
1029
1032
  _validate_workflow_entrypoint(definition)
1030
- self.workflows[_workflow_name(definition)] = definition
1033
+ _workflow_handler_registries(definition)
1034
+ name = _workflow_name(definition)
1035
+ if name in self.workflows:
1036
+ raise DuplicateRegistrationError("workflow", name, self.workflows[name], definition)
1037
+ self.workflows[name] = definition
1031
1038
  self.workflow_definition_fingerprints = {
1032
1039
  workflow_type: _workflow_definition_fingerprint(workflow_cls)
1033
1040
  for workflow_type, workflow_cls in self.workflows.items()
@@ -1036,7 +1043,12 @@ class Worker:
1036
1043
  workflow_type: _workflow_command_contract(workflow_cls)
1037
1044
  for workflow_type, workflow_cls in self.workflows.items()
1038
1045
  }
1039
- self.activities = {_activity_name(a): a for a in activities}
1046
+ self.activities: dict[str, Callable[..., Any]] = {}
1047
+ for activity_definition in activities:
1048
+ name = _activity_name(activity_definition)
1049
+ if name in self.activities:
1050
+ raise DuplicateRegistrationError("activity", name, self.activities[name], activity_definition)
1051
+ self.activities[name] = activity_definition
1040
1052
  self.capabilities = tuple(dict.fromkeys(capability.strip() for capability in capabilities))
1041
1053
  self._sticky_cache = StickyWorkflowCache(
1042
1054
  sticky_cache_capacity, sticky_cache_max_bytes, sticky_cache_ttl_seconds,
@@ -2502,20 +2514,35 @@ class Worker:
2502
2514
 
2503
2515
  update_id = task.get("workflow_update_id")
2504
2516
  if isinstance(update_id, str) and update_id:
2505
- update_command = apply_update(
2506
- cls,
2507
- history,
2508
- start_input,
2509
- update_id,
2510
- workflow_id=task.get("workflow_id"),
2511
- run_id=run_id,
2512
- payload_codec=codec,
2513
- external_storage=self.external_storage,
2514
- external_storage_cache=self.external_storage_cache,
2515
- prepare_local_activities=self._prepared_local_activities_supported,
2516
- prepare_local_activity_groups=self._prepared_local_activity_groups_supported,
2517
- local_activity_cancellation_policies=self._local_activity_cancellation_policies,
2518
- )
2517
+ try:
2518
+ update_command = apply_update(
2519
+ cls,
2520
+ history,
2521
+ start_input,
2522
+ update_id,
2523
+ workflow_id=task.get("workflow_id"),
2524
+ run_id=run_id,
2525
+ payload_codec=codec,
2526
+ external_storage=self.external_storage,
2527
+ external_storage_cache=self.external_storage_cache,
2528
+ prepare_local_activities=self._prepared_local_activities_supported,
2529
+ prepare_local_activity_groups=self._prepared_local_activity_groups_supported,
2530
+ local_activity_cancellation_policies=self._local_activity_cancellation_policies,
2531
+ )
2532
+ except NonDeterministicReplayError as exc:
2533
+ log.exception("workflow update task %s replay failed for run %s", task_id, run_id)
2534
+ try:
2535
+ await self.client.fail_workflow_task(
2536
+ task_id=task_id,
2537
+ lease_owner=self.worker_id,
2538
+ workflow_task_attempt=attempt,
2539
+ message=f"workflow replay failed before update {update_id!r}: {exc}",
2540
+ failure_type=type(exc).__name__,
2541
+ stack_trace=traceback.format_exc(),
2542
+ )
2543
+ except Exception as failure_error:
2544
+ log.warning("failed to report update replay failure: %s", failure_error)
2545
+ return None
2519
2546
  command = update_command.to_server_command(
2520
2547
  self.task_queue,
2521
2548
  payload_codec=command_codec,
@@ -58,6 +58,7 @@ from .errors import (
58
58
  ChildWorkflowCancelled,
59
59
  ChildWorkflowFailed,
60
60
  ChildWorkflowTerminated,
61
+ DuplicateRegistrationError,
61
62
  DurableOperationCancelled,
62
63
  InvalidWorkflowDefinition,
63
64
  NexusOperationFailed,
@@ -124,6 +125,29 @@ def _validate_workflow_result(value: Any, cls: type) -> None:
124
125
  )
125
126
 
126
127
 
128
+ def _workflow_handler_registries(
129
+ cls: type,
130
+ ) -> tuple[dict[str, str], dict[str, str], dict[str, str], dict[str, str]]:
131
+ handlers: dict[str, dict[str, str]] = {kind: {} for kind in ("signal", "query", "update", "update_validator")}
132
+ for attr in dir(cls):
133
+ if attr.startswith("_"):
134
+ continue
135
+ member = getattr(cls, attr, None)
136
+ for kind, registry in handlers.items():
137
+ wire_name = getattr(member, f"__{kind}_name__", None)
138
+ if not isinstance(wire_name, str) or not wire_name:
139
+ continue
140
+ if kind == "signal" and wire_name == MESSAGE_STREAM_SIGNAL:
141
+ raise ValueError(f"signal name {wire_name!r} is reserved by the workflow runtime")
142
+ if wire_name in registry:
143
+ owner = f"{cls.__module__}.{cls.__qualname__}"
144
+ raise DuplicateRegistrationError(
145
+ kind, wire_name, f"{owner}.{registry[wire_name]}", f"{owner}.{attr}",
146
+ )
147
+ registry[wire_name] = attr
148
+ return handlers["signal"], handlers["query"], handlers["update"], handlers["update_validator"]
149
+
150
+
127
151
  def defn(*, name: str) -> Callable[[type[_WorkflowT]], type[_WorkflowT]]:
128
152
  """Register a class as a workflow type under a language-neutral name.
129
153
 
@@ -136,28 +160,7 @@ def defn(*, name: str) -> Callable[[type[_WorkflowT]], type[_WorkflowT]]:
136
160
  def wrap(cls: type[_WorkflowT]) -> type[_WorkflowT]:
137
161
  cls.__workflow_name__ = name # type: ignore[attr-defined]
138
162
  _validate_workflow_entrypoint(cls)
139
- signals: dict[str, str] = {}
140
- queries: dict[str, str] = {}
141
- updates: dict[str, str] = {}
142
- update_validators: dict[str, str] = {}
143
- for attr in dir(cls):
144
- if attr.startswith("_"):
145
- continue
146
- member = getattr(cls, attr, None)
147
- signal_name = getattr(member, "__signal_name__", None)
148
- if isinstance(signal_name, str) and signal_name:
149
- if signal_name == MESSAGE_STREAM_SIGNAL:
150
- raise ValueError(f"signal name {signal_name!r} is reserved by the workflow runtime")
151
- signals[signal_name] = attr
152
- query_name = getattr(member, "__query_name__", None)
153
- if isinstance(query_name, str) and query_name:
154
- queries[query_name] = attr
155
- update_name = getattr(member, "__update_name__", None)
156
- if isinstance(update_name, str) and update_name:
157
- updates[update_name] = attr
158
- update_validator_name = getattr(member, "__update_validator_name__", None)
159
- if isinstance(update_validator_name, str) and update_validator_name:
160
- update_validators[update_validator_name] = attr
163
+ signals, queries, updates, update_validators = _workflow_handler_registries(cls)
161
164
  cls.__workflow_signals__ = signals # type: ignore[attr-defined]
162
165
  cls.__workflow_queries__ = queries # type: ignore[attr-defined]
163
166
  cls.__workflow_updates__ = updates # type: ignore[attr-defined]
@@ -2578,9 +2581,12 @@ class Replayer:
2578
2581
  def __init__(self, *, workflows: Iterable[type]) -> None:
2579
2582
  self._workflows: dict[str, type] = {}
2580
2583
  for workflow_cls in workflows:
2584
+ _workflow_handler_registries(workflow_cls)
2581
2585
  workflow_type = str(getattr(workflow_cls, "__workflow_name__", workflow_cls.__name__))
2582
2586
  if workflow_type in self._workflows:
2583
- raise ValueError(f"duplicate workflow type registered with Replayer: {workflow_type!r}")
2587
+ raise DuplicateRegistrationError(
2588
+ "workflow", workflow_type, self._workflows[workflow_type], workflow_cls,
2589
+ )
2584
2590
  self._workflows[workflow_type] = workflow_cls
2585
2591
  if not self._workflows:
2586
2592
  raise ValueError("Replayer requires at least one workflow class")
@@ -3067,6 +3073,8 @@ def apply_update(
3067
3073
  prepare_local_activity_groups=prepare_local_activity_groups,
3068
3074
  local_activity_cancellation_policies=local_activity_cancellation_policies,
3069
3075
  )
3076
+ except NonDeterministicReplayError:
3077
+ raise
3070
3078
  except Exception as exc:
3071
3079
  return _fail_update_from_exception(
3072
3080
  update_id,
@@ -5144,16 +5152,20 @@ def _replay_state(
5144
5152
  for _ in range(boundary):
5145
5153
  _apply_receiver(pending_receivers.pop(0))
5146
5154
 
5147
- def _condition_wait_mismatch(opened: Mapping[str, Any], cmd: WaitCondition) -> FailWorkflow | None:
5155
+ def _condition_wait_mismatch(
5156
+ opened: Mapping[str, Any], cmd: WaitCondition,
5157
+ ) -> NonDeterministicReplayError | None:
5158
+ step = recorded_wait_steps[wait_yield_count]
5159
+ identity = f"condition wait {opened.get('condition_wait_id')!r}"
5148
5160
  opened_key = opened.get("condition_key")
5149
5161
  if isinstance(opened_key, str) and opened_key != (cmd.condition_key or ""):
5150
- return FailWorkflow(
5151
- message=(
5152
- "wait_condition key changed during replay: "
5162
+ return NonDeterministicReplayError(
5163
+ step.workflow_sequence, "condition wait", step.event_types,
5164
+ detail=(
5165
+ f"condition_key_changed: {identity} wait_condition key changed during replay: "
5153
5166
  f"history has {opened_key!r}, workflow yielded "
5154
5167
  f"{cmd.condition_key!r}"
5155
5168
  ),
5156
- exception_type="NonDeterministicWaitCondition",
5157
5169
  )
5158
5170
 
5159
5171
  opened_fingerprint = opened.get("condition_definition_fingerprint")
@@ -5161,13 +5173,14 @@ def _replay_state(
5161
5173
  isinstance(opened_fingerprint, str)
5162
5174
  and cmd.condition_definition_fingerprint != opened_fingerprint
5163
5175
  ):
5164
- return FailWorkflow(
5165
- message=(
5176
+ return NonDeterministicReplayError(
5177
+ step.workflow_sequence, "condition wait", step.event_types,
5178
+ detail=(
5179
+ f"condition_fingerprint_changed: {identity} "
5166
5180
  "wait_condition predicate fingerprint changed during replay: "
5167
5181
  f"history has {opened_fingerprint!r}, workflow yielded "
5168
5182
  f"{cmd.condition_definition_fingerprint!r}"
5169
5183
  ),
5170
- exception_type="NonDeterministicWaitCondition",
5171
5184
  )
5172
5185
 
5173
5186
  return None
@@ -6655,7 +6668,7 @@ def _replay_state(
6655
6668
  next_value = True
6656
6669
  continue
6657
6670
  if next_opened_mismatch is not None:
6658
- return _state([next_opened_mismatch])
6671
+ raise next_opened_mismatch
6659
6672
  step = _next_unconsumed_recorded_step()
6660
6673
  if step is not None:
6661
6674
  _assert_step_matches(cmd, step)
@@ -6707,7 +6720,7 @@ def _replay_state(
6707
6720
  opened = wait_opened[wait_yield_count]
6708
6721
  mismatch = _condition_wait_mismatch(opened, cmd)
6709
6722
  if mismatch is not None:
6710
- return _state([mismatch])
6723
+ raise mismatch
6711
6724
  opened_id = opened.get("condition_wait_id")
6712
6725
  if isinstance(opened_id, str):
6713
6726
  resolution = wait_resolutions.get(opened_id)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: durable-workflow
3
- Version: 2.5.2
3
+ Version: 2.5.4
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
@@ -62,6 +62,7 @@ tests/test_committed_cancellation_scope_descendants.py
62
62
  tests/test_committed_cancellation_scope_groups.py
63
63
  tests/test_committed_cancellation_scope_history.py
64
64
  tests/test_committed_cancellation_scope_replay.py
65
+ tests/test_condition_replay_drift.py
65
66
  tests/test_control_plane_parity_fixtures.py
66
67
  tests/test_cooperative_cancellation.py
67
68
  tests/test_cooperative_cancellation_client.py
@@ -94,6 +95,7 @@ tests/test_python_conformance.py
94
95
  tests/test_queries.py
95
96
  tests/test_readme_quickstart.py
96
97
  tests/test_redrive_replay.py
98
+ tests/test_registration_ambiguity.py
97
99
  tests/test_release_metadata.py
98
100
  tests/test_replay.py
99
101
  tests/test_replay_conformance.py
@@ -0,0 +1,132 @@
1
+ from __future__ import annotations
2
+
3
+ from unittest.mock import AsyncMock
4
+
5
+ import pytest
6
+
7
+ from durable_workflow import Worker, serializer, workflow
8
+ from durable_workflow.client import Client
9
+ from durable_workflow.errors import NonDeterministicReplayError, QueryFailed
10
+ from durable_workflow.workflow import WorkflowContext, apply_update, query_state, replay
11
+
12
+
13
+ @workflow.defn(name="tests.replay.condition-identity")
14
+ class ConditionIdentityWorkflow:
15
+ @workflow.query("state")
16
+ def state(self) -> str:
17
+ return "waiting"
18
+
19
+ @workflow.update("approve")
20
+ def approve(self) -> str:
21
+ return "approved"
22
+
23
+ def run(self, ctx: WorkflowContext): # type: ignore[no-untyped-def]
24
+ yield ctx.wait_condition(lambda: False, key="approved")
25
+ return "done"
26
+
27
+
28
+ def condition_history(field: str) -> list[dict]:
29
+ return [{
30
+ "event_type": "ConditionWaitOpened",
31
+ "payload": {
32
+ "sequence": 7,
33
+ "condition_wait_id": "wait-original",
34
+ "condition_key": "approved",
35
+ field: "old-key" if field == "condition_key" else "sha256:different",
36
+ },
37
+ }]
38
+
39
+
40
+ @pytest.mark.parametrize("field", ["condition_key", "condition_definition_fingerprint"])
41
+ @pytest.mark.parametrize("consumer", ["replay", "query", "update"])
42
+ def test_condition_drift_is_not_a_business_failure(field: str, consumer: str) -> None:
43
+ history = condition_history(field)
44
+ error = QueryFailed if consumer == "query" else NonDeterministicReplayError
45
+ with pytest.raises(error) as captured:
46
+ if consumer == "query":
47
+ query_state(ConditionIdentityWorkflow, history, [], "state")
48
+ elif consumer == "update":
49
+ apply_update(ConditionIdentityWorkflow, history, [], "update-original")
50
+ else:
51
+ replay(ConditionIdentityWorkflow, history, [])
52
+
53
+ mismatch = captured.value.__cause__ if consumer == "query" else captured.value
54
+ assert isinstance(mismatch, NonDeterministicReplayError)
55
+ assert mismatch.workflow_sequence == 7
56
+ assert "wait-original" in str(mismatch)
57
+ assert "changed" in str(mismatch)
58
+
59
+
60
+ @pytest.mark.asyncio
61
+ @pytest.mark.parametrize("field", ["condition_key", "condition_definition_fingerprint"])
62
+ @pytest.mark.parametrize("consumer", ["workflow", "update"])
63
+ async def test_worker_reports_drift_once_without_a_completion(field: str, consumer: str) -> None:
64
+ client = AsyncMock(spec=Client)
65
+ worker = Worker(client, task_queue="condition-drift", workflows=[ConditionIdentityWorkflow])
66
+ task = {
67
+ "task_id": "task-original",
68
+ "workflow_id": "workflow-original",
69
+ "run_id": "run-original",
70
+ "workflow_type": "tests.replay.condition-identity",
71
+ "workflow_task_attempt": 2,
72
+ "history_events": condition_history(field),
73
+ "arguments": serializer.envelope([], codec="avro"),
74
+ "payload_codec": "avro",
75
+ }
76
+ if consumer == "update":
77
+ task["workflow_update_id"] = "update-original"
78
+
79
+ assert await worker._run_workflow_task_core(task) is None
80
+
81
+ client.complete_workflow_task.assert_not_awaited()
82
+ client.fail_workflow_task.assert_awaited_once()
83
+ report = client.fail_workflow_task.await_args.kwargs
84
+ assert report["task_id"] == "task-original"
85
+ assert report["workflow_task_attempt"] == 2
86
+ assert report["lease_owner"] == worker.worker_id
87
+ assert report["failure_type"] == "NonDeterministicReplayError"
88
+ assert "wait-original" in report["message"]
89
+
90
+
91
+ def test_application_predicate_failure_remains_terminal() -> None:
92
+ @workflow.defn(name="tests.condition-predicate-failure")
93
+ class PredicateFailure:
94
+ def run(self, ctx: WorkflowContext): # type: ignore[no-untyped-def]
95
+ def predicate() -> bool:
96
+ raise ValueError("application failed")
97
+
98
+ yield ctx.wait_condition(predicate, key="approved")
99
+
100
+ outcome = replay(PredicateFailure, [], [])
101
+ assert len(outcome.commands) == 1
102
+ command = outcome.commands[0].to_server_command("condition-drift")
103
+ assert command["type"] == "fail_workflow"
104
+ assert command["exception_type"] == "ValueError"
105
+
106
+
107
+ @pytest.mark.asyncio
108
+ @pytest.mark.parametrize("field", ["condition_key", "condition_definition_fingerprint"])
109
+ async def test_query_worker_reports_unavailable_state_without_changing_the_run(field: str) -> None:
110
+ client = AsyncMock(spec=Client)
111
+ worker = Worker(client, task_queue="condition-drift", workflows=[ConditionIdentityWorkflow])
112
+ outcome = await worker._run_query_task({
113
+ "query_task_id": "query-original",
114
+ "query_task_attempt": 2,
115
+ "workflow_id": "workflow-original",
116
+ "run_id": "run-original",
117
+ "workflow_type": "tests.replay.condition-identity",
118
+ "query_name": "state",
119
+ "history_events": condition_history(field),
120
+ "workflow_arguments": serializer.envelope([], codec="avro"),
121
+ "query_arguments": serializer.envelope([], codec="avro"),
122
+ "payload_codec": "avro",
123
+ })
124
+
125
+ assert outcome == "failed"
126
+ client.complete_query_task.assert_not_awaited()
127
+ client.complete_workflow_task.assert_not_awaited()
128
+ client.fail_workflow_task.assert_not_awaited()
129
+ client.fail_query_task.assert_awaited_once()
130
+ report = client.fail_query_task.await_args.kwargs
131
+ assert report["reason"] == "query_workflow_state_unavailable"
132
+ assert "wait-original" in report["message"]