durable-workflow 2.4.3__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.3/src/durable_workflow.egg-info → durable_workflow-2.5.0}/PKG-INFO +1 -1
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/pyproject.toml +3 -3
- durable_workflow-2.5.0/src/durable_workflow/_sticky_workflow_cache.py +103 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/src/durable_workflow/client.py +4 -1
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/src/durable_workflow/worker.py +128 -3
- {durable_workflow-2.4.3 → durable_workflow-2.5.0/src/durable_workflow.egg-info}/PKG-INFO +1 -1
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/src/durable_workflow.egg-info/SOURCES.txt +2 -0
- {durable_workflow-2.4.3 → 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.3 → durable_workflow-2.5.0}/tests/test_worker.py +28 -1
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/LICENSE +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/README.md +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/setup.cfg +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/src/durable_workflow/__init__.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/src/durable_workflow/_activity_process.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/src/durable_workflow/_avro.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/src/durable_workflow/_cancellation_scope.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/src/durable_workflow/_cancellation_scope_history.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/src/durable_workflow/_cooperative_cancellation.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/src/durable_workflow/_prepared_local_activity.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/src/durable_workflow/activity.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/src/durable_workflow/auth_composition.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/src/durable_workflow/cancellation.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/src/durable_workflow/errors.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/src/durable_workflow/external_storage.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/src/durable_workflow/external_task_input.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/src/durable_workflow/external_task_result.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/src/durable_workflow/history_bundle_verify.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/src/durable_workflow/interceptors.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/src/durable_workflow/invocable.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/src/durable_workflow/metrics.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/src/durable_workflow/nexus.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/src/durable_workflow/py.typed +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/src/durable_workflow/python_conformance.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/src/durable_workflow/replay_conformance.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/src/durable_workflow/replay_verify.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/src/durable_workflow/retry_policy.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/src/durable_workflow/serializer.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/src/durable_workflow/sync.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/src/durable_workflow/testing.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/src/durable_workflow/worker_session.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/src/durable_workflow/workflow.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/src/durable_workflow/workflow_updates_conformance.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/src/durable_workflow.egg-info/dependency_links.txt +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/src/durable_workflow.egg-info/entry_points.txt +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/src/durable_workflow.egg-info/requires.txt +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/src/durable_workflow.egg-info/top_level.txt +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_activity_cancellation_policies.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_activity_context.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_activity_process.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_auth_composition.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_avro_value_benchmark.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_avro_value_protocol.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_cancellation_context.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_cancellation_remaining_time.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_cancellation_scope_admission.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_cancellation_scope_authoring.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_cancellation_scope_boundary.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_cancellation_scope_opening.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_child_workflow_policies.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_ci_checkout.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_ci_integration_endpoint.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_client.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_client_timeouts.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_codec_regression_corpus.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_committed_cancellation_scope_descendants.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_committed_cancellation_scope_groups.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_committed_cancellation_scope_history.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_committed_cancellation_scope_replay.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_control_plane_parity_fixtures.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_cooperative_cancellation.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_cooperative_cancellation_client.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_cooperative_cancellation_worker.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_cooperative_remote_worker.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_docstring_cross_references.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_durable_selection.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_errors.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_external_storage.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_external_task_input.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_external_task_result.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_golden_history_replay.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_history_bundle_verify.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_history_event_contract.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_invocable.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_message_streams.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_metrics.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_order_processing_example.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_parallel_completion_order_replay.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_parallel_saga_authoring.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_pending_operation_replay.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_prepared_local_activity_client.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_prepared_local_activity_groups.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_prepared_local_activity_policies.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_prepared_local_activity_worker.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_public_boundary_scanner.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_pypi_project_surface.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_python_conformance.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_queries.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_readme_quickstart.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_redrive_replay.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_release_metadata.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_replay.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_replay_conformance.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_replay_verify.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_retry_policy.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_run_cancellation_scope_replay.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_runtime_external_payload_transport.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_schedules.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_scoped_run_cancellation_context.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_serializer.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_signals.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_sleep.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_standalone_activity_client.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_storage_admission.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_sync.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_testing_harness.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_update_signal_condition_replay.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_updates.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_wait_condition.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_worker_session.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_workflow_result_timeout.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_workflow_streams.py +0 -0
- {durable_workflow-2.4.3 → 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"
|
|
@@ -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"
|
|
@@ -5112,6 +5112,7 @@ class Client:
|
|
|
5112
5112
|
commands: list[dict[str, Any]],
|
|
5113
5113
|
message_stream_cursors: list[dict[str, Any]] | None = None,
|
|
5114
5114
|
message_stream_waits: list[dict[str, Any]] | None = None,
|
|
5115
|
+
sticky_cache: dict[str, Any] | None = None,
|
|
5115
5116
|
) -> Any:
|
|
5116
5117
|
"""Report successful execution of a workflow task with its emitted commands.
|
|
5117
5118
|
|
|
@@ -5143,6 +5144,8 @@ class Client:
|
|
|
5143
5144
|
body["message_stream_cursors"] = message_stream_cursors
|
|
5144
5145
|
if message_stream_waits:
|
|
5145
5146
|
body["message_stream_waits"] = message_stream_waits
|
|
5147
|
+
if sticky_cache is not None:
|
|
5148
|
+
body["sticky_cache"] = sticky_cache
|
|
5146
5149
|
return await self._request(
|
|
5147
5150
|
"POST", f"/worker/workflow-tasks/{task_id}/complete", worker=True, json=body
|
|
5148
5151
|
)
|
|
@@ -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
|
|
@@ -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
|
[
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
from unittest.mock import AsyncMock, patch
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from durable_workflow import Client, Worker
|
|
9
|
+
from durable_workflow._sticky_workflow_cache import StickyWorkflowCache
|
|
10
|
+
from durable_workflow.errors import ServerError
|
|
11
|
+
from durable_workflow.workflow import LocalActivityExecutionAborted
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def history(count: int = 6) -> list[dict[str, Any]]:
|
|
15
|
+
return [{"id": f"event-{sequence}", "sequence": sequence,
|
|
16
|
+
"event_type": "WorkflowStarted" if sequence == 1 else "SideEffectRecorded",
|
|
17
|
+
"payload": {"value": {"number": sequence}}} for sequence in range(1, count + 1)]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def task(events: list[dict[str, Any]], *, mode: str = "sticky_hit_expected",
|
|
21
|
+
last: int = 6, token: str | None = None) -> dict[str, Any]:
|
|
22
|
+
return {"task_id": "current-task", "workflow_id": "workflow", "run_id": "run",
|
|
23
|
+
"workflow_task_attempt": 3, "history_events": events,
|
|
24
|
+
"sticky_replay_mode": mode, "last_history_sequence": last,
|
|
25
|
+
"next_history_page_token": token}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@pytest.mark.parametrize(("capacity", "max_bytes", "ttl"), [
|
|
29
|
+
(-1, 100, 1), (True, 100, 1), (1, 0, 1), (1, True, 1),
|
|
30
|
+
(1, 100, 0), (1, 100, 3601), (1, 100, 1.5),
|
|
31
|
+
])
|
|
32
|
+
def test_cache_limits_reject_invalid_values(capacity: int, max_bytes: int, ttl: int) -> None:
|
|
33
|
+
with pytest.raises(ValueError, match="sticky_cache"):
|
|
34
|
+
StickyWorkflowCache(capacity, max_bytes, ttl)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_lru_entry_limit_and_build_run_isolation() -> None:
|
|
38
|
+
cache = StickyWorkflowCache(2, 10_000, 300)
|
|
39
|
+
a, b, c = ("wf", "run-a", "build-a"), ("wf", "run-b", "build-a"), ("wf", "run-a", "build-b")
|
|
40
|
+
assert cache.remember(a, history())
|
|
41
|
+
assert cache.remember(b, history())
|
|
42
|
+
assert cache.lookup(a) is not None # Keep A, evict B.
|
|
43
|
+
assert cache.remember(c, history())
|
|
44
|
+
assert cache.lookup(b) is None
|
|
45
|
+
assert cache.lookup(a) is not None
|
|
46
|
+
assert cache.lookup(c) is not None
|
|
47
|
+
assert cache.metrics()["eviction"] == 1
|
|
48
|
+
assert cache.metrics()["entries"] == 2
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_byte_limit_evicts_and_rejects_oversized_history() -> None:
|
|
52
|
+
sizing = StickyWorkflowCache(10, 100_000, 300)
|
|
53
|
+
assert sizing.remember(("a", "run", "build"), history(2))
|
|
54
|
+
one_size = sizing.metrics()["history_bytes"]
|
|
55
|
+
cache = StickyWorkflowCache(10, one_size * 2 - 1, 300)
|
|
56
|
+
assert cache.remember(("a", "run", "build"), history(2))
|
|
57
|
+
assert cache.remember(("b", "run", "build"), history(2))
|
|
58
|
+
assert cache.metrics()["entries"] == 1
|
|
59
|
+
assert cache.metrics()["eviction"] == 1
|
|
60
|
+
assert not cache.remember(("b", "run", "build"), history(10))
|
|
61
|
+
assert cache.metrics()["entries"] == 0
|
|
62
|
+
assert cache.metrics()["history_bytes"] == 0
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_cache_snapshot_is_independent_of_input_and_replayed_mutation() -> None:
|
|
66
|
+
cache = StickyWorkflowCache(1, 10_000, 300)
|
|
67
|
+
original = history()
|
|
68
|
+
key = ("wf", "run", "build")
|
|
69
|
+
assert cache.remember(key, original)
|
|
70
|
+
original[0]["payload"]["value"]["number"] = 999
|
|
71
|
+
first = cache.lookup(key)
|
|
72
|
+
assert first is not None
|
|
73
|
+
first[0][0]["payload"]["value"]["number"] = 888
|
|
74
|
+
second = cache.lookup(key)
|
|
75
|
+
assert second is not None
|
|
76
|
+
assert second[0] == history()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_expiry_disabled_cache_and_malformed_history_are_not_retained() -> None:
|
|
80
|
+
cache = StickyWorkflowCache(1, 10_000, 1)
|
|
81
|
+
key = ("wf", "run", "build")
|
|
82
|
+
with patch("durable_workflow._sticky_workflow_cache.time.monotonic", return_value=10):
|
|
83
|
+
assert cache.remember(key, history())
|
|
84
|
+
with patch("durable_workflow._sticky_workflow_cache.time.monotonic", return_value=11):
|
|
85
|
+
assert cache.lookup(key) is None
|
|
86
|
+
assert cache.metrics()["history_bytes"] == 0
|
|
87
|
+
assert not StickyWorkflowCache(0, 10_000, 300).remember(key, history())
|
|
88
|
+
assert not cache.remember(key, history()[1:])
|
|
89
|
+
broken = history()
|
|
90
|
+
broken[2]["sequence"] = 9
|
|
91
|
+
assert not cache.remember(key, broken)
|
|
92
|
+
broken = history()
|
|
93
|
+
broken[0]["payload"] = object()
|
|
94
|
+
assert not cache.remember(key, broken)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@pytest.mark.asyncio
|
|
98
|
+
async def test_warm_history_reuses_server_cursor_on_current_lease_and_advances_boundary() -> None:
|
|
99
|
+
client = AsyncMock(spec=Client)
|
|
100
|
+
client.workflow_task_history.side_effect = [
|
|
101
|
+
{"history_events": history()[2:4], "next_history_page_token": "server-cursor-four"},
|
|
102
|
+
{"history_events": history()[4:], "next_history_page_token": None},
|
|
103
|
+
]
|
|
104
|
+
worker = Worker(client, task_queue="q", worker_id="holder", build_id="build", sticky_cache_capacity=2)
|
|
105
|
+
first = task(history()[:2], mode="cold_replay", token="server-cursor-two")
|
|
106
|
+
loaded = await worker._load_workflow_claim_history(first)
|
|
107
|
+
claim = worker._sticky_cache_claim(first, loaded, [{"type": "start_timer"}])
|
|
108
|
+
assert claim is not None
|
|
109
|
+
assert claim["build_id"] == "build"
|
|
110
|
+
assert claim["metrics"]["miss"] == 1
|
|
111
|
+
client.workflow_task_history.reset_mock(side_effect=True)
|
|
112
|
+
client.workflow_task_history.side_effect = [
|
|
113
|
+
{"history_events": history(8)[4:6], "next_history_page_token": "server-cursor-six"},
|
|
114
|
+
{"history_events": history(8)[6:], "next_history_page_token": None},
|
|
115
|
+
]
|
|
116
|
+
current = task(history(8)[:2], last=8, token="server-cursor-two")
|
|
117
|
+
current["total_history_events"] = current.pop("last_history_sequence")
|
|
118
|
+
assert await worker._load_workflow_claim_history(current) == history(8)
|
|
119
|
+
assert [call.kwargs["next_history_page_token"] for call in client.workflow_task_history.await_args_list] == [
|
|
120
|
+
"server-cursor-four", "server-cursor-six",
|
|
121
|
+
]
|
|
122
|
+
assert all(call.kwargs["task_id"] == "current-task" and call.kwargs["lease_owner"] == "holder"
|
|
123
|
+
and call.kwargs["workflow_task_attempt"] == 3 for call in client.workflow_task_history.await_args_list)
|
|
124
|
+
worker._sticky_cache_claim(current, history(8), [{"type": "start_timer"}])
|
|
125
|
+
retained = worker._sticky_cache.lookup(("workflow", "run", "build"))
|
|
126
|
+
assert retained is not None
|
|
127
|
+
assert retained[1:] == ("server-cursor-six", 6)
|
|
128
|
+
assert worker.sticky_cache_metrics()["hit"] == 1
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@pytest.mark.asyncio
|
|
132
|
+
@pytest.mark.parametrize("change", ["prefix", "tail", "shorter", "cursor"])
|
|
133
|
+
async def test_changed_history_or_rejected_cursor_fetches_complete_authoritative_history(change: str) -> None:
|
|
134
|
+
client = AsyncMock(spec=Client)
|
|
135
|
+
worker = Worker(client, task_queue="q", build_id="build", sticky_cache_capacity=1)
|
|
136
|
+
worker._sticky_cache.remember(("workflow", "run", "build"), history(),
|
|
137
|
+
resume_token="old-cursor", resume_offset=4)
|
|
138
|
+
authoritative = history()
|
|
139
|
+
inline = authoritative[:2]
|
|
140
|
+
last = 6
|
|
141
|
+
if change == "prefix":
|
|
142
|
+
authoritative[1]["payload"] = {"changed": True}
|
|
143
|
+
elif change == "shorter":
|
|
144
|
+
authoritative = history(4)
|
|
145
|
+
inline, last = authoritative[:2], 4
|
|
146
|
+
replies: list[Any] = [{"history_events": authoritative[2:], "next_history_page_token": None}]
|
|
147
|
+
if change == "tail":
|
|
148
|
+
authoritative[5]["payload"] = {"changed": True}
|
|
149
|
+
replies.insert(0, {"history_events": authoritative[4:], "next_history_page_token": None})
|
|
150
|
+
if change == "cursor":
|
|
151
|
+
replies.insert(0, ServerError(400, {"reason": "invalid_page_token"}))
|
|
152
|
+
client.workflow_task_history.side_effect = replies
|
|
153
|
+
loaded = await worker._load_workflow_claim_history(task(inline, last=last, token="current-cursor"))
|
|
154
|
+
assert loaded == authoritative
|
|
155
|
+
assert client.workflow_task_history.await_args_list[-1].kwargs["next_history_page_token"] == "current-cursor"
|
|
156
|
+
assert worker.sticky_cache_metrics()["hit"] == 0
|
|
157
|
+
assert worker.sticky_cache_metrics()["forced_cold_replay"] == 1
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@pytest.mark.asyncio
|
|
161
|
+
@pytest.mark.parametrize("mode", ["sticky_hit_expected", "forced_cold_replay"])
|
|
162
|
+
async def test_suffix_without_matching_prefix_uses_lease_scoped_cold_fetch(mode: str) -> None:
|
|
163
|
+
client = AsyncMock(spec=Client)
|
|
164
|
+
client.workflow_task_history.return_value = {"history_events": history(), "next_history_page_token": None}
|
|
165
|
+
worker = Worker(client, task_queue="q", worker_id="replacement", sticky_cache_capacity=1)
|
|
166
|
+
assert await worker._load_workflow_claim_history(task(history()[4:], mode=mode)) == history()
|
|
167
|
+
client.workflow_task_history.assert_awaited_once_with(
|
|
168
|
+
task_id="current-task", next_history_page_token="MA==", lease_owner="replacement", workflow_task_attempt=3,
|
|
169
|
+
)
|
|
170
|
+
assert worker.sticky_cache_metrics()["forced_cold_replay"] == 1
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@pytest.mark.asyncio
|
|
174
|
+
async def test_build_mismatch_forced_replay_and_replacement_do_not_use_retained_history() -> None:
|
|
175
|
+
client = AsyncMock(spec=Client)
|
|
176
|
+
client.workflow_task_history.return_value = {"history_events": history()[2:], "next_history_page_token": None}
|
|
177
|
+
worker = Worker(client, task_queue="q", build_id="new-build", sticky_cache_capacity=1)
|
|
178
|
+
worker._sticky_cache.remember(("workflow", "run", "old-build"), history(),
|
|
179
|
+
resume_token="old-cursor", resume_offset=4)
|
|
180
|
+
assert await worker._load_workflow_claim_history(task(history()[:2], token="current-cursor")) == history()
|
|
181
|
+
assert client.workflow_task_history.await_args.kwargs["next_history_page_token"] == "current-cursor"
|
|
182
|
+
assert worker.sticky_cache_metrics()["hit"] == 0
|
|
183
|
+
await worker.stop()
|
|
184
|
+
assert worker.sticky_cache_metrics()["entries"] == 0
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@pytest.mark.asyncio
|
|
188
|
+
async def test_incomplete_cold_history_and_repeated_tokens_never_replay_partial_data() -> None:
|
|
189
|
+
client = AsyncMock(spec=Client)
|
|
190
|
+
worker = Worker(client, task_queue="q", sticky_cache_capacity=1)
|
|
191
|
+
client.workflow_task_history.return_value = {"history_events": history()[2:], "next_history_page_token": None}
|
|
192
|
+
with pytest.raises(LocalActivityExecutionAborted, match="complete canonical"):
|
|
193
|
+
await worker._load_workflow_claim_history(task(history()[4:]))
|
|
194
|
+
client.workflow_task_history.return_value = {"history_events": history()[:2], "next_history_page_token": "repeat"}
|
|
195
|
+
with pytest.raises(LocalActivityExecutionAborted, match="paging did not advance"):
|
|
196
|
+
await worker._load_workflow_claim_history(task(history()[:2], mode="cold_replay", token="repeat"))
|
|
197
|
+
client.complete_workflow_task.assert_not_awaited()
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def test_oversized_and_terminal_histories_issue_no_affinity_claim() -> None:
|
|
201
|
+
worker = Worker(AsyncMock(spec=Client), task_queue="q", build_id="build",
|
|
202
|
+
sticky_cache_capacity=2, sticky_cache_max_bytes=100)
|
|
203
|
+
assert worker._sticky_cache_claim(task(history()), history(), [{"type": "start_timer"}]) is None
|
|
204
|
+
worker = Worker(AsyncMock(spec=Client), task_queue="q", build_id="build", sticky_cache_capacity=2)
|
|
205
|
+
assert worker._sticky_cache_claim(task(history()), history(), [{"type": "start_timer"}]) is not None
|
|
206
|
+
assert worker._sticky_cache_claim(task(history()), history(), [{"type": "complete_workflow"}]) is None
|
|
207
|
+
assert worker.sticky_cache_metrics()["entries"] == 0
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
@pytest.mark.asyncio
|
|
211
|
+
async def test_completion_transport_retries_identical_claim_and_omits_it_when_disabled() -> None:
|
|
212
|
+
client = AsyncMock(spec=Client)
|
|
213
|
+
worker = Worker(client, task_queue="q", worker_id="holder", build_id="build", sticky_cache_capacity=1)
|
|
214
|
+
claim = worker._sticky_cache_claim(task(history()), history(), [{"type": "start_timer"}])
|
|
215
|
+
client.complete_workflow_task.side_effect = [ServerError(503, {}), {}]
|
|
216
|
+
with patch("durable_workflow.worker.asyncio.sleep", new_callable=AsyncMock):
|
|
217
|
+
await worker._complete_workflow_task_with_retry(
|
|
218
|
+
task_id="current-task", attempt=3, commands=[{"type": "start_timer"}], sticky_cache=claim,
|
|
219
|
+
)
|
|
220
|
+
assert client.complete_workflow_task.await_args_list[0] == client.complete_workflow_task.await_args_list[1]
|
|
221
|
+
assert client.complete_workflow_task.await_args.kwargs["sticky_cache"] == claim
|
|
222
|
+
client.complete_workflow_task.reset_mock(side_effect=True)
|
|
223
|
+
await worker._complete_workflow_task_with_retry(task_id="next-task", attempt=1, commands=[{"type": "start_timer"}])
|
|
224
|
+
assert "sticky_cache" not in client.complete_workflow_task.await_args.kwargs
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
@pytest.mark.asyncio
|
|
228
|
+
async def test_client_completion_serializes_claim_on_worker_plane() -> None:
|
|
229
|
+
async with Client("https://server.example", worker_token="test") as client:
|
|
230
|
+
with patch.object(client, "_request", new_callable=AsyncMock) as request:
|
|
231
|
+
await client.complete_workflow_task(
|
|
232
|
+
task_id="task", lease_owner="worker", workflow_task_attempt=1,
|
|
233
|
+
commands=[{"type": "start_timer"}], sticky_cache={"worker_id": "worker"},
|
|
234
|
+
)
|
|
235
|
+
assert request.await_args.kwargs["worker"] is True
|
|
236
|
+
assert request.await_args.kwargs["json"]["sticky_cache"] == {"worker_id": "worker"}
|
|
@@ -447,7 +447,7 @@ class TestWorkerRegistration:
|
|
|
447
447
|
"sticky_execution": {
|
|
448
448
|
"supported": False,
|
|
449
449
|
"minimum_protocol_version": "1.18",
|
|
450
|
-
"reason": "
|
|
450
|
+
"reason": "sticky_cache_disabled",
|
|
451
451
|
},
|
|
452
452
|
},
|
|
453
453
|
"task_slots": {
|
|
@@ -1196,6 +1196,33 @@ class TestWorkerRegistration:
|
|
|
1196
1196
|
mock_client.register_worker.assert_not_called()
|
|
1197
1197
|
|
|
1198
1198
|
|
|
1199
|
+
class TestStickyWorkerRegistration:
|
|
1200
|
+
@pytest.mark.asyncio
|
|
1201
|
+
async def test_only_enabled_worker_advertises_sticky_support(self, mock_client: AsyncMock) -> None:
|
|
1202
|
+
info = compatible_cluster_info()
|
|
1203
|
+
info["worker_protocol"]["server_capabilities"]["sticky_execution"] = {"supported": True} # type: ignore[index]
|
|
1204
|
+
mock_client.get_cluster_info.return_value = info
|
|
1205
|
+
worker = Worker(mock_client, task_queue="q", sticky_cache_capacity=2)
|
|
1206
|
+
await worker._register()
|
|
1207
|
+
registration = mock_client.register_worker.await_args.kwargs
|
|
1208
|
+
assert registration["capabilities"].count("sticky_execution") == 1
|
|
1209
|
+
assert registration["capability_manifest"]["sticky_execution"] == {
|
|
1210
|
+
"supported": True, "minimum_protocol_version": "1.18", "implementation": "bounded_durable_history_cache",
|
|
1211
|
+
}
|
|
1212
|
+
await worker.stop()
|
|
1213
|
+
|
|
1214
|
+
@pytest.mark.asyncio
|
|
1215
|
+
async def test_enabled_cache_requires_server_support(self, mock_client: AsyncMock) -> None:
|
|
1216
|
+
worker = Worker(mock_client, task_queue="q", sticky_cache_capacity=2)
|
|
1217
|
+
with pytest.raises(RuntimeError, match="advertised sticky_execution support"):
|
|
1218
|
+
await worker._register()
|
|
1219
|
+
mock_client.register_worker.assert_not_awaited()
|
|
1220
|
+
|
|
1221
|
+
def test_manual_capability_cannot_advertise_disabled_cache(self, mock_client: AsyncMock) -> None:
|
|
1222
|
+
with pytest.raises(ValueError, match="positive sticky_cache_capacity"):
|
|
1223
|
+
Worker(mock_client, task_queue="q", capabilities=["sticky_execution"])
|
|
1224
|
+
|
|
1225
|
+
|
|
1199
1226
|
class TestWorkflowTaskExecution:
|
|
1200
1227
|
@pytest.mark.asyncio
|
|
1201
1228
|
async def test_history_page_failure_does_not_replay_partial_history(
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{durable_workflow-2.4.3 → durable_workflow-2.5.0}/src/durable_workflow/_cancellation_scope.py
RENAMED
|
File without changes
|
|
File without changes
|
{durable_workflow-2.4.3 → durable_workflow-2.5.0}/src/durable_workflow/_cooperative_cancellation.py
RENAMED
|
File without changes
|
{durable_workflow-2.4.3 → durable_workflow-2.5.0}/src/durable_workflow/_prepared_local_activity.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{durable_workflow-2.4.3 → durable_workflow-2.5.0}/src/durable_workflow/external_task_input.py
RENAMED
|
File without changes
|
{durable_workflow-2.4.3 → durable_workflow-2.5.0}/src/durable_workflow/external_task_result.py
RENAMED
|
File without changes
|
{durable_workflow-2.4.3 → durable_workflow-2.5.0}/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.4.3 → durable_workflow-2.5.0}/src/durable_workflow/python_conformance.py
RENAMED
|
File without changes
|
{durable_workflow-2.4.3 → durable_workflow-2.5.0}/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.4.3 → durable_workflow-2.5.0}/src/durable_workflow.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{durable_workflow-2.4.3 → durable_workflow-2.5.0}/src/durable_workflow.egg-info/entry_points.txt
RENAMED
|
File without changes
|
{durable_workflow-2.4.3 → durable_workflow-2.5.0}/src/durable_workflow.egg-info/requires.txt
RENAMED
|
File without changes
|
{durable_workflow-2.4.3 → durable_workflow-2.5.0}/src/durable_workflow.egg-info/top_level.txt
RENAMED
|
File without changes
|
{durable_workflow-2.4.3 → durable_workflow-2.5.0}/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
|
|
File without changes
|
{durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_cancellation_scope_admission.py
RENAMED
|
File without changes
|
{durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_cancellation_scope_authoring.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.4.3 → durable_workflow-2.5.0}/tests/test_committed_cancellation_scope_groups.py
RENAMED
|
File without changes
|
{durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_committed_cancellation_scope_history.py
RENAMED
|
File without changes
|
{durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_committed_cancellation_scope_replay.py
RENAMED
|
File without changes
|
{durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_control_plane_parity_fixtures.py
RENAMED
|
File without changes
|
|
File without changes
|
{durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_cooperative_cancellation_client.py
RENAMED
|
File without changes
|
{durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_cooperative_cancellation_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
|
|
File without changes
|
|
File without changes
|
{durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_parallel_completion_order_replay.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_prepared_local_activity_client.py
RENAMED
|
File without changes
|
{durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_prepared_local_activity_groups.py
RENAMED
|
File without changes
|
{durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_prepared_local_activity_policies.py
RENAMED
|
File without changes
|
{durable_workflow-2.4.3 → durable_workflow-2.5.0}/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
|
{durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_run_cancellation_scope_replay.py
RENAMED
|
File without changes
|
{durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_runtime_external_payload_transport.py
RENAMED
|
File without changes
|
|
File without changes
|
{durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_scoped_run_cancellation_context.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.4.3 → durable_workflow-2.5.0}/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.4.3 → durable_workflow-2.5.0}/tests/test_workflow_updates_conformance.py
RENAMED
|
File without changes
|