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.
Files changed (87) hide show
  1. {durable_workflow-2.0.2/src/durable_workflow.egg-info → durable_workflow-2.0.3}/PKG-INFO +1 -1
  2. {durable_workflow-2.0.2 → durable_workflow-2.0.3}/pyproject.toml +3 -3
  3. {durable_workflow-2.0.2 → durable_workflow-2.0.3}/src/durable_workflow/errors.py +26 -0
  4. {durable_workflow-2.0.2 → durable_workflow-2.0.3}/src/durable_workflow/retry_policy.py +63 -0
  5. {durable_workflow-2.0.2 → durable_workflow-2.0.3}/src/durable_workflow/worker.py +62 -8
  6. {durable_workflow-2.0.2 → durable_workflow-2.0.3/src/durable_workflow.egg-info}/PKG-INFO +1 -1
  7. {durable_workflow-2.0.2 → durable_workflow-2.0.3}/src/durable_workflow.egg-info/SOURCES.txt +1 -0
  8. {durable_workflow-2.0.2 → durable_workflow-2.0.3}/tests/test_release_metadata.py +2 -2
  9. durable_workflow-2.0.3/tests/test_storage_admission.py +590 -0
  10. {durable_workflow-2.0.2 → durable_workflow-2.0.3}/LICENSE +0 -0
  11. {durable_workflow-2.0.2 → durable_workflow-2.0.3}/README.md +0 -0
  12. {durable_workflow-2.0.2 → durable_workflow-2.0.3}/setup.cfg +0 -0
  13. {durable_workflow-2.0.2 → durable_workflow-2.0.3}/src/durable_workflow/__init__.py +0 -0
  14. {durable_workflow-2.0.2 → durable_workflow-2.0.3}/src/durable_workflow/_avro.py +0 -0
  15. {durable_workflow-2.0.2 → durable_workflow-2.0.3}/src/durable_workflow/activity.py +0 -0
  16. {durable_workflow-2.0.2 → durable_workflow-2.0.3}/src/durable_workflow/auth_composition.py +0 -0
  17. {durable_workflow-2.0.2 → durable_workflow-2.0.3}/src/durable_workflow/client.py +0 -0
  18. {durable_workflow-2.0.2 → durable_workflow-2.0.3}/src/durable_workflow/external_storage.py +0 -0
  19. {durable_workflow-2.0.2 → durable_workflow-2.0.3}/src/durable_workflow/external_task_input.py +0 -0
  20. {durable_workflow-2.0.2 → durable_workflow-2.0.3}/src/durable_workflow/external_task_result.py +0 -0
  21. {durable_workflow-2.0.2 → durable_workflow-2.0.3}/src/durable_workflow/history_bundle_verify.py +0 -0
  22. {durable_workflow-2.0.2 → durable_workflow-2.0.3}/src/durable_workflow/interceptors.py +0 -0
  23. {durable_workflow-2.0.2 → durable_workflow-2.0.3}/src/durable_workflow/invocable.py +0 -0
  24. {durable_workflow-2.0.2 → durable_workflow-2.0.3}/src/durable_workflow/metrics.py +0 -0
  25. {durable_workflow-2.0.2 → durable_workflow-2.0.3}/src/durable_workflow/nexus.py +0 -0
  26. {durable_workflow-2.0.2 → durable_workflow-2.0.3}/src/durable_workflow/py.typed +0 -0
  27. {durable_workflow-2.0.2 → durable_workflow-2.0.3}/src/durable_workflow/python_conformance.py +0 -0
  28. {durable_workflow-2.0.2 → durable_workflow-2.0.3}/src/durable_workflow/replay_conformance.py +0 -0
  29. {durable_workflow-2.0.2 → durable_workflow-2.0.3}/src/durable_workflow/replay_verify.py +0 -0
  30. {durable_workflow-2.0.2 → durable_workflow-2.0.3}/src/durable_workflow/serializer.py +0 -0
  31. {durable_workflow-2.0.2 → durable_workflow-2.0.3}/src/durable_workflow/sync.py +0 -0
  32. {durable_workflow-2.0.2 → durable_workflow-2.0.3}/src/durable_workflow/testing.py +0 -0
  33. {durable_workflow-2.0.2 → durable_workflow-2.0.3}/src/durable_workflow/workflow.py +0 -0
  34. {durable_workflow-2.0.2 → durable_workflow-2.0.3}/src/durable_workflow/workflow_updates_conformance.py +0 -0
  35. {durable_workflow-2.0.2 → durable_workflow-2.0.3}/src/durable_workflow.egg-info/dependency_links.txt +0 -0
  36. {durable_workflow-2.0.2 → durable_workflow-2.0.3}/src/durable_workflow.egg-info/entry_points.txt +0 -0
  37. {durable_workflow-2.0.2 → durable_workflow-2.0.3}/src/durable_workflow.egg-info/requires.txt +0 -0
  38. {durable_workflow-2.0.2 → durable_workflow-2.0.3}/src/durable_workflow.egg-info/top_level.txt +0 -0
  39. {durable_workflow-2.0.2 → durable_workflow-2.0.3}/tests/test_activity_context.py +0 -0
  40. {durable_workflow-2.0.2 → durable_workflow-2.0.3}/tests/test_auth_composition.py +0 -0
  41. {durable_workflow-2.0.2 → durable_workflow-2.0.3}/tests/test_avro_value_benchmark.py +0 -0
  42. {durable_workflow-2.0.2 → durable_workflow-2.0.3}/tests/test_avro_value_protocol.py +0 -0
  43. {durable_workflow-2.0.2 → durable_workflow-2.0.3}/tests/test_ci_checkout.py +0 -0
  44. {durable_workflow-2.0.2 → durable_workflow-2.0.3}/tests/test_ci_integration_endpoint.py +0 -0
  45. {durable_workflow-2.0.2 → durable_workflow-2.0.3}/tests/test_client.py +0 -0
  46. {durable_workflow-2.0.2 → durable_workflow-2.0.3}/tests/test_codec_regression_corpus.py +0 -0
  47. {durable_workflow-2.0.2 → durable_workflow-2.0.3}/tests/test_control_plane_parity_fixtures.py +0 -0
  48. {durable_workflow-2.0.2 → durable_workflow-2.0.3}/tests/test_docstring_cross_references.py +0 -0
  49. {durable_workflow-2.0.2 → durable_workflow-2.0.3}/tests/test_durable_selection.py +0 -0
  50. {durable_workflow-2.0.2 → durable_workflow-2.0.3}/tests/test_errors.py +0 -0
  51. {durable_workflow-2.0.2 → durable_workflow-2.0.3}/tests/test_external_storage.py +0 -0
  52. {durable_workflow-2.0.2 → durable_workflow-2.0.3}/tests/test_external_task_input.py +0 -0
  53. {durable_workflow-2.0.2 → durable_workflow-2.0.3}/tests/test_external_task_result.py +0 -0
  54. {durable_workflow-2.0.2 → durable_workflow-2.0.3}/tests/test_golden_history_replay.py +0 -0
  55. {durable_workflow-2.0.2 → durable_workflow-2.0.3}/tests/test_history_bundle_verify.py +0 -0
  56. {durable_workflow-2.0.2 → durable_workflow-2.0.3}/tests/test_history_event_contract.py +0 -0
  57. {durable_workflow-2.0.2 → durable_workflow-2.0.3}/tests/test_invocable.py +0 -0
  58. {durable_workflow-2.0.2 → durable_workflow-2.0.3}/tests/test_message_streams.py +0 -0
  59. {durable_workflow-2.0.2 → durable_workflow-2.0.3}/tests/test_metrics.py +0 -0
  60. {durable_workflow-2.0.2 → durable_workflow-2.0.3}/tests/test_order_processing_example.py +0 -0
  61. {durable_workflow-2.0.2 → durable_workflow-2.0.3}/tests/test_parallel_completion_order_replay.py +0 -0
  62. {durable_workflow-2.0.2 → durable_workflow-2.0.3}/tests/test_parallel_saga_authoring.py +0 -0
  63. {durable_workflow-2.0.2 → durable_workflow-2.0.3}/tests/test_public_boundary_scanner.py +0 -0
  64. {durable_workflow-2.0.2 → durable_workflow-2.0.3}/tests/test_pypi_project_surface.py +0 -0
  65. {durable_workflow-2.0.2 → durable_workflow-2.0.3}/tests/test_python_conformance.py +0 -0
  66. {durable_workflow-2.0.2 → durable_workflow-2.0.3}/tests/test_queries.py +0 -0
  67. {durable_workflow-2.0.2 → durable_workflow-2.0.3}/tests/test_readme_quickstart.py +0 -0
  68. {durable_workflow-2.0.2 → durable_workflow-2.0.3}/tests/test_replay.py +0 -0
  69. {durable_workflow-2.0.2 → durable_workflow-2.0.3}/tests/test_replay_conformance.py +0 -0
  70. {durable_workflow-2.0.2 → durable_workflow-2.0.3}/tests/test_replay_regression_corpus.py +0 -0
  71. {durable_workflow-2.0.2 → durable_workflow-2.0.3}/tests/test_replay_verify.py +0 -0
  72. {durable_workflow-2.0.2 → durable_workflow-2.0.3}/tests/test_retry_policy.py +0 -0
  73. {durable_workflow-2.0.2 → durable_workflow-2.0.3}/tests/test_runtime_external_payload_transport.py +0 -0
  74. {durable_workflow-2.0.2 → durable_workflow-2.0.3}/tests/test_schedules.py +0 -0
  75. {durable_workflow-2.0.2 → durable_workflow-2.0.3}/tests/test_serializer.py +0 -0
  76. {durable_workflow-2.0.2 → durable_workflow-2.0.3}/tests/test_signals.py +0 -0
  77. {durable_workflow-2.0.2 → durable_workflow-2.0.3}/tests/test_sleep.py +0 -0
  78. {durable_workflow-2.0.2 → durable_workflow-2.0.3}/tests/test_standalone_activity_client.py +0 -0
  79. {durable_workflow-2.0.2 → durable_workflow-2.0.3}/tests/test_sync.py +0 -0
  80. {durable_workflow-2.0.2 → durable_workflow-2.0.3}/tests/test_testing_harness.py +0 -0
  81. {durable_workflow-2.0.2 → durable_workflow-2.0.3}/tests/test_update_signal_condition_replay.py +0 -0
  82. {durable_workflow-2.0.2 → durable_workflow-2.0.3}/tests/test_updates.py +0 -0
  83. {durable_workflow-2.0.2 → durable_workflow-2.0.3}/tests/test_wait_condition.py +0 -0
  84. {durable_workflow-2.0.2 → durable_workflow-2.0.3}/tests/test_worker.py +0 -0
  85. {durable_workflow-2.0.2 → durable_workflow-2.0.3}/tests/test_workflow_result_timeout.py +0 -0
  86. {durable_workflow-2.0.2 → durable_workflow-2.0.3}/tests/test_workflow_streams.py +0 -0
  87. {durable_workflow-2.0.2 → durable_workflow-2.0.3}/tests/test_workflow_updates_conformance.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: durable-workflow
3
- Version: 2.0.2
3
+ Version: 2.0.3
4
4
  Summary: Python client and worker SDK for Durable Workflow Cloud and self-hosted Server
5
5
  Author: Durable Workflow Contributors
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "durable-workflow"
7
- version = "2.0.2"
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.2"
75
- registry-version = "2.0.2"
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
- await self._register()
2724
- finally:
2725
- self._registration_done.set()
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 run_until_loop
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: durable-workflow
3
- Version: 2.0.2
3
+ Version: 2.0.3
4
4
  Summary: Python client and worker SDK for Durable Workflow Cloud and self-hosted Server
5
5
  Author: Durable Workflow Contributors
6
6
  License-Expression: MIT
@@ -73,6 +73,7 @@ tests/test_serializer.py
73
73
  tests/test_signals.py
74
74
  tests/test_sleep.py
75
75
  tests/test_standalone_activity_client.py
76
+ tests/test_storage_admission.py
76
77
  tests/test_sync.py
77
78
  tests/test_testing_harness.py
78
79
  tests/test_update_signal_condition_replay.py
@@ -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.2"
44
+ assert project["version"] == "2.0.3"
45
45
  assert release["product-train"] == project["version"]
46
- assert release["registry-version"] == "2.0.2"
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"]