durable-workflow 2.6.1__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.1/src/durable_workflow.egg-info → durable_workflow-2.6.2}/PKG-INFO +1 -1
  2. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/pyproject.toml +3 -3
  3. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/src/durable_workflow/__init__.py +2 -0
  4. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/src/durable_workflow/activity.py +28 -0
  5. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/src/durable_workflow/client.py +6 -1
  6. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/src/durable_workflow/errors.py +15 -0
  7. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/src/durable_workflow/worker.py +34 -11
  8. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/src/durable_workflow/workflow.py +128 -15
  9. {durable_workflow-2.6.1 → durable_workflow-2.6.2/src/durable_workflow.egg-info}/PKG-INFO +1 -1
  10. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/src/durable_workflow.egg-info/SOURCES.txt +3 -0
  11. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/tests/test_replay_regression_corpus.py +30 -1
  12. durable_workflow-2.6.2/tests/test_sync_activities.py +197 -0
  13. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/tests/test_worker.py +7 -3
  14. durable_workflow-2.6.2/tests/test_workflow_entry_arguments.py +141 -0
  15. durable_workflow-2.6.2/tests/test_workflow_handler_validation.py +150 -0
  16. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/LICENSE +0 -0
  17. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/README.md +0 -0
  18. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/setup.cfg +0 -0
  19. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/src/durable_workflow/_activity_process.py +0 -0
  20. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/src/durable_workflow/_avro.py +0 -0
  21. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/src/durable_workflow/_cancellation_scope.py +0 -0
  22. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/src/durable_workflow/_cancellation_scope_history.py +0 -0
  23. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/src/durable_workflow/_cooperative_cancellation.py +0 -0
  24. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/src/durable_workflow/_prepared_local_activity.py +0 -0
  25. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/src/durable_workflow/_query_task_authority.py +0 -0
  26. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/src/durable_workflow/_sticky_workflow_cache.py +0 -0
  27. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/src/durable_workflow/auth_composition.py +0 -0
  28. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/src/durable_workflow/cancellation.py +0 -0
  29. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/src/durable_workflow/external_storage.py +0 -0
  30. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/src/durable_workflow/external_task_input.py +0 -0
  31. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/src/durable_workflow/external_task_result.py +0 -0
  32. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/src/durable_workflow/history_bundle_verify.py +0 -0
  33. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/src/durable_workflow/interceptors.py +0 -0
  34. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/src/durable_workflow/invocable.py +0 -0
  35. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/src/durable_workflow/metrics.py +0 -0
  36. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/src/durable_workflow/nexus.py +0 -0
  37. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/src/durable_workflow/py.typed +0 -0
  38. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/src/durable_workflow/python_conformance.py +0 -0
  39. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/src/durable_workflow/replay_conformance.py +0 -0
  40. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/src/durable_workflow/replay_verify.py +0 -0
  41. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/src/durable_workflow/retry_policy.py +0 -0
  42. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/src/durable_workflow/serializer.py +0 -0
  43. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/src/durable_workflow/sync.py +0 -0
  44. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/src/durable_workflow/testing.py +0 -0
  45. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/src/durable_workflow/worker_session.py +0 -0
  46. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/src/durable_workflow/workflow_updates_conformance.py +0 -0
  47. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/src/durable_workflow.egg-info/dependency_links.txt +0 -0
  48. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/src/durable_workflow.egg-info/entry_points.txt +0 -0
  49. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/src/durable_workflow.egg-info/requires.txt +0 -0
  50. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/src/durable_workflow.egg-info/top_level.txt +0 -0
  51. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/tests/test_activity_cancellation_policies.py +0 -0
  52. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/tests/test_activity_context.py +0 -0
  53. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/tests/test_activity_process.py +0 -0
  54. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/tests/test_auth_composition.py +0 -0
  55. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/tests/test_avro_value_benchmark.py +0 -0
  56. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/tests/test_avro_value_protocol.py +0 -0
  57. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/tests/test_cancellation_context.py +0 -0
  58. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/tests/test_cancellation_remaining_time.py +0 -0
  59. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/tests/test_cancellation_scope_admission.py +0 -0
  60. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/tests/test_cancellation_scope_authoring.py +0 -0
  61. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/tests/test_cancellation_scope_boundary.py +0 -0
  62. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/tests/test_cancellation_scope_opening.py +0 -0
  63. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/tests/test_child_workflow_policies.py +0 -0
  64. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/tests/test_ci_checkout.py +0 -0
  65. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/tests/test_ci_integration_endpoint.py +0 -0
  66. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/tests/test_client.py +0 -0
  67. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/tests/test_client_timeouts.py +0 -0
  68. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/tests/test_codec_regression_corpus.py +0 -0
  69. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/tests/test_committed_cancellation_scope_descendants.py +0 -0
  70. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/tests/test_committed_cancellation_scope_groups.py +0 -0
  71. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/tests/test_committed_cancellation_scope_history.py +0 -0
  72. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/tests/test_committed_cancellation_scope_replay.py +0 -0
  73. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/tests/test_condition_replay_drift.py +0 -0
  74. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/tests/test_control_plane_parity_fixtures.py +0 -0
  75. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/tests/test_cooperative_cancellation.py +0 -0
  76. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/tests/test_cooperative_cancellation_client.py +0 -0
  77. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/tests/test_cooperative_cancellation_worker.py +0 -0
  78. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/tests/test_cooperative_remote_worker.py +0 -0
  79. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/tests/test_docstring_cross_references.py +0 -0
  80. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/tests/test_durable_selection.py +0 -0
  81. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/tests/test_errors.py +0 -0
  82. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/tests/test_external_storage.py +0 -0
  83. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/tests/test_external_task_input.py +0 -0
  84. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/tests/test_external_task_result.py +0 -0
  85. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/tests/test_golden_history_replay.py +0 -0
  86. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/tests/test_history_bundle_verify.py +0 -0
  87. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/tests/test_history_event_contract.py +0 -0
  88. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/tests/test_invocable.py +0 -0
  89. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/tests/test_message_streams.py +0 -0
  90. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/tests/test_metrics.py +0 -0
  91. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/tests/test_nexus_durable_wait.py +0 -0
  92. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/tests/test_nexus_namespace.py +0 -0
  93. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/tests/test_nexus_wire.py +0 -0
  94. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/tests/test_order_processing_example.py +0 -0
  95. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/tests/test_parallel_completion_order_replay.py +0 -0
  96. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/tests/test_parallel_saga_authoring.py +0 -0
  97. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/tests/test_patch_history.py +0 -0
  98. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/tests/test_pending_operation_replay.py +0 -0
  99. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/tests/test_prepared_local_activity_client.py +0 -0
  100. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/tests/test_prepared_local_activity_groups.py +0 -0
  101. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/tests/test_prepared_local_activity_policies.py +0 -0
  102. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/tests/test_prepared_local_activity_worker.py +0 -0
  103. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/tests/test_public_boundary_scanner.py +0 -0
  104. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/tests/test_pypi_project_surface.py +0 -0
  105. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/tests/test_python_conformance.py +0 -0
  106. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/tests/test_queries.py +0 -0
  107. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/tests/test_query_task_authority.py +0 -0
  108. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/tests/test_query_timeout_retry.py +0 -0
  109. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/tests/test_readme_quickstart.py +0 -0
  110. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/tests/test_redrive_replay.py +0 -0
  111. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/tests/test_registration_ambiguity.py +0 -0
  112. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/tests/test_release_metadata.py +0 -0
  113. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/tests/test_replay.py +0 -0
  114. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/tests/test_replay_clock.py +0 -0
  115. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/tests/test_replay_conformance.py +0 -0
  116. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/tests/test_replay_verify.py +0 -0
  117. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/tests/test_retry_policy.py +0 -0
  118. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/tests/test_run_cancellation_scope_replay.py +0 -0
  119. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/tests/test_runtime_external_payload_transport.py +0 -0
  120. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/tests/test_schedules.py +0 -0
  121. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/tests/test_scoped_run_cancellation_context.py +0 -0
  122. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/tests/test_serializer.py +0 -0
  123. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/tests/test_signals.py +0 -0
  124. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/tests/test_sleep.py +0 -0
  125. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/tests/test_standalone_activity_client.py +0 -0
  126. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/tests/test_sticky_execution.py +0 -0
  127. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/tests/test_storage_admission.py +0 -0
  128. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/tests/test_sync.py +0 -0
  129. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/tests/test_testing_harness.py +0 -0
  130. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/tests/test_update_signal_condition_replay.py +0 -0
  131. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/tests/test_updates.py +0 -0
  132. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/tests/test_wait_condition.py +0 -0
  133. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/tests/test_worker_observation.py +0 -0
  134. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/tests/test_worker_session.py +0 -0
  135. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/tests/test_workflow_entrypoint_validation.py +0 -0
  136. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/tests/test_workflow_result_timeout.py +0 -0
  137. {durable_workflow-2.6.1 → durable_workflow-2.6.2}/tests/test_workflow_streams.py +0 -0
  138. {durable_workflow-2.6.1 → 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.1
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "durable-workflow"
7
- version = "2.6.1"
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.1"
75
- registry-version = "2.6.1"
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
 
@@ -5312,15 +5312,20 @@ class Client:
5312
5312
  message: str,
5313
5313
  failure_type: str | None = None,
5314
5314
  stack_trace: str | None = None,
5315
+ failure_reason: str | None = None,
5315
5316
  ) -> Any:
