durable-workflow 2.7.0__tar.gz → 2.8.0__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 (149) hide show
  1. {durable_workflow-2.7.0/src/durable_workflow.egg-info → durable_workflow-2.8.0}/PKG-INFO +12 -1
  2. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/README.md +11 -0
  3. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/pyproject.toml +3 -3
  4. durable_workflow-2.8.0/src/durable_workflow/_activity_lease.py +325 -0
  5. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/src/durable_workflow/activity.py +25 -3
  6. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/src/durable_workflow/client.py +84 -3
  7. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/src/durable_workflow/errors.py +9 -0
  8. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/src/durable_workflow/worker.py +119 -122
  9. {durable_workflow-2.7.0 → durable_workflow-2.8.0/src/durable_workflow.egg-info}/PKG-INFO +12 -1
  10. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/src/durable_workflow.egg-info/SOURCES.txt +5 -0
  11. durable_workflow-2.8.0/tests/test_activity_heartbeat_authority.py +155 -0
  12. durable_workflow-2.8.0/tests/test_activity_lease_authority.py +176 -0
  13. durable_workflow-2.8.0/tests/test_activity_lease_renewal.py +131 -0
  14. durable_workflow-2.8.0/tests/test_activity_lease_worker.py +323 -0
  15. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/LICENSE +0 -0
  16. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/setup.cfg +0 -0
  17. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/src/durable_workflow/__init__.py +0 -0
  18. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/src/durable_workflow/_activity_process.py +0 -0
  19. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/src/durable_workflow/_avro.py +0 -0
  20. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/src/durable_workflow/_cancellation_scope.py +0 -0
  21. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/src/durable_workflow/_cancellation_scope_history.py +0 -0
  22. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/src/durable_workflow/_cooperative_cancellation.py +0 -0
  23. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/src/durable_workflow/_prepared_local_activity.py +0 -0
  24. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/src/durable_workflow/_query_task_authority.py +0 -0
  25. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/src/durable_workflow/_sticky_workflow_cache.py +0 -0
  26. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/src/durable_workflow/auth_composition.py +0 -0
  27. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/src/durable_workflow/cancellation.py +0 -0
  28. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/src/durable_workflow/external_storage.py +0 -0
  29. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/src/durable_workflow/external_task_input.py +0 -0
  30. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/src/durable_workflow/external_task_result.py +0 -0
  31. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/src/durable_workflow/history_bundle_verify.py +0 -0
  32. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/src/durable_workflow/interceptors.py +0 -0
  33. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/src/durable_workflow/invocable.py +0 -0
  34. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/src/durable_workflow/metrics.py +0 -0
  35. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/src/durable_workflow/nexus.py +0 -0
  36. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/src/durable_workflow/py.typed +0 -0
  37. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/src/durable_workflow/python_conformance.py +0 -0
  38. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/src/durable_workflow/replay_conformance.py +0 -0
  39. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/src/durable_workflow/replay_verify.py +0 -0
  40. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/src/durable_workflow/retry_policy.py +0 -0
  41. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/src/durable_workflow/serializer.py +0 -0
  42. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/src/durable_workflow/sync.py +0 -0
  43. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/src/durable_workflow/telemetry.py +0 -0
  44. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/src/durable_workflow/telemetry_carrier.py +0 -0
  45. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/src/durable_workflow/testing.py +0 -0
  46. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/src/durable_workflow/worker_session.py +0 -0
  47. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/src/durable_workflow/workflow.py +0 -0
  48. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/src/durable_workflow/workflow_updates_conformance.py +0 -0
  49. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/src/durable_workflow.egg-info/dependency_links.txt +0 -0
  50. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/src/durable_workflow.egg-info/entry_points.txt +0 -0
  51. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/src/durable_workflow.egg-info/requires.txt +0 -0
  52. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/src/durable_workflow.egg-info/top_level.txt +0 -0
  53. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_activity_cancellation_policies.py +0 -0
  54. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_activity_context.py +0 -0
  55. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_activity_process.py +0 -0
  56. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_auth_composition.py +0 -0
  57. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_avro_value_benchmark.py +0 -0
  58. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_avro_value_protocol.py +0 -0
  59. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_cancellation_context.py +0 -0
  60. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_cancellation_remaining_time.py +0 -0
  61. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_cancellation_scope_admission.py +0 -0
  62. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_cancellation_scope_authoring.py +0 -0
  63. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_cancellation_scope_boundary.py +0 -0
  64. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_cancellation_scope_opening.py +0 -0
  65. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_child_workflow_policies.py +0 -0
  66. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_ci_checkout.py +0 -0
  67. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_ci_integration_endpoint.py +0 -0
  68. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_client.py +0 -0
  69. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_client_telemetry.py +0 -0
  70. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_client_timeouts.py +0 -0
  71. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_codec_regression_corpus.py +0 -0
  72. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_committed_cancellation_scope_descendants.py +0 -0
  73. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_committed_cancellation_scope_groups.py +0 -0
  74. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_committed_cancellation_scope_history.py +0 -0
  75. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_committed_cancellation_scope_replay.py +0 -0
  76. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_condition_replay_drift.py +0 -0
  77. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_control_plane_parity_fixtures.py +0 -0
  78. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_cooperative_cancellation.py +0 -0
  79. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_cooperative_cancellation_client.py +0 -0
  80. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_cooperative_cancellation_worker.py +0 -0
  81. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_cooperative_remote_worker.py +0 -0
  82. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_docstring_cross_references.py +0 -0
  83. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_durable_selection.py +0 -0
  84. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_errors.py +0 -0
  85. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_external_storage.py +0 -0
  86. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_external_task_input.py +0 -0
  87. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_external_task_result.py +0 -0
  88. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_golden_history_replay.py +0 -0
  89. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_history_bundle_verify.py +0 -0
  90. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_history_event_contract.py +0 -0
  91. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_invocable.py +0 -0
  92. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_message_streams.py +0 -0
  93. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_metrics.py +0 -0
  94. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_nexus_durable_wait.py +0 -0
  95. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_nexus_namespace.py +0 -0
  96. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_nexus_wire.py +0 -0
  97. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_order_processing_example.py +0 -0
  98. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_parallel_completion_order_replay.py +0 -0
  99. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_parallel_saga_authoring.py +0 -0
  100. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_patch_history.py +0 -0
  101. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_pending_operation_replay.py +0 -0
  102. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_prepared_local_activity_client.py +0 -0
  103. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_prepared_local_activity_groups.py +0 -0
  104. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_prepared_local_activity_policies.py +0 -0
  105. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_prepared_local_activity_worker.py +0 -0
  106. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_public_boundary_scanner.py +0 -0
  107. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_pypi_project_surface.py +0 -0
  108. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_python_conformance.py +0 -0
  109. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_queries.py +0 -0
  110. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_query_task_authority.py +0 -0
  111. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_query_timeout_retry.py +0 -0
  112. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_readme_quickstart.py +0 -0
  113. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_redrive_replay.py +0 -0
  114. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_registration_ambiguity.py +0 -0
  115. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_release_metadata.py +0 -0
  116. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_replay.py +0 -0
  117. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_replay_clock.py +0 -0
  118. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_replay_conformance.py +0 -0
  119. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_replay_regression_corpus.py +0 -0
  120. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_replay_verify.py +0 -0
  121. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_retry_policy.py +0 -0
  122. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_run_cancellation_scope_replay.py +0 -0
  123. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_runtime_external_payload_transport.py +0 -0
  124. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_schedules.py +0 -0
  125. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_scoped_run_cancellation_context.py +0 -0
  126. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_serializer.py +0 -0
  127. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_signals.py +0 -0
  128. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_sleep.py +0 -0
  129. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_standalone_activity_client.py +0 -0
  130. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_sticky_execution.py +0 -0
  131. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_storage_admission.py +0 -0
  132. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_sync.py +0 -0
  133. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_sync_activities.py +0 -0
  134. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_telemetry.py +0 -0
  135. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_telemetry_carrier.py +0 -0
  136. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_testing_harness.py +0 -0
  137. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_update_signal_condition_replay.py +0 -0
  138. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_updates.py +0 -0
  139. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_wait_condition.py +0 -0
  140. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_worker.py +0 -0
  141. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_worker_observation.py +0 -0
  142. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_worker_session.py +0 -0
  143. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_worker_telemetry_wire.py +0 -0
  144. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_workflow_entry_arguments.py +0 -0
  145. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_workflow_entrypoint_validation.py +0 -0
  146. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_workflow_handler_validation.py +0 -0
  147. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_workflow_result_timeout.py +0 -0
  148. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_workflow_streams.py +0 -0
  149. {durable_workflow-2.7.0 → durable_workflow-2.8.0}/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.7.0
