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