durable-workflow 2.0.3__tar.gz → 2.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {durable_workflow-2.0.3/src/durable_workflow.egg-info → durable_workflow-2.1.0}/PKG-INFO +1 -1
- {durable_workflow-2.0.3 → durable_workflow-2.1.0}/pyproject.toml +4 -4
- {durable_workflow-2.0.3 → durable_workflow-2.1.0}/src/durable_workflow/client.py +85 -3
- {durable_workflow-2.0.3 → durable_workflow-2.1.0}/src/durable_workflow/errors.py +2 -0
- {durable_workflow-2.0.3 → durable_workflow-2.1.0}/src/durable_workflow/retry_policy.py +6 -0
- {durable_workflow-2.0.3 → durable_workflow-2.1.0}/src/durable_workflow/sync.py +16 -0
- {durable_workflow-2.0.3 → durable_workflow-2.1.0}/src/durable_workflow/workflow.py +24 -0
- {durable_workflow-2.0.3 → durable_workflow-2.1.0/src/durable_workflow.egg-info}/PKG-INFO +1 -1
- {durable_workflow-2.0.3 → durable_workflow-2.1.0}/src/durable_workflow.egg-info/SOURCES.txt +1 -0
- {durable_workflow-2.0.3 → durable_workflow-2.1.0}/tests/test_client.py +38 -0
- durable_workflow-2.1.0/tests/test_redrive_replay.py +46 -0
- {durable_workflow-2.0.3 → durable_workflow-2.1.0}/tests/test_release_metadata.py +19 -12
- {durable_workflow-2.0.3 → durable_workflow-2.1.0}/tests/test_replay.py +51 -0
- {durable_workflow-2.0.3 → durable_workflow-2.1.0}/tests/test_runtime_external_payload_transport.py +110 -0
- {durable_workflow-2.0.3 → durable_workflow-2.1.0}/tests/test_storage_admission.py +6 -2
- {durable_workflow-2.0.3 → durable_workflow-2.1.0}/tests/test_sync.py +22 -0
- {durable_workflow-2.0.3 → durable_workflow-2.1.0}/tests/test_worker.py +27 -0
- {durable_workflow-2.0.3 → durable_workflow-2.1.0}/LICENSE +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.1.0}/README.md +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.1.0}/setup.cfg +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.1.0}/src/durable_workflow/__init__.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.1.0}/src/durable_workflow/_avro.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.1.0}/src/durable_workflow/activity.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.1.0}/src/durable_workflow/auth_composition.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.1.0}/src/durable_workflow/external_storage.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.1.0}/src/durable_workflow/external_task_input.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.1.0}/src/durable_workflow/external_task_result.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.1.0}/src/durable_workflow/history_bundle_verify.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.1.0}/src/durable_workflow/interceptors.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.1.0}/src/durable_workflow/invocable.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.1.0}/src/durable_workflow/metrics.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.1.0}/src/durable_workflow/nexus.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.1.0}/src/durable_workflow/py.typed +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.1.0}/src/durable_workflow/python_conformance.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.1.0}/src/durable_workflow/replay_conformance.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.1.0}/src/durable_workflow/replay_verify.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.1.0}/src/durable_workflow/serializer.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.1.0}/src/durable_workflow/testing.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.1.0}/src/durable_workflow/worker.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.1.0}/src/durable_workflow/workflow_updates_conformance.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.1.0}/src/durable_workflow.egg-info/dependency_links.txt +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.1.0}/src/durable_workflow.egg-info/entry_points.txt +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.1.0}/src/durable_workflow.egg-info/requires.txt +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.1.0}/src/durable_workflow.egg-info/top_level.txt +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.1.0}/tests/test_activity_context.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.1.0}/tests/test_auth_composition.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.1.0}/tests/test_avro_value_benchmark.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.1.0}/tests/test_avro_value_protocol.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.1.0}/tests/test_ci_checkout.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.1.0}/tests/test_ci_integration_endpoint.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.1.0}/tests/test_codec_regression_corpus.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.1.0}/tests/test_control_plane_parity_fixtures.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.1.0}/tests/test_docstring_cross_references.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.1.0}/tests/test_durable_selection.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.1.0}/tests/test_errors.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.1.0}/tests/test_external_storage.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.1.0}/tests/test_external_task_input.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.1.0}/tests/test_external_task_result.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.1.0}/tests/test_golden_history_replay.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.1.0}/tests/test_history_bundle_verify.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.1.0}/tests/test_history_event_contract.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.1.0}/tests/test_invocable.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.1.0}/tests/test_message_streams.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.1.0}/tests/test_metrics.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.1.0}/tests/test_order_processing_example.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.1.0}/tests/test_parallel_completion_order_replay.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.1.0}/tests/test_parallel_saga_authoring.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.1.0}/tests/test_public_boundary_scanner.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.1.0}/tests/test_pypi_project_surface.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.1.0}/tests/test_python_conformance.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.1.0}/tests/test_queries.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.1.0}/tests/test_readme_quickstart.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.1.0}/tests/test_replay_conformance.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.1.0}/tests/test_replay_regression_corpus.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.1.0}/tests/test_replay_verify.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.1.0}/tests/test_retry_policy.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.1.0}/tests/test_schedules.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.1.0}/tests/test_serializer.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.1.0}/tests/test_signals.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.1.0}/tests/test_sleep.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.1.0}/tests/test_standalone_activity_client.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.1.0}/tests/test_testing_harness.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.1.0}/tests/test_update_signal_condition_replay.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.1.0}/tests/test_updates.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.1.0}/tests/test_wait_condition.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.1.0}/tests/test_workflow_result_timeout.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.1.0}/tests/test_workflow_streams.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.1.0}/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.1.0"
|
|
8
8
|
description = "Python client and worker SDK for Durable Workflow Cloud and self-hosted Server"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -71,9 +71,9 @@ durable-workflow-replay-conformance = "durable_workflow.replay_conformance:main"
|
|
|
71
71
|
durable-workflow-workflow-updates-conformance = "durable_workflow.workflow_updates_conformance:main"
|
|
72
72
|
|
|
73
73
|
[tool.durable-workflow]
|
|
74
|
-
product-train = "2.0
|
|
75
|
-
registry-version = "2.0
|
|
76
|
-
supported-server-versions = "2.
|
|
74
|
+
product-train = "2.1.0"
|
|
75
|
+
registry-version = "2.1.0"
|
|
76
|
+
supported-server-versions = "2.4.0"
|
|
77
77
|
worker-protocol-version = "1.19"
|
|
78
78
|
control-plane-version = "2"
|
|
79
79
|
durable-selection = true
|
|
@@ -20,8 +20,10 @@ from __future__ import annotations
|
|
|
20
20
|
|
|
21
21
|
import asyncio
|
|
22
22
|
import hashlib
|
|
23
|
+
import json as json_module
|
|
23
24
|
import math
|
|
24
25
|
import os
|
|
26
|
+
import re
|
|
25
27
|
import time
|
|
26
28
|
import uuid
|
|
27
29
|
import warnings
|
|
@@ -30,7 +32,7 @@ from dataclasses import dataclass
|
|
|
30
32
|
from importlib.metadata import PackageNotFoundError
|
|
31
33
|
from importlib.metadata import version as _pkg_version
|
|
32
34
|
from typing import Any
|
|
33
|
-
from urllib.parse import quote, urlencode, urlsplit
|
|
35
|
+
from urllib.parse import quote, unquote, urlencode, urlsplit
|
|
34
36
|
|
|
35
37
|
import httpx
|
|
36
38
|
|
|
@@ -101,6 +103,26 @@ _RUNTIME_EXTERNAL_PAYLOAD_FETCH_PATH_TEMPLATE = (
|
|
|
101
103
|
"/external-payloads/v1/{referenceId}"
|
|
102
104
|
)
|
|
103
105
|
_RUNTIME_EXTERNAL_PAYLOAD_ERROR_BODY_LIMIT = 64 * 1024
|
|
106
|
+
_PAYLOAD_COMPLETION_SCHEMA = "durable-workflow.v2.payload-completion-context.v1"
|
|
107
|
+
_PAYLOAD_COMPLETION_HEADER = "X-Durable-Workflow-Payload-Completion"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _payload_completion_context(path: str, body: Any) -> dict[str, Any] | None:
|
|
111
|
+
match = re.fullmatch(r"/worker/(activity|workflow|query)-tasks/([^/]+)/(complete|fail)", path.split("?")[0])
|
|
112
|
+
if match is None or not isinstance(body, dict):
|
|
113
|
+
return None
|
|
114
|
+
kind, task_id, operation = match.groups()
|
|
115
|
+
attempt = body.get("activity_attempt_id" if kind == "activity" else f"{kind}_task_attempt")
|
|
116
|
+
owner = body.get("lease_owner")
|
|
117
|
+
if not isinstance(owner, str) or not owner:
|
|
118
|
+
return None
|
|
119
|
+
if kind == "activity":
|
|
120
|
+
if not isinstance(attempt, str) or not attempt:
|
|
121
|
+
return None
|
|
122
|
+
elif type(attempt) is not int or attempt < 1:
|
|
123
|
+
return None
|
|
124
|
+
return {"schema": _PAYLOAD_COMPLETION_SCHEMA, "kind": kind, "task_id": unquote(task_id),
|
|
125
|
+
"attempt": attempt, "lease_owner": owner, "operation": operation}
|
|
104
126
|
|
|
105
127
|
|
|
106
128
|
def _default_sdk_version() -> str:
|
|
@@ -277,6 +299,7 @@ class _RuntimeExternalPayloadTransport:
|
|
|
277
299
|
max_payload_bytes: int
|
|
278
300
|
request_timeout_seconds: float
|
|
279
301
|
status: str
|
|
302
|
+
completion_context: bool = False
|
|
280
303
|
|
|
281
304
|
|
|
282
305
|
@dataclass
|
|
@@ -577,6 +600,7 @@ class WorkflowCommandResult:
|
|
|
577
600
|
command_status: str | None = None
|
|
578
601
|
command_id: str | None = None
|
|
579
602
|
raw: dict[str, Any] | None = None
|
|
603
|
+
run_id: str | None = None
|
|
580
604
|
|
|
581
605
|
@classmethod
|
|
582
606
|
def from_dict(cls, data: dict[str, Any], *, workflow_id: str | None = None) -> WorkflowCommandResult:
|
|
@@ -586,6 +610,7 @@ class WorkflowCommandResult:
|
|
|
586
610
|
command_status=data.get("command_status"),
|
|
587
611
|
command_id=data.get("command_id"),
|
|
588
612
|
raw=data,
|
|
613
|
+
run_id=data.get("run_id"),
|
|
589
614
|
)
|
|
590
615
|
|
|
591
616
|
|
|
@@ -1302,6 +1327,12 @@ class WorkflowHandle:
|
|
|
1302
1327
|
"""Ask the server to repair this workflow. See :meth:`Client.repair_workflow`."""
|
|
1303
1328
|
return await self._client.repair_workflow(self.workflow_id)
|
|
1304
1329
|
|
|
1330
|
+
async def redrive(self, *, request_id: str | None = None) -> WorkflowCommandResult:
|
|
1331
|
+
"""Continue this failed run from its recorded activity failure boundary."""
|
|
1332
|
+
if self.run_id is None:
|
|
1333
|
+
raise ValueError("run_id is required to redrive a workflow from a handle")
|
|
1334
|
+
return await self._client.redrive_workflow(self.workflow_id, self.run_id, request_id=request_id)
|
|
1335
|
+
|
|
1305
1336
|
async def archive(self, *, reason: str | None = None) -> WorkflowCommandResult:
|
|
1306
1337
|
"""Move this terminal workflow into the archive tier. See :meth:`Client.archive_workflow`."""
|
|
1307
1338
|
return await self._client.archive_workflow(self.workflow_id, reason=reason)
|
|
@@ -1734,6 +1765,9 @@ class Client:
|
|
|
1734
1765
|
worker=worker,
|
|
1735
1766
|
transport=transport,
|
|
1736
1767
|
uploaded={},
|
|
1768
|
+
completion=(
|
|
1769
|
+
_payload_completion_context(path, json) if worker and transport.completion_context else None
|
|
1770
|
+
),
|
|
1737
1771
|
)
|
|
1738
1772
|
|
|
1739
1773
|
start = time.perf_counter()
|
|
@@ -1904,11 +1938,15 @@ class Client:
|
|
|
1904
1938
|
)
|
|
1905
1939
|
|
|
1906
1940
|
status = policy.get("status")
|
|
1941
|
+
completion = upload.get("completion_context")
|
|
1907
1942
|
transport = _RuntimeExternalPayloadTransport(
|
|
1908
1943
|
threshold_bytes=threshold_bytes,
|
|
1909
1944
|
max_payload_bytes=max_payload_bytes,
|
|
1910
1945
|
request_timeout_seconds=float(request_timeout_seconds),
|
|
1911
1946
|
status=status if isinstance(status, str) else "unknown",
|
|
1947
|
+
completion_context=isinstance(completion, dict)
|
|
1948
|
+
and completion.get("schema") == _PAYLOAD_COMPLETION_SCHEMA
|
|
1949
|
+
and completion.get("header") == _PAYLOAD_COMPLETION_HEADER,
|
|
1912
1950
|
)
|
|
1913
1951
|
self._runtime_external_payload_transport_cache = transport
|
|
1914
1952
|
self._runtime_external_payload_transport_resolved = True
|
|
@@ -1972,6 +2010,8 @@ class Client:
|
|
|
1972
2010
|
worker: bool,
|
|
1973
2011
|
transport: _RuntimeExternalPayloadTransport,
|
|
1974
2012
|
uploaded: dict[tuple[str, str, int], RuntimeExternalPayloadReference],
|
|
2013
|
+
completion: dict[str, Any] | None = None,
|
|
2014
|
+
slot: tuple[str | int, ...] = (),
|
|
1975
2015
|
) -> Any:
|
|
1976
2016
|
if isinstance(value, dict):
|
|
1977
2017
|
if (
|
|
@@ -1984,6 +2024,8 @@ class Client:
|
|
|
1984
2024
|
worker=worker,
|
|
1985
2025
|
transport=transport,
|
|
1986
2026
|
uploaded=uploaded,
|
|
2027
|
+
completion=completion,
|
|
2028
|
+
slot=(*slot, "result"),
|
|
1987
2029
|
)
|
|
1988
2030
|
if "external_payload" in externalized_result:
|
|
1989
2031
|
normalized_command["result"] = externalized_result
|
|
@@ -2014,6 +2056,7 @@ class Client:
|
|
|
2014
2056
|
sha256=sha256,
|
|
2015
2057
|
worker=worker,
|
|
2016
2058
|
transport=transport,
|
|
2059
|
+
completion={**completion, "slot": list(slot)} if completion is not None else None,
|
|
2017
2060
|
)
|
|
2018
2061
|
uploaded[identity] = reference
|
|
2019
2062
|
return {"codec": codec, "external_payload": reference.to_dict()}
|
|
@@ -2024,6 +2067,8 @@ class Client:
|
|
|
2024
2067
|
worker=worker,
|
|
2025
2068
|
transport=transport,
|
|
2026
2069
|
uploaded=uploaded,
|
|
2070
|
+
completion=completion,
|
|
2071
|
+
slot=(*slot, key),
|
|
2027
2072
|
)
|
|
2028
2073
|
for key, item in value.items()
|
|
2029
2074
|
}
|
|
@@ -2045,8 +2090,10 @@ class Client:
|
|
|
2045
2090
|
worker=worker,
|
|
2046
2091
|
transport=transport,
|
|
2047
2092
|
uploaded=uploaded,
|
|
2093
|
+
completion=completion,
|
|
2094
|
+
slot=(*slot, index),
|
|
2048
2095
|
)
|
|
2049
|
-
for item in value
|
|
2096
|
+
for index, item in enumerate(value)
|
|
2050
2097
|
]
|
|
2051
2098
|
return value
|
|
2052
2099
|
|
|
@@ -2058,6 +2105,7 @@ class Client:
|
|
|
2058
2105
|
sha256: str,
|
|
2059
2106
|
worker: bool,
|
|
2060
2107
|
transport: _RuntimeExternalPayloadTransport,
|
|
2108
|
+
completion: dict[str, Any] | None = None,
|
|
2061
2109
|
) -> RuntimeExternalPayloadReference:
|
|
2062
2110
|
headers = self._headers(worker=worker)
|
|
2063
2111
|
headers.update({
|
|
@@ -2069,13 +2117,30 @@ class Client:
|
|
|
2069
2117
|
})
|
|
2070
2118
|
|
|
2071
2119
|
async def _do_request() -> httpx.Response:
|
|
2120
|
+
attempt_headers = dict(headers)
|
|
2072
2121
|
response = await self._http.request(
|
|
2073
2122
|
"POST",
|
|
2074
2123
|
f"/api{_RUNTIME_EXTERNAL_PAYLOAD_UPLOAD_PATH}",
|
|
2075
|
-
headers=
|
|
2124
|
+
headers=attempt_headers,
|
|
2076
2125
|
content=data,
|
|
2077
2126
|
timeout=transport.request_timeout_seconds,
|
|
2078
2127
|
)
|
|
2128
|
+
if (response.status_code == 503 and completion is not None
|
|
2129
|
+
and len(response.content) <= _RUNTIME_EXTERNAL_PAYLOAD_ERROR_BODY_LIMIT):
|
|
2130
|
+
try:
|
|
2131
|
+
refusal = response.json()
|
|
2132
|
+
except ValueError:
|
|
2133
|
+
refusal = None
|
|
2134
|
+
context = json_module.dumps(completion, separators=(",", ":"))
|
|
2135
|
+
if (isinstance(refusal, dict) and refusal.get("reason") == "storage_pressure"
|
|
2136
|
+
and refusal.get("storage_state") == "draining" and len(context.encode("utf-8")) <= 4096):
|
|
2137
|
+
# Keep the same identity if the ordinary transport policy retries
|
|
2138
|
+
# an ambiguous response; never rerun application activity code.
|
|
2139
|
+
attempt_headers[_PAYLOAD_COMPLETION_HEADER] = context
|
|
2140
|
+
response = await self._http.request(
|
|
2141
|
+
"POST", f"/api{_RUNTIME_EXTERNAL_PAYLOAD_UPLOAD_PATH}", headers=attempt_headers,
|
|
2142
|
+
content=data, timeout=transport.request_timeout_seconds,
|
|
2143
|
+
)
|
|
2079
2144
|
response.raise_for_status()
|
|
2080
2145
|
return response
|
|
2081
2146
|
|
|
@@ -4076,6 +4141,23 @@ class Client:
|
|
|
4076
4141
|
data = await self._request("POST", f"/workflows/{workflow_id}/repair", json={}, context=workflow_id)
|
|
4077
4142
|
return WorkflowCommandResult.from_dict(data, workflow_id=workflow_id)
|
|
4078
4143
|
|
|
4144
|
+
async def redrive_workflow(
|
|
4145
|
+
self,
|
|
4146
|
+
workflow_id: str,
|
|
4147
|
+
failed_run_id: str,
|
|
4148
|
+
*,
|
|
4149
|
+
request_id: str | None = None,
|
|
4150
|
+
) -> WorkflowCommandResult:
|
|
4151
|
+
"""Continue a failed run, reusing its completed activity results."""
|
|
4152
|
+
body = {"request_id": request_id} if request_id is not None else {}
|
|
4153
|
+
data = await self._request(
|
|
4154
|
+
"POST",
|
|
4155
|
+
f"/workflows/{workflow_id}/runs/{failed_run_id}/redrive",
|
|
4156
|
+
json=body,
|
|
4157
|
+
context=workflow_id,
|
|
4158
|
+
)
|
|
4159
|
+
return WorkflowCommandResult.from_dict(data, workflow_id=workflow_id)
|
|
4160
|
+
|
|
4079
4161
|
async def archive_workflow(self, workflow_id: str, *, reason: str | None = None) -> WorkflowCommandResult:
|
|
4080
4162
|
"""Move a terminal workflow into the archive tier, returning the command outcome."""
|
|
4081
4163
|
body: dict[str, Any] = {}
|
|
@@ -551,6 +551,7 @@ class ActivityFailed(DurableWorkflowError):
|
|
|
551
551
|
message: str,
|
|
552
552
|
*,
|
|
553
553
|
activity_type: str | None = None,
|
|
554
|
+
step_sequence: int | None = None,
|
|
554
555
|
activity_execution_id: str | None = None,
|
|
555
556
|
activity_attempt_id: str | None = None,
|
|
556
557
|
failure_id: str | None = None,
|
|
@@ -564,6 +565,7 @@ class ActivityFailed(DurableWorkflowError):
|
|
|
564
565
|
) -> None:
|
|
565
566
|
super().__init__(message)
|
|
566
567
|
self.activity_type = activity_type
|
|
568
|
+
self.step_sequence = step_sequence
|
|
567
569
|
self.activity_execution_id = activity_execution_id
|
|
568
570
|
self.activity_attempt_id = activity_attempt_id
|
|
569
571
|
self.failure_id = failure_id
|
|
@@ -43,6 +43,12 @@ def _storage_refusal(exc: Exception) -> tuple[ServerError, str | None] | None:
|
|
|
43
43
|
body = exc.response.json()
|
|
44
44
|
except ValueError:
|
|
45
45
|
return None
|
|
46
|
+
# A payload upload is content-addressed and precedes completion submission.
|
|
47
|
+
# Even a late pressure refusal can retry those same bytes. This local retry
|
|
48
|
+
# classification does not alter the original response exposed to callers.
|
|
49
|
+
if (isinstance(body, dict) and "request_admitted" not in body and exc.request.method == "POST"
|
|
50
|
+
and exc.request.url.path.endswith("/api/external-payloads/v1")):
|
|
51
|
+
body = {**body, "request_admitted": False}
|
|
46
52
|
error = ServerError(exc.response.status_code, body)
|
|
47
53
|
if error.reason() not in ("storage_pressure", "storage_admission_unavailable"):
|
|
48
54
|
return None
|
|
@@ -134,6 +134,10 @@ class SyncWorkflowHandle:
|
|
|
134
134
|
result: WorkflowCommandResult = self._runner.run(self._handle.repair())
|
|
135
135
|
return result
|
|
136
136
|
|
|
137
|
+
def redrive(self, *, request_id: str | None = None) -> WorkflowCommandResult:
|
|
138
|
+
result: WorkflowCommandResult = self._runner.run(self._handle.redrive(request_id=request_id))
|
|
139
|
+
return result
|
|
140
|
+
|
|
137
141
|
def archive(self, *, reason: str | None = None) -> WorkflowCommandResult:
|
|
138
142
|
result: WorkflowCommandResult = self._runner.run(self._handle.archive(reason=reason))
|
|
139
143
|
return result
|
|
@@ -525,6 +529,18 @@ class Client:
|
|
|
525
529
|
result: WorkflowCommandResult = self._runner.run(self._async.repair_workflow(workflow_id))
|
|
526
530
|
return result
|
|
527
531
|
|
|
532
|
+
def redrive_workflow(
|
|
533
|
+
self,
|
|
534
|
+
workflow_id: str,
|
|
535
|
+
failed_run_id: str,
|
|
536
|
+
*,
|
|
537
|
+
request_id: str | None = None,
|
|
538
|
+
) -> WorkflowCommandResult:
|
|
539
|
+
result: WorkflowCommandResult = self._runner.run(
|
|
540
|
+
self._async.redrive_workflow(workflow_id, failed_run_id, request_id=request_id)
|
|
541
|
+
)
|
|
542
|
+
return result
|
|
543
|
+
|
|
528
544
|
def archive_workflow(self, workflow_id: str, *, reason: str | None = None) -> WorkflowCommandResult:
|
|
529
545
|
result: WorkflowCommandResult = self._runner.run(self._async.archive_workflow(workflow_id, reason=reason))
|
|
530
546
|
return result
|
|
@@ -621,6 +621,8 @@ class FailWorkflow:
|
|
|
621
621
|
exception_class: str | None = None
|
|
622
622
|
exception: dict[str, Any] | None = None
|
|
623
623
|
non_retryable: bool = False
|
|
624
|
+
failed_step_sequence: int | None = None
|
|
625
|
+
failed_activity_execution_id: str | None = None
|
|
624
626
|
|
|
625
627
|
def to_server_command(
|
|
626
628
|
self,
|
|
@@ -642,6 +644,9 @@ class FailWorkflow:
|
|
|
642
644
|
cmd["exception"] = self.exception
|
|
643
645
|
if self.non_retryable:
|
|
644
646
|
cmd["non_retryable"] = True
|
|
647
|
+
if self.failed_step_sequence is not None and self.failed_activity_execution_id is not None:
|
|
648
|
+
cmd["failed_step_sequence"] = self.failed_step_sequence
|
|
649
|
+
cmd["failed_activity_execution_id"] = self.failed_activity_execution_id
|
|
645
650
|
return cmd
|
|
646
651
|
|
|
647
652
|
|
|
@@ -2795,11 +2800,25 @@ def _fail_workflow_from_exception(exc: BaseException, *, prefix: str | None = No
|
|
|
2795
2800
|
if activity_failure.activity_attempt_id is not None:
|
|
2796
2801
|
exception["activity_attempt_id"] = activity_failure.activity_attempt_id
|
|
2797
2802
|
|
|
2803
|
+
failed_step_sequence = None
|
|
2804
|
+
failed_activity_execution_id = None
|
|
2805
|
+
if (
|
|
2806
|
+
isinstance(exc, ActivityFailed)
|
|
2807
|
+
and type(exc.step_sequence) is int
|
|
2808
|
+
and exc.step_sequence > 0
|
|
2809
|
+
and isinstance(exc.activity_execution_id, str)
|
|
2810
|
+
and exc.activity_execution_id
|
|
2811
|
+
):
|
|
2812
|
+
failed_step_sequence = exc.step_sequence
|
|
2813
|
+
failed_activity_execution_id = exc.activity_execution_id
|
|
2814
|
+
|
|
2798
2815
|
return FailWorkflow(
|
|
2799
2816
|
message=message,
|
|
2800
2817
|
exception_type=exception_type,
|
|
2801
2818
|
exception_class=exception_class,
|
|
2802
2819
|
exception=exception,
|
|
2820
|
+
failed_step_sequence=failed_step_sequence,
|
|
2821
|
+
failed_activity_execution_id=failed_activity_execution_id,
|
|
2803
2822
|
)
|
|
2804
2823
|
|
|
2805
2824
|
|
|
@@ -3345,6 +3364,11 @@ def _activity_failed_from_payload(payload: Mapping[str, Any]) -> ActivityFailed:
|
|
|
3345
3364
|
return ActivityFailed(
|
|
3346
3365
|
message or "activity failed",
|
|
3347
3366
|
activity_type=_activity_type_from_payload(payload),
|
|
3367
|
+
step_sequence=(
|
|
3368
|
+
payload["sequence"]
|
|
3369
|
+
if type(payload.get("sequence")) is int and payload["sequence"] > 0
|
|
3370
|
+
else None
|
|
3371
|
+
),
|
|
3348
3372
|
activity_execution_id=_optional_str(payload.get("activity_execution_id")),
|
|
3349
3373
|
activity_attempt_id=_optional_str(payload.get("activity_attempt_id")),
|
|
3350
3374
|
failure_id=_optional_str(payload.get("failure_id")),
|
|
@@ -832,14 +832,20 @@ class TestWorkflowHandleControlPlane:
|
|
|
832
832
|
async def test_maintenance_delegates_to_client(self, client: Client) -> None:
|
|
833
833
|
handle = WorkflowHandle(client, workflow_id="wf-1", run_id="r1", workflow_type="greeter")
|
|
834
834
|
client.repair_workflow = AsyncMock(return_value="repair")
|
|
835
|
+
client.redrive_workflow = AsyncMock(return_value="redrive")
|
|
835
836
|
client.archive_workflow = AsyncMock(return_value="archive")
|
|
836
837
|
|
|
837
838
|
assert await handle.repair() == "repair"
|
|
839
|
+
assert await handle.redrive(request_id="retry-1") == "redrive"
|
|
838
840
|
assert await handle.archive(reason="retention") == "archive"
|
|
839
841
|
|
|
840
842
|
client.repair_workflow.assert_awaited_once_with("wf-1")
|
|
843
|
+
client.redrive_workflow.assert_awaited_once_with("wf-1", "r1", request_id="retry-1")
|
|
841
844
|
client.archive_workflow.assert_awaited_once_with("wf-1", reason="retention")
|
|
842
845
|
|
|
846
|
+
with pytest.raises(ValueError, match="run_id is required"):
|
|
847
|
+
await WorkflowHandle(client, workflow_id="wf-1").redrive()
|
|
848
|
+
|
|
843
849
|
|
|
844
850
|
class TestSignalWorkflow:
|
|
845
851
|
@pytest.mark.asyncio
|
|
@@ -1065,6 +1071,38 @@ class TestTerminateWorkflow:
|
|
|
1065
1071
|
|
|
1066
1072
|
|
|
1067
1073
|
class TestWorkflowMaintenanceCommands:
|
|
1074
|
+
@pytest.mark.asyncio
|
|
1075
|
+
async def test_redrive_returns_successor_and_sends_optional_request_id(self, client: Client) -> None:
|
|
1076
|
+
resp = _mock_response(202, {
|
|
1077
|
+
"workflow_id": "wf-1",
|
|
1078
|
+
"run_id": "run-2",
|
|
1079
|
+
"outcome": "redriven",
|
|
1080
|
+
"command_status": "accepted",
|
|
1081
|
+
"resume_step_sequence": 2,
|
|
1082
|
+
})
|
|
1083
|
+
with patch.object(client._http, "request", new_callable=AsyncMock, return_value=resp) as mock:
|
|
1084
|
+
result = await client.redrive_workflow("wf-1", "run-1", request_id="retry-1")
|
|
1085
|
+
|
|
1086
|
+
assert mock.call_args.args == ("POST", "/api/workflows/wf-1/runs/run-1/redrive")
|
|
1087
|
+
assert mock.call_args.kwargs["json"] == {"request_id": "retry-1"}
|
|
1088
|
+
assert result.workflow_id == "wf-1"
|
|
1089
|
+
assert result.run_id == "run-2"
|
|
1090
|
+
assert result.outcome == "redriven"
|
|
1091
|
+
assert result.raw is not None and result.raw["resume_step_sequence"] == 2
|
|
1092
|
+
|
|
1093
|
+
with patch.object(client._http, "request", new_callable=AsyncMock, return_value=resp) as mock:
|
|
1094
|
+
await client.redrive_workflow("wf-1", "run-1")
|
|
1095
|
+
assert mock.call_args.kwargs["json"] == {}
|
|
1096
|
+
|
|
1097
|
+
@pytest.mark.asyncio
|
|
1098
|
+
async def test_redrive_rejection_is_not_reported_as_success(self, client: Client) -> None:
|
|
1099
|
+
resp = _mock_response(409, {"reason": "run_not_failed", "message": "Run is not failed."})
|
|
1100
|
+
with (
|
|
1101
|
+
patch.object(client._http, "request", new_callable=AsyncMock, return_value=resp),
|
|
1102
|
+
pytest.raises(ServerError),
|
|
1103
|
+
):
|
|
1104
|
+
await client.redrive_workflow("wf-1", "run-1")
|
|
1105
|
+
|
|
1068
1106
|
@pytest.mark.asyncio
|
|
1069
1107
|
async def test_repair_request_matches_polyglot_fixture(self, client: Client) -> None:
|
|
1070
1108
|
fixture_path = Path(__file__).parent / "fixtures" / "control-plane" / "workflow-repair-parity.json"
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from durable_workflow import serializer, workflow
|
|
2
|
+
from durable_workflow.workflow import CompleteWorkflow, ScheduleActivity, replay
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@workflow.defn(name="tests.redrive")
|
|
6
|
+
class RedriveWorkflow:
|
|
7
|
+
def run(self, ctx): # type: ignore[no-untyped-def]
|
|
8
|
+
first = yield ctx.schedule_activity("tests.first", [])
|
|
9
|
+
second = yield ctx.schedule_activity("tests.second", [first])
|
|
10
|
+
return {"first": first, "second": second}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def completed(sequence: int, activity_type: str, result: str) -> dict[str, object]:
|
|
14
|
+
payload: dict[str, object] = {
|
|
15
|
+
"sequence": sequence,
|
|
16
|
+
"activity_type": activity_type,
|
|
17
|
+
"result": serializer.encode(result, codec="avro"),
|
|
18
|
+
"payload_codec": "avro",
|
|
19
|
+
}
|
|
20
|
+
if sequence == 1:
|
|
21
|
+
payload["reused_from_run_id"] = "failed-run"
|
|
22
|
+
payload["reused_activity_execution_id"] = "original-first"
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
"event_type": "ActivityCompleted",
|
|
26
|
+
"payload": payload,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_redriven_history_reuses_completed_prefix_and_retries_failed_step() -> None:
|
|
31
|
+
history = [completed(1, "tests.first", "recorded")]
|
|
32
|
+
|
|
33
|
+
retry = replay(RedriveWorkflow, history, []).commands
|
|
34
|
+
assert len(retry) == 1
|
|
35
|
+
assert isinstance(retry[0], ScheduleActivity)
|
|
36
|
+
assert retry[0].activity_type == "tests.second"
|
|
37
|
+
assert retry[0].arguments == ["recorded"]
|
|
38
|
+
|
|
39
|
+
completed_run = replay(
|
|
40
|
+
RedriveWorkflow,
|
|
41
|
+
history + [completed(2, "tests.second", "retried")],
|
|
42
|
+
[],
|
|
43
|
+
).commands
|
|
44
|
+
assert len(completed_run) == 1
|
|
45
|
+
assert isinstance(completed_run[0], CompleteWorkflow)
|
|
46
|
+
assert completed_run[0].result == {"first": "recorded", "second": "retried"}
|
|
@@ -41,10 +41,10 @@ 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.1.0"
|
|
45
45
|
assert release["product-train"] == project["version"]
|
|
46
|
-
assert release["registry-version"] == "2.0
|
|
47
|
-
assert release["supported-server-versions"] == "2.
|
|
46
|
+
assert release["registry-version"] == "2.1.0"
|
|
47
|
+
assert release["supported-server-versions"] == "2.4.0"
|
|
48
48
|
assert release["worker-protocol-version"] == PROTOCOL_VERSION == "1.19"
|
|
49
49
|
assert release["durable-selection"] is True
|
|
50
50
|
assert release["durable-selection-minimum-worker-protocol-version"] == "1.19"
|
|
@@ -86,24 +86,30 @@ def test_release_metadata_loader_uses_tomli_without_stdlib_tomllib(
|
|
|
86
86
|
assert imports == ["tomllib", "tomli"]
|
|
87
87
|
|
|
88
88
|
|
|
89
|
+
@pytest.mark.parametrize(
|
|
90
|
+
("version", "server_version"),
|
|
91
|
+
[("2.0.1", "2.0.0"), ("2.1.0", "2.4.0")],
|
|
92
|
+
)
|
|
89
93
|
def test_release_metadata_loader_accepts_the_authorized_stable_identity(
|
|
90
94
|
monkeypatch: pytest.MonkeyPatch,
|
|
95
|
+
version: str,
|
|
96
|
+
server_version: str,
|
|
91
97
|
) -> None:
|
|
92
98
|
commit = "b" * 40
|
|
93
|
-
pyproject =
|
|
99
|
+
pyproject = f"""
|
|
94
100
|
[project]
|
|
95
101
|
name = "durable-workflow"
|
|
96
|
-
version = "
|
|
102
|
+
version = "{version}"
|
|
97
103
|
description = "Python SDK for Durable Workflow 2.0"
|
|
98
104
|
readme = "README.md"
|
|
99
105
|
classifiers = ["Programming Language :: Python :: 3"]
|
|
100
106
|
|
|
101
107
|
[tool.durable-workflow]
|
|
102
|
-
product-train = "
|
|
103
|
-
registry-version = "
|
|
104
|
-
supported-server-versions = "
|
|
108
|
+
product-train = "{version}"
|
|
109
|
+
registry-version = "{version}"
|
|
110
|
+
supported-server-versions = "{server_version}"
|
|
105
111
|
worker-protocol-version = "1.19"
|
|
106
|
-
"""
|
|
112
|
+
""".encode()
|
|
107
113
|
readme = b"# Durable Workflow\n\nBuild durable Python workflows.\n"
|
|
108
114
|
runtime = b'PROTOCOL_VERSION = "1.19"\n'
|
|
109
115
|
|
|
@@ -120,9 +126,10 @@ worker-protocol-version = "1.19"
|
|
|
120
126
|
|
|
121
127
|
monkeypatch.setattr(check_release_metadata, "_git", git)
|
|
122
128
|
|
|
123
|
-
source = check_release_metadata.load_source_metadata(
|
|
124
|
-
assert source.version ==
|
|
125
|
-
assert source.registry_version ==
|
|
129
|
+
source = check_release_metadata.load_source_metadata(version)
|
|
130
|
+
assert source.version == version
|
|
131
|
+
assert source.registry_version == version
|
|
132
|
+
assert source.server_version == server_version
|
|
126
133
|
|
|
127
134
|
|
|
128
135
|
def test_normal_project_page_is_retained_as_rendered_evidence(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
@@ -60,6 +60,15 @@ class OneActivity:
|
|
|
60
60
|
return {"greeting": result}
|
|
61
61
|
|
|
62
62
|
|
|
63
|
+
@workflow.defn(name="translated-activity-failure")
|
|
64
|
+
class TranslatedActivityFailure:
|
|
65
|
+
def run(self, ctx: WorkflowContext): # type: ignore[no-untyped-def]
|
|
66
|
+
try:
|
|
67
|
+
yield ctx.schedule_activity("greet", [])
|
|
68
|
+
except ActivityFailed as exc:
|
|
69
|
+
raise RuntimeError("translated failure") from exc
|
|
70
|
+
|
|
71
|
+
|
|
63
72
|
@workflow.defn(name="activity-failed-saga")
|
|
64
73
|
class ActivityFailedSaga:
|
|
65
74
|
def run(self, ctx: WorkflowContext, order_id: str): # type: ignore[no-untyped-def]
|
|
@@ -442,6 +451,48 @@ class TestPublicReplayer:
|
|
|
442
451
|
|
|
443
452
|
|
|
444
453
|
class TestOneActivity:
|
|
454
|
+
def test_uncaught_recorded_activity_failure_claims_only_its_persisted_boundary(self) -> None:
|
|
455
|
+
history = [{
|
|
456
|
+
"event_type": "ActivityFailed",
|
|
457
|
+
"payload": {
|
|
458
|
+
"sequence": 1,
|
|
459
|
+
"activity_type": "greet",
|
|
460
|
+
"activity_execution_id": "activity-1",
|
|
461
|
+
"message": "failed",
|
|
462
|
+
},
|
|
463
|
+
}]
|
|
464
|
+
|
|
465
|
+
outcome = replay(OneActivity, history, ["Ada"])
|
|
466
|
+
command = outcome.commands[0].to_server_command("workers")
|
|
467
|
+
|
|
468
|
+
assert command["type"] == "fail_workflow"
|
|
469
|
+
assert command["failed_step_sequence"] == 1
|
|
470
|
+
assert command["failed_activity_execution_id"] == "activity-1"
|
|
471
|
+
|
|
472
|
+
translated = replay(TranslatedActivityFailure, history, [])
|
|
473
|
+
translated_command = translated.commands[0].to_server_command("workers")
|
|
474
|
+
assert translated_command["type"] == "fail_workflow"
|
|
475
|
+
assert "failed_step_sequence" not in translated_command
|
|
476
|
+
assert "failed_activity_execution_id" not in translated_command
|
|
477
|
+
|
|
478
|
+
handled_history = [{
|
|
479
|
+
"event_type": "ActivityFailed",
|
|
480
|
+
"payload": {**history[0]["payload"], "activity_type": "charge-card"},
|
|
481
|
+
}]
|
|
482
|
+
handled = replay(ActivityFailedSaga, handled_history, ["order-1"])
|
|
483
|
+
assert isinstance(handled.commands[0], ScheduleActivity)
|
|
484
|
+
|
|
485
|
+
def test_activity_failure_without_complete_recorded_identity_does_not_claim_redrive(self) -> None:
|
|
486
|
+
for payload in (
|
|
487
|
+
{"sequence": 1, "activity_type": "greet", "message": "failed"},
|
|
488
|
+
{"activity_execution_id": "activity-1", "activity_type": "greet", "message": "failed"},
|
|
489
|
+
):
|
|
490
|
+
outcome = replay(OneActivity, [{"event_type": "ActivityFailed", "payload": payload}], ["Ada"])
|
|
491
|
+
command = outcome.commands[0].to_server_command("workers")
|
|
492
|
+
assert command["type"] == "fail_workflow"
|
|
493
|
+
assert "failed_step_sequence" not in command
|
|
494
|
+
assert "failed_activity_execution_id" not in command
|
|
495
|
+
|
|
445
496
|
def test_first_replay_schedules(self) -> None:
|
|
446
497
|
outcome = replay(OneActivity, [], ["world"])
|
|
447
498
|
assert len(outcome.commands) == 1
|
{durable_workflow-2.0.3 → durable_workflow-2.1.0}/tests/test_runtime_external_payload_transport.py
RENAMED
|
@@ -19,6 +19,7 @@ from durable_workflow.errors import (
|
|
|
19
19
|
ExternalPayloadUnavailable,
|
|
20
20
|
ExternalPayloadUnsupported,
|
|
21
21
|
RuntimeCapabilityUnsupported,
|
|
22
|
+
ServerError,
|
|
22
23
|
)
|
|
23
24
|
from durable_workflow.external_storage import (
|
|
24
25
|
RUNTIME_EXTERNAL_PAYLOAD_REFERENCE_SCHEMA,
|
|
@@ -174,6 +175,115 @@ class FakeRuntimePayloadServer:
|
|
|
174
175
|
return httpx.Response(200, json=response)
|
|
175
176
|
|
|
176
177
|
|
|
178
|
+
class CompletionPayloadServer(FakeRuntimePayloadServer):
|
|
179
|
+
def __init__(self, *, supported: bool = True, state: str = "draining", reject_bound: bool = False) -> None:
|
|
180
|
+
super().__init__()
|
|
181
|
+
self.supported = supported
|
|
182
|
+
self.state = state
|
|
183
|
+
self.reject_bound = reject_bound
|
|
184
|
+
self.upload_requests: list[httpx.Request] = []
|
|
185
|
+
|
|
186
|
+
def cluster_info(self) -> dict[str, Any]:
|
|
187
|
+
info = super().cluster_info()
|
|
188
|
+
if self.supported:
|
|
189
|
+
info["namespace"]["external_payload_storage"]["transport"]["upload"]["completion_context"] = {
|
|
190
|
+
"schema": "durable-workflow.v2.payload-completion-context.v1",
|
|
191
|
+
"header": "X-Durable-Workflow-Payload-Completion",
|
|
192
|
+
}
|
|
193
|
+
return info
|
|
194
|
+
|
|
195
|
+
def handler(self, request: httpx.Request) -> httpx.Response:
|
|
196
|
+
if request.method == "POST" and request.url.path == "/api/external-payloads/v1":
|
|
197
|
+
self.upload_requests.append(request)
|
|
198
|
+
if self.state != "normal" and "X-Durable-Workflow-Payload-Completion" not in request.headers:
|
|
199
|
+
return httpx.Response(503, json={"reason": "storage_pressure", "storage_state": self.state,
|
|
200
|
+
"request_admitted": False, "retryable": True, "retry_after_seconds": 1})
|
|
201
|
+
if self.reject_bound:
|
|
202
|
+
return httpx.Response(409, json={"reason": "external_payload_completion_lease_rejected",
|
|
203
|
+
"retryable": False, "message": "Lease rejected."})
|
|
204
|
+
return super().handler(request)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def completion_cases() -> list[tuple[str, str, dict[str, Any], list[str | int]]]:
|
|
208
|
+
envelope = serializer.envelope("x" * 100)
|
|
209
|
+
activity = {"lease_owner": "worker", "activity_attempt_id": "attempt"}
|
|
210
|
+
cases = [
|
|
211
|
+
("activity", "complete", {**activity, "result": envelope}, ["result"]),
|
|
212
|
+
("activity", "fail", {**activity, "failure": {"details": envelope}}, ["failure", "details"]),
|
|
213
|
+
("query", "complete", {"lease_owner": "worker", "query_task_attempt": 2, "result_envelope": envelope},
|
|
214
|
+
["result_envelope"]),
|
|
215
|
+
]
|
|
216
|
+
for kind, field in [("complete_workflow", "result"), ("schedule_activity", "arguments"),
|
|
217
|
+
("upsert_memo", "entries"), ("start_service_operation", "request_payload")]:
|
|
218
|
+
cases.append(("workflow", "complete", {"lease_owner": "worker", "workflow_task_attempt": 2,
|
|
219
|
+
"commands": [{"type": kind, field: envelope}]}, ["commands", 0, field]))
|
|
220
|
+
cases.append(("workflow", "complete", {"lease_owner": "worker", "workflow_task_attempt": 2,
|
|
221
|
+
"commands": [{"type": "fail_workflow", "exception": {"details": envelope}}]},
|
|
222
|
+
["commands", 0, "exception", "details"]))
|
|
223
|
+
cases.append(("workflow", "complete", {"lease_owner": "worker", "workflow_task_attempt": 2,
|
|
224
|
+
"commands": [{"type": "record_side_effect", "result": serializer.encode("x" * 100)}]},
|
|
225
|
+
["commands", 0, "result"]))
|
|
226
|
+
cases.append(("workflow", "complete", {"lease_owner": "worker", "workflow_task_attempt": 2,
|
|
227
|
+
"commands": [{"type": "record_side_effect", "workflow_stream": {"items": [
|
|
228
|
+
{"payload": envelope, "payload_codec": "avro"}]}}]},
|
|
229
|
+
["commands", 0, "workflow_stream", "items", 0, "payload"]))
|
|
230
|
+
return cases
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
@pytest.mark.parametrize("kind,operation,body,slot", completion_cases())
|
|
234
|
+
async def test_draining_upload_retry_carries_current_completion_identity(
|
|
235
|
+
kind: str, operation: str, body: dict[str, Any], slot: list[str | int],
|
|
236
|
+
) -> None:
|
|
237
|
+
server = CompletionPayloadServer()
|
|
238
|
+
async with runtime_client(server, retry_policy=TransportRetryPolicy(max_attempts=1)) as client:
|
|
239
|
+
await client._request("POST", f"/worker/{kind}-tasks/task/{operation}", worker=True, json=body)
|
|
240
|
+
first, bound = server.upload_requests
|
|
241
|
+
assert "X-Durable-Workflow-Payload-Completion" not in first.headers
|
|
242
|
+
assert json.loads(bound.headers["X-Durable-Workflow-Payload-Completion"]) == {
|
|
243
|
+
"schema": "durable-workflow.v2.payload-completion-context.v1", "kind": kind,
|
|
244
|
+
"task_id": "task", "attempt": "attempt" if kind == "activity" else 2,
|
|
245
|
+
"lease_owner": "worker", "operation": operation, "slot": slot,
|
|
246
|
+
}
|
|
247
|
+
assert first.content == bound.content
|
|
248
|
+
assert first.headers["authorization"] == bound.headers["authorization"]
|
|
249
|
+
assert first.headers["x-namespace"] == bound.headers["x-namespace"]
|
|
250
|
+
assert len(server.requests) == 1
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
@pytest.mark.parametrize("supported,worker,state", [(False, True, "draining"), (True, False, "draining"),
|
|
254
|
+
(True, True, "fenced")])
|
|
255
|
+
async def test_completion_upload_does_not_bypass_unsupported_client_or_fenced_admission(
|
|
256
|
+
supported: bool, worker: bool, state: str,
|
|
257
|
+
) -> None:
|
|
258
|
+
server = CompletionPayloadServer(supported=supported, state=state)
|
|
259
|
+
async with runtime_client(server, retry_policy=TransportRetryPolicy(max_attempts=1)) as client:
|
|
260
|
+
with pytest.raises((ServerError, ExternalPayloadError)):
|
|
261
|
+
await client._request("POST", "/worker/activity-tasks/task/complete" if worker else "/workflows",
|
|
262
|
+
worker=worker, json={"lease_owner": "worker", "activity_attempt_id": "attempt",
|
|
263
|
+
"result" if worker else "input": serializer.envelope("x" * 100)})
|
|
264
|
+
assert len(server.upload_requests) == 1
|
|
265
|
+
assert server.requests == []
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
async def test_rejected_bound_upload_does_not_loop_or_submit_completion() -> None:
|
|
269
|
+
server = CompletionPayloadServer(reject_bound=True)
|
|
270
|
+
async with runtime_client(server, retry_policy=TransportRetryPolicy(max_attempts=3)) as client:
|
|
271
|
+
with pytest.raises((ServerError, ExternalPayloadError)):
|
|
272
|
+
await client.complete_activity_task(task_id="task", activity_attempt_id="attempt", lease_owner="worker",
|
|
273
|
+
result="x" * 100)
|
|
274
|
+
assert len(server.upload_requests) == 2
|
|
275
|
+
assert server.requests == []
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
async def test_normal_payload_upload_is_unchanged_when_completion_capability_exists() -> None:
|
|
279
|
+
server = CompletionPayloadServer(state="normal")
|
|
280
|
+
async with runtime_client(server) as client:
|
|
281
|
+
await client.complete_activity_task(task_id="task", activity_attempt_id="attempt", lease_owner="worker",
|
|
282
|
+
result="x" * 100)
|
|
283
|
+
assert len(server.upload_requests) == 1
|
|
284
|
+
assert "X-Durable-Workflow-Payload-Completion" not in server.upload_requests[0].headers
|
|
285
|
+
|
|
286
|
+
|
|
177
287
|
def runtime_client(
|
|
178
288
|
server: FakeRuntimePayloadServer,
|
|
179
289
|
*,
|
|
@@ -225,8 +225,9 @@ async def test_shutdown_interrupts_pressure_without_another_mutation(monkeypatch
|
|
|
225
225
|
|
|
226
226
|
|
|
227
227
|
@pytest.mark.parametrize("kind", ["activity", "query", "workflow"])
|
|
228
|
+
@pytest.mark.parametrize("late_upload_refusal", [False, True])
|
|
228
229
|
async def test_runtime_payload_upload_and_acknowledgement_are_not_repeated(
|
|
229
|
-
kind: str, retry_sleeps: list[float],
|
|
230
|
+
kind: str, retry_sleeps: list[float], late_upload_refusal: bool,
|
|
230
231
|
) -> None:
|
|
231
232
|
server = FakeRuntimePayloadServer()
|
|
232
233
|
uploads: list[bytes] = []
|
|
@@ -236,7 +237,10 @@ async def test_runtime_payload_upload_and_acknowledgement_are_not_repeated(
|
|
|
236
237
|
if request.method == "POST" and request.url.path == "/api/external-payloads/v1":
|
|
237
238
|
uploads.append(request.content)
|
|
238
239
|
if len(uploads) <= 3:
|
|
239
|
-
|
|
240
|
+
refusal = pressure()
|
|
241
|
+
if late_upload_refusal:
|
|
242
|
+
refusal.pop("request_admitted")
|
|
243
|
+
return httpx.Response(503, json=refusal)
|
|
240
244
|
if request.url.path.endswith("/complete"):
|
|
241
245
|
acknowledgements.append(request.content)
|
|
242
246
|
if len(acknowledgements) <= 4:
|
|
@@ -519,6 +519,25 @@ class TestSyncClientRunVisibility:
|
|
|
519
519
|
|
|
520
520
|
|
|
521
521
|
class TestSyncClientMaintenance:
|
|
522
|
+
def test_redrive_workflow(self) -> None:
|
|
523
|
+
client = Client("http://localhost:8080")
|
|
524
|
+
resp = _mock_response(
|
|
525
|
+
202,
|
|
526
|
+
{
|
|
527
|
+
"workflow_id": "wf-1",
|
|
528
|
+
"run_id": "run-2",
|
|
529
|
+
"outcome": "redriven",
|
|
530
|
+
"command_status": "accepted",
|
|
531
|
+
},
|
|
532
|
+
)
|
|
533
|
+
with patch.object(client._async._http, "request", new_callable=AsyncMock, return_value=resp) as mock:
|
|
534
|
+
result = client.redrive_workflow("wf-1", "run-1", request_id="retry-1")
|
|
535
|
+
|
|
536
|
+
assert result.run_id == "run-2"
|
|
537
|
+
assert result.outcome == "redriven"
|
|
538
|
+
assert mock.call_args.args[:2] == ("POST", "/api/workflows/wf-1/runs/run-1/redrive")
|
|
539
|
+
assert mock.call_args.kwargs["json"] == {"request_id": "retry-1"}
|
|
540
|
+
|
|
522
541
|
def test_repair_workflow(self) -> None:
|
|
523
542
|
client = Client("http://localhost:8080")
|
|
524
543
|
resp = _mock_response(
|
|
@@ -786,6 +805,7 @@ class TestSyncWorkflowHandleControlPlane:
|
|
|
786
805
|
async_handle.export_history = AsyncMock(return_value={"schema": "durable.workflow.history.v2"}) # type: ignore[method-assign]
|
|
787
806
|
async_handle.list_runs = AsyncMock(return_value=[]) # type: ignore[method-assign]
|
|
788
807
|
async_handle.describe_run = AsyncMock(return_value={"run_id": "r1"}) # type: ignore[method-assign]
|
|
808
|
+
async_handle.redrive = AsyncMock(return_value={"run_id": "r2"}) # type: ignore[method-assign]
|
|
789
809
|
async_handle.repair = AsyncMock(return_value={"outcome": "accepted"}) # type: ignore[method-assign]
|
|
790
810
|
async_handle.archive = AsyncMock(return_value={"outcome": "completed"}) # type: ignore[method-assign]
|
|
791
811
|
with Client("http://localhost:8080") as client:
|
|
@@ -796,7 +816,9 @@ class TestSyncWorkflowHandleControlPlane:
|
|
|
796
816
|
assert handle.list_runs() == []
|
|
797
817
|
assert handle.describe_run() == {"run_id": "r1"}
|
|
798
818
|
assert handle.repair() == {"outcome": "accepted"}
|
|
819
|
+
assert handle.redrive(request_id="retry-1") == {"run_id": "r2"}
|
|
799
820
|
assert handle.archive(reason="retention") == {"outcome": "completed"}
|
|
821
|
+
async_handle.redrive.assert_awaited_once_with(request_id="retry-1")
|
|
800
822
|
async_handle.archive.assert_awaited_once_with(reason="retention")
|
|
801
823
|
|
|
802
824
|
|
|
@@ -1123,6 +1123,33 @@ class TestWorkflowTaskExecution:
|
|
|
1123
1123
|
assert commands[0]["arguments"]["codec"] == "avro"
|
|
1124
1124
|
assert serializer.decode(commands[0]["arguments"]["blob"], codec="avro") == ["hello"]
|
|
1125
1125
|
|
|
1126
|
+
@pytest.mark.asyncio
|
|
1127
|
+
async def test_uncaught_recorded_activity_failure_sends_redrive_boundary(
|
|
1128
|
+
self, mock_client: AsyncMock
|
|
1129
|
+
) -> None:
|
|
1130
|
+
worker = Worker(mock_client, task_queue="q1", workflows=[TestWorkflow], activities=[])
|
|
1131
|
+
await worker._run_workflow_task({
|
|
1132
|
+
"task_id": "failed-activity-task",
|
|
1133
|
+
"workflow_type": "test-wf",
|
|
1134
|
+
"workflow_task_attempt": 1,
|
|
1135
|
+
"history_events": [{
|
|
1136
|
+
"event_type": "ActivityFailed",
|
|
1137
|
+
"payload": {
|
|
1138
|
+
"sequence": 1,
|
|
1139
|
+
"activity_type": "test-act",
|
|
1140
|
+
"activity_execution_id": "activity-execution-1",
|
|
1141
|
+
"message": "failed",
|
|
1142
|
+
},
|
|
1143
|
+
}],
|
|
1144
|
+
"arguments": serializer.encode(["hello"], codec="avro"),
|
|
1145
|
+
"payload_codec": "avro",
|
|
1146
|
+
})
|
|
1147
|
+
|
|
1148
|
+
commands = mock_client.complete_workflow_task.await_args.kwargs["commands"]
|
|
1149
|
+
assert commands[0]["type"] == "fail_workflow"
|
|
1150
|
+
assert commands[0]["failed_step_sequence"] == 1
|
|
1151
|
+
assert commands[0]["failed_activity_execution_id"] == "activity-execution-1"
|
|
1152
|
+
|
|
1126
1153
|
@pytest.mark.asyncio
|
|
1127
1154
|
async def test_workflow_task_ambiguous_completion_error_preserves_commands(
|
|
1128
1155
|
self, mock_client: AsyncMock
|
|
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.3 → durable_workflow-2.1.0}/src/durable_workflow/external_task_input.py
RENAMED
|
File without changes
|
{durable_workflow-2.0.3 → durable_workflow-2.1.0}/src/durable_workflow/external_task_result.py
RENAMED
|
File without changes
|
{durable_workflow-2.0.3 → durable_workflow-2.1.0}/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.3 → durable_workflow-2.1.0}/src/durable_workflow/python_conformance.py
RENAMED
|
File without changes
|
{durable_workflow-2.0.3 → durable_workflow-2.1.0}/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
|
{durable_workflow-2.0.3 → durable_workflow-2.1.0}/src/durable_workflow.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{durable_workflow-2.0.3 → durable_workflow-2.1.0}/src/durable_workflow.egg-info/entry_points.txt
RENAMED
|
File without changes
|
{durable_workflow-2.0.3 → durable_workflow-2.1.0}/src/durable_workflow.egg-info/requires.txt
RENAMED
|
File without changes
|
{durable_workflow-2.0.3 → durable_workflow-2.1.0}/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
|
{durable_workflow-2.0.3 → durable_workflow-2.1.0}/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.3 → durable_workflow-2.1.0}/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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{durable_workflow-2.0.3 → durable_workflow-2.1.0}/tests/test_update_signal_condition_replay.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{durable_workflow-2.0.3 → durable_workflow-2.1.0}/tests/test_workflow_updates_conformance.py
RENAMED
|
File without changes
|