3
+ Version: 2.8.0
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
@@ -185,6 +185,17 @@ independently of application heartbeats. Existing terminal cancellation remains
185
185
  available. See the [cancellation guide](docs/cooperative-cancellation-design.md)
186
186
  for immutable context, operation policies, shielded cleanup and recovery.
187
187
 
188
+ Both `await activity.context().heartbeat()` and synchronous
189
+ `activity.context().heartbeat_sync()` stop at an explicit Server refusal.
190
+ They raise `ActivityLeaseAuthorityLost` with its original `.reason` for an
191
+ elapsed deadline, closed attempt or closed run. Cancellation continues to raise
192
+ `ActivityCancelled`. The worker submits no result or failure for an attempt
193
+ whose heartbeat refused continuation, even if application code catches that
194
+ exception. A heartbeat check acts only when called. Code that never heartbeats
195
+ can continue until it returns, subject to independent lease/cancellation
196
+ supervision; a running native thread cannot be forcibly stopped or its effects
197
+ undone by the SDK.
198
+
188
199
  ## Development
189
200
 
190
201
  The installed `durable-workflow-replay-conformance` command runs an offline
@@ -142,6 +142,17 @@ independently of application heartbeats. Existing terminal cancellation remains
142
142
  available. See the [cancellation guide](docs/cooperative-cancellation-design.md)
143
143
  for immutable context, operation policies, shielded cleanup and recovery.
144
144
 
145
+ Both `await activity.context().heartbeat()` and synchronous
146
+ `activity.context().heartbeat_sync()` stop at an explicit Server refusal.
147
+ They raise `ActivityLeaseAuthorityLost` with its original `.reason` for an
148
+ elapsed deadline, closed attempt or closed run. Cancellation continues to raise
149
+ `ActivityCancelled`. The worker submits no result or failure for an attempt
150
+ whose heartbeat refused continuation, even if application code catches that
151
+ exception. A heartbeat check acts only when called. Code that never heartbeats
152
+ can continue until it returns, subject to independent lease/cancellation
153
+ supervision; a running native thread cannot be forcibly stopped or its effects
154
+ undone by the SDK.
155
+
145
156
  ## Development
