durable-workflow 2.4.2__tar.gz → 2.5.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {durable_workflow-2.4.2/src/durable_workflow.egg-info → durable_workflow-2.5.0}/PKG-INFO +1 -1
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/pyproject.toml +3 -3
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/src/durable_workflow/__init__.py +2 -0
- durable_workflow-2.5.0/src/durable_workflow/_sticky_workflow_cache.py +103 -0
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/src/durable_workflow/client.py +7 -2
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/src/durable_workflow/errors.py +30 -0
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/src/durable_workflow/worker.py +128 -3
- {durable_workflow-2.4.2 → durable_workflow-2.5.0/src/durable_workflow.egg-info}/PKG-INFO +1 -1
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/src/durable_workflow.egg-info/SOURCES.txt +2 -0
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_client.py +29 -0
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_errors.py +53 -0
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_replay_regression_corpus.py +21 -0
- durable_workflow-2.5.0/tests/test_sticky_execution.py +236 -0
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_sync.py +29 -1
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_worker.py +28 -1
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/LICENSE +0 -0
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/README.md +0 -0
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/setup.cfg +0 -0
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/src/durable_workflow/_activity_process.py +0 -0
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/src/durable_workflow/_avro.py +0 -0
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/src/durable_workflow/_cancellation_scope.py +0 -0
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/src/durable_workflow/_cancellation_scope_history.py +0 -0
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/src/durable_workflow/_cooperative_cancellation.py +0 -0
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/src/durable_workflow/_prepared_local_activity.py +0 -0
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/src/durable_workflow/activity.py +0 -0
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/src/durable_workflow/auth_composition.py +0 -0
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/src/durable_workflow/cancellation.py +0 -0
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/src/durable_workflow/external_storage.py +0 -0
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/src/durable_workflow/external_task_input.py +0 -0
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/src/durable_workflow/external_task_result.py +0 -0
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/src/durable_workflow/history_bundle_verify.py +0 -0
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/src/durable_workflow/interceptors.py +0 -0
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/src/durable_workflow/invocable.py +0 -0
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/src/durable_workflow/metrics.py +0 -0
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/src/durable_workflow/nexus.py +0 -0
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/src/durable_workflow/py.typed +0 -0
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/src/durable_workflow/python_conformance.py +0 -0
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/src/durable_workflow/replay_conformance.py +0 -0
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/src/durable_workflow/replay_verify.py +0 -0
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/src/durable_workflow/retry_policy.py +0 -0
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/src/durable_workflow/serializer.py +0 -0
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/src/durable_workflow/sync.py +0 -0
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/src/durable_workflow/testing.py +0 -0
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/src/durable_workflow/worker_session.py +0 -0
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/src/durable_workflow/workflow.py +0 -0
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/src/durable_workflow/workflow_updates_conformance.py +0 -0
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/src/durable_workflow.egg-info/dependency_links.txt +0 -0
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/src/durable_workflow.egg-info/entry_points.txt +0 -0
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/src/durable_workflow.egg-info/requires.txt +0 -0
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/src/durable_workflow.egg-info/top_level.txt +0 -0
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_activity_cancellation_policies.py +0 -0
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_activity_context.py +0 -0
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_activity_process.py +0 -0
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_auth_composition.py +0 -0
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_avro_value_benchmark.py +0 -0
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_avro_value_protocol.py +0 -0
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_cancellation_context.py +0 -0
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_cancellation_remaining_time.py +0 -0
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_cancellation_scope_admission.py +0 -0
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_cancellation_scope_authoring.py +0 -0
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_cancellation_scope_boundary.py +0 -0
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_cancellation_scope_opening.py +0 -0
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_child_workflow_policies.py +0 -0
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_ci_checkout.py +0 -0
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_ci_integration_endpoint.py +0 -0
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_client_timeouts.py +0 -0
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_codec_regression_corpus.py +0 -0
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_committed_cancellation_scope_descendants.py +0 -0
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_committed_cancellation_scope_groups.py +0 -0
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_committed_cancellation_scope_history.py +0 -0
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_committed_cancellation_scope_replay.py +0 -0
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_control_plane_parity_fixtures.py +0 -0
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_cooperative_cancellation.py +0 -0
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_cooperative_cancellation_client.py +0 -0
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_cooperative_cancellation_worker.py +0 -0
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_cooperative_remote_worker.py +0 -0
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_docstring_cross_references.py +0 -0
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_durable_selection.py +0 -0
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_external_storage.py +0 -0
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_external_task_input.py +0 -0
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_external_task_result.py +0 -0
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_golden_history_replay.py +0 -0
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_history_bundle_verify.py +0 -0
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_history_event_contract.py +0 -0
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_invocable.py +0 -0
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_message_streams.py +0 -0
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_metrics.py +0 -0
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_order_processing_example.py +0 -0
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_parallel_completion_order_replay.py +0 -0
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_parallel_saga_authoring.py +0 -0
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_pending_operation_replay.py +0 -0
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_prepared_local_activity_client.py +0 -0
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_prepared_local_activity_groups.py +0 -0
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_prepared_local_activity_policies.py +0 -0
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_prepared_local_activity_worker.py +0 -0
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_public_boundary_scanner.py +0 -0
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_pypi_project_surface.py +0 -0
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_python_conformance.py +0 -0
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_queries.py +0 -0
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_readme_quickstart.py +0 -0
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_redrive_replay.py +0 -0
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_release_metadata.py +0 -0
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_replay.py +0 -0
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_replay_conformance.py +0 -0
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_replay_verify.py +0 -0
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_retry_policy.py +0 -0
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_run_cancellation_scope_replay.py +0 -0
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_runtime_external_payload_transport.py +0 -0
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_schedules.py +0 -0
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_scoped_run_cancellation_context.py +0 -0
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_serializer.py +0 -0
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_signals.py +0 -0
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_sleep.py +0 -0
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_standalone_activity_client.py +0 -0
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_storage_admission.py +0 -0
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_testing_harness.py +0 -0
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_update_signal_condition_replay.py +0 -0
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_updates.py +0 -0
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_wait_condition.py +0 -0
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_worker_session.py +0 -0
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_workflow_result_timeout.py +0 -0
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_workflow_streams.py +0 -0
- {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_workflow_updates_conformance.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "durable-workflow"
|
|
7
|
-
version = "2.
|
|
7
|
+
version = "2.5.0"
|
|
8
8
|
description = "Python client and worker SDK for Durable Workflow Cloud and self-hosted Server"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -71,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.
|
|
75
|
-
registry-version = "2.
|
|
74
|
+
product-train = "2.5.0"
|
|
75
|
+
registry-version = "2.5.0"
|
|
76
76
|
supported-server-versions = "2.5.0"
|
|
77
77
|
worker-protocol-version = "1.19"
|
|
78
78
|
control-plane-version = "2"
|
|
@@ -95,6 +95,7 @@ from .errors import (
|
|
|
95
95
|
ServerError,
|
|
96
96
|
SignalFailed,
|
|
97
97
|
Unauthorized,
|
|
98
|
+
UpdateFailed,
|
|
98
99
|
UpdateRejected,
|
|
99
100
|
UpdateValidationFailed,
|
|
100
101
|
WorkflowAlreadyStarted,
|
|
@@ -385,6 +386,7 @@ __all__ = [
|
|
|
385
386
|
"SignalFailed",
|
|
386
387
|
"TransportRetryPolicy",
|
|
387
388
|
"Unauthorized",
|
|
389
|
+
"UpdateFailed",
|
|
388
390
|
"UpdateRejected",
|
|
389
391
|
"UpdateValidationFailed",
|
|
390
392
|
"WorkflowAlreadyStarted",
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Bounded snapshots of durable wire history, never live workflow instances."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import time
|
|
7
|
+
from collections import OrderedDict
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
CacheKey = tuple[str, str, str]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def complete_history(history: list[dict[str, Any]]) -> bool:
|
|
15
|
+
if not history or any(not isinstance(event, dict) for event in history):
|
|
16
|
+
return False
|
|
17
|
+
types = [event.get("event_type") for event in history[:2]]
|
|
18
|
+
if types[0] != "WorkflowStarted" and types != ["StartAccepted", "WorkflowStarted"]:
|
|
19
|
+
return False
|
|
20
|
+
return all(type(event.get("sequence")) is int and event["sequence"] == index
|
|
21
|
+
for index, event in enumerate(history, 1))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class _Entry:
|
|
26
|
+
encoded: bytes
|
|
27
|
+
expires_at: float
|
|
28
|
+
resume_token: str | None
|
|
29
|
+
resume_offset: int
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class StickyWorkflowCache:
|
|
33
|
+
def __init__(self, capacity: int, max_bytes: int, ttl_seconds: int) -> None:
|
|
34
|
+
for name, value, minimum in (("capacity", capacity, 0), ("max_bytes", max_bytes, 1),
|
|
35
|
+
("ttl_seconds", ttl_seconds, 1)):
|
|
36
|
+
if type(value) is not int or value < minimum:
|
|
37
|
+
raise ValueError(f"sticky_cache_{name} must be an integer of at least {minimum}")
|
|
38
|
+
if ttl_seconds > 3600:
|
|
39
|
+
raise ValueError("sticky_cache_ttl_seconds must be at most 3600")
|
|
40
|
+
self.capacity = capacity
|
|
41
|
+
self.max_bytes = max_bytes
|
|
42
|
+
self.ttl_seconds = ttl_seconds
|
|
43
|
+
self._entries: OrderedDict[CacheKey, _Entry] = OrderedDict()
|
|
44
|
+
self._bytes = 0
|
|
45
|
+
self._metrics = {"hit": 0, "miss": 0, "eviction": 0, "forced_cold_replay": 0}
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def enabled(self) -> bool:
|
|
49
|
+
return self.capacity > 0
|
|
50
|
+
|
|
51
|
+
def _expire(self) -> None:
|
|
52
|
+
now = time.monotonic()
|
|
53
|
+
for key, entry in list(self._entries.items()):
|
|
54
|
+
if entry.expires_at <= now:
|
|
55
|
+
self.discard(key)
|
|
56
|
+
|
|
57
|
+
def discard(self, key: CacheKey) -> None:
|
|
58
|
+
entry = self._entries.pop(key, None)
|
|
59
|
+
if entry is not None:
|
|
60
|
+
self._bytes -= len(entry.encoded)
|
|
61
|
+
|
|
62
|
+
def lookup(self, key: CacheKey) -> tuple[list[dict[str, Any]], str | None, int] | None:
|
|
63
|
+
self._expire()
|
|
64
|
+
entry = self._entries.get(key)
|
|
65
|
+
if entry is None:
|
|
66
|
+
return None
|
|
67
|
+
self._entries.move_to_end(key)
|
|
68
|
+
# Decoding isolates the retained snapshot from replay and handler mutation.
|
|
69
|
+
history: list[dict[str, Any]] = json.loads(entry.encoded)
|
|
70
|
+
return history, entry.resume_token, entry.resume_offset
|
|
71
|
+
|
|
72
|
+
def remember(self, key: CacheKey, history: list[dict[str, Any]], *,
|
|
73
|
+
resume_token: str | None = None, resume_offset: int = 0) -> bool:
|
|
74
|
+
self._expire()
|
|
75
|
+
self.discard(key)
|
|
76
|
+
if not self.enabled or not complete_history(history):
|
|
77
|
+
return False
|
|
78
|
+
try:
|
|
79
|
+
encoded = json.dumps(history, ensure_ascii=False, separators=(",", ":"), allow_nan=False).encode("utf-8")
|
|
80
|
+
except (TypeError, ValueError):
|
|
81
|
+
return False
|
|
82
|
+
if len(encoded) > self.max_bytes:
|
|
83
|
+
return False
|
|
84
|
+
while self._entries and (len(self._entries) >= self.capacity or self._bytes + len(encoded) > self.max_bytes):
|
|
85
|
+
oldest = next(iter(self._entries))
|
|
86
|
+
self.discard(oldest)
|
|
87
|
+
self._metrics["eviction"] += 1
|
|
88
|
+
self._entries[key] = _Entry(encoded, time.monotonic() + self.ttl_seconds, resume_token, resume_offset)
|
|
89
|
+
self._bytes += len(encoded)
|
|
90
|
+
return True
|
|
91
|
+
|
|
92
|
+
def record_replay(self, *, hit: bool, forced: bool = False) -> None:
|
|
93
|
+
self._metrics["hit" if hit else "miss"] += 1
|
|
94
|
+
if forced:
|
|
95
|
+
self._metrics["forced_cold_replay"] += 1
|
|
96
|
+
|
|
97
|
+
def metrics(self) -> dict[str, int]:
|
|
98
|
+
self._expire()
|
|
99
|
+
return {**self._metrics, "entries": len(self._entries), "history_bytes": self._bytes}
|
|
100
|
+
|
|
101
|
+
def clear(self) -> None:
|
|
102
|
+
self._entries.clear()
|
|
103
|
+
self._bytes = 0
|
|
@@ -87,7 +87,7 @@ PORTABLE_WORKER_AFFINITY_CAPABILITY_MANIFEST: dict[str, dict[str, str | bool]] =
|
|
|
87
87
|
"sticky_execution": {
|
|
88
88
|
"supported": False,
|
|
89
89
|
"minimum_protocol_version": "1.18",
|
|
90
|
-
"reason": "
|
|
90
|
+
"reason": "sticky_cache_disabled",
|
|
91
91
|
},
|
|
92
92
|
}
|
|
93
93
|
_MESSAGE_STREAMS_CAPABILITY = "message_streams"
|
|
@@ -4349,7 +4349,9 @@ class Client:
|
|
|
4349
4349
|
:class:`~durable_workflow.errors.UpdateRejected` when the workflow's
|
|
4350
4350
|
validator rejects the update, or
|
|
4351
4351
|
:class:`~durable_workflow.errors.UpdateValidationFailed` when the
|
|
4352
|
-
declared validation boundary cannot be enforced.
|
|
4352
|
+
declared validation boundary cannot be enforced. An accepted update
|
|
4353
|
+
whose handler fails raises :class:`~durable_workflow.errors.UpdateFailed`
|
|
4354
|
+
with the failure message, Server response and durable identities.
|
|
4353
4355
|
"""
|
|
4354
4356
|
await self._require_update_wait_stage(wait_for or "accepted")
|
|
4355
4357
|
body: dict[str, Any] = {}
|
|
@@ -5110,6 +5112,7 @@ class Client:
|
|
|
5110
5112
|
commands: list[dict[str, Any]],
|
|
5111
5113
|
message_stream_cursors: list[dict[str, Any]] | None = None,
|
|
5112
5114
|
message_stream_waits: list[dict[str, Any]] | None = None,
|
|
5115
|
+
sticky_cache: dict[str, Any] | None = None,
|
|
5113
5116
|
) -> Any:
|
|
5114
5117
|
"""Report successful execution of a workflow task with its emitted commands.
|
|
5115
5118
|
|
|
@@ -5141,6 +5144,8 @@ class Client:
|
|
|
5141
5144
|
body["message_stream_cursors"] = message_stream_cursors
|
|
5142
5145
|
if message_stream_waits:
|
|
5143
5146
|
body["message_stream_waits"] = message_stream_waits
|
|
5147
|
+
if sticky_cache is not None:
|
|
5148
|
+
body["sticky_cache"] = sticky_cache
|
|
5144
5149
|
return await self._request(
|
|
5145
5150
|
"POST", f"/worker/workflow-tasks/{task_id}/complete", worker=True, json=body
|
|
5146
5151
|
)
|
|
@@ -453,6 +453,29 @@ class NonDeterministicReplayError(DurableWorkflowError):
|
|
|
453
453
|
self.recorded_event_types = list(recorded_event_types)
|
|
454
454
|
|
|
455
455
|
|
|
456
|
+
class UpdateFailed(InvalidArgument):
|
|
457
|
+
"""An accepted workflow update failed during handler execution.
|
|
458
|
+
|
|
459
|
+
``body`` preserves the Server response, including its durable failure
|
|
460
|
+
identity. This inherits from :class:`InvalidArgument` to preserve catches
|
|
461
|
+
written for earlier SDK versions that mapped all HTTP 422 responses there.
|
|
462
|
+
"""
|
|
463
|
+
|
|
464
|
+
def __init__(self, message: str, *, status: int, body: dict[str, Any]) -> None:
|
|
465
|
+
super().__init__(message)
|
|
466
|
+
self.status = status
|
|
467
|
+
self.body = body
|
|
468
|
+
self.workflow_id = self._identity(body, "workflow_id")
|
|
469
|
+
self.run_id = self._identity(body, "run_id")
|
|
470
|
+
self.update_id = self._identity(body, "update_id")
|
|
471
|
+
self.failure_id = self._identity(body, "failure_id")
|
|
472
|
+
|
|
473
|
+
@staticmethod
|
|
474
|
+
def _identity(body: dict[str, Any], name: str) -> str | None:
|
|
475
|
+
value = body.get(name)
|
|
476
|
+
return value if isinstance(value, str) and value.strip() else None
|
|
477
|
+
|
|
478
|
+
|
|
456
479
|
class UpdateRejected(DurableWorkflowError):
|
|
457
480
|
"""A workflow update was rejected by the workflow's validator."""
|
|
458
481
|
|
|
@@ -859,6 +882,13 @@ def _raise_for_status(status: int, body: object, *, context: str = "") -> None:
|
|
|
859
882
|
raise signal_failed("signal argument validation failed")
|
|
860
883
|
if reason == "invalid_query_arguments":
|
|
861
884
|
raise query_failed("query argument validation failed")
|
|
885
|
+
if isinstance(body, dict) and body.get("update_status") == "failed":
|
|
886
|
+
failure_message = body.get("failure_message")
|
|
887
|
+
if not isinstance(failure_message, str) or not failure_message.strip():
|
|
888
|
+
failure_message = message
|
|
889
|
+
if not isinstance(failure_message, str) or not failure_message.strip():
|
|
890
|
+
failure_message = "workflow update failed"
|
|
891
|
+
raise UpdateFailed(failure_message, status=status, body=body)
|
|
862
892
|
errors = None
|
|
863
893
|
if isinstance(body, dict):
|
|
864
894
|
errors = body.get("errors") or body.get("validation_errors")
|
|
@@ -41,6 +41,7 @@ from ._activity_process import CallbackFailure, CallbackInvocation, CallbackProc
|
|
|
41
41
|
from ._cancellation_scope_history import CancellationScopeBudget, CancellationScopeDeliveryReceipt
|
|
42
42
|
from ._cooperative_cancellation import CancellationRequest, read_cancellation_history
|
|
43
43
|
from ._prepared_local_activity import PreparedAttempt, PreparedCancellationObserved, PreparedLocalRunner
|
|
44
|
+
from ._sticky_workflow_cache import CacheKey, StickyWorkflowCache, complete_history
|
|
44
45
|
from .activity import ActivityContext, ActivityInfo, _set_context
|
|
45
46
|
from .auth_composition import (
|
|
46
47
|
AUTH_COMPOSITION_CONTRACT_SCHEMA,
|
|
@@ -53,6 +54,7 @@ from .client import (
|
|
|
53
54
|
CONTROL_PLANE_REQUEST_CONTRACT_SCHEMA,
|
|
54
55
|
CONTROL_PLANE_REQUEST_CONTRACT_VERSION,
|
|
55
56
|
CONTROL_PLANE_VERSION,
|
|
57
|
+
DEFAULT_SDK_VERSION,
|
|
56
58
|
PORTABLE_WORKER_AFFINITY_CAPABILITY_MANIFEST,
|
|
57
59
|
PROTOCOL_VERSION,
|
|
58
60
|
Client,
|
|
@@ -1008,6 +1010,9 @@ class Worker:
|
|
|
1008
1010
|
max_concurrent_workflow_tasks: int = 10,
|
|
1009
1011
|
max_concurrent_activity_tasks: int = 10,
|
|
1010
1012
|
max_concurrent_worker_sessions: int = 10,
|
|
1013
|
+
sticky_cache_capacity: int = 0,
|
|
1014
|
+
sticky_cache_max_bytes: int = 16 * 1024 * 1024,
|
|
1015
|
+
sticky_cache_ttl_seconds: int = 300,
|
|
1011
1016
|
shutdown_timeout: float = 30.0,
|
|
1012
1017
|
heartbeat_interval: float = 60.0,
|
|
1013
1018
|
metrics: MetricsRecorder | None = None,
|
|
@@ -1029,6 +1034,11 @@ class Worker:
|
|
|
1029
1034
|
}
|
|
1030
1035
|
self.activities = {_activity_name(a): a for a in activities}
|
|
1031
1036
|
self.capabilities = tuple(dict.fromkeys(capability.strip() for capability in capabilities))
|
|
1037
|
+
self._sticky_cache = StickyWorkflowCache(
|
|
1038
|
+
sticky_cache_capacity, sticky_cache_max_bytes, sticky_cache_ttl_seconds,
|
|
1039
|
+
)
|
|
1040
|
+
if "sticky_execution" in self.capabilities and not self._sticky_cache.enabled:
|
|
1041
|
+
raise ValueError("sticky_execution requires a positive sticky_cache_capacity")
|
|
1032
1042
|
self._cooperative_cancellation_supported = False
|
|
1033
1043
|
self._prepared_local_activities_supported = False
|
|
1034
1044
|
self._prepared_local_activity_groups_supported = False
|
|
@@ -1211,6 +1221,13 @@ class Worker:
|
|
|
1211
1221
|
_validate_server_compatibility(info)
|
|
1212
1222
|
protocol = info.get("worker_protocol")
|
|
1213
1223
|
server_capabilities = protocol.get("server_capabilities") if isinstance(protocol, Mapping) else None
|
|
1224
|
+
sticky_support = (
|
|
1225
|
+
server_capabilities.get("sticky_execution") if isinstance(server_capabilities, Mapping) else None
|
|
1226
|
+
)
|
|
1227
|
+
if self._sticky_cache.enabled and (
|
|
1228
|
+
not isinstance(sticky_support, Mapping) or sticky_support.get("supported") is not True
|
|
1229
|
+
):
|
|
1230
|
+
raise RuntimeError("sticky execution requires Server's advertised sticky_execution support")
|
|
1214
1231
|
self._cooperative_cancellation_supported = (
|
|
1215
1232
|
"cooperative_cancellation" in self.capabilities
|
|
1216
1233
|
and isinstance(protocol, Mapping)
|
|
@@ -1292,6 +1309,15 @@ class Worker:
|
|
|
1292
1309
|
capabilities.append("prepared_local_activity_cancellation_policies")
|
|
1293
1310
|
if PORTABLE_WORKER_AFFINITY_CAPABILITY_MANIFEST["worker_sessions"]["supported"]:
|
|
1294
1311
|
capabilities.append("worker_sessions")
|
|
1312
|
+
affinity_manifest = {name: dict(entry) for name, entry in PORTABLE_WORKER_AFFINITY_CAPABILITY_MANIFEST.items()}
|
|
1313
|
+
if self._sticky_cache.enabled:
|
|
1314
|
+
if "sticky_execution" not in capabilities:
|
|
1315
|
+
capabilities.append("sticky_execution")
|
|
1316
|
+
affinity_manifest["sticky_execution"] = {
|
|
1317
|
+
"supported": True,
|
|
1318
|
+
"minimum_protocol_version": "1.18",
|
|
1319
|
+
"implementation": "bounded_durable_history_cache",
|
|
1320
|
+
}
|
|
1295
1321
|
|
|
1296
1322
|
ack = await self.client.register_worker(
|
|
1297
1323
|
worker_id=self.worker_id,
|
|
@@ -1306,7 +1332,7 @@ class Worker:
|
|
|
1306
1332
|
build_id=self.build_id,
|
|
1307
1333
|
capabilities=capabilities,
|
|
1308
1334
|
capability_manifest={
|
|
1309
|
-
**
|
|
1335
|
+
**affinity_manifest,
|
|
1310
1336
|
**({"prepared_local_activities": {
|
|
1311
1337
|
"supported": True, "minimum_protocol_version": "1.20",
|
|
1312
1338
|
"implementation": "durable_sequential_admission",
|
|
@@ -1514,8 +1540,65 @@ class Worker:
|
|
|
1514
1540
|
async def _load_workflow_claim_history(
|
|
1515
1541
|
self, task: dict[str, Any], *, first_page_token: str | None = None,
|
|
1516
1542
|
) -> list[dict[str, Any]]:
|
|
1517
|
-
|
|
1518
|
-
|
|
1543
|
+
if first_page_token is not None:
|
|
1544
|
+
return await self._fetch_workflow_claim_history_pages(task, [], first_page_token)
|
|
1545
|
+
inline = list(task.get("history_events", []))
|
|
1546
|
+
key = self._sticky_cache_key(task)
|
|
1547
|
+
mode = task.get("sticky_replay_mode")
|
|
1548
|
+
cached = self._sticky_cache.lookup(key) if key is not None and mode == "sticky_hit_expected" else None
|
|
1549
|
+
if cached is not None and key is not None:
|
|
1550
|
+
prefix, resume_token, resume_offset = cached
|
|
1551
|
+
overlap = min(len(inline), len(prefix))
|
|
1552
|
+
# Published Server responses also express the boundary as an event count.
|
|
1553
|
+
# Retained histories are contiguous from sequence 1, so the two agree.
|
|
1554
|
+
raw_last_sequence = task.get("last_history_sequence", task.get("total_history_events"))
|
|
1555
|
+
last_sequence = raw_last_sequence if isinstance(raw_last_sequence, int) else -1
|
|
1556
|
+
anchored = (
|
|
1557
|
+
complete_history(inline) and inline[:overlap] == prefix[:overlap]
|
|
1558
|
+
and type(raw_last_sequence) is int and last_sequence >= len(prefix)
|
|
1559
|
+
)
|
|
1560
|
+
if anchored:
|
|
1561
|
+
if resume_token is not None and resume_offset >= len(inline) and len(inline) < len(prefix):
|
|
1562
|
+
try:
|
|
1563
|
+
tail = await self._fetch_workflow_claim_history_pages(task, [], resume_token)
|
|
1564
|
+
except (InvalidArgument, LocalActivityExecutionAborted):
|
|
1565
|
+
tail = []
|
|
1566
|
+
except ServerError as error:
|
|
1567
|
+
if error.status != 400 or error.reason() != "invalid_page_token":
|
|
1568
|
+
raise
|
|
1569
|
+
tail = []
|
|
1570
|
+
if tail[:len(prefix) - resume_offset] == prefix[resume_offset:]:
|
|
1571
|
+
history = prefix[:resume_offset] + tail
|
|
1572
|
+
if complete_history(history) and len(history) >= last_sequence:
|
|
1573
|
+
# Paging measured the offset within the tail; retain its absolute boundary.
|
|
1574
|
+
task["_sticky_resume_offset"] += resume_offset
|
|
1575
|
+
self._sticky_cache.record_replay(hit=True)
|
|
1576
|
+
return history
|
|
1577
|
+
else:
|
|
1578
|
+
history = await self._fetch_workflow_claim_history_pages(
|
|
1579
|
+
task, inline, task.get("next_history_page_token"),
|
|
1580
|
+
)
|
|
1581
|
+
if complete_history(history) and history[:len(prefix)] == prefix and len(history) >= last_sequence:
|
|
1582
|
+
self._sticky_cache.record_replay(hit=True)
|
|
1583
|
+
return history
|
|
1584
|
+
# A cached prefix must never replace an inconsistent authoritative page.
|
|
1585
|
+
self._sticky_cache.discard(key)
|
|
1586
|
+
if key is not None:
|
|
1587
|
+
self._sticky_cache.record_replay(
|
|
1588
|
+
hit=False, forced=mode in {"sticky_hit_expected", "forced_cold_replay"},
|
|
1589
|
+
)
|
|
1590
|
+
if mode in {"sticky_hit_expected", "forced_cold_replay"} and not complete_history(inline):
|
|
1591
|
+
history = await self._fetch_workflow_claim_history_pages(task, [], "MA==")
|
|
1592
|
+
if not complete_history(history):
|
|
1593
|
+
raise LocalActivityExecutionAborted("cold replay lacks a complete canonical history prefix")
|
|
1594
|
+
return history
|
|
1595
|
+
return await self._fetch_workflow_claim_history_pages(task, inline, task.get("next_history_page_token"))
|
|
1596
|
+
|
|
1597
|
+
async def _fetch_workflow_claim_history_pages(
|
|
1598
|
+
self, task: dict[str, Any], history: list[dict[str, Any]], token: Any,
|
|
1599
|
+
) -> list[dict[str, Any]]:
|
|
1600
|
+
task["_sticky_resume_token"] = None
|
|
1601
|
+
task["_sticky_resume_offset"] = 0
|
|
1519
1602
|
seen: set[str] = set()
|
|
1520
1603
|
while token is not None:
|
|
1521
1604
|
if not isinstance(token, str) or not token or token in seen:
|
|
@@ -1529,10 +1612,46 @@ class Worker:
|
|
|
1529
1612
|
raise LocalActivityExecutionAborted("workflow history page was not acknowledged")
|
|
1530
1613
|
if any(not isinstance(event, dict) for event in page["history_events"]):
|
|
1531
1614
|
raise LocalActivityExecutionAborted("workflow history page contains a malformed event")
|
|
1615
|
+
task["_sticky_resume_token"] = token
|
|
1616
|
+
task["_sticky_resume_offset"] = len(history)
|
|
1532
1617
|
history.extend(page["history_events"])
|
|
1533
1618
|
token = page.get("next_history_page_token")
|
|
1534
1619
|
return history
|
|
1535
1620
|
|
|
1621
|
+
def _sticky_cache_key(self, task: dict[str, Any]) -> CacheKey | None:
|
|
1622
|
+
if not self._sticky_cache.enabled:
|
|
1623
|
+
return None
|
|
1624
|
+
workflow_id, run_id = task.get("workflow_id"), task.get("run_id")
|
|
1625
|
+
if not isinstance(workflow_id, str) or not workflow_id or not isinstance(run_id, str) or not run_id:
|
|
1626
|
+
return None
|
|
1627
|
+
return workflow_id, run_id, self.build_id.strip() if self.build_id is not None else DEFAULT_SDK_VERSION
|
|
1628
|
+
|
|
1629
|
+
def _sticky_cache_claim(
|
|
1630
|
+
self, task: dict[str, Any], history: list[dict[str, Any]], commands: list[dict[str, Any]],
|
|
1631
|
+
) -> dict[str, Any] | None:
|
|
1632
|
+
key = self._sticky_cache_key(task)
|
|
1633
|
+
if key is None:
|
|
1634
|
+
return None
|
|
1635
|
+
if any(command.get("type") in {"complete_workflow", "fail_workflow", "continue_as_new"}
|
|
1636
|
+
for command in commands):
|
|
1637
|
+
self._sticky_cache.discard(key)
|
|
1638
|
+
return None
|
|
1639
|
+
if not self._sticky_cache.remember(
|
|
1640
|
+
key, history, resume_token=task.get("_sticky_resume_token"),
|
|
1641
|
+
resume_offset=task.get("_sticky_resume_offset", 0),
|
|
1642
|
+
):
|
|
1643
|
+
return None
|
|
1644
|
+
return {
|
|
1645
|
+
"worker_id": self.worker_id, "workflow_id": key[0], "run_id": key[1], "build_id": key[2],
|
|
1646
|
+
"ttl_seconds": self._sticky_cache.ttl_seconds,
|
|
1647
|
+
"metrics": {name: self._sticky_cache.metrics()[name]
|
|
1648
|
+
for name in ("hit", "miss", "eviction", "forced_cold_replay")},
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
def sticky_cache_metrics(self) -> dict[str, int]:
|
|
1652
|
+
"""Return replay counters, retained entries and encoded history bytes."""
|
|
1653
|
+
return self._sticky_cache.metrics()
|
|
1654
|
+
|
|
1536
1655
|
async def _refresh_cancellation_history(
|
|
1537
1656
|
self, task: dict[str, Any], observed: CancellationRequest,
|
|
1538
1657
|
) -> list[dict[str, Any]]:
|
|
@@ -2416,6 +2535,7 @@ class Worker:
|
|
|
2416
2535
|
task_id=task_id,
|
|
2417
2536
|
attempt=attempt,
|
|
2418
2537
|
commands=[command],
|
|
2538
|
+
sticky_cache=self._sticky_cache_claim(task, history, [command]),
|
|
2419
2539
|
)
|
|
2420
2540
|
except Exception as e:
|
|
2421
2541
|
log.warning("failed to complete workflow update task %s: %s", task_id, e)
|
|
@@ -2622,6 +2742,7 @@ class Worker:
|
|
|
2622
2742
|
commands=commands,
|
|
2623
2743
|
message_stream_cursors=outcome.message_stream_cursors,
|
|
2624
2744
|
message_stream_waits=outcome.message_stream_waits,
|
|
2745
|
+
sticky_cache=self._sticky_cache_claim(task, history, commands),
|
|
2625
2746
|
)
|
|
2626
2747
|
except Exception as e:
|
|
2627
2748
|
log.warning("failed to complete workflow task %s: %s", task_id, e)
|
|
@@ -2640,9 +2761,11 @@ class Worker:
|
|
|
2640
2761
|
commands: list[dict[str, Any]],
|
|
2641
2762
|
message_stream_cursors: list[dict[str, Any]] | None = None,
|
|
2642
2763
|
message_stream_waits: list[dict[str, Any]] | None = None,
|
|
2764
|
+
sticky_cache: dict[str, Any] | None = None,
|
|
2643
2765
|
) -> None:
|
|
2644
2766
|
for completion_attempt in range(1, _WORKFLOW_TASK_COMPLETION_MAX_ATTEMPTS + 1):
|
|
2645
2767
|
try:
|
|
2768
|
+
completion_kwargs = {"sticky_cache": sticky_cache} if sticky_cache is not None else {}
|
|
2646
2769
|
await self.client.complete_workflow_task(
|
|
2647
2770
|
task_id=task_id,
|
|
2648
2771
|
lease_owner=self.worker_id,
|
|
@@ -2650,6 +2773,7 @@ class Worker:
|
|
|
2650
2773
|
commands=commands,
|
|
2651
2774
|
message_stream_cursors=message_stream_cursors,
|
|
2652
2775
|
message_stream_waits=message_stream_waits,
|
|
2776
|
+
**completion_kwargs,
|
|
2653
2777
|
)
|
|
2654
2778
|
return
|
|
2655
2779
|
except Exception as error:
|
|
@@ -4353,6 +4477,7 @@ class Worker:
|
|
|
4353
4477
|
await self.client.deregister_worker_registration(self.worker_id)
|
|
4354
4478
|
self._registered = False
|
|
4355
4479
|
log.info("worker %s deregistered", self.worker_id)
|
|
4480
|
+
self._sticky_cache.clear()
|
|
4356
4481
|
|
|
4357
4482
|
@staticmethod
|
|
4358
4483
|
def _remaining_shutdown_time(deadline: float) -> float:
|
|
@@ -8,6 +8,7 @@ src/durable_workflow/_cancellation_scope.py
|
|
|
8
8
|
src/durable_workflow/_cancellation_scope_history.py
|
|
9
9
|
src/durable_workflow/_cooperative_cancellation.py
|
|
10
10
|
src/durable_workflow/_prepared_local_activity.py
|
|
11
|
+
src/durable_workflow/_sticky_workflow_cache.py
|
|
11
12
|
src/durable_workflow/activity.py
|
|
12
13
|
src/durable_workflow/auth_composition.py
|
|
13
14
|
src/durable_workflow/cancellation.py
|
|
@@ -106,6 +107,7 @@ tests/test_serializer.py
|
|
|
106
107
|
tests/test_signals.py
|
|
107
108
|
tests/test_sleep.py
|
|
108
109
|
tests/test_standalone_activity_client.py
|
|
110
|
+
tests/test_sticky_execution.py
|
|
109
111
|
tests/test_storage_admission.py
|
|
110
112
|
tests/test_sync.py
|
|
111
113
|
tests/test_testing_harness.py
|
|
@@ -30,6 +30,7 @@ from durable_workflow.errors import (
|
|
|
30
30
|
ServerError,
|
|
31
31
|
SignalFailed,
|
|
32
32
|
Unauthorized,
|
|
33
|
+
UpdateFailed,
|
|
33
34
|
UpdateRejected,
|
|
34
35
|
WorkflowAlreadyStarted,
|
|
35
36
|
WorkflowNotFound,
|
|
@@ -3090,6 +3091,34 @@ class TestHeartbeatActivityTask:
|
|
|
3090
3091
|
|
|
3091
3092
|
|
|
3092
3093
|
class TestUpdateWorkflow:
|
|
3094
|
+
@pytest.mark.asyncio
|
|
3095
|
+
async def test_failed_update_retries_preserve_failure_identity(self, client: Client) -> None:
|
|
3096
|
+
body = {
|
|
3097
|
+
"update_status": "failed", "accepted": True,
|
|
3098
|
+
"failure_message": "inventory unavailable", "workflow_id": "wf-1",
|
|
3099
|
+
"run_id": "run-1", "update_id": "update-1", "failure_id": "failure-1",
|
|
3100
|
+
}
|
|
3101
|
+
with patch.object(
|
|
3102
|
+
client._http, "request", new_callable=AsyncMock, return_value=_mock_response(422, body),
|
|
3103
|
+
) as request:
|
|
3104
|
+
failures = []
|
|
3105
|
+
for _ in range(2):
|
|
3106
|
+
with pytest.raises(UpdateFailed) as exc_info:
|
|
3107
|
+
await client.update_workflow(
|
|
3108
|
+
"wf-1", "reserve", wait_for="completed", request_id="request-1",
|
|
3109
|
+
)
|
|
3110
|
+
failures.append(exc_info.value)
|
|
3111
|
+
assert request.await_count == 2
|
|
3112
|
+
assert all(call.kwargs["json"]["request_id"] == "request-1"
|
|
3113
|
+
for call in request.await_args_list)
|
|
3114
|
+
for error in failures:
|
|
3115
|
+
assert str(error) == "inventory unavailable"
|
|
3116
|
+
assert error.status == 422
|
|
3117
|
+
assert error.body == body
|
|
3118
|
+
assert (error.workflow_id, error.run_id, error.update_id, error.failure_id) == (
|
|
3119
|
+
"wf-1", "run-1", "update-1", "failure-1",
|
|
3120
|
+
)
|
|
3121
|
+
|
|
3093
3122
|
@pytest.mark.asyncio
|
|
3094
3123
|
async def test_unsupported_wait_stage_raises_typed_error_before_update(self) -> None:
|
|
3095
3124
|
client = Client("http://localhost:8080", token="test-token")
|
|
@@ -13,6 +13,7 @@ from durable_workflow.errors import (
|
|
|
13
13
|
ServerError,
|
|
14
14
|
SignalFailed,
|
|
15
15
|
Unauthorized,
|
|
16
|
+
UpdateFailed,
|
|
16
17
|
UpdateRejected,
|
|
17
18
|
UpdateValidationFailed,
|
|
18
19
|
WorkflowAlreadyStarted,
|
|
@@ -102,6 +103,56 @@ class TestRaiseForStatus:
|
|
|
102
103
|
with pytest.raises(InvalidArgument) as exc_info:
|
|
103
104
|
_raise_for_status(422, {"message": "bad", "errors": {"f": ["req"]}})
|
|
104
105
|
assert exc_info.value.errors == {"f": ["req"]}
|
|
106
|
+
assert type(exc_info.value) is InvalidArgument
|
|
107
|
+
|
|
108
|
+
def test_422_accepted_update_failure_preserves_diagnostics_and_legacy_catch(self) -> None:
|
|
109
|
+
body = {
|
|
110
|
+
"update_status": "failed", "accepted": True,
|
|
111
|
+
"failure_message": "inventory unavailable", "message": "generic failure",
|
|
112
|
+
"workflow_id": "wf-1", "run_id": "run-1", "update_id": "update-1",
|
|
113
|
+
"failure_id": "failure-1",
|
|
114
|
+
}
|
|
115
|
+
with pytest.raises(InvalidArgument) as exc_info:
|
|
116
|
+
_raise_for_status(422, body)
|
|
117
|
+
error = exc_info.value
|
|
118
|
+
assert isinstance(error, UpdateFailed)
|
|
119
|
+
assert str(error) == "inventory unavailable"
|
|
120
|
+
assert error.status == 422
|
|
121
|
+
assert error.body is body
|
|
122
|
+
assert error.errors is None
|
|
123
|
+
assert (error.workflow_id, error.run_id, error.update_id, error.failure_id) == (
|
|
124
|
+
"wf-1", "run-1", "update-1", "failure-1",
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
@pytest.mark.parametrize("failure_message", [None, "", " ", 42])
|
|
128
|
+
@pytest.mark.parametrize("message", ["handler failed", None, "", " ", 42])
|
|
129
|
+
def test_update_failure_message_fallback(self, failure_message: object, message: object) -> None:
|
|
130
|
+
with pytest.raises(UpdateFailed) as exc_info:
|
|
131
|
+
_raise_for_status(422, {
|
|
132
|
+
"update_status": "failed", "failure_message": failure_message, "message": message,
|
|
133
|
+
})
|
|
134
|
+
assert str(exc_info.value) == (
|
|
135
|
+
message if message == "handler failed" else "workflow update failed"
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
def test_update_failure_missing_or_malformed_identities_remain_absent(self) -> None:
|
|
139
|
+
with pytest.raises(UpdateFailed) as exc_info:
|
|
140
|
+
_raise_for_status(422, {
|
|
141
|
+
"update_status": "failed", "workflow_id": 42, "run_id": " ", "update_id": None,
|
|
142
|
+
})
|
|
143
|
+
error = exc_info.value
|
|
144
|
+
assert (error.workflow_id, error.run_id, error.update_id, error.failure_id) == (
|
|
145
|
+
None, None, None, None,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
def test_rejected_update_arguments_are_not_handler_failure(self) -> None:
|
|
149
|
+
with pytest.raises(InvalidArgument) as exc_info:
|
|
150
|
+
_raise_for_status(422, {
|
|
151
|
+
"update_status": "rejected", "message": "invalid arguments",
|
|
152
|
+
"validation_errors": {"quantity": ["must be positive"]},
|
|
153
|
+
})
|
|
154
|
+
assert type(exc_info.value) is InvalidArgument
|
|
155
|
+
assert exc_info.value.errors == {"quantity": ["must be positive"]}
|
|
105
156
|
|
|
106
157
|
def test_422_update_validator_rejected_is_typed(self) -> None:
|
|
107
158
|
with pytest.raises(UpdateRejected) as exc_info:
|
|
@@ -109,6 +160,7 @@ class TestRaiseForStatus:
|
|
|
109
160
|
422,
|
|
110
161
|
{
|
|
111
162
|
"reason": "update_validator_rejected",
|
|
163
|
+
"update_status": "failed",
|
|
112
164
|
"message": "approval required",
|
|
113
165
|
"validation_errors": {"approved": ["must be true"]},
|
|
114
166
|
},
|
|
@@ -132,6 +184,7 @@ class TestRaiseForStatus:
|
|
|
132
184
|
status,
|
|
133
185
|
{
|
|
134
186
|
"reason": reason,
|
|
187
|
+
"update_status": "failed",
|
|
135
188
|
"message": "validation could not complete",
|
|
136
189
|
"retryable": retryable,
|
|
137
190
|
},
|
|
@@ -489,6 +489,27 @@ async def test_worker_replays_recorded_side_effects_across_history_pages() -> No
|
|
|
489
489
|
assert serializer.decode_envelope(commands[0]["result"]) == 198
|
|
490
490
|
|
|
491
491
|
|
|
492
|
+
@pytest.mark.asyncio
|
|
493
|
+
async def test_replacement_with_sticky_suffix_fetches_cold_history_before_replay() -> None:
|
|
494
|
+
fixture = json.loads((FIXTURE_DIR / "sticky-cold-prefix-side-effects-avro.json").read_text(encoding="utf-8"))
|
|
495
|
+
history = fixture["history"]
|
|
496
|
+
client = AsyncMock(spec=Client)
|
|
497
|
+
client.workflow_task_history.return_value = {"history_events": history, "next_history_page_token": None}
|
|
498
|
+
worker = Worker(client, task_queue="cold-sticky", workflows=[PagedRecordedSideEffectsWorkflow])
|
|
499
|
+
commands = await worker._run_workflow_task_core({
|
|
500
|
+
"task_id": "replacement-task", "workflow_id": "cold-sticky", "run_id": "original-run",
|
|
501
|
+
"workflow_type": fixture["workflow"]["type"], "workflow_task_attempt": 2,
|
|
502
|
+
"history_events": history[-1:], "sticky_replay_mode": "sticky_hit_expected",
|
|
503
|
+
"arguments": serializer.encode([], codec="avro"), "payload_codec": "avro",
|
|
504
|
+
})
|
|
505
|
+
client.workflow_task_history.assert_awaited_once_with(
|
|
506
|
+
task_id="replacement-task", next_history_page_token="MA==", lease_owner=worker.worker_id,
|
|
507
|
+
workflow_task_attempt=2,
|
|
508
|
+
)
|
|
509
|
+
assert commands is not None
|
|
510
|
+
assert serializer.decode_envelope(commands[0]["result"]) == 198
|
|
511
|
+
|
|
512
|
+
|
|
492
513
|
@pytest.mark.parametrize(
|
|
493
514
|
"fixture",
|
|
494
515
|
[
|