Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions src/durable_workflow/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,32 @@ def reason(self) -> str | None:
return self.body.get("reason")
return None

def is_storage_admission_failure(self, poll_request_id: str | None = None) -> bool:
"""Whether the runtime explicitly refused admission and requested an identity-preserving retry."""
body = self.body
if (
self.status != 503
or not isinstance(body, dict)
or self.reason() not in ("storage_pressure", "storage_admission_unavailable")
or body.get("retryable") is not True
or type(body.get("retry_after_seconds")) is not int
or body["retry_after_seconds"] <= 0
or body.get("storage_state") not in ("draining", "fenced")
or (self.reason() == "storage_admission_unavailable" and body["storage_state"] != "fenced")
or ("request_admitted" in body and body["request_admitted"] is not False)
):
return False
if poll_request_id is None:
return body.get("request_admitted") is False
return (
bool(poll_request_id)
and "task" in body and body["task"] is None
and body.get("poll_status") == self.reason()
and body.get("poll_request_id") == poll_request_id
and body.get("retry_same_poll_request_id") is True
and body.get("claim_admitted") is False
)


class NexusOperationFailed(DurableWorkflowError):
"""A Nexus service operation completed with a typed service failure."""
Expand Down
63 changes: 63 additions & 0 deletions src/durable_workflow/retry_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,50 @@
from __future__ import annotations

import asyncio
import contextvars
import json
import logging
import random
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import TypeVar

import httpx

from .errors import ServerError

T = TypeVar("T")
log = logging.getLogger("durable_workflow.worker")

# Task-local so sharing a Client never changes unrelated client/control requests.
_worker_storage_admission_stop: contextvars.ContextVar[Callable[[], bool] | None] = contextvars.ContextVar(
"worker_storage_admission_stop", default=None,
)


def _storage_refusal(exc: Exception) -> tuple[ServerError, str | None] | None:
if not isinstance(exc, httpx.HTTPStatusError):
return None
if "X-Durable-Workflow-Protocol-Version" not in exc.request.headers:
return None
try:
body = exc.response.json()
except ValueError:
return None
error = ServerError(exc.response.status_code, body)
if error.reason() not in ("storage_pressure", "storage_admission_unavailable"):
return None
poll_id = None
if exc.request.url.path.endswith("/poll"):
try:
request = json.loads(exc.request.content)
poll_id = request.get("poll_request_id") if isinstance(request, dict) else None
except ValueError:
pass
# An invalid submitted ID must not fall through to the non-poll contract.
if not isinstance(poll_id, str) or not poll_id:
poll_id = ""
return error, poll_id