146
157
 
147
158
  The installed `durable-workflow-replay-conformance` command runs an offline
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "durable-workflow"
7
- version = "2.7.0"
7
+ version = "2.8.0"
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"
@@ -75,8 +75,8 @@ durable-workflow-replay-conformance = "durable_workflow.replay_conformance:main"
75
75
  durable-workflow-workflow-updates-conformance = "durable_workflow.workflow_updates_conformance:main"
76
76
 
77
77
  [tool.durable-workflow]
78
- product-train = "2.7.0"
79
- registry-version = "2.7.0"
78
+ product-train = "2.8.0"
79
+ registry-version = "2.8.0"
80
80
  supported-server-versions = "2.8.0"
81
81
  worker-protocol-version = "1.20"
82
82
  control-plane-version = "2"
@@ -0,0 +1,325 @@
1
+ """Runtime lease maintenance for one original activity delivery."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import re
7
+ import time
8
+ from collections.abc import Awaitable, Callable, Mapping
9
+ from contextvars import ContextVar
10
+ from dataclasses import dataclass, field
11
+ from datetime import datetime, timedelta
12
+ from typing import Any, TypeVar
13
+ from urllib.parse import quote
14
+
15
+ import httpx
16
+
17
+ from .errors import ActivityLeaseAuthorityLost, ServerError
18
+
19
+ _T = TypeVar("_T")
20
+ _UTC6 = re.compile(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{6}Z\Z")
21
+
22
+
23
+ def _timestamp(value: Any) -> datetime:
24
+ if not isinstance(value, str) or _UTC6.fullmatch(value) is None:
25
+ raise ValueError("activity timing must be UTC with six fractional digits")
26
+ return datetime.fromisoformat(value.replace("Z", "+00:00"))
27
+
28
+
29
+ def activity_lease_renewal_supported(client: Any) -> bool:
30
+ """Use the same cached discovery that validates actual renewal requests."""
31
+ info = getattr(client, "_cluster_info", None)
32
+ protocol = info.get("worker_protocol") if isinstance(info, Mapping) else None
33
+ capabilities = protocol.get("server_capabilities") if isinstance(protocol, Mapping) else None
34
+ return isinstance(capabilities, Mapping) and capabilities.get("activity_lease_renewal") is True
35
+
36
+
37
+ def _heartbeat_terms(task: Mapping[str, Any], deadlines: Mapping[str, Any],
38
+ reference: datetime) -> tuple[datetime | None, int | None]:
39
+ heartbeat = _timestamp(deadlines["heartbeat"]) if deadlines.get("heartbeat") is not None else None
40
+ policy = task.get("retry_policy")
41
+ interval = policy.get("heartbeat_timeout") if isinstance(policy, Mapping) else None
42
+ if heartbeat is not None and (type(interval) is not int or interval <= 0):
43
+ raise ValueError("missing recorded activity heartbeat interval")
44
+ interval = interval if type(interval) is int and interval > 0 else None
45
+ if heartbeat is not None and interval is not None and heartbeat - reference > timedelta(seconds=interval):
46
+ raise ValueError("activity heartbeat deadline predates its claimed progress")
47
+ return heartbeat, interval
48
+
49
+
50
+ @dataclass
51
+ class ActivityLeaseAuthority:
52
+ client: Any
53
+ namespace: str
54
+ identity: dict[str, Any]
55
+ task_identity: dict[str, Any]
56
+ captured_at: float
57
+ lease_until: float
58
+ business_until: float | None
59
+ heartbeat_until: float | None
60
+ heartbeat_timeout: int | None
61
+ server_time: datetime
62
+ lease_expiry: datetime
63
+ heartbeat_time: datetime | None
64
+ heartbeat_deadline: datetime | None
65
+ last_renewal: tuple[datetime, datetime, float] | None = None
66
+ lost_reason: str | None = field(default=None, init=False)
67
+
68
+ @classmethod
69
+ async def confirm_delivery(cls, client: Any, namespace: str, task: Mapping[str, Any], *, owner: str,
70
+ task_queue: str, server_time: Any) -> ActivityLeaseAuthority:
71
+ # A long poll may wait before a task or its lease exists. Establish the
72
+ # first execution budget with a separate, original-claim confirmation;
73
+ # its entire round trip is charged and it cannot revive an expired lease.
74
+ provisional = cls.delivery(client, namespace, task, owner=owner, task_queue=task_queue,
75
+ server_time=server_time, request_started=time.monotonic())
76
+ started = time.monotonic()
77
+ try:
78
+ reply = await client.renew_activity_task_lease(
79
+ task_id=provisional.task_id, activity_attempt_id=provisional.identity["activity_attempt_id"],
80
+ lease_owner=owner, timeout=min(5.0, provisional.remaining()),
81
+ )
82
+ provisional.acknowledge(reply, started)
83
+ except ActivityLeaseAuthorityLost:
84
+ raise
85
+ except Exception as error:
86
+ reason = error.reason() if isinstance(error, ServerError) else None
87
+ raise ActivityLeaseAuthorityLost(
88
+ provisional.task_id, reason or "activity_initial_lease_unavailable",
89
+ ) from error
90
+ observed = dict(task)
91
+ observed["lease_expires_at"] = reply["lease_expires_at"]
92
+ if provisional.heartbeat_deadline is not None:
93
+ observed["deadlines"] = {**dict(task.get("deadlines", {})),
94
+ "heartbeat": provisional.heartbeat_deadline.isoformat(
95
+ timespec="microseconds").replace("+00:00", "Z")}
96
+ return cls.delivery(client, namespace, observed, owner=owner, task_queue=task_queue,
97
+ server_time=reply["server_time"], request_started=started)
98
+
99
+ @classmethod
100
+ def delivery(cls, client: Any, namespace: str, task: Mapping[str, Any], *, owner: str,
101
+ task_queue: str, server_time: Any, request_started: float) -> ActivityLeaseAuthority:
102
+ task_id = str(task.get("task_id") or "")
103
+ try:
104
+ keys = ("task_id", "workflow_id", "run_id", "activity_execution_id", "activity_attempt_id",
105
+ "lease_owner", "task_queue")
106
+ if (any(not isinstance(task.get(key), str) or not task[key] for key in keys)
107
+ or task["lease_owner"] != owner or task["task_queue"] != task_queue
108
+ or task.get("namespace", namespace) != namespace
109
+ or type(task.get("attempt_number")) is not int or task["attempt_number"] < 1):
110
+ raise ValueError("invalid original activity identity")
111
+ reference = _timestamp(server_time)
112
+ expiry = _timestamp(task.get("lease_expires_at"))
113
+ duration = (expiry - reference).total_seconds()
114
+ if not 0 < duration <= 86400 or not 0 <= request_started <= time.monotonic():
115
+ raise ValueError("invalid original activity lease")
116
+ deadlines = task.get("deadlines", {})
117
+ if not isinstance(deadlines, Mapping):
118
+ raise ValueError("invalid activity deadlines")
119
+ bounds = [_timestamp(deadlines[key]) for key in ("start_to_close", "schedule_to_close", "run", "execution")
120
+ if deadlines.get(key) is not None]
121
+ business = request_started + (min(bounds) - reference).total_seconds() if bounds else None
122
+ heartbeat, interval = _heartbeat_terms(task, deadlines, reference)
123
+ result = cls(
124
+ client, namespace,
125
+ {"workflow_task_id": task["task_id"], "workflow_instance_id": task["workflow_id"],
126
+ "workflow_run_id": task["run_id"], "activity_execution_id": task["activity_execution_id"],
127
+ "activity_attempt_id": task["activity_attempt_id"], "attempt_number": task["attempt_number"],
128
+ "lease_owner": owner},
129
+ {key: task[key] for key in (*keys, "attempt_number")},
130
+ request_started, request_started + duration, business,
131
+ request_started + (heartbeat - reference).total_seconds() if heartbeat is not None else None,
132
+ interval, reference, expiry,
133
+ heartbeat - timedelta(seconds=interval) if heartbeat is not None and interval is not None else None,
134
+ heartbeat,
135
+ )
136
+ except (KeyError, TypeError, ValueError, OverflowError) as error:
137
+ raise ActivityLeaseAuthorityLost(task_id, "invalid_activity_claim") from error
138
+ result.remaining()
139
+ return result
140
+
141
+ @property
142
+ def task_id(self) -> str:
143
+ return str(self.identity["workflow_task_id"])
144
+
145
+ def matches(self, client: Any, namespace: str, task: Mapping[str, Any], owner: str, task_queue: str) -> bool:
146
+ return (self.client is client and self.namespace == namespace and self.identity["lease_owner"] == owner
147
+ and self.task_identity["task_queue"] == task_queue
148
+ and type(task.get("attempt_number")) is int
149
+ and all(task.get(key) == value for key, value in self.task_identity.items()))
150
+
151
+ def governs(self, client: Any, namespace: str, path: str) -> bool:
152
+ prefixes = (f"/worker/activity-tasks/{self.task_id}/",
153
+ f"/worker/activity-tasks/{quote(self.task_id, safe='')}/")
154
+ return (self.client is client and self.namespace == namespace
155
+ and (any(path == prefix + action for prefix in prefixes
156
+ for action in ("complete", "fail", "heartbeat", "renew-lease"))
157
+ or path == "/external-payloads/v1"))
158
+
159
+ def acknowledge_progress(self, reply: Any, request_started: float) -> None:
160
+ """Use an actual application heartbeat, never a runtime liveness ping.
161
+
162
+ The existing heartbeat contract records last_heartbeat_at at the write
163
+ and resets its original recorded timeout interval from that instant.
164
+ Charging the whole request duration consumes storage/network latency.
165
+ """
166
+ self.remaining()
167
+ received = time.monotonic()
168
+ try:
169
+ expected = {"task_id": self.task_id, "activity_attempt_id": self.identity["activity_attempt_id"],
170
+ "lease_owner": self.identity["lease_owner"]}
171
+ if (not isinstance(reply, Mapping) or reply.get("heartbeat_recorded") is not True
172
+ or reply.get("can_continue") is not True or reply.get("cancel_requested") is not False
173
+ or reply.get("reason") is not None
174
+ or any(reply.get(key) != value for key, value in expected.items())
175
+ or not self.captured_at <= request_started <= received):
176
+ raise ValueError("heartbeat did not confirm the original activity")
177
+ progress = _timestamp(reply.get("last_heartbeat_at"))
178
+ expiry = _timestamp(reply.get("lease_expires_at"))
179
+ duration = (expiry - progress).total_seconds()
180
+ if not 0 < duration <= 86400:
181
+ raise ValueError("invalid heartbeat lease grant")
182
+ if self.heartbeat_time is not None and progress <= self.heartbeat_time:
183
+ return
184
+ if request_started + duration <= received:
185
+ raise ValueError("heartbeat lease grant was consumed in transport")
186
+ if self.heartbeat_timeout is not None:
187
+ until = request_started + self.heartbeat_timeout
188
+ if until <= received:
189
+ raise ValueError("heartbeat progress window was consumed in transport")
190
+ self.heartbeat_until = max(self.heartbeat_until or until, until)
191
+ self.heartbeat_deadline = progress + timedelta(seconds=self.heartbeat_timeout)
192
+ self.heartbeat_time = progress
193
+ self.lease_until = max(self.lease_until, request_started + duration)
194
+ self.lease_expiry = max(self.lease_expiry, expiry)
195
+ except (TypeError, ValueError, OverflowError) as error:
196
+ self.lost_reason = "invalid_activity_heartbeat"
197
+ raise ActivityLeaseAuthorityLost(self.task_id, self.lost_reason) from error
198
+
199
+ def remaining(self) -> float:
200
+ if self.lost_reason is not None:
201
+ raise ActivityLeaseAuthorityLost(self.task_id, self.lost_reason)
202
+ bounds = [self.lease_until, *(value for value in (self.business_until, self.heartbeat_until)
203
+ if value is not None)]
204
+ remaining = min(bounds) - time.monotonic()
205
+ if remaining <= 0:
206
+ self.lost_reason = "activity_execution_budget_expired"
207
+ raise ActivityLeaseAuthorityLost(self.task_id, self.lost_reason)
208
+ return remaining
209
+
210
+ def acknowledge(self, reply: Any, request_started: float) -> None:
211
+ self.remaining()
212
+ received = time.monotonic()
213
+ try:
214
+ if (not isinstance(reply, Mapping) or reply.get("lease_renewed") is not True
215
+ or reply.get("can_continue") is not True or reply.get("cancel_requested") is not False
216
+ or reply.get("heartbeat_recorded") is not False or reply.get("reason") is not None
217
+ or type(reply.get("attempt_number")) is not int
218
+ or any(reply.get(key) != value for key, value in self.identity.items())
219
+ or not self.captured_at <= request_started <= received):
220
+ raise ValueError("renewal did not preserve the original activity claim")
221
+ server = _timestamp(reply.get("server_time"))
222
+ expiry = _timestamp(reply.get("lease_expires_at"))
223
+ duration = (expiry - server).total_seconds()
224
+ if not 0 < duration <= 86400 or request_started + duration <= received:
225
+ raise ValueError("renewal did not prove a live grant")
226
+ if (self.heartbeat_time is not None and server < self.heartbeat_time and expiry <= self.lease_expiry):
227
+ # An independently acknowledged application heartbeat can
228
+ # overtake an in-flight renewal. Its older reply grants nothing.
229
+ return
230
+ if expiry < self.lease_expiry or server < self.server_time:
231
+ raise ValueError("renewal predates the confirmed grant")
232
+ confirmation = (server, expiry, request_started)
233
+ if self.last_renewal is not None and server <= self.last_renewal[0]:
234
+ if confirmation == self.last_renewal:
235
+ return
236
+ raise ValueError("replayed activity renewal")
237
+ self.lease_until = max(self.lease_until, request_started + duration)
238
+ self._observe_progress(reply, server, request_started, received)
239
+ self.server_time, self.lease_expiry, self.last_renewal = server, expiry, confirmation
240
+ except (TypeError, ValueError, OverflowError) as error:
241
+ self.lost_reason = "invalid_activity_renewal"
242
+ raise ActivityLeaseAuthorityLost(self.task_id, self.lost_reason) from error
243
+
244
+ def _observe_progress(self, reply: Mapping[str, Any], server: datetime, started: float, received: float) -> None:
245
+ if self.heartbeat_until is None or self.heartbeat_timeout is None:
246
+ return
247
+ try:
248
+ progress = _timestamp(reply.get("last_heartbeat_at"))
249
+ deadline = _timestamp(reply.get("heartbeat_deadline_at"))
250
+ except (TypeError, ValueError):
251
+ return
252
+ until = started + (deadline - server).total_seconds()
253
+ if (progress <= server < deadline and until > received
254
+ and (self.heartbeat_time is None or progress > self.heartbeat_time)
255
+ and (self.heartbeat_deadline is None or deadline > self.heartbeat_deadline)
256
+ and deadline - progress == timedelta(seconds=self.heartbeat_timeout)):
257
+ self.heartbeat_until = max(self.heartbeat_until, until)
258
+ self.heartbeat_time, self.heartbeat_deadline = progress, deadline
259
+
260
+ async def renew(self) -> bool:
261
+ started = time.monotonic()
262
+ try:
263
+ reply = await asyncio.wait_for(self.client.renew_activity_task_lease(
264
+ task_id=self.task_id, activity_attempt_id=self.identity["activity_attempt_id"],
265
+ lease_owner=self.identity["lease_owner"], timeout=min(5.0, self.remaining()),
266
+ ), timeout=min(5.0, self.remaining()))
267
+ self.acknowledge(reply, started)
268
+ return True
269
+ except ActivityLeaseAuthorityLost:
270
+ raise
271
+ except Exception as error:
272
+ if (isinstance(error, (httpx.TransportError, asyncio.TimeoutError))
273
+ or isinstance(error, ServerError) and (error.status >= 500 or error.status == 429)):
274
+ # A failed exchange grants nothing. The existing lease may
275
+ # still authorize work until its original local bound.
276
+ self.remaining()
277
+ return False
278
+ self.lost_reason = "activity_renewal_unavailable"
279
+ raise ActivityLeaseAuthorityLost(self.task_id, self.lost_reason) from error
280
+
281
+ async def _maintain(self, stopped: asyncio.Event) -> None:
282
+ confirmed = True
283
+ while not stopped.is_set():
284
+ delay = min(30.0 if confirmed else 1.0, max(0.0, self.lease_until - time.monotonic()) / 3)
285
+ if self.heartbeat_timeout is not None:
286
+ delay = min(delay, self.heartbeat_timeout / 3)
287
+ await asyncio.sleep(min(delay, self.remaining()))
288
+ if stopped.is_set():
289
+ return
290
+ confirmed = await self.renew()
291
+
292
+ async def run(self, operation: Callable[[], Awaitable[_T]]) -> _T:
293
+ self.remaining()
294
+ stopped = asyncio.Event()
295
+ execution = asyncio.ensure_future(operation())
296
+ maintenance = asyncio.create_task(self._maintain(stopped))
297
+ try:
298
+ while True:
299
+ done, _ = await asyncio.wait({execution, maintenance}, timeout=self.remaining(),
300
+ return_when=asyncio.FIRST_COMPLETED)
301
+ if maintenance in done:
302
+ await maintenance
303
+ raise ActivityLeaseAuthorityLost(self.task_id, "activity_renewal_stopped")
304
+ if execution in done:
305
+ result = await execution
306
+ self.remaining()
307
+ return result
308
+ except BaseException:
309
+ self.lost_reason = self.lost_reason or "activity_execution_abandoned"
310
+ raise
311
+ finally:
312
+ # An I/O wrapper may complete concurrently with cancel() and consume
313
+ # that cancellation. The settled scope must still stop renewing.
314
+ stopped.set()
315
+ # A Python thread may continue after cancellation. Its existing
316
+ # executor keeps the physical slot until it actually exits, while
317
+ # this context independently fences every late HTTP publication.
318
+ for future in (execution, maintenance):
319
+ future.cancel()
320
+ await asyncio.gather(execution, maintenance, return_exceptions=True)
321
+
322
+
323
+ current_activity_authority: ContextVar[ActivityLeaseAuthority | None] = ContextVar(
324
+ "activity_lease_authority", default=None,
325
+ )
@@ -7,7 +7,8 @@ from collections.abc import Awaitable, Callable
7
7
  from dataclasses import dataclass
8
8
  from typing import TYPE_CHECKING, Any
9
9
 
10
- from .errors import ActivityCancelled
10
+ from ._activity_lease import current_activity_authority
11
+ from .errors import ActivityCancelled, ActivityLeaseAuthorityLost
11
12
 
12
13
  if TYPE_CHECKING:
13
14
  from .client import Client
@@ -45,6 +46,7 @@ class ActivityContext:
45
46
  self._client = client
46
47
  self._heartbeat_callback = heartbeat_callback
47
48
  self._cancel_requested = False
49
+ self._cannot_continue_reason: str | None = None
48
50
  try:
49
51
  self._event_loop: asyncio.AbstractEventLoop | None = asyncio.get_running_loop()
50
52
  except RuntimeError:
@@ -61,7 +63,7 @@ class ActivityContext:
61
63
  return self._cancel_requested
62
64
 
63
65
  def heartbeat_sync(self, details: dict[str, Any] | None = None) -> None:
64
- """Heartbeat from a synchronous activity thread and observe cancellation.
66
+ """Heartbeat from a synchronous activity thread and check attempt authority.
65
67
 
66
68
  The request runs on the worker's event loop using the original attempt
67
69
  and client. Async handlers must use ``await heartbeat()`` instead.
@@ -84,7 +86,7 @@ class ActivityContext:
84
86
  pending.result()
85
87
 
86
88
  async def heartbeat(self, details: dict[str, Any] | None = None) -> None:
87
- """Report liveness to the server and check for a cancellation request.
89
+ """Report progress and check cancellation and continued attempt authority.
88
90
 
89
91
  Long-running activities should call ``heartbeat()`` periodically so the
90
92
  server can distinguish a slow-but-alive attempt from a dead worker.
@@ -94,7 +96,12 @@ class ActivityContext:
94
96
  Raises :class:`~durable_workflow.errors.ActivityCancelled` when the
95
97
  owning workflow has requested cancellation, so the activity can exit
96
98
  cleanly at its next natural break point.
99
+
100
+ Raises :class:`~durable_workflow.errors.ActivityLeaseAuthorityLost`
101
+ with the original Server reason when the attempt cannot continue,
102
+ including an elapsed deadline, closed attempt or closed run.
97
103
  """
