durable-workflow 2.0.2__tar.gz → 2.0.3__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.0.2/src/durable_workflow.egg-info → durable_workflow-2.0.3}/PKG-INFO +1 -1
- {durable_workflow-2.0.2 → durable_workflow-2.0.3}/pyproject.toml +3 -3
- {durable_workflow-2.0.2 → durable_workflow-2.0.3}/src/durable_workflow/errors.py +26 -0
- {durable_workflow-2.0.2 → durable_workflow-2.0.3}/src/durable_workflow/retry_policy.py +63 -0
- {durable_workflow-2.0.2 → durable_workflow-2.0.3}/src/durable_workflow/worker.py +62 -8
- {durable_workflow-2.0.2 → durable_workflow-2.0.3/src/durable_workflow.egg-info}/PKG-INFO +1 -1
- {durable_workflow-2.0.2 → durable_workflow-2.0.3}/src/durable_workflow.egg-info/SOURCES.txt +1 -0
- {durable_workflow-2.0.2 → durable_workflow-2.0.3}/tests/test_release_metadata.py +2 -2
- durable_workflow-2.0.3/tests/test_storage_admission.py +590 -0
- {durable_workflow-2.0.2 → durable_workflow-2.0.3}/LICENSE +0 -0
- {durable_workflow-2.0.2 → durable_workflow-2.0.3}/README.md +0 -0
- {durable_workflow-2.0.2 → durable_workflow-2.0.3}/setup.cfg +0 -0
- {durable_workflow-2.0.2 → durable_workflow-2.0.3}/src/durable_workflow/__init__.py +0 -0
- {durable_workflow-2.0.2 → durable_workflow-2.0.3}/src/durable_workflow/_avro.py +0 -0
- {durable_workflow-2.0.2 → durable_workflow-2.0.3}/src/durable_workflow/activity.py +0 -0
- {durable_workflow-2.0.2 → durable_workflow-2.0.3}/src/durable_workflow/auth_composition.py +0 -0
- {durable_workflow-2.0.2 → durable_workflow-2.0.3}/src/durable_workflow/client.py +0 -0
- {durable_workflow-2.0.2 → durable_workflow-2.0.3}/src/durable_workflow/external_storage.py +0 -0
- {durable_workflow-2.0.2 → durable_workflow-2.0.3}/src/durable_workflow/external_task_input.py +0 -0
- {durable_workflow-2.0.2 → durable_workflow-2.0.3}/src/durable_workflow/external_task_result.py +0 -0
- {durable_workflow-2.0.2 → durable_workflow-2.0.3}/src/durable_workflow/history_bundle_verify.py +0 -0
- {durable_workflow-2.0.2 → durable_workflow-2.0.3}/src/durable_workflow/interceptors.py +0 -0
- {durable_workflow-2.0.2 → durable_workflow-2.0.3}/src/durable_workflow/invocable.py +0 -0
- {durable_workflow-2.0.2 → durable_workflow-2.0.3}/src/durable_workflow/metrics.py +0 -0
- {durable_workflow-2.0.2 → durable_workflow-2.0.3}/src/durable_workflow/nexus.py +0 -0
- {durable_workflow-2.0.2 → durable_workflow-2.0.3}/src/durable_workflow/py.typed +0 -0
- {durable_workflow-2.0.2 → durable_workflow-2.0.3}/src/durable_workflow/python_conformance.py +0 -0
- {durable_workflow-2.0.2 → durable_workflow-2.0.3}/src/durable_workflow/replay_conformance.py +0 -0
- {durable_workflow-2.0.2 → durable_workflow-2.0.3}/src/durable_workflow/replay_verify.py +0 -0
- {durable_workflow-2.0.2 → durable_workflow-2.0.3}/src/durable_workflow/serializer.py +0 -0
- {durable_workflow-2.0.2 → durable_workflow-2.0.3}/src/durable_workflow/sync.py +0 -0
- {durable_workflow-2.0.2 → durable_workflow-2.0.3}/src/durable_workflow/testing.py +0 -0
- {durable_workflow-2.0.2 → durable_workflow-2.0.3}/src/durable_workflow/workflow.py +0 -0
- {durable_workflow-2.0.2 → durable_workflow-2.0.3}/src/durable_workflow/workflow_updates_conformance.py +0 -0
- {durable_workflow-2.0.2 → durable_workflow-2.0.3}/src/durable_workflow.egg-info/dependency_links.txt +0 -0
- {durable_workflow-2.0.2 → durable_workflow-2.0.3}/src/durable_workflow.egg-info/entry_points.txt +0 -0
- {durable_workflow-2.0.2 → durable_workflow-2.0.3}/src/durable_workflow.egg-info/requires.txt +0 -0
- {durable_workflow-2.0.2 → durable_workflow-2.0.3}/src/durable_workflow.egg-info/top_level.txt +0 -0
- {durable_workflow-2.0.2 → durable_workflow-2.0.3}/tests/test_activity_context.py +0 -0
- {durable_workflow-2.0.2 → durable_workflow-2.0.3}/tests/test_auth_composition.py +0 -0
- {durable_workflow-2.0.2 → durable_workflow-2.0.3}/tests/test_avro_value_benchmark.py +0 -0
- {durable_workflow-2.0.2 → durable_workflow-2.0.3}/tests/test_avro_value_protocol.py +0 -0
- {durable_workflow-2.0.2 → durable_workflow-2.0.3}/tests/test_ci_checkout.py +0 -0
- {durable_workflow-2.0.2 → durable_workflow-2.0.3}/tests/test_ci_integration_endpoint.py +0 -0
- {durable_workflow-2.0.2 → durable_workflow-2.0.3}/tests/test_client.py +0 -0
- {durable_workflow-2.0.2 → durable_workflow-2.0.3}/tests/test_codec_regression_corpus.py +0 -0
- {durable_workflow-2.0.2 → durable_workflow-2.0.3}/tests/test_control_plane_parity_fixtures.py +0 -0
- {durable_workflow-2.0.2 → durable_workflow-2.0.3}/tests/test_docstring_cross_references.py +0 -0
- {durable_workflow-2.0.2 → durable_workflow-2.0.3}/tests/test_durable_selection.py +0 -0
- {durable_workflow-2.0.2 → durable_workflow-2.0.3}/tests/test_errors.py +0 -0
- {durable_workflow-2.0.2 → durable_workflow-2.0.3}/tests/test_external_storage.py +0 -0
- {durable_workflow-2.0.2 → durable_workflow-2.0.3}/tests/test_external_task_input.py +0 -0
- {durable_workflow-2.0.2 → durable_workflow-2.0.3}/tests/test_external_task_result.py +0 -0
- {durable_workflow-2.0.2 → durable_workflow-2.0.3}/tests/test_golden_history_replay.py +0 -0
- {durable_workflow-2.0.2 → durable_workflow-2.0.3}/tests/test_history_bundle_verify.py +0 -0
- {durable_workflow-2.0.2 → durable_workflow-2.0.3}/tests/test_history_event_contract.py +0 -0
- {durable_workflow-2.0.2 → durable_workflow-2.0.3}/tests/test_invocable.py +0 -0
- {durable_workflow-2.0.2 → durable_workflow-2.0.3}/tests/test_message_streams.py +0 -0
- {durable_workflow-2.0.2 → durable_workflow-2.0.3}/tests/test_metrics.py +0 -0
- {durable_workflow-2.0.2 → durable_workflow-2.0.3}/tests/test_order_processing_example.py +0 -0
- {durable_workflow-2.0.2 → durable_workflow-2.0.3}/tests/test_parallel_completion_order_replay.py +0 -0
- {durable_workflow-2.0.2 → durable_workflow-2.0.3}/tests/test_parallel_saga_authoring.py +0 -0
- {durable_workflow-2.0.2 → durable_workflow-2.0.3}/tests/test_public_boundary_scanner.py +0 -0
- {durable_workflow-2.0.2 → durable_workflow-2.0.3}/tests/test_pypi_project_surface.py +0 -0
- {durable_workflow-2.0.2 → durable_workflow-2.0.3}/tests/test_python_conformance.py +0 -0
- {durable_workflow-2.0.2 → durable_workflow-2.0.3}/tests/test_queries.py +0 -0
- {durable_workflow-2.0.2 → durable_workflow-2.0.3}/tests/test_readme_quickstart.py +0 -0
- {durable_workflow-2.0.2 → durable_workflow-2.0.3}/tests/test_replay.py +0 -0
- {durable_workflow-2.0.2 → durable_workflow-2.0.3}/tests/test_replay_conformance.py +0 -0
- {durable_workflow-2.0.2 → durable_workflow-2.0.3}/tests/test_replay_regression_corpus.py +0 -0
- {durable_workflow-2.0.2 → durable_workflow-2.0.3}/tests/test_replay_verify.py +0 -0
- {durable_workflow-2.0.2 → durable_workflow-2.0.3}/tests/test_retry_policy.py +0 -0
- {durable_workflow-2.0.2 → durable_workflow-2.0.3}/tests/test_runtime_external_payload_transport.py +0 -0
- {durable_workflow-2.0.2 → durable_workflow-2.0.3}/tests/test_schedules.py +0 -0
- {durable_workflow-2.0.2 → durable_workflow-2.0.3}/tests/test_serializer.py +0 -0
- {durable_workflow-2.0.2 → durable_workflow-2.0.3}/tests/test_signals.py +0 -0
- {durable_workflow-2.0.2 → durable_workflow-2.0.3}/tests/test_sleep.py +0 -0
- {durable_workflow-2.0.2 → durable_workflow-2.0.3}/tests/test_standalone_activity_client.py +0 -0
- {durable_workflow-2.0.2 → durable_workflow-2.0.3}/tests/test_sync.py +0 -0
- {durable_workflow-2.0.2 → durable_workflow-2.0.3}/tests/test_testing_harness.py +0 -0
- {durable_workflow-2.0.2 → durable_workflow-2.0.3}/tests/test_update_signal_condition_replay.py +0 -0
- {durable_workflow-2.0.2 → durable_workflow-2.0.3}/tests/test_updates.py +0 -0
- {durable_workflow-2.0.2 → durable_workflow-2.0.3}/tests/test_wait_condition.py +0 -0
- {durable_workflow-2.0.2 → durable_workflow-2.0.3}/tests/test_worker.py +0 -0
- {durable_workflow-2.0.2 → durable_workflow-2.0.3}/tests/test_workflow_result_timeout.py +0 -0
- {durable_workflow-2.0.2 → durable_workflow-2.0.3}/tests/test_workflow_streams.py +0 -0
- {durable_workflow-2.0.2 → durable_workflow-2.0.3}/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.0.
|
|
7
|
+
version = "2.0.3"
|
|
8
8
|
description = "Python client and worker SDK for Durable Workflow Cloud and self-hosted Server"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -71,8 +71,8 @@ durable-workflow-replay-conformance = "durable_workflow.replay_conformance:main"
|
|
|
71
71
|
durable-workflow-workflow-updates-conformance = "durable_workflow.workflow_updates_conformance:main"
|
|
72
72
|
|
|
73
73
|
[tool.durable-workflow]
|
|
74
|
-
product-train = "2.0.
|
|
75
|
-
registry-version = "2.0.
|
|
74
|
+
product-train = "2.0.3"
|
|
75
|
+
registry-version = "2.0.3"
|
|
76
76
|
supported-server-versions = "2.0.0"
|
|
77
77
|
worker-protocol-version = "1.19"
|
|
78
78
|
control-plane-version = "2"
|
|
@@ -171,6 +171,32 @@ class ServerError(DurableWorkflowError):
|
|
|
171
171
|
return self.body.get("reason")
|
|
172
172
|
return None
|
|
173
173
|
|
|
174
|
+
def is_storage_admission_failure(self, poll_request_id: str | None = None) -> bool:
|
|
175
|
+
"""Whether the runtime explicitly refused admission and requested an identity-preserving retry."""
|
|
176
|
+
body = self.body
|
|
177
|
+
if (
|
|
178
|
+
self.status != 503
|
|
179
|
+
or not isinstance(body, dict)
|
|
180
|
+
or self.reason() not in ("storage_pressure", "storage_admission_unavailable")
|
|
181
|
+
or body.get("retryable") is not True
|
|
182
|
+
or type(body.get("retry_after_seconds")) is not int
|
|
183
|
+
or body["retry_after_seconds"] <= 0
|
|
184
|
+
or body.get("storage_state") not in ("draining", "fenced")
|
|
185
|
+
or (self.reason() == "storage_admission_unavailable" and body["storage_state"] != "fenced")
|
|
186
|
+
or ("request_admitted" in body and body["request_admitted"] is not False)
|
|
187
|
+
):
|
|
188
|
+
return False
|
|
189
|
+
if poll_request_id is None:
|
|
190
|
+
return body.get("request_admitted") is False
|
|
191
|
+
return (
|
|
192
|
+
bool(poll_request_id)
|
|
193
|
+
and "task" in body and body["task"] is None
|
|
194
|
+
and body.get("poll_status") == self.reason()
|
|
195
|
+
and body.get("poll_request_id") == poll_request_id
|
|
196
|
+
and body.get("retry_same_poll_request_id") is True
|
|
197
|
+
and body.get("claim_admitted") is False
|
|
198
|
+
)
|
|
199
|
+
|
|
174
200
|
|
|
175
201
|
class NexusOperationFailed(DurableWorkflowError):
|
|
176
202
|
"""A Nexus service operation completed with a typed service failure."""
|
|
@@ -13,6 +13,9 @@
|
|
|
13
13
|
from __future__ import annotations
|
|
14
14
|
|
|
15
15
|
import asyncio
|
|
16
|
+
import contextvars
|
|
17
|
+
import json
|
|
18
|
+
import logging
|
|
16
19
|
import random
|
|
17
20
|
from collections.abc import Awaitable, Callable
|
|
18
21
|
from dataclasses import dataclass
|
|
@@ -20,7 +23,40 @@ from typing import TypeVar
|
|
|
20
23
|
|
|
21
24
|
import httpx
|
|
22
25
|
|
|
26
|
+
from .errors import ServerError
|
|
27
|
+
|
|
23
28
|
T = TypeVar("T")
|
|
29
|
+
log = logging.getLogger("durable_workflow.worker")
|
|
30
|
+
|
|
31
|
+
# Task-local so sharing a Client never changes unrelated client/control requests.
|
|
32
|
+
_worker_storage_admission_stop: contextvars.ContextVar[Callable[[], bool] | None] = contextvars.ContextVar(
|
|
33
|
+
"worker_storage_admission_stop", default=None,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _storage_refusal(exc: Exception) -> tuple[ServerError, str | None] | None:
|
|
38
|
+
if not isinstance(exc, httpx.HTTPStatusError):
|
|
39
|
+
return None
|
|
40
|
+
if "X-Durable-Workflow-Protocol-Version" not in exc.request.headers:
|
|
41
|
+
return None
|
|
42
|
+
try:
|
|
43
|
+
body = exc.response.json()
|
|
44
|
+
except ValueError:
|
|
45
|
+
return None
|
|
46
|
+
error = ServerError(exc.response.status_code, body)
|
|
47
|
+
if error.reason() not in ("storage_pressure", "storage_admission_unavailable"):
|
|
48
|
+
return None
|
|
49
|
+
poll_id = None
|
|
50
|
+
if exc.request.url.path.endswith("/poll"):
|
|
51
|
+
try:
|
|
52
|
+
request = json.loads(exc.request.content)
|
|
53
|
+
poll_id = request.get("poll_request_id") if isinstance(request, dict) else None
|
|
54
|
+
except ValueError:
|
|
55
|
+
pass
|
|
56
|
+
# An invalid submitted ID must not fall through to the non-poll contract.
|
|
57
|
+
if not isinstance(poll_id, str) or not poll_id:
|
|
58
|
+
poll_id = ""
|
|
59
|
+
return error, poll_id
|
|
24
60
|
|
|
25
61
|
|
|
26
62
|
@dataclass
|
|
@@ -81,6 +117,7 @@ class TransportRetryPolicy:
|
|
|
81
117
|
Raises the last exception if all retries are exhausted.
|
|
82
118
|
"""
|
|
83
119
|
attempt = 0
|
|
120
|
+
storage_attempt = 0
|
|
84
121
|
last_exc: Exception | None = None
|
|
85
122
|
|
|
86
123
|
while attempt < self.max_attempts:
|
|
@@ -89,6 +126,32 @@ class TransportRetryPolicy:
|
|
|
89
126
|
return result
|
|
90
127
|
except Exception as exc:
|
|
91
128
|
last_exc = exc
|
|
129
|
+
stop = _worker_storage_admission_stop.get()
|
|
130
|
+
refusal = _storage_refusal(exc) if stop is not None else None
|
|
131
|
+
if refusal is not None and stop is not None:
|
|
132
|
+
error, poll_id = refusal
|
|
133
|
+
if not error.is_storage_admission_failure(poll_id) or stop():
|
|
134
|
+
raise
|
|
135
|
+
storage_attempt += 1
|
|
136
|
+
assert isinstance(error.body, dict)
|
|
137
|
+
delay = min(
|
|
138
|
+
5.0,
|
|
139
|
+
max(
|
|
140
|
+
self.backoff_seconds(min(storage_attempt - 1, 6)),
|
|
141
|
+
error.body["retry_after_seconds"],
|
|
142
|
+
),
|
|
143
|
+
)
|
|
144
|
+
log.warning("storage admission paused; retrying the same worker request in %.2fs", delay)
|
|
145
|
+
# Do not consume the finite transport budget or repeat serialization/uploads.
|
|
146
|
+
while delay > 0:
|
|
147
|
+
if stop():
|
|
148
|
+
raise
|
|
149
|
+
interval = min(0.1, delay)
|
|
150
|
+
await asyncio.sleep(interval)
|
|
151
|
+
delay -= interval
|
|
152
|
+
if stop():
|
|
153
|
+
raise
|
|
154
|
+
continue
|
|
92
155
|
if not self.should_retry(exc, attempt):
|
|
93
156
|
raise
|
|
94
157
|
|
|
@@ -27,10 +27,11 @@ import time
|
|
|
27
27
|
import traceback
|
|
28
28
|
import types
|
|
29
29
|
import uuid
|
|
30
|
-
from collections.abc import Awaitable, Callable, Iterable, Mapping
|
|
30
|
+
from collections.abc import Awaitable, Callable, Coroutine, Iterable, Mapping
|
|
31
31
|
from datetime import datetime, timezone
|
|
32
|
+
from functools import wraps
|
|
32
33
|
from types import FunctionType
|
|
33
|
-
from typing import Annotated, Any, Literal, Union, get_args, get_origin, get_type_hints
|
|
34
|
+
from typing import Annotated, Any, Concatenate, Literal, ParamSpec, TypeVar, Union, get_args, get_origin, get_type_hints
|
|
34
35
|
|
|
35
36
|
from . import serializer
|
|
36
37
|
from .activity import ActivityContext, ActivityInfo, _set_context
|
|
@@ -75,6 +76,7 @@ from .metrics import (
|
|
|
75
76
|
WORKER_TASKS,
|
|
76
77
|
MetricsRecorder,
|
|
77
78
|
)
|
|
79
|
+
from .retry_policy import _worker_storage_admission_stop
|
|
78
80
|
from .workflow import (
|
|
79
81
|
Command,
|
|
80
82
|
NexusServiceCall,
|
|
@@ -137,6 +139,27 @@ _WORKFLOW_TASK_COMPLETION_MAX_ATTEMPTS = 3
|
|
|
137
139
|
_WORKFLOW_TASK_COMPLETION_RETRY_DELAYS = (0.05, 0.2)
|
|
138
140
|
_WORKFLOW_TASK_NEXUS_RESOLUTION_LIMIT = 100
|
|
139
141
|
_WORKER_WORKFLOW_FINGERPRINTS: dict[tuple[str, str], str] = {}
|
|
142
|
+
_P = ParamSpec("_P")
|
|
143
|
+
_R = TypeVar("_R")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _with_storage_admission_retries(
|
|
147
|
+
fn: Callable[Concatenate[Worker, _P], Coroutine[Any, Any, _R]],
|
|
148
|
+
) -> Callable[Concatenate[Worker, _P], Coroutine[Any, Any, _R]]:
|
|
149
|
+
@wraps(fn)
|
|
150
|
+
async def run(self: Worker, /, *args: _P.args, **kwargs: _P.kwargs) -> _R:
|
|
151
|
+
token = _worker_storage_admission_stop.set(self._stop.is_set)
|
|
152
|
+
try:
|
|
153
|
+
return await fn(self, *args, **kwargs)
|
|
154
|
+
finally:
|
|
155
|
+
_worker_storage_admission_stop.reset(token)
|
|
156
|
+
return run
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _is_storage_admission_error(error: BaseException) -> bool:
|
|
160
|
+
return isinstance(error, ServerError) and error.reason() in (
|
|
161
|
+
"storage_pressure", "storage_admission_unavailable",
|
|
162
|
+
)
|
|
140
163
|
|
|
141
164
|
|
|
142
165
|
def _command_payload_codec(codec: object) -> str:
|
|
@@ -208,6 +231,8 @@ def _should_fail_workflow_task_after_completion_error(error: BaseException) -> b
|
|
|
208
231
|
|
|
209
232
|
|
|
210
233
|
def _should_retry_workflow_task_completion_error(error: BaseException) -> bool:
|
|
234
|
+
if _is_storage_admission_error(error):
|
|
235
|
+
return False
|
|
211
236
|
if isinstance(error, ServerError):
|
|
212
237
|
return error.status >= 500 or error.status == 429
|
|
213
238
|
|
|
@@ -1424,6 +1449,8 @@ class Worker:
|
|
|
1424
1449
|
)
|
|
1425
1450
|
except Exception as e:
|
|
1426
1451
|
log.warning("failed to complete workflow update task %s: %s", task_id, e)
|
|
1452
|
+
if _is_storage_admission_error(e):
|
|
1453
|
+
return None
|
|
1427
1454
|
if _should_fail_workflow_task_after_completion_error(e):
|
|
1428
1455
|
await self._report_workflow_task_after_completion_error(task_id, attempt, e)
|
|
1429
1456
|
return None
|
|
@@ -1550,6 +1577,8 @@ class Worker:
|
|
|
1550
1577
|
)
|
|
1551
1578
|
except Exception as e:
|
|
1552
1579
|
log.warning("failed to complete workflow task %s: %s", task_id, e)
|
|
1580
|
+
if _is_storage_admission_error(e):
|
|
1581
|
+
return None
|
|
1553
1582
|
if _should_fail_workflow_task_after_completion_error(e):
|
|
1554
1583
|
await self._report_workflow_task_after_completion_error(task_id, attempt, e)
|
|
1555
1584
|
return None
|
|
@@ -1736,6 +1765,8 @@ class Worker:
|
|
|
1736
1765
|
log.warning("failed to report activity failure: %s", fe)
|
|
1737
1766
|
return "failed_non_retryable"
|
|
1738
1767
|
except Exception as e:
|
|
1768
|
+
if _is_storage_admission_error(e):
|
|
1769
|
+
raise
|
|
1739
1770
|
log.exception("activity failed")
|
|
1740
1771
|
try:
|
|
1741
1772
|
await self.client.fail_activity_task(
|
|
@@ -1971,6 +2002,9 @@ class Worker:
|
|
|
1971
2002
|
**self._external_storage_completion_kwargs(),
|
|
1972
2003
|
)
|
|
1973
2004
|
except ServerError as e:
|
|
2005
|
+
if _is_storage_admission_error(e):
|
|
2006
|
+
log.warning("query task %s acknowledgement paused: %s", query_task_id, e)
|
|
2007
|
+
return "complete_error"
|
|
1974
2008
|
if _is_final_query_task_rejection(e):
|
|
1975
2009
|
log.info(
|
|
1976
2010
|
"query task %s completion was rejected after the task ended server-side: %s",
|
|
@@ -2147,6 +2181,8 @@ class Worker:
|
|
|
2147
2181
|
self._release_workflow_capacity()
|
|
2148
2182
|
if self._stop.is_set():
|
|
2149
2183
|
return
|
|
2184
|
+
if _is_storage_admission_error(e):
|
|
2185
|
+
raise
|
|
2150
2186
|
self._record_poll_metrics("workflow", "error", time.perf_counter() - poll_start)
|
|
2151
2187
|
log.warning("workflow poll error: %s", e)
|
|
2152
2188
|
await asyncio.sleep(1.0)
|
|
@@ -2236,10 +2272,15 @@ class Worker:
|
|
|
2236
2272
|
timeout=self._poll_http_timeout,
|
|
2237
2273
|
build_id=self.build_id,
|
|
2238
2274
|
)
|
|
2275
|
+
except asyncio.CancelledError:
|
|
2276
|
+
self._act_semaphore.release()
|
|
2277
|
+
raise
|
|
2239
2278
|
except Exception as e:
|
|
2240
2279
|
self._act_semaphore.release()
|
|
2241
2280
|
if self._stop.is_set():
|
|
2242
2281
|
return
|
|
2282
|
+
if _is_storage_admission_error(e):
|
|
2283
|
+
raise
|
|
2243
2284
|
self._record_poll_metrics("activity", "error", time.perf_counter() - poll_start)
|
|
2244
2285
|
log.warning("activity poll error: %s", e)
|
|
2245
2286
|
await asyncio.sleep(1.0)
|
|
@@ -2289,6 +2330,8 @@ class Worker:
|
|
|
2289
2330
|
query_thread_stop is not None and query_thread_stop.is_set()
|
|
2290
2331
|
):
|
|
2291
2332
|
return
|
|
2333
|
+
if _is_storage_admission_error(e):
|
|
2334
|
+
raise
|
|
2292
2335
|
self._record_poll_metrics("query", "error", time.perf_counter() - poll_start)
|
|
2293
2336
|
log.warning("query poll error: %s", e)
|
|
2294
2337
|
await asyncio.sleep(1.0)
|
|
@@ -2525,6 +2568,7 @@ class Worker:
|
|
|
2525
2568
|
except Exception:
|
|
2526
2569
|
log.exception("query task poller thread stopped unexpectedly")
|
|
2527
2570
|
|
|
2571
|
+
@_with_storage_admission_retries
|
|
2528
2572
|
async def _query_task_thread_main(self) -> None:
|
|
2529
2573
|
loop = asyncio.get_running_loop()
|
|
2530
2574
|
task = asyncio.current_task()
|
|
@@ -2562,6 +2606,7 @@ class Worker:
|
|
|
2562
2606
|
"the worker registration remains active"
|
|
2563
2607
|
)
|
|
2564
2608
|
|
|
2609
|
+
@_with_storage_admission_retries
|
|
2565
2610
|
async def run(self) -> None:
|
|
2566
2611
|
"""Register the worker and poll until `stop()` is called or the task is cancelled."""
|
|
2567
2612
|
self._begin_run()
|
|
@@ -2622,6 +2667,8 @@ class Worker:
|
|
|
2622
2667
|
process_metrics=self._current_process_metrics(),
|
|
2623
2668
|
)
|
|
2624
2669
|
except Exception as e:
|
|
2670
|
+
if _is_storage_admission_error(e) and not self._stop.is_set():
|
|
2671
|
+
raise
|
|
2625
2672
|
log.warning("worker heartbeat failed: %s", e)
|
|
2626
2673
|
continue
|
|
2627
2674
|
if isinstance(ack, dict):
|
|
@@ -2703,6 +2750,7 @@ class Worker:
|
|
|
2703
2750
|
|
|
2704
2751
|
return metrics
|
|
2705
2752
|
|
|
2753
|
+
@_with_storage_admission_retries
|
|
2706
2754
|
async def run_until(
|
|
2707
2755
|
self,
|
|
2708
2756
|
*,
|
|
@@ -2714,17 +2762,19 @@ class Worker:
|
|
|
2714
2762
|
|
|
2715
2763
|
This is intended for examples, smoke tests, and single-workflow scripts.
|
|
2716
2764
|
Long-running workers should call :meth:`run` and coordinate shutdown from
|
|
2717
|
-
their process supervisor.
|
|
2765
|
+
their process supervisor. ``timeout`` includes registration and runtime
|
|
2766
|
+
admission pauses; shutdown retains its separate drain timeout.
|
|
2718
2767
|
"""
|
|
2719
2768
|
background_tasks: list[asyncio.Task[Any]] = []
|
|
2769
|
+
deadline = asyncio.get_running_loop().time() + timeout
|
|
2720
2770
|
|
|
2721
2771
|
self._begin_run()
|
|
2722
2772
|
try:
|
|
2723
|
-
|
|
2724
|
-
|
|
2725
|
-
|
|
2773
|
+
try:
|
|
2774
|
+
await asyncio.wait_for(self._register(), timeout=timeout)
|
|
2775
|
+
finally:
|
|
2776
|
+
self._registration_done.set()
|
|
2726
2777
|
|
|
2727
|
-
try:
|
|
2728
2778
|
if self._stop.is_set():
|
|
2729
2779
|
raise asyncio.CancelledError
|
|
2730
2780
|
background_tasks.append(asyncio.create_task(self._heartbeat_loop()))
|
|
@@ -2742,7 +2792,11 @@ class Worker:
|
|
|
2742
2792
|
)
|
|
2743
2793
|
)
|
|
2744
2794
|
self._poller_tasks.add(run_until_loop)
|
|
2745
|
-
return await
|
|
2795
|
+
return await asyncio.wait_for(
|
|
2796
|
+
run_until_loop, timeout=max(0.0, deadline - asyncio.get_running_loop().time()),
|
|
2797
|
+
)
|
|
2798
|
+
except asyncio.TimeoutError as error:
|
|
2799
|
+
raise TimeoutError(f"workflow {workflow_id} not terminal after {timeout}s") from error
|
|
2746
2800
|
finally:
|
|
2747
2801
|
primary_error = sys.exc_info()[1]
|
|
2748
2802
|
primary_traceback = primary_error.__traceback__ if primary_error is not None else None
|
|
@@ -41,9 +41,9 @@ def test_worker_release_identity_matches_supported_server_and_protocol() -> None
|
|
|
41
41
|
project = manifest["project"]
|
|
42
42
|
release = manifest["tool"]["durable-workflow"]
|
|
43
43
|
|
|
44
|
-
assert project["version"] == "2.0.
|
|
44
|
+
assert project["version"] == "2.0.3"
|
|
45
45
|
assert release["product-train"] == project["version"]
|
|
46
|
-
assert release["registry-version"] == "2.0.
|
|
46
|
+
assert release["registry-version"] == "2.0.3"
|
|
47
47
|
assert release["supported-server-versions"] == "2.0.0"
|
|
48
48
|
assert release["worker-protocol-version"] == PROTOCOL_VERSION == "1.19"
|
|
49
49
|
assert release["durable-selection"] is True
|
|
@@ -0,0 +1,590 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import threading
|
|
6
|
+
from collections.abc import Callable, Iterator
|
|
7
|
+
from contextlib import contextmanager
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from types import SimpleNamespace
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
import pytest
|
|
14
|
+
|
|
15
|
+
import durable_workflow.retry_policy as retry_module
|
|
16
|
+
from durable_workflow import activity, serializer, workflow
|
|
17
|
+
from durable_workflow.client import Client
|
|
18
|
+
from durable_workflow.errors import ServerError, Unauthorized
|
|
19
|
+
from durable_workflow.retry_policy import TransportRetryPolicy, _worker_storage_admission_stop
|
|
20
|
+
from durable_workflow.worker import PORTABLE_WORKER_AFFINITY_CAPABILITY_MANIFEST, Worker
|
|
21
|
+
from tests.test_runtime_external_payload_transport import FakeRuntimePayloadServer, runtime_client
|
|
22
|
+
from tests.test_worker import ValidatedUpdateWorkflow, compatible_cluster_info
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def pressure(poll_id: str | None = None, *, reason: str = "storage_pressure", entry: bool = True) -> dict[str, Any]:
|
|
26
|
+
body: dict[str, Any] = {
|
|
27
|
+
"reason": reason,
|
|
28
|
+
"storage_state": "draining" if reason == "storage_pressure" else "fenced",
|
|
29
|
+
"retryable": True,
|
|
30
|
+
"retry_after_seconds": 1,
|
|
31
|
+
}
|
|
32
|
+
if entry:
|
|
33
|
+
body["request_admitted"] = False
|
|
34
|
+
if poll_id is not None:
|
|
35
|
+
body.update({
|
|
36
|
+
"task": None, "poll_status": reason, "poll_request_id": poll_id,
|
|
37
|
+
"retry_same_poll_request_id": True, "claim_admitted": False,
|
|
38
|
+
})
|
|
39
|
+
return body
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@contextmanager
|
|
43
|
+
def worker_scope(stop: Callable[[], bool] = lambda: False) -> Iterator[None]:
|
|
44
|
+
token = _worker_storage_admission_stop.set(stop)
|
|
45
|
+
try:
|
|
46
|
+
yield
|
|
47
|
+
finally:
|
|
48
|
+
_worker_storage_admission_stop.reset(token)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@pytest.fixture
|
|
52
|
+
def retry_sleeps(monkeypatch: pytest.MonkeyPatch) -> list[float]:
|
|
53
|
+
sleeps: list[float] = []
|
|
54
|
+
|
|
55
|
+
async def sleep(delay: float) -> None:
|
|
56
|
+
sleeps.append(delay)
|
|
57
|
+
assert len(sleeps) < 1000, "The simulated storage outage must recover or be interrupted."
|
|
58
|
+
await asyncio.sleep(0)
|
|
59
|
+
|
|
60
|
+
monkeypatch.setattr(retry_module, "asyncio", SimpleNamespace(sleep=sleep))
|
|
61
|
+
return sleeps
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def client_for(handler: Callable[..., Any]) -> Client:
|
|
65
|
+
client = Client(
|
|
66
|
+
"https://runtime.example", token="test-runtime-token",
|
|
67
|
+
retry_policy=TransportRetryPolicy(max_attempts=2, initial_backoff_seconds=0, jitter=False),
|
|
68
|
+
)
|
|
69
|
+
client._http = httpx.AsyncClient(base_url=client.base_url, transport=httpx.MockTransport(handler))
|
|
70
|
+
client._runtime_external_payload_transport_resolved = True
|
|
71
|
+
return client
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@pytest.mark.parametrize("kind", ["workflow", "activity", "query", "multiplexed"])
|
|
75
|
+
@pytest.mark.parametrize("reason", ["storage_pressure", "storage_admission_unavailable"])
|
|
76
|
+
@pytest.mark.parametrize("entry", [True, False])
|
|
77
|
+
async def test_poll_keeps_identity_after_ambiguous_response_and_pressure(
|
|
78
|
+
kind: str, reason: str, entry: bool, retry_sleeps: list[float],
|
|
79
|
+
) -> None:
|
|
80
|
+
requests: list[bytes] = []
|
|
81
|
+
|
|
82
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
83
|
+
requests.append(request.content)
|
|
84
|
+
body = json.loads(request.content)
|
|
85
|
+
if len(requests) == 1:
|
|
86
|
+
raise httpx.ReadTimeout("Response lost after a possible claim.", request=request)
|
|
87
|
+
if len(requests) < 7:
|
|
88
|
+
return httpx.Response(503, json=pressure(body["poll_request_id"], reason=reason, entry=entry))
|
|
89
|
+
return httpx.Response(200, json={"task": {"task_id": "same-claim"}})
|
|
90
|
+
|
|
91
|
+
async with client_for(handler) as client:
|
|
92
|
+
with worker_scope():
|
|
93
|
+
if kind == "multiplexed":
|
|
94
|
+
result = await client.poll_workflow_task(
|
|
95
|
+
worker_id="storage-worker", task_queue="orders",
|
|
96
|
+
task_kinds=("workflow", "update_validation"),
|
|
97
|
+
)
|
|
98
|
+
else:
|
|
99
|
+
poll = getattr(client, f"poll_{kind}_task")
|
|
100
|
+
result = await poll(worker_id="storage-worker", task_queue="orders")
|
|
101
|
+
assert result == {"task_id": "same-claim"}
|
|
102
|
+
assert len(requests) == 7
|
|
103
|
+
assert len(set(requests)) == 1
|
|
104
|
+
assert sum(retry_sleeps) == pytest.approx(5)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@pytest.mark.parametrize("method,kwargs", [
|
|
108
|
+
("register_worker", {
|
|
109
|
+
"worker_id": "storage-worker", "task_queue": "orders",
|
|
110
|
+
"capability_manifest": PORTABLE_WORKER_AFFINITY_CAPABILITY_MANIFEST,
|
|
111
|
+
}),
|
|
112
|
+
("heartbeat_worker", {"worker_id": "storage-worker"}),
|
|
113
|
+
("complete_workflow_task", {"task_id": "task", "workflow_task_attempt": 7, "commands": []}),
|
|
114
|
+
("fail_workflow_task", {"task_id": "task", "workflow_task_attempt": 7, "message": "failed"}),
|
|
115
|
+
("complete_activity_task", {"task_id": "task", "activity_attempt_id": "attempt-7", "result": "done"}),
|
|
116
|
+
("fail_activity_task", {"task_id": "task", "activity_attempt_id": "attempt-7", "message": "failed"}),
|
|
117
|
+
("heartbeat_activity_task", {"task_id": "task", "activity_attempt_id": "attempt-7", "details": {"step": 2}}),
|
|
118
|
+
("complete_query_task", {"query_task_id": "task", "query_task_attempt": 7, "result": "done"}),
|
|
119
|
+
("fail_query_task", {"query_task_id": "task", "query_task_attempt": 7, "message": "failed"}),
|
|
120
|
+
("approve_update_validation_task", {"update_validation_task_id": "task", "update_validation_attempt": 7}),
|
|
121
|
+
("reject_update_validation_task", {
|
|
122
|
+
"update_validation_task_id": "task", "update_validation_attempt": 7, "message": "rejected",
|
|
123
|
+
"reason": "update_rejected",
|
|
124
|
+
}),
|
|
125
|
+
])
|
|
126
|
+
async def test_worker_mutations_retry_the_prepared_request(
|
|
127
|
+
method: str, kwargs: dict[str, Any], retry_sleeps: list[float],
|
|
128
|
+
) -> None:
|
|
129
|
+
requests: list[bytes] = []
|
|
130
|
+
|
|
131
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
132
|
+
requests.append(request.content)
|
|
133
|
+
if len(requests) <= 4:
|
|
134
|
+
return httpx.Response(503, json=pressure(reason="storage_admission_unavailable"))
|
|
135
|
+
return httpx.Response(200, json={"recorded": True})
|
|
136
|
+
|
|
137
|
+
if method not in ("register_worker", "heartbeat_worker"):
|
|
138
|
+
kwargs = {**kwargs, "lease_owner": "storage-worker"}
|
|
139
|
+
async with client_for(handler) as client:
|
|
140
|
+
with worker_scope():
|
|
141
|
+
await getattr(client, method)(**kwargs)
|
|
142
|
+
assert len(requests) == 5
|
|
143
|
+
assert len(set(requests)) == 1
|
|
144
|
+
assert sum(retry_sleeps) == pytest.approx(4)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@pytest.mark.parametrize("override", [
|
|
148
|
+
{"task": {"task_id": "already-claimed"}}, {"poll_request_id": "another-poll"},
|
|
149
|
+
{"poll_status": "empty"}, {"claim_admitted": True}, {"retry_same_poll_request_id": False},
|
|
150
|
+
{"request_admitted": True}, {"retryable": False}, {"storage_state": "normal"},
|
|
151
|
+
{"retry_after_seconds": 0}, {"retry_after_seconds": True}, {"retry_after_seconds": "1"},
|
|
152
|
+
{"reason": "storage_admission_unavailable", "poll_status": "storage_admission_unavailable"},
|
|
153
|
+
])
|
|
154
|
+
async def test_invalid_storage_poll_contract_is_not_retried(
|
|
155
|
+
override: dict[str, Any], retry_sleeps: list[float],
|
|
156
|
+
) -> None:
|
|
157
|
+
calls = 0
|
|
158
|
+
|
|
159
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
160
|
+
nonlocal calls
|
|
161
|
+
calls += 1
|
|
162
|
+
return httpx.Response(503, json={**pressure(json.loads(request.content)["poll_request_id"]), **override})
|
|
163
|
+
|
|
164
|
+
async with client_for(handler) as client:
|
|
165
|
+
with worker_scope(), pytest.raises(ServerError):
|
|
166
|
+
await client.poll_activity_task(worker_id="storage-worker", task_queue="orders")
|
|
167
|
+
assert calls == 1
|
|
168
|
+
assert not retry_sleeps
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@pytest.mark.parametrize("scoped,worker_plane", [(False, True), (True, False)])
|
|
172
|
+
async def test_direct_clients_and_control_plane_keep_bounded_retries(
|
|
173
|
+
scoped: bool, worker_plane: bool, retry_sleeps: list[float],
|
|
174
|
+
) -> None:
|
|
175
|
+
calls = 0
|
|
176
|
+
|
|
177
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
178
|
+
nonlocal calls
|
|
179
|
+
calls += 1
|
|
180
|
+
return httpx.Response(503, json=pressure())
|
|
181
|
+
|
|
182
|
+
async with client_for(handler) as client:
|
|
183
|
+
async def call() -> None:
|
|
184
|
+
with pytest.raises(ServerError):
|
|
185
|
+
await client._request("POST", "/test", worker=worker_plane, json={})
|
|
186
|
+
if scoped:
|
|
187
|
+
with worker_scope():
|
|
188
|
+
await call()
|
|
189
|
+
else:
|
|
190
|
+
await call()
|
|
191
|
+
assert calls == 2
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
async def test_retry_context_is_task_local_and_restored(retry_sleeps: list[float]) -> None:
|
|
195
|
+
async def check() -> None:
|
|
196
|
+
assert _worker_storage_admission_stop.get() is None
|
|
197
|
+
|
|
198
|
+
outside = asyncio.create_task(check())
|
|
199
|
+
with worker_scope():
|
|
200
|
+
await outside
|
|
201
|
+
assert _worker_storage_admission_stop.get() is not None
|
|
202
|
+
assert _worker_storage_admission_stop.get() is None
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
async def test_shutdown_interrupts_pressure_without_another_mutation(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
206
|
+
calls = 0
|
|
207
|
+
stopped = False
|
|
208
|
+
|
|
209
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
210
|
+
nonlocal calls
|
|
211
|
+
calls += 1
|
|
212
|
+
return httpx.Response(503, json=pressure())
|
|
213
|
+
|
|
214
|
+
async def stop_on_sleep(delay: float) -> None:
|
|
215
|
+
nonlocal stopped
|
|
216
|
+
stopped = True
|
|
217
|
+
|
|
218
|
+
monkeypatch.setattr(retry_module, "asyncio", SimpleNamespace(sleep=stop_on_sleep))
|
|
219
|
+
async with client_for(handler) as client:
|
|
220
|
+
with worker_scope(lambda: stopped), pytest.raises(ServerError):
|
|
221
|
+
await client.complete_activity_task(
|
|
222
|
+
task_id="task", activity_attempt_id="attempt-7", lease_owner="worker", result="done",
|
|
223
|
+
)
|
|
224
|
+
assert calls == 1
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
@pytest.mark.parametrize("kind", ["activity", "query", "workflow"])
|
|
228
|
+
async def test_runtime_payload_upload_and_acknowledgement_are_not_repeated(
|
|
229
|
+
kind: str, retry_sleeps: list[float],
|
|
230
|
+
) -> None:
|
|
231
|
+
server = FakeRuntimePayloadServer()
|
|
232
|
+
uploads: list[bytes] = []
|
|
233
|
+
acknowledgements: list[bytes] = []
|
|
234
|
+
|
|
235
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
236
|
+
if request.method == "POST" and request.url.path == "/api/external-payloads/v1":
|
|
237
|
+
uploads.append(request.content)
|
|
238
|
+
if len(uploads) <= 3:
|
|
239
|
+
return httpx.Response(503, json=pressure())
|
|
240
|
+
if request.url.path.endswith("/complete"):
|
|
241
|
+
acknowledgements.append(request.content)
|
|
242
|
+
if len(acknowledgements) <= 4:
|
|
243
|
+
return httpx.Response(503, json=pressure())
|
|
244
|
+
return httpx.Response(200, json={"completed": True})
|
|
245
|
+
return server.handler(request)
|
|
246
|
+
|
|
247
|
+
async with runtime_client(server) as client:
|
|
248
|
+
await client._http.aclose()
|
|
249
|
+
client._http = httpx.AsyncClient(base_url=client.base_url, transport=httpx.MockTransport(handler))
|
|
250
|
+
with worker_scope():
|
|
251
|
+
if kind == "activity":
|
|
252
|
+
await client.complete_activity_task(
|
|
253
|
+
task_id="task", activity_attempt_id="attempt-7", lease_owner="worker", result="payload" * 30,
|
|
254
|
+
)
|
|
255
|
+
elif kind == "query":
|
|
256
|
+
await client.complete_query_task(
|
|
257
|
+
query_task_id="task", query_task_attempt=7, lease_owner="worker", result="payload" * 30,
|
|
258
|
+
)
|
|
259
|
+
else:
|
|
260
|
+
await client.complete_workflow_task(
|
|
261
|
+
task_id="task", workflow_task_attempt=7, lease_owner="worker",
|
|
262
|
+
commands=[{"type": "complete_workflow", "result": serializer.envelope("payload" * 30)}],
|
|
263
|
+
)
|
|
264
|
+
assert len(uploads) == 4
|
|
265
|
+
assert len(set(uploads)) == 1
|
|
266
|
+
assert server.upload_count == 1
|
|
267
|
+
assert len(acknowledgements) == 5
|
|
268
|
+
assert len(set(acknowledgements)) == 1
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
@pytest.mark.parametrize("fails", [False, True])
|
|
272
|
+
async def test_running_worker_preserves_activity_outcome_without_reexecution(
|
|
273
|
+
fails: bool, retry_sleeps: list[float],
|
|
274
|
+
) -> None:
|
|
275
|
+
calls = 0
|
|
276
|
+
claims = 0
|
|
277
|
+
heartbeats = 0
|
|
278
|
+
acks: list[bytes] = []
|
|
279
|
+
completed = asyncio.Event()
|
|
280
|
+
|
|
281
|
+
@activity.defn(name="storage.activity")
|
|
282
|
+
async def receipt() -> dict[str, bool]:
|
|
283
|
+
nonlocal calls
|
|
284
|
+
calls += 1
|
|
285
|
+
await activity.context().heartbeat({"step": "receipt"})
|
|
286
|
+
if fails:
|
|
287
|
+
raise ValueError("intentional activity failure")
|
|
288
|
+
return {"receipt": True}
|
|
289
|
+
|
|
290
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
291
|
+
nonlocal claims, heartbeats
|
|
292
|
+
path = request.url.path
|
|
293
|
+
if path.endswith("/cluster/info"):
|
|
294
|
+
info = compatible_cluster_info()
|
|
295
|
+
info["worker_protocol"]["server_capabilities"]["query_tasks"] = False
|
|
296
|
+
return httpx.Response(200, json=info)
|
|
297
|
+
if path.endswith("/register") or request.method == "DELETE":
|
|
298
|
+
return httpx.Response(200, json={"registered": True})
|
|
299
|
+
if path.endswith("/activity-tasks/poll"):
|
|
300
|
+
claims += 1
|
|
301
|
+
if claims == 1:
|
|
302
|
+
return httpx.Response(200, json={"task": {
|
|
303
|
+
"task_id": "storage-task", "activity_type": "storage.activity",
|
|
304
|
+
"activity_attempt_id": "attempt-7", "attempt_number": 7,
|
|
305
|
+
"payload_codec": "avro", "arguments": serializer.envelope([]),
|
|
306
|
+
}})
|
|
307
|
+
if path.endswith("/storage-task/heartbeat"):
|
|
308
|
+
heartbeats += 1
|
|
309
|
+
if heartbeats <= 3:
|
|
310
|
+
return httpx.Response(503, json=pressure())
|
|
311
|
+
return httpx.Response(200, json={"cancel_requested": False})
|
|
312
|
+
if path.endswith("/storage-task/fail" if fails else "/storage-task/complete"):
|
|
313
|
+
acks.append(request.content)
|
|
314
|
+
if len(acks) <= 4:
|
|
315
|
+
return httpx.Response(503, json=pressure())
|
|
316
|
+
completed.set()
|
|
317
|
+
return httpx.Response(200, json={"recorded": True})
|
|
318
|
+
assert path.endswith("/poll"), f"Unexpected request: {request.method} {path}"
|
|
319
|
+
return httpx.Response(200, json={"task": None})
|
|
320
|
+
|
|
321
|
+
async with client_for(handler) as client:
|
|
322
|
+
worker = Worker(client, task_queue="orders", activities=[receipt], max_concurrent_activity_tasks=1)
|
|
323
|
+
run = asyncio.create_task(worker.run())
|
|
324
|
+
try:
|
|
325
|
+
await asyncio.wait_for(completed.wait(), 2)
|
|
326
|
+
finally:
|
|
327
|
+
await worker.stop()
|
|
328
|
+
await run
|
|
329
|
+
assert calls == 1
|
|
330
|
+
assert heartbeats == 4
|
|
331
|
+
assert len(acks) == 5
|
|
332
|
+
assert len(set(acks)) == 1
|
|
333
|
+
assert worker._act_semaphore._value == 1
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
async def test_query_thread_uses_pressure_retries(retry_sleeps: list[float]) -> None:
|
|
337
|
+
completed = threading.Event()
|
|
338
|
+
calls = 0
|
|
339
|
+
claims = 0
|
|
340
|
+
acks: list[bytes] = []
|
|
341
|
+
|
|
342
|
+
@workflow.defn(name="storage.query")
|
|
343
|
+
class QueryWorkflow:
|
|
344
|
+
def run(self, ctx):
|
|
345
|
+
yield ctx.sleep(30)
|
|
346
|
+
|
|
347
|
+
@workflow.query("status")
|
|
348
|
+
def status(self) -> str:
|
|
349
|
+
nonlocal calls
|
|
350
|
+
calls += 1
|
|
351
|
+
return "waiting"
|
|
352
|
+
|
|
353
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
354
|
+
nonlocal claims
|
|
355
|
+
path = request.url.path
|
|
356
|
+
if path.endswith("/query-tasks/poll"):
|
|
357
|
+
claims += 1
|
|
358
|
+
if claims == 1:
|
|
359
|
+
return httpx.Response(200, json={"task": {
|
|
360
|
+
"query_task_id": "query-task", "query_task_attempt": 7,
|
|
361
|
+
"workflow_type": "storage.query", "workflow_id": "workflow", "run_id": "run",
|
|
362
|
+
"query_name": "status", "payload_codec": "avro", "history_events": [],
|
|
363
|
+
"arguments": serializer.envelope([]),
|
|
364
|
+
}})
|
|
365
|
+
return httpx.Response(200, json={"task": None})
|
|
366
|
+
if path.endswith("/query-task/complete"):
|
|
367
|
+
acks.append(request.content)
|
|
368
|
+
if len(acks) <= 4:
|
|
369
|
+
return httpx.Response(503, json=pressure())
|
|
370
|
+
completed.set()
|
|
371
|
+
return httpx.Response(200, json={"completed": True})
|
|
372
|
+
raise AssertionError(f"Unexpected query request: {path}")
|
|
373
|
+
|
|
374
|
+
async with client_for(handler) as client:
|
|
375
|
+
worker = Worker(client, task_queue="orders", workflows=[QueryWorkflow])
|
|
376
|
+
worker._clone_client_for_query_tasks = lambda: client_for(handler)
|
|
377
|
+
worker._start_query_task_thread()
|
|
378
|
+
try:
|
|
379
|
+
assert await asyncio.to_thread(completed.wait, 2)
|
|
380
|
+
finally:
|
|
381
|
+
worker._request_query_task_thread_stop()
|
|
382
|
+
await worker._stop_query_task_thread(deadline=asyncio.get_running_loop().time() + 2)
|
|
383
|
+
assert calls == 1
|
|
384
|
+
assert len(acks) == 5
|
|
385
|
+
assert len(set(acks)) == 1
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
@pytest.mark.parametrize("phase", ["registration", "poll", "completion"])
|
|
389
|
+
async def test_run_until_timeout_interrupts_storage_pressure(phase: str) -> None:
|
|
390
|
+
refused = 0
|
|
391
|
+
deregistered = 0
|
|
392
|
+
|
|
393
|
+
@workflow.defn(name="storage.timeout")
|
|
394
|
+
class TimeoutWorkflow:
|
|
395
|
+
def run(self, ctx):
|
|
396
|
+
return "done"
|
|
397
|
+
|
|
398
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
399
|
+
nonlocal refused, deregistered
|
|
400
|
+
path = request.url.path
|
|
401
|
+
if path.endswith("/cluster/info"):
|
|
402
|
+
info = compatible_cluster_info()
|
|
403
|
+
info["worker_protocol"]["server_capabilities"]["query_tasks"] = False
|
|
404
|
+
return httpx.Response(200, json=info)
|
|
405
|
+
if request.method == "DELETE":
|
|
406
|
+
deregistered += 1
|
|
407
|
+
return httpx.Response(200, json={})
|
|
408
|
+
if (
|
|
409
|
+
(phase == "registration" and path.endswith("/register"))
|
|
410
|
+
or (phase == "poll" and path.endswith("/poll"))
|
|
411
|
+
or (phase == "completion" and path.endswith("/complete"))
|
|
412
|
+
):
|
|
413
|
+
refused += 1
|
|
414
|
+
poll_id = json.loads(request.content)["poll_request_id"] if phase == "poll" else None
|
|
415
|
+
return httpx.Response(503, json=pressure(poll_id))
|
|
416
|
+
if path.endswith("/register"):
|
|
417
|
+
return httpx.Response(200, json={})
|
|
418
|
+
if path.endswith("/workflows/storage-timeout"):
|
|
419
|
+
return httpx.Response(200, json={"status": "running"})
|
|
420
|
+
if path.endswith("/workflow-tasks/poll"):
|
|
421
|
+
return httpx.Response(200, json={"task": {
|
|
422
|
+
"task_id": "task", "workflow_task_attempt": 1, "workflow_type": "storage.timeout",
|
|
423
|
+
"payload_codec": "avro", "arguments": serializer.envelope([]), "history_events": [],
|
|
424
|
+
}})
|
|
425
|
+
raise AssertionError(f"Unexpected request: {path}")
|
|
426
|
+
|
|
427
|
+
async with client_for(handler) as client:
|
|
428
|
+
worker = Worker(client, task_queue="orders", workflows=[TimeoutWorkflow], shutdown_timeout=0.2)
|
|
429
|
+
with pytest.raises(TimeoutError, match="storage-timeout"):
|
|
430
|
+
await asyncio.wait_for(worker.run_until(workflow_id="storage-timeout", timeout=0.05), 1)
|
|
431
|
+
assert refused == 1
|
|
432
|
+
assert deregistered == (0 if phase == "registration" else 1)
|
|
433
|
+
assert worker._stop.is_set()
|
|
434
|
+
assert worker._registration_done.is_set()
|
|
435
|
+
assert worker._workflow_reserved == 0
|
|
436
|
+
assert not worker._in_flight
|
|
437
|
+
assert _worker_storage_admission_stop.get() is None
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
async def test_stop_interrupts_registration_pressure() -> None:
|
|
441
|
+
refused = asyncio.Event()
|
|
442
|
+
calls = 0
|
|
443
|
+
|
|
444
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
445
|
+
nonlocal calls
|
|
446
|
+
if request.url.path.endswith("/cluster/info"):
|
|
447
|
+
return httpx.Response(200, json=compatible_cluster_info())
|
|
448
|
+
assert request.url.path.endswith("/register")
|
|
449
|
+
calls += 1
|
|
450
|
+
refused.set()
|
|
451
|
+
return httpx.Response(503, json=pressure())
|
|
452
|
+
|
|
453
|
+
async with client_for(handler) as client:
|
|
454
|
+
worker = Worker(client, task_queue="orders")
|
|
455
|
+
run = asyncio.create_task(worker.run())
|
|
456
|
+
try:
|
|
457
|
+
await asyncio.wait_for(refused.wait(), 1)
|
|
458
|
+
finally:
|
|
459
|
+
await asyncio.wait_for(worker.stop(), 1)
|
|
460
|
+
with pytest.raises(ServerError):
|
|
461
|
+
await run
|
|
462
|
+
assert calls == 1
|
|
463
|
+
assert worker._registration_done.is_set()
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
@pytest.mark.parametrize("status,reason", [(401, "unauthenticated"), (409, "activity_attempt_expired")])
|
|
467
|
+
async def test_recovery_does_not_retry_auth_or_lease_rejection(
|
|
468
|
+
status: int, reason: str, retry_sleeps: list[float],
|
|
469
|
+
) -> None:
|
|
470
|
+
requests: list[bytes] = []
|
|
471
|
+
|
|
472
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
473
|
+
requests.append(request.content)
|
|
474
|
+
if len(requests) <= 3:
|
|
475
|
+
return httpx.Response(503, json=pressure())
|
|
476
|
+
return httpx.Response(status, json={"reason": reason})
|
|
477
|
+
|
|
478
|
+
async with client_for(handler) as client:
|
|
479
|
+
with worker_scope(), pytest.raises(Unauthorized if status == 401 else ServerError) as error:
|
|
480
|
+
await client.complete_activity_task(
|
|
481
|
+
task_id="task", activity_attempt_id="attempt-7", lease_owner="worker", result="receipt",
|
|
482
|
+
)
|
|
483
|
+
if isinstance(error.value, ServerError):
|
|
484
|
+
assert error.value.status == status
|
|
485
|
+
assert error.value.reason() == reason
|
|
486
|
+
assert len(requests) == 4
|
|
487
|
+
assert len(set(requests)) == 1
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
async def test_legacy_validation_poll_without_identity_fails_closed(retry_sleeps: list[float]) -> None:
|
|
491
|
+
calls = 0
|
|
492
|
+
|
|
493
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
494
|
+
nonlocal calls
|
|
495
|
+
calls += 1
|
|
496
|
+
body = pressure("")
|
|
497
|
+
body["poll_request_id"] = None
|
|
498
|
+
return httpx.Response(503, json=body)
|
|
499
|
+
|
|
500
|
+
async with client_for(handler) as client:
|
|
501
|
+
with worker_scope(), pytest.raises(ServerError):
|
|
502
|
+
await client.poll_update_validation_task(worker_id="worker", task_queue="orders")
|
|
503
|
+
assert calls == 1
|
|
504
|
+
assert not retry_sleeps
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
@pytest.mark.parametrize("approved", [True, False])
|
|
508
|
+
async def test_update_validator_is_not_reexecuted_for_acknowledgement(
|
|
509
|
+
approved: bool, retry_sleeps: list[float],
|
|
510
|
+
) -> None:
|
|
511
|
+
ValidatedUpdateWorkflow.validator_calls = 0
|
|
512
|
+
ValidatedUpdateWorkflow.handler_calls = 0
|
|
513
|
+
requests: list[bytes] = []
|
|
514
|
+
|
|
515
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
516
|
+
assert request.url.path.endswith("/approve" if approved else "/reject")
|
|
517
|
+
requests.append(request.content)
|
|
518
|
+
if len(requests) <= 4:
|
|
519
|
+
return httpx.Response(503, json=pressure())
|
|
520
|
+
return httpx.Response(200, json={})
|
|
521
|
+
|
|
522
|
+
async with client_for(handler) as client:
|
|
523
|
+
worker = Worker(client, task_queue="orders", workflows=[ValidatedUpdateWorkflow])
|
|
524
|
+
with worker_scope():
|
|
525
|
+
outcome = await worker._run_update_validation_task({
|
|
526
|
+
"update_validation_task_id": "validation-task", "update_validation_attempt": 7,
|
|
527
|
+
"workflow_type": "validated-update-wf", "update_name": "approve",
|
|
528
|
+
"history_events": [], "workflow_arguments": serializer.envelope([]),
|
|
529
|
+
"update_arguments": serializer.envelope([approved]), "payload_codec": "avro",
|
|
530
|
+
})
|
|
531
|
+
assert outcome == ("approved" if approved else "rejected")
|
|
532
|
+
assert ValidatedUpdateWorkflow.validator_calls == 1
|
|
533
|
+
assert ValidatedUpdateWorkflow.handler_calls == 0
|
|
534
|
+
assert len(requests) == 5
|
|
535
|
+
assert len(set(requests)) == 1
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
async def test_cold_replay_completion_retries_only_its_prepared_acknowledgement(
|
|
539
|
+
retry_sleeps: list[float], monkeypatch: pytest.MonkeyPatch,
|
|
540
|
+
) -> None:
|
|
541
|
+
from tests.test_golden_history_replay import GoldenSingleActivityWorkflow
|
|
542
|
+
|
|
543
|
+
path = Path(__file__).parent / "fixtures/replay_regressions/storage-paused-cold-completion.json"
|
|
544
|
+
fixture = json.loads(path.read_text())
|
|
545
|
+
replays = 0
|
|
546
|
+
original = GoldenSingleActivityWorkflow.run
|
|
547
|
+
|
|
548
|
+
def counted_run(self, *args):
|
|
549
|
+
nonlocal replays
|
|
550
|
+
replays += 1
|
|
551
|
+
return original(self, *args)
|
|
552
|
+
|
|
553
|
+
monkeypatch.setattr(GoldenSingleActivityWorkflow, "run", counted_run)
|
|
554
|
+
requests: list[bytes] = []
|
|
555
|
+
completed = False
|
|
556
|
+
|
|
557
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
558
|
+
nonlocal completed
|
|
559
|
+
path = request.url.path
|
|
560
|
+
if path.endswith("/cluster/info"):
|
|
561
|
+
info = compatible_cluster_info()
|
|
562
|
+
info["worker_protocol"]["server_capabilities"]["query_tasks"] = False
|
|
563
|
+
return httpx.Response(200, json=info)
|
|
564
|
+
if path.endswith("/register") or request.method == "DELETE":
|
|
565
|
+
return httpx.Response(200, json={})
|
|
566
|
+
if path.endswith("/workflows/cold-replay"):
|
|
567
|
+
return httpx.Response(200, json={"status": "completed" if completed else "running"})
|
|
568
|
+
if path.endswith("/workflow-tasks/poll"):
|
|
569
|
+
return httpx.Response(200, json={"task": {
|
|
570
|
+
"task_id": "task", "workflow_task_attempt": 7, "workflow_type": fixture["workflow"]["type"],
|
|
571
|
+
"payload_codec": "avro", "arguments": serializer.envelope(fixture["workflow"]["input"]),
|
|
572
|
+
"history_events": fixture["history"],
|
|
573
|
+
}})
|
|
574
|
+
assert path.endswith("/workflow-tasks/task/complete"), f"Unexpected request: {path}"
|
|
575
|
+
requests.append(request.content)
|
|
576
|
+
if len(requests) <= 7:
|
|
577
|
+
return httpx.Response(503, json=pressure())
|
|
578
|
+
completed = True
|
|
579
|
+
return httpx.Response(200, json={})
|
|
580
|
+
|
|
581
|
+
async with client_for(handler) as client:
|
|
582
|
+
worker = Worker(client, task_queue="orders", workflows=[GoldenSingleActivityWorkflow])
|
|
583
|
+
result = await worker.run_until(workflow_id="cold-replay", timeout=2)
|
|
584
|
+
assert result.status == "completed"
|
|
585
|
+
assert replays == 1
|
|
586
|
+
assert len(requests) == 8
|
|
587
|
+
assert len(set(requests)) == 1
|
|
588
|
+
command, = json.loads(requests[0])["commands"]
|
|
589
|
+
assert command["type"] == "complete_workflow"
|
|
590
|
+
assert serializer.decode_envelope(command["result"]) == fixture["expected"]["result"]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{durable_workflow-2.0.2 → durable_workflow-2.0.3}/src/durable_workflow/external_task_input.py
RENAMED
|
File without changes
|
{durable_workflow-2.0.2 → durable_workflow-2.0.3}/src/durable_workflow/external_task_result.py
RENAMED
|
File without changes
|
{durable_workflow-2.0.2 → durable_workflow-2.0.3}/src/durable_workflow/history_bundle_verify.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{durable_workflow-2.0.2 → durable_workflow-2.0.3}/src/durable_workflow/python_conformance.py
RENAMED
|
File without changes
|
{durable_workflow-2.0.2 → durable_workflow-2.0.3}/src/durable_workflow/replay_conformance.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{durable_workflow-2.0.2 → durable_workflow-2.0.3}/src/durable_workflow.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{durable_workflow-2.0.2 → durable_workflow-2.0.3}/src/durable_workflow.egg-info/entry_points.txt
RENAMED
|
File without changes
|
{durable_workflow-2.0.2 → durable_workflow-2.0.3}/src/durable_workflow.egg-info/requires.txt
RENAMED
|
File without changes
|
{durable_workflow-2.0.2 → durable_workflow-2.0.3}/src/durable_workflow.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{durable_workflow-2.0.2 → durable_workflow-2.0.3}/tests/test_control_plane_parity_fixtures.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{durable_workflow-2.0.2 → durable_workflow-2.0.3}/tests/test_parallel_completion_order_replay.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{durable_workflow-2.0.2 → durable_workflow-2.0.3}/tests/test_runtime_external_payload_transport.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{durable_workflow-2.0.2 → durable_workflow-2.0.3}/tests/test_update_signal_condition_replay.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{durable_workflow-2.0.2 → durable_workflow-2.0.3}/tests/test_workflow_updates_conformance.py
RENAMED
|
File without changes
|