@dataclass
Expand Down Expand Up @@ -81,6 +117,7 @@ async def execute(self, fn: Callable[[], Awaitable[T]]) -> T:
Raises the last exception if all retries are exhausted.
"""
attempt = 0
storage_attempt = 0
last_exc: Exception | None = None

while attempt < self.max_attempts:
Expand All @@ -89,6 +126,32 @@ async def execute(self, fn: Callable[[], Awaitable[T]]) -> T:
return result
except Exception as exc:
last_exc = exc
stop = _worker_storage_admission_stop.get()
refusal = _storage_refusal(exc) if stop is not None else None
if refusal is not None and stop is not None:
error, poll_id = refusal
if not error.is_storage_admission_failure(poll_id) or stop():
raise
storage_attempt += 1
assert isinstance(error.body, dict)
delay = min(
5.0,
max(
self.backoff_seconds(min(storage_attempt - 1, 6)),
error.body["retry_after_seconds"],
),
)
log.warning("storage admission paused; retrying the same worker request in %.2fs", delay)
# Do not consume the finite transport budget or repeat serialization/uploads.
while delay > 0:
if stop():
raise
interval = min(0.1, delay)
await asyncio.sleep(interval)
delay -= interval
if stop():
raise
continue
if not self.should_retry(exc, attempt):
raise

Expand Down
70 changes: 62 additions & 8 deletions src/durable_workflow/worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,11 @@
import traceback
import types
import uuid
from collections.abc import Awaitable, Callable, Iterable, Mapping
from collections.abc import Awaitable, Callable, Coroutine, Iterable, Mapping
from datetime import datetime, timezone
from functools import wraps
from types import FunctionType
from typing import Annotated, Any, Literal, Union, get_args, get_origin, get_type_hints
from typing import Annotated, Any, Concatenate, Literal, ParamSpec, TypeVar, Union, get_args, get_origin, get_type_hints

from . import serializer
from .activity import ActivityContext, ActivityInfo, _set_context
Expand Down Expand Up @@ -75,6 +76,7 @@
WORKER_TASKS,
MetricsRecorder,
)
from .retry_policy import _worker_storage_admission_stop
from .workflow import (
Command,
NexusServiceCall,
Expand Down Expand Up @@ -137,6 +139,27 @@
_WORKFLOW_TASK_COMPLETION_RETRY_DELAYS = (0.05, 0.2)
_WORKFLOW_TASK_NEXUS_RESOLUTION_LIMIT = 100
_WORKER_WORKFLOW_FINGERPRINTS: dict[tuple[str, str], str] = {}
_P = ParamSpec("_P")
_R = TypeVar("_R")


def _with_storage_admission_retries(
fn: Callable[Concatenate[Worker, _P], Coroutine[Any, Any, _R]],
) -> Callable[Concatenate[Worker, _P], Coroutine[Any, Any, _R]]:
@wraps(fn)
async def run(self: Worker, /, *args: _P.args, **kwargs: _P.kwargs) -> _R:
token = _worker_storage_admission_stop.set(self._stop.is_set)
try:
return await fn(self, *args, **kwargs)
finally:
_worker_storage_admission_stop.reset(token)
return run


def _is_storage_admission_error(error: BaseException) -> bool:
return isinstance(error, ServerError) and error.reason() in (
"storage_pressure", "storage_admission_unavailable",
)


def _command_payload_codec(codec: object) -> str:
Expand Down Expand Up @@ -208,6 +231,8 @@ def _should_fail_workflow_task_after_completion_error(error: BaseException) -> b


def _should_retry_workflow_task_completion_error(error: BaseException) -> bool:
if _is_storage_admission_error(error):
return False
if isinstance(error, ServerError):
return error.status >= 500 or error.status == 429

Expand Down Expand Up @@ -1424,6 +1449,8 @@ async def _run_workflow_task_core(self, task: dict[str, Any]) -> list[dict[str,
)
except Exception as e:
log.warning("failed to complete workflow update task %s: %s", task_id, e)
if _is_storage_admission_error(e):
return None
if _should_fail_workflow_task_after_completion_error(e):
await self._report_workflow_task_after_completion_error(task_id, attempt, e)
return None
Expand Down Expand Up @@ -1550,6 +1577,8 @@ async def _run_workflow_task_core(self, task: dict[str, Any]) -> list[dict[str,
)
except Exception as e:
log.warning("failed to complete workflow task %s: %s", task_id, e)
if _is_storage_admission_error(e):
return None
if _should_fail_workflow_task_after_completion_error(e):
await self._report_workflow_task_after_completion_error(task_id, attempt, e)
return None
Expand Down Expand Up @@ -1736,6 +1765,8 @@ async def _run_activity_task(self, task: dict[str, Any]) -> str:
log.warning("failed to report activity failure: %s", fe)
return "failed_non_retryable"
except Exception as e:
if _is_storage_admission_error(e):
raise
log.exception("activity failed")
try:
await self.client.fail_activity_task(
Expand Down Expand Up @@ -1971,6 +2002,9 @@ async def _run_query_task_core(self, task: dict[str, Any], *, client: Client | N
**self._external_storage_completion_kwargs(),
)
except ServerError as e:
if _is_storage_admission_error(e):
log.warning("query task %s acknowledgement paused: %s", query_task_id, e)
return "complete_error"
if _is_final_query_task_rejection(e):
log.info(
"query task %s completion was rejected after the task ended server-side: %s",
Expand Down Expand Up @@ -2147,6 +2181,8 @@ async def _poll_workflow_tasks(self) -> None:
self._release_workflow_capacity()
if self._stop.is_set():
return
if _is_storage_admission_error(e):
raise
self._record_poll_metrics("workflow", "error", time.perf_counter() - poll_start)
log.warning("workflow poll error: %s", e)
await asyncio.sleep(1.0)
Expand Down Expand Up @@ -2236,10 +2272,15 @@ async def _poll_activity_tasks(self) -> None:
timeout=self._poll_http_timeout,
build_id=self.build_id,
)
except asyncio.CancelledError:
self._act_semaphore.release()
raise
except Exception as e:
self._act_semaphore.release()
if self._stop.is_set():
return
if _is_storage_admission_error(e):
raise
self._record_poll_metrics("activity", "error", time.perf_counter() - poll_start)
log.warning("activity poll error: %s", e)
await asyncio.sleep(1.0)
Expand Down Expand Up @@ -2289,6 +2330,8 @@ async def _poll_query_tasks(self, *, client: Client | None = None, track_tasks:
query_thread_stop is not None and query_thread_stop.is_set()
):
return
if _is_storage_admission_error(e):
raise
self._record_poll_metrics("query", "error", time.perf_counter() - poll_start)
log.warning("query poll error: %s", e)
await asyncio.sleep(1.0)
Expand Down Expand Up @@ -2525,6 +2568,7 @@ def _run_query_task_thread(self) -> None:
except Exception:
log.exception("query task poller thread stopped unexpectedly")

@_with_storage_admission_retries
async def _query_task_thread_main(self) -> None:
loop = asyncio.get_running_loop()
task = asyncio.current_task()
Expand Down Expand Up @@ -2562,6 +2606,7 @@ async def _stop_query_task_thread(self, *, deadline: float) -> None:
"the worker registration remains active"
)

@_with_storage_admission_retries
async def run(self) -> None:
"""Register the worker and poll until `stop()` is called or the task is cancelled."""
self._begin_run()
Expand Down Expand Up @@ -2622,6 +2667,8 @@ async def _heartbeat_loop(self) -> None:
process_metrics=self._current_process_metrics(),
)
except Exception as e:
if _is_storage_admission_error(e) and not self._stop.is_set():
raise
log.warning("worker heartbeat failed: %s", e)
continue
if isinstance(ack, dict):
Expand Down Expand Up @@ -2703,6 +2750,7 @@ def _current_process_metrics(self) -> dict[str, Any]:

return metrics

@_with_storage_admission_retries
async def run_until(
self,
*,
Expand All @@ -2714,17 +2762,19 @@ async def run_until(

This is intended for examples, smoke tests, and single-workflow scripts.
Long-running workers should call :meth:`run` and coordinate shutdown from
their process supervisor.
their process supervisor. ``timeout`` includes registration and runtime
admission pauses; shutdown retains its separate drain timeout.
"""
background_tasks: list[asyncio.Task[Any]] = []
deadline = asyncio.get_running_loop().time() + timeout