104
+ self._raise_if_cannot_continue()
98
105
  if self._heartbeat_callback is not None:
99
106
  try:
100
107
  await self._heartbeat_callback(details)
@@ -111,6 +118,21 @@ class ActivityContext:
111
118
  if isinstance(resp, dict) and resp.get("cancel_requested"):
112
119
  self._cancel_requested = True
113
120
  raise ActivityCancelled()
121
+ if isinstance(resp, dict) and resp.get("can_continue") is False:
122
+ reason = resp.get("reason")
123
+ self._cannot_continue_reason = reason if isinstance(reason, str) and reason else "activity_cannot_continue"
124
+ self._raise_if_cannot_continue()
125
+
126
+ def _raise_if_cannot_continue(self) -> None:
127
+ if self._cannot_continue_reason is not None:
128
+ authority = current_activity_authority.get()
129
+ if (authority is not None and authority.client is self._client
130
+ and authority.task_id == self._info.task_id
131
+ and authority.identity["activity_attempt_id"] == self._info.activity_attempt_id
132
+ and authority.identity["attempt_number"] == self._info.attempt_number
133
+ and authority.identity["lease_owner"] == self._info.worker_id):
134
+ authority.lost_reason = authority.lost_reason or self._cannot_continue_reason
135
+ raise ActivityLeaseAuthorityLost(self._info.task_id, self._cannot_continue_reason)
114
136
 
