durable-workflow 2.5.8__tar.gz → 2.5.9__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 (129) hide show
  1. {durable_workflow-2.5.8/src/durable_workflow.egg-info → durable_workflow-2.5.9}/PKG-INFO +1 -1
  2. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/pyproject.toml +3 -3
  3. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/src/durable_workflow/client.py +78 -2
  4. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/src/durable_workflow/worker.py +29 -0
  5. {durable_workflow-2.5.8 → durable_workflow-2.5.9/src/durable_workflow.egg-info}/PKG-INFO +1 -1
  6. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/tests/test_runtime_external_payload_transport.py +204 -2
  7. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/tests/test_worker.py +19 -8
  8. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/LICENSE +0 -0
  9. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/README.md +0 -0
  10. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/setup.cfg +0 -0
  11. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/src/durable_workflow/__init__.py +0 -0
  12. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/src/durable_workflow/_activity_process.py +0 -0
  13. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/src/durable_workflow/_avro.py +0 -0
  14. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/src/durable_workflow/_cancellation_scope.py +0 -0
  15. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/src/durable_workflow/_cancellation_scope_history.py +0 -0
  16. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/src/durable_workflow/_cooperative_cancellation.py +0 -0
  17. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/src/durable_workflow/_prepared_local_activity.py +0 -0
  18. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/src/durable_workflow/_sticky_workflow_cache.py +0 -0
  19. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/src/durable_workflow/activity.py +0 -0
  20. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/src/durable_workflow/auth_composition.py +0 -0
  21. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/src/durable_workflow/cancellation.py +0 -0
  22. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/src/durable_workflow/errors.py +0 -0
  23. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/src/durable_workflow/external_storage.py +0 -0
  24. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/src/durable_workflow/external_task_input.py +0 -0
  25. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/src/durable_workflow/external_task_result.py +0 -0
  26. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/src/durable_workflow/history_bundle_verify.py +0 -0
  27. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/src/durable_workflow/interceptors.py +0 -0
  28. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/src/durable_workflow/invocable.py +0 -0
  29. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/src/durable_workflow/metrics.py +0 -0
  30. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/src/durable_workflow/nexus.py +0 -0
  31. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/src/durable_workflow/py.typed +0 -0
  32. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/src/durable_workflow/python_conformance.py +0 -0
  33. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/src/durable_workflow/replay_conformance.py +0 -0
  34. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/src/durable_workflow/replay_verify.py +0 -0
  35. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/src/durable_workflow/retry_policy.py +0 -0
  36. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/src/durable_workflow/serializer.py +0 -0
  37. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/src/durable_workflow/sync.py +0 -0
  38. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/src/durable_workflow/testing.py +0 -0
  39. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/src/durable_workflow/worker_session.py +0 -0
  40. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/src/durable_workflow/workflow.py +0 -0
  41. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/src/durable_workflow/workflow_updates_conformance.py +0 -0
  42. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/src/durable_workflow.egg-info/SOURCES.txt +0 -0
  43. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/src/durable_workflow.egg-info/dependency_links.txt +0 -0
  44. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/src/durable_workflow.egg-info/entry_points.txt +0 -0
  45. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/src/durable_workflow.egg-info/requires.txt +0 -0
  46. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/src/durable_workflow.egg-info/top_level.txt +0 -0
  47. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/tests/test_activity_cancellation_policies.py +0 -0
  48. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/tests/test_activity_context.py +0 -0
  49. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/tests/test_activity_process.py +0 -0
  50. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/tests/test_auth_composition.py +0 -0
  51. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/tests/test_avro_value_benchmark.py +0 -0
  52. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/tests/test_avro_value_protocol.py +0 -0
  53. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/tests/test_cancellation_context.py +0 -0
  54. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/tests/test_cancellation_remaining_time.py +0 -0
  55. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/tests/test_cancellation_scope_admission.py +0 -0
  56. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/tests/test_cancellation_scope_authoring.py +0 -0
  57. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/tests/test_cancellation_scope_boundary.py +0 -0
  58. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/tests/test_cancellation_scope_opening.py +0 -0
  59. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/tests/test_child_workflow_policies.py +0 -0
  60. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/tests/test_ci_checkout.py +0 -0
  61. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/tests/test_ci_integration_endpoint.py +0 -0
  62. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/tests/test_client.py +0 -0
  63. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/tests/test_client_timeouts.py +0 -0
  64. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/tests/test_codec_regression_corpus.py +0 -0
  65. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/tests/test_committed_cancellation_scope_descendants.py +0 -0
  66. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/tests/test_committed_cancellation_scope_groups.py +0 -0
  67. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/tests/test_committed_cancellation_scope_history.py +0 -0
  68. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/tests/test_committed_cancellation_scope_replay.py +0 -0
  69. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/tests/test_condition_replay_drift.py +0 -0
  70. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/tests/test_control_plane_parity_fixtures.py +0 -0
  71. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/tests/test_cooperative_cancellation.py +0 -0
  72. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/tests/test_cooperative_cancellation_client.py +0 -0
  73. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/tests/test_cooperative_cancellation_worker.py +0 -0
  74. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/tests/test_cooperative_remote_worker.py +0 -0
  75. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/tests/test_docstring_cross_references.py +0 -0
  76. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/tests/test_durable_selection.py +0 -0
  77. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/tests/test_errors.py +0 -0
  78. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/tests/test_external_storage.py +0 -0
  79. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/tests/test_external_task_input.py +0 -0
  80. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/tests/test_external_task_result.py +0 -0
  81. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/tests/test_golden_history_replay.py +0 -0
  82. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/tests/test_history_bundle_verify.py +0 -0
  83. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/tests/test_history_event_contract.py +0 -0
  84. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/tests/test_invocable.py +0 -0
  85. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/tests/test_message_streams.py +0 -0
  86. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/tests/test_metrics.py +0 -0
  87. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/tests/test_nexus_namespace.py +0 -0
  88. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/tests/test_nexus_wire.py +0 -0
  89. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/tests/test_order_processing_example.py +0 -0
  90. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/tests/test_parallel_completion_order_replay.py +0 -0
  91. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/tests/test_parallel_saga_authoring.py +0 -0
  92. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/tests/test_patch_history.py +0 -0
  93. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/tests/test_pending_operation_replay.py +0 -0
  94. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/tests/test_prepared_local_activity_client.py +0 -0
  95. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/tests/test_prepared_local_activity_groups.py +0 -0
  96. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/tests/test_prepared_local_activity_policies.py +0 -0
  97. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/tests/test_prepared_local_activity_worker.py +0 -0
  98. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/tests/test_public_boundary_scanner.py +0 -0
  99. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/tests/test_pypi_project_surface.py +0 -0
  100. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/tests/test_python_conformance.py +0 -0
  101. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/tests/test_queries.py +0 -0
  102. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/tests/test_readme_quickstart.py +0 -0
  103. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/tests/test_redrive_replay.py +0 -0
  104. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/tests/test_registration_ambiguity.py +0 -0
  105. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/tests/test_release_metadata.py +0 -0
  106. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/tests/test_replay.py +0 -0
  107. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/tests/test_replay_conformance.py +0 -0
  108. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/tests/test_replay_regression_corpus.py +0 -0
  109. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/tests/test_replay_verify.py +0 -0
  110. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/tests/test_retry_policy.py +0 -0
  111. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/tests/test_run_cancellation_scope_replay.py +0 -0
  112. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/tests/test_schedules.py +0 -0
  113. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/tests/test_scoped_run_cancellation_context.py +0 -0
  114. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/tests/test_serializer.py +0 -0
  115. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/tests/test_signals.py +0 -0
  116. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/tests/test_sleep.py +0 -0
  117. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/tests/test_standalone_activity_client.py +0 -0
  118. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/tests/test_sticky_execution.py +0 -0
  119. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/tests/test_storage_admission.py +0 -0
  120. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/tests/test_sync.py +0 -0
  121. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/tests/test_testing_harness.py +0 -0
  122. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/tests/test_update_signal_condition_replay.py +0 -0
  123. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/tests/test_updates.py +0 -0
  124. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/tests/test_wait_condition.py +0 -0
  125. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/tests/test_worker_session.py +0 -0
  126. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/tests/test_workflow_entrypoint_validation.py +0 -0
  127. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/tests/test_workflow_result_timeout.py +0 -0
  128. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/tests/test_workflow_streams.py +0 -0
  129. {durable_workflow-2.5.8 → durable_workflow-2.5.9}/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.8
