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.
Files changed (123) hide show
  1. {durable_workflow-2.4.3/src/durable_workflow.egg-info → durable_workflow-2.5.0}/PKG-INFO +1 -1
  2. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/pyproject.toml +3 -3
  3. durable_workflow-2.5.0/src/durable_workflow/_sticky_workflow_cache.py +103 -0
  4. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/src/durable_workflow/client.py +4 -1
  5. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/src/durable_workflow/worker.py +128 -3
  6. {durable_workflow-2.4.3 → durable_workflow-2.5.0/src/durable_workflow.egg-info}/PKG-INFO +1 -1
  7. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/src/durable_workflow.egg-info/SOURCES.txt +2 -0
  8. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_replay_regression_corpus.py +21 -0
  9. durable_workflow-2.5.0/tests/test_sticky_execution.py +236 -0
  10. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_worker.py +28 -1
  11. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/LICENSE +0 -0
  12. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/README.md +0 -0
  13. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/setup.cfg +0 -0
  14. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/src/durable_workflow/__init__.py +0 -0
  15. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/src/durable_workflow/_activity_process.py +0 -0
  16. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/src/durable_workflow/_avro.py +0 -0
  17. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/src/durable_workflow/_cancellation_scope.py +0 -0
  18. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/src/durable_workflow/_cancellation_scope_history.py +0 -0
  19. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/src/durable_workflow/_cooperative_cancellation.py +0 -0
  20. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/src/durable_workflow/_prepared_local_activity.py +0 -0
  21. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/src/durable_workflow/activity.py +0 -0
  22. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/src/durable_workflow/auth_composition.py +0 -0
  23. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/src/durable_workflow/cancellation.py +0 -0
  24. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/src/durable_workflow/errors.py +0 -0
  25. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/src/durable_workflow/external_storage.py +0 -0
  26. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/src/durable_workflow/external_task_input.py +0 -0
  27. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/src/durable_workflow/external_task_result.py +0 -0
  28. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/src/durable_workflow/history_bundle_verify.py +0 -0
  29. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/src/durable_workflow/interceptors.py +0 -0
  30. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/src/durable_workflow/invocable.py +0 -0
  31. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/src/durable_workflow/metrics.py +0 -0
  32. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/src/durable_workflow/nexus.py +0 -0
  33. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/src/durable_workflow/py.typed +0 -0
  34. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/src/durable_workflow/python_conformance.py +0 -0
  35. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/src/durable_workflow/replay_conformance.py +0 -0
  36. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/src/durable_workflow/replay_verify.py +0 -0
  37. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/src/durable_workflow/retry_policy.py +0 -0
  38. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/src/durable_workflow/serializer.py +0 -0
  39. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/src/durable_workflow/sync.py +0 -0
  40. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/src/durable_workflow/testing.py +0 -0
  41. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/src/durable_workflow/worker_session.py +0 -0
  42. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/src/durable_workflow/workflow.py +0 -0
  43. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/src/durable_workflow/workflow_updates_conformance.py +0 -0
  44. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/src/durable_workflow.egg-info/dependency_links.txt +0 -0
  45. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/src/durable_workflow.egg-info/entry_points.txt +0 -0
  46. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/src/durable_workflow.egg-info/requires.txt +0 -0
  47. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/src/durable_workflow.egg-info/top_level.txt +0 -0
  48. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_activity_cancellation_policies.py +0 -0
  49. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_activity_context.py +0 -0
  50. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_activity_process.py +0 -0
  51. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_auth_composition.py +0 -0
  52. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_avro_value_benchmark.py +0 -0
  53. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_avro_value_protocol.py +0 -0
  54. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_cancellation_context.py +0 -0
  55. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_cancellation_remaining_time.py +0 -0
  56. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_cancellation_scope_admission.py +0 -0
  57. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_cancellation_scope_authoring.py +0 -0
  58. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_cancellation_scope_boundary.py +0 -0
  59. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_cancellation_scope_opening.py +0 -0
  60. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_child_workflow_policies.py +0 -0
  61. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_ci_checkout.py +0 -0
  62. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_ci_integration_endpoint.py +0 -0
  63. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_client.py +0 -0
  64. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_client_timeouts.py +0 -0
  65. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_codec_regression_corpus.py +0 -0
  66. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_committed_cancellation_scope_descendants.py +0 -0
  67. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_committed_cancellation_scope_groups.py +0 -0
  68. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_committed_cancellation_scope_history.py +0 -0
  69. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_committed_cancellation_scope_replay.py +0 -0
  70. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_control_plane_parity_fixtures.py +0 -0
  71. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_cooperative_cancellation.py +0 -0
  72. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_cooperative_cancellation_client.py +0 -0
  73. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_cooperative_cancellation_worker.py +0 -0
  74. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_cooperative_remote_worker.py +0 -0
  75. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_docstring_cross_references.py +0 -0
  76. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_durable_selection.py +0 -0
  77. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_errors.py +0 -0
  78. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_external_storage.py +0 -0
  79. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_external_task_input.py +0 -0
  80. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_external_task_result.py +0 -0
  81. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_golden_history_replay.py +0 -0
  82. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_history_bundle_verify.py +0 -0
  83. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_history_event_contract.py +0 -0
  84. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_invocable.py +0 -0
  85. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_message_streams.py +0 -0
  86. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_metrics.py +0 -0
  87. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_order_processing_example.py +0 -0
  88. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_parallel_completion_order_replay.py +0 -0
  89. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_parallel_saga_authoring.py +0 -0
  90. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_pending_operation_replay.py +0 -0
  91. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_prepared_local_activity_client.py +0 -0
  92. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_prepared_local_activity_groups.py +0 -0
  93. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_prepared_local_activity_policies.py +0 -0
  94. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_prepared_local_activity_worker.py +0 -0
  95. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_public_boundary_scanner.py +0 -0
  96. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_pypi_project_surface.py +0 -0
  97. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_python_conformance.py +0 -0
  98. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_queries.py +0 -0
  99. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_readme_quickstart.py +0 -0
  100. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_redrive_replay.py +0 -0
  101. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_release_metadata.py +0 -0
  102. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_replay.py +0 -0
  103. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_replay_conformance.py +0 -0
  104. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_replay_verify.py +0 -0
  105. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_retry_policy.py +0 -0
  106. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_run_cancellation_scope_replay.py +0 -0
  107. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_runtime_external_payload_transport.py +0 -0
  108. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_schedules.py +0 -0
  109. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_scoped_run_cancellation_context.py +0 -0
  110. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_serializer.py +0 -0
  111. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_signals.py +0 -0
  112. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_sleep.py +0 -0
  113. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_standalone_activity_client.py +0 -0
  114. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_storage_admission.py +0 -0
  115. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_sync.py +0 -0
  116. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_testing_harness.py +0 -0
  117. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_update_signal_condition_replay.py +0 -0
  118. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_updates.py +0 -0
  119. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_wait_condition.py +0 -0
  120. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_worker_session.py +0 -0
  121. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_workflow_result_timeout.py +0 -0
  122. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_workflow_streams.py +0 -0
  123. {durable_workflow-2.4.3 → durable_workflow-2.5.0}/tests/test_workflow_updates_conformance.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: durable-workflow
3
- Version: 2.4.3
3
+ Version: 2.5.0
4
4
  Summary: Python client and worker SDK for Durable Workflow Cloud and self-hosted Server
5
5
  Author: Durable Workflow Contributors
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "durable-workflow"
7
- version = "2.4.3"
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.4.3"
75
- registry-version = "2.4.3"
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": "python_worker_uses_complete_durable_history_replay",
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
- **PORTABLE_WORKER_AFFINITY_CAPABILITY_MANIFEST,
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
- history = [] if first_page_token is not None else list(task.get("history_events", []))
1518
- token = first_page_token if first_page_token is not None else task.get("next_history_page_token")
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:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: durable-workflow
3
- Version: 2.4.3
3
+ Version: 2.5.0
4
4
  Summary: Python client and worker SDK for Durable Workflow Cloud and self-hosted Server
5
5
  Author: Durable Workflow Contributors
6
6
  License-Expression: MIT
@@ -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": "python_worker_uses_complete_durable_history_replay",
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(