115
137
 
116
138
  def context() -> ActivityContext:
@@ -39,6 +39,7 @@ from urllib.parse import quote, unquote, urlencode, urlsplit
39
39
  import httpx
40
40
 
41
41
  from . import serializer
42
+ from ._activity_lease import ActivityLeaseAuthority, current_activity_authority
42
43
  from ._cancellation_scope import CancellationScopeOpenReceipt, scope_identity
43
44
  from ._cancellation_scope_history import (
44
45
  CancellationScopeBudget,
@@ -1871,8 +1872,14 @@ class Client:
1871
1872
  raise ValueError("cluster discovery credentials can only authorize /cluster/info")
1872
1873
 
1873
1874
  query_poll = worker and path == "/worker/query-tasks/poll"
1875
+ activity_poll = worker and path == "/worker/activity-tasks/poll"
1874
1876
  if query_poll:
1875
1877
  current_query_authority.set(None)
1878
+ if activity_poll:
1879
+ current_activity_authority.set(None)
1880
+ activity_authority = current_activity_authority.get()
1881
+ if worker and activity_authority is not None and activity_authority.governs(self, self.namespace, path):
1882
+ activity_authority.remaining()
1876
1883
  request_json = json
1877
1884
  if (
1878
1885
  not discovery
@@ -1904,14 +1911,20 @@ class Client:
1904
1911
  plane = "worker" if worker or discovery_uses_worker_token else "control"
1905
1912
  status_code = "none"
1906
1913
  outcome = "pending"
1914
+ request_started = time.monotonic()
1907
1915
 
1908
1916
  async def _do_request() -> httpx.Response:
1917
+ nonlocal request_started
1918
+ request_started = time.monotonic()
1919
+ request_timeout = self.timeout if timeout is None else timeout
1920
+ if worker and activity_authority is not None and activity_authority.governs(self, self.namespace, path):
1921
+ request_timeout = min(request_timeout, activity_authority.remaining())
1909
1922
  resp = await self._http.request(
1910
1923
  method,
1911
1924
  f"/api{path}",
1912
1925
  headers=self._discovery_headers() if discovery else self._headers(worker=worker),
1913
1926
  json=request_json,
1914
- timeout=self.timeout if timeout is None else timeout,
1927
+ timeout=request_timeout,
1915
1928
  )
1916
1929
  # Raise HTTPStatusError for 4xx/5xx so retry policy can catch it
1917
1930
  resp.raise_for_status()
@@ -1936,11 +1949,28 @@ class Client:
1936
1949
  outcome = "ok"
1937
1950
  return None
1938
1951
  result = resp.json()
1952
+ if (worker and activity_authority is not None
1953
+ and activity_authority.governs(self, self.namespace, path)
1954
+ and path.endswith("/heartbeat") and isinstance(result, dict)
1955
+ and result.get("heartbeat_recorded") is True):
1956
+ activity_authority.acknowledge_progress(result, request_started)
1957
+ if activity_poll and isinstance(result, dict) and isinstance(result.get("task"), dict):
1958
+ capabilities = result.get("server_capabilities")
1959
+ if isinstance(capabilities, dict) and capabilities.get("activity_lease_renewal") is True:
1960
+ activity_authority = await ActivityLeaseAuthority.confirm_delivery(
1961
+ self, self.namespace, result["task"], owner=request_json["worker_id"],
1962
+ task_queue=request_json["task_queue"], server_time=result.get("server_time"),
1963
+ )
1964
+ current_activity_authority.set(activity_authority)
1939
1965
  query_authority = None
1940
1966
  if query_poll and isinstance(result, dict) and isinstance(result.get("task"), dict):
1941
1967
  query_authority = await self._query_delivery_authority(result["task"], request_json)
1942
1968
  current_query_authority.set(query_authority)
1943
- if not discovery and _contains_runtime_payload_envelope(result):
1969
+ if activity_authority is not None and activity_poll and _contains_runtime_payload_envelope(result):
1970
+ result = await activity_authority.run(
1971
+ lambda: self._resolve_activity_claim_payloads(result, worker=worker),
1972
+ )
1973
+ elif not discovery and _contains_runtime_payload_envelope(result):
1944
1974
  transport = (await query_authority.run(self._runtime_external_payload_transport)
1945
1975
  if query_authority is not None else await self._runtime_external_payload_transport())
1946
1976
  if transport is None:
@@ -1957,6 +1987,8 @@ class Client:
1957
1987
  result, worker=worker, transport=transport, path=path, request_json=request_json,
1958
1988
  )
1959
1989
  result = self._hydrate_result_envelopes(result)
1990
+ if activity_poll and activity_authority is not None:
1991
+ activity_authority.remaining()
1960
1992
  if query_authority is not None:
1961
1993
  await query_authority.confirm()
1962
1994
  outcome = "ok"
@@ -1976,6 +2008,12 @@ class Client:
1976
2008
  self.metrics.increment(CLIENT_REQUESTS, tags=tags)
1977
2009
  self.metrics.record(CLIENT_REQUEST_DURATION_SECONDS, time.perf_counter() - start, tags=tags)
1978
2010
 
2011
+ async def _resolve_activity_claim_payloads(self, value: Any, *, worker: bool) -> Any:
2012
+ transport = await self._runtime_external_payload_transport()
2013
+ if transport is None:
2014
+ raise ExternalPayloadUnsupported("activity payload reference has no authenticated runtime transport")
2015
+ return await self._resolve_runtime_payloads(value, worker=worker, transport=transport, resolved={})
2016
+
1979
2017
  async def _runtime_external_payload_transport(
1980
2018
  self,
1981
2019
  ) -> _RuntimeExternalPayloadTransport | None:
@@ -6160,6 +6198,48 @@ class Client:
6160
6198
  "POST", f"/worker/activity-tasks/{task_id}/fail", worker=True, json=body
6161
6199
  )
