durable-workflow 2.4.3__tar.gz → 2.5.1__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.1}/PKG-INFO +1 -1
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/pyproject.toml +4 -4
- durable_workflow-2.5.1/src/durable_workflow/_sticky_workflow_cache.py +103 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/src/durable_workflow/client.py +4 -1
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/src/durable_workflow/worker.py +128 -3
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/src/durable_workflow/workflow.py +94 -12
- {durable_workflow-2.4.3 → durable_workflow-2.5.1/src/durable_workflow.egg-info}/PKG-INFO +1 -1
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/src/durable_workflow.egg-info/SOURCES.txt +3 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/tests/test_cooperative_remote_worker.py +10 -3
- durable_workflow-2.5.1/tests/test_patch_history.py +145 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/tests/test_replay_regression_corpus.py +25 -0
- durable_workflow-2.5.1/tests/test_sticky_execution.py +236 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/tests/test_worker.py +28 -1
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/LICENSE +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/README.md +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/setup.cfg +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/src/durable_workflow/__init__.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/src/durable_workflow/_activity_process.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/src/durable_workflow/_avro.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/src/durable_workflow/_cancellation_scope.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/src/durable_workflow/_cancellation_scope_history.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/src/durable_workflow/_cooperative_cancellation.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/src/durable_workflow/_prepared_local_activity.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/src/durable_workflow/activity.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/src/durable_workflow/auth_composition.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/src/durable_workflow/cancellation.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/src/durable_workflow/errors.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/src/durable_workflow/external_storage.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/src/durable_workflow/external_task_input.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/src/durable_workflow/external_task_result.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/src/durable_workflow/history_bundle_verify.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/src/durable_workflow/interceptors.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/src/durable_workflow/invocable.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/src/durable_workflow/metrics.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/src/durable_workflow/nexus.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/src/durable_workflow/py.typed +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/src/durable_workflow/python_conformance.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/src/durable_workflow/replay_conformance.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/src/durable_workflow/replay_verify.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/src/durable_workflow/retry_policy.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/src/durable_workflow/serializer.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/src/durable_workflow/sync.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/src/durable_workflow/testing.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/src/durable_workflow/worker_session.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/src/durable_workflow/workflow_updates_conformance.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/src/durable_workflow.egg-info/dependency_links.txt +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/src/durable_workflow.egg-info/entry_points.txt +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/src/durable_workflow.egg-info/requires.txt +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/src/durable_workflow.egg-info/top_level.txt +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/tests/test_activity_cancellation_policies.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/tests/test_activity_context.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/tests/test_activity_process.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/tests/test_auth_composition.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/tests/test_avro_value_benchmark.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/tests/test_avro_value_protocol.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/tests/test_cancellation_context.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/tests/test_cancellation_remaining_time.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/tests/test_cancellation_scope_admission.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/tests/test_cancellation_scope_authoring.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/tests/test_cancellation_scope_boundary.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/tests/test_cancellation_scope_opening.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/tests/test_child_workflow_policies.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/tests/test_ci_checkout.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/tests/test_ci_integration_endpoint.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/tests/test_client.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/tests/test_client_timeouts.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/tests/test_codec_regression_corpus.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/tests/test_committed_cancellation_scope_descendants.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/tests/test_committed_cancellation_scope_groups.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/tests/test_committed_cancellation_scope_history.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/tests/test_committed_cancellation_scope_replay.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/tests/test_control_plane_parity_fixtures.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/tests/test_cooperative_cancellation.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/tests/test_cooperative_cancellation_client.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/tests/test_cooperative_cancellation_worker.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/tests/test_docstring_cross_references.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/tests/test_durable_selection.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/tests/test_errors.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/tests/test_external_storage.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/tests/test_external_task_input.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/tests/test_external_task_result.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/tests/test_golden_history_replay.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/tests/test_history_bundle_verify.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/tests/test_history_event_contract.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/tests/test_invocable.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/tests/test_message_streams.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/tests/test_metrics.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/tests/test_order_processing_example.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/tests/test_parallel_completion_order_replay.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/tests/test_parallel_saga_authoring.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/tests/test_pending_operation_replay.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/tests/test_prepared_local_activity_client.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/tests/test_prepared_local_activity_groups.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/tests/test_prepared_local_activity_policies.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/tests/test_prepared_local_activity_worker.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/tests/test_public_boundary_scanner.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/tests/test_pypi_project_surface.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/tests/test_python_conformance.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/tests/test_queries.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/tests/test_readme_quickstart.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/tests/test_redrive_replay.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/tests/test_release_metadata.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/tests/test_replay.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/tests/test_replay_conformance.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/tests/test_replay_verify.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/tests/test_retry_policy.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/tests/test_run_cancellation_scope_replay.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/tests/test_runtime_external_payload_transport.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/tests/test_schedules.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/tests/test_scoped_run_cancellation_context.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/tests/test_serializer.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/tests/test_signals.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/tests/test_sleep.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/tests/test_standalone_activity_client.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/tests/test_storage_admission.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/tests/test_sync.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/tests/test_testing_harness.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/tests/test_update_signal_condition_replay.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/tests/test_updates.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/tests/test_wait_condition.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/tests/test_worker_session.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/tests/test_workflow_result_timeout.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/tests/test_workflow_streams.py +0 -0
- {durable_workflow-2.4.3 → durable_workflow-2.5.1}/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.1"
|
|
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,9 +71,9 @@ 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.
|
|
76
|
-
supported-server-versions = "2.5.
|
|
74
|
+
product-train = "2.5.1"
|
|
75
|
+
registry-version = "2.5.1"
|
|
76
|
+
supported-server-versions = "2.5.16"
|
|
77
77
|
worker-protocol-version = "1.19"
|
|
78
78
|
control-plane-version = "2"
|
|
79
79
|
durable-selection = true
|
|
@@ -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:
|
|
@@ -5185,6 +5185,96 @@ def _replay_state(
|
|
|
5185
5185
|
pending: list[Command] = []
|
|
5186
5186
|
advanced_cmd: Any = None
|
|
5187
5187
|
terminal_condition_reopen_cmd: WaitCondition | None = None
|
|
5188
|
+
version_decisions: dict[str, tuple[int, str]] = {}
|
|
5189
|
+
|
|
5190
|
+
def _version_error(detail: str, step: _RecordedStep | None = None) -> NonDeterministicReplayError:
|
|
5191
|
+
return NonDeterministicReplayError(
|
|
5192
|
+
step.workflow_sequence if step is not None else authored_sequence,
|
|
5193
|
+
"compatible version marker",
|
|
5194
|
+
step.event_types if step is not None else [],
|
|
5195
|
+
detail=detail,
|
|
5196
|
+
)
|
|
5197
|
+
|
|
5198
|
+
def _recorded_version(step: _RecordedStep) -> int:
|
|
5199
|
+
if step.history_index is None:
|
|
5200
|
+
raise _version_error("Version marker is missing its history position.", step)
|
|
5201
|
+
payload = events[step.history_index].get("payload") or {}
|
|
5202
|
+
version = payload.get("version")
|
|
5203
|
+
if type(version) is not int:
|
|
5204
|
+
raise _version_error("Version marker must contain an integer version.", step)
|
|
5205
|
+
change_id = payload.get("change_id")
|
|
5206
|
+
if "change_id" in payload and (not isinstance(change_id, str) or not change_id.strip()):
|
|
5207
|
+
raise _version_error("Version marker must retain a non-empty change ID.", step)
|
|
5208
|
+
if "min_supported" in payload or "max_supported" in payload:
|
|
5209
|
+
minimum, maximum = payload.get("min_supported"), payload.get("max_supported")
|
|
5210
|
+
if (type(minimum) is not int or type(maximum) is not int
|
|
5211
|
+
or not minimum <= version <= maximum):
|
|
5212
|
+
raise _version_error("Recorded version marker has an invalid supported range.", step)
|
|
5213
|
+
return version
|
|
5214
|
+
|
|
5215
|
+
def _consume_duplicate_version_markers() -> None:
|
|
5216
|
+
nonlocal result_cursor, authored_sequence
|
|
5217
|
+
if not version_decisions:
|
|
5218
|
+
return
|
|
5219
|
+
while result_cursor < len(recorded_steps):
|
|
5220
|
+
step = recorded_steps[result_cursor]
|
|
5221
|
+
change_id = step.details.get("change_id")
|
|
5222
|
+
if step.shape != "version marker" or change_id not in version_decisions:
|
|
5223
|
+
return
|
|
5224
|
+
if _next_unconsumed_recorded_step() is not step:
|
|
5225
|
+
return
|
|
5226
|
+
version = _recorded_version(step)
|
|
5227
|
+
if version != version_decisions[change_id][0]:
|
|
5228
|
+
raise _version_error(f"Conflicting recorded decisions for change ID {change_id!r}.", step)
|
|
5229
|
+
# Earlier SDKs emitted a marker for every invocation. Consume these
|
|
5230
|
+
# aliases while retaining their physical positions for cancellation
|
|
5231
|
+
# and prepared operations. The stored history itself is unchanged.
|
|
5232
|
+
result_cursor += 1
|
|
5233
|
+
authored_sequence = max(authored_sequence, step.workflow_sequence + 1)
|
|
5234
|
+
|
|
5235
|
+
def _resolve_version(command: RecordVersionMarker) -> Any:
|
|
5236
|
+
nonlocal result_cursor, authored_sequence
|
|
5237
|
+
change_id = command.change_id
|
|
5238
|
+
if not isinstance(change_id, str) or not change_id.strip():
|
|
5239
|
+
raise _version_error("Version markers require a stable non-empty change ID.")
|
|
5240
|
+
if (type(command.min_supported) is not int or type(command.max_supported) is not int
|
|
5241
|
+
or command.min_supported > command.max_supported):
|
|
5242
|
+
raise _version_error(f"Invalid supported range for change ID {change_id!r}.")
|
|
5243
|
+
family = "version" if command.result_kind == "version" else "patch"
|
|
5244
|
+
decision = version_decisions.get(change_id)
|
|
5245
|
+
step = _next_unconsumed_recorded_step()
|
|
5246
|
+
if decision is not None:
|
|
5247
|
+
version, original_family = decision
|
|
5248
|
+
if original_family != family:
|
|
5249
|
+
raise _version_error(f"Change ID {change_id!r} cannot switch between version and patch helpers.", step)
|
|
5250
|
+
elif step is not None and step.shape == "version marker":
|
|
5251
|
+
_assert_step_matches(command, step)
|
|
5252
|
+
version = _recorded_version(step)
|
|
5253
|
+
result_cursor += 1
|
|
5254
|
+
authored_sequence = max(authored_sequence, step.workflow_sequence + 1)
|
|
5255
|
+
elif step is not None or (cancellation.delivery is not None and not cancellation_consumed) or any(
|
|
5256
|
+
sequence not in consumed_scope_deliveries for sequence in committed_scopes.deliveries
|
|
5257
|
+
) or any(_history_event_type(event) in {
|
|
5258
|
+
"WorkflowCompleted", "WorkflowFailed", "WorkflowCancelled", "WorkflowTerminated",
|
|
5259
|
+
"WorkflowContinuedAsNew", "WorkflowTimedOut",
|
|
5260
|
+
} for event in events):
|
|
5261
|
+
# An unmarked recorded command proves this history passed the new
|
|
5262
|
+
# patch site. Leave that command available for its original call.
|
|
5263
|
+
version = -1
|
|
5264
|
+
else:
|
|
5265
|
+
version = command.version
|
|
5266
|
+
if type(version) is not int:
|
|
5267
|
+
raise _version_error(f"Invalid version for change ID {change_id!r}.")
|
|
5268
|
+
ctx.logger._set_replaying(False)
|
|
5269
|
+
pending.append(command)
|
|
5270
|
+
authored_sequence += 1
|
|
5271
|
+
if not command.min_supported <= version <= command.max_supported:
|
|
5272
|
+
raise _version_error(
|
|
5273
|
+
f"Recorded version {version} for change ID {change_id!r} is outside "
|
|
5274
|
+
f"the supported range {command.min_supported}..{command.max_supported}.", step,
|
|
5275
|
+
)
|
|
5276
|
+
version_decisions.setdefault(change_id, (version, family))
|
|
5277
|
+
return _version_marker_result(command, version)
|
|
5188
5278
|
|
|
5189
5279
|
def _parallel_leaf_kind(command: Any) -> str:
|
|
5190
5280
|
if isinstance(command, ScheduleActivity | RecordLocalActivity):
|
|
@@ -6064,6 +6154,7 @@ def _replay_state(
|
|
|
6064
6154
|
replay_token = _ACTIVE_WORKFLOW_REPLAY.set(ctx)
|
|
6065
6155
|
try:
|
|
6066
6156
|
while True:
|
|
6157
|
+
_consume_duplicate_version_markers()
|
|
6067
6158
|
# Cursor-0 receivers are start-boundary events. Enter run() once
|
|
6068
6159
|
# before applying them so workflow initialization observes the
|
|
6069
6160
|
# same WorkflowStarted-before-Signal/Update ordering the server
|
|
@@ -6081,6 +6172,9 @@ def _replay_state(
|
|
|
6081
6172
|
first = False
|
|
6082
6173
|
_apply_due_receivers()
|
|
6083
6174
|
_validate_local_activity_policies(cmd)
|
|
6175
|
+
if isinstance(cmd, RecordVersionMarker):
|
|
6176
|
+
next_value = _resolve_version(cmd)
|
|
6177
|
+
continue
|
|
6084
6178
|
if prepare_local_activities and isinstance(cmd, list | SelectGroup) and _contains_local_activity(cmd) and (
|
|
6085
6179
|
not prepare_local_activity_groups or isinstance(cmd, SelectGroup)
|
|
6086
6180
|
):
|
|
@@ -6497,18 +6591,6 @@ def _replay_state(
|
|
|
6497
6591
|
pending.append(cmd)
|
|
6498
6592
|
next_value = None
|
|
6499
6593
|
continue
|
|
6500
|
-
if isinstance(cmd, RecordVersionMarker):
|
|
6501
|
-
if result_cursor < len(resolved_results):
|
|
6502
|
-
_assert_next_step_matches(cmd)
|
|
6503
|
-
val = resolved_results[result_cursor]
|
|
6504
|
-
result_cursor += 1
|
|
6505
|
-
next_value = _version_marker_result(cmd, val)
|
|
6506
|
-
continue
|
|
6507
|
-
ctx.logger._set_replaying(False)
|
|
6508
|
-
_assert_pending_step_matches(cmd)
|
|
6509
|
-
pending.append(cmd)
|
|
6510
|
-
next_value = _version_marker_result(cmd, cmd.version)
|
|
6511
|
-
continue
|
|
6512
6594
|
if isinstance(cmd, WaitCondition):
|
|
6513
6595
|
# A condition that is already true is not persisted. Apply
|
|
6514
6596
|
# receivers at the yield boundary before deciding whether the
|
|
@@ -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
|
|
@@ -81,6 +82,7 @@ tests/test_metrics.py
|
|
|
81
82
|
tests/test_order_processing_example.py
|
|
82
83
|
tests/test_parallel_completion_order_replay.py
|
|
83
84
|
tests/test_parallel_saga_authoring.py
|
|
85
|
+
tests/test_patch_history.py
|
|
84
86
|
tests/test_pending_operation_replay.py
|
|
85
87
|
tests/test_prepared_local_activity_client.py
|
|
86
88
|
tests/test_prepared_local_activity_groups.py
|
|
@@ -106,6 +108,7 @@ tests/test_serializer.py
|
|
|
106
108
|
tests/test_signals.py
|
|
107
109
|
tests/test_sleep.py
|
|
108
110
|
tests/test_standalone_activity_client.py
|
|
111
|
+
tests/test_sticky_execution.py
|
|
109
112
|
tests/test_storage_admission.py
|
|
110
113
|
tests/test_sync.py
|
|
111
114
|
tests/test_testing_harness.py
|
|
@@ -47,22 +47,29 @@ def stop_receipt() -> dict[str, Any]:
|
|
|
47
47
|
"heartbeat_recorded": False, "history_event_id": "receipt-event"}
|
|
48
48
|
|
|
49
49
|
|
|
50
|
+
def write_pid_marker(marker: str) -> None:
|
|
51
|
+
target = Path(marker)
|
|
52
|
+
pending = target.with_name(target.name + ".pending")
|
|
53
|
+
pending.write_text(str(os.getpid()))
|
|
54
|
+
pending.replace(target)
|
|
55
|
+
|
|
56
|
+
|
|
50
57
|
async def blocked_async(marker: str, heartbeat: bool = False) -> None:
|
|
51
58
|
if heartbeat:
|
|
52
59
|
await activity.context().heartbeat({"authored": True})
|
|
53
|
-
|
|
60
|
+
write_pid_marker(marker)
|
|
54
61
|
await asyncio.Event().wait()
|
|
55
62
|
|
|
56
63
|
|
|
57
64
|
def blocked_sync(marker: str, heartbeat: bool = False) -> None:
|
|
58
65
|
if heartbeat:
|
|
59
66
|
asyncio.run(activity.context().heartbeat({"authored": True}))
|
|
60
|
-
|
|
67
|
+
write_pid_marker(marker)
|
|
61
68
|
time.sleep(60)
|
|
62
69
|
|
|
63
70
|
|
|
64
71
|
def released_sync(marker: str) -> bytes:
|
|
65
|
-
|
|
72
|
+
write_pid_marker(marker)
|
|
66
73
|
while not Path(marker + ".release").exists():
|
|
67
74
|
time.sleep(0.02)
|
|
68
75
|
return b"late result"
|