durable-workflow 2.3.3__tar.gz → 2.3.5__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.3.3/src/durable_workflow.egg-info → durable_workflow-2.3.5}/PKG-INFO +1 -1
- {durable_workflow-2.3.3 → durable_workflow-2.3.5}/pyproject.toml +3 -3
- {durable_workflow-2.3.3 → durable_workflow-2.3.5}/src/durable_workflow/client.py +70 -34
- {durable_workflow-2.3.3 → durable_workflow-2.3.5}/src/durable_workflow/retry_policy.py +30 -0
- {durable_workflow-2.3.3 → durable_workflow-2.3.5/src/durable_workflow.egg-info}/PKG-INFO +1 -1
- {durable_workflow-2.3.3 → durable_workflow-2.3.5}/tests/test_client.py +47 -0
- {durable_workflow-2.3.3 → durable_workflow-2.3.5}/tests/test_storage_admission.py +63 -4
- {durable_workflow-2.3.3 → durable_workflow-2.3.5}/tests/test_workflow_result_timeout.py +3 -1
- {durable_workflow-2.3.3 → durable_workflow-2.3.5}/LICENSE +0 -0
- {durable_workflow-2.3.3 → durable_workflow-2.3.5}/README.md +0 -0
- {durable_workflow-2.3.3 → durable_workflow-2.3.5}/setup.cfg +0 -0
- {durable_workflow-2.3.3 → durable_workflow-2.3.5}/src/durable_workflow/__init__.py +0 -0
- {durable_workflow-2.3.3 → durable_workflow-2.3.5}/src/durable_workflow/_avro.py +0 -0
- {durable_workflow-2.3.3 → durable_workflow-2.3.5}/src/durable_workflow/activity.py +0 -0
- {durable_workflow-2.3.3 → durable_workflow-2.3.5}/src/durable_workflow/auth_composition.py +0 -0
- {durable_workflow-2.3.3 → durable_workflow-2.3.5}/src/durable_workflow/errors.py +0 -0
- {durable_workflow-2.3.3 → durable_workflow-2.3.5}/src/durable_workflow/external_storage.py +0 -0
- {durable_workflow-2.3.3 → durable_workflow-2.3.5}/src/durable_workflow/external_task_input.py +0 -0
- {durable_workflow-2.3.3 → durable_workflow-2.3.5}/src/durable_workflow/external_task_result.py +0 -0
- {durable_workflow-2.3.3 → durable_workflow-2.3.5}/src/durable_workflow/history_bundle_verify.py +0 -0
- {durable_workflow-2.3.3 → durable_workflow-2.3.5}/src/durable_workflow/interceptors.py +0 -0
- {durable_workflow-2.3.3 → durable_workflow-2.3.5}/src/durable_workflow/invocable.py +0 -0
- {durable_workflow-2.3.3 → durable_workflow-2.3.5}/src/durable_workflow/metrics.py +0 -0
- {durable_workflow-2.3.3 → durable_workflow-2.3.5}/src/durable_workflow/nexus.py +0 -0
- {durable_workflow-2.3.3 → durable_workflow-2.3.5}/src/durable_workflow/py.typed +0 -0
- {durable_workflow-2.3.3 → durable_workflow-2.3.5}/src/durable_workflow/python_conformance.py +0 -0
- {durable_workflow-2.3.3 → durable_workflow-2.3.5}/src/durable_workflow/replay_conformance.py +0 -0
- {durable_workflow-2.3.3 → durable_workflow-2.3.5}/src/durable_workflow/replay_verify.py +0 -0
- {durable_workflow-2.3.3 → durable_workflow-2.3.5}/src/durable_workflow/serializer.py +0 -0
- {durable_workflow-2.3.3 → durable_workflow-2.3.5}/src/durable_workflow/sync.py +0 -0
- {durable_workflow-2.3.3 → durable_workflow-2.3.5}/src/durable_workflow/testing.py +0 -0
- {durable_workflow-2.3.3 → durable_workflow-2.3.5}/src/durable_workflow/worker.py +0 -0
- {durable_workflow-2.3.3 → durable_workflow-2.3.5}/src/durable_workflow/worker_session.py +0 -0
- {durable_workflow-2.3.3 → durable_workflow-2.3.5}/src/durable_workflow/workflow.py +0 -0
- {durable_workflow-2.3.3 → durable_workflow-2.3.5}/src/durable_workflow/workflow_updates_conformance.py +0 -0
- {durable_workflow-2.3.3 → durable_workflow-2.3.5}/src/durable_workflow.egg-info/SOURCES.txt +0 -0
- {durable_workflow-2.3.3 → durable_workflow-2.3.5}/src/durable_workflow.egg-info/dependency_links.txt +0 -0
- {durable_workflow-2.3.3 → durable_workflow-2.3.5}/src/durable_workflow.egg-info/entry_points.txt +0 -0
- {durable_workflow-2.3.3 → durable_workflow-2.3.5}/src/durable_workflow.egg-info/requires.txt +0 -0
- {durable_workflow-2.3.3 → durable_workflow-2.3.5}/src/durable_workflow.egg-info/top_level.txt +0 -0
- {durable_workflow-2.3.3 → durable_workflow-2.3.5}/tests/test_activity_context.py +0 -0
- {durable_workflow-2.3.3 → durable_workflow-2.3.5}/tests/test_auth_composition.py +0 -0
- {durable_workflow-2.3.3 → durable_workflow-2.3.5}/tests/test_avro_value_benchmark.py +0 -0
- {durable_workflow-2.3.3 → durable_workflow-2.3.5}/tests/test_avro_value_protocol.py +0 -0
- {durable_workflow-2.3.3 → durable_workflow-2.3.5}/tests/test_ci_checkout.py +0 -0
- {durable_workflow-2.3.3 → durable_workflow-2.3.5}/tests/test_ci_integration_endpoint.py +0 -0
- {durable_workflow-2.3.3 → durable_workflow-2.3.5}/tests/test_codec_regression_corpus.py +0 -0
- {durable_workflow-2.3.3 → durable_workflow-2.3.5}/tests/test_control_plane_parity_fixtures.py +0 -0
- {durable_workflow-2.3.3 → durable_workflow-2.3.5}/tests/test_docstring_cross_references.py +0 -0
- {durable_workflow-2.3.3 → durable_workflow-2.3.5}/tests/test_durable_selection.py +0 -0
- {durable_workflow-2.3.3 → durable_workflow-2.3.5}/tests/test_errors.py +0 -0
- {durable_workflow-2.3.3 → durable_workflow-2.3.5}/tests/test_external_storage.py +0 -0
- {durable_workflow-2.3.3 → durable_workflow-2.3.5}/tests/test_external_task_input.py +0 -0
- {durable_workflow-2.3.3 → durable_workflow-2.3.5}/tests/test_external_task_result.py +0 -0
- {durable_workflow-2.3.3 → durable_workflow-2.3.5}/tests/test_golden_history_replay.py +0 -0
- {durable_workflow-2.3.3 → durable_workflow-2.3.5}/tests/test_history_bundle_verify.py +0 -0
- {durable_workflow-2.3.3 → durable_workflow-2.3.5}/tests/test_history_event_contract.py +0 -0
- {durable_workflow-2.3.3 → durable_workflow-2.3.5}/tests/test_invocable.py +0 -0
- {durable_workflow-2.3.3 → durable_workflow-2.3.5}/tests/test_message_streams.py +0 -0
- {durable_workflow-2.3.3 → durable_workflow-2.3.5}/tests/test_metrics.py +0 -0
- {durable_workflow-2.3.3 → durable_workflow-2.3.5}/tests/test_order_processing_example.py +0 -0
- {durable_workflow-2.3.3 → durable_workflow-2.3.5}/tests/test_parallel_completion_order_replay.py +0 -0
- {durable_workflow-2.3.3 → durable_workflow-2.3.5}/tests/test_parallel_saga_authoring.py +0 -0
- {durable_workflow-2.3.3 → durable_workflow-2.3.5}/tests/test_public_boundary_scanner.py +0 -0
- {durable_workflow-2.3.3 → durable_workflow-2.3.5}/tests/test_pypi_project_surface.py +0 -0
- {durable_workflow-2.3.3 → durable_workflow-2.3.5}/tests/test_python_conformance.py +0 -0
- {durable_workflow-2.3.3 → durable_workflow-2.3.5}/tests/test_queries.py +0 -0
- {durable_workflow-2.3.3 → durable_workflow-2.3.5}/tests/test_readme_quickstart.py +0 -0
- {durable_workflow-2.3.3 → durable_workflow-2.3.5}/tests/test_redrive_replay.py +0 -0
- {durable_workflow-2.3.3 → durable_workflow-2.3.5}/tests/test_release_metadata.py +0 -0
- {durable_workflow-2.3.3 → durable_workflow-2.3.5}/tests/test_replay.py +0 -0
- {durable_workflow-2.3.3 → durable_workflow-2.3.5}/tests/test_replay_conformance.py +0 -0
- {durable_workflow-2.3.3 → durable_workflow-2.3.5}/tests/test_replay_regression_corpus.py +0 -0
- {durable_workflow-2.3.3 → durable_workflow-2.3.5}/tests/test_replay_verify.py +0 -0
- {durable_workflow-2.3.3 → durable_workflow-2.3.5}/tests/test_retry_policy.py +0 -0
- {durable_workflow-2.3.3 → durable_workflow-2.3.5}/tests/test_runtime_external_payload_transport.py +0 -0
- {durable_workflow-2.3.3 → durable_workflow-2.3.5}/tests/test_schedules.py +0 -0
- {durable_workflow-2.3.3 → durable_workflow-2.3.5}/tests/test_serializer.py +0 -0
- {durable_workflow-2.3.3 → durable_workflow-2.3.5}/tests/test_signals.py +0 -0
- {durable_workflow-2.3.3 → durable_workflow-2.3.5}/tests/test_sleep.py +0 -0
- {durable_workflow-2.3.3 → durable_workflow-2.3.5}/tests/test_standalone_activity_client.py +0 -0
- {durable_workflow-2.3.3 → durable_workflow-2.3.5}/tests/test_sync.py +0 -0
- {durable_workflow-2.3.3 → durable_workflow-2.3.5}/tests/test_testing_harness.py +0 -0
- {durable_workflow-2.3.3 → durable_workflow-2.3.5}/tests/test_update_signal_condition_replay.py +0 -0
- {durable_workflow-2.3.3 → durable_workflow-2.3.5}/tests/test_updates.py +0 -0
- {durable_workflow-2.3.3 → durable_workflow-2.3.5}/tests/test_wait_condition.py +0 -0
- {durable_workflow-2.3.3 → durable_workflow-2.3.5}/tests/test_worker.py +0 -0
- {durable_workflow-2.3.3 → durable_workflow-2.3.5}/tests/test_worker_session.py +0 -0
- {durable_workflow-2.3.3 → durable_workflow-2.3.5}/tests/test_workflow_streams.py +0 -0
- {durable_workflow-2.3.3 → durable_workflow-2.3.5}/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.3.
|
|
7
|
+
version = "2.3.5"
|
|
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.3.
|
|
75
|
-
registry-version = "2.3.
|
|
74
|
+
product-train = "2.3.5"
|
|
75
|
+
registry-version = "2.3.5"
|
|
76
76
|
supported-server-versions = "2.4.8"
|
|
77
77
|
worker-protocol-version = "1.19"
|
|
78
78
|
control-plane-version = "2"
|
|
@@ -1271,11 +1271,21 @@ class WorkflowHandle:
|
|
|
1271
1271
|
"""Return the server's current view of this workflow. See :meth:`Client.describe_workflow`."""
|
|
1272
1272
|
return await self._client.describe_workflow(self.workflow_id)
|
|
1273
1273
|
|
|
1274
|
-
async def get_history(
|
|
1275
|
-
|
|
1274
|
+
async def get_history(
|
|
1275
|
+
self,
|
|
1276
|
+
*,
|
|
1277
|
+
page_size: int | None = None,
|
|
1278
|
+
next_page_token: str | None = None,
|
|
1279
|
+
) -> Any:
|
|
1280
|
+
"""Fetch one page of this run's history. See :meth:`Client.get_history`."""
|
|
1276
1281
|
if self.run_id is None:
|
|
1277
1282
|
raise ValueError("run_id is required to fetch workflow history from a handle")
|
|
1278
|
-
|
|
1283
|
+
options: dict[str, Any] = {}
|
|
1284
|
+
if page_size is not None:
|
|
1285
|
+
options["page_size"] = page_size
|
|
1286
|
+
if next_page_token is not None:
|
|
1287
|
+
options["next_page_token"] = next_page_token
|
|
1288
|
+
return await self._client.get_history(self.workflow_id, self.run_id, **options)
|
|
1279
1289
|
|
|
1280
1290
|
async def export_history(self) -> Any:
|
|
1281
1291
|
"""Export this run's history as a replay bundle. See :meth:`Client.export_history`."""
|
|
@@ -3462,10 +3472,26 @@ class Client:
|
|
|
3462
3472
|
next_page_token=data.get("next_page_token"),
|
|
3463
3473
|
)
|
|
3464
3474
|
|
|
3465
|
-
async def get_history(
|
|
3466
|
-
|
|
3475
|
+
async def get_history(
|
|
3476
|
+
self,
|
|
3477
|
+
workflow_id: str,
|
|
3478
|
+
run_id: str,
|
|
3479
|
+
*,
|
|
3480
|
+
page_size: int | None = None,
|
|
3481
|
+
next_page_token: str | None = None,
|
|
3482
|
+
) -> Any:
|
|
3483
|
+
"""Fetch one history page; pass its opaque next_page_token for the next page."""
|
|
3484
|
+
params: dict[str, str] = {}
|
|
3485
|
+
if page_size is not None:
|
|
3486
|
+
params["page_size"] = str(page_size)
|
|
3487
|
+
if next_page_token is not None:
|
|
3488
|
+
params["next_page_token"] = next_page_token
|
|
3489
|
+
query = urlencode(params)
|
|
3490
|
+
path = f"/workflows/{workflow_id}/runs/{run_id}/history"
|
|
3491
|
+
if query:
|
|
3492
|
+
path += f"?{query}"
|
|
3467
3493
|
return await self._request(
|
|
3468
|
-
"GET",
|
|
3494
|
+
"GET", path, context=workflow_id
|
|
3469
3495
|
)
|
|
3470
3496
|
|
|
3471
3497
|
async def export_history(self, workflow_id: str, run_id: str) -> Any:
|
|
@@ -4240,34 +4266,44 @@ class Client:
|
|
|
4240
4266
|
run_id = handle.run_id or desc.run_id
|
|
4241
4267
|
if run_id is None:
|
|
4242
4268
|
raise WorkflowFailed("no run_id available to fetch history")
|
|
4243
|
-
|
|
4244
|
-
|
|
4245
|
-
|
|
4246
|
-
|
|
4247
|
-
|
|
4248
|
-
|
|
4249
|
-
|
|
4250
|
-
|
|
4251
|
-
|
|
4252
|
-
|
|
4253
|
-
|
|
4254
|
-
|
|
4255
|
-
|
|
4256
|
-
|
|
4257
|
-
|
|
4258
|
-
|
|
4259
|
-
|
|
4260
|
-
|
|
4261
|
-
|
|
4262
|
-
|
|
4263
|
-
|
|
4264
|
-
|
|
4265
|
-
|
|
4266
|
-
|
|
4267
|
-
|
|
4268
|
-
|
|
4269
|
-
|
|
4270
|
-
|
|
4269
|
+
page_token: str | None = None
|
|
4270
|
+
seen_tokens: set[str] = set()
|
|
4271
|
+
while True:
|
|
4272
|
+
history = await self.get_history(
|
|
4273
|
+
handle.workflow_id, run_id, page_size=1000, next_page_token=page_token
|
|
4274
|
+
)
|
|
4275
|
+
events = history.get("events", [])
|
|
4276
|
+
for ev in reversed(events):
|
|
4277
|
+
etype = ev.get("event_type")
|
|
4278
|
+
payload = ev.get("payload") or {}
|
|
4279
|
+
if etype == "WorkflowCompleted":
|
|
4280
|
+
return serializer.decode_envelope(
|
|
4281
|
+
payload.get("output"),
|
|
4282
|
+
codec=payload.get("payload_codec") or desc.payload_codec,
|
|
4283
|
+
external_storage=self.external_storage,
|
|
4284
|
+
external_storage_cache=self.external_storage_cache,
|
|
4285
|
+
)
|
|
4286
|
+
if etype == "WorkflowFailed":
|
|
4287
|
+
raise WorkflowFailed(
|
|
4288
|
+
payload.get("message", "workflow failed"),
|
|
4289
|
+
payload.get("exception_class"),
|
|
4290
|
+
)
|
|
4291
|
+
if etype == "WorkflowTerminated":
|
|
4292
|
+
raise WorkflowTerminated(
|
|
4293
|
+
payload.get("reason", "workflow was terminated")
|
|
4294
|
+
)
|
|
4295
|
+
if etype == "WorkflowCancelled":
|
|
4296
|
+
raise WorkflowCancelled(
|
|
4297
|
+
payload.get("reason", "workflow was cancelled")
|
|
4298
|
+
)
|
|
4299
|
+
if etype == "WorkflowTimedOut":
|
|
4300
|
+
raise WorkflowTimedOut()
|
|
4301
|
+
page_token = history.get("next_page_token")
|
|
4302
|
+
if not page_token:
|
|
4303
|
+
return None
|
|
4304
|
+
if page_token in seen_tokens:
|
|
4305
|
+
raise RuntimeError("history pagination repeated a page token")
|
|
4306
|
+
seen_tokens.add(page_token)
|
|
4271
4307
|
if asyncio.get_running_loop().time() > deadline:
|
|
4272
4308
|
raise TimeoutError(
|
|
4273
4309
|
f"workflow {handle.workflow_id} not terminal after {timeout}s (status={status})"
|
|
@@ -80,6 +80,18 @@ def _backend_unavailable_refusal(exc: Exception) -> tuple[bool, int | None]:
|
|
|
80
80
|
"/api/worker/heartbeat": "heartbeat_worker",
|
|
81
81
|
}
|
|
82
82
|
operation = next((name for path, name in operations.items() if request.url.path.endswith(path)), None)
|
|
83
|
+
_, task_heartbeat_marker, task_heartbeat_tail = request.url.path.rpartition(
|
|
84
|
+
"/api/worker/workflow-tasks/"
|
|
85
|
+
)
|
|
86
|
+
task_id = task_heartbeat_tail.removesuffix("/heartbeat")
|
|
87
|
+
task_heartbeat = (
|
|
88
|
+
bool(task_heartbeat_marker)
|
|
89
|
+
and task_heartbeat_tail.endswith("/heartbeat")
|
|
90
|
+
and bool(task_id)
|
|
91
|
+
and "/" not in task_id
|
|
92
|
+
)
|
|
93
|
+
if task_heartbeat:
|
|
94
|
+
operation = "heartbeat_workflow_task"
|
|
83
95
|
if operation is None:
|
|
84
96
|
return False, None
|
|
85
97
|
try:
|
|
@@ -97,6 +109,24 @@ def _backend_unavailable_refusal(exc: Exception) -> tuple[bool, int | None]:
|
|
|
97
109
|
worker_id = submitted.get("worker_id")
|
|
98
110
|
queue = submitted.get("task_queue")
|
|
99
111
|
delay = body.get("retry_after_seconds")
|
|
112
|
+
if task_heartbeat:
|
|
113
|
+
lease_owner = submitted.get("lease_owner")
|
|
114
|
+
attempt = submitted.get("workflow_task_attempt")
|
|
115
|
+
if (
|
|
116
|
+
not isinstance(lease_owner, str) or not lease_owner
|
|
117
|
+
or type(attempt) is not int or attempt <= 0
|
|
118
|
+
or body.get("operation") != operation
|
|
119
|
+
or body.get("outcome") != "unknown"
|
|
120
|
+
or body.get("worker_id") != lease_owner
|
|
121
|
+
or body.get("task_queue") is not None
|
|
122
|
+
or body.get("task_id") != task_id
|
|
123
|
+
or body.get("lease_owner") != lease_owner
|
|
124
|
+
or body.get("workflow_task_attempt") != attempt
|
|
125
|
+
or body.get("retryable") is not True
|
|
126
|
+
or type(delay) is not int or delay <= 0
|
|
127
|
+
):
|
|
128
|
+
return True, None
|
|
129
|
+
return True, delay
|
|
100
130
|
if (
|
|
101
131
|
not isinstance(worker_id, str) or not worker_id
|
|
102
132
|
or body.get("operation") != operation
|
|
@@ -791,6 +791,17 @@ class TestWorkflowRunVisibility:
|
|
|
791
791
|
|
|
792
792
|
|
|
793
793
|
class TestWorkflowHandleControlPlane:
|
|
794
|
+
@pytest.mark.asyncio
|
|
795
|
+
async def test_history_page_token_is_encoded(self, client: Client) -> None:
|
|
796
|
+
response = _mock_response(200, {"events": [], "next_page_token": None})
|
|
797
|
+
with patch.object(client._http, "request", new_callable=AsyncMock, return_value=response) as request:
|
|
798
|
+
page = await client.get_history("wf-1", "run-1", page_size=1000, next_page_token="opaque+/=")
|
|
799
|
+
|
|
800
|
+
assert page["events"] == []
|
|
801
|
+
assert request.call_args.args[1] == (
|
|
802
|
+
"/api/workflows/wf-1/runs/run-1/history?page_size=1000&next_page_token=opaque%2B%2F%3D"
|
|
803
|
+
)
|
|
804
|
+
|
|
794
805
|
@pytest.mark.asyncio
|
|
795
806
|
async def test_run_visibility_delegates_to_client(self, client: Client) -> None:
|
|
796
807
|
handle = WorkflowHandle(client, workflow_id="wf-1", run_id="r1", workflow_type="greeter")
|
|
@@ -3181,6 +3192,42 @@ class TestUpdateWorkflow:
|
|
|
3181
3192
|
|
|
3182
3193
|
|
|
3183
3194
|
class TestGetResult:
|
|
3195
|
+
@pytest.mark.asyncio
|
|
3196
|
+
async def test_completed_result_on_later_history_page(self, client: Client) -> None:
|
|
3197
|
+
handle = WorkflowHandle(client, workflow_id="wf-1", run_id="run-1", workflow_type="greeter")
|
|
3198
|
+
client.describe_workflow = AsyncMock(
|
|
3199
|
+
return_value=WorkflowExecution(
|
|
3200
|
+
workflow_id="wf-1", run_id="run-1", workflow_type="greeter", status="completed",
|
|
3201
|
+
)
|
|
3202
|
+
)
|
|
3203
|
+
client.get_history = AsyncMock(side_effect=[
|
|
3204
|
+
{"events": [{"event_type": "WorkflowStarted"}], "next_page_token": "page two"},
|
|
3205
|
+
{"events": [{"event_type": "WorkflowCompleted", "payload": {
|
|
3206
|
+
"output": serializer.encode(42, codec="avro"), "payload_codec": "avro",
|
|
3207
|
+
}}], "next_page_token": None},
|
|
3208
|
+
])
|
|
3209
|
+
|
|
3210
|
+
assert await client.get_result(handle) == 42
|
|
3211
|
+
assert client.get_history.await_args_list[0].args == ("wf-1", "run-1")
|
|
3212
|
+
assert client.get_history.await_args_list[0].kwargs == {"page_size": 1000, "next_page_token": None}
|
|
3213
|
+
assert client.get_history.await_args_list[1].kwargs == {
|
|
3214
|
+
"page_size": 1000, "next_page_token": "page two",
|
|
3215
|
+
}
|
|
3216
|
+
|
|
3217
|
+
@pytest.mark.asyncio
|
|
3218
|
+
async def test_repeated_history_page_token_fails(self, client: Client) -> None:
|
|
3219
|
+
handle = WorkflowHandle(client, workflow_id="wf-1", run_id="run-1", workflow_type="greeter")
|
|
3220
|
+
client.describe_workflow = AsyncMock(
|
|
3221
|
+
return_value=WorkflowExecution(
|
|
3222
|
+
workflow_id="wf-1", run_id="run-1", workflow_type="greeter", status="completed",
|
|
3223
|
+
)
|
|
3224
|
+
)
|
|
3225
|
+
client.get_history = AsyncMock(return_value={"events": [], "next_page_token": "same"})
|
|
3226
|
+
|
|
3227
|
+
with pytest.raises(RuntimeError, match="repeated a page token"):
|
|
3228
|
+
await client.get_result(handle)
|
|
3229
|
+
assert client.get_history.await_count == 2
|
|
3230
|
+
|
|
3184
3231
|
@pytest.mark.asyncio
|
|
3185
3232
|
async def test_completed_result_uses_event_payload_codec(self, client: Client) -> None:
|
|
3186
3233
|
handle = WorkflowHandle(client, workflow_id="wf-1", run_id="run-1", workflow_type="greeter")
|
|
@@ -42,7 +42,8 @@ def pressure(poll_id: str | None = None, *, reason: str = "storage_pressure", en
|
|
|
42
42
|
def backend_unavailable(request: httpx.Request) -> dict[str, Any]:
|
|
43
43
|
submitted = json.loads(request.content)
|
|
44
44
|
path = request.url.path
|
|
45
|
-
|
|
45
|
+
task_heartbeat = "/api/worker/workflow-tasks/" in path and path.endswith("/heartbeat")
|
|
46
|
+
operation = "heartbeat_workflow_task" if task_heartbeat else {
|
|
46
47
|
"/api/worker/workflow-tasks/poll": "poll_workflow_task",
|
|
47
48
|
"/api/worker/activity-tasks/poll": "poll_activity_task",
|
|
48
49
|
"/api/worker/query-tasks/poll": "poll_query_task",
|
|
@@ -51,9 +52,16 @@ def backend_unavailable(request: httpx.Request) -> dict[str, Any]:
|
|
|
51
52
|
}[path]
|
|
52
53
|
body: dict[str, Any] = {
|
|
53
54
|
"reason": "backend_unavailable", "operation": operation, "outcome": "unknown",
|
|
54
|
-
"worker_id": submitted
|
|
55
|
+
"worker_id": submitted.get("lease_owner") if task_heartbeat else submitted["worker_id"],
|
|
56
|
+
"task_queue": submitted.get("task_queue"),
|
|
55
57
|
"retryable": True, "retry_after_seconds": 1,
|
|
56
58
|
}
|
|
59
|
+
if task_heartbeat:
|
|
60
|
+
body.update({
|
|
61
|
+
"task_id": path.rpartition("/api/worker/workflow-tasks/")[2].removesuffix("/heartbeat"),
|
|
62
|
+
"lease_owner": submitted["lease_owner"],
|
|
63
|
+
"workflow_task_attempt": submitted["workflow_task_attempt"],
|
|
64
|
+
})
|
|
57
65
|
if path.endswith("/poll"):
|
|
58
66
|
body.update({
|
|
59
67
|
"task": None, "poll_status": "backend_unavailable",
|
|
@@ -85,9 +93,9 @@ def retry_sleeps(monkeypatch: pytest.MonkeyPatch) -> list[float]:
|
|
|
85
93
|
return sleeps
|
|
86
94
|
|
|
87
95
|
|
|
88
|
-
def client_for(handler: Callable[..., Any]) -> Client:
|
|
96
|
+
def client_for(handler: Callable[..., Any], base_url: str = "https://runtime.example") -> Client:
|
|
89
97
|
client = Client(
|
|
90
|
-
|
|
98
|
+
base_url, token="test-runtime-token",
|
|
91
99
|
retry_policy=TransportRetryPolicy(max_attempts=2, initial_backoff_seconds=0, jitter=False),
|
|
92
100
|
)
|
|
93
101
|
client._http = httpx.AsyncClient(base_url=client.base_url, transport=httpx.MockTransport(handler))
|
|
@@ -186,6 +194,57 @@ async def test_backend_outage_retries_worker_registration_and_heartbeat(
|
|
|
186
194
|
assert sum(retry_sleeps) == pytest.approx(4)
|
|
187
195
|
|
|
188
196
|
|
|
197
|
+
@pytest.mark.parametrize("base_url", ["https://runtime.example", "https://runtime.example/managed/namespace"])
|
|
198
|
+
async def test_backend_outage_retries_workflow_task_heartbeat_with_same_fence(
|
|
199
|
+
base_url: str, retry_sleeps: list[float],
|
|
200
|
+
) -> None:
|
|
201
|
+
requests: list[bytes] = []
|
|
202
|
+
|
|
203
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
204
|
+
requests.append(request.content)
|
|
205
|
+
if len(requests) <= 4:
|
|
206
|
+
return httpx.Response(503, json=backend_unavailable(request))
|
|
207
|
+
return httpx.Response(200, json={
|
|
208
|
+
"task_id": "task-1", "lease_owner": "worker-1",
|
|
209
|
+
"workflow_task_attempt": 3, "renewed": True,
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
async with client_for(handler, base_url) as client:
|
|
213
|
+
with worker_scope():
|
|
214
|
+
result = await client.heartbeat_workflow_task(
|
|
215
|
+
task_id="task-1", lease_owner="worker-1", workflow_task_attempt=3,
|
|
216
|
+
)
|
|
217
|
+
assert result["renewed"] is True
|
|
218
|
+
assert len(requests) == 5
|
|
219
|
+
assert len(set(requests)) == 1
|
|
220
|
+
assert sum(retry_sleeps) == pytest.approx(4)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
@pytest.mark.parametrize("override", [
|
|
224
|
+
{"task_id": "other-task"}, {"lease_owner": "other-worker"},
|
|
225
|
+
{"worker_id": "other-worker"}, {"workflow_task_attempt": 4},
|
|
226
|
+
{"operation": "heartbeat_worker"}, {"outcome": "failed"},
|
|
227
|
+
{"retryable": False}, {"retry_after_seconds": 0},
|
|
228
|
+
])
|
|
229
|
+
async def test_invalid_workflow_task_heartbeat_backend_response_is_not_retried(
|
|
230
|
+
override: dict[str, Any], retry_sleeps: list[float],
|
|
231
|
+
) -> None:
|
|
232
|
+
calls = 0
|
|
233
|
+
|
|
234
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
235
|
+
nonlocal calls
|
|
236
|
+
calls += 1
|
|
237
|
+
return httpx.Response(503, json={**backend_unavailable(request), **override})
|
|
238
|
+
|
|
239
|
+
async with client_for(handler) as client:
|
|
240
|
+
with worker_scope(), pytest.raises(ServerError):
|
|
241
|
+
await client.heartbeat_workflow_task(
|
|
242
|
+
task_id="task-1", lease_owner="worker-1", workflow_task_attempt=3,
|
|
243
|
+
)
|
|
244
|
+
assert calls == 1
|
|
245
|
+
assert not retry_sleeps
|
|
246
|
+
|
|
247
|
+
|
|
189
248
|
@pytest.mark.parametrize("override", [
|
|
190
249
|
{"operation": "poll_activity_task"},
|
|
191
250
|
{"outcome": "failed"}, {"worker_id": "other-worker"},
|
|
@@ -28,7 +28,9 @@ async def test_persisted_deadline_raises_typed_timeout_for_selected_history(kind
|
|
|
28
28
|
try:
|
|
29
29
|
with pytest.raises(WorkflowTimedOut, match="workflow execution timed out"):
|
|
30
30
|
await client.get_result(handle, timeout=0)
|
|
31
|
-
client.get_history.assert_awaited_once_with(
|
|
31
|
+
client.get_history.assert_awaited_once_with(
|
|
32
|
+
"order", "selected-run", page_size=1000, next_page_token=None
|
|
33
|
+
)
|
|
32
34
|
finally:
|
|
33
35
|
await client.aclose()
|
|
34
36
|
|
|
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.3.3 → durable_workflow-2.3.5}/src/durable_workflow/external_task_input.py
RENAMED
|
File without changes
|
{durable_workflow-2.3.3 → durable_workflow-2.3.5}/src/durable_workflow/external_task_result.py
RENAMED
|
File without changes
|
{durable_workflow-2.3.3 → durable_workflow-2.3.5}/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.3.3 → durable_workflow-2.3.5}/src/durable_workflow/python_conformance.py
RENAMED
|
File without changes
|
{durable_workflow-2.3.3 → durable_workflow-2.3.5}/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
|
|
File without changes
|
{durable_workflow-2.3.3 → durable_workflow-2.3.5}/src/durable_workflow.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{durable_workflow-2.3.3 → durable_workflow-2.3.5}/src/durable_workflow.egg-info/entry_points.txt
RENAMED
|
File without changes
|
{durable_workflow-2.3.3 → durable_workflow-2.3.5}/src/durable_workflow.egg-info/requires.txt
RENAMED
|
File without changes
|
{durable_workflow-2.3.3 → durable_workflow-2.3.5}/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.3.3 → durable_workflow-2.3.5}/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.3.3 → durable_workflow-2.3.5}/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
|
{durable_workflow-2.3.3 → durable_workflow-2.3.5}/tests/test_runtime_external_payload_transport.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
|
{durable_workflow-2.3.3 → durable_workflow-2.3.5}/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.3.3 → durable_workflow-2.3.5}/tests/test_workflow_updates_conformance.py
RENAMED
|
File without changes
|