durable-workflow 2.5.13__tar.gz → 2.5.15__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.5.13/src/durable_workflow.egg-info → durable_workflow-2.5.15}/PKG-INFO +1 -1
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/pyproject.toml +4 -4
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/src/durable_workflow/__init__.py +2 -0
- durable_workflow-2.5.15/src/durable_workflow/_query_task_authority.py +153 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/src/durable_workflow/client.py +77 -9
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/src/durable_workflow/errors.py +9 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/src/durable_workflow/worker.py +65 -7
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/src/durable_workflow/workflow.py +43 -18
- {durable_workflow-2.5.13 → durable_workflow-2.5.15/src/durable_workflow.egg-info}/PKG-INFO +1 -1
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/src/durable_workflow.egg-info/SOURCES.txt +2 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/tests/test_client.py +17 -8
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/tests/test_condition_replay_drift.py +4 -2
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/tests/test_external_storage.py +8 -4
- durable_workflow-2.5.15/tests/test_query_task_authority.py +305 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/tests/test_replay_regression_corpus.py +36 -1
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/tests/test_storage_admission.py +16 -6
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/tests/test_worker.py +26 -25
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/LICENSE +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/README.md +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/setup.cfg +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/src/durable_workflow/_activity_process.py +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/src/durable_workflow/_avro.py +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/src/durable_workflow/_cancellation_scope.py +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/src/durable_workflow/_cancellation_scope_history.py +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/src/durable_workflow/_cooperative_cancellation.py +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/src/durable_workflow/_prepared_local_activity.py +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/src/durable_workflow/_sticky_workflow_cache.py +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/src/durable_workflow/activity.py +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/src/durable_workflow/auth_composition.py +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/src/durable_workflow/cancellation.py +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/src/durable_workflow/external_storage.py +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/src/durable_workflow/external_task_input.py +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/src/durable_workflow/external_task_result.py +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/src/durable_workflow/history_bundle_verify.py +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/src/durable_workflow/interceptors.py +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/src/durable_workflow/invocable.py +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/src/durable_workflow/metrics.py +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/src/durable_workflow/nexus.py +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/src/durable_workflow/py.typed +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/src/durable_workflow/python_conformance.py +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/src/durable_workflow/replay_conformance.py +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/src/durable_workflow/replay_verify.py +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/src/durable_workflow/retry_policy.py +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/src/durable_workflow/serializer.py +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/src/durable_workflow/sync.py +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/src/durable_workflow/testing.py +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/src/durable_workflow/worker_session.py +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/src/durable_workflow/workflow_updates_conformance.py +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/src/durable_workflow.egg-info/dependency_links.txt +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/src/durable_workflow.egg-info/entry_points.txt +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/src/durable_workflow.egg-info/requires.txt +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/src/durable_workflow.egg-info/top_level.txt +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/tests/test_activity_cancellation_policies.py +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/tests/test_activity_context.py +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/tests/test_activity_process.py +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/tests/test_auth_composition.py +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/tests/test_avro_value_benchmark.py +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/tests/test_avro_value_protocol.py +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/tests/test_cancellation_context.py +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/tests/test_cancellation_remaining_time.py +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/tests/test_cancellation_scope_admission.py +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/tests/test_cancellation_scope_authoring.py +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/tests/test_cancellation_scope_boundary.py +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/tests/test_cancellation_scope_opening.py +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/tests/test_child_workflow_policies.py +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/tests/test_ci_checkout.py +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/tests/test_ci_integration_endpoint.py +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/tests/test_client_timeouts.py +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/tests/test_codec_regression_corpus.py +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/tests/test_committed_cancellation_scope_descendants.py +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/tests/test_committed_cancellation_scope_groups.py +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/tests/test_committed_cancellation_scope_history.py +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/tests/test_committed_cancellation_scope_replay.py +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/tests/test_control_plane_parity_fixtures.py +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/tests/test_cooperative_cancellation.py +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/tests/test_cooperative_cancellation_client.py +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/tests/test_cooperative_cancellation_worker.py +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/tests/test_cooperative_remote_worker.py +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/tests/test_docstring_cross_references.py +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/tests/test_durable_selection.py +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/tests/test_errors.py +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/tests/test_external_task_input.py +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/tests/test_external_task_result.py +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/tests/test_golden_history_replay.py +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/tests/test_history_bundle_verify.py +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/tests/test_history_event_contract.py +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/tests/test_invocable.py +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/tests/test_message_streams.py +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/tests/test_metrics.py +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/tests/test_nexus_namespace.py +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/tests/test_nexus_wire.py +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/tests/test_order_processing_example.py +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/tests/test_parallel_completion_order_replay.py +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/tests/test_parallel_saga_authoring.py +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/tests/test_patch_history.py +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/tests/test_pending_operation_replay.py +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/tests/test_prepared_local_activity_client.py +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/tests/test_prepared_local_activity_groups.py +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/tests/test_prepared_local_activity_policies.py +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/tests/test_prepared_local_activity_worker.py +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/tests/test_public_boundary_scanner.py +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/tests/test_pypi_project_surface.py +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/tests/test_python_conformance.py +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/tests/test_queries.py +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/tests/test_query_timeout_retry.py +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/tests/test_readme_quickstart.py +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/tests/test_redrive_replay.py +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/tests/test_registration_ambiguity.py +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/tests/test_release_metadata.py +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/tests/test_replay.py +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/tests/test_replay_clock.py +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/tests/test_replay_conformance.py +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/tests/test_replay_verify.py +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/tests/test_retry_policy.py +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/tests/test_run_cancellation_scope_replay.py +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/tests/test_runtime_external_payload_transport.py +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/tests/test_schedules.py +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/tests/test_scoped_run_cancellation_context.py +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/tests/test_serializer.py +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/tests/test_signals.py +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/tests/test_sleep.py +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/tests/test_standalone_activity_client.py +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/tests/test_sticky_execution.py +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/tests/test_sync.py +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/tests/test_testing_harness.py +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/tests/test_update_signal_condition_replay.py +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/tests/test_updates.py +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/tests/test_wait_condition.py +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/tests/test_worker_session.py +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/tests/test_workflow_entrypoint_validation.py +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/tests/test_workflow_result_timeout.py +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/tests/test_workflow_streams.py +0 -0
- {durable_workflow-2.5.13 → durable_workflow-2.5.15}/tests/test_workflow_updates_conformance.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "durable-workflow"
|
|
7
|
-
version = "2.5.
|
|
7
|
+
version = "2.5.15"
|
|
8
8
|
description = "Python client and worker SDK for Durable Workflow Cloud and self-hosted Server"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -71,9 +71,9 @@ durable-workflow-replay-conformance = "durable_workflow.replay_conformance:main"
|
|
|
71
71
|
durable-workflow-workflow-updates-conformance = "durable_workflow.workflow_updates_conformance:main"
|
|
72
72
|
|
|
73
73
|
[tool.durable-workflow]
|
|
74
|
-
product-train = "2.5.
|
|
75
|
-
registry-version = "2.5.
|
|
76
|
-
supported-server-versions = "2.
|
|
74
|
+
product-train = "2.5.15"
|
|
75
|
+
registry-version = "2.5.15"
|
|
76
|
+
supported-server-versions = "2.8.0"
|
|
77
77
|
worker-protocol-version = "1.20"
|
|
78
78
|
control-plane-version = "2"
|
|
79
79
|
durable-selection = true
|
|
@@ -88,6 +88,7 @@ from .errors import (
|
|
|
88
88
|
NonDeterministicReplayError,
|
|
89
89
|
NonRetryableError,
|
|
90
90
|
QueryFailed,
|
|
91
|
+
QueryTaskAuthorityLost,
|
|
91
92
|
RuntimeCapabilityUnsupported,
|
|
92
93
|
RuntimeDiscoveryUnavailable,
|
|
93
94
|
SagaCompensationFailed,
|
|
@@ -375,6 +376,7 @@ __all__ = [
|
|
|
375
376
|
"InvalidWorkflowDefinition",
|
|
376
377
|
"NoopMetrics",
|
|
377
378
|
"QueryFailed",
|
|
379
|
+
"QueryTaskAuthorityLost",
|
|
378
380
|
"RuntimeCapabilityUnsupported",
|
|
379
381
|
"RuntimeDiscoveryUnavailable",
|
|
380
382
|
"RuntimeExternalPayloadReference",
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""A query delivery's original, non-renewing execution budget."""
|
|
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, timezone
|
|
12
|
+
from typing import Any, TypeVar
|
|
13
|
+
|
|
14
|
+
from .errors import QueryTaskAuthorityLost
|
|
15
|
+
|
|
16
|
+
_T = TypeVar("_T")
|
|
17
|
+
_UTC6 = re.compile(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{6}Z\Z")
|
|
18
|
+
_OBSERVATION_INTERVAL_SECONDS = 5.0
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _timestamp(value: Any, *, canonical: bool) -> datetime:
|
|
22
|
+
if not isinstance(value, str) or (canonical and _UTC6.fullmatch(value) is None):
|
|
23
|
+
raise ValueError("missing or malformed query timing")
|
|
24
|
+
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
25
|
+
if parsed.tzinfo is None:
|
|
26
|
+
raise ValueError("query timing must include a timezone")
|
|
27
|
+
return parsed
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class QueryTaskAuthority:
|
|
32
|
+
client: object
|
|
33
|
+
namespace: str
|
|
34
|
+
task_id: str
|
|
35
|
+
attempt: int
|
|
36
|
+
owner: str
|
|
37
|
+
observed: bool
|
|
38
|
+
timing: dict[str, str]
|
|
39
|
+
until: float
|
|
40
|
+
identity: dict[str, Any]
|
|
41
|
+
observer: Callable[[], Awaitable[Mapping[str, Any]]] | None = field(default=None, repr=False)
|
|
42
|
+
|
|
43
|
+
@classmethod
|
|
44
|
+
def delivery(cls, client: object, namespace: str, task: Mapping[str, Any], *,
|
|
45
|
+
owner: str, task_queue: str, observed: bool) -> QueryTaskAuthority:
|
|
46
|
+
task_id, attempt = task.get("query_task_id"), task.get("query_task_attempt")
|
|
47
|
+
if (not isinstance(owner, str) or not owner or not isinstance(task_queue, str) or not task_queue
|
|
48
|
+
or not isinstance(task_id, str) or not task_id or type(attempt) is not int or attempt < 1
|
|
49
|
+
or task.get("lease_owner") != owner or task.get("task_queue") != task_queue
|
|
50
|
+
or task.get("namespace", namespace) != namespace):
|
|
51
|
+
raise QueryTaskAuthorityLost(str(task_id or ""), "invalid_query_claim")
|
|
52
|
+
try:
|
|
53
|
+
lease = _timestamp(task.get("lease_expires_at"), canonical=observed)
|
|
54
|
+
timing = {"lease_expires_at": task["lease_expires_at"]}
|
|
55
|
+
bounds = [lease]
|
|
56
|
+
if observed or "query_admitted_at" in task or "query_deadline_at" in task:
|
|
57
|
+
admitted = _timestamp(task.get("query_admitted_at"), canonical=True)
|
|
58
|
+
deadline = _timestamp(task.get("query_deadline_at"), canonical=True)
|
|
59
|
+
if deadline < admitted or (observed and lease > deadline):
|
|
60
|
+
raise ValueError("inconsistent original query timing")
|
|
61
|
+
timing.update({"query_admitted_at": task["query_admitted_at"],
|
|
62
|
+
"query_deadline_at": task["query_deadline_at"]})
|
|
63
|
+
bounds.append(deadline)
|
|
64
|
+
# Legacy compatibility has no non-renewing observation. Its local
|
|
65
|
+
# clock check cannot establish an undisclosed caller deadline.
|
|
66
|
+
reference = (_timestamp(task.get("server_time"), canonical=True) if observed
|
|
67
|
+
else datetime.now(timezone.utc))
|
|
68
|
+
until = time.monotonic() + (min(bounds) - reference).total_seconds()
|
|
69
|
+
except (ValueError, TypeError, OverflowError) as error:
|
|
70
|
+
raise QueryTaskAuthorityLost(task_id, "invalid_query_timing") from error
|
|
71
|
+
result = cls(client, namespace, task_id, attempt, owner, observed, timing, until,
|
|
72
|
+
{key: task.get(key) for key in ("workflow_id", "run_id", "task_queue")})
|
|
73
|
+
result.remaining()
|
|
74
|
+
return result
|
|
75
|
+
|
|
76
|
+
def matches(self, client: object, namespace: str, task: Mapping[str, Any], owner: str) -> bool:
|
|
77
|
+
return (self.client is client and self.namespace == namespace and self.owner == owner
|
|
78
|
+
and task.get("query_task_id") == self.task_id
|
|
79
|
+
and type(task.get("query_task_attempt")) is int and task.get("query_task_attempt") == self.attempt
|
|
80
|
+
and task.get("lease_owner") == owner
|
|
81
|
+
and all(task.get(key) == value for key, value in self.identity.items())
|
|
82
|
+
and all(task.get(key) == value for key, value in self.timing.items()))
|
|
83
|
+
|
|
84
|
+
def remaining(self) -> float:
|
|
85
|
+
remaining = self.until - time.monotonic()
|
|
86
|
+
if remaining <= 0:
|
|
87
|
+
raise QueryTaskAuthorityLost(self.task_id, "query_execution_budget_expired")
|
|
88
|
+
return remaining
|
|
89
|
+
|
|
90
|
+
async def confirm(self) -> None:
|
|
91
|
+
remaining = self.remaining()
|
|
92
|
+
if not self.observed:
|
|
93
|
+
return
|
|
94
|
+
started = time.monotonic()
|
|
95
|
+
try:
|
|
96
|
+
if self.observer is None:
|
|
97
|
+
raise ValueError("query authority observer is unavailable")
|
|
98
|
+
reply = await asyncio.wait_for(self.observer(), timeout=min(5.0, remaining))
|
|
99
|
+
if (not isinstance(reply, Mapping) or reply.get("query_task_id") != self.task_id
|
|
100
|
+
or type(reply.get("query_task_attempt")) is not int
|
|
101
|
+
or reply.get("query_task_attempt") != self.attempt or reply.get("lease_owner") != self.owner
|
|
102
|
+
or any(reply.get(key) != value for key, value in self.timing.items())):
|
|
103
|
+
raise ValueError("query authority observation changed its original claim")
|
|
104
|
+
if (reply.get("can_continue") is not True or reply.get("query_task_status") != "leased"
|
|
105
|
+
or reply.get("reason") is not None):
|
|
106
|
+
raise QueryTaskAuthorityLost(self.task_id, str(reply.get("reason") or "query_claim_unavailable"))
|
|
107
|
+
server_time = _timestamp(reply.get("server_time"), canonical=True)
|
|
108
|
+
bound = min(_timestamp(value, canonical=True) for key, value in self.timing.items()
|
|
109
|
+
if key != "query_admitted_at")
|
|
110
|
+
# Charging the entire observation round trip is conservative. A
|
|
111
|
+
# later observation may shorten this bound but can never reset it.
|
|
112
|
+
self.until = min(self.until, started + (bound - server_time).total_seconds())
|
|
113
|
+
except QueryTaskAuthorityLost:
|
|
114
|
+
raise
|
|
115
|
+
except Exception as error:
|
|
116
|
+
raise QueryTaskAuthorityLost(self.task_id, "query_authority_unavailable") from error
|
|
117
|
+
self.remaining()
|
|
118
|
+
|
|
119
|
+
async def run(self, operation: Callable[[], Awaitable[_T]], *, observe: bool = True,
|
|
120
|
+
confirm_after: bool = True) -> _T:
|
|
121
|
+
await self.confirm()
|
|
122
|
+
execution = asyncio.ensure_future(operation())
|
|
123
|
+
|
|
124
|
+
async def watch() -> None:
|
|
125
|
+
while True:
|
|
126
|
+
await asyncio.sleep(min(_OBSERVATION_INTERVAL_SECONDS, self.remaining()))
|
|
127
|
+
await self.confirm()
|
|
128
|
+
|
|
129
|
+
observation = asyncio.create_task(watch()) if observe and self.observed else None
|
|
130
|
+
pending: set[asyncio.Future[Any]] = {execution}
|
|
131
|
+
if observation is not None:
|
|
132
|
+
pending.add(observation)
|
|
133
|
+
try:
|
|
134
|
+
done, _ = await asyncio.wait(pending, timeout=self.remaining(), return_when=asyncio.FIRST_COMPLETED)
|
|
135
|
+
if not done:
|
|
136
|
+
raise QueryTaskAuthorityLost(self.task_id, "query_execution_budget_expired")
|
|
137
|
+
if observation is not None and observation in done:
|
|
138
|
+
await observation
|
|
139
|
+
raise QueryTaskAuthorityLost(self.task_id, "query_authority_unavailable")
|
|
140
|
+
result = await execution
|
|
141
|
+
if confirm_after:
|
|
142
|
+
await self.confirm()
|
|
143
|
+
return result
|
|
144
|
+
finally:
|
|
145
|
+
for future in pending:
|
|
146
|
+
future.cancel()
|
|
147
|
+
await asyncio.gather(*pending, return_exceptions=True)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# Async task creation copies this context. Each dispatched query keeps the
|
|
151
|
+
# loading budget without adding private fields to the public wire dictionary
|
|
152
|
+
# or retaining an unbounded task-ID registry on Client/Worker.
|
|
153
|
+
current_query_authority: ContextVar[QueryTaskAuthority | None] = ContextVar("query_task_authority", default=None)
|
|
@@ -45,6 +45,7 @@ from ._cancellation_scope_history import (
|
|
|
45
45
|
scope_timestamp,
|
|
46
46
|
)
|
|
47
47
|
from ._cooperative_cancellation import CancellationDelivery, CancellationRequest
|
|
48
|
+
from ._query_task_authority import QueryTaskAuthority, current_query_authority
|
|
48
49
|
from .errors import (
|
|
49
50
|
ExternalPayloadIntegrityMismatch,
|
|
50
51
|
ExternalPayloadOversized,
|
|
@@ -1809,6 +1810,9 @@ class Client:
|
|
|
1809
1810
|
if discovery and path != "/cluster/info":
|
|
1810
1811
|
raise ValueError("cluster discovery credentials can only authorize /cluster/info")
|
|
1811
1812
|
|
|
1813
|
+
query_poll = worker and path == "/worker/query-tasks/poll"
|
|
1814
|
+
if query_poll:
|
|
1815
|
+
current_query_authority.set(None)
|
|
1812
1816
|
request_json = json
|
|
1813
1817
|
if (
|
|
1814
1818
|
not discovery
|
|
@@ -1872,21 +1876,29 @@ class Client:
|
|
|
1872
1876
|
outcome = "ok"
|
|
1873
1877
|
return None
|
|
1874
1878
|
result = resp.json()
|
|
1879
|
+
query_authority = None
|
|
1880
|
+
if query_poll and isinstance(result, dict) and isinstance(result.get("task"), dict):
|
|
1881
|
+
query_authority = await self._query_delivery_authority(result["task"], request_json)
|
|
1882
|
+
current_query_authority.set(query_authority)
|
|
1875
1883
|
if not discovery and _contains_runtime_payload_envelope(result):
|
|
1876
|
-
transport = await self._runtime_external_payload_transport
|
|
1884
|
+
transport = (await query_authority.run(self._runtime_external_payload_transport)
|
|
1885
|
+
if query_authority is not None else await self._runtime_external_payload_transport())
|
|
1877
1886
|
if transport is None:
|
|
1878
1887
|
raise ExternalPayloadUnsupported(
|
|
1879
1888
|
"runtime returned an external payload reference without advertising "
|
|
1880
1889
|
"the authenticated namespace transport"
|
|
1881
1890
|
)
|
|
1882
|
-
|
|
1883
|
-
result
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1891
|
+
if query_authority is not None:
|
|
1892
|
+
result = await query_authority.run(lambda: self._resolve_runtime_payloads(
|
|
1893
|
+
result, worker=worker, transport=transport, resolved={},
|
|
1894
|
+
))
|
|
1895
|
+
else:
|
|
1896
|
+
result = await self._resolve_workflow_claim_payloads(
|
|
1897
|
+
result, worker=worker, transport=transport, path=path, request_json=request_json,
|
|
1898
|
+
)
|
|
1889
1899
|
result = self._hydrate_result_envelopes(result)
|
|
1900
|
+
if query_authority is not None:
|
|
1901
|
+
await query_authority.confirm()
|
|
1890
1902
|
outcome = "ok"
|
|
1891
1903
|
return result
|
|
1892
1904
|
except Exception as exc:
|
|
@@ -2332,6 +2344,7 @@ class Client:
|
|
|
2332
2344
|
*,
|
|
2333
2345
|
worker: bool,
|
|
2334
2346
|
transport: _RuntimeExternalPayloadTransport,
|
|
2347
|
+
resolved: dict[tuple[str, str, int, str], str] | None = None,
|
|
2335
2348
|
) -> Any:
|
|
2336
2349
|
if isinstance(value, dict):
|
|
2337
2350
|
if "external_payload" in value:
|
|
@@ -2347,6 +2360,9 @@ class Client:
|
|
|
2347
2360
|
"runtime external payload envelope codec does not match its reference",
|
|
2348
2361
|
reference_id=reference.reference_id,
|
|
2349
2362
|
)
|
|
2363
|
+
identity = (reference.reference_id, reference.codec, reference.size_bytes, reference.sha256)
|
|
2364
|
+
if resolved is not None and identity in resolved:
|
|
2365
|
+
return {"codec": reference.codec, "blob": resolved[identity]}
|
|
2350
2366
|
data = await self._fetch_runtime_payload(
|
|
2351
2367
|
reference,
|
|
2352
2368
|
worker=worker,
|
|
@@ -2359,12 +2375,15 @@ class Client:
|
|
|
2359
2375
|
"runtime external payload bytes are not a UTF-8 payload blob",
|
|
2360
2376
|
reference_id=reference.reference_id,
|
|
2361
2377
|
) from exc
|
|
2378
|
+
if resolved is not None:
|
|
2379
|
+
resolved[identity] = blob
|
|
2362
2380
|
return {"codec": reference.codec, "blob": blob}
|
|
2363
2381
|
return {
|
|
2364
2382
|
key: await self._resolve_runtime_payloads(
|
|
2365
2383
|
item,
|
|
2366
2384
|
worker=worker,
|
|
2367
2385
|
transport=transport,
|
|
2386
|
+
resolved=resolved,
|
|
2368
2387
|
)
|
|
2369
2388
|
for key, item in value.items()
|
|
2370
2389
|
}
|
|
@@ -2374,6 +2393,7 @@ class Client:
|
|
|
2374
2393
|
item,
|
|
2375
2394
|
worker=worker,
|
|
2376
2395
|
transport=transport,
|
|
2396
|
+
resolved=resolved,
|
|
2377
2397
|
)
|
|
2378
2398
|
for item in value
|
|
2379
2399
|
]
|
|
@@ -4825,8 +4845,10 @@ class Client:
|
|
|
4825
4845
|
end_time: str,
|
|
4826
4846
|
overlap_policy: str | None = None,
|
|
4827
4847
|
) -> ScheduleBackfillResult:
|
|
4828
|
-
"""Fire a schedule for every would-have-been moment in ``[start_time, end_time
|
|
4848
|
+
"""Fire a schedule for every would-have-been moment in ``[start_time, end_time)``.
|
|
4829
4849
|
|
|
4850
|
+
The start is inclusive and the end is exclusive. An occurrence equal
|
|
4851
|
+
to ``end_time`` is excluded and belongs to the next adjacent window.
|
|
4830
4852
|
Times are ISO-8601 strings. Useful to replay a period the schedule
|
|
4831
4853
|
was paused or to seed historical runs. The returned
|
|
4832
4854
|
:class:`ScheduleBackfillResult` reports how many fires were attempted
|
|
@@ -5625,6 +5647,48 @@ class Client:
|
|
|
5625
5647
|
"POST", f"/worker/workflow-tasks/{task_id}/history", worker=True, json=body
|
|
5626
5648
|
)
|
|
5627
5649
|
|
|
5650
|
+
async def _query_delivery_authority(self, task: dict[str, Any], request: Any) -> QueryTaskAuthority:
|
|
5651
|
+
# Worker registration establishes discovery before it polls. Advanced
|
|
5652
|
+
# callers without discovery retain only the exposed local lease checks.
|
|
5653
|
+
info = self._cluster_info or {}
|
|
5654
|
+
protocol = info.get("worker_protocol")
|
|
5655
|
+
capabilities = protocol.get("server_capabilities") if isinstance(protocol, dict) else None
|
|
5656
|
+
observed = (isinstance(capabilities, dict) and capabilities.get("query_task_authority") is True
|
|
5657
|
+
and _supports_cooperative_cancellation_protocol(_protocol_version_from_env(
|
|
5658
|
+
"DURABLE_WORKFLOW_WORKER_PROTOCOL_VERSION", PROTOCOL_VERSION)))
|
|
5659
|
+
request = request if isinstance(request, dict) else {}
|
|
5660
|
+
authority = QueryTaskAuthority.delivery(self, self.namespace, task, owner=request.get("worker_id", ""),
|
|
5661
|
+
task_queue=request.get("task_queue", ""), observed=observed)
|
|
5662
|
+
if observed:
|
|
5663
|
+
authority.observer = lambda: self.query_task_status(query_task_id=authority.task_id,
|
|
5664
|
+
lease_owner=authority.owner, query_task_attempt=authority.attempt)
|
|
5665
|
+
await authority.confirm()
|
|
5666
|
+
return authority
|
|
5667
|
+
|
|
5668
|
+
async def query_task_status(self, *, query_task_id: str, lease_owner: str,
|
|
5669
|
+
query_task_attempt: int) -> Any:
|
|
5670
|
+
"""Observe a protocol 1.20 query claim without renewing it.
|
|
5671
|
+
|
|
5672
|
+
Requires ``query_task_authority=true`` discovery. A positive observation
|
|
5673
|
+
does not reserve authority for later execution or completion.
|
|
5674
|
+
"""
|
|
5675
|
+
if not _supports_cooperative_cancellation_protocol(_protocol_version_from_env(
|
|
5676
|
+
"DURABLE_WORKFLOW_WORKER_PROTOCOL_VERSION", PROTOCOL_VERSION,
|
|
5677
|
+
)):
|
|
5678
|
+
raise ValueError("query task observation requires worker protocol 1.20")
|
|
5679
|
+
capability_path = "worker_protocol.server_capabilities.query_task_authority"
|
|
5680
|
+
info = await self._runtime_discovery(operation="Client.query_task_status", required_path=capability_path)
|
|
5681
|
+
protocol = info.get("worker_protocol")
|
|
5682
|
+
capabilities = protocol.get("server_capabilities") if isinstance(protocol, dict) else None
|
|
5683
|
+
supported = capabilities.get("query_task_authority") if isinstance(capabilities, dict) else None
|
|
5684
|
+
if supported is not True:
|
|
5685
|
+
error_type = RuntimeCapabilityUnsupported if supported is False else RuntimeDiscoveryUnavailable
|
|
5686
|
+
raise error_type("Client.query_task_status", capability_path,
|
|
5687
|
+
"Query task observation requires Server query_task_authority=true discovery.")
|
|
5688
|
+
query = urlencode({"lease_owner": lease_owner, "query_task_attempt": query_task_attempt})
|
|
5689
|
+
return await self._request("GET", f"/worker/query-tasks/{quote(query_task_id, safe='')}/status?{query}",
|
|
5690
|
+
worker=True, timeout=5.0)
|
|
5691
|
+
|
|
5628
5692
|
async def poll_query_task(
|
|
5629
5693
|
self,
|
|
5630
5694
|
*,
|
|
@@ -5640,6 +5704,10 @@ class Client:
|
|
|
5640
5704
|
must route a control-plane query to a non-PHP workflow runtime.
|
|
5641
5705
|
``timeout`` controls the server long-poll window; the HTTP request
|
|
5642
5706
|
uses a small grace margin.
|
|
5707
|
+
|
|
5708
|
+
Worker registration establishes capability discovery. Custom workers
|
|
5709
|
+
should call ``get_cluster_info()`` first to enable advertised query
|
|
5710
|
+
status checks; without discovery only supplied local lease bounds apply.
|
|
5643
5711
|
"""
|
|
5644
5712
|
body: dict[str, Any] = {
|
|
5645
5713
|
"worker_id": worker_id,
|
|
@@ -27,6 +27,15 @@ class DurableWorkflowError(Exception):
|
|
|
27
27
|
"""Base class for every exception raised by the SDK."""
|
|
28
28
|
|
|
29
29
|
|
|
30
|
+
class QueryTaskAuthorityLost(DurableWorkflowError):
|
|
31
|
+
"""A query delivery cannot safely load or invoke its handler."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, query_task_id: str, reason: str) -> None:
|
|
34
|
+
super().__init__(f"query task {query_task_id!r} cannot continue: {reason}")
|
|
35
|
+
self.query_task_id = query_task_id
|
|
36
|
+
self.reason = reason
|
|
37
|
+
|
|
38
|
+
|
|
30
39
|
class InvalidWorkflowDefinition(DurableWorkflowError, ValueError):
|
|
31
40
|
"""Workflow authoring uses an unsupported entry point or result."""
|
|
32
41
|
|
|
@@ -41,6 +41,7 @@ from ._activity_process import CallbackFailure, CallbackInvocation, CallbackProc
|
|
|
41
41
|
from ._cancellation_scope_history import CancellationScopeBudget, CancellationScopeDeliveryReceipt
|
|
42
42
|
from ._cooperative_cancellation import CancellationRequest, read_cancellation_history
|
|
43
43
|
from ._prepared_local_activity import PreparedAttempt, PreparedCancellationObserved, PreparedLocalRunner
|
|
44
|
+
from ._query_task_authority import QueryTaskAuthority, current_query_authority
|
|
44
45
|
from ._sticky_workflow_cache import CacheKey, StickyWorkflowCache, complete_history
|
|
45
46
|
from .activity import ActivityContext, ActivityInfo, _set_context
|
|
46
47
|
from .auth_composition import (
|
|
@@ -74,6 +75,7 @@ from .errors import (
|
|
|
74
75
|
NonDeterministicReplayError,
|
|
75
76
|
NonRetryableError,
|
|
76
77
|
QueryFailed,
|
|
78
|
+
QueryTaskAuthorityLost,
|
|
77
79
|
ServerError,
|
|
78
80
|
UpdateRejected,
|
|
79
81
|
UpdateValidationFailed,
|
|
@@ -107,11 +109,11 @@ from .workflow import (
|
|
|
107
109
|
ScheduleActivity,
|
|
108
110
|
StartChildWorkflow,
|
|
109
111
|
UpsertMemo,
|
|
112
|
+
_prepare_query_state,
|
|
110
113
|
_validate_workflow_entrypoint,
|
|
111
114
|
_workflow_handler_registries,
|
|
112
115
|
apply_update,
|
|
113
116
|
commands_to_server_commands,
|
|
114
|
-
query_state,
|
|
115
117
|
replay,
|
|
116
118
|
validate_update,
|
|
117
119
|
)
|
|
@@ -1035,8 +1037,11 @@ class Worker:
|
|
|
1035
1037
|
external_storage: ExternalStorageDriver | None = None,
|
|
1036
1038
|
external_storage_threshold_bytes: int | None = None,
|
|
1037
1039
|
external_storage_cache: ExternalPayloadCache | None = None,
|
|
1040
|
+
require_query_task_authority: bool = False,
|
|
1038
1041
|
) -> None:
|
|
1039
1042
|
self.client = client
|
|
1043
|
+
self.require_query_task_authority = require_query_task_authority
|
|
1044
|
+
self._query_authority_supported = False
|
|
1040
1045
|
self.task_queue = task_queue
|
|
1041
1046
|
self.workflows: dict[str, type] = {}
|
|
1042
1047
|
for definition in workflows:
|
|
@@ -1300,6 +1305,14 @@ class Worker:
|
|
|
1300
1305
|
)
|
|
1301
1306
|
self._validate_cooperative_activity_handlers()
|
|
1302
1307
|
self._query_tasks_supported = _server_supports_query_tasks(info)
|
|
1308
|
+
self._query_authority_supported = (
|
|
1309
|
+
isinstance(server_capabilities, Mapping) and server_capabilities.get("query_task_authority") is True
|
|
1310
|
+
and _supports_cooperative_cancellation_protocol(_protocol_version_from_env(
|
|
1311
|
+
"DURABLE_WORKFLOW_WORKER_PROTOCOL_VERSION", PROTOCOL_VERSION))
|
|
1312
|
+
)
|
|
1313
|
+
if self.require_query_task_authority and not self._query_authority_supported:
|
|
1314
|
+
raise RuntimeError("query_task_authority_unavailable: require worker protocol 1.20 and "
|
|
1315
|
+
"Server query_task_authority=true before registration")
|
|
1303
1316
|
self._workflow_memo_updates_supported = _server_supports_workflow_memo_updates(info)
|
|
1304
1317
|
has_update_validators = any(
|
|
1305
1318
|
contract["update_validators"] for contract in self.workflow_command_contracts.values()
|
|
@@ -3298,6 +3311,30 @@ class Worker:
|
|
|
3298
3311
|
return await handler(context)
|
|
3299
3312
|
|
|
3300
3313
|
async def _run_query_task(self, task: dict[str, Any], *, client: Client | None = None) -> str:
|
|
3314
|
+
client = client or self.client
|
|
3315
|
+
token = None
|
|
3316
|
+
try:
|
|
3317
|
+
namespace = getattr(client, "namespace", "default")
|
|
3318
|
+
authority = current_query_authority.get()
|
|
3319
|
+
if authority is None:
|
|
3320
|
+
authority = QueryTaskAuthority.delivery(client, namespace, task, owner=self.worker_id,
|
|
3321
|
+
task_queue=self.task_queue, observed=self._query_authority_supported)
|
|
3322
|
+
if authority.observed:
|
|
3323
|
+
authority.observer = lambda: client.query_task_status(query_task_id=authority.task_id,
|
|
3324
|
+
lease_owner=authority.owner, query_task_attempt=authority.attempt)
|
|
3325
|
+
token = current_query_authority.set(authority)
|
|
3326
|
+
if not authority.matches(client, namespace, task, self.worker_id):
|
|
3327
|
+
raise QueryTaskAuthorityLost(str(task.get("query_task_id", "")), "invalid_query_claim")
|
|
3328
|
+
await authority.confirm()
|
|
3329
|
+
return await self._run_authorized_query_task(task, client=client)
|
|
3330
|
+
except QueryTaskAuthorityLost as error:
|
|
3331
|
+
log.info("query delivery suppressed: %s", error)
|
|
3332
|
+
return "expired"
|
|
3333
|
+
finally:
|
|
3334
|
+
if token is not None:
|
|
3335
|
+
current_query_authority.reset(token)
|
|
3336
|
+
|
|
3337
|
+
async def _run_authorized_query_task(self, task: dict[str, Any], *, client: Client) -> str:
|
|
3301
3338
|
client = client or self.client
|
|
3302
3339
|
codec = task.get("payload_codec")
|
|
3303
3340
|
try:
|
|
@@ -3341,6 +3378,11 @@ class Worker:
|
|
|
3341
3378
|
|
|
3342
3379
|
async def _run_query_task_core(self, task: dict[str, Any], *, client: Client | None = None) -> str:
|
|
3343
3380
|
client = client or self.client
|
|
3381
|
+
authority = current_query_authority.get()
|
|
3382
|
+
if authority is None or not authority.matches(client, getattr(client, "namespace", "default"),
|
|
3383
|
+
task, self.worker_id):
|
|
3384
|
+
raise QueryTaskAuthorityLost(str(task.get("query_task_id", "")), "invalid_query_claim")
|
|
3385
|
+
await authority.confirm()
|
|
3344
3386
|
query_task_id: str = task["query_task_id"]
|
|
3345
3387
|
attempt: int = task.get("query_task_attempt", 1)
|
|
3346
3388
|
wf_type: str = task.get("workflow_type", "")
|
|
@@ -3390,7 +3432,7 @@ class Worker:
|
|
|
3390
3432
|
codec=codec,
|
|
3391
3433
|
context="workflow query arguments",
|
|
3392
3434
|
)
|
|
3393
|
-
|
|
3435
|
+
invoke = _prepare_query_state(
|
|
3394
3436
|
cls,
|
|
3395
3437
|
history,
|
|
3396
3438
|
start_input,
|
|
@@ -3405,8 +3447,14 @@ class Worker:
|
|
|
3405
3447
|
prepare_local_activity_groups=self._prepared_local_activity_groups_supported,
|
|
3406
3448
|
local_activity_cancellation_policies=self._local_activity_cancellation_policies,
|
|
3407
3449
|
)
|
|
3408
|
-
|
|
3409
|
-
|
|
3450
|
+
await authority.confirm()
|
|
3451
|
+
async def execute() -> Any:
|
|
3452
|
+
value = invoke()
|
|
3453
|
+
return await value if inspect.isawaitable(value) else value
|
|
3454
|
+
|
|
3455
|
+
result = await authority.run(execute)
|
|
3456
|
+
except QueryTaskAuthorityLost:
|
|
3457
|
+
raise
|
|
3410
3458
|
except AvroNotInstalledError as e:
|
|
3411
3459
|
await self._fail_query_task(
|
|
3412
3460
|
query_task_id,
|
|
@@ -3452,7 +3500,7 @@ class Worker:
|
|
|
3452
3500
|
return "failed"
|
|
3453
3501
|
|
|
3454
3502
|
try:
|
|
3455
|
-
await client.complete_query_task(
|
|
3503
|
+
await authority.run(lambda: client.complete_query_task(
|
|
3456
3504
|
query_task_id=query_task_id,
|
|
3457
3505
|
lease_owner=self.worker_id,
|
|
3458
3506
|
query_task_attempt=attempt,
|
|
@@ -3462,7 +3510,9 @@ class Worker:
|
|
|
3462
3510
|
run_id=task.get("run_id"),
|
|
3463
3511
|
query_name=query_name,
|
|
3464
3512
|
**self._external_storage_completion_kwargs(),
|
|
3465
|
-
)
|
|
3513
|
+
), observe=False, confirm_after=False)
|
|
3514
|
+
except QueryTaskAuthorityLost:
|
|
3515
|
+
raise
|
|
3466
3516
|
except ServerError as e:
|
|
3467
3517
|
if _is_storage_admission_error(e):
|
|
3468
3518
|
log.warning("query task %s acknowledgement paused: %s", query_task_id, e)
|
|
@@ -3552,6 +3602,12 @@ class Worker:
|
|
|
3552
3602
|
client: Client | None = None,
|
|
3553
3603
|
) -> None:
|
|
3554
3604
|
client = client or self.client
|
|
3605
|
+
authority = current_query_authority.get()
|
|
3606
|
+
if authority is not None:
|
|
3607
|
+
if (authority.client is not client or authority.task_id != query_task_id or authority.attempt != attempt
|
|
3608
|
+
or authority.owner != self.worker_id):
|
|
3609
|
+
raise QueryTaskAuthorityLost(query_task_id, "invalid_query_claim")
|
|
3610
|
+
await authority.confirm()
|
|
3555
3611
|
try:
|
|
3556
3612
|
await client.fail_query_task(
|
|
3557
3613
|
query_task_id=query_task_id,
|
|
@@ -4013,7 +4069,7 @@ class Worker:
|
|
|
4013
4069
|
def _clone_client_for_query_tasks(self) -> Client:
|
|
4014
4070
|
warning_config = self._payload_size_warning_config()
|
|
4015
4071
|
|
|
4016
|
-
|
|
4072
|
+
client = Client(
|
|
4017
4073
|
self.client.base_url,
|
|
4018
4074
|
token=self.client.token,
|
|
4019
4075
|
control_token=self.client.control_token,
|
|
@@ -4035,6 +4091,8 @@ class Worker:
|
|
|
4035
4091
|
external_storage_threshold_bytes=self.external_storage_threshold_bytes,
|
|
4036
4092
|
external_storage_cache=self.external_storage_cache,
|
|
4037
4093
|
)
|
|
4094
|
+
client._cluster_info = self.client._cluster_info
|
|
4095
|
+
return client
|
|
4038
4096
|
|
|
4039
4097
|
def _can_clone_client_for_query_tasks(self) -> bool:
|
|
4040
4098
|
return type(self.client) is Client
|
|
@@ -2991,7 +2991,7 @@ def replay(
|
|
|
2991
2991
|
).outcome
|
|
2992
2992
|
|
|
2993
2993
|
|
|
2994
|
-
def
|
|
2994
|
+
def _prepare_query_state(
|
|
2995
2995
|
workflow_cls: type,
|
|
2996
2996
|
history_events: Iterable[dict[str, Any]],
|
|
2997
2997
|
start_input: list[Any],
|
|
@@ -3006,17 +3006,8 @@ def query_state(
|
|
|
3006
3006
|
prepare_local_activities: bool = False,
|
|
3007
3007
|
prepare_local_activity_groups: bool = False,
|
|
3008
3008
|
local_activity_cancellation_policies: tuple[str, ...] = (),
|
|
3009
|
-
) -> Any:
|
|
3010
|
-
"""
|
|
3011
|
-
|
|
3012
|
-
This is the replay and invocation core used by the current server-routed
|
|
3013
|
-
worker query path after it fetches durable history. That path is negotiated
|
|
3014
|
-
through ``worker_protocol.server_capabilities.query_tasks``: workers
|
|
3015
|
-
advertise ``query_tasks`` only after discovery reports it as available,
|
|
3016
|
-
and clients fail with typed capability or discovery errors when it is not.
|
|
3017
|
-
Unknown query names and handler exceptions are normalized to
|
|
3018
|
-
:class:`~durable_workflow.errors.QueryFailed`.
|
|
3019
|
-
"""
|
|
3009
|
+
) -> Callable[[], Any]:
|
|
3010
|
+
"""Prepare the query so a worker can recheck authority after replay."""
|
|
3020
3011
|
try:
|
|
3021
3012
|
state = _replay_state(
|
|
3022
3013
|
workflow_cls,
|
|
@@ -3047,12 +3038,46 @@ def query_state(
|
|
|
3047
3038
|
if handler is None:
|
|
3048
3039
|
raise QueryFailed(f"query handler {query_name!r} is not available")
|
|
3049
3040
|
|
|
3050
|
-
|
|
3051
|
-
|
|
3052
|
-
|
|
3053
|
-
|
|
3054
|
-
|
|
3055
|
-
|
|
3041
|
+
def invoke() -> Any:
|
|
3042
|
+
try:
|
|
3043
|
+
return handler(*(list(args) if args is not None else []))
|
|
3044
|
+
except QueryFailed:
|
|
3045
|
+
raise
|
|
3046
|
+
except Exception as exc:
|
|
3047
|
+
raise QueryFailed(str(exc) or f"query {query_name!r} failed") from exc
|
|
3048
|
+
|
|
3049
|
+
return invoke
|
|
3050
|
+
|
|
3051
|
+
|
|
3052
|
+
def query_state(
|
|
3053
|
+
workflow_cls: type,
|
|
3054
|
+
history_events: Iterable[dict[str, Any]],
|
|
3055
|
+
start_input: list[Any],
|
|
3056
|
+
query_name: str,
|
|
3057
|
+
args: list[Any] | None = None,
|
|
3058
|
+
*,
|
|
3059
|
+
workflow_id: str | None = None,
|
|
3060
|
+
run_id: str = "",
|
|
3061
|
+
payload_codec: str | None = None,
|
|
3062
|
+
external_storage: ExternalStorageDriver | None = None,
|
|
3063
|
+
external_storage_cache: ExternalPayloadCache | None = None,
|
|
3064
|
+
prepare_local_activities: bool = False,
|
|
3065
|
+
prepare_local_activity_groups: bool = False,
|
|
3066
|
+
local_activity_cancellation_policies: tuple[str, ...] = (),
|
|
3067
|
+
) -> Any:
|
|
3068
|
+
"""Replay workflow state and invoke a query, normalizing handler failures.
|
|
3069
|
+
|
|
3070
|
+
Server-routed Worker execution separately checks its original query claim
|
|
3071
|
+
between replay and handler invocation. Direct local use has no Server lease.
|
|
3072
|
+
"""
|
|
3073
|
+
return _prepare_query_state(
|
|
3074
|
+
workflow_cls, history_events, start_input, query_name, args,
|
|
3075
|
+
workflow_id=workflow_id, run_id=run_id, payload_codec=payload_codec,
|
|
3076
|
+
external_storage=external_storage, external_storage_cache=external_storage_cache,
|
|
3077
|
+
prepare_local_activities=prepare_local_activities,
|
|
3078
|
+
prepare_local_activity_groups=prepare_local_activity_groups,
|
|
3079
|
+
local_activity_cancellation_policies=local_activity_cancellation_policies,
|
|
3080
|
+
)()
|
|
3056
3081
|
|
|
3057
3082
|
|
|
3058
3083
|
def apply_update(
|
{durable_workflow-2.5.13 → durable_workflow-2.5.15}/src/durable_workflow.egg-info/SOURCES.txt
RENAMED
|
@@ -8,6 +8,7 @@ src/durable_workflow/_cancellation_scope.py
|
|
|
8
8
|
src/durable_workflow/_cancellation_scope_history.py
|
|
9
9
|
src/durable_workflow/_cooperative_cancellation.py
|
|
10
10
|
src/durable_workflow/_prepared_local_activity.py
|
|
11
|
+
src/durable_workflow/_query_task_authority.py
|
|
11
12
|
src/durable_workflow/_sticky_workflow_cache.py
|
|
12
13
|
src/durable_workflow/activity.py
|
|
13
14
|
src/durable_workflow/auth_composition.py
|
|
@@ -95,6 +96,7 @@ tests/test_public_boundary_scanner.py
|
|
|
95
96
|
tests/test_pypi_project_surface.py
|
|
96
97
|
tests/test_python_conformance.py
|
|
97
98
|
tests/test_queries.py
|
|
99
|
+
tests/test_query_task_authority.py
|
|
98
100
|
tests/test_query_timeout_retry.py
|
|
99
101
|
tests/test_readme_quickstart.py
|
|
100
102
|
tests/test_redrive_replay.py
|