durable-workflow 2.5.1__tar.gz → 2.5.3__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 (126) hide show
  1. {durable_workflow-2.5.1/src/durable_workflow.egg-info → durable_workflow-2.5.3}/PKG-INFO +1 -1
  2. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/pyproject.toml +3 -3
  3. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/src/durable_workflow/__init__.py +2 -0
  4. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/src/durable_workflow/errors.py +4 -0
  5. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/src/durable_workflow/testing.py +2 -0
  6. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/src/durable_workflow/worker.py +35 -15
  7. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/src/durable_workflow/workflow.py +50 -12
  8. {durable_workflow-2.5.1 → durable_workflow-2.5.3/src/durable_workflow.egg-info}/PKG-INFO +1 -1
  9. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/src/durable_workflow.egg-info/SOURCES.txt +2 -0
  10. durable_workflow-2.5.3/tests/test_condition_replay_drift.py +132 -0
  11. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/tests/test_replay_regression_corpus.py +10 -0
  12. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/tests/test_wait_condition.py +10 -15
  13. durable_workflow-2.5.3/tests/test_workflow_entrypoint_validation.py +116 -0
  14. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/LICENSE +0 -0
  15. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/README.md +0 -0
  16. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/setup.cfg +0 -0
  17. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/src/durable_workflow/_activity_process.py +0 -0
  18. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/src/durable_workflow/_avro.py +0 -0
  19. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/src/durable_workflow/_cancellation_scope.py +0 -0
  20. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/src/durable_workflow/_cancellation_scope_history.py +0 -0
  21. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/src/durable_workflow/_cooperative_cancellation.py +0 -0
  22. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/src/durable_workflow/_prepared_local_activity.py +0 -0
  23. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/src/durable_workflow/_sticky_workflow_cache.py +0 -0
  24. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/src/durable_workflow/activity.py +0 -0
  25. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/src/durable_workflow/auth_composition.py +0 -0
  26. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/src/durable_workflow/cancellation.py +0 -0
  27. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/src/durable_workflow/client.py +0 -0
  28. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/src/durable_workflow/external_storage.py +0 -0
  29. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/src/durable_workflow/external_task_input.py +0 -0
  30. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/src/durable_workflow/external_task_result.py +0 -0
  31. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/src/durable_workflow/history_bundle_verify.py +0 -0
  32. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/src/durable_workflow/interceptors.py +0 -0
  33. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/src/durable_workflow/invocable.py +0 -0
  34. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/src/durable_workflow/metrics.py +0 -0
  35. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/src/durable_workflow/nexus.py +0 -0
  36. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/src/durable_workflow/py.typed +0 -0
  37. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/src/durable_workflow/python_conformance.py +0 -0
  38. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/src/durable_workflow/replay_conformance.py +0 -0
  39. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/src/durable_workflow/replay_verify.py +0 -0
  40. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/src/durable_workflow/retry_policy.py +0 -0
  41. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/src/durable_workflow/serializer.py +0 -0
  42. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/src/durable_workflow/sync.py +0 -0
  43. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/src/durable_workflow/worker_session.py +0 -0
  44. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/src/durable_workflow/workflow_updates_conformance.py +0 -0
  45. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/src/durable_workflow.egg-info/dependency_links.txt +0 -0
  46. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/src/durable_workflow.egg-info/entry_points.txt +0 -0
  47. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/src/durable_workflow.egg-info/requires.txt +0 -0
  48. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/src/durable_workflow.egg-info/top_level.txt +0 -0
  49. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/tests/test_activity_cancellation_policies.py +0 -0
  50. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/tests/test_activity_context.py +0 -0
  51. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/tests/test_activity_process.py +0 -0
  52. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/tests/test_auth_composition.py +0 -0
  53. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/tests/test_avro_value_benchmark.py +0 -0
  54. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/tests/test_avro_value_protocol.py +0 -0
  55. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/tests/test_cancellation_context.py +0 -0
  56. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/tests/test_cancellation_remaining_time.py +0 -0
  57. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/tests/test_cancellation_scope_admission.py +0 -0
  58. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/tests/test_cancellation_scope_authoring.py +0 -0
  59. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/tests/test_cancellation_scope_boundary.py +0 -0
  60. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/tests/test_cancellation_scope_opening.py +0 -0
  61. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/tests/test_child_workflow_policies.py +0 -0
  62. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/tests/test_ci_checkout.py +0 -0
  63. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/tests/test_ci_integration_endpoint.py +0 -0
  64. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/tests/test_client.py +0 -0
  65. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/tests/test_client_timeouts.py +0 -0
  66. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/tests/test_codec_regression_corpus.py +0 -0
  67. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/tests/test_committed_cancellation_scope_descendants.py +0 -0
  68. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/tests/test_committed_cancellation_scope_groups.py +0 -0
  69. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/tests/test_committed_cancellation_scope_history.py +0 -0
  70. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/tests/test_committed_cancellation_scope_replay.py +0 -0
  71. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/tests/test_control_plane_parity_fixtures.py +0 -0
  72. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/tests/test_cooperative_cancellation.py +0 -0
  73. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/tests/test_cooperative_cancellation_client.py +0 -0
  74. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/tests/test_cooperative_cancellation_worker.py +0 -0
  75. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/tests/test_cooperative_remote_worker.py +0 -0
  76. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/tests/test_docstring_cross_references.py +0 -0
  77. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/tests/test_durable_selection.py +0 -0
  78. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/tests/test_errors.py +0 -0
  79. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/tests/test_external_storage.py +0 -0
  80. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/tests/test_external_task_input.py +0 -0
  81. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/tests/test_external_task_result.py +0 -0
  82. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/tests/test_golden_history_replay.py +0 -0
  83. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/tests/test_history_bundle_verify.py +0 -0
  84. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/tests/test_history_event_contract.py +0 -0
  85. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/tests/test_invocable.py +0 -0
  86. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/tests/test_message_streams.py +0 -0
  87. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/tests/test_metrics.py +0 -0
  88. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/tests/test_order_processing_example.py +0 -0
  89. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/tests/test_parallel_completion_order_replay.py +0 -0
  90. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/tests/test_parallel_saga_authoring.py +0 -0
  91. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/tests/test_patch_history.py +0 -0
  92. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/tests/test_pending_operation_replay.py +0 -0
  93. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/tests/test_prepared_local_activity_client.py +0 -0
  94. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/tests/test_prepared_local_activity_groups.py +0 -0
  95. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/tests/test_prepared_local_activity_policies.py +0 -0
  96. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/tests/test_prepared_local_activity_worker.py +0 -0
  97. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/tests/test_public_boundary_scanner.py +0 -0
  98. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/tests/test_pypi_project_surface.py +0 -0
  99. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/tests/test_python_conformance.py +0 -0
  100. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/tests/test_queries.py +0 -0
  101. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/tests/test_readme_quickstart.py +0 -0
  102. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/tests/test_redrive_replay.py +0 -0
  103. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/tests/test_release_metadata.py +0 -0
  104. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/tests/test_replay.py +0 -0
  105. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/tests/test_replay_conformance.py +0 -0
  106. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/tests/test_replay_verify.py +0 -0
  107. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/tests/test_retry_policy.py +0 -0
  108. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/tests/test_run_cancellation_scope_replay.py +0 -0
  109. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/tests/test_runtime_external_payload_transport.py +0 -0
  110. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/tests/test_schedules.py +0 -0
  111. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/tests/test_scoped_run_cancellation_context.py +0 -0
  112. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/tests/test_serializer.py +0 -0
  113. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/tests/test_signals.py +0 -0
  114. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/tests/test_sleep.py +0 -0
  115. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/tests/test_standalone_activity_client.py +0 -0
  116. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/tests/test_sticky_execution.py +0 -0
  117. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/tests/test_storage_admission.py +0 -0
  118. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/tests/test_sync.py +0 -0
  119. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/tests/test_testing_harness.py +0 -0
  120. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/tests/test_update_signal_condition_replay.py +0 -0
  121. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/tests/test_updates.py +0 -0
  122. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/tests/test_worker.py +0 -0
  123. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/tests/test_worker_session.py +0 -0
  124. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/tests/test_workflow_result_timeout.py +0 -0
  125. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/tests/test_workflow_streams.py +0 -0
  126. {durable_workflow-2.5.1 → durable_workflow-2.5.3}/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.1