self._begin_run()
try:
await self._register()
finally:
self._registration_done.set()
try:
await asyncio.wait_for(self._register(), timeout=timeout)
finally:
self._registration_done.set()

try:
if self._stop.is_set():
raise asyncio.CancelledError
background_tasks.append(asyncio.create_task(self._heartbeat_loop()))
Expand All @@ -2742,7 +2792,11 @@ async def run_until(
)
)
self._poller_tasks.add(run_until_loop)
return await run_until_loop
return await asyncio.wait_for(
run_until_loop, timeout=max(0.0, deadline - asyncio.get_running_loop().time()),
)
except asyncio.TimeoutError as error:
raise TimeoutError(f"workflow {workflow_id} not terminal after {timeout}s") from error
finally:
primary_error = sys.exc_info()[1]
primary_traceback = primary_error.__traceback__ if primary_error is not None else None
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"$schema": "https://raw.githubusercontent.com/durable-workflow/.github/main/regression-corpus/evidence-schema.json",
"fixture_schema": "durable-workflow.replay-regression/v1",
"id": "storage-paused-cold-completion",
"protocol_version": "1.19",
"bindings": ["python"],
"workflow": {
"type": "golden.single-activity",
"input": ["Ada"],
"payload_codec": "avro"
},
"history": [
{"event_type": "WorkflowStarted", "payload": {"payload_codec": "avro"}},
{"event_type": "ActivityScheduled", "payload": {"sequence": 1, "activity_type": "golden.greet"}},
{"event_type": "ActivityCompleted", "payload": {"sequence": 1, "result": "wwHioz3/VYAiNwoSaGVsbG8gQWRh"}}
],
"expected": {"command_type": "CompleteWorkflow", "result": "hello Ada"}
}
Loading