5316
- """Report a workflow task failure so the server can schedule a retry.
5317
+ """Report a workflow task failure without closing the run.
5317
5318
 
5318
5319
  Worker-plane endpoint. Task failures (e.g. non-determinism) are
5319
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.
5320
5323
  """
5321
5324
  failure: dict[str, Any] = {"message": message}
5322
5325
  if failure_type is not None:
5323
5326
  failure["type"] = failure_type
5327
+ if failure_reason is not None:
5328
+ failure["reason"] = failure_reason
5324
5329
  if stack_trace is not None:
5325
5330
  failure["stack_trace"] = stack_trace
5326
5331
  body: dict[str, Any] = {
@@ -465,6 +465,21 @@ class WorkflowPayloadDecodeError(DurableWorkflowError):
465
465
  self.exception_type = exception_type
466
466
 
467
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
+
468
483
  class NonDeterministicReplayError(DurableWorkflowError):
469
484
  """Current workflow code yielded a command shape that does not match history."""
470
485
 
@@ -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.1
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
@@ -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
@@ -12,7 +12,12 @@ import pytest
12
12
 
13
13
  from durable_workflow import Replayer, Worker, serializer, workflow
14
14
  from durable_workflow.client import PROTOCOL_VERSION, Client, WorkflowExecution, WorkflowStreamAppendItem
15
- from durable_workflow.errors import NonDeterministicReplayError, WorkflowCancelled, WorkflowPayloadDecodeError
15
+ from durable_workflow.errors import (
16
+ DurableWorkflowError,
17
+ NonDeterministicReplayError,
18
+ WorkflowCancelled,
19
+ WorkflowPayloadDecodeError,
20
+ )
16
21
  from durable_workflow.replay_conformance import compose_report
17
22
  from durable_workflow.workflow import (
18
23
  LocalActivityExecutionAborted,
@@ -282,6 +287,23 @@ class UnsupportedAsyncWorkflow:
282
287
  return "body was never executed"
283
288
 
284
289
 
290
+ class UnsupportedAsyncSignalWorkflow:
291
+ __workflow_name__ = "tests.replay.unsupported-async-signal"
292
+
293
+ @workflow.signal("change")
294
+ async def receive(self) -> None:
295
+ raise AssertionError("unsupported signal body must never run")
296
+
297
+ def run(self, ctx: WorkflowContext) -> str:
298
+ return "done"
299
+
300
+
301
+ @workflow.defn(name="tests.replay.entry-arguments-required")
302
+ class RequiredEntryArgumentWorkflow:
303
+ def run(self, ctx: WorkflowContext, amount: int, required: int) -> int:
304
+ raise AssertionError("incompatible entry body must never run")
305
+
306
+
285
307
  @workflow.defn(name="tests.replay.nexus-target")
286
308
  class NexusTargetWorkflow:
287
309
  def run(self, ctx: WorkflowContext): # type: ignore[no-untyped-def]
@@ -323,6 +345,7 @@ WORKFLOWS = [
323
345
  NexusTerminalDeliveryWorkflow,
324
346
  NexusTargetWorkflow,
325
347
  UnsupportedAsyncWorkflow,
348
+ UnsupportedAsyncSignalWorkflow,
326
349
  ChildPolicyAuthorWorkflow,
327
350
  ColdReplacementSatisfiedConditionWorkflow,
328
351
  CooperativeReopenedConditionCleanupWorkflow,
@@ -356,6 +379,7 @@ WORKFLOWS = [
356
379
  ]
357
380
  WORKFLOW_TYPES = {str(getattr(workflow, "__workflow_name__", workflow.__name__)): workflow for workflow in WORKFLOWS}
358
381
  WORKFLOW_TYPES["tests.replay.condition-identity"] = ConditionIdentityWorkflow
382
+ WORKFLOW_TYPES["tests.replay.entry-arguments-required"] = RequiredEntryArgumentWorkflow
359
383
 
360
384
 
361
385
  def _fixture_paths() -> list[Path]:
@@ -558,6 +582,11 @@ def test_checked_in_replay_regression_corpus_uses_official_replayer(
558
582
  message = expected_replay_error.get("message_contains")
559
583
  assert isinstance(message, str) and message
560
584
  assert expected.get("command_sequence") == []
585
+ if expected_replay_error.get("type") == "WorkflowEntryArgumentsMismatchError":
586
+ with pytest.raises(DurableWorkflowError, match=message) as binding_error:
587
+ _execute_fixture(fixture)
588
+ assert type(binding_error.value).__name__ == "WorkflowEntryArgumentsMismatchError"
589
+ return
561
590
  if expected_replay_error.get("type") == "LocalActivityExecutionAborted":
562
591
  with pytest.raises(LocalActivityExecutionAborted, match=message):
563
592
  _execute_fixture(fixture)