3
+ Version: 2.5.9
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.8"
7
+ version = "2.5.9"
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.8"
75
- registry-version = "2.5.8"
74
+ product-train = "2.5.9"
75
+ registry-version = "2.5.9"
76
76
  supported-server-versions = "2.5.16"
77
77
  worker-protocol-version = "1.20"
78
78
  control-plane-version = "2"
@@ -28,7 +28,7 @@ import re
28
28
  import time
29
29
  import uuid
30
30
  import warnings
31
- from collections.abc import AsyncIterator, Callable, Mapping, Sequence
31
+ from collections.abc import AsyncIterator, Awaitable, Callable, Mapping, Sequence
32
32
  from dataclasses import dataclass
33
33
  from importlib.metadata import PackageNotFoundError
34
34
  from importlib.metadata import version as _pkg_version
@@ -1624,6 +1624,7 @@ class Client:
1624
1624
  _RuntimeExternalPayloadTransport | None
1625
1625
  ) = None
1626
1626
  self._runtime_external_payload_transport_resolved = False
1627
+ self._workflow_payload_observers: dict[str, Callable[[dict[str, Any]], Awaitable[None]]] = {}
1627
1628
 
1628
1629
  async def aclose(self) -> None:
1629
1630
  """Close the underlying ``httpx`` connection pool.
@@ -1631,6 +1632,7 @@ class Client:
1631
1632
  Equivalent to exiting the async-context-manager form of the client.
1632
1633
  Safe to call multiple times.
1633
1634
  """
