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