3
+ Version: 2.5.3
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.1"
7
+ version = "2.5.3"
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.1"
75
- registry-version = "2.5.1"
74
+ product-train = "2.5.3"
75
+ registry-version = "2.5.3"
76
76
  supported-server-versions = "2.5.16"
77
77
  worker-protocol-version = "1.19"
78
78
  control-plane-version = "2"
@@ -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(
@@ -69,6 +69,7 @@ from .errors import (
69
69
  DurableWorkflowError,
70
70
  ExternalPayloadError,
71
71
  InvalidArgument,
72
+ NonDeterministicReplayError,
72
73
  NonRetryableError,
73
74
  QueryFailed,
74
75
  ServerError,
@@ -104,6 +105,7 @@ from .workflow import (
104
105
  ScheduleActivity,
105
106
  StartChildWorkflow,
106
107
  UpsertMemo,
108
+ _validate_workflow_entrypoint,
107
109
  apply_update,
108
110
  commands_to_server_commands,
109
111
  query_state,
@@ -1023,7 +1025,10 @@ class Worker:
1023
1025
  ) -> None:
1024
1026
  self.client = client
1025
1027
  self.task_queue = task_queue
1026
- self.workflows = {_workflow_name(w): w for w in workflows}
1028
+ self.workflows: dict[str, type] = {}
1029
+ for definition in workflows:
1030
+ _validate_workflow_entrypoint(definition)
1031
+ self.workflows[_workflow_name(definition)] = definition
1027
1032
  self.workflow_definition_fingerprints = {
1028
1033
  workflow_type: _workflow_definition_fingerprint(workflow_cls)
1029
1034
  for workflow_type, workflow_cls in self.workflows.items()
@@ -2498,20 +2503,35 @@ class Worker:
2498
2503
 
2499
2504
  update_id = task.get("workflow_update_id")
2500
2505
  if isinstance(update_id, str) and update_id:
2501
- update_command = apply_update(
2502
- cls,
2503
- history,
2504
- start_input,
2505
- update_id,
2506
- workflow_id=task.get("workflow_id"),
2507
- run_id=run_id,
2508
- payload_codec=codec,
2509
- external_storage=self.external_storage,
2510
- external_storage_cache=self.external_storage_cache,
2511
- prepare_local_activities=self._prepared_local_activities_supported,
2512
- prepare_local_activity_groups=self._prepared_local_activity_groups_supported,
2513
- local_activity_cancellation_policies=self._local_activity_cancellation_policies,
2514
- )
2506
+ try:
2507
+ update_command = apply_update(
2508
+ cls,
2509
+ history,
2510
+ start_input,
2511
+ update_id,
2512
+ workflow_id=task.get("workflow_id"),
2513
+ run_id=run_id,
2514
+ payload_codec=codec,
2515
+ external_storage=self.external_storage,
2516
+ external_storage_cache=self.external_storage_cache,
2517
+ prepare_local_activities=self._prepared_local_activities_supported,
2518
+ prepare_local_activity_groups=self._prepared_local_activity_groups_supported,
2519
+ local_activity_cancellation_policies=self._local_activity_cancellation_policies,
2520
+ )
2521
+ except NonDeterministicReplayError as exc:
2522
+ log.exception("workflow update task %s replay failed for run %s", task_id, run_id)
2523
+ try:
2524
+ await self.client.fail_workflow_task(
2525
+ task_id=task_id,
2526
+ lease_owner=self.worker_id,
2527
+ workflow_task_attempt=attempt,
2528
+ message=f"workflow replay failed before update {update_id!r}: {exc}",
2529
+ failure_type=type(exc).__name__,
2530
+ stack_trace=traceback.format_exc(),
2531
+ )
2532
+ except Exception as failure_error:
2533
+ log.warning("failed to report update replay failure: %s", failure_error)
2534
+ return None
2515
2535
  command = update_command.to_server_command(
2516
2536
  self.task_queue,
2517
2537
  payload_codec=command_codec,
@@ -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] = {}
@@ -3039,6 +3067,8 @@ def apply_update(
3039
3067
  prepare_local_activity_groups=prepare_local_activity_groups,
3040
3068
  local_activity_cancellation_policies=local_activity_cancellation_policies,
3041
3069
  )
3070
+ except NonDeterministicReplayError:
3071
+ raise
3042
3072
  except Exception as exc:
3043
3073
  return _fail_update_from_exception(
3044
3074
  update_id,
@@ -4037,6 +4067,7 @@ def _replay_state(
4037
4067
  allow_cancellation_scope_authoring: bool = False,
4038
4068
  allow_cancellation_scope_delivery: bool = False,
4039
4069
  ) -> _ReplayState:
4070
+ _validate_workflow_entrypoint(workflow_cls)
4040
4071
  if allow_cancellation_scope_delivery and not allow_cancellation_scope_authoring:
4041
4072
  raise LocalActivityExecutionAborted("scope delivery requires canonical scope authoring")
4042
4073
  if prepare_local_activity_groups and not prepare_local_activities:
@@ -5115,16 +5146,20 @@ def _replay_state(
5115
5146
  for _ in range(boundary):
5116
5147
  _apply_receiver(pending_receivers.pop(0))
5117
5148
 
5118
- def _condition_wait_mismatch(opened: Mapping[str, Any], cmd: WaitCondition) -> FailWorkflow | None:
5149
+ def _condition_wait_mismatch(
5150
+ opened: Mapping[str, Any], cmd: WaitCondition,
5151
+ ) -> NonDeterministicReplayError | None:
5152
+ step = recorded_wait_steps[wait_yield_count]
5153
+ identity = f"condition wait {opened.get('condition_wait_id')!r}"
5119
5154
  opened_key = opened.get("condition_key")
5120
5155
  if isinstance(opened_key, str) and opened_key != (cmd.condition_key or ""):
5121
- return FailWorkflow(
5122
- message=(
5123
- "wait_condition key changed during replay: "
5156
+ return NonDeterministicReplayError(
5157
+ step.workflow_sequence, "condition wait", step.event_types,
5158
+ detail=(
5159
+ f"condition_key_changed: {identity} wait_condition key changed during replay: "
5124
5160
  f"history has {opened_key!r}, workflow yielded "
5125
5161
  f"{cmd.condition_key!r}"
5126
5162
  ),
5127
- exception_type="NonDeterministicWaitCondition",
5128
5163
  )
5129
5164
 
5130
5165
  opened_fingerprint = opened.get("condition_definition_fingerprint")
@@ -5132,13 +5167,14 @@ def _replay_state(
5132
5167
  isinstance(opened_fingerprint, str)
5133
5168
  and cmd.condition_definition_fingerprint != opened_fingerprint
5134
5169
  ):
5135
- return FailWorkflow(
5136
- message=(
5170
+ return NonDeterministicReplayError(
5171
+ step.workflow_sequence, "condition wait", step.event_types,
5172
+ detail=(
5173
+ f"condition_fingerprint_changed: {identity} "
5137
5174
  "wait_condition predicate fingerprint changed during replay: "
5138
5175
  f"history has {opened_fingerprint!r}, workflow yielded "
5139
5176
  f"{cmd.condition_definition_fingerprint!r}"
5140
5177
  ),
5141
- exception_type="NonDeterministicWaitCondition",
5142
5178
  )
5143
5179
 
5144
5180
  return None
@@ -5172,6 +5208,7 @@ def _replay_state(
5172
5208
  except Exception as exc:
5173
5209
  return _state([_fail_workflow_from_exception(exc)])
5174
5210
  if not hasattr(gen, "__next__"):
5211
+ _validate_workflow_result(gen, workflow_cls)
5175
5212
  if isinstance(gen, ContinueAsNew):
5176
5213
  _assert_no_unconsumed_history("continue as new")
5177
5214
  return _state([gen])
@@ -6032,6 +6069,7 @@ def _replay_state(
6032
6069
  return next(iter(memberships))
6033
6070
 
6034
6071
  def _terminal_state(value: Any, *, include_pending: bool) -> _ReplayState:
6072
+ _validate_workflow_result(value, workflow_cls)
6035
6073
  _apply_due_receivers()
6036
6074
  _consume_terminal_condition_reopens()
6037
6075
  commands = list(pending) if include_pending else []
@@ -6624,7 +6662,7 @@ def _replay_state(
6624
6662
  next_value = True
6625
6663
  continue
6626
6664
  if next_opened_mismatch is not None:
6627
- return _state([next_opened_mismatch])
6665
+ raise next_opened_mismatch
6628
6666
  step = _next_unconsumed_recorded_step()
6629
6667
  if step is not None:
6630
6668
  _assert_step_matches(cmd, step)
@@ -6676,7 +6714,7 @@ def _replay_state(
6676
6714
  opened = wait_opened[wait_yield_count]
6677
6715
  mismatch = _condition_wait_mismatch(opened, cmd)
6678
6716
  if mismatch is not None:
6679
- return _state([mismatch])
6717
+ raise mismatch
6680
6718
  opened_id = opened.get("condition_wait_id")
6681
6719
  if isinstance(opened_id, str):
6682
6720
  resolution = wait_resolutions.get(opened_id)
@@ -6784,10 +6822,10 @@ def _replay_state(
6784
6822
  try:
6785
6823
  return _terminal_state(stop.value, include_pending=True)
6786
6824
  except Exception as exc:
6787
- if isinstance(exc, NonDeterministicReplayError):
6825
+ if isinstance(exc, (NonDeterministicReplayError, InvalidWorkflowDefinition)):
6788
6826
  raise
6789
6827
  return _state(pending + [_fail_workflow_from_exception(exc)])
6790
- except NonDeterministicReplayError:
6828
+ except (NonDeterministicReplayError, InvalidWorkflowDefinition):
6791
6829
  raise
6792
6830
  except LocalActivityExecutionAborted:
6793
6831
  raise
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: durable-workflow
3
- Version: 2.5.1
3
+ Version: 2.5.3
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
@@ -117,6 +118,7 @@ tests/test_updates.py
117
118
  tests/test_wait_condition.py
118
119
  tests/test_worker.py
119
120
  tests/test_worker_session.py
121
+ tests/test_workflow_entrypoint_validation.py
120
122
  tests/test_workflow_result_timeout.py
121
123
  tests/test_workflow_streams.py
122
124
  tests/test_workflow_updates_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"]
@@ -17,6 +17,7 @@ from durable_workflow.workflow import (
17
17
  commands_to_server_commands,
18
18
  query_state,
19
19
  )
20
+ from tests.test_condition_replay_drift import ConditionIdentityWorkflow
20
21
  from tests.test_golden_history_replay import (
21
22
  GoldenSagaCompensationWorkflow,
22
23
  GoldenSignalWaitWorkflow,
@@ -251,7 +252,15 @@ class ChildPolicyAuthorWorkflow:
251
252
  return (yield ctx.start_child_workflow("child", []))
252
253
 
253
254
 
255
+ class UnsupportedAsyncWorkflow:
256
+ __workflow_name__ = "tests.replay.unsupported-async"
257
+
258
+ async def run(self, ctx: WorkflowContext) -> str:
259
+ return "body was never executed"
260
+
261
+
254
262
  WORKFLOWS = [
263
+ UnsupportedAsyncWorkflow,
255
264
  ChildPolicyAuthorWorkflow,
256
265
  ColdReplacementSatisfiedConditionWorkflow,
257
266
  CooperativeReopenedConditionCleanupWorkflow,
@@ -283,6 +292,7 @@ WORKFLOWS = [
283
292
  YieldedContinueAfterMetadataWorkflow,
284
293
  ]
285
294
  WORKFLOW_TYPES = {str(getattr(workflow, "__workflow_name__", workflow.__name__)): workflow for workflow in WORKFLOWS}
295
+ WORKFLOW_TYPES["tests.replay.condition-identity"] = ConditionIdentityWorkflow
286
296
 
287
297
 
288
298
  def _fixture_paths() -> list[Path]:
@@ -6,7 +6,6 @@ from durable_workflow import serializer, workflow
6
6
  from durable_workflow.errors import NonDeterministicReplayError
7
7
  from durable_workflow.workflow import (
8
8
  CompleteWorkflow,
9
- FailWorkflow,
10
9
  WaitCondition,
11
10
  WorkflowContext,
12
11
  query_state,
@@ -1052,14 +1051,12 @@ class TestReplayWaitCondition:
1052
1051
  },
1053
1052
  ]
1054
1053
 
1055
- outcome = replay(WaitUntilApproved, history, [])
1054
+ with pytest.raises(NonDeterministicReplayError, match="key changed") as captured:
1055
+ replay(WaitUntilApproved, history, [])
1056
1056
 
1057
- assert len(outcome.commands) == 1
1058
- assert not isinstance(outcome.commands[0], CompleteWorkflow)
1059
- cmd = outcome.commands[0]
1060
- assert isinstance(cmd, FailWorkflow)
1061
- assert cmd.exception_type == "NonDeterministicWaitCondition"
1062
- assert "key changed" in cmd.message
1057
+ assert captured.value.workflow_sequence == 1
1058
+ assert "old-key" in str(captured.value)
1059
+ assert "approved" in str(captured.value)
1063
1060
 
1064
1061
  def test_replay_rejects_changed_condition_fingerprint(self) -> None:
1065
1062
  initial = replay(WaitUntilApproved, [], [])
@@ -1077,14 +1074,12 @@ class TestReplayWaitCondition:
1077
1074
  },
1078
1075
  ]
1079
1076
 
1080
- outcome = replay(WaitUntilApproved, history, [])
1077
+ with pytest.raises(NonDeterministicReplayError, match="predicate fingerprint changed") as captured:
1078
+ replay(WaitUntilApproved, history, [])
1081
1079
 
1082
- assert len(outcome.commands) == 1
1083
- cmd = outcome.commands[0]
1084
- assert isinstance(cmd, FailWorkflow)
1085
- assert cmd.exception_type == "NonDeterministicWaitCondition"
1086
- assert "predicate fingerprint changed" in cmd.message
1087
- assert fingerprint in cmd.message
1080
+ assert captured.value.workflow_sequence == 1
1081
+ assert "sha256:different" in str(captured.value)
1082
+ assert fingerprint in str(captured.value)
1088
1083
 
1089
1084
  def test_replay_accepts_matching_condition_fingerprint(self) -> None:
1090
1085
  initial = replay(WaitUntilApproved, [], [])
@@ -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]