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.
- {durable_workflow-2.7.0/src/durable_workflow.egg-info → durable_workflow-2.8.0}/PKG-INFO +12 -1
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/README.md +11 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/pyproject.toml +3 -3
- durable_workflow-2.8.0/src/durable_workflow/_activity_lease.py +325 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/src/durable_workflow/activity.py +25 -3
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/src/durable_workflow/client.py +84 -3
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/src/durable_workflow/errors.py +9 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/src/durable_workflow/worker.py +119 -122
- {durable_workflow-2.7.0 → durable_workflow-2.8.0/src/durable_workflow.egg-info}/PKG-INFO +12 -1
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/src/durable_workflow.egg-info/SOURCES.txt +5 -0
- durable_workflow-2.8.0/tests/test_activity_heartbeat_authority.py +155 -0
- durable_workflow-2.8.0/tests/test_activity_lease_authority.py +176 -0
- durable_workflow-2.8.0/tests/test_activity_lease_renewal.py +131 -0
- durable_workflow-2.8.0/tests/test_activity_lease_worker.py +323 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/LICENSE +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/setup.cfg +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/src/durable_workflow/__init__.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/src/durable_workflow/_activity_process.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/src/durable_workflow/_avro.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/src/durable_workflow/_cancellation_scope.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/src/durable_workflow/_cancellation_scope_history.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/src/durable_workflow/_cooperative_cancellation.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/src/durable_workflow/_prepared_local_activity.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/src/durable_workflow/_query_task_authority.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/src/durable_workflow/_sticky_workflow_cache.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/src/durable_workflow/auth_composition.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/src/durable_workflow/cancellation.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/src/durable_workflow/external_storage.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/src/durable_workflow/external_task_input.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/src/durable_workflow/external_task_result.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/src/durable_workflow/history_bundle_verify.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/src/durable_workflow/interceptors.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/src/durable_workflow/invocable.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/src/durable_workflow/metrics.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/src/durable_workflow/nexus.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/src/durable_workflow/py.typed +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/src/durable_workflow/python_conformance.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/src/durable_workflow/replay_conformance.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/src/durable_workflow/replay_verify.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/src/durable_workflow/retry_policy.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/src/durable_workflow/serializer.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/src/durable_workflow/sync.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/src/durable_workflow/telemetry.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/src/durable_workflow/telemetry_carrier.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/src/durable_workflow/testing.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/src/durable_workflow/worker_session.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/src/durable_workflow/workflow.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/src/durable_workflow/workflow_updates_conformance.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/src/durable_workflow.egg-info/dependency_links.txt +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/src/durable_workflow.egg-info/entry_points.txt +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/src/durable_workflow.egg-info/requires.txt +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/src/durable_workflow.egg-info/top_level.txt +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_activity_cancellation_policies.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_activity_context.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_activity_process.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_auth_composition.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_avro_value_benchmark.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_avro_value_protocol.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_cancellation_context.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_cancellation_remaining_time.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_cancellation_scope_admission.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_cancellation_scope_authoring.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_cancellation_scope_boundary.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_cancellation_scope_opening.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_child_workflow_policies.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_ci_checkout.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_ci_integration_endpoint.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_client.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_client_telemetry.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_client_timeouts.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_codec_regression_corpus.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_committed_cancellation_scope_descendants.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_committed_cancellation_scope_groups.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_committed_cancellation_scope_history.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_committed_cancellation_scope_replay.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_condition_replay_drift.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_control_plane_parity_fixtures.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_cooperative_cancellation.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_cooperative_cancellation_client.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_cooperative_cancellation_worker.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_cooperative_remote_worker.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_docstring_cross_references.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_durable_selection.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_errors.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_external_storage.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_external_task_input.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_external_task_result.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_golden_history_replay.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_history_bundle_verify.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_history_event_contract.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_invocable.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_message_streams.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_metrics.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_nexus_durable_wait.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_nexus_namespace.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_nexus_wire.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_order_processing_example.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_parallel_completion_order_replay.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_parallel_saga_authoring.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_patch_history.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_pending_operation_replay.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_prepared_local_activity_client.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_prepared_local_activity_groups.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_prepared_local_activity_policies.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_prepared_local_activity_worker.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_public_boundary_scanner.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_pypi_project_surface.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_python_conformance.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_queries.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_query_task_authority.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_query_timeout_retry.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_readme_quickstart.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_redrive_replay.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_registration_ambiguity.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_release_metadata.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_replay.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_replay_clock.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_replay_conformance.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_replay_regression_corpus.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_replay_verify.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_retry_policy.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_run_cancellation_scope_replay.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_runtime_external_payload_transport.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_schedules.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_scoped_run_cancellation_context.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_serializer.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_signals.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_sleep.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_standalone_activity_client.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_sticky_execution.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_storage_admission.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_sync.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_sync_activities.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_telemetry.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_telemetry_carrier.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_testing_harness.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_update_signal_condition_replay.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_updates.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_wait_condition.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_worker.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_worker_observation.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_worker_session.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_worker_telemetry_wire.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_workflow_entry_arguments.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_workflow_entrypoint_validation.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_workflow_handler_validation.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_workflow_result_timeout.py +0 -0
- {durable_workflow-2.7.0 → durable_workflow-2.8.0}/tests/test_workflow_streams.py +0 -0
- {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.
|
|
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
|
+
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.
|
|
79
|
-
registry-version = "2.
|
|
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 .
|
|
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
|
|
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
|
|
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=
|
|
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
|
|
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
|
-
|
|
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
|
|