durable-workflow 2.5.11__tar.gz → 2.5.13__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.5.11/src/durable_workflow.egg-info → durable_workflow-2.5.13}/PKG-INFO +1 -1
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/pyproject.toml +3 -3
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/src/durable_workflow/client.py +3 -1
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/src/durable_workflow/retry_policy.py +22 -1
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/src/durable_workflow/workflow.py +27 -6
- {durable_workflow-2.5.11 → durable_workflow-2.5.13/src/durable_workflow.egg-info}/PKG-INFO +1 -1
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/src/durable_workflow.egg-info/SOURCES.txt +2 -0
- durable_workflow-2.5.13/tests/test_query_timeout_retry.py +73 -0
- durable_workflow-2.5.13/tests/test_replay_clock.py +93 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_replay_regression_corpus.py +2 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/LICENSE +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/README.md +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/setup.cfg +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/src/durable_workflow/__init__.py +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/src/durable_workflow/_activity_process.py +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/src/durable_workflow/_avro.py +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/src/durable_workflow/_cancellation_scope.py +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/src/durable_workflow/_cancellation_scope_history.py +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/src/durable_workflow/_cooperative_cancellation.py +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/src/durable_workflow/_prepared_local_activity.py +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/src/durable_workflow/_sticky_workflow_cache.py +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/src/durable_workflow/activity.py +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/src/durable_workflow/auth_composition.py +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/src/durable_workflow/cancellation.py +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/src/durable_workflow/errors.py +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/src/durable_workflow/external_storage.py +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/src/durable_workflow/external_task_input.py +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/src/durable_workflow/external_task_result.py +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/src/durable_workflow/history_bundle_verify.py +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/src/durable_workflow/interceptors.py +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/src/durable_workflow/invocable.py +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/src/durable_workflow/metrics.py +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/src/durable_workflow/nexus.py +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/src/durable_workflow/py.typed +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/src/durable_workflow/python_conformance.py +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/src/durable_workflow/replay_conformance.py +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/src/durable_workflow/replay_verify.py +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/src/durable_workflow/serializer.py +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/src/durable_workflow/sync.py +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/src/durable_workflow/testing.py +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/src/durable_workflow/worker.py +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/src/durable_workflow/worker_session.py +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/src/durable_workflow/workflow_updates_conformance.py +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/src/durable_workflow.egg-info/dependency_links.txt +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/src/durable_workflow.egg-info/entry_points.txt +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/src/durable_workflow.egg-info/requires.txt +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/src/durable_workflow.egg-info/top_level.txt +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_activity_cancellation_policies.py +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_activity_context.py +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_activity_process.py +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_auth_composition.py +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_avro_value_benchmark.py +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_avro_value_protocol.py +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_cancellation_context.py +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_cancellation_remaining_time.py +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_cancellation_scope_admission.py +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_cancellation_scope_authoring.py +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_cancellation_scope_boundary.py +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_cancellation_scope_opening.py +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_child_workflow_policies.py +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_ci_checkout.py +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_ci_integration_endpoint.py +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_client.py +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_client_timeouts.py +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_codec_regression_corpus.py +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_committed_cancellation_scope_descendants.py +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_committed_cancellation_scope_groups.py +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_committed_cancellation_scope_history.py +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_committed_cancellation_scope_replay.py +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_condition_replay_drift.py +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_control_plane_parity_fixtures.py +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_cooperative_cancellation.py +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_cooperative_cancellation_client.py +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_cooperative_cancellation_worker.py +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_cooperative_remote_worker.py +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_docstring_cross_references.py +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_durable_selection.py +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_errors.py +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_external_storage.py +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_external_task_input.py +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_external_task_result.py +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_golden_history_replay.py +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_history_bundle_verify.py +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_history_event_contract.py +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_invocable.py +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_message_streams.py +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_metrics.py +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_nexus_namespace.py +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_nexus_wire.py +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_order_processing_example.py +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_parallel_completion_order_replay.py +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_parallel_saga_authoring.py +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_patch_history.py +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_pending_operation_replay.py +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_prepared_local_activity_client.py +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_prepared_local_activity_groups.py +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_prepared_local_activity_policies.py +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_prepared_local_activity_worker.py +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_public_boundary_scanner.py +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_pypi_project_surface.py +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_python_conformance.py +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_queries.py +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_readme_quickstart.py +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_redrive_replay.py +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_registration_ambiguity.py +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_release_metadata.py +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_replay.py +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_replay_conformance.py +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_replay_verify.py +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_retry_policy.py +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_run_cancellation_scope_replay.py +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_runtime_external_payload_transport.py +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_schedules.py +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_scoped_run_cancellation_context.py +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_serializer.py +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_signals.py +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_sleep.py +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_standalone_activity_client.py +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_sticky_execution.py +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_storage_admission.py +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_sync.py +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_testing_harness.py +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_update_signal_condition_replay.py +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_updates.py +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_wait_condition.py +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_worker.py +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_worker_session.py +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_workflow_entrypoint_validation.py +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_workflow_result_timeout.py +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_workflow_streams.py +0 -0
- {durable_workflow-2.5.11 → durable_workflow-2.5.13}/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.5.
|
|
7
|
+
version = "2.5.13"
|
|
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.5.
|
|
75
|
-
registry-version = "2.5.
|
|
74
|
+
product-train = "2.5.13"
|
|
75
|
+
registry-version = "2.5.13"
|
|
76
76
|
supported-server-versions = "2.5.16"
|
|
77
77
|
worker-protocol-version = "1.20"
|
|
78
78
|
control-plane-version = "2"
|
|
@@ -4323,7 +4323,9 @@ class Client:
|
|
|
4323
4323
|
Queries are synchronous and non-mutating. The server runs the named
|
|
4324
4324
|
query handler inside the workflow process and returns the decoded
|
|
4325
4325
|
result. Raises :class:`~durable_workflow.errors.QueryFailed` if the
|
|
4326
|
-
query was rejected or the
|
|
4326
|
+
query was rejected, the handler errored, or the original query timed
|
|
4327
|
+
out. A canonical query timeout is surfaced without automatically
|
|
4328
|
+
admitting another query.
|
|
4327
4329
|
"""
|
|
4328
4330
|
await self._require_query_support()
|
|
4329
4331
|
body: dict[str, Any] = {}
|
|
@@ -17,6 +17,7 @@ import contextvars
|
|
|
17
17
|
import json
|
|
18
18
|
import logging
|
|
19
19
|
import random
|
|
20
|
+
import re
|
|
20
21
|
from collections.abc import Awaitable, Callable
|
|
21
22
|
from dataclasses import dataclass
|
|
22
23
|
from typing import TypeVar
|
|
@@ -34,6 +35,24 @@ _worker_storage_admission_stop: contextvars.ContextVar[Callable[[], bool] | None
|
|
|
34
35
|
)
|
|
35
36
|
|
|
36
37
|
|
|
38
|
+
def _query_timeout_refusal(exc: httpx.HTTPStatusError) -> bool:
|
|
39
|
+
request = exc.request
|
|
40
|
+
if (exc.response.status_code != 504 or request.method != "POST"
|
|
41
|
+
or request.headers.get("X-Durable-Workflow-Control-Plane-Version") != "2"
|
|
42
|
+
or re.search(r"/api/workflows/[^/]+/query/[^/]+$", request.url.path) is None):
|
|
43
|
+
return False
|
|
44
|
+
try:
|
|
45
|
+
body = exc.response.json()
|
|
46
|
+
except ValueError:
|
|
47
|
+
return False
|
|
48
|
+
# This response ends an admitted query. Another POST creates a new task,
|
|
49
|
+
# rather than retrying the original task or its remaining caller budget.
|
|
50
|
+
reason = body.get("reason") if isinstance(body, dict) else None
|
|
51
|
+
return isinstance(reason, str) and reason in {
|
|
52
|
+
"query_worker_timeout", "query_worker_execution_timeout", "query_task_not_claimed",
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
37
56
|
def _storage_refusal(exc: Exception) -> tuple[ServerError, str | None] | None:
|
|
38
57
|
if not isinstance(exc, httpx.HTTPStatusError):
|
|
39
58
|
return None
|
|
@@ -194,6 +213,8 @@ class TransportRetryPolicy:
|
|
|
194
213
|
:class:`durable_workflow.workflow.ChildWorkflowRetryPolicy`.
|
|
195
214
|
|
|
196
215
|
Uses exponential backoff with jitter to avoid thundering herd.
|
|
216
|
+
Canonical query timeouts end the original query and are surfaced without
|
|
217
|
+
another query admission; unrelated transient HTTP failures still retry.
|
|
197
218
|
"""
|
|
198
219
|
|
|
199
220
|
max_attempts: int = 3
|
|
@@ -213,7 +234,7 @@ class TransportRetryPolicy:
|
|
|
213
234
|
|
|
214
235
|
# Retry 5xx server errors and 429 rate limit
|
|
215
236
|
if isinstance(exc, httpx.HTTPStatusError):
|
|
216
|
-
if _poll_capacity_refusal(exc):
|
|
237
|
+
if _query_timeout_refusal(exc) or _poll_capacity_refusal(exc):
|
|
217
238
|
return False
|
|
218
239
|
return exc.response.status_code >= 500 or exc.response.status_code == 429
|
|
219
240
|
|
|
@@ -1937,10 +1937,14 @@ class WorkflowContext:
|
|
|
1937
1937
|
workflow_command_id: str | None = None,
|
|
1938
1938
|
cancel_requested: bool = False,
|
|
1939
1939
|
allow_cancellation_scope_authoring: bool = False,
|
|
1940
|
+
_allow_wall_clock: bool = True,
|
|
1940
1941
|
) -> None:
|
|
1941
1942
|
self._workflow_id = workflow_id
|
|
1942
1943
|
self._run_id = run_id
|
|
1943
|
-
self._current_time = current_time
|
|
1944
|
+
self._current_time = current_time
|
|
1945
|
+
if self._current_time is None and _allow_wall_clock:
|
|
1946
|
+
self._current_time = datetime.now(timezone.utc)
|
|
1947
|
+
self._replay_clock_error: str | None = None
|
|
1944
1948
|
self._workflow_command_id = workflow_command_id or run_id or workflow_id
|
|
1945
1949
|
self._cancel_requested = bool(cancel_requested)
|
|
1946
1950
|
self._cancellation_request_id: str | None = None
|
|
@@ -2538,6 +2542,12 @@ class WorkflowContext:
|
|
|
2538
2542
|
return ContinueAsNew(workflow_type=workflow_type, arguments=list(args), task_queue=task_queue)
|
|
2539
2543
|
|
|
2540
2544
|
def now(self) -> datetime:
|
|
2545
|
+
"""Return the persisted workflow-start time, fixed during replay."""
|
|
2546
|
+
if self._current_time is None:
|
|
2547
|
+
raise NonDeterministicReplayError(
|
|
2548
|
+
0, "persisted workflow-start clock", [],
|
|
2549
|
+
detail=self._replay_clock_error or "Workflow replay clock unavailable: missing persisted start time",
|
|
2550
|
+
)
|
|
2541
2551
|
return self._current_time
|
|
2542
2552
|
|
|
2543
2553
|
def random(self) -> random.Random:
|
|
@@ -2548,7 +2558,7 @@ class WorkflowContext:
|
|
|
2548
2558
|
return uuid.UUID(bytes=rand_bytes, version=4)
|
|
2549
2559
|
|
|
2550
2560
|
def uuid7(self) -> uuid.UUID:
|
|
2551
|
-
timestamp_ms = int(self.
|
|
2561
|
+
timestamp_ms = int(self.now().timestamp() * 1000)
|
|
2552
2562
|
if timestamp_ms < 0 or timestamp_ms >= (1 << 48):
|
|
2553
2563
|
raise ValueError("ctx.uuid7() requires ctx.now() within the UUIDv7 timestamp range")
|
|
2554
2564
|
|
|
@@ -4276,13 +4286,22 @@ def _replay_state(
|
|
|
4276
4286
|
resolved_sequences.add(sequence)
|
|
4277
4287
|
|
|
4278
4288
|
workflow_start_time: datetime | None = None
|
|
4289
|
+
replay_clock_error = "Workflow replay clock unavailable: missing WorkflowStarted persisted start time"
|
|
4279
4290
|
for ev in events:
|
|
4280
4291
|
etype = _history_event_type(ev)
|
|
4281
4292
|
if etype == "WorkflowStarted":
|
|
4282
|
-
|
|
4283
|
-
if
|
|
4284
|
-
|
|
4285
|
-
|
|
4293
|
+
start_payload = ev.get("payload")
|
|
4294
|
+
if not isinstance(start_payload, Mapping):
|
|
4295
|
+
start_payload = {}
|
|
4296
|
+
explicit_time = "timestamp" in start_payload
|
|
4297
|
+
ts = start_payload.get("timestamp") if explicit_time else ev.get("recorded_at")
|
|
4298
|
+
clock_source = "payload.timestamp" if explicit_time else "recorded_at"
|
|
4299
|
+
replay_clock_error = f"Workflow replay clock unavailable: invalid or missing WorkflowStarted.{clock_source}"
|
|
4300
|
+
if isinstance(ts, str):
|
|
4301
|
+
with contextlib.suppress(ValueError):
|
|
4302
|
+
parsed_time = datetime.fromisoformat(ts.replace("Z", "+00:00"))
|
|
4303
|
+
if parsed_time.tzinfo is not None and parsed_time.utcoffset() is not None:
|
|
4304
|
+
workflow_start_time = parsed_time
|
|
4286
4305
|
break
|
|
4287
4306
|
|
|
4288
4307
|
for sequence, membership in scope_history.memberships.items():
|
|
@@ -4298,7 +4317,9 @@ def _replay_state(
|
|
|
4298
4317
|
workflow_command_id=workflow_command_id,
|
|
4299
4318
|
cancel_requested=cancel_requested,
|
|
4300
4319
|
allow_cancellation_scope_authoring=allow_cancellation_scope_authoring,
|
|
4320
|
+
_allow_wall_clock=not events,
|
|
4301
4321
|
)
|
|
4322
|
+
ctx._replay_clock_error = replay_clock_error if workflow_start_time is None else None
|
|
4302
4323
|
# Continue-as-new cursor checkpoints describe instance state that predates
|
|
4303
4324
|
# this run. Seed them before the workflow opens its first stream wait so
|
|
4304
4325
|
# both the durable wait key and worker acknowledgement use the global
|
{durable_workflow-2.5.11 → durable_workflow-2.5.13}/src/durable_workflow.egg-info/SOURCES.txt
RENAMED
|
@@ -95,11 +95,13 @@ tests/test_public_boundary_scanner.py
|
|
|
95
95
|
tests/test_pypi_project_surface.py
|
|
96
96
|
tests/test_python_conformance.py
|
|
97
97
|
tests/test_queries.py
|
|
98
|
+
tests/test_query_timeout_retry.py
|
|
98
99
|
tests/test_readme_quickstart.py
|
|
99
100
|
tests/test_redrive_replay.py
|
|
100
101
|
tests/test_registration_ambiguity.py
|
|
101
102
|
tests/test_release_metadata.py
|
|
102
103
|
tests/test_replay.py
|
|
104
|
+
tests/test_replay_clock.py
|
|
103
105
|
tests/test_replay_conformance.py
|
|
104
106
|
tests/test_replay_regression_corpus.py
|
|
105
107
|
tests/test_replay_verify.py
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from durable_workflow import Client
|
|
7
|
+
from durable_workflow.errors import QueryFailed
|
|
8
|
+
from durable_workflow.retry_policy import TransportRetryPolicy
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@pytest.mark.parametrize("protocol", ["1.20", "1.19"])
|
|
12
|
+
@pytest.mark.parametrize("reason", [
|
|
13
|
+
"query_worker_execution_timeout", "query_task_not_claimed", "query_worker_timeout",
|
|
14
|
+
])
|
|
15
|
+
@pytest.mark.parametrize("prefix", ["", "/api/runtime/v1/namespaces/example"])
|
|
16
|
+
async def test_query_exposes_original_canonical_timeout_without_another_admission(
|
|
17
|
+
monkeypatch: pytest.MonkeyPatch, protocol: str, reason: str, prefix: str,
|
|
18
|
+
) -> None:
|
|
19
|
+
monkeypatch.setenv("DURABLE_WORKFLOW_WORKER_PROTOCOL_VERSION", protocol)
|
|
20
|
+
original = {"reason": reason, "message": "Original query ended.",
|
|
21
|
+
"control_plane": {"operation": "query", "reason": reason}}
|
|
22
|
+
admissions = 0
|
|
23
|
+
discovery_protocols = []
|
|
24
|
+
|
|
25
|
+
def handle(request: httpx.Request) -> httpx.Response:
|
|
26
|
+
nonlocal admissions
|
|
27
|
+
if request.url.path.endswith("/api/cluster/info"):
|
|
28
|
+
discovery_protocols.append(request.headers["X-Durable-Workflow-Protocol-Version"])
|
|
29
|
+
return httpx.Response(200, json={
|
|
30
|
+
"worker_protocol": {"server_capabilities": {"query_tasks": True}},
|
|
31
|
+
})
|
|
32
|
+
assert request.url.path == prefix + "/api/workflows/original/query/total"
|
|
33
|
+
assert request.headers["X-Durable-Workflow-Control-Plane-Version"] == "2"
|
|
34
|
+
admissions += 1
|
|
35
|
+
if admissions == 1:
|
|
36
|
+
return httpx.Response(504, json=original)
|
|
37
|
+
# The old policy hides the original failure behind a distinct admission.
|
|
38
|
+
return httpx.Response(200, json={"result": "replacement-query"})
|
|
39
|
+
|
|
40
|
+
async with Client("https://runtime.example" + prefix, worker_token="worker", control_token="control",
|
|
41
|
+
retry_policy=TransportRetryPolicy(initial_backoff_seconds=0, jitter=False)) as client:
|
|
42
|
+
await client._http.aclose()
|
|
43
|
+
client._http = httpx.AsyncClient(base_url=client.base_url, transport=httpx.MockTransport(handle))
|
|
44
|
+
with pytest.raises(QueryFailed) as failure:
|
|
45
|
+
await client.query_workflow("original", "total")
|
|
46
|
+
assert admissions == 1
|
|
47
|
+
assert discovery_protocols == [protocol]
|
|
48
|
+
assert failure.value.status == 504 and failure.value.reason == reason
|
|
49
|
+
assert failure.value.body == original
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@pytest.mark.parametrize(("method", "path", "status", "body", "headers"), [
|
|
53
|
+
("POST", "/api/workflows/w/query/q", 504, {"reason": "gateway_timeout"},
|
|
54
|
+
{"X-Durable-Workflow-Control-Plane-Version": "2"}),
|
|
55
|
+
("POST", "/api/workflows/w/query/q", 503, {"reason": "query_worker_timeout"},
|
|
56
|
+
{"X-Durable-Workflow-Control-Plane-Version": "2"}),
|
|
57
|
+
("GET", "/api/workflows/w/query/q", 504, {"reason": "query_worker_timeout"},
|
|
58
|
+
{"X-Durable-Workflow-Control-Plane-Version": "2"}),
|
|
59
|
+
("POST", "/api/workflows/w/signals/q", 504, {"reason": "query_worker_timeout"},
|
|
60
|
+
{"X-Durable-Workflow-Control-Plane-Version": "2"}),
|
|
61
|
+
("POST", "/api/workflows/w/query/q", 504, {"reason": "query_worker_timeout"}, {}),
|
|
62
|
+
("POST", "/api/workflows/w/query/q", 504, [],
|
|
63
|
+
{"X-Durable-Workflow-Control-Plane-Version": "2"}),
|
|
64
|
+
("POST", "/api/workflows/w/query/q", 504, {"reason": []},
|
|
65
|
+
{"X-Durable-Workflow-Control-Plane-Version": "2"}),
|
|
66
|
+
])
|
|
67
|
+
def test_other_transport_failures_keep_existing_retry_policy(
|
|
68
|
+
method: str, path: str, status: int, body: object, headers: dict[str, str],
|
|
69
|
+
) -> None:
|
|
70
|
+
request = httpx.Request(method, "https://runtime.example" + path, headers=headers)
|
|
71
|
+
response = httpx.Response(status, request=request, json=body)
|
|
72
|
+
error = httpx.HTTPStatusError("transport failure", request=request, response=response)
|
|
73
|
+
assert TransportRetryPolicy().should_retry(error, 0) is True
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from copy import deepcopy
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from typing import Any
|
|
6
|
+
from uuid import UUID
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from durable_workflow import workflow
|
|
11
|
+
from durable_workflow.errors import NonDeterministicReplayError
|
|
12
|
+
from durable_workflow.workflow import CompleteWorkflow, WorkflowContext, replay
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@workflow.defn(name="tests.replay.persisted-clock")
|
|
16
|
+
class PersistedClockWorkflow:
|
|
17
|
+
def run(self, ctx: WorkflowContext) -> str:
|
|
18
|
+
return ctx.now().isoformat(timespec="microseconds")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@workflow.defn(name="tests.replay.persisted-uuid7")
|
|
22
|
+
class PersistedUUID7Workflow:
|
|
23
|
+
def run(self, ctx: WorkflowContext) -> str:
|
|
24
|
+
return str(ctx.uuid7())
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@workflow.defn(name="tests.replay.marker-clock")
|
|
28
|
+
class MarkerClockWorkflow:
|
|
29
|
+
def run(self, ctx: WorkflowContext): # type: ignore[no-untyped-def]
|
|
30
|
+
before = ctx.now().isoformat(timespec="microseconds")
|
|
31
|
+
version = yield ctx.get_version("clock-case", 0, 7)
|
|
32
|
+
after = ctx.now().isoformat(timespec="microseconds")
|
|
33
|
+
return [before, version, after, str(ctx.uuid7())]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def result(workflow_cls: type, history: list[dict[str, Any]]) -> Any:
|
|
37
|
+
outcome = replay(workflow_cls, history, [], run_id="original-clock-run", payload_codec="avro")
|
|
38
|
+
assert len(outcome.commands) == 1
|
|
39
|
+
command = outcome.commands[0]
|
|
40
|
+
assert isinstance(command, CompleteWorkflow)
|
|
41
|
+
return command.result
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_recorded_start_time_survives_repeated_cold_reads_and_later_marker() -> None:
|
|
45
|
+
history = [
|
|
46
|
+
{"event_type": "WorkflowStarted", "recorded_at": "2026-06-01T12:00:00.123456Z", "payload": {}},
|
|
47
|
+
{"event_type": "VersionMarkerRecorded", "recorded_at": "2026-06-01T12:00:03.987654Z",
|
|
48
|
+
"payload": {"sequence": 1, "change_id": "clock-case", "version": 7,
|
|
49
|
+
"min_supported": 0, "max_supported": 7}},
|
|
50
|
+
]
|
|
51
|
+
original = deepcopy(history)
|
|
52
|
+
first = result(MarkerClockWorkflow, history)
|
|
53
|
+
second = result(MarkerClockWorkflow, history)
|
|
54
|
+
assert first == second
|
|
55
|
+
assert first[:3] == ["2026-06-01T12:00:00.123456+00:00", 7, "2026-06-01T12:00:00.123456+00:00"]
|
|
56
|
+
assert UUID(first[3]).int >> 80 == int(datetime.fromisoformat(first[0]).timestamp() * 1000)
|
|
57
|
+
assert history == original
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_explicit_payload_time_retains_precedence() -> None:
|
|
61
|
+
history = [{"event_type": "WorkflowStarted", "recorded_at": "2026-06-01T12:00:00.123456Z",
|
|
62
|
+
"payload": {"timestamp": "2026-06-01T14:01:02.654321+02:00"}}]
|
|
63
|
+
assert result(PersistedClockWorkflow, history) == "2026-06-01T14:01:02.654321+02:00"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@pytest.mark.parametrize("invalid", [None, False, 123, "", "not-a-time", "2026-06-01T12:00:00"])
|
|
67
|
+
@pytest.mark.parametrize("explicit", [False, True])
|
|
68
|
+
@pytest.mark.parametrize("workflow_cls", [PersistedClockWorkflow, PersistedUUID7Workflow])
|
|
69
|
+
def test_clock_helpers_refuse_invalid_time_without_fallback(
|
|
70
|
+
invalid: Any, explicit: bool, workflow_cls: type,
|
|
71
|
+
) -> None:
|
|
72
|
+
event: dict[str, Any] = {"event_type": "WorkflowStarted", "payload": {}}
|
|
73
|
+
if explicit:
|
|
74
|
+
event["recorded_at"] = "2026-06-01T12:00:00.123456Z"
|
|
75
|
+
event["payload"]["timestamp"] = invalid
|
|
76
|
+
else:
|
|
77
|
+
event["recorded_at"] = invalid
|
|
78
|
+
with pytest.raises(NonDeterministicReplayError, match="Workflow replay clock unavailable"):
|
|
79
|
+
result(workflow_cls, [event])
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@pytest.mark.parametrize("history", [
|
|
83
|
+
[{"event_type": "WorkflowStarted", "payload": {}}],
|
|
84
|
+
[{"event_type": "SignalReceived", "payload": {"signal_name": "unhandled"}}],
|
|
85
|
+
])
|
|
86
|
+
def test_missing_replay_time_is_explicit(history: list[dict[str, Any]]) -> None:
|
|
87
|
+
with pytest.raises(NonDeterministicReplayError, match="Workflow replay clock unavailable"):
|
|
88
|
+
result(PersistedClockWorkflow, history)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_empty_local_authoring_remains_available() -> None:
|
|
92
|
+
assert datetime.fromisoformat(result(PersistedClockWorkflow, [])).tzinfo is not None
|
|
93
|
+
assert UUID(result(PersistedUUID7Workflow, [])).version == 7
|
|
@@ -34,6 +34,7 @@ from tests.test_patch_history import (
|
|
|
34
34
|
RepeatedPatchWorkflow,
|
|
35
35
|
)
|
|
36
36
|
from tests.test_pending_operation_replay import PendingOperation
|
|
37
|
+
from tests.test_replay_clock import PersistedClockWorkflow
|
|
37
38
|
from tests.test_update_signal_condition_replay import (
|
|
38
39
|
PostConditionReceiversWorkflow,
|
|
39
40
|
UpdateSignalConditionTimerWorkflow,
|
|
@@ -278,6 +279,7 @@ class NexusTargetWorkflow:
|
|
|
278
279
|
|
|
279
280
|
|
|
280
281
|
WORKFLOWS = [
|
|
282
|
+
PersistedClockWorkflow,
|
|
281
283
|
NexusTargetWorkflow,
|
|
282
284
|
UnsupportedAsyncWorkflow,
|
|
283
285
|
ChildPolicyAuthorWorkflow,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{durable_workflow-2.5.11 → durable_workflow-2.5.13}/src/durable_workflow/_activity_process.py
RENAMED
|
File without changes
|
|
File without changes
|
{durable_workflow-2.5.11 → durable_workflow-2.5.13}/src/durable_workflow/_cancellation_scope.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{durable_workflow-2.5.11 → durable_workflow-2.5.13}/src/durable_workflow/_prepared_local_activity.py
RENAMED
|
File without changes
|
{durable_workflow-2.5.11 → durable_workflow-2.5.13}/src/durable_workflow/_sticky_workflow_cache.py
RENAMED
|
File without changes
|
|
File without changes
|
{durable_workflow-2.5.11 → durable_workflow-2.5.13}/src/durable_workflow/auth_composition.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{durable_workflow-2.5.11 → durable_workflow-2.5.13}/src/durable_workflow/external_storage.py
RENAMED
|
File without changes
|
{durable_workflow-2.5.11 → durable_workflow-2.5.13}/src/durable_workflow/external_task_input.py
RENAMED
|
File without changes
|
{durable_workflow-2.5.11 → durable_workflow-2.5.13}/src/durable_workflow/external_task_result.py
RENAMED
|
File without changes
|
{durable_workflow-2.5.11 → durable_workflow-2.5.13}/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.5.11 → durable_workflow-2.5.13}/src/durable_workflow/python_conformance.py
RENAMED
|
File without changes
|
{durable_workflow-2.5.11 → durable_workflow-2.5.13}/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.5.11 → durable_workflow-2.5.13}/src/durable_workflow.egg-info/entry_points.txt
RENAMED
|
File without changes
|
{durable_workflow-2.5.11 → durable_workflow-2.5.13}/src/durable_workflow.egg-info/requires.txt
RENAMED
|
File without changes
|
{durable_workflow-2.5.11 → durable_workflow-2.5.13}/src/durable_workflow.egg-info/top_level.txt
RENAMED
|
File without changes
|
{durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_activity_cancellation_policies.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_cancellation_remaining_time.py
RENAMED
|
File without changes
|
{durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_cancellation_scope_admission.py
RENAMED
|
File without changes
|
{durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_cancellation_scope_authoring.py
RENAMED
|
File without changes
|
{durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_cancellation_scope_boundary.py
RENAMED
|
File without changes
|
{durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_cancellation_scope_opening.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
|
{durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_control_plane_parity_fixtures.py
RENAMED
|
File without changes
|
|
File without changes
|
{durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_cooperative_cancellation_client.py
RENAMED
|
File without changes
|
{durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_cooperative_cancellation_worker.py
RENAMED
|
File without changes
|
|
File without changes
|
{durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_docstring_cross_references.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
|
{durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_parallel_completion_order_replay.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_prepared_local_activity_client.py
RENAMED
|
File without changes
|
{durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_prepared_local_activity_groups.py
RENAMED
|
File without changes
|
{durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_prepared_local_activity_policies.py
RENAMED
|
File without changes
|
{durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_prepared_local_activity_worker.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
|
{durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_run_cancellation_scope_replay.py
RENAMED
|
File without changes
|
{durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_runtime_external_payload_transport.py
RENAMED
|
File without changes
|
|
File without changes
|
{durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_scoped_run_cancellation_context.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_standalone_activity_client.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{durable_workflow-2.5.11 → durable_workflow-2.5.13}/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.5.11 → durable_workflow-2.5.13}/tests/test_workflow_entrypoint_validation.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_workflow_updates_conformance.py
RENAMED
|
File without changes
|