durable-workflow 2.6.0rc3__tar.gz → 2.6.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 (138) hide show
  1. {durable_workflow-2.6.0rc3/src/durable_workflow.egg-info → durable_workflow-2.6.2}/PKG-INFO +2 -1
  2. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/README.md +1 -0
  3. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/pyproject.toml +3 -3
  4. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/src/durable_workflow/__init__.py +2 -0
  5. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/src/durable_workflow/activity.py +28 -0
  6. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/src/durable_workflow/client.py +18 -4
  7. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/src/durable_workflow/errors.py +20 -3
  8. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/src/durable_workflow/worker.py +34 -11
  9. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/src/durable_workflow/workflow.py +128 -15
  10. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2/src/durable_workflow.egg-info}/PKG-INFO +2 -1
  11. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/src/durable_workflow.egg-info/SOURCES.txt +3 -0
  12. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/tests/test_errors.py +5 -3
  13. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/tests/test_replay_regression_corpus.py +30 -1
  14. durable_workflow-2.6.2/tests/test_sync_activities.py +197 -0
  15. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/tests/test_worker.py +7 -3
  16. durable_workflow-2.6.2/tests/test_workflow_entry_arguments.py +141 -0
  17. durable_workflow-2.6.2/tests/test_workflow_handler_validation.py +150 -0
  18. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/LICENSE +0 -0
  19. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/setup.cfg +0 -0
  20. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/src/durable_workflow/_activity_process.py +0 -0
  21. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/src/durable_workflow/_avro.py +0 -0
  22. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/src/durable_workflow/_cancellation_scope.py +0 -0
  23. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/src/durable_workflow/_cancellation_scope_history.py +0 -0
  24. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/src/durable_workflow/_cooperative_cancellation.py +0 -0
  25. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/src/durable_workflow/_prepared_local_activity.py +0 -0
  26. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/src/durable_workflow/_query_task_authority.py +0 -0
  27. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/src/durable_workflow/_sticky_workflow_cache.py +0 -0
  28. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/src/durable_workflow/auth_composition.py +0 -0
  29. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/src/durable_workflow/cancellation.py +0 -0
  30. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/src/durable_workflow/external_storage.py +0 -0
  31. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/src/durable_workflow/external_task_input.py +0 -0
  32. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/src/durable_workflow/external_task_result.py +0 -0
  33. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/src/durable_workflow/history_bundle_verify.py +0 -0
  34. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/src/durable_workflow/interceptors.py +0 -0
  35. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/src/durable_workflow/invocable.py +0 -0
  36. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/src/durable_workflow/metrics.py +0 -0
  37. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/src/durable_workflow/nexus.py +0 -0
  38. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/src/durable_workflow/py.typed +0 -0
  39. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/src/durable_workflow/python_conformance.py +0 -0
  40. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/src/durable_workflow/replay_conformance.py +0 -0
  41. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/src/durable_workflow/replay_verify.py +0 -0
  42. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/src/durable_workflow/retry_policy.py +0 -0
  43. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/src/durable_workflow/serializer.py +0 -0
  44. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/src/durable_workflow/sync.py +0 -0
  45. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/src/durable_workflow/testing.py +0 -0
  46. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/src/durable_workflow/worker_session.py +0 -0
  47. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/src/durable_workflow/workflow_updates_conformance.py +0 -0
  48. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/src/durable_workflow.egg-info/dependency_links.txt +0 -0
  49. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/src/durable_workflow.egg-info/entry_points.txt +0 -0
  50. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/src/durable_workflow.egg-info/requires.txt +0 -0
  51. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/src/durable_workflow.egg-info/top_level.txt +0 -0
  52. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/tests/test_activity_cancellation_policies.py +0 -0
  53. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/tests/test_activity_context.py +0 -0
  54. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/tests/test_activity_process.py +0 -0
  55. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/tests/test_auth_composition.py +0 -0
  56. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/tests/test_avro_value_benchmark.py +0 -0
  57. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/tests/test_avro_value_protocol.py +0 -0
  58. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/tests/test_cancellation_context.py +0 -0
  59. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/tests/test_cancellation_remaining_time.py +0 -0
  60. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/tests/test_cancellation_scope_admission.py +0 -0
  61. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/tests/test_cancellation_scope_authoring.py +0 -0
  62. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/tests/test_cancellation_scope_boundary.py +0 -0
  63. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/tests/test_cancellation_scope_opening.py +0 -0
  64. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/tests/test_child_workflow_policies.py +0 -0
  65. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/tests/test_ci_checkout.py +0 -0
  66. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/tests/test_ci_integration_endpoint.py +0 -0
  67. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/tests/test_client.py +0 -0
  68. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/tests/test_client_timeouts.py +0 -0
  69. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/tests/test_codec_regression_corpus.py +0 -0
  70. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/tests/test_committed_cancellation_scope_descendants.py +0 -0
  71. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/tests/test_committed_cancellation_scope_groups.py +0 -0
  72. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/tests/test_committed_cancellation_scope_history.py +0 -0
  73. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/tests/test_committed_cancellation_scope_replay.py +0 -0
  74. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/tests/test_condition_replay_drift.py +0 -0
  75. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/tests/test_control_plane_parity_fixtures.py +0 -0
  76. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/tests/test_cooperative_cancellation.py +0 -0
  77. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/tests/test_cooperative_cancellation_client.py +0 -0
  78. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/tests/test_cooperative_cancellation_worker.py +0 -0
  79. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/tests/test_cooperative_remote_worker.py +0 -0
  80. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/tests/test_docstring_cross_references.py +0 -0
  81. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/tests/test_durable_selection.py +0 -0
  82. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/tests/test_external_storage.py +0 -0
  83. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/tests/test_external_task_input.py +0 -0
  84. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/tests/test_external_task_result.py +0 -0
  85. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/tests/test_golden_history_replay.py +0 -0
  86. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/tests/test_history_bundle_verify.py +0 -0
  87. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/tests/test_history_event_contract.py +0 -0
  88. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/tests/test_invocable.py +0 -0
  89. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/tests/test_message_streams.py +0 -0
  90. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/tests/test_metrics.py +0 -0
  91. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/tests/test_nexus_durable_wait.py +0 -0
  92. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/tests/test_nexus_namespace.py +0 -0
  93. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/tests/test_nexus_wire.py +0 -0
  94. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/tests/test_order_processing_example.py +0 -0
  95. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/tests/test_parallel_completion_order_replay.py +0 -0
  96. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/tests/test_parallel_saga_authoring.py +0 -0
  97. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/tests/test_patch_history.py +0 -0
  98. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/tests/test_pending_operation_replay.py +0 -0
  99. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/tests/test_prepared_local_activity_client.py +0 -0
  100. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/tests/test_prepared_local_activity_groups.py +0 -0
  101. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/tests/test_prepared_local_activity_policies.py +0 -0
  102. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/tests/test_prepared_local_activity_worker.py +0 -0
  103. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/tests/test_public_boundary_scanner.py +0 -0
  104. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/tests/test_pypi_project_surface.py +0 -0
  105. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/tests/test_python_conformance.py +0 -0
  106. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/tests/test_queries.py +0 -0
  107. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/tests/test_query_task_authority.py +0 -0
  108. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/tests/test_query_timeout_retry.py +0 -0
  109. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/tests/test_readme_quickstart.py +0 -0
  110. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/tests/test_redrive_replay.py +0 -0
  111. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/tests/test_registration_ambiguity.py +0 -0
  112. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/tests/test_release_metadata.py +0 -0
  113. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/tests/test_replay.py +0 -0
  114. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/tests/test_replay_clock.py +0 -0
  115. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/tests/test_replay_conformance.py +0 -0
  116. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/tests/test_replay_verify.py +0 -0
  117. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/tests/test_retry_policy.py +0 -0
  118. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/tests/test_run_cancellation_scope_replay.py +0 -0
  119. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/tests/test_runtime_external_payload_transport.py +0 -0
  120. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/tests/test_schedules.py +0 -0
  121. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/tests/test_scoped_run_cancellation_context.py +0 -0
  122. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/tests/test_serializer.py +0 -0
  123. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/tests/test_signals.py +0 -0
  124. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/tests/test_sleep.py +0 -0
  125. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/tests/test_standalone_activity_client.py +0 -0
  126. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/tests/test_sticky_execution.py +0 -0
  127. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/tests/test_storage_admission.py +0 -0
  128. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/tests/test_sync.py +0 -0
  129. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/tests/test_testing_harness.py +0 -0
  130. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/tests/test_update_signal_condition_replay.py +0 -0
  131. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/tests/test_updates.py +0 -0
  132. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/tests/test_wait_condition.py +0 -0
  133. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/tests/test_worker_observation.py +0 -0
  134. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/tests/test_worker_session.py +0 -0
  135. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/tests/test_workflow_entrypoint_validation.py +0 -0
  136. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/tests/test_workflow_result_timeout.py +0 -0
  137. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.2}/tests/test_workflow_streams.py +0 -0
  138. {durable_workflow-2.6.0rc3 → durable_workflow-2.6.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.6.0rc3
3
+ Version: 2.6.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
@@ -139,6 +139,7 @@ for the complete cross-SDK contract.
139
139
  - [Python SDK guide](https://durable-workflow.com/docs/2.0/polyglot/python/)
140
140
  - [Complete SDK reference](docs/sdk-reference.md)
141
141
  - [Runnable examples](examples/)
142
+ - [Separate workflow and activity worker processes](docs/guides/separate-workers.md)
142
143
  - [Symmetric SDK playground](https://github.com/durable-workflow/sample-app#symmetric-sdk-playground)
143
144
 
144
145
  ## Runtime choices
@@ -99,6 +99,7 @@ for the complete cross-SDK contract.
99
99
  - [Python SDK guide](https://durable-workflow.com/docs/2.0/polyglot/python/)
100
100
  - [Complete SDK reference](docs/sdk-reference.md)
101
101
  - [Runnable examples](examples/)
102
+ - [Separate workflow and activity worker processes](docs/guides/separate-workers.md)
102
103
  - [Symmetric SDK playground](https://github.com/durable-workflow/sample-app#symmetric-sdk-playground)
103
104
 
104
105
  ## Runtime choices
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "durable-workflow"
7
- version = "2.6.0-rc.3"
7
+ version = "2.6.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,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.6.0-rc.3"
75
- registry-version = "2.6.0rc3"
74
+ product-train = "2.6.2"
75
+ registry-version = "2.6.2"
76
76
  supported-server-versions = "2.8.0"
77
77
  worker-protocol-version = "1.20"
78
78
  control-plane-version = "2"
@@ -103,6 +103,7 @@ from .errors import (
103
103
  UpdateValidationFailed,
104
104
  WorkflowAlreadyStarted,
105
105
  WorkflowCancelled,
106
+ WorkflowEntryArgumentsMismatchError,
106
107
  WorkflowFailed,
107
108
  WorkflowNotFound,
108
109
  WorkflowPayloadDecodeError,
@@ -319,6 +320,7 @@ __all__ = [
319
320
  "WorkflowHandle",
320
321
  "WorkflowList",
321
322
  "WorkflowPayloadDecodeError",
323
+ "WorkflowEntryArgumentsMismatchError",
322
324
  "WorkflowRun",
323
325
  "WorkflowRunList",
324
326
  "WorkflowStreamAppendItem",
@@ -1,6 +1,7 @@
1
1
  """Activity decorator, registry, and execution context."""
2
2
  from __future__ import annotations
3
3
 
4
+ import asyncio
4
5
  import contextvars
5
6
  from collections.abc import Awaitable, Callable
6
7
  from dataclasses import dataclass
@@ -44,6 +45,10 @@ class ActivityContext:
44
45
  self._client = client
45
46
  self._heartbeat_callback = heartbeat_callback
46
47
  self._cancel_requested = False
48
+ try:
49
+ self._event_loop: asyncio.AbstractEventLoop | None = asyncio.get_running_loop()
50
+ except RuntimeError:
51
+ self._event_loop = None
47
52
 
48
53
  @property
49
54
  def info(self) -> ActivityInfo:
@@ -55,6 +60,29 @@ class ActivityContext:
55
60
  """``True`` once the server has signalled that the owning workflow cancelled this activity."""
56
61
  return self._cancel_requested
57
62
 
63
+ def heartbeat_sync(self, details: dict[str, Any] | None = None) -> None:
64
+ """Heartbeat from a synchronous activity thread and observe cancellation.
65
+
66
+ The request runs on the worker's event loop using the original attempt
67
+ and client. Async handlers must use ``await heartbeat()`` instead.
68
+ """
69
+ loop = self._event_loop
70
+ if loop is None or not loop.is_running():
71
+ raise RuntimeError("heartbeat_sync requires a running worker event loop")
72
+ try:
73
+ caller_loop = asyncio.get_running_loop()
74
+ except RuntimeError:
75
+ caller_loop = None
76
+ if caller_loop is loop:
77
+ raise RuntimeError("use await activity.context().heartbeat() on the worker event loop")
78
+ heartbeat = self.heartbeat(details)
79
+ try:
80
+ pending = asyncio.run_coroutine_threadsafe(heartbeat, loop)
81
+ except RuntimeError:
82
+ heartbeat.close()
83
+ raise
84
+ pending.result()
85
+
58
86
  async def heartbeat(self, details: dict[str, Any] | None = None) -> None:
59
87
  """Report liveness to the server and check for a cancellation request.
60
88
 
@@ -3527,9 +3527,18 @@ class Client:
3527
3527
  ``workflow_type`` is the language-neutral type key registered via
3528
3528
  :func:`durable_workflow.workflow.defn`. ``task_queue`` selects which
3529
3529
  worker pool picks up the workflow. ``workflow_id`` is the
3530
- caller-supplied instance id — if it collides with an existing
3531
- workflow, behavior depends on ``duplicate_policy``
3532
- (``reject`` | ``allow`` | ``terminate_existing``).
3530
+ caller-supplied instance ID. The PHP Server start contract accepts
3531
+ ``duplicate_policy="fail"`` (also the default when omitted) or
3532
+ ``"use-existing"``. ``fail`` raises :class:`WorkflowAlreadyStarted`
3533
+ if the ID already exists. ``use-existing`` returns the original run
3534
+ only while it is pending, running or waiting. Both policies reject
3535
+ a closed instance. Neither replaces a run or changes its input or
3536
+ deadlines. Use a new workflow ID for a new independent execution.
3537
+
3538
+ The SDK forwards the policy unchanged. ``reject``, ``allow`` and
3539
+ ``terminate_existing`` are not supported wire values. A resource-keyed
3540
+ workflow ID coordinates start admission; it does not revoke work
3541
+ already running in an external system.
3533
3542
 
3534
3543
  ``input`` is a list of positional arguments passed to the workflow's
3535
3544
  ``run`` method; the SDK encodes the list with the default payload
@@ -5303,15 +5312,20 @@ class Client:
5303
5312
  message: str,
5304
5313
  failure_type: str | None = None,
5305
5314
  stack_trace: str | None = None,
5315
+ failure_reason: str | None = None,
5306
5316
  ) -> Any:
5307
- """Report a workflow task failure so the server can schedule a retry.
5317
+ """Report a workflow task failure without closing the run.
5308
5318
 
5309
5319
  Worker-plane endpoint. Task failures (e.g. non-determinism) are
5310
5320
  distinct from workflow failures (``FailWorkflow`` commands).
5321
+ The runtime may block incompatible replay until compatible code and
5322
+ a supported repair are available instead of automatically retrying it.
5311
5323
  """
5312
5324
  failure: dict[str, Any] = {"message": message}
5313
5325
  if failure_type is not None:
5314
5326
  failure["type"] = failure_type
5327
+ if failure_reason is not None:
5328
+ failure["reason"] = failure_reason
5315
5329
  if stack_trace is not None:
5316
5330
  failure["stack_trace"] = stack_trace
5317
5331
  body: dict[str, Any] = {
@@ -312,8 +312,10 @@ class WorkflowNotFound(DurableWorkflowError):
312
312
  class WorkflowAlreadyStarted(DurableWorkflowError):
313
313
  """A start request collided with an existing instance id.
314
314
 
315
- Raised when duplicate-start policy is ``reject`` (the default) and the
316
- caller-supplied ``workflow_id`` is already in use.
315
+ The PHP Server raises this for an existing ``workflow_id`` when the
316
+ duplicate policy is ``fail`` (the default), or when ``use-existing``
317
+ addresses a closed instance. ``use-existing`` returns a handle only
318
+ while the original run is pending, running or waiting.
317
319
  """
318
320
 
319
321
  def __init__(self, workflow_id: str) -> None:
@@ -463,6 +465,21 @@ class WorkflowPayloadDecodeError(DurableWorkflowError):
463
465
  self.exception_type = exception_type
464
466
 
465
467
 
468
+ class WorkflowEntryArgumentsMismatchError(DurableWorkflowError):
469
+ """Recorded input cannot bind to the deployed entry method before it runs."""
470
+
471
+ reason = "workflow_entry_arguments_mismatch"
472
+
473
+ def __init__(self, workflow_type: str, binding_reason: str) -> None:
474
+ self.workflow_type = workflow_type
475
+ self.entry_method = "run"
476
+ self.binding_reason = binding_reason
477
+ super().__init__(
478
+ f"{self.reason}: {workflow_type}.run cannot bind the recorded arguments "
479
+ f"({binding_reason}). Restore compatible code and repair the workflow task."
480
+ )
481
+
482
+
466
483
  class NonDeterministicReplayError(DurableWorkflowError):
467
484
  """Current workflow code yielded a command shape that does not match history."""
468
485
 
@@ -874,7 +891,7 @@ def _raise_for_status(status: int, body: object, *, context: str = "") -> None:
874
891
  if status == 409:
875
892
  if reason == "schedule_already_exists":
876
893
  raise ScheduleAlreadyExists(context)
877
- if reason == "duplicate_not_allowed":
894
+ if reason in ("duplicate_not_allowed", "instance_already_started"):
878
895
  raise WorkflowAlreadyStarted(context)
879
896
  if reason == "run_not_active":
880
897
  if operation == "signal":
@@ -79,6 +79,7 @@ from .errors import (
79
79
  ServerError,
80
80
  UpdateRejected,
81
81
  UpdateValidationFailed,
82
+ WorkflowEntryArgumentsMismatchError,
82
83
  )
83
84
  from .external_storage import ExternalPayloadCache, ExternalStorageDriver, store_external_payload
84
85
  from .interceptors import (
@@ -108,12 +109,13 @@ from .workflow import (
108
109
  StartChildWorkflow,
109
110
  UpsertMemo,
110
111
  _prepare_query_state,
112
+ _prepare_update_validation,
113
+ _UpdateValidationReplayOptions,
111
114
  _validate_workflow_entrypoint,
112
115
  _workflow_handler_registries,
113
116
  apply_update,
114
117
  commands_to_server_commands,
115
118
  replay,
116
- validate_update,
117
119
  )
118
120
 
119
121
  log = logging.getLogger("durable_workflow.worker")
@@ -136,6 +138,7 @@ _WORKFLOW_COMMAND_CAPABILITIES = (
136
138
  TYPED_SEARCH_ATTRIBUTES_CAPABILITY,
137
139
  DURABLE_SELECTION_CAPABILITY,
138
140
  LOCAL_ACTIVITIES_CAPABILITY,
141
+ "workflow_entry_arguments_validation",
139
142
  )
140
143
 
141
144
  _WORKFLOW_WORK_TASK_KINDS = ("workflow", "update_validation")
@@ -2619,6 +2622,9 @@ class Worker:
2619
2622
  return None
2620
2623
  except Exception as e:
2621
2624
  log.exception("replay failed")
2625
+ failure_details = (
2626
+ {"failure_reason": e.reason} if isinstance(e, WorkflowEntryArgumentsMismatchError) else {}
2627
+ )
2622
2628
  try:
2623
2629
  await self.client.fail_workflow_task(
2624
2630
  task_id=task_id,
@@ -2627,6 +2633,7 @@ class Worker:
2627
2633
  message=f"replay failed: {e}",
2628
2634
  failure_type=type(e).__name__,
2629
2635
  stack_trace=traceback.format_exc(),
2636
+ **failure_details,
2630
2637
  )
2631
2638
  except Exception as fe:
2632
2639
  log.warning("failed to report replay failure: %s", fe)
@@ -3065,7 +3072,9 @@ class Worker:
3065
3072
  if self._cooperative_cancellation_supported:
3066
3073
  result = await self._execute_cooperative_remote_callable(task, fn, tuple(args), act_ctx.info)
3067
3074
  else:
3068
- result = await self._execute_activity_callable(task, activity_type, tuple(args), fn)
3075
+ result = await self._execute_activity_callable(
3076
+ task, activity_type, tuple(args), fn, run_sync_in_thread=True, remote=True,
3077
+ )
3069
3078
  except _RemoteActivityExecutionAborted as error:
3070
3079
  log.warning("remote activity %s claim abandoned: %s", task_id, error)
3071
3080
  return "claim_aborted"
@@ -3859,21 +3868,24 @@ class Worker:
3859
3868
  codec=codec,
3860
3869
  context="workflow update validation arguments",
3861
3870
  )
3862
- result = validate_update(
3871
+ invoke = _prepare_update_validation(
3863
3872
  workflow_cls,
3864
3873
  history,
3865
3874
  start_input,
3866
3875
  update_name,
3867
3876
  update_args,
3868
- workflow_id=task.get("workflow_id"),
3869
- run_id=task.get("run_id", ""),
3870
- payload_codec=codec,
3871
- external_storage=self.external_storage,
3872
- external_storage_cache=self.external_storage_cache,
3873
- prepare_local_activities=self._prepared_local_activities_supported,
3874
- prepare_local_activity_groups=self._prepared_local_activity_groups_supported,
3875
- local_activity_cancellation_policies=self._local_activity_cancellation_policies,
3877
+ replay_options=_UpdateValidationReplayOptions(
3878
+ workflow_id=task.get("workflow_id"),
3879
+ run_id=task.get("run_id", ""),
3880
+ payload_codec=codec,
3881
+ external_storage=self.external_storage,
3882
+ external_storage_cache=self.external_storage_cache,
3883
+ prepare_local_activities=self._prepared_local_activities_supported,
3884
+ prepare_local_activity_groups=self._prepared_local_activity_groups_supported,
3885
+ local_activity_cancellation_policies=self._local_activity_cancellation_policies,
3886
+ ),
3876
3887
  )
3888
+ result = invoke()
3877
3889
  if inspect.isawaitable(result):
3878
3890
  result = await result
3879
3891
  if result is False:
@@ -4506,6 +4518,17 @@ class Worker:
4506
4518
  )
4507
4519
  await asyncio.gather(*in_flight, return_exceptions=True)
4508
4520
 
4521
+ # Cancelling the asyncio await cannot stop a running Python thread.
4522
+ # Keep registration and capacity until the actual callback has returned.
4523
+ while any(not future.done() for future in self._remote_activity_threads):
4524
+ remaining = self._remaining_shutdown_time(deadline)
4525
+ if remaining <= 0:
4526
+ raise RuntimeError(
4527
+ "worker shutdown has unconfirmed activity thread stop(s); "
4528
+ "the worker registration remains active"
4529
+ )
4530
+ await asyncio.sleep(min(0.01, remaining))
4531
+
4509
4532
  if self._local_activity_executor is not None:
4510
4533
  self._local_activity_executor.shutdown(wait=False, cancel_futures=True)
4511
4534
  if self._remote_activity_executor is not None:
@@ -66,6 +66,7 @@ from .errors import (
66
66
  QueryFailed,
67
67
  SagaCompensationFailed,
68
68
  WorkflowCancelled,
69
+ WorkflowEntryArgumentsMismatchError,
69
70
  WorkflowPayloadDecodeError,
70
71
  )
71
72
  from .external_storage import ExternalPayloadCache, ExternalStorageDriver
@@ -137,6 +138,21 @@ def _workflow_handler_registries(
137
138
  wire_name = getattr(member, f"__{kind}_name__", None)
138
139
  if not isinstance(wire_name, str) or not wire_name:
139
140
  continue
141
+ if not callable(member):
142
+ raise InvalidWorkflowDefinition(f"workflow {cls.__qualname__}.{attr} handler must be callable")
143
+ target = inspect.unwrap(member)
144
+ if not inspect.isroutine(target) and callable(target):
145
+ target = inspect.unwrap(target.__call__)
146
+ if (
147
+ (inspect.iscoroutinefunction(target) and kind not in {"query", "update_validator"})
148
+ or inspect.isasyncgenfunction(target)
149
+ or inspect.isgeneratorfunction(target)
150
+ ):
151
+ name = getattr(cls, "__workflow_name__", cls.__name__)
152
+ raise InvalidWorkflowDefinition(
153
+ f"unsupported_workflow_handler: workflow {name!r} ({cls.__qualname__}.{attr}) "
154
+ f"{kind} handler {wire_name!r} must be synchronous; yield durable commands from run()."
155
+ )
140
156
  if kind == "signal" and wire_name == MESSAGE_STREAM_SIGNAL:
141
157
  raise ValueError(f"signal name {wire_name!r} is reserved by the workflow runtime")
142
158
  if wire_name in registry:
@@ -148,6 +164,28 @@ def _workflow_handler_registries(
148
164
  return handlers["signal"], handlers["query"], handlers["update"], handlers["update_validator"]
149
165
 
150
166
 
167
+ def _invoke_workflow_handler(
168
+ handler: Callable[..., Any], args: list[Any], cls: type, method: str, *, allow_async: bool = False,
169
+ ) -> Any:
170
+ return _validate_workflow_handler_result(handler(*args), cls, method, allow_async=allow_async)
171
+
172
+
173
+ def _validate_workflow_handler_result(result: Any, cls: type, method: str, *, allow_async: bool = False) -> Any:
174
+ if (
175
+ (inspect.isawaitable(result) and not allow_async)
176
+ or inspect.isasyncgen(result)
177
+ or inspect.isgenerator(result)
178
+ ):
179
+ if inspect.iscoroutine(result) or inspect.isgenerator(result):
180
+ result.close()
181
+ name = getattr(cls, "__workflow_name__", cls.__name__)
182
+ raise InvalidWorkflowDefinition(
183
+ f"unsupported_workflow_handler_result: workflow {name!r} ({cls.__qualname__}.{method}) "
184
+ "returned an async or generator value; handlers must return synchronous values."
185
+ )
186
+ return result
187
+
188
+
151
189
  def defn(*, name: str) -> Callable[[type[_WorkflowT]], type[_WorkflowT]]:
152
190
  """Register a class as a workflow type under a language-neutral name.
153
191
 
@@ -3132,7 +3170,9 @@ def _prepare_query_state(
3132
3170
 
3133
3171
  def invoke() -> Any:
3134
3172
  try:
3135
- return handler(*(list(args) if args is not None else []))
3173
+ return _invoke_workflow_handler(
3174
+ handler, list(args) if args is not None else [], workflow_cls, method_name, allow_async=True,
3175
+ )
3136
3176
  except QueryFailed:
3137
3177
  raise
3138
3178
  except Exception as exc:
@@ -3162,7 +3202,7 @@ def query_state(
3162
3202
  Server-routed Worker execution separately checks its original query claim
3163
3203
  between replay and handler invocation. Direct local use has no Server lease.
3164
3204
  """
3165
- return _prepare_query_state(
3205
+ result = _prepare_query_state(
3166
3206
  workflow_cls, history_events, start_input, query_name, args,
3167
3207
  workflow_id=workflow_id, run_id=run_id, payload_codec=payload_codec,
3168
3208
  external_storage=external_storage, external_storage_cache=external_storage_cache,
@@ -3170,6 +3210,11 @@ def query_state(
3170
3210
  prepare_local_activity_groups=prepare_local_activity_groups,
3171
3211
  local_activity_cancellation_policies=local_activity_cancellation_policies,
3172
3212
  )()
3213
+ try:
3214
+ registry: dict[str, str] = getattr(workflow_cls, "__workflow_queries__", {}) or {}
3215
+ return _validate_workflow_handler_result(result, workflow_cls, registry.get(query_name, query_name))
3216
+ except InvalidWorkflowDefinition as exc:
3217
+ raise QueryFailed(str(exc)) from exc
3173
3218
 
3174
3219
 
3175
3220
  def apply_update(
@@ -3275,11 +3320,24 @@ def apply_update(
3275
3320
  )
3276
3321
 
3277
3322
  try:
3278
- return CompleteUpdate(update_id=update_id, result=handler(*args))
3323
+ result = _invoke_workflow_handler(handler, args, workflow_cls, method_name)
3324
+ return CompleteUpdate(update_id=update_id, result=result)
3279
3325
  except Exception as exc:
3280
3326
  return _fail_update_from_exception(update_id, "update handler failed", exc)
3281
3327
 
3282
3328
 
3329
+ @dataclass(frozen=True)
3330
+ class _UpdateValidationReplayOptions:
3331
+ workflow_id: str | None = None
3332
+ run_id: str = ""
3333
+ payload_codec: str | None = None
3334
+ external_storage: ExternalStorageDriver | None = None
3335
+ external_storage_cache: ExternalPayloadCache | None = None
3336
+ prepare_local_activities: bool = False
3337
+ prepare_local_activity_groups: bool = False
3338
+ local_activity_cancellation_policies: tuple[str, ...] = ()
3339
+
3340
+
3283
3341
  def validate_update(
3284
3342
  workflow_cls: type,
3285
3343
  history_events: Iterable[dict[str, Any]],
@@ -3296,13 +3354,42 @@ def validate_update(
3296
3354
  prepare_local_activity_groups: bool = False,
3297
3355
  local_activity_cancellation_policies: tuple[str, ...] = (),
3298
3356
  ) -> Any:
3357
+ """Replay and invoke a validator synchronously, refusing asynchronous results."""
3358
+ from .errors import UpdateRejected
3359
+
3360
+ result = _prepare_update_validation(
3361
+ workflow_cls, history_events, start_input, update_name, args,
3362
+ replay_options=_UpdateValidationReplayOptions(
3363
+ workflow_id=workflow_id, run_id=run_id, payload_codec=payload_codec,
3364
+ external_storage=external_storage, external_storage_cache=external_storage_cache,
3365
+ prepare_local_activities=prepare_local_activities,
3366
+ prepare_local_activity_groups=prepare_local_activity_groups,
3367
+ local_activity_cancellation_policies=local_activity_cancellation_policies,
3368
+ ),
3369
+ )()
3370
+ try:
3371
+ registry: dict[str, str] = getattr(workflow_cls, "__workflow_update_validators__", {}) or {}
3372
+ return _validate_workflow_handler_result(result, workflow_cls, registry.get(update_name, update_name))
3373
+ except InvalidWorkflowDefinition as exc:
3374
+ raise UpdateRejected(str(exc), exception_type=type(exc).__name__) from exc
3375
+
3376
+
3377
+ def _prepare_update_validation(
3378
+ workflow_cls: type,
3379
+ history_events: Iterable[dict[str, Any]],
3380
+ start_input: list[Any],
3381
+ update_name: str,
3382
+ args: list[Any],
3383
+ *,
3384
+ replay_options: _UpdateValidationReplayOptions,
3385
+ ) -> Callable[[], Any]:
3299
3386
  """Replay state and invoke only the declared pre-accept update validator.
3300
3387
 
3301
3388
  The replayed instance is discarded after validation. This helper does not
3302
3389
  run the update handler and does not emit workflow commands, so the server
3303
3390
  remains the sole authority that can cross the accepted-state boundary.
3304
3391
  """
3305
- from .errors import InvalidArgument, UpdateRejected, UpdateValidationFailed
3392
+ from .errors import UpdateValidationFailed
3306
3393
 
3307
3394
  events = list(history_events)
3308
3395
  try:
@@ -3310,14 +3397,14 @@ def validate_update(
3310
3397
  workflow_cls,
3311
3398
  events,
3312
3399
  start_input,
3313
- workflow_id=workflow_id,
3314
- run_id=run_id,
3315
- payload_codec=payload_codec,
3316
- external_storage=external_storage,
3317
- external_storage_cache=external_storage_cache,
3318
- prepare_local_activities=prepare_local_activities,
3319
- prepare_local_activity_groups=prepare_local_activity_groups,
3320
- local_activity_cancellation_policies=local_activity_cancellation_policies,
3400
+ workflow_id=replay_options.workflow_id,
3401
+ run_id=replay_options.run_id,
3402
+ payload_codec=replay_options.payload_codec,
3403
+ external_storage=replay_options.external_storage,
3404
+ external_storage_cache=replay_options.external_storage_cache,
3405
+ prepare_local_activities=replay_options.prepare_local_activities,
3406
+ prepare_local_activity_groups=replay_options.prepare_local_activity_groups,
3407
+ local_activity_cancellation_policies=replay_options.local_activity_cancellation_policies,
3321
3408
  )
3322
3409
  except Exception as exc:
3323
3410
  raise UpdateValidationFailed(
@@ -3348,8 +3435,20 @@ def validate_update(
3348
3435
  if validator is None:
3349
3436
  raise UpdateValidationFailed(f"update validator {update_name!r} is not available")
3350
3437
 
3438
+ return lambda: _invoke_update_validator(validator, args, workflow_cls, validator_name, update_name)
3439
+
3440
+
3441
+ def _invoke_update_validator(
3442
+ validator: Callable[..., Any],
3443
+ args: list[Any],
3444
+ workflow_cls: type,
3445
+ validator_name: str,
3446
+ update_name: str,
3447
+ ) -> Any:
3448
+ from .errors import InvalidArgument, UpdateRejected
3449
+
3351
3450
  try:
3352
- result = validator(*list(args))
3451
+ result = _invoke_workflow_handler(validator, list(args), workflow_cls, validator_name, allow_async=True)
3353
3452
  if result is False:
3354
3453
  raise ValueError(f"update validator {update_name!r} returned false")
3355
3454
  return result
@@ -4239,6 +4338,7 @@ def _replay_state(
4239
4338
  allow_cancellation_scope_delivery: bool = False,
4240
4339
  ) -> _ReplayState:
4241
4340
  _validate_workflow_entrypoint(workflow_cls)
4341
+ _workflow_handler_registries(workflow_cls)
4242
4342
  if allow_cancellation_scope_delivery and not allow_cancellation_scope_authoring:
4243
4343
  raise LocalActivityExecutionAborted("scope delivery requires canonical scope authoring")
4244
4344
  if prepare_local_activity_groups and not prepare_local_activities:
@@ -5284,7 +5384,7 @@ def _replay_state(
5284
5384
  return
5285
5385
  raise TypeError(f"update handler {receiver.name!r} is not available")
5286
5386
  ctx.logger._set_replaying(True)
5287
- handler(*receiver.args)
5387
+ _invoke_workflow_handler(handler, receiver.args, workflow_cls, method_name)
5288
5388
 
5289
5389
  def _receiver_due(receiver: _PendingReceiver, *, before_consuming_result: bool) -> bool:
5290
5390
  if receiver.after_cancellation_delivery and not cancellation_consumed:
@@ -5386,8 +5486,21 @@ def _replay_state(
5386
5486
  result_cursor = 0
5387
5487
  pending_step_cursor = 0
5388
5488
  wait_yield_count = 0
5489
+ entry = instance.run
5490
+ workflow_type = str(getattr(workflow_cls, "__workflow_name__", workflow_cls.__name__))
5491
+ try:
5492
+ entry_signature = inspect.signature(entry)
5493
+ except (TypeError, ValueError):
5494
+ raise WorkflowEntryArgumentsMismatchError(workflow_type, "signature_unavailable") from None
5495
+ try:
5496
+ entry_signature.bind(ctx, *start_input)
5497
+ except TypeError:
5498
+ raise WorkflowEntryArgumentsMismatchError(workflow_type, "argument_binding_failed") from None
5499
+
5500
+ # Binding happens outside the application-error path. A TypeError raised
5501
+ # by a successfully bound body is still an actual workflow failure.
5389
5502
  try:
5390
- gen = instance.run(ctx, *start_input)
5503
+ gen = entry(ctx, *start_input)
5391
5504
  except NonDeterministicReplayError:
5392
5505
  raise
5393
5506
  except LocalActivityExecutionAborted:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: durable-workflow
3
- Version: 2.6.0rc3
3
+ Version: 2.6.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
@@ -139,6 +139,7 @@ for the complete cross-SDK contract.
139
139
  - [Python SDK guide](https://durable-workflow.com/docs/2.0/polyglot/python/)
140
140
  - [Complete SDK reference](docs/sdk-reference.md)
141
141
  - [Runnable examples](examples/)
142
+ - [Separate workflow and activity worker processes](docs/guides/separate-workers.md)
142
143
  - [Symmetric SDK playground](https://github.com/durable-workflow/sample-app#symmetric-sdk-playground)
143
144
 
144
145
  ## Runtime choices
@@ -120,6 +120,7 @@ tests/test_standalone_activity_client.py
120
120
  tests/test_sticky_execution.py
121
121
  tests/test_storage_admission.py
122
122
  tests/test_sync.py
123
+ tests/test_sync_activities.py
123
124
  tests/test_testing_harness.py
124
125
  tests/test_update_signal_condition_replay.py
125
126
  tests/test_updates.py
@@ -127,7 +128,9 @@ tests/test_wait_condition.py
127
128
  tests/test_worker.py
128
129
  tests/test_worker_observation.py
129
130
  tests/test_worker_session.py
131
+ tests/test_workflow_entry_arguments.py
130
132
  tests/test_workflow_entrypoint_validation.py
133
+ tests/test_workflow_handler_validation.py
131
134
  tests/test_workflow_result_timeout.py
132
135
  tests/test_workflow_streams.py
133
136
  tests/test_workflow_updates_conformance.py
@@ -57,9 +57,11 @@ class TestRaiseForStatus:
57
57
  with pytest.raises(ServerError):
58
58
  _raise_for_status(404, {"reason": "other"})
59
59
 
60
- def test_409_duplicate(self) -> None:
61
- with pytest.raises(WorkflowAlreadyStarted):
62
- _raise_for_status(409, {"reason": "duplicate_not_allowed"}, context="wf-1")
60
+ @pytest.mark.parametrize("reason", ["duplicate_not_allowed", "instance_already_started"])
61
+ def test_409_duplicate(self, reason: str) -> None:
62
+ with pytest.raises(WorkflowAlreadyStarted) as duplicate:
63
+ _raise_for_status(409, {"reason": reason}, context="resource-017")
64
+ assert duplicate.value.workflow_id == "resource-017"
63
65
 
64
66
  def test_409_query_rejected(self) -> None:
65
67
  with pytest.raises(QueryFailed):