1635
+ self._workflow_payload_observers.clear()
1634
1636
  await self._http.aclose()
1635
1637
 
1636
1638
  async def __aenter__(self) -> Client:
@@ -1877,10 +1879,12 @@ class Client:
1877
1879
  "runtime returned an external payload reference without advertising "
1878
1880
  "the authenticated namespace transport"
1879
1881
  )
1880
- result = await self._resolve_runtime_payloads(
1882
+ result = await self._resolve_workflow_claim_payloads(
1881
1883
  result,
1882
1884
  worker=worker,
1883
1885
  transport=transport,
1886
+ path=path,
1887
+ request_json=request_json,
1884
1888
  )
1885
1889
  result = self._hydrate_result_envelopes(result)
1886
1890
  outcome = "ok"
@@ -2251,6 +2255,77 @@ class Client:
2251
2255
  self.external_storage_cache.put(reference, data)
2252
2256
  return reference
2253
2257
 
2258
+ async def _resolve_workflow_claim_payloads(
2259
+ self,
2260
+ value: Any,
2261
+ *,
2262
+ worker: bool,
2263
+ transport: _RuntimeExternalPayloadTransport,
2264
+ path: str,
2265
+ request_json: Any,
2266
+ ) -> Any:
2267
+ lease: dict[str, Any] | None = None
2268
+ owner: Any = None
2269
+ is_poll = worker and path == "/worker/workflow-tasks/poll"
2270
+ history = re.fullmatch(r"/worker/workflow-tasks/([^/]+)/history", path) if worker else None
2271
+ if isinstance(request_json, dict) and isinstance(value, dict):
2272
+ if is_poll and isinstance(value.get("task"), dict):
2273
+ task = value["task"]
2274
+ if task.get("task_kind", "workflow") == "workflow":
2275
+ lease = dict(task)
2276
+ owner = request_json.get("worker_id")
2277
+ elif history is not None:
2278
+ lease = dict(value)
2279
+ lease.update({
2280
+ "task_id": unquote(history.group(1)),
2281
+ "lease_owner": request_json.get("lease_owner"),
2282
+ "workflow_task_attempt": request_json.get("workflow_task_attempt"),
2283
+ })
2284
+ owner = request_json.get("lease_owner")
2285
+ observer = self._workflow_payload_observers.get(owner) if isinstance(owner, str) else None
2286
+ if lease is None or observer is None:
2287
+ return await self._resolve_runtime_payloads(value, worker=worker, transport=transport)
2288
+ if (not isinstance(lease.get("task_id"), str) or not lease["task_id"]
2289
+ or lease.get("lease_owner", owner) != owner
2290
+ or type(lease.get("workflow_task_attempt", 1)) is not int
2291
+ or lease.get("workflow_task_attempt", 1) < 1
2292
+ or lease.get("namespace", self.namespace) != self.namespace):
2293
+ raise ExternalPayloadUnsupported("workflow payload response has an invalid task ownership fence")
2294
+ lease.setdefault("lease_owner", owner)
2295
+ lease.setdefault("workflow_task_attempt", 1)
2296
+
2297
+ async def confirm() -> None:
2298
+ await asyncio.wait_for(observer(lease), timeout=5.0)
2299
+
2300
+ async def observe() -> None:
2301
+ while True:
2302
+ await asyncio.sleep(5.0)
2303
+ await confirm()
2304
+
2305
+ # The poll body is now available, but application code cannot run until
2306
+ # its referenced bytes arrive and the same claim is confirmed again.
2307
+ await confirm()
2308
+ resolution = asyncio.create_task(self._resolve_runtime_payloads(value, worker=worker, transport=transport))
2309
+ observation = asyncio.create_task(observe())
2310
+ try:
2311
+ done, _ = await asyncio.wait({resolution, observation}, return_when=asyncio.FIRST_COMPLETED)
2312
+ if observation in done:
2313
+ await observation
2314
+ raise ExternalPayloadUnavailable("workflow payload lease observer stopped")
2315
+ result = await resolution
2316
+ observation.cancel()
2317
+ await asyncio.gather(observation, return_exceptions=True)
2318
+ await confirm()
2319
+ if isinstance(result, dict) and "cancellation_request" in lease:
2320
+ target = result.get("task") if is_poll else result
2321
+ if isinstance(target, dict):
2322
+ target["cancellation_request"] = lease["cancellation_request"]
2323
+ return result
2324
+ finally:
2325
+ resolution.cancel()
2326
+ observation.cancel()
2327
+ await asyncio.gather(resolution, observation, return_exceptions=True)
2328
+
2254
2329
  async def _resolve_runtime_payloads(
2255
2330
  self,
2256
2331
  value: Any,
@@ -2689,6 +2764,7 @@ class Client:
2689
2764
  scoped._cluster_info_lock = asyncio.Lock()
2690
2765
  scoped._runtime_external_payload_transport_cache = None
2691
2766
  scoped._runtime_external_payload_transport_resolved = False
2767
+ scoped._workflow_payload_observers = {}
2692
2768
  scoped.external_storage = None
2693
2769
  scoped.external_storage_threshold_bytes = None
2694
2770
  scoped.external_storage_cache = ExternalPayloadCache()
@@ -1519,6 +1519,28 @@ class Worker:
1519
1519
 
1520
1520
  raise RuntimeError("workflow yielded too many consecutive Nexus service calls")
1521
1521
 
1522
+ async def _renew_workflow_payload_lease(self, task: dict[str, Any]) -> None:
1523
+ if self._stop.is_set() or self._local_activity_shutdown.is_set():
1524
+ raise LocalActivityExecutionAborted("worker shutdown stopped its workflow payload transfer")
1525
+ response = await self.client.heartbeat_workflow_task(
1526
+ task_id=task["task_id"], lease_owner=self.worker_id,
1527
+ workflow_task_attempt=task["workflow_task_attempt"],
1528
+ )
1529
+ if self._stop.is_set() or self._local_activity_shutdown.is_set():
1530
+ raise LocalActivityExecutionAborted("worker shutdown stopped its workflow payload transfer")
1531
+ if not isinstance(response, Mapping) or any((
1532
+ response.get("task_id") != task["task_id"],
1533
+ response.get("lease_owner") != self.worker_id,
1534
+ response.get("workflow_task_attempt") != task["workflow_task_attempt"],
1535
+ response.get("renewed") is not True,
1536
+ )):
1537
+ raise LocalActivityExecutionAborted("workflow payload lease was not fenced and acknowledged")
1538
+ observation = response.get("cancellation_request")
1539
+ if observation is not None:
1540
+ # Preserve the observation for the normal canonical delivery path.
1541
+ # Hydrating a payload does not deliver or acknowledge cancellation.
1542
+ self._observe_workflow_cancellation(task, observation)
1543
+
1522
1544
  async def _renew_local_workflow_lease(self, task: dict[str, Any]) -> None:
1523
1545
  if self._cooperative_cancellation_supported and self._local_activity_shutdown.is_set():
1524
1546
  raise LocalActivityExecutionAborted("worker shutdown abandoned its local workflow claim")
@@ -4414,6 +4436,9 @@ class Worker:
4414
4436
  if self._stop.is_set():
4415
4437
  raise RuntimeError("worker cannot run after stop() has been called")
4416
4438
  self._run_started = True
4439
+ payload_observers = getattr(self.client, "_workflow_payload_observers", None)
4440
+ if isinstance(payload_observers, dict):
4441
+ payload_observers[self.worker_id] = self._renew_workflow_payload_lease
4417
4442
 
4418
4443
  def worker_session(self, options: WorkerSessionOptions) -> WorkerSession:
4419
4444
  """Return a typed lease handle held by this worker until shutdown."""
@@ -4532,6 +4557,10 @@ class Worker:
4532
4557
  self._registered = False
4533
4558
  log.info("worker %s deregistered", self.worker_id)
4534
4559
  self._sticky_cache.clear()
4560
+ payload_observers = getattr(self.client, "_workflow_payload_observers", None)
4561
+ if (isinstance(payload_observers, dict)
4562
+ and payload_observers.get(self.worker_id) == self._renew_workflow_payload_lease):
4563
+ payload_observers.pop(self.worker_id)
4535
4564
 
4536
4565
  @staticmethod
4537
4566
  def _remaining_shutdown_time(deadline: float) -> float:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: durable-workflow
3
- Version: 2.5.8
3
+ Version: 2.5.9
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
@@ -1,13 +1,16 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import asyncio
4
+ import gc
3
5
  import hashlib
4
6
  import json
7
+ import weakref
5
8
  from typing import Any
6
9
 
7
10
  import httpx
8
11
  import pytest
9
12
 
10
- from durable_workflow import Replayer, serializer, workflow
13
+ from durable_workflow import Replayer, Worker, serializer, workflow
11
14
  from durable_workflow.client import Client
12
15
  from durable_workflow.errors import (
13
16
  ExternalPayloadError,
@@ -26,7 +29,7 @@ from durable_workflow.external_storage import (
26
29
  ExternalPayloadCache,
27
30
  )
28
31
  from durable_workflow.retry_policy import TransportRetryPolicy
29
- from durable_workflow.workflow import CompleteWorkflow, WorkflowContext
32
+ from durable_workflow.workflow import CompleteWorkflow, LocalActivityExecutionAborted, WorkflowContext
30
33
 
31
34
 
32
35
  @workflow.defn(name="runtime-payload-replay")
@@ -309,6 +312,205 @@ def runtime_envelope(server: FakeRuntimePayloadServer, value: Any) -> dict[str,
309
312
  return {"codec": "avro", "external_payload": server.seed(blob)}
310
313
 
311
314
 
315
+ def payload_worker(client: Client, *, capabilities: list[str] | None = None) -> Worker:
316
+ worker = Worker(client, task_queue="billing", worker_id="worker-1", workflows=[RuntimePayloadReplayWorkflow],
317
+ capabilities=capabilities or [])
318
+ worker._begin_run()
319
+ worker._registration_done.set()
320
+ return worker
321
+
322
+
323
+ @pytest.mark.asyncio
324
+ @pytest.mark.parametrize("history_page", [False, True])
325
+ async def test_worker_confirms_original_lease_before_and_after_payload_hydration(history_page: bool) -> None:
326
+ server = FakeRuntimePayloadServer()
327
+ client = runtime_client(server)
328
+ payload_worker(client)
329
+ task = {"task_id": "task-1", "lease_owner": "worker-1", "workflow_task_attempt": 3,
330
+ "arguments": runtime_envelope(server, ["original-input"])}
331
+ path = "/api/worker/workflow-tasks/task-1/history" if history_page else "/api/worker/workflow-tasks/poll"
332
+ server.responses[("POST", path)] = {"history_events": [task]} if history_page else {"task": task}
333
+ server.responses[("POST", "/api/worker/workflow-tasks/task-1/heartbeat")] = {
334
+ "task_id": "task-1", "lease_owner": "worker-1", "workflow_task_attempt": 3, "renewed": True,
335
+ }
336
+ original_handler = server.handler
337
+
338
+ def handler(request: httpx.Request) -> httpx.Response:
339
+ if request.method == "GET" and "/external-payloads/" in request.url.path:
340
+ assert len([entry for entry in server.requests if entry[1].endswith("/heartbeat")]) == 1
341
+ return original_handler(request)
342
+
343
+ client._http = httpx.AsyncClient(base_url=client.base_url, transport=httpx.MockTransport(handler))
344
+ try:
345
+ if history_page:
346
+ result = await client.workflow_task_history(task_id="task-1", lease_owner="worker-1",
347
+ workflow_task_attempt=3, next_history_page_token="page-2")
348
+ resolved = result["history_events"][0]
349
+ else:
350
+ result = await client.poll_workflow_task(worker_id="worker-1", task_queue="billing", timeout=0)
351
+ resolved = result
352
+ assert serializer.decode_envelope(resolved["arguments"]) == ["original-input"]
353
+ heartbeats = [entry for entry in server.requests if entry[1].endswith("/heartbeat")]
354
+ assert len(heartbeats) == 2
355
+ assert all(entry[2] == {"lease_owner": "worker-1", "workflow_task_attempt": 3} for entry in heartbeats)
356
+ finally:
357
+ await client.aclose()
358
+
359
+
360
+ @pytest.mark.asyncio
361
+ @pytest.mark.parametrize("changed_fence", [
362
+ {"workflow_task_attempt": 4}, {"lease_owner": "worker-2"}, {"renewed": False},
363
+ ])
364
+ async def test_worker_does_not_fetch_payload_when_original_claim_is_not_confirmed(
365
+ changed_fence: dict[str, Any],
366
+ ) -> None:
367
+ server = FakeRuntimePayloadServer()
368
+ client = runtime_client(server)
369
+ payload_worker(client)
370
+ server.responses[("POST", "/api/worker/workflow-tasks/poll")] = {"task": {
371
+ "task_id": "task-1", "lease_owner": "worker-1", "workflow_task_attempt": 3,
372
+ "arguments": runtime_envelope(server, ["never-executed"]),
373
+ }}
374
+ server.responses[("POST", "/api/worker/workflow-tasks/task-1/heartbeat")] = {
375
+ "task_id": "task-1", "lease_owner": "worker-1", "workflow_task_attempt": 3, "renewed": True, **changed_fence,
376
+ }
377
+ try:
378
+ with pytest.raises(LocalActivityExecutionAborted, match="workflow payload lease was not fenced"):
379
+ await client.poll_workflow_task(worker_id="worker-1", task_queue="billing", timeout=0)
380
+ assert server.fetch_count == 0
381
+ finally:
382
+ await client.aclose()
383
+
384
+
385
+ @pytest.mark.asyncio
386
+ async def test_payload_renewal_preserves_new_cancellation_for_canonical_delivery() -> None:
387
+ from tests.test_cooperative_cancellation import observation
388
+
389
+ server = FakeRuntimePayloadServer()
390
+ client = runtime_client(server)
391
+ worker = payload_worker(client, capabilities=["cooperative_cancellation"])
392
+ worker._cooperative_cancellation_supported = True
393
+ original = observation()
394
+ server.responses[("POST", "/api/worker/workflow-tasks/poll")] = {"task": {
395
+ "task_id": "task-1", "lease_owner": "worker-1", "workflow_task_attempt": 3,
396
+ "arguments": runtime_envelope(server, ["original-input"]),
397
+ }}
398
+ server.responses[("POST", "/api/worker/workflow-tasks/task-1/heartbeat")] = {
399
+ "task_id": "task-1", "lease_owner": "worker-1", "workflow_task_attempt": 3, "renewed": True,
400
+ "cancellation_request": original,
401
+ }
402
+ try:
403
+ result = await client.poll_workflow_task(worker_id="worker-1", task_queue="billing", timeout=0)
404
+ assert result["cancellation_request"] == original
405
+ assert "_delivered_cancellation_request_id" not in result
406
+ assert not any("/cancellation/" in entry[1] for entry in server.requests)
407
+ finally:
408
+ await client.aclose()
409
+
410
+
411
+ @pytest.mark.asyncio
412
+ async def test_stopped_worker_does_not_start_claim_payload_download() -> None:
413
+ server = FakeRuntimePayloadServer()
414
+ client = runtime_client(server)
415
+ worker = payload_worker(client)
416
+ worker._stop.set()
417
+ server.responses[("POST", "/api/worker/workflow-tasks/poll")] = {"task": {
418
+ "task_id": "task-1", "lease_owner": "worker-1", "workflow_task_attempt": 3,
419
+ "arguments": runtime_envelope(server, ["never-executed"]),
420
+ }}
421
+ try:
422
+ with pytest.raises(LocalActivityExecutionAborted, match="shutdown stopped"):
423
+ await client.poll_workflow_task(worker_id="worker-1", task_queue="billing", timeout=0)
424
+ assert server.fetch_count == 0
425
+ assert not any(entry[1].endswith("/heartbeat") for entry in server.requests)
426
+ finally:
427
+ await client.aclose()
428
+
429
+
430
+ @pytest.mark.asyncio
431
+ async def test_payload_transfer_is_cancelled_when_its_periodic_lease_observer_loses_authority(
432
+ monkeypatch: pytest.MonkeyPatch,
433
+ ) -> None:
434
+ server = FakeRuntimePayloadServer()
435
+ client = runtime_client(server)
436
+ payload_worker(client)
437
+
438
+
439
+ server.responses[("POST", "/api/worker/workflow-tasks/poll")] = {"task": {
440
+ "task_id": "task-1", "lease_owner": "worker-1", "workflow_task_attempt": 3,
441
+ "arguments": runtime_envelope(server, ["never-executed"]),
442
+ }}
443
+ fetch_started = asyncio.Event()
444
+ fetch_cancelled = asyncio.Event()
445
+ observations = 0
446
+
447
+ async def heartbeat(request: httpx.Request) -> httpx.Response:
448
+ nonlocal observations
449
+ if request.url.path.endswith("/heartbeat"):
450
+ observations += 1
451
+ if observations > 1:
452
+ return httpx.Response(409, json={"reason": "lease_expired"})
453
+ return httpx.Response(200, json={"task_id": "task-1", "lease_owner": "worker-1",
454
+ "workflow_task_attempt": 3, "renewed": True})
455
+ if request.method == "GET" and "/external-payloads/" in request.url.path:
456
+ fetch_started.set()
457
+ try:
458
+ await asyncio.Event().wait()
459
+ finally:
460
+ fetch_cancelled.set()
461
+ return server.handler(request)
462
+
463
+ real_sleep = asyncio.sleep
464
+
465
+ async def prompt_observation(delay: float) -> None:
466
+ if delay == 5.0:
467
+ await fetch_started.wait()
468
+ await real_sleep(0)
469
+ else:
470
+ await real_sleep(delay)
471
+
472
+ monkeypatch.setattr("durable_workflow.client.asyncio.sleep", prompt_observation)
473
+ client._http = httpx.AsyncClient(base_url=client.base_url, transport=httpx.MockTransport(heartbeat))
474
+ try:
475
+ with pytest.raises(ServerError) as failure:
476
+ await asyncio.wait_for(client.poll_workflow_task(worker_id="worker-1", task_queue="billing", timeout=0), 2)
477
+ assert failure.value.reason() == "lease_expired"
478
+ assert fetch_cancelled.is_set()
479
+ assert observations == 2
480
+ finally:
481
+ await client.aclose()
482
+
483
+
484
+ @pytest.mark.asyncio
485
+ async def test_shared_client_does_not_retain_unstarted_or_stopped_workers() -> None:
486
+ client = runtime_client(FakeRuntimePayloadServer())
487
+ worker = Worker(client, task_queue="billing", worker_id="worker-1", workflows=[RuntimePayloadReplayWorkflow])
488
+ assert client._workflow_payload_observers == {}
489
+ reference = weakref.ref(worker)
490
+ worker._begin_run()
491
+ worker._registration_done.set()
492
+ assert "worker-1" in client._workflow_payload_observers
493
+ await worker.stop()
494
+ assert client._workflow_payload_observers == {}
495
+ del worker
496
+ await asyncio.sleep(0)
497
+ gc.collect()
498
+ assert reference() is None
499
+ await client.aclose()
500
+
501
+
502
+ @pytest.mark.asyncio
503
+ async def test_stopping_old_worker_preserves_a_new_worker_observer() -> None:
504
+ client = runtime_client(FakeRuntimePayloadServer())
505
+ older = payload_worker(client)
506
+ newer = payload_worker(client)
507
+ await older.stop()
508
+ assert client._workflow_payload_observers["worker-1"] == newer._renew_workflow_payload_lease
509
+ await newer.stop()
510
+ assert client._workflow_payload_observers == {}
511
+ await client.aclose()
512
+
513
+
312
514
  @pytest.mark.asyncio
313
515
  @pytest.mark.parametrize("threshold_bytes", [1, 4096])
314
516
  async def test_nexus_arguments_offload_without_changing_recorded_arguments(threshold_bytes: int) -> None:
@@ -4955,9 +4955,18 @@ class TestWorkerHeartbeats:
4955
4955
  async def test_heartbeat_loop_survives_transient_errors(
4956
4956
  self, mock_client: AsyncMock
4957
4957
  ) -> None:
4958
- mock_client.heartbeat_worker = AsyncMock(
4959
- side_effect=[RuntimeError("temporary"), {"acknowledged": True}]
4960
- )
4958
+ recovered = asyncio.Event()
4959
+ attempts = 0
4960
+
4961
+ async def heartbeat(**kwargs: object) -> dict[str, bool]:
4962
+ nonlocal attempts
4963
+ attempts += 1
4964
+ if attempts == 1:
4965
+ raise RuntimeError("temporary")
4966
+ recovered.set()
4967
+ return {"acknowledged": True}
4968
+
4969
+ mock_client.heartbeat_worker = AsyncMock(side_effect=heartbeat)
4961
4970
  worker = Worker(
4962
4971
  mock_client,
4963
4972
  task_queue="q1",
@@ -4967,11 +4976,13 @@ class TestWorkerHeartbeats:
4967
4976
  heartbeat_interval=0.02,
4968
4977
  )
4969
4978
  run_task = asyncio.create_task(worker.run())
4970
- await asyncio.sleep(0.15)
4971
- await worker.stop()
4972
- run_task.cancel()
4973
- with contextlib.suppress(asyncio.CancelledError):
4974
- await run_task
4979
+ try:
4980
+ await asyncio.wait_for(recovered.wait(), timeout=2)
4981
+ finally:
4982
+ await worker.stop()
4983
+ run_task.cancel()
4984
+ with contextlib.suppress(asyncio.CancelledError):
4985
+ await run_task
4975
4986
  assert mock_client.heartbeat_worker.call_count >= 2
4976
4987
 
4977
4988
  def test_process_metrics_cpu_percent_is_instantaneous_not_lifetime(