6162
6200
 
6201
+ async def renew_activity_task_lease(
6202
+ self,
6203
+ *,
6204
+ task_id: str,
6205
+ activity_attempt_id: str,
6206
+ lease_owner: str,
6207
+ timeout: float = 5.0,
6208
+ ) -> Any:
6209
+ """Renew one live activity claim without recording application progress.
6210
+
6211
+ Requires affirmative ``activity_lease_renewal`` discovery. The caller
6212
+ must validate the returned original identity and grant before using it.
6213
+ This neither revives an expired claim nor extends business timeouts.
6214
+ Discovery, retries and transport share the supplied bounded window.
6215
+ """
6216
+ for field, value in (
6217
+ ("task_id", task_id), ("activity_attempt_id", activity_attempt_id), ("lease_owner", lease_owner),
6218
+ ):
6219
+ if not isinstance(value, str) or not value.strip():
6220
+ raise ValueError(f"{field} must be a non-empty string")
6221
+ if type(timeout) not in (int, float) or not math.isfinite(timeout) or not 0 < timeout <= 5:
6222
+ raise ValueError("activity lease renewal timeout must be greater than zero and at most five seconds")
6223
+
6224
+ async def renew() -> Any:
6225
+ operation = "Client.renew_activity_task_lease"
6226
+ path = "worker_protocol.server_capabilities.activity_lease_renewal"
6227
+ info = await self._runtime_discovery(operation=operation, required_path=path)
6228
+ protocol = info.get("worker_protocol")
6229
+ capabilities = protocol.get("server_capabilities") if isinstance(protocol, dict) else None
6230
+ supported = capabilities.get("activity_lease_renewal") if isinstance(capabilities, dict) else None
6231
+ if supported is False:
6232
+ raise RuntimeCapabilityUnsupported(operation, path, f"This runtime does not support {path}.")
6233
+ if supported is not True:
6234
+ raise RuntimeDiscoveryUnavailable(operation, path, f"Runtime discovery must advertise {path}=true.")
6235
+
6236
+ return await self._request(
6237
+ "POST", f"/worker/activity-tasks/{quote(task_id, safe='')}/renew-lease", worker=True,
6238
+ json={"activity_attempt_id": activity_attempt_id, "lease_owner": lease_owner}, timeout=timeout,
6239
+ )
6240
+
6241
+ return await asyncio.wait_for(renew(), timeout=timeout)
6242
+
6163
6243
  async def activity_task_status(
6164
6244
  self,
6165
6245
  *,
@@ -6305,7 +6385,8 @@ class Client:
6305
6385
  Worker-plane endpoint. Most code calls
6306
6386
  :meth:`~durable_workflow.ActivityContext.heartbeat` instead, which
6307
6387
  additionally raises :class:`~durable_workflow.errors.ActivityCancelled`
6308
- when the server reports the activity should stop.
6388
+ for cancellation or :class:`~durable_workflow.errors.ActivityLeaseAuthorityLost`
6389
+ with the original reason when execution cannot continue.
6309
6390
  """
6310
6391
  body: dict[str, Any] = {
6311
6392
  "activity_attempt_id": activity_attempt_id,
@@ -36,6 +36,15 @@ class QueryTaskAuthorityLost(DurableWorkflowError):
36
36
  self.reason = reason
37
37
 
38
38
 
39
+ class ActivityLeaseAuthorityLost(DurableWorkflowError):
40
+ """An activity delivery lost its original live execution authority."""
41
+
42
+ def __init__(self, task_id: str, reason: str) -> None:
43
+ super().__init__(f"activity task {task_id!r} cannot continue: {reason}")
44
+ self.task_id = task_id
45
+ self.reason = reason
46
+
47
+
39
48
  class InvalidWorkflowDefinition(DurableWorkflowError, ValueError):
40
49
  """Workflow authoring uses an unsupported entry point or result."""
41
50