durable-workflow 2.0.3__tar.gz → 2.0.4__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.0.4}/PKG-INFO +1 -1
- {durable_workflow-2.0.3 → durable_workflow-2.0.4}/pyproject.toml +3 -3
- {durable_workflow-2.0.3 → durable_workflow-2.0.4}/src/durable_workflow/client.py +60 -3
- {durable_workflow-2.0.3 → durable_workflow-2.0.4}/src/durable_workflow/retry_policy.py +6 -0
- {durable_workflow-2.0.3 → durable_workflow-2.0.4/src/durable_workflow.egg-info}/PKG-INFO +1 -1
- {durable_workflow-2.0.3 → durable_workflow-2.0.4}/tests/test_release_metadata.py +2 -2
- {durable_workflow-2.0.3 → durable_workflow-2.0.4}/tests/test_runtime_external_payload_transport.py +110 -0
- {durable_workflow-2.0.3 → durable_workflow-2.0.4}/tests/test_storage_admission.py +6 -2
- {durable_workflow-2.0.3 → durable_workflow-2.0.4}/LICENSE +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.0.4}/README.md +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.0.4}/setup.cfg +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.0.4}/src/durable_workflow/__init__.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.0.4}/src/durable_workflow/_avro.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.0.4}/src/durable_workflow/activity.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.0.4}/src/durable_workflow/auth_composition.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.0.4}/src/durable_workflow/errors.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.0.4}/src/durable_workflow/external_storage.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.0.4}/src/durable_workflow/external_task_input.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.0.4}/src/durable_workflow/external_task_result.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.0.4}/src/durable_workflow/history_bundle_verify.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.0.4}/src/durable_workflow/interceptors.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.0.4}/src/durable_workflow/invocable.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.0.4}/src/durable_workflow/metrics.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.0.4}/src/durable_workflow/nexus.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.0.4}/src/durable_workflow/py.typed +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.0.4}/src/durable_workflow/python_conformance.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.0.4}/src/durable_workflow/replay_conformance.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.0.4}/src/durable_workflow/replay_verify.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.0.4}/src/durable_workflow/serializer.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.0.4}/src/durable_workflow/sync.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.0.4}/src/durable_workflow/testing.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.0.4}/src/durable_workflow/worker.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.0.4}/src/durable_workflow/workflow.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.0.4}/src/durable_workflow/workflow_updates_conformance.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.0.4}/src/durable_workflow.egg-info/SOURCES.txt +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.0.4}/src/durable_workflow.egg-info/dependency_links.txt +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.0.4}/src/durable_workflow.egg-info/entry_points.txt +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.0.4}/src/durable_workflow.egg-info/requires.txt +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.0.4}/src/durable_workflow.egg-info/top_level.txt +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.0.4}/tests/test_activity_context.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.0.4}/tests/test_auth_composition.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.0.4}/tests/test_avro_value_benchmark.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.0.4}/tests/test_avro_value_protocol.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.0.4}/tests/test_ci_checkout.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.0.4}/tests/test_ci_integration_endpoint.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.0.4}/tests/test_client.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.0.4}/tests/test_codec_regression_corpus.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.0.4}/tests/test_control_plane_parity_fixtures.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.0.4}/tests/test_docstring_cross_references.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.0.4}/tests/test_durable_selection.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.0.4}/tests/test_errors.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.0.4}/tests/test_external_storage.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.0.4}/tests/test_external_task_input.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.0.4}/tests/test_external_task_result.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.0.4}/tests/test_golden_history_replay.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.0.4}/tests/test_history_bundle_verify.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.0.4}/tests/test_history_event_contract.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.0.4}/tests/test_invocable.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.0.4}/tests/test_message_streams.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.0.4}/tests/test_metrics.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.0.4}/tests/test_order_processing_example.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.0.4}/tests/test_parallel_completion_order_replay.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.0.4}/tests/test_parallel_saga_authoring.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.0.4}/tests/test_public_boundary_scanner.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.0.4}/tests/test_pypi_project_surface.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.0.4}/tests/test_python_conformance.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.0.4}/tests/test_queries.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.0.4}/tests/test_readme_quickstart.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.0.4}/tests/test_replay.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.0.4}/tests/test_replay_conformance.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.0.4}/tests/test_replay_regression_corpus.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.0.4}/tests/test_replay_verify.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.0.4}/tests/test_retry_policy.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.0.4}/tests/test_schedules.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.0.4}/tests/test_serializer.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.0.4}/tests/test_signals.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.0.4}/tests/test_sleep.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.0.4}/tests/test_standalone_activity_client.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.0.4}/tests/test_sync.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.0.4}/tests/test_testing_harness.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.0.4}/tests/test_update_signal_condition_replay.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.0.4}/tests/test_updates.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.0.4}/tests/test_wait_condition.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.0.4}/tests/test_worker.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.0.4}/tests/test_workflow_result_timeout.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.0.4}/tests/test_workflow_streams.py +0 -0
- {durable_workflow-2.0.3 → durable_workflow-2.0.4}/tests/test_workflow_updates_conformance.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "durable-workflow"
|
|
7
|
-
version = "2.0.
|
|
7
|
+
version = "2.0.4"
|
|
8
8
|
description = "Python client and worker SDK for Durable Workflow Cloud and self-hosted Server"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -71,8 +71,8 @@ durable-workflow-replay-conformance = "durable_workflow.replay_conformance:main"
|
|
|
71
71
|
durable-workflow-workflow-updates-conformance = "durable_workflow.workflow_updates_conformance:main"
|
|
72
72
|
|
|
73
73
|
[tool.durable-workflow]
|
|
74
|
-
product-train = "2.0.
|
|
75
|
-
registry-version = "2.0.
|
|
74
|
+
product-train = "2.0.4"
|
|
75
|
+
registry-version = "2.0.4"
|
|
76
76
|
supported-server-versions = "2.0.0"
|
|
77
77
|
worker-protocol-version = "1.19"
|
|
78
78
|
control-plane-version = "2"
|
|
@@ -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
|
|
@@ -1734,6 +1757,9 @@ class Client:
|
|
|
1734
1757
|
worker=worker,
|
|
1735
1758
|
transport=transport,
|
|
1736
1759
|
uploaded={},
|
|
1760
|
+
completion=(
|
|
1761
|
+
_payload_completion_context(path, json) if worker and transport.completion_context else None
|
|
1762
|
+
),
|
|
1737
1763
|
)
|
|
1738
1764
|
|
|
1739
1765
|
start = time.perf_counter()
|
|
@@ -1904,11 +1930,15 @@ class Client:
|
|
|
1904
1930
|
)
|
|
1905
1931
|
|
|
1906
1932
|
status = policy.get("status")
|
|
1933
|
+
completion = upload.get("completion_context")
|
|
1907
1934
|
transport = _RuntimeExternalPayloadTransport(
|
|
1908
1935
|
threshold_bytes=threshold_bytes,
|
|
1909
1936
|
max_payload_bytes=max_payload_bytes,
|
|
1910
1937
|
request_timeout_seconds=float(request_timeout_seconds),
|
|
1911
1938
|
status=status if isinstance(status, str) else "unknown",
|
|
1939
|
+
completion_context=isinstance(completion, dict)
|
|
1940
|
+
and completion.get("schema") == _PAYLOAD_COMPLETION_SCHEMA
|
|
1941
|
+
and completion.get("header") == _PAYLOAD_COMPLETION_HEADER,
|
|
1912
1942
|
)
|
|
1913
1943
|
self._runtime_external_payload_transport_cache = transport
|
|
1914
1944
|
self._runtime_external_payload_transport_resolved = True
|
|
@@ -1972,6 +2002,8 @@ class Client:
|
|
|
1972
2002
|
worker: bool,
|
|
1973
2003
|
transport: _RuntimeExternalPayloadTransport,
|
|
1974
2004
|
uploaded: dict[tuple[str, str, int], RuntimeExternalPayloadReference],
|
|
2005
|
+
completion: dict[str, Any] | None = None,
|
|
2006
|
+
slot: tuple[str | int, ...] = (),
|
|
1975
2007
|
) -> Any:
|
|
1976
2008
|
if isinstance(value, dict):
|
|
1977
2009
|
if (
|
|
@@ -1984,6 +2016,8 @@ class Client:
|
|
|
1984
2016
|
worker=worker,
|
|
1985
2017
|
transport=transport,
|
|
1986
2018
|
uploaded=uploaded,
|
|
2019
|
+
completion=completion,
|
|
2020
|
+
slot=(*slot, "result"),
|
|
1987
2021
|
)
|
|
1988
2022
|
if "external_payload" in externalized_result:
|
|
1989
2023
|
normalized_command["result"] = externalized_result
|
|
@@ -2014,6 +2048,7 @@ class Client:
|
|
|
2014
2048
|
sha256=sha256,
|
|
2015
2049
|
worker=worker,
|
|
2016
2050
|
transport=transport,
|
|
2051
|
+
completion={**completion, "slot": list(slot)} if completion is not None else None,
|
|
2017
2052
|
)
|
|
2018
2053
|
uploaded[identity] = reference
|
|
2019
2054
|
return {"codec": codec, "external_payload": reference.to_dict()}
|
|
@@ -2024,6 +2059,8 @@ class Client:
|
|
|
2024
2059
|
worker=worker,
|
|
2025
2060
|
transport=transport,
|
|
2026
2061
|
uploaded=uploaded,
|
|
2062
|
+
completion=completion,
|
|
2063
|
+
slot=(*slot, key),
|
|
2027
2064
|
)
|
|
2028
2065
|
for key, item in value.items()
|
|
2029
2066
|
}
|
|
@@ -2045,8 +2082,10 @@ class Client:
|
|
|
2045
2082
|
worker=worker,
|
|
2046
2083
|
transport=transport,
|
|
2047
2084
|
uploaded=uploaded,
|
|
2085
|
+
completion=completion,
|
|
2086
|
+
slot=(*slot, index),
|
|
2048
2087
|
)
|
|
2049
|
-
for item in value
|
|
2088
|
+
for index, item in enumerate(value)
|
|
2050
2089
|
]
|
|
2051
2090
|
return value
|
|
2052
2091
|
|
|
@@ -2058,6 +2097,7 @@ class Client:
|
|
|
2058
2097
|
sha256: str,
|
|
2059
2098
|
worker: bool,
|
|
2060
2099
|
transport: _RuntimeExternalPayloadTransport,
|
|
2100
|
+
completion: dict[str, Any] | None = None,
|
|
2061
2101
|
) -> RuntimeExternalPayloadReference:
|
|
2062
2102
|
headers = self._headers(worker=worker)
|
|
2063
2103
|
headers.update({
|
|
@@ -2069,13 +2109,30 @@ class Client:
|
|
|
2069
2109
|
})
|
|
2070
2110
|
|
|
2071
2111
|
async def _do_request() -> httpx.Response:
|
|
2112
|
+
attempt_headers = dict(headers)
|
|
2072
2113
|
response = await self._http.request(
|
|
2073
2114
|
"POST",
|
|
2074
2115
|
f"/api{_RUNTIME_EXTERNAL_PAYLOAD_UPLOAD_PATH}",
|
|
2075
|
-
headers=
|
|
2116
|
+
headers=attempt_headers,
|
|
2076
2117
|
content=data,
|
|
2077
2118
|
timeout=transport.request_timeout_seconds,
|
|
2078
2119
|
)
|
|
2120
|
+
if (response.status_code == 503 and completion is not None
|
|
2121
|
+
and len(response.content) <= _RUNTIME_EXTERNAL_PAYLOAD_ERROR_BODY_LIMIT):
|
|
2122
|
+
try:
|
|
2123
|
+
refusal = response.json()
|
|
2124
|
+
except ValueError:
|
|
2125
|
+
refusal = None
|
|
2126
|
+
context = json_module.dumps(completion, separators=(",", ":"))
|
|
2127
|
+
if (isinstance(refusal, dict) and refusal.get("reason") == "storage_pressure"
|
|
2128
|
+
and refusal.get("storage_state") == "draining" and len(context.encode("utf-8")) <= 4096):
|
|
2129
|
+
# Keep the same identity if the ordinary transport policy retries
|
|
2130
|
+
# an ambiguous response; never rerun application activity code.
|
|
2131
|
+
attempt_headers[_PAYLOAD_COMPLETION_HEADER] = context
|
|
2132
|
+
response = await self._http.request(
|
|
2133
|
+
"POST", f"/api{_RUNTIME_EXTERNAL_PAYLOAD_UPLOAD_PATH}", headers=attempt_headers,
|
|
2134
|
+
content=data, timeout=transport.request_timeout_seconds,
|
|
2135
|
+
)
|
|
2079
2136
|
response.raise_for_status()
|
|
2080
2137
|
return response
|
|
2081
2138
|
|
|
@@ -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
|
|
@@ -41,9 +41,9 @@ def test_worker_release_identity_matches_supported_server_and_protocol() -> None
|
|
|
41
41
|
project = manifest["project"]
|
|
42
42
|
release = manifest["tool"]["durable-workflow"]
|
|
43
43
|
|
|
44
|
-
assert project["version"] == "2.0.
|
|
44
|
+
assert project["version"] == "2.0.4"
|
|
45
45
|
assert release["product-train"] == project["version"]
|
|
46
|
-
assert release["registry-version"] == "2.0.
|
|
46
|
+
assert release["registry-version"] == "2.0.4"
|
|
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
|
{durable_workflow-2.0.3 → durable_workflow-2.0.4}/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:
|
|
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.0.4}/src/durable_workflow/external_task_input.py
RENAMED
|
File without changes
|
{durable_workflow-2.0.3 → durable_workflow-2.0.4}/src/durable_workflow/external_task_result.py
RENAMED
|
File without changes
|
{durable_workflow-2.0.3 → durable_workflow-2.0.4}/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.0.4}/src/durable_workflow/python_conformance.py
RENAMED
|
File without changes
|
{durable_workflow-2.0.3 → durable_workflow-2.0.4}/src/durable_workflow/replay_conformance.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{durable_workflow-2.0.3 → durable_workflow-2.0.4}/src/durable_workflow.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{durable_workflow-2.0.3 → durable_workflow-2.0.4}/src/durable_workflow.egg-info/entry_points.txt
RENAMED
|
File without changes
|
{durable_workflow-2.0.3 → durable_workflow-2.0.4}/src/durable_workflow.egg-info/requires.txt
RENAMED
|
File without changes
|
{durable_workflow-2.0.3 → durable_workflow-2.0.4}/src/durable_workflow.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{durable_workflow-2.0.3 → durable_workflow-2.0.4}/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.0.4}/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
|
|
File without changes
|
|
File without changes
|
{durable_workflow-2.0.3 → durable_workflow-2.0.4}/tests/test_update_signal_condition_replay.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{durable_workflow-2.0.3 → durable_workflow-2.0.4}/tests/test_workflow_updates_conformance.py
RENAMED
|
File without changes
|